From 445547c98932c36f6d98b1ab2e1548c0b01e7320 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 25 Aug 2026 07:35:39 -0700 Subject: [PATCH] feat(duplex): run the Daytona sandbox callback bridge over Node HTTP/2 (#12120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox providers carry agent work through controlled execution channels > - The Daytona callback bridge uses a bespoke line-framed protocol over its duplex channel > - The bespoke protocol adds framing work and does not use the Node transport that already supports multiplexed streams > - This pull request carries raw bytes across the channel, adds a Node HTTP/2 bridge, and selects it for Daytona > - The benefit is one authenticated, multiplexed callback session with queue_v1 as the bounded fallback ## Linked Issues or Issue Description **Subsystem affected** The packages/plugins Daytona provider and the shared duplex execution path. **Problem or motivation** The Daytona callback bridge uses a bespoke line-framed protocol over the provider duplex channel. This adds protocol work and limits stream handling. **Proposed solution** Carry raw bytes through the cross-layer channel. Add an authenticated Node HTTP/2 host server and sandbox client gateway. Select http2_v1 for Daytona and retain queue_v1 as the fallback. **Alternatives considered** Keep the current duplex_v1 protocol. This keeps the bespoke framing path and does not provide one HTTP/2 session for callback streams. **Roadmap alignment** ROADMAP.md lists Daytona under cloud and sandbox agents. This change improves the shipped Daytona provider path. **Additional context** The branch adds no dependency. Node 24 provides the http2 module. The host token check and canonical path parser remain the single dispatch path. ## What Changed - Carry raw Uint8Array chunks through the adapter, plugin, worker, runtime, and Daytona layers. - Encode bytes as base64 only across the JSON-RPC hop, because JSON has no binary type. - Add the bounded host HTTP/2 server and the in-sandbox HTTP/2 client gateway. - Authenticate every stream with the per-run bridge token before route work. - Parse the path once and reuse the canonical result for route and forwarding work. - Select http2_v1 for Daytona and fall back once to queue_v1 when the client preface is absent. - Add transport, session, stream, and fallback telemetry. - Mark HTTP/2 as the preferred transport and queue_v1 as the soft-deprecated fallback. ## Verification - `npx vitest run packages/adapter-utils/src` — 990 passed and 4 skipped. - `npx vitest run server/src/__tests__/plugin-worker-manager-duplex.test.ts` — 32 passed. - `npx vitest run --config packages/plugins/sandbox-providers/daytona/vitest.config.ts` — 220 passed and 6 skipped. - `npx tsc --noEmit` in `packages/adapter-utils`, `packages/shared`, `packages/plugins/sdk`, and `server` — clean. - No `package.json` or `pnpm-lock.yaml` file changed. - The live Daytona test skips when `DAYTONA_API_KEY` is absent. - The root `npx tsc --noEmit` command has a pre-existing missing `packages/adapters/droid-local` reference on this branch and on `master`. ## Risks - The transport change affects several duplex layers and could expose byte-boundary errors. - A missing HTTP/2 client preface falls back once to queue_v1 and records `preface_missing`. - The host token check and canonical path parser must remain on the shared dispatch path. - The live Daytona test needs `DAYTONA_API_KEY` and does not run in this agent sandbox. ## Model Used OpenAI GPT-5, tool-enabled coding agent with repository inspection, GitHub CLI, and shell execution. ## 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 --- cli/src/__tests__/worktree.test.ts | 8 +- doc/observability.md | 4 +- .../src/command-managed-runtime.test.ts | 41 + .../src/command-managed-runtime.ts | 8 +- .../src/duplex-aggregate-byte-ledger.test.ts | 2 + .../src/duplex-aggregate-byte-ledger.ts | 13 + .../duplex-bridge-broker-byte-ledger.test.ts | 6 +- .../src/duplex-bridge-broker.test.ts | 6 +- .../adapter-utils/src/duplex-bridge-broker.ts | 11 +- .../src/duplex-bridge-local-e2e.test.ts | 27 +- .../adapter-utils/src/duplex-frame-codec.ts | 7 + .../src/duplex-observability.test.ts | 70 + .../adapter-utils/src/duplex-observability.ts | 91 +- .../src/execution-target-sandbox.test.ts | 1808 +++++++++++++---- .../adapter-utils/src/execution-target.ts | 938 +++++++-- .../src/http2-bridge-server.test.ts | 1086 ++++++++++ .../adapter-utils/src/http2-bridge-server.ts | 969 +++++++++ .../src/sandbox-callback-bridge.test.ts | 75 + .../src/sandbox-callback-bridge.ts | 457 ++++- .../src/workspace-restore-merge.ts | 25 +- .../src/duplex-command-stream.live.test.ts | 130 +- .../daytona/src/duplex-command-stream.test.ts | 61 +- .../daytona/src/duplex-command-stream.ts | 35 +- .../sandbox-providers/daytona/src/manifest.ts | 4 + .../daytona/src/plugin.test.ts | 14 +- .../sandbox-providers/daytona/src/plugin.ts | 12 +- packages/plugins/sdk/src/define-plugin.ts | 2 + packages/plugins/sdk/src/index.ts | 2 + .../sdk/src/protocol.duplex-channel.test.ts | 54 + packages/plugins/sdk/src/protocol.ts | 52 +- packages/plugins/sdk/src/testing.ts | 2 +- packages/plugins/sdk/src/types.ts | 4 +- packages/plugins/sdk/src/worker-rpc-host.ts | 15 +- packages/shared/src/types/plugin.ts | 2 + ...nvironment-execution-target-duplex.test.ts | 5 +- .../fixtures/plugin-worker-duplex-channel.cjs | 34 +- ...-worker-manager-duplex-byte-ledger.test.ts | 10 +- ...anager-duplex-pending-write-ledger.test.ts | 13 +- ...-manager-duplex-stdin-write-ledger.test.ts | 35 +- .../plugin-worker-manager-duplex.test.ts | 123 +- .../services/environment-execution-target.ts | 1 + server/src/services/environment-runtime.ts | 6 +- server/src/services/plugin-worker-manager.ts | 103 +- 43 files changed, 5631 insertions(+), 740 deletions(-) create mode 100644 packages/adapter-utils/src/duplex-observability.test.ts create mode 100644 packages/adapter-utils/src/http2-bridge-server.test.ts create mode 100644 packages/adapter-utils/src/http2-bridge-server.ts diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 3e5d3444d4..42112836f2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -1610,7 +1610,13 @@ describe("worktree helpers", () => { fs.rmSync(tempRoot, { recursive: true, force: true }); } }, - 30_000, + // This test starts three separate embedded Postgres lifecycles (the + // source database, the worktree init's internal target database, and a + // third instance opened here to verify the seeded rows), so it needs + // more headroom than the other embedded-Postgres tests in this file. + // It normally finishes in well under 10s; the 60s budget absorbs CI + // runner contention without masking a real hang. + 60_000, ); itEmbeddedPostgres( diff --git a/doc/observability.md b/doc/observability.md index 5b86a4e9f7..02ddef5f4b 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -419,9 +419,9 @@ key never reaches a sink by accident. | Key | Type | Optional | Value set | | --- | --- | --- | --- | | `provider` | string | no | `daytona`, or `other` for any other plugin key. | -| `transport` | string | no | `duplex` or `file`. A fallback record uses `file`; every other record uses `duplex`. | +| `transport` | string | no | `duplex`, `http2`, or `file`. `duplex` names the retired bespoke frame protocol; `http2` names the Node HTTP/2 session over the sandbox channel; a fallback record uses `file`. | | `outcome` | string | yes | `ok` or `error`. | -| `fallback_reason` | string | yes | `gate_off`, `capability_absent`, `open_failed`, `ready_invalid`, `ready_nonce_mismatch`, `ready_timeout`, or `contaminated`. It rides only a fallback record. | +| `fallback_reason` | string | yes | `gate_off`, `capability_absent`, `route_busy`, `entrypoint_sync_failed`, `broker_construction_failed`, `channel_open_failed`, `ready_invalid`, `ready_nonce_mismatch`, `ready_timeout`, `contaminated`, `aggregate_bytes_exceeded`, or `preface_missing`. It rides only a fallback record. `route_busy` marks the process-scoped route ceiling full. `entrypoint_sync_failed` and `broker_construction_failed` mark the named build step. `channel_open_failed` marks a failed channel open. `aggregate_bytes_exceeded` marks a readiness handshake, or an `http2` post-preface pre-bind buffer, where the host fell back because the process aggregate byte ceiling had no room. `preface_missing` marks a missing or an invalid HTTP/2 client connection preface inside the bounded readiness buffer: the host found no valid preface after the accepted READY line, aborted the `http2` open, and moved the run to the file bridge (`queue_v1`) one time. | | `loss_class` | string | yes | `pre_dispatch` or `post_dispatch`, relative to the first request dispatch. It rides only a loss record. | | `loss_reason` | string | yes | `stdin_eof`, `provider_exit`, `heartbeat_timeout`, `rpc_failure`, `write_error`, `transport_closed`, or `other`. The host maps every loss cause to one of these values, so no raw provider text reaches a sink. `write_error` marks a rejected host-to-sandbox write. `transport_closed` marks a reason-less provider transport close with no exit data. It rides only a loss record. | diff --git a/packages/adapter-utils/src/command-managed-runtime.test.ts b/packages/adapter-utils/src/command-managed-runtime.test.ts index 2666d20ae7..cf24045367 100644 --- a/packages/adapter-utils/src/command-managed-runtime.test.ts +++ b/packages/adapter-utils/src/command-managed-runtime.test.ts @@ -8,11 +8,34 @@ import { afterEach, describe, expect, it } from "vitest"; import { createCommandManagedRuntimeClient, prepareCommandManagedRuntime, + type CommandManagedDuplexChannel, type CommandManagedRuntimeRunner, } from "./command-managed-runtime.js"; import type { SandboxSyncOperation } from "./sandbox-managed-runtime.js"; import type { RunProcessResult } from "./server-utils.js"; +/** + * An in-memory fake `CommandManagedDuplexChannel`. It holds no real process; a + * write echoes straight to the one registered data listener, so a test proves + * the channel contract carries a `Uint8Array` chunk with no string coercion in + * between. The fake never exits on its own; a test calls the listener it + * registers with `onExit` only when it needs one. + */ +function createFakeEchoDuplexChannel(): CommandManagedDuplexChannel { + let dataListener: ((chunk: Uint8Array) => void) | null = null; + return { + write(data: Uint8Array): void { + dataListener?.(data); + }, + onData(listener: (chunk: Uint8Array) => void): void { + dataListener = listener; + }, + onExit(): void {}, + stop(): void {}, + close: async (): Promise => {}, + }; +} + const execFile = promisify(execFileCallback); interface SpawnRunnerHandle { @@ -1057,4 +1080,22 @@ describe("command managed runtime", () => { /stdout: tar: workspace-download\.tar: Cannot open: Permission denied/, ); }); + + it("test_channel_round_trips_all_byte_values", () => { + const channel = createFakeEchoDuplexChannel(); + const allByteValues = Uint8Array.from({ length: 256 }, (_, value) => value); + const received: Uint8Array[] = []; + channel.onData((chunk) => { + received.push(chunk); + }); + + channel.write(allByteValues); + + expect(received).toHaveLength(1); + // A byte value of zero must survive. A UTF-8 string channel loses it: a + // JavaScript string can hold the code point U+0000, but a C-style consumer + // downstream of a string channel often treats it as a terminator. + expect(received[0]).toEqual(allByteValues); + expect(Array.from(received[0] ?? [])).toEqual(Array.from({ length: 256 }, (_, value) => value)); + }); }); diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index 1121d8365d..b707529f4d 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -37,9 +37,9 @@ export interface DuplexChannelOpenInput { */ export interface CommandManagedDuplexChannel { /** Writes raw input bytes to the channel. */ - write(data: string): void; - /** Registers the one data listener. The channel streams each raw chunk in order. */ - onData(listener: (chunk: string) => void): void; + write(data: Uint8Array): void; + /** Registers the one data listener. The channel streams each raw byte chunk in order. */ + onData(listener: (chunk: Uint8Array) => void): void; /** * Registers the one exit listener. The channel calls it one time with the exit. * A numeric `exitCode` is a real process exit. `transportClosed` is true when the @@ -117,6 +117,8 @@ export interface CommandManagedRuntimeRunner { * bidirectional channel to a long-lived command in the sandbox. The SSH runner * and every provider without the capability omit the member, so a caller gates * on its presence in the same style as {@link syncIn}/{@link syncOut}. + * + * HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. */ openDuplexChannel?(input: DuplexChannelOpenInput): Promise; } diff --git a/packages/adapter-utils/src/duplex-aggregate-byte-ledger.test.ts b/packages/adapter-utils/src/duplex-aggregate-byte-ledger.test.ts index 790100509e..6711bf625b 100644 --- a/packages/adapter-utils/src/duplex-aggregate-byte-ledger.test.ts +++ b/packages/adapter-utils/src/duplex-aggregate-byte-ledger.test.ts @@ -163,6 +163,8 @@ describe("DuplexAggregateByteLedger", () => { "decoder_buffer", "readiness_buffer", "readiness_replay", + "http2_preface_scan", + "http2_preface_replay", "pending_write", "stdin_write", ]); diff --git a/packages/adapter-utils/src/duplex-aggregate-byte-ledger.ts b/packages/adapter-utils/src/duplex-aggregate-byte-ledger.ts index b544dee9f1..9924659ea8 100644 --- a/packages/adapter-utils/src/duplex-aggregate-byte-ledger.ts +++ b/packages/adapter-utils/src/duplex-aggregate-byte-ledger.ts @@ -80,6 +80,17 @@ export const MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX = 2 * 1024 * 1024 * 1024; * frame decoder charges its own retention. This is a separate retention from * `readiness_buffer`: the gate drops the whole pre-READY buffer on READY, then * charges only the retained suffix and each later pre-bind chunk under this owner. + * - `http2_preface_scan`: the raw untrusted bytes the `http2_v1` preface scan + * retains while it searches for the client connection preface, from the + * readiness-replay handoff until the preface match or the scan cap. This + * is a separate retention from `readiness_replay`: it starts only after + * the readiness gate hands its own retained suffix to the preface scan. + * - `http2_preface_replay`: the bytes the `http2_v1` preface scan holds after the + * client connection preface, from the preface match until the HTTP/2 server + * binds its downstream listener. This is a separate retention from + * `http2_preface_scan`: the scan drops its own buffer on the preface match, + * then charges only the retained suffix and each later pre-bind chunk under + * this owner. * - `pending_write`: the raw host-to-worker write payload a pending duplex write * RPC retains, from the enqueue seam until the RPC settles. * - `stdin_write`: the serialized host-to-worker frame the child-stdin transport @@ -99,6 +110,8 @@ export const DUPLEX_AGGREGATE_TOKEN_OWNERS = [ "decoder_buffer", "readiness_buffer", "readiness_replay", + "http2_preface_scan", + "http2_preface_replay", "pending_write", "stdin_write", ] as const; diff --git a/packages/adapter-utils/src/duplex-bridge-broker-byte-ledger.test.ts b/packages/adapter-utils/src/duplex-bridge-broker-byte-ledger.test.ts index ff901dd1d9..c82446343c 100644 --- a/packages/adapter-utils/src/duplex-bridge-broker-byte-ledger.test.ts +++ b/packages/adapter-utils/src/duplex-bridge-broker-byte-ledger.test.ts @@ -85,7 +85,7 @@ function createFakeChannelHarness(): FakeChannelHarness { string, { frame: DuplexResponseFrame; received: number; chunks: Buffer[] } >(); - let dataListener: ((chunk: string) => void) | null = null; + let dataListener: ((chunk: Uint8Array) => void) | null = null; let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; const channel: CommandManagedDuplexChannel = { @@ -129,14 +129,14 @@ function createFakeChannelHarness(): FakeChannelHarness { channel, feed: ({ frame, bodyText }) => { if (!dataListener) throw new Error("The broker did not bind the data listener."); - dataListener(encodeDuplexFrame(frame)); + dataListener(Buffer.from(encodeDuplexFrame(frame), "utf8")); if (bodyText.length > 0) { for (const chunk of splitBodyIntoChunkFrames( frame.id, Buffer.from(bodyText, "utf8"), DUPLEX_FRAME_VERSION, )) { - dataListener(encodeDuplexFrame(chunk)); + dataListener(Buffer.from(encodeDuplexFrame(chunk), "utf8")); } } }, diff --git a/packages/adapter-utils/src/duplex-bridge-broker.test.ts b/packages/adapter-utils/src/duplex-bridge-broker.test.ts index 57cb89d1bb..cc7f5ce3de 100644 --- a/packages/adapter-utils/src/duplex-bridge-broker.test.ts +++ b/packages/adapter-utils/src/duplex-bridge-broker.test.ts @@ -111,7 +111,7 @@ function createFakeChannelHarness(): FakeChannelHarness { string, { frame: DuplexResponseFrame; received: number; chunks: Buffer[] } >(); - let dataListener: ((chunk: string) => void) | null = null; + let dataListener: ((chunk: Uint8Array) => void) | null = null; const channel: CommandManagedDuplexChannel = { write: (data) => { @@ -152,14 +152,14 @@ function createFakeChannelHarness(): FakeChannelHarness { channel, feed: ({ frame, bodyText }) => { if (!dataListener) throw new Error("The broker did not bind the data listener."); - dataListener(encodeDuplexFrame(frame)); + dataListener(Buffer.from(encodeDuplexFrame(frame), "utf8")); if (bodyText.length > 0) { const chunks = splitBodyIntoChunkFrames( frame.id, Buffer.from(bodyText, "utf8"), DUPLEX_FRAME_VERSION, ); - for (const chunk of chunks) dataListener(encodeDuplexFrame(chunk)); + for (const chunk of chunks) dataListener(Buffer.from(encodeDuplexFrame(chunk), "utf8")); } }, responses, diff --git a/packages/adapter-utils/src/duplex-bridge-broker.ts b/packages/adapter-utils/src/duplex-bridge-broker.ts index 00ebf516bb..86a21ab011 100644 --- a/packages/adapter-utils/src/duplex-bridge-broker.ts +++ b/packages/adapter-utils/src/duplex-bridge-broker.ts @@ -699,7 +699,10 @@ export async function createDuplexBridgeBroker( const writeLine = (line: string): boolean => { try { - channel.write(line); + // The channel carries raw bytes (see `ChannelBytesWireValue` in the plugin + // SDK's protocol.ts). Encode the frame's UTF-8 text to bytes at this one + // write seam. + channel.write(Buffer.from(line, "utf8")); return true; } catch (error) { recordLoss("stream_failure", errorMessage(error)); @@ -1337,7 +1340,7 @@ export async function createDuplexBridgeBroker( } }; - const onData = (chunk: string): void => { + const onData = (chunk: Uint8Array): void => { if (stopped) return; const results = decoder.push(chunk); for (const result of results) { @@ -1364,7 +1367,7 @@ export async function createDuplexBridgeBroker( const sendHeartbeat = (): void => { if (state !== "open") return; try { - channel.write(encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" })); + channel.write(Buffer.from(encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" }), "utf8")); } catch (error) { recordLoss("heartbeat_write_failure", errorMessage(error)); } @@ -1387,7 +1390,7 @@ export async function createDuplexBridgeBroker( // Send an orderly close frame. Ignore a write failure here; the broker is // already closing, so a dead channel needs no loss record. try { - channel.write(encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "close" })); + channel.write(Buffer.from(encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "close" }), "utf8")); } catch (error) { options.logger?.(`Duplex broker could not send the close frame: ${errorMessage(error)}`); } diff --git a/packages/adapter-utils/src/duplex-bridge-local-e2e.test.ts b/packages/adapter-utils/src/duplex-bridge-local-e2e.test.ts index 91485d7908..91f8755c0d 100644 --- a/packages/adapter-utils/src/duplex-bridge-local-e2e.test.ts +++ b/packages/adapter-utils/src/duplex-bridge-local-e2e.test.ts @@ -5,7 +5,6 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Readable } from "node:stream"; -import { StringDecoder } from "node:string_decoder"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -145,20 +144,15 @@ interface ChildDuplexChannel { * writes to the child stdin, reads the child stdout, and learns of the child * exit through this channel. * - * The host end reads a byte stream from the stdout pipe. A pipe read can split - * one multi-byte UTF-8 character across two chunks. The `StringDecoder` holds - * the bytes of an incomplete character until the next chunk, so the broker only - * ever reads whole characters. The channel keeps a second decoder for - * observation only; it lets the harness assert the READY frame and the request - * frames the child produced, and it never feeds the broker. + * The host end forwards each raw stdout chunk unchanged; the channel carries + * bytes, so no decode step sits between the pipe and the broker. */ function attachChildDuplexChannel(child: ChildProcessWithoutNullStreams): ChildDuplexChannel { const observed = new DuplexFrameDecoder(); const observedFrames: DuplexFrame[] = []; - const stdoutDecoder = new StringDecoder("utf8"); - let dataListener: ((chunk: string) => void) | null = null; + let dataListener: ((chunk: Uint8Array) => void) | null = null; let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; - let pendingText = ""; + let pendingBytes: Buffer = Buffer.alloc(0); let pendingExit: { exitCode: number | null } | null = null; let stderrText = ""; @@ -166,10 +160,9 @@ function attachChildDuplexChannel(child: ChildProcessWithoutNullStreams): ChildD for (const result of observed.push(buffer)) { if (result.ok) observedFrames.push(result.frame); } - const text = stdoutDecoder.write(buffer); - if (text.length === 0) return; - if (dataListener) dataListener(text); - else pendingText += text; + if (buffer.length === 0) return; + if (dataListener) dataListener(buffer); + else pendingBytes = pendingBytes.length === 0 ? buffer : Buffer.concat([pendingBytes, buffer]); }); child.stderr.on("data", (buffer: Buffer) => { stderrText += buffer.toString("utf8"); @@ -189,9 +182,9 @@ function attachChildDuplexChannel(child: ChildProcessWithoutNullStreams): ChildD }, onData: (listener) => { dataListener = listener; - if (pendingText.length > 0) { - const replay = pendingText; - pendingText = ""; + if (pendingBytes.length > 0) { + const replay = pendingBytes; + pendingBytes = Buffer.alloc(0); listener(replay); } }, diff --git a/packages/adapter-utils/src/duplex-frame-codec.ts b/packages/adapter-utils/src/duplex-frame-codec.ts index 1013dbf106..0938603139 100644 --- a/packages/adapter-utils/src/duplex-frame-codec.ts +++ b/packages/adapter-utils/src/duplex-frame-codec.ts @@ -15,6 +15,13 @@ * The decoder never throws on the read path. A malformed, oversized, or * version-mismatch frame becomes a protocol-error result, not an exception. This * keeps one bad frame from crashing the read loop. + * + * HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. + * The `http2_v1` host readiness gate imports {@link decodeDuplexLine} from this + * file to read the one READY line every gateway sends, so this file's READY + * frame path stays live for both transports. `duplex-bridge-broker.ts` and + * `duplex-body-spool.ts` still import the request, response, and body-chunk + * frame types from this file, so this phase keeps every frame type here. */ import { diff --git a/packages/adapter-utils/src/duplex-observability.test.ts b/packages/adapter-utils/src/duplex-observability.test.ts new file mode 100644 index 0000000000..095b64b04d --- /dev/null +++ b/packages/adapter-utils/src/duplex-observability.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + DUPLEX_LOSS_REASONS, + HTTP2_TELEMETRY_EVENT_NAMES, + mapHttp2EventToDuplexLossReason, + normalizeDuplexLossReason, + normalizeDuplexProvider, + type DuplexFallbackReason, + type DuplexTransportValue, +} from "./duplex-observability.js"; + +describe("duplex observability: HTTP/2 event mapping (accepted security fix 7)", () => { + it("test_http2_events_map_to_the_loss_taxonomy", () => { + // Every closed HTTP/2 event name maps to a value from the existing, + // closed DuplexLossReason set. The map reuses one taxonomy across every + // transport, so no separate HTTP/2-only reason list exists. + for (const event of HTTP2_TELEMETRY_EVENT_NAMES) { + const reason = mapHttp2EventToDuplexLossReason(event); + expect(DUPLEX_LOSS_REASONS).toContain(reason); + } + expect(mapHttp2EventToDuplexLossReason("session_error")).toBe("rpc_failure"); + expect(mapHttp2EventToDuplexLossReason("session_goaway")).toBe("transport_closed"); + expect(mapHttp2EventToDuplexLossReason("session_stall")).toBe("heartbeat_timeout"); + expect(mapHttp2EventToDuplexLossReason("write_error")).toBe("write_error"); + expect(mapHttp2EventToDuplexLossReason("transport_closed")).toBe("transport_closed"); + expect(mapHttp2EventToDuplexLossReason("channel_exit")).toBe("provider_exit"); + }); + + it("test_an_unknown_telemetry_input_maps_to_other", () => { + // An unknown event name, a raw provider error string, and a missing value + // all map to `other`. No raw text ever reaches a sink through this map. + expect(mapHttp2EventToDuplexLossReason("ECONNRESET: read failed at socket.js:42")).toBe("other"); + expect(mapHttp2EventToDuplexLossReason("some_future_event")).toBe("other"); + expect(mapHttp2EventToDuplexLossReason(null)).toBe("other"); + expect(mapHttp2EventToDuplexLossReason(undefined)).toBe("other"); + expect(mapHttp2EventToDuplexLossReason("")).toBe("other"); + }); + + it("keeps the closed HTTP/2 event-name set to the seven named signals", () => { + expect([...HTTP2_TELEMETRY_EVENT_NAMES].sort()).toEqual( + [ + "session_error", + "session_goaway", + "session_stall", + "write_error", + "transport_closed", + "channel_exit", + ].sort(), + ); + }); +}); + +describe("duplex observability: closed transport and fallback-reason values", () => { + it("accepts the http2 transport value and the preface_missing fallback reason", () => { + // A type-level check: these string literals must widen to the exported + // union types with no cast, so a drift in either union breaks the build. + const transport: DuplexTransportValue = "http2"; + const fallbackReason: DuplexFallbackReason = "preface_missing"; + expect(transport).toBe("http2"); + expect(fallbackReason).toBe("preface_missing"); + }); +}); + +describe("duplex observability: existing normalization stays intact", () => { + it("still maps an unknown loss cause and an unknown provider key to their closed defaults", () => { + expect(normalizeDuplexLossReason("not_a_real_reason")).toBe("other"); + expect(normalizeDuplexProvider("some-unlisted-plugin")).toBe("other"); + }); +}); diff --git a/packages/adapter-utils/src/duplex-observability.ts b/packages/adapter-utils/src/duplex-observability.ts index d88769a4a3..1d144fa60f 100644 --- a/packages/adapter-utils/src/duplex-observability.ts +++ b/packages/adapter-utils/src/duplex-observability.ts @@ -86,8 +86,12 @@ export const DUPLEX_DIMENSION_KEYS = [ /** One dimension key from the closed set. */ export type DuplexDimensionKey = (typeof DUPLEX_DIMENSION_KEYS)[number]; -/** The transport a record is about. */ -export type DuplexTransportValue = "duplex" | "file"; +/** + * The transport a record is about. `duplex` names the retired bespoke frame + * protocol; `http2` names the Node HTTP/2 session over the sandbox channel; + * `file` names the queue-file bridge. + */ +export type DuplexTransportValue = "duplex" | "http2" | "file"; /** The outcome of a record. */ export type DuplexOutcomeValue = "ok" | "error"; @@ -98,9 +102,13 @@ export type DuplexOutcomeValue = "ok" | "error"; * stage: the process-scoped route ceiling was full (`route_busy`), the entrypoint * sync failed (`entrypoint_sync_failed`), the broker construction failed * (`broker_construction_failed`), or the channel open failed (`channel_open_failed`). - * The `aggregate_bytes_exceeded` reason names a readiness handshake the host fell - * back because the process aggregate byte ceiling had no room for the readiness - * buffer. + * The `aggregate_bytes_exceeded` reason names a readiness handshake, or an + * `http2` post-preface pre-bind buffer, the host fell back because the + * process aggregate byte ceiling had no room. The `preface_missing` reason + * names a missing or an invalid HTTP/2 client connection preface inside the + * bounded readiness buffer: the host found no valid preface after the + * accepted READY line, aborted the HTTP/2 open, and moved the run to + * `queue_v1` one time. */ export type DuplexFallbackReason = | "gate_off" @@ -113,7 +121,8 @@ export type DuplexFallbackReason = | "ready_nonce_mismatch" | "ready_timeout" | "contaminated" - | "aggregate_bytes_exceeded"; + | "aggregate_bytes_exceeded" + | "preface_missing"; /** The class of a terminal loss, relative to the first request dispatch. */ export type DuplexLossClass = "pre_dispatch" | "post_dispatch"; @@ -155,6 +164,58 @@ export function normalizeDuplexLossReason(value: string | null | undefined): Dup : "other"; } +/** + * The closed set of HTTP/2 session and stream event names the host maps to a + * loss reason. Each name spells one distinct signal the `http2_v1` transport + * can observe: a session-level protocol fault, a peer GOAWAY, a stalled PING + * watchdog, a rejected host-to-sandbox write, a reason-less transport close, + * or the pseudo-terminal channel process exit. + */ +export const HTTP2_TELEMETRY_EVENT_NAMES = [ + "session_error", + "session_goaway", + "session_stall", + "write_error", + "transport_closed", + "channel_exit", +] as const; + +/** One event name from the closed HTTP/2 event set. */ +export type Http2TelemetryEventName = (typeof HTTP2_TELEMETRY_EVENT_NAMES)[number]; + +/** The host-owned closed HTTP/2 event-name set. It backs {@link mapHttp2EventToDuplexLossReason}. */ +const HTTP2_EVENT_NAMES: ReadonlySet = new Set(HTTP2_TELEMETRY_EVENT_NAMES); + +/** + * The map from one closed HTTP/2 event name to the existing, closed + * {@link DuplexLossReason} taxonomy. The map reuses that one taxonomy instead + * of a second, HTTP/2-only reason list, so the closed-set pattern stays one + * set of values across every transport. + */ +const HTTP2_EVENT_TO_LOSS_REASON: Readonly> = { + session_error: "rpc_failure", + session_goaway: "transport_closed", + session_stall: "heartbeat_timeout", + write_error: "write_error", + transport_closed: "transport_closed", + channel_exit: "provider_exit", +}; + +/** + * Map one HTTP/2 session or stream event name to the closed + * {@link DuplexLossReason} taxonomy (accepted security fix 7). Return the + * mapped reason when the closed event-name set holds the value. Return + * `other` for any other value or a missing value, so an unknown event name or + * a raw provider string never reaches a sink. + */ +export function mapHttp2EventToDuplexLossReason( + event: string | null | undefined, +): DuplexLossReason { + return typeof event === "string" && HTTP2_EVENT_NAMES.has(event) + ? HTTP2_EVENT_TO_LOSS_REASON[event as Http2TelemetryEventName] + : "other"; +} + /** The one approved public provider value. */ export const DUPLEX_APPROVED_PROVIDER = "daytona"; /** The constant for any provider key outside the allowlist. */ @@ -278,6 +339,13 @@ export interface DuplexObservabilityOptions { recorder?: DuplexObservabilityRecorder | null; /** The raw provider key. The facade maps it through the allowlist one time. */ providerKey?: string | null; + /** + * The transport value the facade stamps on every non-file record (a channel + * open, a request, a loss, a session leak). The default is `duplex`, so an + * existing caller that names no transport sees no change. The host passes + * `http2` for the `http2_v1` path. + */ + transport?: DuplexTransportValue; } /** @@ -289,6 +357,7 @@ export interface DuplexObservabilityOptions { export function createDuplexObservability(options: DuplexObservabilityOptions = {}): DuplexObservability { const recorder = options.recorder ?? NOOP_DUPLEX_OBSERVABILITY_RECORDER; const provider = normalizeDuplexProvider(options.providerKey); + const transport: DuplexTransportValue = options.transport ?? "duplex"; const safeSpan = (record: DuplexObservabilitySpanRecord): void => { try { @@ -332,7 +401,7 @@ export function createDuplexObservability(options: DuplexObservabilityOptions = settled = true; const dimensions: DuplexObservabilityDimensions = { provider, - transport: "duplex", + transport, outcome: "ok", }; safeSpan({ name: DUPLEX_SPAN_CHANNEL_OPEN, dimensions }); @@ -349,7 +418,7 @@ export function createDuplexObservability(options: DuplexObservabilityOptions = // closed dimension key, so no new key reaches a sink. safeSpan({ name: DUPLEX_SPAN_CHANNEL_OPEN, - dimensions: { provider, transport: "duplex", outcome: "error", fallback_reason: reason }, + dimensions: { provider, transport, outcome: "error", fallback_reason: reason }, }); recordFallback(reason); }, @@ -359,14 +428,14 @@ export function createDuplexObservability(options: DuplexObservabilityOptions = recordRequest(record: { latencyMs: number; outcome: DuplexOutcomeValue }): void { safeSpan({ name: DUPLEX_SPAN_REQUEST, - dimensions: { provider, transport: "duplex", outcome: record.outcome }, + dimensions: { provider, transport, outcome: record.outcome }, latencyMs: record.latencyMs, }); }, recordLoss(lossClass: DuplexLossClass, lossReason: DuplexLossReason): void { const dimensions: DuplexObservabilityDimensions = { provider, - transport: "duplex", + transport, outcome: "error", loss_class: lossClass, loss_reason: lossReason, @@ -377,7 +446,7 @@ export function createDuplexObservability(options: DuplexObservabilityOptions = recordSessionLeak(): void { safeCounter({ metric: DUPLEX_COUNTER_SESSION_LEAK_TOTAL, - dimensions: { provider, transport: "duplex", outcome: "error" }, + dimensions: { provider, transport, outcome: "error" }, }); }, }; diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index 90fcb45b76..d7aaaa2ba7 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -1,5 +1,7 @@ import { createServer } from "node:http"; +import http2 from "node:http2"; import net from "node:net"; +import { duplexPair, type Duplex } from "node:stream"; import { execFile, spawn } from "node:child_process"; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; @@ -15,6 +17,7 @@ import { import { __duplexReadinessTesting, + __http2PrefaceScanTesting, buildDuplexGatewayLaunchArgv, DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, adapterExecutionTargetDuplexObservabilityRecorder, @@ -3164,25 +3167,25 @@ describe("sandbox adapter execution targets", () => { const joined = openInput.command.join(" "); const nonce = /PAPERCLIP_BRIDGE_NONCE='([^']*)'/.exec(joined)?.[1] ?? ""; const port = /PAPERCLIP_BRIDGE_PORT='([^']*)'/.exec(joined)?.[1] ?? ""; - let dataListener: ((chunk: string) => void) | null = null; + let dataListener: ((chunk: Uint8Array) => void) | null = null; let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; const channel: CommandManagedDuplexChannel = { - write(data: string): void { - const decoded = decodeDuplexLine(data.replace(/\n$/, "")); + write(data: Uint8Array): void { + const decoded = decodeDuplexLine(Buffer.from(data).toString("utf8").replace(/\n$/, "")); if (decoded.ok) { control.writtenTypes.push(decoded.frame.type); if (decoded.frame.type === "response") control.written.push(decoded.frame); } }, - onData(listener: (chunk: string) => void): void { + onData(listener: (chunk: Uint8Array) => void): void { dataListener = listener; - control.emitData = (chunk) => dataListener?.(chunk); + control.emitData = (chunk) => dataListener?.(new TextEncoder().encode(chunk)); // Drive the readiness emission on the next tick, after the gate also // registers its exit listener. setImmediate(() => { - const emitRaw = (text: string) => dataListener?.(text); + const emitRaw = (text: string) => dataListener?.(new TextEncoder().encode(text)); const emitFrame = (frame: Record) => - dataListener?.(`${JSON.stringify(frame)}\n`); + dataListener?.(new TextEncoder().encode(`${JSON.stringify(frame)}\n`)); const emitExit = (exit: { exitCode: number | null }) => exitListener?.(exit); if (onOpen) { onOpen({ nonce, port, emitRaw, emitFrame, emitExit }); @@ -3207,6 +3210,153 @@ describe("sandbox adapter execution targets", () => { return { runner: { ...base, openDuplexChannel }, control }; } + // The HTTP/2 client connection preface, 24 octets (RFC 9113, Section 3.4). + // A test writes this literal to script a preface look-alike; it does not + // import the production constant, so the test proves the real wire bytes + // match, not only that the two source files agree on a name. + const HTTP2_TEST_CLIENT_PREFACE = Buffer.from("505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", "hex"); + + interface Http2SelectionControl { + openCount: number; + stopCount: number; + closeCount: number; + } + + // The hook a test supplies to script the bytes a fake sandbox gateway sends + // after the host binds the readiness gate, and to open a real HTTP/2 client + // session on the same channel. The default hook sends one valid READY line, + // then opens the client session and leaves it idle — the happy path needs + // no hook. + interface Http2OpenContext { + nonce: string; + port: string; + /** The real per-run bridge token the host generated for this open. A + * test attaches it as the `authorization` header on every real HTTP/2 + * request it dispatches, the same way the sandbox gateway does. */ + bridgeToken: string; + /** Write raw bytes onto the channel, ahead of or instead of a READY line + * or the client preface. */ + emitRaw: (bytes: Buffer | string) => void; + /** Write one READY line. Echoes the launch nonce by default. */ + emitReady: (nonce?: string) => void; + /** Open a real HTTP/2 client session on the channel and return it, the + * same session type the generated sandbox gateway opens in production. */ + connectHttp2: () => http2.ClientHttp2Session; + /** End the channel from the sandbox side, simulating a provider process exit. */ + emitExit: () => void; + } + + // Build a runner whose fake `openDuplexChannel` returns one side of a real + // paired in-memory `Duplex` (`node:stream`'s `duplexPair`). A test drives + // the other side directly, including opening a real `http2.connect()` + // client session on it, so the host's HTTP/2 server under test speaks one + // real, wire-compatible HTTP/2 session — the same proof + // `http2-bridge-server.test.ts` uses for the server and the gateway in + // isolation, exercised here through the full transport-selection path. + function makeHttp2SelectionRunner(onOpen?: (ctx: Http2OpenContext) => void): { + runner: ReturnType & { + openDuplexChannel: (openInput: { command: readonly string[] }) => Promise; + }; + control: Http2SelectionControl; + } { + const base = createLocalSandboxRunner(); + const control: Http2SelectionControl = { openCount: 0, stopCount: 0, closeCount: 0 }; + const openDuplexChannel = async (openInput: { + command: readonly string[]; + }): Promise => { + control.openCount += 1; + const joined = openInput.command.join(" "); + const nonce = /PAPERCLIP_BRIDGE_NONCE='([^']*)'/.exec(joined)?.[1] ?? ""; + const port = /PAPERCLIP_BRIDGE_PORT='([^']*)'/.exec(joined)?.[1] ?? ""; + const bridgeToken = /PAPERCLIP_BRIDGE_TOKEN='([^']*)'/.exec(joined)?.[1] ?? ""; + const [hostSide, sandboxSide] = duplexPair(); + const dataListeners: Array<(chunk: Uint8Array) => void> = []; + const exitListeners: Array<(exit: { exitCode: number | null }) => void> = []; + hostSide.on("data", (chunk: Buffer) => { + for (const listener of dataListeners) listener(chunk); + }); + hostSide.on("end", () => { + for (const listener of exitListeners) listener({ exitCode: 0 }); + }); + const channel: CommandManagedDuplexChannel = { + write: (data: Uint8Array) => { + hostSide.write(Buffer.from(data)); + }, + onData: (listener: (chunk: Uint8Array) => void) => { + dataListeners.push(listener); + }, + onExit: (listener: (exit: { exitCode: number | null }) => void) => { + exitListeners.push(listener); + }, + stop: () => { + control.stopCount += 1; + hostSide.destroy(); + }, + close: async () => { + control.closeCount += 1; + hostSide.end(); + }, + }; + setImmediate(() => { + const ctx: Http2OpenContext = { + nonce, + port, + bridgeToken, + emitRaw: (bytes) => sandboxSide.write(typeof bytes === "string" ? Buffer.from(bytes) : bytes), + emitReady: (readyNonce = nonce) => + sandboxSide.write( + encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "ready", nonce: readyNonce }), + ), + connectHttp2: () => http2.connect("http://bridge.internal", { createConnection: () => sandboxSide }), + emitExit: () => sandboxSide.end(), + }; + if (onOpen) { + onOpen(ctx); + } else { + ctx.emitReady(); + ctx.connectHttp2(); + } + }); + return channel; + }; + return { runner: { ...base, openDuplexChannel }, control }; + } + + /** + * Forward one request over a real HTTP/2 client session and resolve with + * the response the host sends back. Mirrors the shape of the sandbox + * gateway's own request forward, so a test drives the transport exactly + * like production does. + */ + function http2TestRequest( + session: http2.ClientHttp2Session, + request: { method: string; path: string; headers?: Record; body?: string }, + ): Promise<{ status: number; headers: Record; body: string }> { + return new Promise((resolve, reject) => { + const body = Buffer.from(request.body ?? "", "utf8"); + const stream = session.request( + { ":method": request.method, ":path": request.path, ...request.headers }, + { endStream: body.length === 0 }, + ); + const chunks: Buffer[] = []; + let status = 502; + let headers: Record = {}; + stream.on("response", (h) => { + status = Number(h[":status"]) || 502; + headers = {}; + for (const [key, value] of Object.entries(h)) { + if (key.startsWith(":") || value == null) continue; + headers[key] = Array.isArray(value) ? value.join(", ") : String(value); + } + }); + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.once("end", () => resolve({ status, headers, body: Buffer.concat(chunks).toString("utf8") })); + stream.once("error", (error) => reject(error)); + if (body.length > 0) stream.end(body); + else if (!stream.writableEnded) stream.end(); + }); + } + // 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. @@ -3258,17 +3408,72 @@ describe("sandbox adapter execution targets", () => { }; } - it("selects the duplex transport when both strict gates pass and readiness completes", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-select-")); + it("test_startup_failure_falls_back_to_queue_v1", async () => { + // The channel open itself fails (a provider startup fault, before any + // READY line or preface is possible). The host must fall back to the + // file bridge and record the typed open-failure reason, never hang. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-startup-fail-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const base = createLocalSandboxRunner(); + let openCount = 0; + const openDuplexChannel = async (): Promise => { + openCount += 1; + throw new Error("provider could not start the sandbox process"); + }; + const runner = { ...base, openDuplexChannel }; + const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", - providerKey: "e2b", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-startup-fail", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + duplexObservabilityRecorder: recorder, + }); + try { + expect(bridge).not.toBeNull(); + expect(openCount).toBe(1); + 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"); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_daytona_selects_http2_v1", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-select-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner, control } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", environmentId: "env-1", leaseId: "lease-1", remoteCwd, @@ -3278,7 +3483,7 @@ describe("sandbox adapter execution targets", () => { }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ - runId: "run-duplex", + runId: "run-http2", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", @@ -3289,46 +3494,30 @@ describe("sandbox adapter execution targets", () => { try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); // The host builds the origin from the port it assigned, never from a frame. expect(bridge?.env.PAPERCLIP_API_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(bridge?.env.PAPERCLIP_API_KEY).not.toBe("real-run-jwt"); - // The gateway forwards one agent request as a request frame. The broker - // forwards it on the host path with the real token and the run id, then - // writes one response frame back. - emitDuplexRequest( - control, - { - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-1", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { authorization: "Bearer bridge-token" }, - }, - "", - ); - await waitForCondition( - () => control.written.length >= 1, - "the broker to write a duplex response frame", - 4000, - ); - expect(control.written[0]).toMatchObject({ - type: "response", - id: "req-1", - status: 200, - outcome: "completed", + // The sandbox gateway forwards one agent request as one real HTTP/2 + // stream, over the one session that runs directly on the sandbox + // channel. The host serves it with the real token and the run id. + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + const response = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, }); + expect(response.status).toBe(200); expect(api.requests).toHaveLength(1); expect(api.requests[0]).toMatchObject({ method: "GET", url: "/api/agents/me", auth: "Bearer real-run-jwt", - runId: "run-duplex", + runId: "run-http2", }); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } @@ -3397,13 +3586,13 @@ describe("sandbox adapter execution targets", () => { } }, 20000); - it("streams run logs on the duplex path under the same gate and log line as the file path", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-runlog-")); + it("streams run logs on the http2 path under the same gate and log line as the file path", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-runlog-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner } = makeDuplexSelectionRunner(); + const { runner } = makeHttp2SelectionRunner(); const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; const target: AdapterSandboxExecutionTarget = { kind: "remote", @@ -3429,9 +3618,9 @@ describe("sandbox adapter execution targets", () => { }, }); try { - // The duplex transport served, and it still streams run logs with the same + // The http2 transport served, and it still streams run logs with the same // gate and the same log line as the file path. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); expect(bridge?.runLogTail).toBeTruthy(); expect(combinedStream(logs, "stdout")).toContain("Sandbox run log streaming enabled"); const wrapped = bridge!.runLogTail!.create().wrapCommand("agent-cli", ["--message", "hello world"]); @@ -3443,13 +3632,13 @@ describe("sandbox adapter execution targets", () => { } }, 20000); - it("returns no run-log tail on the duplex path when streaming is opted out", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-runlog-off-")); + it("returns no run-log tail on the http2 path when streaming is opted out", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-runlog-off-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner } = makeDuplexSelectionRunner(); + const { runner } = makeHttp2SelectionRunner(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", @@ -3462,7 +3651,7 @@ describe("sandbox adapter execution targets", () => { }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ - runId: "run-duplex-log-off", + runId: "run-http2-log-off", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", @@ -3471,7 +3660,7 @@ describe("sandbox adapter execution targets", () => { enableSandboxDuplexBridge: true, }); try { - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); expect(bridge?.runLogTail ?? null).toBeNull(); } finally { await bridge?.stop(); @@ -3479,8 +3668,8 @@ describe("sandbox adapter execution targets", () => { } }, 20000); - it("routes duplex channel-open and fallback records to a recorder attached on the server seam", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-recorder-")); + it("routes http2 channel-open and fallback records to a recorder attached on the server seam", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-recorder-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); @@ -3495,7 +3684,7 @@ describe("sandbox adapter execution targets", () => { emitEvent() {}, }; - // A channel open reaches the recorder on the duplex success path. The host + // A channel open reaches the recorder on the http2 success path. The host // attaches the recorder to the sandbox target on the same seam as the // runner; the caller reads it with the accessor and passes it to the bridge. const openTarget: AdapterSandboxExecutionTarget = { @@ -3504,12 +3693,12 @@ describe("sandbox adapter execution targets", () => { providerKey: "daytona", remoteCwd, timeoutMs: 30_000, - runner: makeDuplexSelectionRunner().runner, + runner: makeHttp2SelectionRunner().runner, effectiveCapabilities: duplexCapabilities(true), duplexObservabilityRecorder: recorder, }; const openBridge = await startAdapterExecutionTargetPaperclipBridge({ - runId: "run-duplex-open", + runId: "run-http2-open", target: openTarget, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", @@ -3519,9 +3708,9 @@ describe("sandbox adapter execution targets", () => { duplexObservabilityRecorder: adapterExecutionTargetDuplexObservabilityRecorder(openTarget), }); try { - expect(openBridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(openBridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); const open = counters.find((record) => record.metric === DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL); - expect(open?.dimensions.transport).toBe("duplex"); + expect(open?.dimensions.transport).toBe("http2"); expect(open?.dimensions.provider).toBe("daytona"); } finally { await openBridge?.stop(); @@ -3744,17 +3933,23 @@ describe("sandbox adapter execution targets", () => { 20000, ); - it("answers an unlisted route 403 over the duplex path without forwarding it", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-403-")); + it("test_route_allowlist_header_cleanup_and_token_replacement_hold_on_http2", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-403-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + 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: "e2b", + providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, @@ -3762,7 +3957,7 @@ describe("sandbox adapter execution targets", () => { }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ - runId: "run-403", + runId: "run-http2-403", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", @@ -3771,29 +3966,39 @@ describe("sandbox adapter execution targets", () => { enableSandboxDuplexBridge: true, }); try { - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); - emitDuplexRequest( - control, - { - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-forbidden", - method: "POST", - path: "/api/secret-admin-route", - query: "", - headers: { authorization: "Bearer bridge-token", "content-type": "application/json" }, - }, - JSON.stringify({ escalate: true }), - ); - await waitForCondition( - () => control.written.length >= 1, - "the broker to write a 403 response frame", - 4000, - ); - expect(control.written[0]).toMatchObject({ type: "response", id: "req-forbidden", status: 403 }); - // The route allowlist rejected the request, so it never forwarded. + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + + // An unlisted route answers 403 over the real HTTP/2 stream and never + // reaches the host API — the route allowlist holds on the http2 path. + const forbidden = await http2TestRequest(sessionRef.current!, { + method: "POST", + path: "/api/secret-admin-route", + headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" }, + body: JSON.stringify({ escalate: true }), + }); + expect(forbidden.status).toBe(403); expect(api.requests).toHaveLength(0); + + // An allowed route forwards with the real host token and the run id — + // the token replacement and the run-id injection hold on the http2 path. + // A header outside the allowlist (`x-not-allowed`) never reaches the host. + const allowed = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}`, "x-not-allowed": "should-be-dropped" }, + }); + expect(allowed.status).toBe(200); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]).toMatchObject({ + method: "GET", + url: "/api/agents/me", + auth: "Bearer real-run-jwt", + runId: "run-http2-403", + }); + expect(api.requests[0].headers["x-not-allowed"]).toBeUndefined(); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } @@ -3869,13 +4074,19 @@ describe("sandbox adapter execution targets", () => { ); }); - it("records a duplex request span with latency and the fixed dimension keys", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-obs-")); + it("records an http2 request span with latency and the fixed dimension keys", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-obs-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const { recorder, spans, counters, events } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", @@ -3898,36 +4109,25 @@ describe("sandbox adapter execution targets", () => { duplexObservabilityRecorder: recorder, }); try { - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); - emitDuplexRequest( - control, - { - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-obs", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { authorization: "Bearer bridge-token" }, - }, - "", - ); - await waitForCondition( - () => control.written.length >= 1, - "the broker to write a duplex response frame", - 4000, - ); + 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: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(200); // The channel-open surface: the span, the counter, and the transport event. const openSpan = spans.find((span) => span.name === DUPLEX_SPAN_CHANNEL_OPEN); expect(openSpan).toBeDefined(); - expect(openSpan?.dimensions).toMatchObject({ provider: "daytona", transport: "duplex", outcome: "ok" }); + expect(openSpan?.dimensions).toMatchObject({ provider: "daytona", transport: "http2", outcome: "ok" }); expect(counters.some((c) => c.metric === DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL)).toBe(true); expect( events.some( (e) => e.name === DUPLEX_TRANSPORT_EVENT && - e.dimensions.transport === "duplex" && + e.dimensions.transport === "http2" && e.dimensions.outcome === "ok", ), ).toBe(true); @@ -3937,9 +4137,10 @@ describe("sandbox adapter execution targets", () => { expect(requestSpan).toBeDefined(); expect(typeof requestSpan?.latencyMs).toBe("number"); expect(requestSpan?.latencyMs).toBeGreaterThanOrEqual(0); - expect(requestSpan?.dimensions).toMatchObject({ provider: "daytona", transport: "duplex", outcome: "ok" }); + expect(requestSpan?.dimensions).toMatchObject({ provider: "daytona", transport: "http2", outcome: "ok" }); assertOnlyFixedDimensionKeys(requestSpan?.dimensions); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } @@ -4080,12 +4281,20 @@ describe("sandbox adapter execution targets", () => { { name: "before any dispatch", dispatchFirst: false, expectedClass: "pre_dispatch" }, { name: "after a dispatch", dispatchFirst: true, expectedClass: "post_dispatch" }, ])("increments the loss counter with the loss class $name", async ({ dispatchFirst, expectedClass }) => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-loss-")); + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-loss-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + let emitExit: (() => void) | null = null; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + emitExit = ctx.emitExit; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", @@ -4108,51 +4317,47 @@ describe("sandbox adapter execution targets", () => { duplexObservabilityRecorder: recorder, }); try { - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); if (dispatchFirst) { - emitDuplexRequest( - control, - { - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-loss", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { authorization: "Bearer bridge-token" }, - }, - "", - ); - await waitForCondition( - () => control.written.length >= 1, - "the broker to write a duplex response frame", - 4000, - ); + const response = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(200); } - // A malformed frame is a protocol failure. The broker records a terminal loss. - control.emitData("@@@ not a duplex frame @@@\n"); + // The pseudo-terminal channel exits. The host records a terminal loss. + emitExit!(); await waitForCondition( () => counters.some((c) => c.metric === DUPLEX_COUNTER_LOSS_TOTAL), - "the broker to record a loss counter", + "the host to record a loss counter", 4000, ); const loss = counters.find((c) => c.metric === DUPLEX_COUNTER_LOSS_TOTAL); expect(loss?.dimensions.loss_class).toBe(expectedClass); - expect(loss?.dimensions).toMatchObject({ transport: "duplex", outcome: "error" }); + expect(loss?.dimensions).toMatchObject({ transport: "http2", outcome: "error", loss_reason: "provider_exit" }); assertOnlyFixedDimensionKeys(loss?.dimensions); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("keeps serving the request path when the telemetry recorder throws", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-guard-")); + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-guard-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const { recorder } = createRecordingDuplexRecorder({ failEvery: true }); const target: AdapterSandboxExecutionTarget = { kind: "remote", @@ -4175,37 +4380,33 @@ describe("sandbox adapter execution targets", () => { duplexObservabilityRecorder: recorder, }); try { - // The throwing recorder never blocked the duplex selection. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); - emitDuplexRequest( - control, - { - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-guard", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { authorization: "Bearer bridge-token" }, - }, - "", - ); - await waitForCondition( - () => control.written.length >= 1, - "the broker to write a duplex response frame", - 4000, - ); + // The throwing recorder never blocked the http2 selection. + 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: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); // The request path still delivered a real host response. - expect(control.written[0]).toMatchObject({ type: "response", id: "req-guard", status: 200 }); + expect(response.status).toBe(200); expect(api.requests).toHaveLength(1); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); - it("keeps sentinel route, query, body, tokens, and provider errors off the duplex telemetry and logs", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-redact-")); + it("keeps sentinel route, query, body, and tokens off the http2 telemetry and logs", async () => { + // `recordHttp2Loss` (execution-target.ts) never accepts a raw error + // message: it maps every host-observed HTTP/2 event to one fixed, closed + // `DuplexLossReason` value before any sink reads it (accepted security + // fix 7, `duplex-observability.test.ts` pins the closed map). A raw provider + // error string has no code path into a sink on the http2_v1 transport, so + // this test proves the property that does need a live run: the route, + // the query, the body, and both tokens never ride a sink either. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-redact-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); @@ -4214,47 +4415,16 @@ describe("sandbox adapter execution targets", () => { const ROUTE_SENTINEL = "sentinelroute8f21"; const QUERY_SENTINEL = "sentinelquery3d90"; const BODY_SENTINEL = "sentinelbodya17c"; - const BRIDGE_TOKEN_SENTINEL = "sentinelbridgetok55e2"; const AGENT_TOKEN_SENTINEL = "sentinelagenttoke91b4"; - const PROVIDER_ERROR_SENTINEL = "sentinelprovidererr7a3d"; - const sentinels = [ - ROUTE_SENTINEL, - QUERY_SENTINEL, - BODY_SENTINEL, - BRIDGE_TOKEN_SENTINEL, - AGENT_TOKEN_SENTINEL, - PROVIDER_ERROR_SENTINEL, - ]; + const sentinels = [ROUTE_SENTINEL, QUERY_SENTINEL, BODY_SENTINEL, AGENT_TOKEN_SENTINEL]; - // A runner whose channel throws a provider error on the response write, so the - // broker records a stream-failure loss carrying the sentinel message. - const base = createLocalSandboxRunner(); - const control = { emitData: (_chunk: string) => {} }; - const openDuplexChannel = async (openInput: { - command: readonly string[]; - }): Promise => { - const joined = openInput.command.join(" "); - const nonce = /PAPERCLIP_BRIDGE_NONCE='([^']*)'/.exec(joined)?.[1] ?? ""; - let dataListener: ((chunk: string) => void) | null = null; - const channel: CommandManagedDuplexChannel = { - write(_data: string): void { - // Every response write fails with a provider error carrying the sentinel. - throw new Error(`provider write failed: ${PROVIDER_ERROR_SENTINEL}`); - }, - onData(listener: (chunk: string) => void): void { - dataListener = listener; - control.emitData = (chunk) => dataListener?.(chunk); - setImmediate(() => dataListener?.(`${JSON.stringify({ version: 2, type: "ready", nonce })}\n`)); - }, - onExit(_listener: (exit: { exitCode: number | null }) => void): void {}, - stop(): void {}, - close(): Promise { - return Promise.resolve(); - }, - }; - return channel; - }; - const runner = { ...base, openDuplexChannel }; + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const logLines: string[] = []; const { recorder, spans, counters, events } = createRecordingDuplexRecorder(); @@ -4285,53 +4455,59 @@ describe("sandbox adapter execution targets", () => { logLines.push(chunk); }, }); - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); - // A request that carries the sentinel route, query, body, and bridge token. - // The response write then fails with the sentinel provider error. - emitDuplexRequest( - control, - { - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-redact", - method: "GET", - path: `/api/${ROUTE_SENTINEL}`, - query: `secret=${QUERY_SENTINEL}`, - headers: { authorization: `Bearer ${BRIDGE_TOKEN_SENTINEL}` }, - }, - BODY_SENTINEL, - ); - // Give the forward and the failing response write time to run and record a loss. + // Dispatch one real HTTP/2 stream that carries the sentinel route, + // query, and body. The route allowlist accepts an arbitrary issue id + // segment, so the sentinel rides an allowed route. The real agent + // token (also a sentinel) never leaves the host. + const response = await http2TestRequest(sessionRef.current!, { + 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 }), + }); + expect(response.status).toBe(200); await waitForCondition( - () => counters.some((c) => c.metric === DUPLEX_COUNTER_LOSS_TOTAL), - "the broker to record a loss counter after the failed write", + () => spans.some((s) => s.name === DUPLEX_SPAN_REQUEST), + "the host to record a request span", 4000, ); // Serialize every telemetry record and every log line, then assert that no - // sentinel reaches any of them on the duplex path. + // sentinel reaches any of them on the http2 path. const telemetryDump = JSON.stringify({ spans, counters, events }); const logDump = logLines.join(""); for (const sentinel of sentinels) { expect(telemetryDump).not.toContain(sentinel); expect(logDump).not.toContain(sentinel); } + // The real agent token replaced the bridge token on the forward; the + // recording API server saw only the real token, never the bridge token. + expect(api.requests[0]?.auth).toBe(`Bearer ${AGENT_TOKEN_SENTINEL}`); } finally { if (previousDebug === undefined) delete process.env.PAPERCLIP_BRIDGE_DEBUG; else process.env.PAPERCLIP_BRIDGE_DEBUG = previousDebug; + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("maps a sentinel provider key to the constant other across every sink", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-prov-")); + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-prov-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const { recorder, spans, counters, events } = createRecordingDuplexRecorder(); const PROVIDER_SENTINEL = "sentinel-plugin-provider-key-9c2a"; const target: AdapterSandboxExecutionTarget = { @@ -4355,23 +4531,17 @@ describe("sandbox adapter execution targets", () => { duplexObservabilityRecorder: recorder, }); try { - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); - emitDuplexRequest( - control, - { - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-prov", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { authorization: "Bearer bridge-token" }, - }, - "", - ); + 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: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(200); await waitForCondition( () => spans.some((s) => s.name === DUPLEX_SPAN_REQUEST), - "the broker to record a request span", + "the host to record a request span", 4000, ); @@ -4389,6 +4559,7 @@ describe("sandbox adapter execution targets", () => { const telemetryDump = JSON.stringify({ spans, counters, events }); expect(telemetryDump).not.toContain(PROVIDER_SENTINEL); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } @@ -4566,22 +4737,23 @@ describe("sandbox adapter execution targets", () => { }, 20000); it("charges the pre-READY buffer against the injected host ledger and releases it when readiness passes", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-ready-ledger-ok-")); + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-ready-ledger-ok-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // The host stamps one process-owned aggregate byte ledger on the sandbox // target at a generous ceiling. The fake gateway sends one pre-READY noise - // line, then the valid READY frame. The gate charges the noise bytes against - // the injected ledger, passes readiness, and releases the pre-READY tokens. - // The broker's frame decoder re-charges any post-READY bytes, so the ledger - // returns to zero after the handshake. + // line, then the valid READY frame, then a real HTTP/2 client preface. The + // gate charges the noise bytes against the injected ledger, passes + // readiness, and releases the pre-READY tokens. The ledger returns to zero + // once the post-READY replay hands off to the bound HTTP/2 channel. const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1024 * 1024 }); const reserveSpy = vi.spyOn(ledger, "reserve"); - const { runner, control } = makeDuplexSelectionRunner((ctx) => { + const { runner, control } = makeHttp2SelectionRunner((ctx) => { ctx.emitRaw("pty-echo-noise"); - ctx.emitFrame({ version: 2, type: "ready", nonce: ctx.nonce }); + ctx.emitReady(); + ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", @@ -4606,8 +4778,8 @@ describe("sandbox adapter execution targets", () => { }); try { expect(bridge).not.toBeNull(); - // Readiness passed, so the duplex transport serves. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + // Readiness passed, so the http2 transport serves. + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); // The gate charged the pre-READY noise against the exact injected ledger, so // the identity holds at this seam. expect(reserveSpy).toHaveBeenCalledWith("readiness_buffer", expect.any(Number)); @@ -4683,6 +4855,64 @@ describe("sandbox adapter execution targets", () => { } }, 20000); + it("bounds the pre-READY buffer growth-copy work by the bytes received", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-growth-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + // An adversarial provider sends many small newline-less fragments before the + // cap fires. A one-copy-per-fragment append copies the whole retained buffer + // on every fragment, so the total copy work is quadratic in the number of + // fragments. The gate must instead grow its backing storage by doubling, so + // the total copy work stays linear in the bytes received. + const readinessBufferCapBytes = DEFAULT_MAX_DUPLEX_FRAME_BYTES + 4_096; + const smallChunk = "x".repeat(64); + const chunkCount = Math.ceil(readinessBufferCapBytes / smallChunk.length) + 1; + const totalBytes = chunkCount * smallChunk.length; + const { runner, control } = makeDuplexSelectionRunner((ctx) => { + for (let i = 0; i < chunkCount; i += 1) { + ctx.emitRaw(smallChunk); + } + }); + const { recorder } = createRecordingDuplexRecorder(); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + __duplexReadinessTesting.resetBufferGrowthCopyUnits(); + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-growth-bound", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + duplexReadinessTimeoutMs: 5_000, + duplexObservabilityRecorder: recorder, + }); + try { + expect(bridge).not.toBeNull(); + expect(control.openCount).toBe(1); + const copyUnits = __duplexReadinessTesting.readBufferGrowthCopyUnits(); + // Doubling growth copies a logarithmic number of times, each at most the + // current buffer length, so the total stays within a small multiple of + // totalBytes. A per-fragment full-buffer copy is quadratic (about + // totalBytes^2 / (2 * chunkSize)), far above this bound. + expect(copyUnits).toBeLessThanOrEqual(4 * totalBytes); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + it("bounds the pre-READY skip scan work by the bytes received", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-blank-")); cleanupDirs.push(rootDir); @@ -4696,9 +4926,10 @@ describe("sandbox adapter execution targets", () => { // work quadratic. The READY frame then settles the gate ready. const blankLineCount = 15_000; const noisePrefix = "\n".repeat(blankLineCount) + "a non-frame echo line\n"; - const { runner, control } = makeDuplexSelectionRunner((ctx) => { + const { runner, control } = makeHttp2SelectionRunner((ctx) => { ctx.emitRaw(noisePrefix); - ctx.emitFrame({ version: 2, type: "ready", nonce: ctx.nonce }); + ctx.emitReady(); + ctx.connectHttp2(); }); const readyLine = '{"version":2,"type":"ready","nonce":""}\n'; const totalBytes = noisePrefix.length + readyLine.length; @@ -4733,9 +4964,9 @@ describe("sandbox adapter execution targets", () => { // near totalBytes. A per-line full rescan is quadratic (about // blankLineCount^2 / 2), far above this bound. expect(scanUnits).toBeLessThanOrEqual(4 * totalBytes); - // The gate skipped the noise and accepted the READY frame, so the duplex + // The gate skipped the noise and accepted the READY frame, so the http2 // transport serves and no fallback fired. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback).toBeUndefined(); } finally { @@ -4744,19 +4975,24 @@ describe("sandbox adapter execution targets", () => { } }, 20000); - it("skips pre-READY noise lines, then accepts a valid READY frame and serves the duplex transport", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-noise-")); + it("test_a_prologue_of_any_length_before_the_ready_line_is_discarded", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-noise-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // A PTY channel echoes the launch wrapper line before it sets raw mode, so the // first line the host reads is a non-frame echo, not the READY frame. The gate - // must skip the echo line and a partial-JSON line, then accept the READY frame. - const { runner, control } = makeDuplexSelectionRunner((ctx) => { + // must skip the echo line and a partial-JSON line, then accept the READY + // frame — no matter how long the prologue is, and with no length held anywhere + // in the code. The preface scan then starts only on the bytes the gate + // retained after that accepted line. + const { runner, control } = makeHttp2SelectionRunner((ctx) => { ctx.emitRaw("sh -c exec env PAPERCLIP_BRIDGE_NONCE=... node gateway.mjs\n"); ctx.emitRaw('{"version":2,"type":"ready"}\n'); - ctx.emitFrame({ version: 2, type: "ready", nonce: ctx.nonce }); + ctx.emitRaw("x".repeat(50_000)); // an arbitrarily long prologue, no fixed length + ctx.emitReady(); + ctx.connectHttp2(); }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { @@ -4783,9 +5019,10 @@ describe("sandbox adapter execution targets", () => { try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); - // The gate skipped the echo and the partial frame, then accepted the READY - // frame, so the duplex transport serves and no fallback fired. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + // The gate skipped the echo, the partial frame, and the long prologue, + // then accepted the READY frame, so the http2 transport serves and no + // fallback fired. + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback).toBeUndefined(); } finally { @@ -4944,7 +5181,7 @@ describe("sandbox adapter execution targets", () => { expect(openSpan).toBeDefined(); expect(openSpan?.dimensions).toMatchObject({ provider: "daytona", - transport: "duplex", + transport: "http2", outcome: "error", fallback_reason: "ready_nonce_mismatch", }); @@ -4956,17 +5193,23 @@ describe("sandbox adapter execution targets", () => { } }, 20000); - it("drops a frame header outside the allowlist on the host duplex forward path", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-hdr-")); + it("drops a header outside the allowlist on the host http2 forward path", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-hdr-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + 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: "e2b", + providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, @@ -4983,32 +5226,21 @@ describe("sandbox adapter execution targets", () => { enableSandboxDuplexBridge: true, }); try { - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); - emitDuplexRequest( - control, - { - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-hdr", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { - // An allowlisted header the host must keep. - accept: "application/json", - // A header outside the allowlist the host must drop. - "x-injected-header": "attacker", - // A sandbox-supplied auth header the host must replace with the real token. - authorization: "Bearer bridge-token", - }, + 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: "GET", + path: "/api/agents/me", + headers: { + authorization: `Bearer ${bridgeToken}`, + // An allowlisted header the host must keep. + accept: "application/json", + // A header outside the allowlist the host must drop. + "x-injected-header": "attacker", }, - "", - ); - await waitForCondition( - () => api.requests.length >= 1, - "the broker to forward the duplex request", - 4000, - ); + }); + expect(response.status).toBe(200); + await waitForCondition(() => api.requests.length >= 1, "the host to forward the http2 request", 4000); const forwarded = api.requests[0]; // The allowlisted header reaches the host. expect(forwarded.headers.accept).toBe("application/json"); @@ -5018,30 +5250,39 @@ describe("sandbox adapter execution targets", () => { expect(forwarded.auth).toBe("Bearer real-run-jwt"); expect(forwarded.runId).toBe("run-hdr"); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); - it("selects the duplex transport for a large forward budget and starts the broker", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-budget-")); + it("selects the http2 transport for a large forward budget", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-budget-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + 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: "e2b", + providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; - // A forward budget past the default response budget (32 s). Before the budget - // derivation, this made the nested budget assertion throw and leak the channel. + // A forward budget past the default response budget (32 s). The http2 path + // holds no nested-budget derivation (that budget set belonged to the retired + // duplex_v1 broker only), so a large forward budget must still select and + // serve normally. const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-budget", target, @@ -5053,68 +5294,67 @@ describe("sandbox adapter execution targets", () => { forwardTimeoutMs: 60_000, }); try { - // The broker started with derived nested budgets, so the duplex transport serves. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); - emitDuplexRequest( - control, - { - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-budget", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { authorization: "Bearer bridge-token" }, - }, - "", - ); - await waitForCondition( - () => control.written.length >= 1, - "the broker to write a duplex response frame", - 4000, - ); - expect(control.written[0]).toMatchObject({ type: "response", id: "req-budget", status: 200 }); + 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: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(200); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); - it("falls back to the file bridge when the broker construction throws", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-ctor-")); + it("test_a_missing_preface_aborts_the_open_and_falls_back_to_queue_v1", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-no-preface-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + // The gateway sends a valid READY line, but no HTTP/2 client preface ever + // follows (a suppressed or a stalled sandbox client). Readiness passes, + // then the bounded preface scan finds nothing before its bound; the host + // must abort the open and select the file bridge instead of hanging or + // leaking the channel. + const { runner, control } = makeHttp2SelectionRunner((ctx) => { + ctx.emitReady(); + // No connectHttp2() call: the client preface never arrives. + }); + const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", - providerKey: "e2b", + providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; - // A non-finite forward budget makes the nested budget assertion throw at - // broker construction. Readiness passes first, so the guarded region must - // close the channel and select the file bridge instead of leaking it. const bridge = await startAdapterExecutionTargetPaperclipBridge({ - runId: "run-ctor", + runId: "run-no-preface", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, - forwardTimeoutMs: Number.NaN, + // A short readiness timeout, so the preface-missing bound (which reapplies + // this same value) fires quickly in the test. + duplexReadinessTimeoutMs: 500, + duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); - // Readiness passed, then the broker construction threw; the file bridge serves. + // The missing preface aborted the open; the file bridge serves. 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("preface_missing"); // The bounded cleanup left no live provider session. expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1); } finally { @@ -5123,6 +5363,398 @@ describe("sandbox adapter execution targets", () => { } }, 20000); + it("test_disabled_flag_selects_queue_v1", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-disabled-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const { runner, control } = makeHttp2SelectionRunner(); + const { recorder, counters } = createRecordingDuplexRecorder(); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + // The kill switch is off. The host must never open the channel at all. + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-disabled", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: false, + duplexObservabilityRecorder: recorder, + }); + try { + expect(bridge).not.toBeNull(); + expect(control.openCount).toBe(0); + 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("gate_off"); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_fallback_to_queue_v1_happens_at_most_once_per_run", async () => { + // The open attempt runs once per run, with no retry loop: a preface + // failure falls through to the file bridge exactly one time, and the + // host never re-attempts http2_v1 afterward in the same run. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-one-way-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const { runner, control } = makeHttp2SelectionRunner((ctx) => { + ctx.emitReady(); + // No connectHttp2() call: the preface never arrives, so the open fails. + }); + const { recorder, counters } = createRecordingDuplexRecorder(); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-one-way", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + duplexReadinessTimeoutMs: 500, + duplexObservabilityRecorder: recorder, + }); + try { + expect(bridge).not.toBeNull(); + // The host opened the channel exactly once for the whole run — no retry + // loop re-attempted http2_v1 after the fallback. + expect(control.openCount).toBe(1); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); + // Exactly one fallback record — the transition never repeats. + const fallbacks = counters.filter((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); + expect(fallbacks).toHaveLength(1); + expect(fallbacks[0]?.dimensions.fallback_reason).toBe("preface_missing"); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_the_host_writes_no_byte_before_the_ready_line_is_accepted", async () => { + // The launch wrapper gives the gateway no start acknowledgment (accepted + // fact from PAP-5065): a host byte written before the READY line is + // accepted can reach the shell instead of the child. This test proves + // the host writes nothing to the channel until well after it accepted + // READY: it holds the channel open, with no client preface arriving + // (so the http2 server, if bound, would try to write its own SETTINGS + // frame), and asserts zero bytes crossed the channel the whole time. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-no-early-write-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const base = createLocalSandboxRunner(); + const hostWrites: Buffer[] = []; + let readyLineSent = false; + const readySentAtMs = { value: 0 }; + const openDuplexChannel = async (openInput: { + command: readonly string[]; + }): Promise => { + const joined = openInput.command.join(" "); + const nonce = /PAPERCLIP_BRIDGE_NONCE='([^']*)'/.exec(joined)?.[1] ?? ""; + let dataListener: ((chunk: Uint8Array) => void) | null = null; + const channel: CommandManagedDuplexChannel = { + write: (data: Uint8Array) => { + // Any host write before READY is accepted is exactly the fault + // this test guards against. + hostWrites.push(Buffer.from(data)); + }, + onData: (listener: (chunk: Uint8Array) => void) => { + dataListener = listener; + setImmediate(() => { + readySentAtMs.value = Date.now(); + readyLineSent = true; + dataListener?.( + new TextEncoder().encode(`${JSON.stringify({ version: 2, type: "ready", nonce })}\n`), + ); + // No client preface follows: the host must still write nothing + // while it waits out the preface bound, proving the READY-accept + // gate — not a timer — is what would unlock a host write. + }); + }, + onExit: (_listener: (exit: { exitCode: number | null }) => void) => {}, + stop: () => {}, + close: async () => Promise.resolve(), + }; + return channel; + }; + const runner = { ...base, openDuplexChannel }; + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-no-early-write", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + duplexReadinessTimeoutMs: 300, + }); + try { + expect(readyLineSent).toBe(true); + // The preface never arrived, so the open fell back to the file bridge — + // and across the whole open attempt, including the wait after READY, + // the host wrote zero bytes to the channel. + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); + expect(hostWrites).toHaveLength(0); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_invalid_ready_line_bytes_do_not_reach_a_log_line", async () => { + // Fix 5: decode only the bounded READY line as strict protocol text, and + // never log raw channel bytes. This test sends a pre-READY line whose + // bytes are deliberately sentinel-marked and syntactically invalid (not + // valid UTF-8 JSON), then a valid READY frame. No log line — on any + // stream — may contain the sentinel bytes. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-invalid-ready-bytes-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const INVALID_LINE_SENTINEL = "sentinelinvalidreadyline62fa"; + const logLines: string[] = []; + const { runner } = makeHttp2SelectionRunner((ctx) => { + // Invalid bytes: not valid UTF-8 (a lone continuation byte), immediately + // followed by a sentinel and a newline — an invalid candidate line. + ctx.emitRaw(Buffer.concat([Buffer.from([0x80]), Buffer.from(INVALID_LINE_SENTINEL), Buffer.from("\n")])); + ctx.emitReady(); + 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-invalid-ready-bytes", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + onLog: async (_stream, chunk) => { + logLines.push(chunk); + }, + }); + try { + // The invalid line was skipped as noise, and READY still passed. + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + const logDump = logLines.join(""); + expect(logDump).not.toContain(INVALID_LINE_SENTINEL); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_close_after_orderly_completion_keeps_the_run_result", async () => { + // A loss ordered after a host-observed orderly completion is a normal + // teardown, not a failure: the run already completed. The disposition + // latch must keep the success and emit no loss event for it. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-orderly-close-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + let emitExit: (() => void) | null = null; + const { runner } = makeHttp2SelectionRunner((ctx) => { + emitExit = ctx.emitExit; + ctx.emitReady(); + ctx.connectHttp2(); + }); + const { recorder, events, counters } = createRecordingDuplexRecorder(); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-orderly-close", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + duplexObservabilityRecorder: recorder, + }); + try { + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + // The agent turn completes cleanly before the channel ends. + expect(bridge?.settleRunDisposition?.()).toEqual({ failed: false, lossReason: null }); + emitExit!(); + await new Promise((resolve) => setTimeout(resolve, 100)); + // The disposition still reports success after the teardown loss. + expect(bridge?.readRunDisposition?.()).toEqual({ failed: false, lossReason: null }); + // A normal teardown after completion is not a loss: no loss event, no + // loss counter. + expect(events.some((e) => e.dimensions.loss_reason !== undefined)).toBe(false); + expect(counters.some((c) => c.metric === DUPLEX_COUNTER_LOSS_TOTAL)).toBe(false); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_safe_request_during_channel_loss_stays_retryable", async () => { + // A GET never changes host state, so a response-body read failure stays + // retryable: the host answers 502 with no indeterminate marker, the same + // rule `forwardBridgeRequest` already applies on every transport. This + // proves the http2_v1 forward handler reuses that one function unchanged. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-safe-retry-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + 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 target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-safe-retry", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + // A ceiling far under any real response, so every response-body read + // fails the size check deterministically. + maxBodyBytes: 1, + }); + 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: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(502); + expect(response.headers["x-paperclip-bridge-outcome"]).toBeUndefined(); + } finally { + sessionRef.current?.close(); + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_unsafe_request_during_channel_loss_is_indeterminate", async () => { + // A POST may have committed on the host before the response-body read + // 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. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-unsafe-indeterminate-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + 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 target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-unsafe-indeterminate", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + maxBodyBytes: 1, + }); + 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" }, + body: JSON.stringify({ body: "hello" }), + }); + expect(response.status).toBe(504); + expect(response.headers["x-paperclip-bridge-outcome"]).toBe("indeterminate"); + } finally { + sessionRef.current?.close(); + await bridge?.stop(); + await api.close(); + } + }, 20000); + // --------------------------------------------------------------------------- // Real-PTY replay. // @@ -5133,13 +5765,13 @@ describe("sandbox adapter execution targets", () => { // rather than against synthetic frames. // --------------------------------------------------------------------------- - async function runReadinessReplay(emit: (ctx: DuplexOpenContext) => void) { + async function runReadinessReplay(emit: (ctx: Http2OpenContext) => void) { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pty-replay-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(emit); + const { runner, control } = makeHttp2SelectionRunner(emit); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", @@ -5177,8 +5809,9 @@ describe("sandbox adapter execution targets", () => { "exec 'bash' '-c' 'exec env PAPERCLIP_BRIDGE_NONCE=" + ctx.nonce + " node gateway.mjs'\r\n", ); ctx.emitRaw('{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); + ctx.connectHttp2(); }); - expect(mode).toBe("duplex_v1"); + expect(mode).toBe("http2_v1"); }, 20000); // Case 2: captured from a local pty.fork() + bash on a host whose bash enables @@ -5194,8 +5827,9 @@ describe("sandbox adapter execution targets", () => { ); // No newline between the escape sequence and the frame: same line. ctx.emitRaw('\x1b[?2004l\r{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); + ctx.connectHttp2(); }); - expect(mode).toBe("duplex_v1"); + expect(mode).toBe("http2_v1"); }, 20000); // Case 3: the READY frame split across two chunk deliveries. @@ -5205,8 +5839,9 @@ describe("sandbox adapter execution targets", () => { const frame = '{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'; ctx.emitRaw(frame.slice(0, 12)); ctx.emitRaw(frame.slice(12)); + ctx.connectHttp2(); }); - expect(mode).toBe("duplex_v1"); + expect(mode).toBe("http2_v1"); }, 20000); // Case 4: a multibyte character split across two chunks in the pre-READY noise. @@ -5216,8 +5851,121 @@ describe("sandbox adapter execution targets", () => { ctx.emitRaw(noise.slice(0, 8).toString("utf8")); ctx.emitRaw(noise.slice(8).toString("utf8")); ctx.emitRaw('{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); + ctx.connectHttp2(); }); - expect(mode).toBe("duplex_v1"); + expect(mode).toBe("http2_v1"); + }, 20000); + + it("test_the_session_starts_at_the_client_preface_after_the_ready_line", async () => { + // The gate retains every byte after the accepted READY line, and the host + // starts the HTTP/2 session at the client preface offset inside that + // retained suffix, inclusive — no byte before the preface reaches the + // HTTP/2 server. A pre-preface byte would make the server report a + // `PROTOCOL_ERROR`; this test proves the real session opens cleanly + // instead, which only holds when the offset is exact. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-preface-offset-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + // The READY line and the client preface arrive in the same chunk, back + // to back, with no gap — the exact production shape. + 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-preface-offset", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + 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: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(200); + } finally { + sessionRef.current?.close(); + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_a_preface_pattern_before_the_ready_line_does_not_start_a_session", async () => { + // The scan window opens only after the gate accepts the READY line. A + // banner byte can carry the same 24 octets before that line; a scan that + // started earlier could match those bytes and start a session against + // the shell. This test embeds the exact preface bytes in the pre-READY + // noise, then proves a session still starts only at the REAL preface + // that follows READY. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-preface-lookalike-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner, control } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + // A pre-READY banner that happens to carry the literal preface bytes. + ctx.emitRaw(Buffer.concat([Buffer.from("prompt$ "), HTTP2_TEST_CLIENT_PREFACE, Buffer.from("\r\n")])); + 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-preface-lookalike", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + }); + try { + expect(bridge).not.toBeNull(); + expect(control.openCount).toBe(1); + 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: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(200); + } finally { + sessionRef.current?.close(); + await bridge?.stop(); + await api.close(); + } }, 20000); // Case 5: a flood of short noise lines must fail closed at the cap rather than @@ -5996,7 +6744,7 @@ function createFakeDuplexChannel(): { setWriteError: (error: Error | null) => void; setCloseBehavior: (behavior: "resolve" | "hang" | "reject") => void; } { - let dataListener: ((chunk: string) => void) | null = null; + let dataListener: ((chunk: Uint8Array) => void) | null = null; let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; let writeError: Error | null = null; let closeBehavior: "resolve" | "hang" | "reject" = "resolve"; @@ -6013,9 +6761,9 @@ function createFakeDuplexChannel(): { >(); const channel: CommandManagedDuplexChannel = { - write(data: string): void { + write(data: Uint8Array): void { if (writeError) throw writeError; - const decoded = decodeDuplexLine(data.replace(/\n$/, "")); + const decoded = decodeDuplexLine(Buffer.from(data).toString("utf8").replace(/\n$/, "")); if (!decoded.ok) return; const frame = decoded.frame; writtenTypes.push(frame.type); @@ -6040,7 +6788,7 @@ function createFakeDuplexChannel(): { } } }, - onData(listener: (chunk: string) => void): void { + onData(listener: (chunk: Uint8Array) => void): void { dataListener = listener; }, onExit(listener: (exit: { exitCode: number | null }) => void): void { @@ -6058,7 +6806,7 @@ function createFakeDuplexChannel(): { return { channel, - emitData: (chunk: string) => dataListener?.(chunk), + emitData: (chunk: string) => dataListener?.(new TextEncoder().encode(chunk)), emitExit: (exit: { exitCode: number | null }) => exitListener?.(exit), written, writtenTypes, @@ -6708,20 +7456,20 @@ describe("duplex readiness gate replay-buffer reservation", () => { emitExit: (exit: { exitCode: number | null }) => void; }; } { - let dataListener: ((chunk: string) => void) | null = null; + let dataListener: ((chunk: Uint8Array) => void) | null = null; let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; const control = { stopCount: 0, closeCount: 0, written: [] as string[], - emitData: (chunk: string): void => dataListener?.(chunk), + emitData: (chunk: string): void => dataListener?.(new TextEncoder().encode(chunk)), emitExit: (exit: { exitCode: number | null }): void => exitListener?.(exit), }; const channel: CommandManagedDuplexChannel = { - write(data: string): void { - control.written.push(data); + write(data: Uint8Array): void { + control.written.push(Buffer.from(data).toString("utf8")); }, - onData(listener: (chunk: string) => void): void { + onData(listener: (chunk: Uint8Array) => void): void { dataListener = listener; }, onExit(listener: (exit: { exitCode: number | null }) => void): void { @@ -6785,7 +7533,7 @@ describe("duplex readiness gate replay-buffer reservation", () => { // The broker binds and replays the suffix; the gate releases the token after // the synchronous handoff. const replayed: string[] = []; - gate.brokerChannel.onData((chunk) => replayed.push(chunk)); + gate.brokerChannel.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); expect(replayed).toEqual([suffix]); expect(ledger.bytesInUse).toBe(0); expect(ledger.liveTokenCount).toBe(0); @@ -6834,7 +7582,7 @@ describe("duplex readiness gate replay-buffer reservation", () => { // reservation. const replayed: string[] = []; const exits: Array<{ exitCode: number | null }> = []; - gate.brokerChannel.onData((chunk) => replayed.push(chunk)); + gate.brokerChannel.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); gate.brokerChannel.onExit((exit) => exits.push(exit)); expect(replayed).toEqual([suffix]); expect(exits).toEqual([{ exitCode: 0 }]); @@ -6867,7 +7615,7 @@ describe("duplex readiness gate replay-buffer reservation", () => { expect(counts.underflows).toBe(0); // Binding the broker replays nothing, because the gate dropped the buffer. const replayed: string[] = []; - gate.brokerChannel.onData((chunk) => replayed.push(chunk)); + gate.brokerChannel.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); expect(replayed).toEqual([]); }); @@ -6894,12 +7642,388 @@ describe("duplex readiness gate replay-buffer reservation", () => { expect(ledger.liveTokenCount).toBe(1); // The broker binds, replays the suffix, and the gate releases the token. const replayed: string[] = []; - gate.brokerChannel.onData((chunk) => replayed.push(chunk)); + gate.brokerChannel.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); expect(replayed).toEqual([suffix]); expect(ledger.bytesInUse).toBe(0); expect(gate.retainedReadinessBufferLength()).toBe(0); expect(counts.underflows).toBe(0); }); + + it("releases the readiness_replay token before the downstream listener runs, so a same-size downstream reservation for the same bytes does not double-book", async () => { + const { channel, control } = makeFakeReadinessChannel(); + const suffix = "s".repeat(512); + const suffixBytes = Buffer.byteLength(suffix, "utf8"); + // The ceiling admits the READY line plus one reservation of the suffix + // size, but not a second, separate reservation of the suffix on top of + // that. A downstream listener that reserves the same bytes under its own + // owner — the real shape of the HTTP/2 preface scanner — proves the gate + // releases its own `readiness_replay` token first: the downstream + // reservation must still fit. + const { ledger, counts } = makeCountingLedger(Buffer.byteLength(readyLine(), "utf8") + suffixBytes); + const gate = __duplexReadinessTesting.createReadinessGate(channel, { + nonce: READY_NONCE, + timeoutMs: 5_000, + ledger, + }); + control.emitData(`${readyLine()}${suffix}`); + expect((await gate.ready).ok).toBe(true); + expect(ledger.bytesInUse).toBe(suffixBytes); + + let peakBytesInUseDuringHandoff = -1; + let downstreamToken: ReturnType = null; + gate.brokerChannel.onData((chunk) => { + // The downstream listener reserves the same bytes under its own owner, + // the same way the preface scanner charges `http2_preface_scan` for the + // replayed chunk. The gate must have released its own token before this + // call runs, so this reservation fits under the tight ceiling. + downstreamToken = ledger.reserve("http2_preface_scan", chunk.byteLength); + peakBytesInUseDuringHandoff = ledger.bytesInUse; + }); + + // The downstream reservation succeeded: the gate's release ran first, so + // only one reservation for these bytes was ever live at once. + expect(downstreamToken).not.toBeNull(); + expect(peakBytesInUseDuringHandoff).toBe(suffixBytes); + expect(counts.rejections).toBe(0); + expect(counts.underflows).toBe(0); + // The gate's own token is gone; only the downstream token remains live. + expect(ledger.liveTokenCount).toBe(1); + expect(ledger.bytesInUse).toBe(suffixBytes); + }); +}); + +describe("http2 preface scan post-preface replay buffer", () => { + // The HTTP/2 client connection preface, 24 octets (RFC 9113, Section 3.4). + // A test writes this literal, not the production constant, so the test + // proves the real wire bytes match, not only that the two source files + // agree on a name. + const PREFACE = Buffer.from("505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", "hex"); + + // A fake duplex channel the test drives directly. `control.emitData` + // re-enters the data listener the scan bound at construction. The fake + // records the stop call, so a test proves an overflow fails closed. + function makeFakeChannel(): { + channel: CommandManagedDuplexChannel; + control: { stopCount: number; emitData: (chunk: Buffer) => void }; + } { + let dataListener: ((chunk: Uint8Array) => void) | null = null; + const control = { + stopCount: 0, + emitData: (chunk: Buffer): void => dataListener?.(chunk), + }; + const channel: CommandManagedDuplexChannel = { + write(): void {}, + onData(listener: (chunk: Uint8Array) => void): void { + dataListener = listener; + }, + onExit(): void {}, + stop(): void { + control.stopCount += 1; + }, + close(): Promise { + return Promise.resolve(); + }, + }; + return { channel, control }; + } + + function makeCountingLedger(ceilingBytes: number): { + ledger: DuplexAggregateByteLedger; + counts: { rejections: number }; + } { + const counts = { rejections: 0 }; + const ledger = new DuplexAggregateByteLedger({ + ceilingBytes, + telemetry: { + setBytesInUse(): void {}, + recordReservationRejection(): void { + counts.rejections += 1; + }, + recordAccountingUnderflow(): void {}, + }, + }); + return { ledger, counts }; + } + + it("charges the pre-preface scan buffer while it searches, across many chunks", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger, counts } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 4_096, + timeoutMs: 5_000, + ledger, + }); + // Noise, then the preface, arrive as two separate chunks: the scan + // charges each chunk against the ledger as it grows the buffer, not + // only once the preface is found. + const noise = "not-the-preface-yet"; + control.emitData(Buffer.from(noise)); + expect(ledger.bytesInUse).toBe(Buffer.byteLength(noise, "utf8")); + expect(ledger.liveTokenCount).toBe(1); + control.emitData(PREFACE); + expect(await scan.settled).toBe("found"); + // The preface match releases the whole scan buffer (the noise prefix, + // dropped, plus the preface) and re-charges only the retained preface + // under the replay owner. + expect(ledger.bytesInUse).toBe(PREFACE.byteLength); + expect(ledger.liveTokenCount).toBe(1); + expect(counts.rejections).toBe(0); + }); + + it("fails closed when the aggregate byte ledger refuses the pre-preface scan reservation", async () => { + const { channel, control } = makeFakeChannel(); + // The ceiling admits nothing: even the 24-octet preface itself cannot + // reserve, so the scan fails closed before it ever finds a preface. + const { ledger, counts } = makeCountingLedger(4); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 4_096, + timeoutMs: 5_000, + ledger, + }); + control.emitData(Buffer.concat([PREFACE, Buffer.from("12345678")])); + expect(await scan.settled).toBe("missing"); + expect(scan.replayOverflowed()).toBe(false); + expect(ledger.bytesInUse).toBe(0); + expect(counts.rejections).toBe(1); + }); + + it("rejects a single pre-preface chunk over the cap without reserving or concatenating it", async () => { + const { channel, control } = makeFakeChannel(); + // The ceiling is far larger than the cap, so a ledger refusal cannot + // explain a rejection here: only the cap check on the chunk's + // prospective length can. + const { ledger, counts } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 64, + timeoutMs: 5_000, + ledger, + }); + // One chunk, larger than the cap on its own, with no preface inside it. + // The scan must reject it on its prospective length before the + // `Buffer.concat` allocation and before the ledger reservation, so + // nothing here ever charges the ledger. + control.emitData(Buffer.from("x".repeat(128))); + expect(await scan.settled).toBe("missing"); + expect(scan.replayOverflowed()).toBe(false); + expect(ledger.bytesInUse).toBe(0); + expect(ledger.liveTokenCount).toBe(0); + expect(counts.rejections).toBe(0); + }); + + it("rejects a pre-preface chunk that tips an already-buffered scan past the cap", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger, counts } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 64, + timeoutMs: 5_000, + ledger, + }); + // The first chunk stays under the cap on its own, so the scan buffers + // and charges it while it keeps searching. + const firstChunk = "n".repeat(40); + control.emitData(Buffer.from(firstChunk)); + expect(ledger.bytesInUse).toBe(Buffer.byteLength(firstChunk, "utf8")); + // The second chunk, added to the first, passes the cap. The scan must + // reject it before the concat that would grow the buffer past the cap, + // and it must drop the already-buffered first chunk too. + control.emitData(Buffer.from("n".repeat(40))); + expect(await scan.settled).toBe("missing"); + expect(scan.replayOverflowed()).toBe(false); + expect(ledger.bytesInUse).toBe(0); + expect(ledger.liveTokenCount).toBe(0); + expect(counts.rejections).toBe(0); + }); + + it("charges the post-preface bytes and releases them after the downstream bind", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 4_096, + timeoutMs: 5_000, + ledger, + }); + const suffix = "one-http2-frame"; + // The preface and the trailing suffix arrive in one chunk. The scan + // delivers every byte from the preface onward, inclusive, so the charged + // and replayed bytes carry the preface itself plus the suffix. + const fromPreface = Buffer.concat([PREFACE, Buffer.from(suffix)]).toString("utf8"); + control.emitData(Buffer.concat([PREFACE, Buffer.from(suffix)])); + expect(await scan.settled).toBe("found"); + expect(scan.replayOverflowed()).toBe(false); + expect(ledger.bytesInUse).toBe(Buffer.byteLength(fromPreface, "utf8")); + expect(ledger.liveTokenCount).toBe(1); + // The HTTP/2 server binds and replays the bytes; the scan releases the + // token after the synchronous handoff. + const replayed: string[] = []; + scan.scanned.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); + expect(replayed).toEqual([fromPreface]); + expect(ledger.bytesInUse).toBe(0); + expect(ledger.liveTokenCount).toBe(0); + }); + + it("fails closed and stops the channel when the post-preface buffer floods past the cap", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 64, + timeoutMs: 5_000, + ledger, + }); + // The preface arrives alone, so the pending buffer starts empty. + control.emitData(PREFACE); + expect(await scan.settled).toBe("found"); + expect(scan.replayOverflowed()).toBe(false); + // A post-preface chunk larger than the cap floods the buffer before the + // HTTP/2 server binds. The scan drops the buffer, stops the channel, and + // fails closed — the same shape a missing preface fails closed. + control.emitData(Buffer.from("x".repeat(128))); + expect(scan.replayOverflowed()).toBe(true); + expect(control.stopCount).toBe(1); + expect(ledger.bytesInUse).toBe(0); + expect(ledger.liveTokenCount).toBe(0); + // Binding the downstream listener replays nothing: the scan dropped the + // buffer on the overflow. + const replayed: string[] = []; + scan.scanned.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); + expect(replayed).toEqual([]); + }); + + it("fails closed when the aggregate byte ledger refuses the post-preface replay reservation", async () => { + const { channel, control } = makeFakeChannel(); + // The ceiling admits the 24-octet preface itself, but not an 8-byte + // suffix on top of it. + const { ledger, counts } = makeCountingLedger(30); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 4_096, + timeoutMs: 5_000, + ledger, + }); + // The preface arrives alone, so it is the only bytes charged so far. + control.emitData(PREFACE); + expect(await scan.settled).toBe("found"); + expect(scan.replayOverflowed()).toBe(false); + expect(ledger.bytesInUse).toBe(PREFACE.byteLength); + // A post-preface suffix, on top of the retained preface, passes the + // ceiling. The scan drops the buffer, stops the channel, and fails + // closed — the same shape a missing preface fails closed. + control.emitData(Buffer.from("12345678")); + expect(scan.replayOverflowed()).toBe(true); + expect(control.stopCount).toBe(1); + expect(ledger.bytesInUse).toBe(0); + expect(counts.rejections).toBe(1); + }); + + it("releases the scan-buffer tokens when the readiness timeout elapses with no preface found", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 4_096, + // A short bound, so the test does not wait out a production-sized one. + timeoutMs: 20, + ledger, + }); + // Partial, non-matching data arrives and stays charged while the scan + // keeps searching. No preface ever completes, so nothing else in the + // scan releases this charge — only the timeout path can. + const partial = "not-a-preface-and-never-will-be"; + control.emitData(Buffer.from(partial)); + expect(ledger.bytesInUse).toBe(Buffer.byteLength(partial, "utf8")); + expect(ledger.liveTokenCount).toBe(1); + // The bound readiness timeout elapses before a preface ever arrives. The + // scan must release its held tokens here — the one terminal path that + // has no cap or ledger refusal of its own to trigger a release. + expect(await scan.settled).toBe("missing"); + expect(ledger.bytesInUse).toBe(0); + expect(ledger.liveTokenCount).toBe(0); + }); + + it("finds a preface fragmented into many one-byte chunks", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 4_096, + timeoutMs: 5_000, + ledger, + }); + // A slow sandbox socket can deliver the preface one byte at a time. The + // scan must still find it and deliver the exact octets, the same as it + // does for a preface that arrives in one chunk. + for (const byte of PREFACE) { + control.emitData(Buffer.from([byte])); + } + expect(await scan.settled).toBe("found"); + expect(scan.replayOverflowed()).toBe(false); + const replayed: string[] = []; + scan.scanned.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); + expect(replayed).toEqual([PREFACE.toString("utf8")]); + }); + + it("bounds the pre-preface scan search and growth-copy work by the bytes received, across many one-byte fragments", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger } = makeCountingLedger(1024 * 1024); + __http2PrefaceScanTesting.resetScanSearchUnits(); + __http2PrefaceScanTesting.resetScanBufferGrowthCopyUnits(); + // An adversarial sandbox sends many one-byte fragments, none of them the + // preface, right up to the cap boundary. A search that always restarts + // from the beginning of the retained buffer re-examines the whole + // buffer on every fragment; a one-copy-per-fragment append copies the + // whole retained buffer on every fragment. Both are quadratic in the + // fragment count. + const capBytes = 2_048; + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes, + timeoutMs: 5_000, + ledger, + }); + for (let i = 0; i < capBytes; i += 1) { + control.emitData(Buffer.from([0x2e])); // '.', never part of the preface + } + // One more one-byte fragment tips the retained buffer past the cap, so + // the scan settles and this test can read the final work counts. + control.emitData(Buffer.from([0x2e])); + expect(await scan.settled).toBe("missing"); + const searchUnits = __http2PrefaceScanTesting.readScanSearchUnits(); + const growthCopyUnits = __http2PrefaceScanTesting.readScanBufferGrowthCopyUnits(); + // Each one-byte fragment can re-examine at most the preface's 24 octets + // of overlap (RFC 9113, Section 3.4), so the search work stays within a + // small multiple of capBytes. Doubling growth copies a logarithmic + // number of times, each at most the current buffer length, so the + // growth-copy work stays within a small multiple of capBytes too. A + // per-fragment full rescan or full copy is quadratic (about + // capBytes^2 / 2), far above either bound. + expect(searchUnits).toBeLessThanOrEqual(50 * capBytes); + expect(growthCopyUnits).toBeLessThanOrEqual(4 * capBytes); + }); + + it("bounds the post-preface replay buffer growth-copy work by the bytes received, across many one-byte fragments, and still enforces the cap", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger } = makeCountingLedger(1024 * 1024); + __http2PrefaceScanTesting.resetReplayBufferGrowthCopyUnits(); + const capBytes = 2_048; + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes, + timeoutMs: 5_000, + ledger, + }); + control.emitData(PREFACE); + expect(await scan.settled).toBe("found"); + // Many one-byte post-preface fragments arrive before the HTTP/2 server + // binds, right up to the cap boundary. A one-copy-per-fragment append + // copies the whole retained buffer on every fragment; the fix must + // instead grow by doubling. + const postPrefaceBytes = capBytes - PREFACE.byteLength; + for (let i = 0; i < postPrefaceBytes; i += 1) { + control.emitData(Buffer.from([0x2e])); + } + const growthCopyUnits = __http2PrefaceScanTesting.readReplayBufferGrowthCopyUnits(); + expect(growthCopyUnits).toBeLessThanOrEqual(4 * capBytes); + // One more one-byte fragment tips the retained buffer past the cap. The + // scan drops the buffer, stops the channel, and fails closed — the same + // shape a missing preface fails closed. + control.emitData(Buffer.from([0x2e])); + expect(scan.replayOverflowed()).toBe(true); + expect(control.stopCount).toBe(1); + }); }); describe("EffectiveSandboxCapabilities deprecated alias", () => { diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index d8f740a25d..e2238f142a 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -25,46 +25,42 @@ export type { SandboxAdditionalSource, } from "./sandbox-managed-runtime.js"; import { - authorizeSandboxCallbackBridgeRequestWithRoutes, createCommandManagedSandboxCallbackBridgeQueueClient, createSandboxCallbackBridgeAsset, createSandboxCallbackBridgeToken, DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES, DEFAULT_SANDBOX_DUPLEX_DECODER_MAX_BYTES, - SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT, + SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE, sandboxCallbackBridgeDirectories, - sanitizeSandboxCallbackBridgeHeaders, startSandboxCallbackBridgeServer, startSandboxCallbackBridgeWorker, syncRemoteTextFileWithHashSkip, syncSandboxCallbackBridgeEntrypoint, } from "./sandbox-callback-bridge.js"; +import { + createHttp2BridgeServer, + type Http2BridgeForwardHandler, +} from "./http2-bridge-server.js"; import { createSandboxRunLogTailFactory, type SandboxRunLogTailFactory, } from "./sandbox-run-log-stream.js"; import { - createDuplexBridgeBroker, DEFAULT_DUPLEX_BROKER_BUDGETS, DUPLEX_CHANNEL_LOST_ERROR_CODE, isSafeBridgeMethod, - typedDuplexLossReason, - type DuplexBridgeBroker, - type DuplexBrokerBudgets, - type DuplexBrokerForwardResult, type DuplexBrokerRunDisposition, } from "./duplex-bridge-broker.js"; -import { - decodeDuplexLine, - DEFAULT_MAX_DUPLEX_FRAME_BYTES, - type DuplexRequestFrame, -} from "./duplex-frame-codec.js"; +import { decodeDuplexLine, DEFAULT_MAX_DUPLEX_FRAME_BYTES } from "./duplex-frame-codec.js"; import type { ReassembledBody } from "./duplex-body-spool.js"; import { createDuplexObservability, + mapHttp2EventToDuplexLossReason, type DuplexFallbackReason, + type DuplexLossReason, type DuplexObservabilityRecorder, + type Http2TelemetryEventName, } from "./duplex-observability.js"; import { DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED, @@ -2542,21 +2538,486 @@ function isDuplexRouteBusyError(error: unknown): boolean { */ const DUPLEX_READINESS_BUFFER_CAP_BYTES = DEFAULT_MAX_DUPLEX_FRAME_BYTES + 4_096; +// --------------------------------------------------------------------------- +// http2_v1: the client connection preface scan and the run disposition latch. +// --------------------------------------------------------------------------- + /** - * Derive the nested broker budgets from one forward budget. The response budget - * and the gateway wait budget each add a fixed margin, so the nested budget - * order holds for any finite forward budget. The default forward budget (30 s) - * yields the historical 32 s response budget and 35 s gateway wait budget, so a - * caller that sets no forward budget sees no change. + * The HTTP/2 client connection preface: 24 octets, `PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n` + * (RFC 9113, Section 3.4). A Node `http2.performServerHandshake` call needs a + * `Duplex` whose readable side starts at this exact sequence; one extra + * leading byte makes the preface invalid, and the server reports a + * `PROTOCOL_ERROR`. The server does not skip a leading byte and does not + * search for the sequence, so the host finds the offset itself before it + * hands the channel to the server. */ -function deriveNestedDuplexBrokerBudgets(forwardTimeoutMs: number): DuplexBrokerBudgets { - const responseMargin = - DEFAULT_DUPLEX_BROKER_BUDGETS.responseBudgetMs - DEFAULT_DUPLEX_BROKER_BUDGETS.forwardTimeoutMs; - const gatewayMargin = - DEFAULT_DUPLEX_BROKER_BUDGETS.gatewayWaitMs - DEFAULT_DUPLEX_BROKER_BUDGETS.responseBudgetMs; - const responseBudgetMs = forwardTimeoutMs + responseMargin; - const gatewayWaitMs = responseBudgetMs + gatewayMargin; - return { forwardTimeoutMs, responseBudgetMs, gatewayWaitMs }; +const HTTP2_CLIENT_CONNECTION_PREFACE = Buffer.from( + "505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", + "hex", +); + +/** The one shared empty buffer. The preface scan starts and resets its two + * retained buffers to it. */ +const HTTP2_PREFACE_EMPTY_BUFFER = Buffer.alloc(0); + +// The count of bytes the preface scan's substring search examines, in bytes. +// A search that always starts from the beginning of the retained buffer +// re-examines the whole buffer on every fragment, so this count grows +// quadratically in the number of fragments. `findPrefaceFrom` instead +// resumes from where the prior search left off, so this count stays linear +// in the bytes received. A test reads this count to prove the search work +// does not regress to the quadratic shape. Production code never reads this +// count. +let http2PrefaceScanSearchUnits = 0; + +// The count of bytes the pre-preface scan buffer copies while it grows its +// backing storage. A one-copy-per-fragment append copies the whole retained +// buffer on every fragment, so this count grows quadratically in the number +// of fragments. The doubling-growth approach copies only on a reallocation, +// so this count stays linear in the bytes received. A test reads this count +// to prove the growth work does not regress to the quadratic shape. +// Production code never reads this count. +let http2PrefaceScanBufferGrowthCopyUnits = 0; + +// The same count as `http2PrefaceScanBufferGrowthCopyUnits`, for the +// post-preface replay buffer instead of the pre-preface scan buffer. +let http2PrefaceReplayBufferGrowthCopyUnits = 0; + +/** + * A byte buffer that grows its backing storage by doubling its capacity, + * instead of copying the whole retained buffer on every appended fragment. A + * sender that trickles input in many small fragments would otherwise force + * one full copy of the whole retained buffer per fragment: with the buffer + * growing toward its cap, that is quadratic work in the number of fragments. + * Doubling the backing storage's capacity only when the current capacity + * runs out reallocates and copies a logarithmic number of times, so the + * total copy work stays linear in the bytes received. `countGrowthCopy` + * receives the number of bytes each reallocation copies, so a test can add + * these up and prove the growth work stays linear. + */ +function createGrowableByteBuffer(countGrowthCopy: (copiedBytes: number) => void): { + view: () => Buffer; + length: () => number; + append: (chunk: Uint8Array) => void; + reset: () => void; +} { + let used: Buffer = HTTP2_PREFACE_EMPTY_BUFFER; + let storage: Buffer = HTTP2_PREFACE_EMPTY_BUFFER; + return { + view: () => used, + length: () => used.length, + append: (chunk: Uint8Array): void => { + const usedLength = used.length; + const neededLength = usedLength + chunk.byteLength; + if (neededLength > storage.length) { + let nextCapacity = storage.length === 0 ? chunk.byteLength : storage.length * 2; + while (nextCapacity < neededLength) { + nextCapacity *= 2; + } + const grown = Buffer.allocUnsafe(nextCapacity); + storage.copy(grown, 0, 0, usedLength); + countGrowthCopy(usedLength); + storage = grown; + } + storage.set(chunk, usedLength); + used = storage.subarray(0, neededLength); + }, + reset: (): void => { + used = HTTP2_PREFACE_EMPTY_BUFFER; + storage = HTTP2_PREFACE_EMPTY_BUFFER; + }, + }; +} + +/** + * Find the client connection preface in `buffer` at or after index `from` + * and return its offset, or -1. This counts the real scan distance for a + * test: from `from` up to the found preface's end, or to the end of the + * buffer when it finds none. The count stays linear in the bytes received + * when the caller advances `from` to just short of the buffer's end on every + * miss, instead of always searching from the start of the buffer. + */ +function findPrefaceFrom(buffer: Buffer, from: number): number { + const offset = buffer.indexOf(HTTP2_CLIENT_CONNECTION_PREFACE, from); + const scannedTo = + offset === -1 ? buffer.length : offset + HTTP2_CLIENT_CONNECTION_PREFACE.length; + http2PrefaceScanSearchUnits += Math.max(scannedTo - from, 0); + return offset; +} + +/** + * Wrap the readiness gate's broker channel so its `onData` delivers no byte + * until the client connection preface appears, then delivers every byte from + * the preface onward. + * + * The wrapped channel opens its scan window only on the bytes the readiness + * gate already retained after it accepted the READY line (constraint: the + * scan window opens only after the gate accepts the nonce). Nothing before + * that line ever reaches this scan, because the gate itself discards the + * whole pre-READY buffer on acceptance. The scan buffers at most + * {@link DUPLEX_READINESS_BUFFER_CAP_BYTES}; past that bound with no preface + * found, it calls `onMissing` exactly one time and stops buffering, so the + * caller can abort the open and fall back to `queue_v1`. The function holds + * no prologue byte count: it always scans for the fixed 24-octet sequence, + * never a length. The scan buffer holds untrusted, sandbox-controlled bytes + * on the same footing as the readiness gate's own pre-READY buffer, so, when + * the caller supplies a ledger, it charges each received chunk against the + * process aggregate byte ledger under the `http2_preface_scan` owner before + * the chunk grows the buffer. A refusal fails closed the same way the cap + * does: the function drops the buffer and calls `onMissing`. + * + * The bytes that follow the found preface, before the HTTP/2 server binds a + * downstream listener, land in `pendingAfterPreface`. This buffer holds + * untrusted bytes on the same footing as the scan buffer, so it carries the + * same {@link DUPLEX_READINESS_BUFFER_CAP_BYTES} cap and, when the caller + * supplies a ledger, charges each retained chunk against it under the + * `http2_preface_replay` owner. A chunk that would pass the cap, or that the + * ledger refuses, fails closed: the function + * drops the buffer, releases its ledger tokens, and stops the channel. The + * caller reads {@link replayOverflowed} after the preface settles and, on + * `true`, treats the open the same as a missing preface. + */ +function createHttp2PrefaceScanningChannel( + channel: CommandManagedDuplexChannel, + options: { + capBytes: number; + onFound: () => void; + onMissing: () => void; + ledger?: DuplexAggregateByteLedger | null; + }, +): { + channel: CommandManagedDuplexChannel; + replayOverflowed: () => boolean; + disposeScanBuffer: () => void; +} { + const ledger = options.ledger ?? null; + // The pre-preface scan buffer. `scanBuf.append` grows its backing storage + // by doubling, instead of copying the whole retained buffer on every + // fragment — see {@link createGrowableByteBuffer}. `scanSearchFrom` is the + // first index the next search must examine: `findPrefaceFrom` advances it + // to just short of the buffer's end on every miss, so a fragmented preface + // is found without a full rescan of the retained buffer on every fragment. + const scanBuf = createGrowableByteBuffer((copiedBytes) => { + http2PrefaceScanBufferGrowthCopyUnits += copiedBytes; + }); + let scanSearchFrom = 0; + let sawPreface = false; + let failed = false; + let downstream: ((chunk: Uint8Array) => void) | null = null; + // Bytes found after the preface before a downstream listener attaches. The + // wrapped channel replays them on attach, the same pattern the readiness + // gate itself uses for its own post-READY replay buffer. This also grows + // by doubling, for the same reason as `scanBuf`: fragmented post-preface + // input must not force a full copy of the retained buffer per fragment. + const pendingAfterPreface = createGrowableByteBuffer((copiedBytes) => { + http2PrefaceReplayBufferGrowthCopyUnits += copiedBytes; + }); + // Every `http2_preface_replay` reservation token this buffer holds. The + // function releases each token exactly once, on the downstream handoff or + // on an overflow. + const replayTokens: ReservationToken[] = []; + // Every `http2_preface_scan` reservation token the pre-preface scan buffer + // holds. The scan is untrusted, sandbox-controlled input on the same + // footing as the readiness gate's own pre-READY buffer, so it charges the + // ledger the same way: one reservation per received chunk, released in + // full on the terminal scan outcome — the preface found, the cap passed + // with no match, or a ledger refusal. + const scanTokens: ReservationToken[] = []; + let replayOverflow = false; + + function releaseReplayTokens(): void { + if (!ledger) return; + for (const token of replayTokens) { + ledger.release(token); + } + replayTokens.length = 0; + } + + function releaseScanTokens(): void { + if (!ledger) return; + for (const token of scanTokens) { + ledger.release(token); + } + scanTokens.length = 0; + } + + // Drop the pending buffer, release its tokens, and stop the channel. The + // caller reads `replayOverflowed()` after the preface settles and falls + // back the same way it does for a missing preface. + function overflowAndStop(): void { + replayOverflow = true; + pendingAfterPreface.reset(); + releaseReplayTokens(); + channel.stop(); + } + + function deliver(chunk: Buffer): void { + if (downstream) { + downstream(chunk); + return; + } + if (replayOverflow) return; + if (pendingAfterPreface.length() + chunk.byteLength > options.capBytes) { + overflowAndStop(); + return; + } + if (ledger) { + const token = ledger.reserve("http2_preface_replay", chunk.byteLength); + if (!token) { + overflowAndStop(); + return; + } + replayTokens.push(token); + } + pendingAfterPreface.append(chunk); + } + + channel.onData((chunk) => { + if (failed || replayOverflow) return; + if (sawPreface) { + deliver(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return; + } + const rawChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + // Reject the chunk on its prospective length before it grows the scan + // buffer, the same way `deliver` bounds `pendingAfterPreface`. A single + // oversized chunk — or a chunk that tips an already-large buffer past + // the cap — must fail closed here, before `scanBuf.append` performs the + // allocation. Checking the cap only after the append still bounds the + // retained buffer, but it lets one untrusted chunk force an allocation + // as large as the chunk itself, unbounded by `capBytes`. + if (scanBuf.length() + rawChunk.byteLength > options.capBytes) { + failed = true; + scanBuf.reset(); + scanSearchFrom = 0; + releaseScanTokens(); + options.onMissing(); + return; + } + // Charge this chunk against the aggregate ledger before it grows the + // scan buffer. A refusal fails closed the same way the cap does: drop + // the buffer and report a missing preface. + if (ledger) { + const token = ledger.reserve("http2_preface_scan", rawChunk.byteLength); + if (!token) { + failed = true; + scanBuf.reset(); + scanSearchFrom = 0; + releaseScanTokens(); + options.onMissing(); + return; + } + scanTokens.push(token); + } + scanBuf.append(rawChunk); + const scanBuffer = scanBuf.view(); + const offset = findPrefaceFrom(scanBuffer, scanSearchFrom); + if (offset === -1) { + // No match yet. Resume the next search just short of the buffer's + // end, keeping back an overlap of one octet less than the preface + // length, so a preface split across this fragment and the next one is + // still found. Each byte enters that overlap window a bounded number + // of times, so the total search work stays linear in the bytes + // received, not quadratic in the number of fragments. + scanSearchFrom = Math.max( + 0, + scanBuffer.length - (HTTP2_CLIENT_CONNECTION_PREFACE.length - 1), + ); + return; + } + sawPreface = true; + options.onFound(); + const fromPreface = Buffer.from(scanBuffer.subarray(offset)); + scanBuf.reset(); + scanSearchFrom = 0; + // Release the scan tokens before `deliver` charges the same bytes under + // `http2_preface_replay`. The two calls run inside one synchronous + // callback with no `await` between them, so no other reservation can + // observe the released state in between; releasing first keeps the + // ledger's momentary peak at the real retained bytes, not double them. + releaseScanTokens(); + deliver(fromPreface); + }); + + return { + channel: { + write: (data: Uint8Array) => channel.write(data), + onData: (listener: (chunk: Uint8Array) => void) => { + downstream = listener; + if (pendingAfterPreface.length() > 0) { + // Copy the exact retained bytes instead of handing the listener + // the growable buffer's backing view. That backing storage can + // run ahead of the bytes in use (the doubling growth in + // `pendingAfterPreface.append` over-provisions it), so a raw view + // would keep the whole over-provisioned allocation alive for as + // long as the listener holds its reference. + const replay = Buffer.from(pendingAfterPreface.view()); + pendingAfterPreface.reset(); + listener(replay); + } + // Release every replay token exactly once, after the synchronous + // handoff to the downstream listener. Order is safe here: the + // downstream listener is the bound HTTP/2 server duplex, which holds + // no aggregate-ledger reservation of its own for these bytes, so + // this release cannot overlap a second reservation for the same + // bytes. Contrast the readiness gate's own `readiness_replay` + // handoff, which releases first because its downstream listener (the + // preface scanner) does take its own reservation for the same bytes. + releaseReplayTokens(); + }, + onExit: (listener: (exit: { exitCode: number | null }) => void) => channel.onExit(listener), + stop: () => channel.stop(), + close: () => channel.close(), + }, + replayOverflowed: () => replayOverflow, + /** + * Release every held `http2_preface_scan` token and drop the scan + * buffer, for a caller-side terminal path this function itself never + * reaches — the bound readiness timeout elapsing while the scan is + * still searching, with no preface found and no cap or ledger refusal + * of its own. A call after the preface already matched, or after the + * cap or the ledger already failed the scan closed, is a no-op: both + * paths already released the scan tokens themselves. + */ + disposeScanBuffer: (): void => { + if (sawPreface || failed) return; + failed = true; + scanBuf.reset(); + scanSearchFrom = 0; + releaseScanTokens(); + }, + }; +} + +/** The terminal outcome of the preface scan: either the client preface + * appeared inside the bounded readiness buffer, or it did not. */ +type Http2PrefaceScanResult = "found" | "missing"; + +/** + * Wait for {@link createHttp2PrefaceScanningChannel} to settle: either the + * preface appears, or the scan passes the bound with no match, or the bound + * readiness timeout elapses first. Reapplying the readiness timeout here + * keeps one configured value for both the READY-line wait and this + * immediately-following preface wait; the task adds no new timeout setting. + * Returns the scanning channel alongside the settled result, so the caller + * binds the HTTP/2 server to it only on a `found` result. On a `found` + * result, the caller must still read `replayOverflowed()`: the post-preface + * buffer can overflow its cap or its ledger reservation after the preface + * settles as `found` and before the caller binds a downstream listener. + */ +function scanForHttp2ClientPreface( + channel: CommandManagedDuplexChannel, + options: { capBytes: number; timeoutMs: number; ledger?: DuplexAggregateByteLedger | null }, +): { + scanned: CommandManagedDuplexChannel; + settled: Promise; + replayOverflowed: () => boolean; +} { + let resolveSettled!: (result: Http2PrefaceScanResult) => void; + let settledOnce = false; + const settled = new Promise((resolve) => { + resolveSettled = resolve; + }); + // Reassigned to the real function once `createHttp2PrefaceScanningChannel` + // returns, below. `settle` can only actually run after that point (the + // timer fires later, and `onMissing`/`onFound` fire from inside the + // channel's own `onData`, which registers after this call), so the + // placeholder never runs for real. + let disposeScanBuffer: () => void = () => {}; + const settle = (result: Http2PrefaceScanResult): void => { + if (settledOnce) return; + settledOnce = true; + clearTimeout(timer); + // The bound readiness timeout can elapse while the scan still searches, + // with no preface found and no cap or ledger refusal of its own. That + // path holds no other cleanup, so release its `http2_preface_scan` + // tokens here. A `found` result, or a `missing` result the scan itself + // already failed closed, is a no-op inside `disposeScanBuffer`. + if (result === "missing") disposeScanBuffer(); + resolveSettled(result); + }; + const timer = setTimeout(() => settle("missing"), options.timeoutMs); + timer.unref?.(); + const scan = createHttp2PrefaceScanningChannel(channel, { + capBytes: options.capBytes, + onFound: () => settle("found"), + onMissing: () => settle("missing"), + ledger: options.ledger, + }); + disposeScanBuffer = scan.disposeScanBuffer; + return { scanned: scan.channel, settled, replayOverflowed: scan.replayOverflowed }; +} + +/** + * Test-only surface for {@link scanForHttp2ClientPreface}. A test drives the + * post-preface replay cap and ledger charge across every terminal path + * without the whole bridge. Production code never reads this export. + */ +export const __http2PrefaceScanTesting = { + scanForHttp2ClientPreface: ( + channel: CommandManagedDuplexChannel, + options: { capBytes: number; timeoutMs: number; ledger?: DuplexAggregateByteLedger | null }, + ) => scanForHttp2ClientPreface(channel, options), + readScanSearchUnits: (): number => http2PrefaceScanSearchUnits, + resetScanSearchUnits: (): void => { + http2PrefaceScanSearchUnits = 0; + }, + readScanBufferGrowthCopyUnits: (): number => http2PrefaceScanBufferGrowthCopyUnits, + resetScanBufferGrowthCopyUnits: (): void => { + http2PrefaceScanBufferGrowthCopyUnits = 0; + }, + readReplayBufferGrowthCopyUnits: (): number => http2PrefaceReplayBufferGrowthCopyUnits, + resetReplayBufferGrowthCopyUnits: (): void => { + http2PrefaceReplayBufferGrowthCopyUnits = 0; + }, +}; + +/** + * The terminal run disposition for the `http2_v1` path, in the same shape as + * {@link DuplexBrokerRunDisposition}. A `failed` disposition means a terminal + * loss ordered before an orderly completion, so the run must not report + * success. + */ +interface Http2RunDispositionLatch { + readonly disposition: DuplexBrokerRunDisposition; + /** + * Record a terminal loss. Returns `true` when the call flipped the + * disposition to failed; returns `false` when a loss or an orderly + * completion already latched, so the run already has its terminal result. + * The first recorded loss latches — a later call never overrides it. + */ + recordLoss(reason: DuplexLossReason): boolean; + /** Mark the host-observed orderly completion of the agent turn. A loss that + * already latched keeps the failure. */ + markOrderlyCompletion(): void; + /** Atomically mark the orderly completion and read the disposition. */ + settleRunDisposition(): DuplexBrokerRunDisposition; +} + +function createHttp2RunDispositionLatch(): Http2RunDispositionLatch { + let lossOrdered = false; + let lossReason: DuplexLossReason | null = null; + let completionOrdered = false; + const markOrderlyCompletion = (): void => { + if (completionOrdered || lossOrdered) return; + completionOrdered = true; + }; + return { + get disposition(): DuplexBrokerRunDisposition { + return { failed: lossOrdered, lossReason }; + }, + recordLoss(reason: DuplexLossReason): boolean { + if (lossOrdered || completionOrdered) return false; + lossOrdered = true; + lossReason = reason; + return true; + }, + markOrderlyCompletion, + settleRunDisposition(): DuplexBrokerRunDisposition { + markOrderlyCompletion(); + return { failed: lossOrdered, lossReason }; + }, + }; } /** @@ -2609,21 +3070,36 @@ async function ensureSandboxRunLogDirectory(input: { * and before it decodes a candidate line, so an over-cap prefix never reaches READY * acceptance. The cap check has priority over READY acceptance on every path. */ -// The count of the pre-READY newline-scan work, in UTF-16 code units. Each -// search adds the number of code units it can read. A test reads this count to -// prove the scan work stays linear in the bytes received. Production code never -// reads this count. +// The count of the pre-READY newline-scan work, in bytes. Each search adds the +// number of bytes it can read. A test reads this count to prove the scan work +// stays linear in the bytes received. Production code never reads this count. let duplexReadinessNewlineScanUnits = 0; +// The count of bytes `appendReadinessBytes` copies while it grows the pre-READY +// buffer's backing storage. A one-copy-per-fragment approach copies the whole +// retained buffer on every fragment, so this count grows quadratically in the +// number of fragments. The doubling-growth approach copies only on a +// reallocation, so this count stays linear in the bytes received. A test reads +// this count to prove the growth work does not regress to the quadratic shape. +// Production code never reads this count. +let duplexReadinessBufferGrowthCopyUnits = 0; + +/** The newline byte. The readiness buffer is a byte buffer, not a string. */ +const READINESS_NEWLINE_BYTE = 0x0a; +/** The opening-brace byte. The bracketed-paste retry scans for it, not a string. */ +const READINESS_OPEN_BRACE_BYTE = 0x7b; +/** The one shared empty buffer. The gate starts and resets `buffer` to it. */ +const READINESS_EMPTY_BUFFER = Buffer.alloc(0); + /** * Find the first newline in `buffer` at or after index `from` and return its - * index, or -1. `String#indexOf` reads the code units from `from` up to the - * newline it finds, or to the end of the buffer when it finds none. This helper - * counts that real scan distance for a test. The count stays linear in the bytes + * index, or -1. `Buffer#indexOf` reads the bytes from `from` up to the newline + * it finds, or to the end of the buffer when it finds none. This helper counts + * that real scan distance for a test. The count stays linear in the bytes * received when the caller advances `from` past each newline it consumes. */ -function findNewlineFrom(buffer: string, from: number): number { - const newlineIndex = buffer.indexOf("\n", from); +function findNewlineFrom(buffer: Buffer, from: number): number { + const newlineIndex = buffer.indexOf(READINESS_NEWLINE_BYTE, from); const scanned = newlineIndex === -1 ? buffer.length - from : newlineIndex - from + 1; duplexReadinessNewlineScanUnits += scanned; return newlineIndex; @@ -2639,6 +3115,10 @@ export const __duplexReadinessTesting = { resetNewlineScanUnits: (): void => { duplexReadinessNewlineScanUnits = 0; }, + readBufferGrowthCopyUnits: (): number => duplexReadinessBufferGrowthCopyUnits, + resetBufferGrowthCopyUnits: (): void => { + duplexReadinessBufferGrowthCopyUnits = 0; + }, // Build one readiness gate over a supplied channel, so a test can drive the // readiness-replay reservation lifecycle across every terminal path without the // whole bridge. Production code never reads this factory. @@ -2675,8 +3155,8 @@ interface DuplexReadinessGate { */ disposePendingReplay(): void; /** - * Test-only. Report the length of the retained pre-READY buffer, in UTF-16 code - * units. A test reads this to prove the gate drops the pre-READY buffer on READY + * Test-only. Report the length of the retained pre-READY buffer, in bytes. A + * test reads this to prove the gate drops the pre-READY buffer on READY * acceptance, so the process does not retain the sandbox-controlled prefix. * Production code does not read this. */ @@ -2734,20 +3214,56 @@ function createDuplexReadinessGate( } replayTokens.length = 0; } - // The raw bytes the host reads before the READY frame completes. The buffer is - // append-only, so the O(1) cap check on `buffer.length` stays valid. - let buffer = ""; + // The raw bytes the host reads before the READY frame completes. `buffer` is + // append-only and always a zero-copy view over the used prefix of `storage`, + // so the O(1) cap check on `buffer.length` stays valid. + let buffer: Buffer = READINESS_EMPTY_BUFFER; + // The backing storage for `buffer`. `appendReadinessBytes` grows this by + // doubling its capacity, instead of copying the whole retained buffer on + // every fragment. See `appendReadinessBytes` for why this bounds the total + // copy work. + let storage: Buffer = READINESS_EMPTY_BUFFER; // The start index of the current line in `buffer`. A leading blank line // advances this cursor past its newline without a buffer copy. let lineStart = 0; // The next index to search for a newline. The gate scans from here, so each - // code unit is read at most one time for the newline search. + // byte is read at most one time for the newline search. let scanFrom = 0; + + /** + * Append `chunk` to the pre-READY buffer without copying the bytes already + * retained. A sender that trickles the handshake in many small fragments + * (a slow socket, a byte-at-a-time PTY echo) would otherwise force one full + * copy of the whole retained buffer per fragment: with `buffer` growing + * toward the {@link DUPLEX_READINESS_BUFFER_CAP_BYTES} cap, that is + * quadratic work in the number of fragments. This instead grows `storage` + * by doubling its capacity only when the current capacity runs out, so the + * backing store reallocates and copies a logarithmic number of times, and + * each append copies only the incoming chunk. The used length still reads + * in O(1) through `buffer.length`, so every cap check and slice below stays + * unchanged. + */ + function appendReadinessBytes(chunk: Uint8Array): void { + const usedLength = buffer.length; + const neededLength = usedLength + chunk.byteLength; + if (neededLength > storage.length) { + let nextCapacity = storage.length === 0 ? chunk.byteLength : storage.length * 2; + while (nextCapacity < neededLength) { + nextCapacity *= 2; + } + const grown = Buffer.allocUnsafe(nextCapacity); + storage.copy(grown, 0, 0, usedLength); + duplexReadinessBufferGrowthCopyUnits += usedLength; + storage = grown; + } + storage.set(chunk, usedLength); + buffer = storage.subarray(0, neededLength); + } // The bytes that followed the READY frame, held until the broker binds. - let pending = ""; + let pending: Uint8Array = READINESS_EMPTY_BUFFER; // The exit that arrived after READY but before the broker bound, if any. let pendingExit: { exitCode: number | null } | null = null; - let dataSink: ((chunk: string) => void) | null = null; + let dataSink: ((chunk: Uint8Array) => void) | null = null; let exitSink: ((exit: { exitCode: number | null }) => void) | null = null; let resolveReady!: (result: DuplexReadinessResult) => void; const ready = new Promise((resolve) => { @@ -2784,17 +3300,20 @@ function createDuplexReadinessGate( // with the aggregate marker, because `ready` already resolved before this // synchronous post-READY chunk arrived. if (ledger) { - const token = ledger.reserve("readiness_replay", Buffer.byteLength(chunk, "utf8")); + const token = ledger.reserve("readiness_replay", chunk.byteLength); if (!token) { replayOverflow = true; - pending = ""; + pending = READINESS_EMPTY_BUFFER; releaseReplayTokens(); channel.stop(); return; } replayTokens.push(token); } - pending += chunk; + // Copy a first chunk instead of aliasing the caller's `Uint8Array`, so a + // channel that reuses its delivered buffer across calls cannot corrupt the + // bytes this gate holds for the broker replay. + pending = pending.length === 0 ? Buffer.from(chunk) : Buffer.concat([pending, chunk]); return; } if (settled) { @@ -2803,12 +3322,12 @@ function createDuplexReadinessGate( // never grows the buffer after the gate settles. return; } - // Reserve the exact UTF-8 bytes of this chunk against the aggregate ledger - // before the gate retains it. The pre-READY buffer holds untrusted bytes, so - // a flood counts toward the process aggregate ceiling. A rejection fails + // Reserve the exact bytes of this chunk against the aggregate ledger before + // the gate retains it. The pre-READY buffer holds untrusted bytes, so a + // flood counts toward the process aggregate ceiling. A rejection fails // closed: the gate retains nothing more and falls back to the file bridge. if (ledger) { - const token = ledger.reserve("readiness_buffer", Buffer.byteLength(chunk, "utf8")); + const token = ledger.reserve("readiness_buffer", chunk.byteLength); if (!token) { finish({ ok: false, reason: "aggregate_bytes_exceeded" }); return; @@ -2816,9 +3335,11 @@ function createDuplexReadinessGate( retainedTokens.push(token); } // Append the new bytes and continue the newline search from `scanFrom`, the - // first index not yet examined. Each code unit is read at most one time for - // the search, so the total scan work stays linear in the bytes received. - buffer += chunk; + // first index not yet examined. Each byte is read at most one time for the + // search, and `appendReadinessBytes` copies at most the incoming chunk, so + // the total work stays linear in the bytes received, not in the number of + // fragments they arrive in. + appendReadinessBytes(chunk); for (;;) { const newlineIndex = findNewlineFrom(buffer, scanFrom); if (newlineIndex === -1) { @@ -2829,13 +3350,8 @@ function createDuplexReadinessGate( // READY frame, so finish with protocol contamination. The retained // skipped lines count against the cap; that is acceptable and fail-closed. // - // Gate on buffer.length, the UTF-16 code-unit count, which is O(1). - // Buffer.byteLength is O(n), so a byte check on every newline-less chunk - // makes the pre-READY window quadratic in the bytes received. The UTF-8 - // byte length is greater than or equal to the UTF-16 code-unit count for - // every string, so this check never fires before the byte cap is truly - // exceeded. It can fire late by at most a factor of 3, so peak buffer - // memory stays bounded near 3 MB. + // `buffer` is a byte buffer, so `buffer.length` is the exact byte count, + // read in O(1). if (buffer.length > DUPLEX_READINESS_BUFFER_CAP_BYTES) { finish({ ok: false, reason: "protocol_contamination" }); } @@ -2876,7 +3392,7 @@ function createDuplexReadinessGate( // strict decode over the remainder of the line, and readiness still // authenticates on the nonce below, so a prefix cannot forge a frame or // smuggle a second one — it can only be discarded. - const braceIndex = line.indexOf("{"); + const braceIndex = line.indexOf(READINESS_OPEN_BRACE_BYTE); if (braceIndex > 0) { decoded = decodeDuplexLine(line.slice(braceIndex)); } @@ -2894,18 +3410,27 @@ function createDuplexReadinessGate( // keeps the transient charge equal to the suffix, not the sum of the dropped // prefix and the retained suffix. The two steps run in one synchronous // section, so no other route can take the freed bytes in between. - const suffix = buffer.slice(newlineIndex + 1); - // Drop the original pre-READY buffer now. The gate keeps only the - // retained suffix as `pending`, and it charges that suffix under - // `readiness_replay` below. If the gate keeps the buffer, the process - // retains the full sandbox-controlled string while the ledger counts - // only the suffix, so aggregate retention passes the ceiling. This clear - // also covers the broker handoff and the replay disposal. Both run later - // and read no buffer bytes. - buffer = ""; + // + // Copy the suffix instead of slicing it off `buffer`. `buffer` is a view + // over `storage`, and `storage`'s capacity can run ahead of the bytes in + // use (the doubling growth in `appendReadinessBytes` over-provisions it). + // A slice would keep that whole over-provisioned allocation alive, so the + // process would retain more physical bytes than the ledger charges under + // `readiness_replay`. The copy is exactly `suffix.length` bytes, one time, + // not a per-fragment cost. + const suffix = Buffer.from(buffer.subarray(newlineIndex + 1)); + // Drop the original pre-READY buffer and its backing storage now. The + // gate keeps only the retained suffix as `pending`, and it charges that + // suffix under `readiness_replay` below. If the gate keeps `storage`, the + // process retains the full sandbox-controlled bytes while the ledger + // counts only the suffix, so aggregate retention passes the ceiling. This + // clear also covers the broker handoff and the replay disposal. Both run + // later and read no buffer bytes. + buffer = READINESS_EMPTY_BUFFER; + storage = READINESS_EMPTY_BUFFER; releaseReadinessBufferTokens(); if (ledger && suffix.length > 0) { - const token = ledger.reserve("readiness_replay", Buffer.byteLength(suffix, "utf8")); + const token = ledger.reserve("readiness_replay", suffix.byteLength); if (!token) { // The retained suffix passes the aggregate ceiling. Fail closed: drop // the suffix and fall back to the file bridge with the aggregate marker. @@ -2946,18 +3471,24 @@ function createDuplexReadinessGate( }); const brokerChannel: CommandManagedDuplexChannel = { - write: (data: string) => channel.write(data), - onData: (listener: (chunk: string) => void) => { + write: (data: Uint8Array) => channel.write(data), + onData: (listener: (chunk: Uint8Array) => void) => { dataSink = listener; if (pending.length > 0) { const replay = pending; - pending = ""; + pending = READINESS_EMPTY_BUFFER; + // Release every readiness-replay token before the synchronous handoff + // to the broker, not after. The broker (the HTTP/2 preface scanner) + // charges its own reservation for these same bytes inside + // `listener(replay)` below, under a different owner. Releasing first + // keeps the ledger's momentary peak at the real retained bytes, not + // double them: the release and the broker's reserve both run inside + // this one synchronous call, with no `await` between them, so no + // other route can claim the freed capacity in between. + releaseReplayTokens(); listener(replay); + return; } - // Release every readiness-replay token exactly once, after the synchronous - // handoff to the broker. The broker charges its own decode retention inside - // the `listener(replay)` call above, so the release here never opens an - // admission gap for the same retained bytes. releaseReplayTokens(); }, onExit: (listener: (exit: { exitCode: number | null }) => void) => { @@ -2977,7 +3508,7 @@ function createDuplexReadinessGate( brokerChannel, replayOverflowed: () => replayOverflow, disposePendingReplay: () => { - pending = ""; + pending = READINESS_EMPTY_BUFFER; releaseReplayTokens(); }, retainedReadinessBufferLength: () => buffer.length, @@ -3032,6 +3563,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { // `duplexCommandStream` is exactly `true`. Any other value of either gate // selects the file bridge. The caller reads this from the experimental instance // setting `enableSandboxDuplexBridge`. The default is the file bridge. + // HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. enableSandboxDuplexBridge?: boolean | null; // The deadline for the duplex readiness handshake, in milliseconds. On a // timeout the host closes the partial channel and selects the file bridge. The @@ -3126,6 +3658,9 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { const duplexObservability = createDuplexObservability({ recorder: input.duplexObservabilityRecorder ?? undefined, providerKey: duplexProviderKey, + // http2_v1 is the one active non-file transport now; every non-file + // record this facade produces stamps the `http2` transport value. + transport: "http2", }); // PAPERCLIP_BRIDGE_DEBUG opts into verbose stdout logs of every bridge proxy @@ -3267,6 +3802,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { // experimental setting is exactly `true`, the resolved capability // `duplexCommandStream` is exactly `true`, and the runner exposes the duplex // channel. Any other value of either gate selects the file bridge below. + // HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. const duplexRequested = input.enableSandboxDuplexBridge === true; const capabilityGranted = "effectiveCapabilities" in target && @@ -3327,7 +3863,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { shellCommand, }); const gatewayEnv: Record = { - PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE, + PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE, PAPERCLIP_BRIDGE_TOKEN: bridgeToken, PAPERCLIP_BRIDGE_HOST: "127.0.0.1", PAPERCLIP_BRIDGE_PORT: String(assignedPort), @@ -3405,93 +3941,144 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { "[paperclip] Sandbox duplex readiness replay exceeded the aggregate byte ceiling (aggregate_bytes_exceeded). Using the file bridge.\n", ); } else { - // Readiness passed. Construct the broker inside the guarded region, so a - // construction throw closes the channel within the cleanup budget and - // selects the file bridge. The broker enforces the same route allowlist - // as the file bridge, then forwards each request with the real token and - // the run id. The agent environment below receives the bridge URL and - // token only now, after readiness passed. - let broker: DuplexBridgeBroker | null = null; - try { - broker = await createDuplexBridgeBroker({ - channel: gate.brokerChannel, - // Derive the nested budgets from the forward budget, so any forward - // budget keeps the nested order the broker asserts at construction. - budgets: deriveNestedDuplexBrokerBudgets(forwardTimeoutMs), - forwardRequest: async ( - request: DuplexRequestFrame, - options: { signal: AbortSignal; body: ReassembledBody }, - ): Promise => { - const denialReason = authorizeSandboxCallbackBridgeRequestWithRoutes(request); - if (denialReason) { - return { - status: 403, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ error: denialReason }), - }; - } - // Apply the header allowlist on the host, next to the route check. - // The host is the trust boundary: the sandbox controls the frame - // headers, so the host drops every header outside the allowlist - // before the authenticated forward. The forward then applies the - // real token and the run id. - const sanitizedRequest = { - ...request, - headers: sanitizeSandboxCallbackBridgeHeaders(request.headers), - }; - // Suppress the per-request debug log on the duplex path, so no route - // or query rides a log line here. Stream the reassembled request - // body, so a spilled body never loads into memory on the host. - return forwardBridgeRequest(sanitizedRequest, options.signal, { - suppressDebugLog: true, - reassembledBody: options.body, + // Readiness passed. The gate retained every byte that followed the + // accepted READY line. Scan those retained bytes for the HTTP/2 + // client connection preface — the scan window opens only now, after + // the gate accepted the nonce — and start the HTTP/2 session at that + // offset, inclusive. A missing preface inside the bounded readiness + // buffer aborts the open; the run falls back to `queue_v1` exactly + // one time (accepted security fix 6). + const openedChannel = channel; + const prefaceScan = scanForHttp2ClientPreface(gate.brokerChannel, { + capBytes: DUPLEX_READINESS_BUFFER_CAP_BYTES, + timeoutMs: readinessTimeoutMs, + // Inject the same host-process aggregate byte ledger the readiness + // gate charges, so the post-preface pre-bind buffer counts toward + // the same aggregate ceiling. + ledger: duplexAggregateByteLedger, + }); + const prefaceResult = await prefaceScan.settled; + if (prefaceResult === "missing" || prefaceScan.replayOverflowed()) { + // Fail closed, the same shape as a readiness failure: close the + // partial channel inside the cleanup budget, then select the file + // bridge. No HTTP/2 server ever bound to this channel, so no + // request reached it or any endpoint. + gate.disposePendingReplay(); + await closeDuplexChannelWithinBudget(openedChannel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS); + if (prefaceResult === "missing") { + duplexChannelOpen.fallback("preface_missing"); + await onLog( + "stderr", + "[paperclip] Sandbox HTTP/2 client preface did not appear inside the bounded readiness buffer (preface_missing). Using the file bridge.\n", + ); + } else { + duplexChannelOpen.fallback("aggregate_bytes_exceeded"); + await onLog( + "stderr", + "[paperclip] Sandbox HTTP/2 post-preface buffer exceeded the aggregate byte ceiling (aggregate_bytes_exceeded). Using the file bridge.\n", + ); + } + } else { + // The run disposition latch for the http2_v1 path, in the same + // shape the retired duplex_v1 broker exposed. A loss ordered before + // an orderly completion reports a failure with the typed loss + // reason; every other state reports a success. + const dispositionLatch = createHttp2RunDispositionLatch(); + // Set before the first await inside the forward handler, so a loss + // that lands mid-request still classifies as `post_dispatch`. + let anyStreamDispatched = false; + + const recordHttp2Loss = (event: Http2TelemetryEventName): void => { + const reason = mapHttp2EventToDuplexLossReason(event); + const disposedNow = dispositionLatch.recordLoss(reason); + if (!disposedNow && (reason === "provider_exit" || reason === "transport_closed")) { + // A clean channel end that orders after a host-observed orderly + // completion is a normal teardown, not a loss: the run already + // completed. Emit no loss telemetry and no log line for it, the + // same policy the retired duplex_v1 broker applied. + return; + } + const lossClass = anyStreamDispatched ? "post_dispatch" : "pre_dispatch"; + duplexObservability.recordLoss(lossClass, reason); + void onLog("stderr", `[paperclip] Sandbox HTTP/2 channel lost (${reason}). The run fails.\n`); + }; + + // The forward handler applies the real host token and the run id + // through the existing `forwardBridgeRequest` — the same function + // the file bridge uses. The route allowlist, the header allowlist, + // and the per-request debug-log suppression already ran inside + // `createHttp2BridgeServer`'s own stream handler before this call. + // It records the request span with the same latency-and-outcome + // shape the retired duplex_v1 broker recorded: `ok` for any + // delivered host response (any status), `error` only when the + // forward call itself throws. + const http2ForwardRequest: Http2BridgeForwardHandler = async (request) => { + anyStreamDispatched = true; + const dispatchStartMs = Date.now(); + try { + const result = await forwardBridgeRequest( + { + method: request.method, + path: request.pathname, + query: request.query, + headers: request.headers, + body: request.body.toString("utf8"), + }, + undefined, + { suppressDebugLog: true }, + ); + duplexObservability.recordRequest({ latencyMs: Date.now() - dispatchStartMs, outcome: "ok" }); + return { status: result.status, headers: result.headers, body: result.body }; + } catch (error) { + duplexObservability.recordRequest({ latencyMs: Date.now() - dispatchStartMs, outcome: "error" }); + throw error; + } + }; + + const http2Server = createHttp2BridgeServer({ + bridgeToken, + forwardRequest: http2ForwardRequest, + onGoaway: () => recordHttp2Loss("session_goaway"), + onSessionError: () => recordHttp2Loss("session_error"), + }); + // Combine the loss hook with the forward-to-Duplex handoff inside + // one `onExit` registration: the channel primitive holds exactly + // one listener slot, and `bindChannel` below registers the one that + // ends the wrapped `Duplex`. + // + // This object's `stop()` is also the real sandbox-side effect of + // the post-bind read backpressure bound `bindChannel` applies + // (`wrapDuplexChannelAsNodeDuplex` in `http2-bridge-server.ts`): + // this raw provider channel exposes no pause, so once the bounded + // read queue there overflows, it calls `stop()` through this exact + // chain, down to `prefaceScan.scanned.stop()` and on to the real + // channel, instead of letting sandbox-controlled bytes grow host + // memory with no bound. + const channelForHttp2Server: CommandManagedDuplexChannel = { + write: (data: Uint8Array) => prefaceScan.scanned.write(data), + onData: (listener: (chunk: Uint8Array) => void) => prefaceScan.scanned.onData(listener), + onExit: (listener: (exit: { exitCode: number | null }) => void) => { + prefaceScan.scanned.onExit((exit) => { + recordHttp2Loss("channel_exit"); + listener(exit); }); }, - // The duplex path emits only the fixed transport telemetry. It passes no - // free-form logger, so no raw provider error rides a log line here. - telemetry: duplexObservability, - // Surface a terminal channel loss on the run log. The broker latches - // the failure on its ordered lifecycle; the host names only the typed, - // closed loss reason here, never the raw provider message. The caller - // reads the latched disposition through `readRunDisposition` at the - // run-disposition seam. - onLoss: (record) => { - void onLog( - "stderr", - `[paperclip] Sandbox duplex channel lost (${typedDuplexLossReason(record.reason)}). The run fails.\n`, - ); - }, - // Inject the one host-process aggregate byte ledger. The broker - // reserves the retained request-frame, request-payload, and no-replay - // set-entry bytes against it, so the aggregate retained bytes across all - // live routes stay under the ceiling. It is the same object the - // response-body reader reads off the stamped target above. - duplexAggregateByteLedger, - }); - } catch { - // The broker construction failed, so no broker owns the channel. The - // broker never bound, so it never released the pending replay reservation; - // release it here. Then close the channel within the cleanup budget and - // select the file bridge. The log line names no raw error, so no raw error - // rides a log line on the duplex path. - gate.disposePendingReplay(); - await closeDuplexChannelWithinBudget(channel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS); - duplexChannelOpen.fallback("broker_construction_failed"); - await onLog( - "stderr", - "[paperclip] Could not start the sandbox duplex broker. Using the file bridge.\n", - ); - } - if (broker) { - const activeBroker = broker; - activeBroker.start(); + stop: () => prefaceScan.scanned.stop(), + close: () => prefaceScan.scanned.close(), + }; + const boundDuplex = http2Server.bindChannel(channelForHttp2Server); + // Also catches the `Duplex` this wrapper destroys when the bounded + // read backpressure queue overflows post-bind, so that loss still + // reaches `recordHttp2Loss` the same way any other write fault does. + boundDuplex.on("error", () => recordHttp2Loss("write_error")); + duplexChannelOpen.ready(); await onLog( "stdout", - "[paperclip] Sandbox duplex transport ready; serving the host-assigned origin.\n", + "[paperclip] Sandbox HTTP/2 transport ready; serving the host-assigned origin.\n", ); - // Stream run logs on the duplex path with the same gate and the same - // log line as the file path. The duplex path starts no file-bridge + // Stream run logs on the http2 path with the same gate and the same + // log line as the file path. The http2 path starts no file-bridge // worker, so create the log directory before the tail starts. let duplexRunLogTail: SandboxRunLogTailFactory | null = null; if (target.transport === "sandbox" && target.streamRunLogs !== false) { @@ -3515,32 +4102,21 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { env: { PAPERCLIP_API_URL: sandboxOrigin, PAPERCLIP_API_KEY: bridgeToken, - PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE, + PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE, }, runLogTail: duplexRunLogTail, - // Read the broker's ordered lifecycle latch at the run-disposition - // seam. A loss ordered before an orderly completion reports a failure - // with the typed loss reason; every other state reports a success. - readRunDisposition: (): DuplexBrokerRunDisposition => activeBroker.runDisposition, + readRunDisposition: (): DuplexBrokerRunDisposition => dispositionLatch.disposition, // Atomically read the latch and mark the orderly completion for the // ACP success-eligible terminal, so no await separates the read from // the mark and a teardown loss cannot slip in between. - settleRunDisposition: (): DuplexBrokerRunDisposition => activeBroker.settleRunDisposition(), - // Surface the broker's orderly-completion mark to the run-disposition - // seam. The seam marks the completion for a success-eligible terminal, - // so a teardown loss after the completion stays a normal teardown. - markOrderlyCompletion: (): void => activeBroker.markOrderlyCompletion(), + settleRunDisposition: (): DuplexBrokerRunDisposition => dispositionLatch.settleRunDisposition(), + markOrderlyCompletion: (): void => dispositionLatch.markOrderlyCompletion(), stop: async () => { - // Close the channel before lease release. The broker sends an orderly - // close and releases the route, then stops the child, so no live - // provider session remains when the caller releases the lease. - await activeBroker.close(); - activeBroker.stop(); - // A channel that did not reach the `closed` state may leave a live - // provider session, so record one session leak. - if (activeBroker.state !== "closed") { - duplexObservability.recordSessionLeak(); - } + // Close the HTTP/2 server's sessions, then the channel, before + // lease release, so no live provider session remains when the + // caller releases the lease. + await http2Server.close(); + await closeDuplexChannelWithinBudget(openedChannel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS); await bridgeAsset.cleanup(); }, }; diff --git a/packages/adapter-utils/src/http2-bridge-server.test.ts b/packages/adapter-utils/src/http2-bridge-server.test.ts new file mode 100644 index 0000000000..4581bcf196 --- /dev/null +++ b/packages/adapter-utils/src/http2-bridge-server.test.ts @@ -0,0 +1,1086 @@ +import { duplexPair } from "node:stream"; +import type { Duplex } from "node:stream"; +import http2 from "node:http2"; + +import { describe, expect, it } from "vitest"; + +import { + buildHttp2BridgeForwardUrl, + classifyStreamAgainstGoaway, + createHttp2BridgeServer, + parseCanonicalBridgeRequestPath, + wrapDuplexChannelAsNodeDuplex, + DEFAULT_HTTP2_BRIDGE_PING_INTERVAL_MS, + DEFAULT_HTTP2_BRIDGE_PING_STALL_MS, + HTTP2_BRIDGE_ENABLE_PUSH, + HTTP2_BRIDGE_HEADER_TABLE_SIZE, + HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS, + HTTP2_BRIDGE_MAX_DEFLATE_DYNAMIC_TABLE_SIZE, + HTTP2_BRIDGE_MAX_HEADER_LIST_PAIRS, + HTTP2_BRIDGE_MAX_HEADER_LIST_SIZE, + HTTP2_BRIDGE_MAX_SESSION_INVALID_FRAMES, + HTTP2_BRIDGE_MAX_SESSION_MEMORY, + HTTP2_BRIDGE_MAX_SESSION_REJECTED_STREAMS, + HTTP2_BRIDGE_SERVER_OPTIONS, + HTTP2_BRIDGE_STREAM_RESET_BURST, + HTTP2_BRIDGE_STREAM_RESET_RATE, + type Http2BridgeForwardRequest, + type Http2BridgeForwardResult, + type Http2BridgeGoawayRecord, +} from "./http2-bridge-server.js"; +import { createSandboxHttp2BridgeGateway } from "./sandbox-callback-bridge.js"; +import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js"; + +/** + * Unit harness for the host HTTP/2 server and the sandbox HTTP/2 client + * gateway. Every test connects the pair over one paired in-memory `Duplex` + * (`node:stream`'s `duplexPair`) — no real TCP socket and no spawned sandbox + * process. This proves the two halves speak one wire-compatible HTTP/2 + * session with no network in between. + */ + +const BRIDGE_TOKEN = "test-bridge-token-fixed-length-32"; + +/** Wrap one side of a paired `Duplex` as a minimal fake `CommandManagedDuplexChannel`. */ +function fakeChannelFromDuplex(duplex: Duplex): CommandManagedDuplexChannel { + const dataListeners: Array<(chunk: Uint8Array) => void> = []; + const exitListeners: Array<(exit: { exitCode: number | null; transportClosed?: boolean }) => void> = []; + duplex.on("data", (chunk: Buffer) => { + for (const listener of dataListeners) listener(chunk); + }); + duplex.on("end", () => { + for (const listener of exitListeners) listener({ exitCode: null, transportClosed: true }); + }); + return { + write: (data) => { + duplex.write(Buffer.from(data)); + }, + onData: (listener) => { + dataListeners.push(listener); + }, + onExit: (listener) => { + exitListeners.push(listener); + }, + stop: () => { + duplex.destroy(); + }, + close: async () => { + duplex.end(); + }, + }; +} + +interface TestPairOptions { + forwardRequest?: (request: Http2BridgeForwardRequest) => Promise; + bridgeToken?: string; + pingIntervalMs?: number; + pingStallMs?: number; + requestBodyTimeoutMs?: number; + requestBodyMaxLifetimeMs?: number; + requestBodyLifetimeCeilingMs?: number; + closeGraceMs?: number; + onGoaway?: (record: Http2BridgeGoawayRecord) => void; + onSessionError?: (error: Error) => void; + onSession?: (session: http2.ServerHttp2Session) => void; +} + +/** Bind the host server to one side of a fresh paired in-memory `Duplex`. + * Returns the other side, unbound, for a caller to connect a client to. */ +function bindTestServer(options: TestPairOptions = {}) { + const bridgeToken = options.bridgeToken ?? BRIDGE_TOKEN; + const [serverSide, clientSide] = duplexPair(); + const forwardRequest = + options.forwardRequest ?? + (async (request: Http2BridgeForwardRequest) => ({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ echoedMethod: request.method, echoedPath: request.pathname }), + })); + const handle = createHttp2BridgeServer({ + bridgeToken, + forwardRequest, + pingIntervalMs: options.pingIntervalMs, + pingStallMs: options.pingStallMs, + requestBodyTimeoutMs: options.requestBodyTimeoutMs, + requestBodyMaxLifetimeMs: options.requestBodyMaxLifetimeMs, + requestBodyLifetimeCeilingMs: options.requestBodyLifetimeCeilingMs, + closeGraceMs: options.closeGraceMs, + onGoaway: options.onGoaway, + onSessionError: options.onSessionError, + onSession: options.onSession, + }); + const channel = fakeChannelFromDuplex(serverSide); + handle.bindChannel(channel); + return { handle, bridgeToken, clientSide, serverSide }; +} + +/** Bind the server, then connect the sandbox HTTP/2 client gateway to the + * other side of the same paired in-memory `Duplex`. */ +function createTestPair(options: TestPairOptions = {}) { + const { handle, bridgeToken, clientSide, serverSide } = bindTestServer(options); + const gateway = createSandboxHttp2BridgeGateway({ + bridgeToken, + createConnection: () => clientSide, + }); + return { handle, gateway, bridgeToken, clientSide, serverSide }; +} + +/** Open a raw HTTP/2 client session directly against one side of the pair, + * bypassing the sandbox gateway. Some tests need direct stream control (an + * explicit RST_STREAM, an explicit GOAWAY) the gateway's `forwardRequest` + * abstraction does not expose. */ +function connectRawClient(clientSide: Duplex): http2.ClientHttp2Session { + return http2.connect("http://bridge.internal", { createConnection: () => clientSide }); +} + +describe("createHttp2BridgeServer + createSandboxHttp2BridgeGateway", () => { + it("test_one_session_over_a_fake_channel_forwards_a_request", async () => { + const { handle, gateway } = createTestPair(); + try { + const response = await gateway.forwardRequest({ + method: "GET", + path: "/api/agents/me", + query: "", + headers: {}, + body: Buffer.alloc(0), + receivedToken: BRIDGE_TOKEN, + }); + expect(response.status).toBe(200); + expect(JSON.parse(response.body.toString("utf8"))).toEqual({ + echoedMethod: "GET", + echoedPath: "/api/agents/me", + }); + } finally { + await gateway.close(); + await handle.close(); + } + }); + + it("test_sixty_four_concurrent_streams_all_complete", async () => { + const seenPaths = new Set(); + const { handle, gateway } = createTestPair({ + forwardRequest: async (request) => { + // Force real overlap: every forward call waits one macrotask before it + // resolves, so 64 concurrent streams are genuinely in flight together. + await new Promise((resolve) => setTimeout(resolve, 5)); + seenPaths.add(request.pathname); + return { + status: 200, + headers: {}, + body: JSON.stringify({ echoedPath: request.pathname }), + }; + }, + }); + try { + const requests = Array.from({ length: 64 }, (_, index) => + gateway.forwardRequest({ + method: "GET", + path: `/api/issues/${index}`, + query: "", + headers: {}, + body: Buffer.alloc(0), + receivedToken: BRIDGE_TOKEN, + }), + ); + const responses = await Promise.all(requests); + expect(responses).toHaveLength(64); + for (const [index, response] of responses.entries()) { + expect(response.status).toBe(200); + expect(JSON.parse(response.body.toString("utf8"))).toEqual({ + echoedPath: `/api/issues/${index}`, + }); + } + expect(seenPaths.size).toBe(64); + } finally { + await gateway.close(); + await handle.close(); + } + }); + + it("test_ping_detects_a_silent_stall_within_twenty_seconds", async () => { + // The production defaults name the twenty-second bound this test name + // promises. The mechanism test below exercises the same code path with + // small overrides, so the suite stays fast. + expect(DEFAULT_HTTP2_BRIDGE_PING_STALL_MS).toBe(20_000); + expect(DEFAULT_HTTP2_BRIDGE_PING_INTERVAL_MS).toBeLessThan(DEFAULT_HTTP2_BRIDGE_PING_STALL_MS); + + const [serverSide] = duplexPair(); + // Nothing consumes the other side of the pair, so every PING frame the + // server sends goes unacknowledged: a silent stall. + const stallErrors: Error[] = []; + const handle = createHttp2BridgeServer({ + bridgeToken: BRIDGE_TOKEN, + forwardRequest: async () => ({ status: 200 }), + pingIntervalMs: 15, + pingStallMs: 40, + onSessionError: (error) => stallErrors.push(error), + }); + handle.bindChannel(fakeChannelFromDuplex(serverSide)); + + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(stallErrors).toHaveLength(1); + expect(stallErrors[0]?.message).toMatch(/stall/i); + await handle.close(); + }); + + it("test_goaway_reports_the_last_processed_stream_identifier", async () => { + // GOAWAY's Last-Stream-ID names the highest stream the SENDER processed + // for the peer's own initiated streams. Every request stream in this + // transport originates from the sandbox, so the meaningful, valid + // direction is the host naming the last client stream it processed — not + // the reverse (a client-sent GOAWAY only ever carries 0 here, since + // server push is disabled). The sandbox gateway observes it and can + // classify its own dispatched stream IDs with `classifyStreamAgainstGoaway`. + let hostSession: http2.ServerHttp2Session | undefined; + const goawayRecords: Array<{ lastStreamId: number; errorCode: number }> = []; + const { handle, bridgeToken, clientSide } = bindTestServer({ + onSession: (session) => { + hostSession = session; + }, + }); + const gateway = createSandboxHttp2BridgeGateway({ + bridgeToken, + createConnection: () => clientSide, + onGoaway: (record) => goawayRecords.push(record), + }); + try { + const response = await gateway.forwardRequest({ + method: "GET", + path: "/api/agents/me", + query: "", + headers: {}, + body: Buffer.alloc(0), + receivedToken: bridgeToken, + }); + expect(response.status).toBe(200); + + await new Promise((resolve) => { + hostSession?.goaway(http2.constants.NGHTTP2_NO_ERROR, 1); + setTimeout(resolve, 50); + }); + expect(goawayRecords).toHaveLength(1); + expect(goawayRecords[0]?.lastStreamId).toBe(1); + expect(classifyStreamAgainstGoaway(1, goawayRecords[0]!.lastStreamId)).toBe("accepted"); + expect(classifyStreamAgainstGoaway(3, goawayRecords[0]!.lastStreamId)).toBe("not_accepted"); + } finally { + await gateway.close(); + await handle.close(); + } + }); + + it("test_rst_stream_fails_one_request_and_keeps_the_session", async () => { + const { handle, bridgeToken, clientSide } = bindTestServer({ + forwardRequest: async (request) => { + if (request.pathname === "/api/agents/me") { + // Hold this one open long enough for the test to RST it. + await new Promise((resolve) => setTimeout(resolve, 200)); + } + return { status: 200, headers: {}, body: JSON.stringify({ path: request.pathname }) }; + }, + }); + const rawClient = connectRawClient(clientSide); + try { + const abortedStream = rawClient.request({ + ":method": "GET", + ":path": "/api/agents/me", + authorization: `Bearer ${bridgeToken}`, + }); + const abortedOutcome = new Promise<"aborted" | "closed">((resolve) => { + abortedStream.on("error", () => resolve("aborted")); + abortedStream.on("close", () => resolve("closed")); + }); + abortedStream.end(); + // Give the request time to reach the server before the reset. + await new Promise((resolve) => setTimeout(resolve, 20)); + abortedStream.close(http2.constants.NGHTTP2_CANCEL); + await abortedOutcome; + + // The session survives: a second request completes normally. + const survivingResponse = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = rawClient.request({ + ":method": "GET", + ":path": "/api/companies/co1", + authorization: `Bearer ${bridgeToken}`, + }); + let status = 0; + let body = ""; + req.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + req.setEncoding("utf8"); + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => resolve({ status, body })); + req.on("error", reject); + req.end(); + }); + expect(survivingResponse.status).toBe(200); + expect(JSON.parse(survivingResponse.body)).toEqual({ path: "/api/companies/co1" }); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_a_stalled_request_body_settles_instead_of_hanging_forever", async () => { + let forwarderCalled = false; + const { handle, bridgeToken, clientSide } = bindTestServer({ + requestBodyTimeoutMs: 30, + forwardRequest: async () => { + forwarderCalled = true; + return { status: 200 }; + }, + }); + const rawClient = connectRawClient(clientSide); + try { + const startMs = Date.now(); + // A partial, never-ended body stalls the read. The timeout destroys the + // stream, so the request settles (through an `error` or a `close`, not + // a clean response) well inside the bound, instead of hanging forever. + await new Promise((resolve) => { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + req.on("error", () => resolve()); + req.on("close", () => resolve()); + req.write("partial-body"); + }); + expect(Date.now() - startMs).toBeLessThan(5_000); + expect(forwarderCalled).toBe(false); + + // The session survives the timed-out stream: a second, complete request + // still succeeds. + const survivingResponse = await new Promise<{ status: number }>((resolve, reject) => { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + let status = 0; + req.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + req.on("data", () => undefined); + req.on("end", () => resolve({ status })); + req.on("error", reject); + req.end(); + }); + expect(survivingResponse.status).toBe(200); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_a_slow_but_progressing_request_body_completes_instead_of_timing_out", async () => { + // The idle bound is well under the total time the whole body takes, so a + // one-shot bound over the full read would trip. Each chunk resets the + // bound, so the request still completes. + const { handle, bridgeToken, clientSide } = bindTestServer({ + requestBodyTimeoutMs: 80, + forwardRequest: async (request) => ({ + status: 200, + body: JSON.stringify({ bodyLength: request.body.byteLength }), + }), + }); + const rawClient = connectRawClient(clientSide); + try { + const response = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + let status = 0; + let body = ""; + req.setEncoding("utf8"); + req.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => resolve({ status, body })); + req.on("error", reject); + // Five chunks, each inside the idle bound, summing past it. + let sent = 0; + const sendNext = () => { + if (sent >= 5) { + req.end(); + return; + } + sent += 1; + req.write("chunk"); + setTimeout(sendNext, 40); + }; + sendNext(); + }); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toEqual({ bodyLength: "chunk".length * 5 }); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_progress_renews_the_lifetime_bound_but_the_hard_ceiling_still_stops_it", async () => { + // The idle bound is generous, so a chunk every 40ms never lets it + // expire. The lifetime bound (50ms) is short, but each chunk renews it, + // so the read survives past 50ms — proving a slow peer that keeps + // making real progress does not lose the stream early. The hard ceiling + // (150ms) never renews, so the read still ends once the read's total + // age passes it, no matter how the peer paces its chunks. This is the + // failure mode an authenticated sandbox could otherwise use to hold one + // of the concurrent-stream slots open indefinitely: keep sending just + // enough to renew the lifetime bound, and never finish the body. + // + // Every write below lands well before the current lifetime deadline, + // and the test makes no further call on the stream after the last one: + // nothing races the server's own reset of it once the ceiling passes, + // so this proves the bound from the server's own, deterministic effect + // (the forward handler never runs) instead of from a raw client-stream + // event whose timing Node does not guarantee here. + let forwarderCalled = false; + const { handle, bridgeToken, clientSide } = bindTestServer({ + requestBodyTimeoutMs: 5_000, + requestBodyMaxLifetimeMs: 50, + requestBodyLifetimeCeilingMs: 150, + forwardRequest: async () => { + forwarderCalled = true; + return { status: 200 }; + }, + }); + const rawClient = connectRawClient(clientSide); + try { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + req.on("error", () => undefined); + // Four chunks, 40ms apart. The total send time, about 120ms, already + // passes the 50ms lifetime bound — proving renewal keeps the read + // alive past it — but stays under the 150ms hard ceiling. The body + // never ends: this request never calls `.end()`. + for (let chunkIndex = 0; chunkIndex < 4; chunkIndex += 1) { + req.write("chunk"); + await new Promise((resolve) => setTimeout(resolve, 40)); + } + // Wait past the hard ceiling with no further write, so the server has + // time to destroy the stalled stream on its own. + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(forwarderCalled).toBe(false); + + // The session survives the ended stream: a second, complete request + // still succeeds. + const survivingResponse = await new Promise<{ status: number }>((resolve, reject) => { + const survivingReq = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + let status = 0; + survivingReq.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + survivingReq.on("data", () => undefined); + survivingReq.on("end", () => resolve({ status })); + survivingReq.on("error", reject); + survivingReq.end(); + }); + expect(survivingResponse.status).toBe(200); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_a_request_body_within_the_lifetime_bound_still_completes", async () => { + // A generous lifetime bound must not interfere with an ordinary request + // that finishes well inside it. + const { handle, bridgeToken, clientSide } = bindTestServer({ + requestBodyTimeoutMs: 5_000, + requestBodyMaxLifetimeMs: 5_000, + forwardRequest: async (request) => ({ + status: 200, + body: JSON.stringify({ bodyLength: request.body.byteLength }), + }), + }); + const rawClient = connectRawClient(clientSide); + try { + const response = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + let status = 0; + let body = ""; + req.setEncoding("utf8"); + req.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => resolve({ status, body })); + req.on("error", reject); + req.write("chunk"); + req.end(); + }); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toEqual({ bodyLength: "chunk".length }); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_a_slow_but_progressing_upload_completes_past_the_base_lifetime_bound", async () => { + // The base lifetime bound (50ms) is short, but every chunk below carries + // real progress and renews it, and the hard ceiling (5s) is generous. + // The total send time, about 200ms, passes the 50ms base bound several + // times over, so this proves a slow but genuinely progressing upload now + // completes instead of losing its stream mid-upload. + let forwarderCalled = false; + const { handle, bridgeToken, clientSide } = bindTestServer({ + requestBodyTimeoutMs: 5_000, + requestBodyMaxLifetimeMs: 50, + requestBodyLifetimeCeilingMs: 5_000, + forwardRequest: async (request) => { + forwarderCalled = true; + return { status: 200, body: JSON.stringify({ bodyLength: request.body.byteLength }) }; + }, + }); + const rawClient = connectRawClient(clientSide); + try { + const response = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + let status = 0; + let body = ""; + req.setEncoding("utf8"); + req.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => resolve({ status, body })); + req.on("error", reject); + void (async () => { + // Six chunks, 40ms apart (well inside the idle bound), each real + // progress. + for (let sent = 0; sent < 6; sent += 1) { + req.write("chunk"); + await new Promise((r) => setTimeout(r, 40)); + } + req.end(); + })(); + }); + expect(forwarderCalled).toBe(true); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toEqual({ bodyLength: "chunk".length * 6 }); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_close_force_destroys_a_session_with_a_stalled_stream_instead_of_waiting_forever", async () => { + const { handle, bridgeToken, clientSide } = bindTestServer({ + // A body timeout far longer than the close grace, so `close()` is the + // only thing that bounds the wait in this test. + requestBodyTimeoutMs: 60_000, + closeGraceMs: 30, + }); + const rawClient = connectRawClient(clientSide); + try { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + req.write("partial-body"); + // Give the request time to reach the server before close() runs. + await new Promise((resolve) => setTimeout(resolve, 20)); + + const closeStart = Date.now(); + await handle.close(); + const closeElapsedMs = Date.now() - closeStart; + // `close()` force-destroyed the stalled session at the grace bound, + // instead of waiting on `session.close()` forever. + expect(closeElapsedMs).toBeLessThan(5_000); + } finally { + rawClient.close(); + } + }); + + it("test_the_server_sets_every_bounded_option", async () => { + expect(HTTP2_BRIDGE_SERVER_OPTIONS).toEqual({ + settings: { + enablePush: HTTP2_BRIDGE_ENABLE_PUSH, + maxConcurrentStreams: HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS, + maxHeaderListSize: HTTP2_BRIDGE_MAX_HEADER_LIST_SIZE, + headerTableSize: HTTP2_BRIDGE_HEADER_TABLE_SIZE, + }, + maxSessionMemory: HTTP2_BRIDGE_MAX_SESSION_MEMORY, + maxHeaderListPairs: HTTP2_BRIDGE_MAX_HEADER_LIST_PAIRS, + maxDeflateDynamicTableSize: HTTP2_BRIDGE_MAX_DEFLATE_DYNAMIC_TABLE_SIZE, + maxSessionInvalidFrames: HTTP2_BRIDGE_MAX_SESSION_INVALID_FRAMES, + maxSessionRejectedStreams: HTTP2_BRIDGE_MAX_SESSION_REJECTED_STREAMS, + streamResetRate: HTTP2_BRIDGE_STREAM_RESET_RATE, + streamResetBurst: HTTP2_BRIDGE_STREAM_RESET_BURST, + }); + expect(HTTP2_BRIDGE_ENABLE_PUSH).toBe(false); + expect(HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS).toBe(64); + expect(HTTP2_BRIDGE_MAX_HEADER_LIST_SIZE).toBe(16384); + expect(HTTP2_BRIDGE_HEADER_TABLE_SIZE).toBe(4096); + expect(HTTP2_BRIDGE_MAX_SESSION_MEMORY).toBe(16); + expect(HTTP2_BRIDGE_MAX_HEADER_LIST_PAIRS).toBe(128); + expect(HTTP2_BRIDGE_MAX_DEFLATE_DYNAMIC_TABLE_SIZE).toBe(4096); + expect(HTTP2_BRIDGE_MAX_SESSION_INVALID_FRAMES).toBe(100); + expect(HTTP2_BRIDGE_MAX_SESSION_REJECTED_STREAMS).toBe(100); + expect(HTTP2_BRIDGE_STREAM_RESET_RATE).toBe(10); + expect(HTTP2_BRIDGE_STREAM_RESET_BURST).toBe(100); + + // The running server actually carries the bound, not only the constant: + // the four `settings` values ride the server's outbound SETTINGS frame, + // so the connected client's `remoteSettings` reflects them after the + // handshake completes. + const { handle, clientSide } = bindTestServer(); + const rawClient = connectRawClient(clientSide); + try { + const remoteSettings = await new Promise((resolve) => { + rawClient.once("remoteSettings", resolve); + }); + expect(remoteSettings.enablePush).toBe(HTTP2_BRIDGE_ENABLE_PUSH); + expect(remoteSettings.maxConcurrentStreams).toBe(HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS); + expect(remoteSettings.maxHeaderListSize).toBe(HTTP2_BRIDGE_MAX_HEADER_LIST_SIZE); + expect(remoteSettings.headerTableSize).toBe(HTTP2_BRIDGE_HEADER_TABLE_SIZE); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + describe("parseCanonicalBridgeRequestPath", () => { + it("parses an origin-form path with a query exactly one time", () => { + const result = parseCanonicalBridgeRequestPath({ ":path": "/api/issues/abc?foo=bar" }); + expect(result).toEqual({ ok: true, value: { pathname: "/api/issues/abc", query: "?foo=bar" } }); + }); + + it("test_a_non_origin_form_path_is_rejected", () => { + expect(parseCanonicalBridgeRequestPath({ ":path": "http://evil.example/api" })).toEqual({ + ok: false, + reason: "non_origin_form", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "//evil.example/api" })).toEqual({ + ok: false, + reason: "non_origin_form", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "*" })).toEqual({ + ok: false, + reason: "non_origin_form", + }); + }); + + it("test_an_encoded_separator_or_a_dot_segment_is_rejected", () => { + expect(parseCanonicalBridgeRequestPath({ ":path": "/api%2fissues/abc" })).toEqual({ + ok: false, + reason: "encoded_slash", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "/api\\issues/abc" })).toEqual({ + ok: false, + reason: "backslash", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "/api/issues\0/abc" })).toEqual({ + ok: false, + reason: "nul_byte", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "/api/%00/abc" })).toEqual({ + ok: false, + reason: "nul_byte", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "/api/../secrets" })).toEqual({ + ok: false, + reason: "dot_segment", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "/api/%2e%2e/secrets" })).toEqual({ + ok: false, + reason: "dot_segment", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "/api/./issues" })).toEqual({ + ok: false, + reason: "dot_segment", + }); + }); + + it("rejects a duplicate pseudo-header", () => { + expect( + parseCanonicalBridgeRequestPath({ ":path": ["/api/agents/me", "/api/agents/other"] } as never), + ).toEqual({ ok: false, reason: "duplicate_pseudo_header" }); + }); + + it("rejects a missing path", () => { + expect(parseCanonicalBridgeRequestPath({})).toEqual({ ok: false, reason: "missing_path" }); + }); + }); + + it("buildHttp2BridgeForwardUrl resolves the parsed pathname and query against the base URL", () => { + const url = buildHttp2BridgeForwardUrl("http://127.0.0.1:4000", { + pathname: "/api/issues/abc", + query: "?foo=bar", + }); + expect(url.toString()).toBe("http://127.0.0.1:4000/api/issues/abc?foo=bar"); + }); + + it("classifyStreamAgainstGoaway classifies by the last processed stream id", () => { + expect(classifyStreamAgainstGoaway(1, 3)).toBe("accepted"); + expect(classifyStreamAgainstGoaway(3, 3)).toBe("accepted"); + expect(classifyStreamAgainstGoaway(5, 3)).toBe("not_accepted"); + }); + + it("test_a_stream_without_the_bridge_token_never_reaches_the_forwarder", async () => { + let forwarderCalled = false; + const { handle, clientSide } = bindTestServer({ + forwardRequest: async (request) => { + forwarderCalled = true; + return { status: 200, body: JSON.stringify({ path: request.pathname }) }; + }, + }); + const rawClient = connectRawClient(clientSide); + try { + const response = await new Promise<{ status: number; body: string }>((resolve, reject) => { + // No `authorization` header at all: the stream never carries a token. + const req = rawClient.request({ ":method": "GET", ":path": "/api/agents/me" }); + let status = 0; + let body = ""; + req.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + req.setEncoding("utf8"); + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => resolve({ status, body })); + req.on("error", reject); + req.end(); + }); + expect(response.status).toBe(401); + expect(forwarderCalled).toBe(false); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_a_denied_stream_with_an_unfinished_body_still_frees_its_stream_slot", async () => { + // The server answers a denied stream (an invalid token, here) at once, + // before it ever reads the request body. A peer that leaves that body + // unfinished must not hold the stream open past the same idle bound an + // authenticated request gets — otherwise, up to + // `HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS` denied streams could each retain + // a slot forever and block every legitimate callback. + let forwarderCalled = false; + const { handle, clientSide } = bindTestServer({ + requestBodyTimeoutMs: 30, + forwardRequest: async () => { + forwarderCalled = true; + return { status: 200 }; + }, + }); + const rawClient = connectRawClient(clientSide); + try { + const startMs = Date.now(); + const closed = new Promise((resolve) => { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + // No `authorization` header: the token check denies this stream + // before the body is ever read. + }); + req.on("error", () => undefined); + req.on("close", () => resolve()); + // Drain the response so its readable side reaches `end`: nothing in + // this test reads the response body otherwise, and an unread + // response can itself hold the client stream open past `close`. + req.resume(); + // A partial body the request never ends: without its own bound, the + // inbound half of this stream would stay open indefinitely. + req.write("partial-body"); + }); + await closed; + expect(Date.now() - startMs).toBeLessThan(5_000); + expect(forwarderCalled).toBe(false); + + // The session survives the freed stream: a second, complete request + // still succeeds, proving the session itself stayed open and healthy. + const survivingResponse = await new Promise<{ status: number }>((resolve, reject) => { + const survivingReq = rawClient.request({ + ":method": "GET", + ":path": "/api/agents/me", + authorization: `Bearer ${BRIDGE_TOKEN}`, + }); + let status = 0; + survivingReq.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + survivingReq.on("data", () => undefined); + survivingReq.on("end", () => resolve({ status })); + survivingReq.on("error", reject); + survivingReq.end(); + }); + expect(survivingResponse.status).toBe(200); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("rejects a route the allowlist does not carry, before the forwarder runs", async () => { + let forwarderCalled = false; + const { gateway, handle } = createTestPair({ + forwardRequest: async () => { + forwarderCalled = true; + return { status: 200 }; + }, + }); + try { + const response = await gateway.forwardRequest({ + method: "DELETE", + path: "/api/agents/me", + query: "", + headers: {}, + body: Buffer.alloc(0), + receivedToken: BRIDGE_TOKEN, + }); + expect(response.status).toBe(403); + expect(forwarderCalled).toBe(false); + } finally { + await gateway.close(); + await handle.close(); + } + }); + + it("the sandbox gateway keeps its own token check before it opens a stream", async () => { + let forwarderCalled = false; + const { gateway, handle } = createTestPair({ + forwardRequest: async () => { + forwarderCalled = true; + return { status: 200 }; + }, + }); + try { + await expect( + gateway.forwardRequest({ + method: "GET", + path: "/api/agents/me", + query: "", + headers: {}, + body: Buffer.alloc(0), + receivedToken: "wrong-token", + }), + ).rejects.toThrow(/invalid bridge token/i); + expect(forwarderCalled).toBe(false); + } finally { + await gateway.close(); + await handle.close(); + } + }); + + it("wrapDuplexChannelAsNodeDuplex relays writes and pushes through onData", async () => { + const written: Buffer[] = []; + let dataListener: ((chunk: Uint8Array) => void) | undefined; + let exitListener: ((exit: { exitCode: number | null }) => void) | undefined; + const channel: CommandManagedDuplexChannel = { + write: (data) => { + written.push(Buffer.from(data)); + }, + onData: (listener) => { + dataListener = listener; + }, + onExit: (listener) => { + exitListener = listener; + }, + stop: () => undefined, + close: async () => undefined, + }; + const duplex = wrapDuplexChannelAsNodeDuplex(channel); + const received: Buffer[] = []; + duplex.on("data", (chunk: Buffer) => received.push(chunk)); + + duplex.write(Buffer.from("outbound")); + await new Promise((resolve) => setImmediate(resolve)); + expect(Buffer.concat(written).toString("utf8")).toBe("outbound"); + + dataListener?.(Buffer.from("inbound")); + await new Promise((resolve) => setImmediate(resolve)); + expect(Buffer.concat(received).toString("utf8")).toBe("inbound"); + + const ended = new Promise((resolve) => duplex.on("end", resolve)); + exitListener?.({ exitCode: 0 }); + await ended; + }); + + it("wrapDuplexChannelAsNodeDuplex queues chunks past a full readable side and drains them once the consumer reads again", async () => { + let dataListener: ((chunk: Uint8Array) => void) | undefined; + const channel: CommandManagedDuplexChannel = { + write: () => undefined, + onData: (listener) => { + dataListener = listener; + }, + onExit: () => undefined, + stop: () => undefined, + close: async () => undefined, + }; + const duplex = wrapDuplexChannelAsNodeDuplex(channel, { maxBufferedReadBytes: 1_000_000 }); + const received: Buffer[] = []; + // The first chunk alone passes the readable side's default 64 KiB + // high-water mark, so `push()` reports the readable side full. The two + // chunks that follow queue in the wrapper instead of pushing past that + // signal — this is the bounded flow-control path this fix adds. + dataListener?.(Buffer.alloc(70_000, "a")); + dataListener?.(Buffer.from("-second-")); + dataListener?.(Buffer.from("-third-")); + + // Attaching a "data" listener now starts flowing mode, which drives the + // wrapper's own `read()` until every queued chunk drains, proving no + // queued chunk was dropped and the arrival order held. + const drainedAll = new Promise((resolve) => { + duplex.on("data", (chunk: Buffer) => { + received.push(chunk); + if (Buffer.concat(received).includes("-third-")) resolve(); + }); + }); + await drainedAll; + + expect(Buffer.concat(received).toString("utf8").endsWith("-second--third-")).toBe(true); + }); + + it("wrapDuplexChannelAsNodeDuplex stops the channel and destroys the duplex once the bounded read backpressure buffer overflows", async () => { + let dataListener: ((chunk: Uint8Array) => void) | undefined; + let stopped = false; + const channel: CommandManagedDuplexChannel = { + write: () => undefined, + onData: (listener) => { + dataListener = listener; + }, + onExit: () => undefined, + stop: () => { + stopped = true; + }, + close: async () => undefined, + }; + // The cap must clear the default 65,536-byte readable high-water mark, + // so the first chunk below still passes the direct-push check and + // forces `push()` to report the readable side full; the overflow this + // test proves comes from the queue that follows, not from that first + // chunk on its own. + const duplex = wrapDuplexChannelAsNodeDuplex(channel, { maxBufferedReadBytes: 80_000 }); + // No consumer ever attaches, so the readable side never drains: every + // chunk past the first, which alone passes the default high-water mark, + // fills the bounded queue instead of the unbounded internal buffer a + // caller ignoring `push()`'s return would grow. + const errored = new Promise((resolve) => duplex.on("error", resolve)); + + dataListener?.(Buffer.alloc(70_000, "a")); // passes the high-water mark and the cap; still pushed directly. + dataListener?.(Buffer.alloc(30_000, "b")); // queues: 30,000 of the 80,000-byte cap. + dataListener?.(Buffer.alloc(30_000, "b")); // queues: 60,000 of the 80,000-byte cap. + dataListener?.(Buffer.alloc(30_000, "b")); // 90,000 queued bytes passes the cap. + + const error = await errored; + expect(error.message).toMatch(/backpressure/i); + expect(stopped).toBe(true); + }); + + it("wrapDuplexChannelAsNodeDuplex fails closed on one inbound chunk larger than the bounded read backpressure buffer, before the queue holds anything to compare it against", async () => { + let dataListener: ((chunk: Uint8Array) => void) | undefined; + let stopped = false; + const channel: CommandManagedDuplexChannel = { + write: () => undefined, + onData: (listener) => { + dataListener = listener; + }, + onExit: () => undefined, + stop: () => { + stopped = true; + }, + close: async () => undefined, + }; + // No consumer ever attaches, and the queue is empty when this chunk + // arrives, so a check that only bounds the queue's cumulative size (and + // not one chunk's own size) would let this chunk reach `push()` unbound. + const duplex = wrapDuplexChannelAsNodeDuplex(channel, { maxBufferedReadBytes: 5_000 }); + const errored = new Promise((resolve) => duplex.on("error", resolve)); + + dataListener?.(Buffer.alloc(10_000, "a")); // exceeds the 5,000-byte cap on its own, on the very first chunk. + + const error = await errored; + expect(error.message).toMatch(/backpressure/i); + expect(stopped).toBe(true); + }); + + it("wrapDuplexChannelAsNodeDuplex fails closed once the read backpressure queue stalls with no drain, even though it stays under the byte cap", async () => { + let dataListener: ((chunk: Uint8Array) => void) | undefined; + let stopped = false; + const channel: CommandManagedDuplexChannel = { + write: () => undefined, + onData: (listener) => { + dataListener = listener; + }, + onExit: () => undefined, + stop: () => { + stopped = true; + }, + close: async () => undefined, + }; + // A generous byte cap, so the byte-cap check above never fires: this + // test proves the independent time bound catches a stuck consumer the + // byte cap alone would miss. + const duplex = wrapDuplexChannelAsNodeDuplex(channel, { + maxBufferedReadBytes: 10_000_000, + readBackpressureStallMs: 40, + }); + const errored = new Promise((resolve) => duplex.on("error", resolve)); + + dataListener?.(Buffer.alloc(70_000, "a")); // passes the high-water mark; pushed directly, no consumer drains it. + dataListener?.(Buffer.from("queued-and-never-drained")); // queues; no "data" listener ever attaches to drain it. + + const error = await errored; + expect(error.message).toMatch(/stall/i); + expect(stopped).toBe(true); + }); + + it("wrapDuplexChannelAsNodeDuplex clears the stall bound once the queue fully drains, instead of firing later on an idle channel", async () => { + let dataListener: ((chunk: Uint8Array) => void) | undefined; + const channel: CommandManagedDuplexChannel = { + write: () => undefined, + onData: (listener) => { + dataListener = listener; + }, + onExit: () => undefined, + stop: () => undefined, + close: async () => undefined, + }; + // A short stall bound, so this test proves the drain below clears the + // timer instead of merely finishing before a long one would have fired. + const duplex = wrapDuplexChannelAsNodeDuplex(channel, { + maxBufferedReadBytes: 10_000_000, + readBackpressureStallMs: 20, + }); + const received: Buffer[] = []; + const drainedAll = new Promise((resolve) => { + duplex.on("data", (chunk: Buffer) => { + received.push(chunk); + if (Buffer.concat(received).includes("-third-")) resolve(); + }); + }); + + dataListener?.(Buffer.alloc(70_000, "a")); // passes the high-water mark; queues the chunks that follow. + dataListener?.(Buffer.from("-second-")); + dataListener?.(Buffer.from("-third-")); + await drainedAll; + + // Wait past the stall bound with the channel now idle and the queue + // empty. A timer the full drain above did not clear would fire here. + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(duplex.destroyed).toBe(false); + }); +}); diff --git a/packages/adapter-utils/src/http2-bridge-server.ts b/packages/adapter-utils/src/http2-bridge-server.ts new file mode 100644 index 0000000000..65c59b4611 --- /dev/null +++ b/packages/adapter-utils/src/http2-bridge-server.ts @@ -0,0 +1,969 @@ +/** + * Host HTTP/2 server for the sandbox callback bridge transport. + * + * The server wraps one {@link CommandManagedDuplexChannel} as a Node `Duplex` + * and runs one plaintext HTTP/2 session on it. It maps every stream on that + * session to one call of the caller-supplied `forwardRequest` handler, then + * writes the result back as the stream response. The handler applies the real + * host token and the run attribution, so those rules stay in one place, next + * to the existing file-bridge and duplex-bridge forward path. + * + * This file does not select the transport for a run. It builds and tests the + * host half of the pair in isolation; a later phase wires the pair into the + * transport-selection path. + * + * Requests flow from the sandbox to the host only: the host never opens a + * stream to the sandbox. The server enforces three checks, in this order, for + * every stream: + * 1. a constant-time compare of the bridge token against the per-run token + * (accepted security fix 4), before any other processing; + * 2. one canonical parse of the `:path` pseudo-header (accepted security fix + * 3), whose result feeds both the route allowlist and the forward URL; + * 3. the route allowlist and the header allowlist, reused unchanged from + * `sandbox-callback-bridge.ts`. + * The server also bounds ten `http2.createServer` options (accepted security + * fix 1), so Node enforces the session, header, and stream-reset limits on + * every connection with no new component. + */ + +import { Duplex } from "node:stream"; +import http2 from "node:http2"; + +import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js"; +import { + authorizeSandboxCallbackBridgeRequestWithRoutes, + compareBridgeTokensConstantTime, + sanitizeSandboxCallbackBridgeHeaders, + DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST, + DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES, + DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST, + type SandboxCallbackBridgeRouteRule, +} from "./sandbox-callback-bridge.js"; + +// --------------------------------------------------------------------------- +// Bounded server options (accepted security fix 1). Node enforces each value, +// so naming them adds configuration and no new component. Every value and +// every name below matches the board-approved table exactly. +// --------------------------------------------------------------------------- + +/** Server push. The transport never needs it. */ +export const HTTP2_BRIDGE_ENABLE_PUSH = false; +/** Open streams. This matches the current broker limit ({@link DEFAULT_DUPLEX_BROKER_MAX_IN_FLIGHT_REQUESTS} in `duplex-bridge-broker.ts`). */ +export const HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS = 64; +/** One decompressed header list. The Node default is 65535. */ +export const HTTP2_BRIDGE_MAX_HEADER_LIST_SIZE = 16384; +/** The header-compression table. This keeps the Node default. */ +export const HTTP2_BRIDGE_HEADER_TABLE_SIZE = 4096; +/** Session memory in mebibytes. The Node default is 10. */ +export const HTTP2_BRIDGE_MAX_SESSION_MEMORY = 16; +/** Header pairs per request. This names the Node default. */ +export const HTTP2_BRIDGE_MAX_HEADER_LIST_PAIRS = 128; +/** The outbound compression table. */ +export const HTTP2_BRIDGE_MAX_DEFLATE_DYNAMIC_TABLE_SIZE = 4096; +/** Invalid frames before Node closes the session. */ +export const HTTP2_BRIDGE_MAX_SESSION_INVALID_FRAMES = 100; +/** Rejected streams before Node closes the session. */ +export const HTTP2_BRIDGE_MAX_SESSION_REJECTED_STREAMS = 100; +/** The stream-reset budget (frames per interval). Node sends GOAWAY past the budget. */ +export const HTTP2_BRIDGE_STREAM_RESET_RATE = 10; +/** The stream-reset budget (burst allowance). Node sends GOAWAY past the budget. */ +export const HTTP2_BRIDGE_STREAM_RESET_BURST = 100; + +/** + * The full bounded options object. The server passes this object, unchanged, + * to `http2.createServer`. A test asserts every value on this object, so it + * proves the running server actually carries the bound, not only that the + * named constant exists. + */ +export const HTTP2_BRIDGE_SERVER_OPTIONS: http2.ServerOptions = { + settings: { + enablePush: HTTP2_BRIDGE_ENABLE_PUSH, + maxConcurrentStreams: HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS, + maxHeaderListSize: HTTP2_BRIDGE_MAX_HEADER_LIST_SIZE, + headerTableSize: HTTP2_BRIDGE_HEADER_TABLE_SIZE, + }, + maxSessionMemory: HTTP2_BRIDGE_MAX_SESSION_MEMORY, + maxHeaderListPairs: HTTP2_BRIDGE_MAX_HEADER_LIST_PAIRS, + maxDeflateDynamicTableSize: HTTP2_BRIDGE_MAX_DEFLATE_DYNAMIC_TABLE_SIZE, + maxSessionInvalidFrames: HTTP2_BRIDGE_MAX_SESSION_INVALID_FRAMES, + maxSessionRejectedStreams: HTTP2_BRIDGE_MAX_SESSION_REJECTED_STREAMS, + streamResetRate: HTTP2_BRIDGE_STREAM_RESET_RATE, + streamResetBurst: HTTP2_BRIDGE_STREAM_RESET_BURST, +}; + +// --------------------------------------------------------------------------- +// Duplex channel adapter +// --------------------------------------------------------------------------- + +/** The default cap, in bytes, on the read-side queue {@link wrapDuplexChannelAsNodeDuplex} + * holds once `Duplex.push()` reports the readable side is full (a `false` + * return). A sandbox-controlled channel has no upstream pause: `onData` below + * keeps delivering bytes whether or not the HTTP/2 session keeps up with + * them. Past this cap the wrapper treats the channel as stuck, not merely + * slow, and fails closed: it stops the channel and destroys the `Duplex`, so + * a producer that keeps outpacing its reader cannot grow host memory without + * bound. This cap also bounds one single chunk: the wrapper checks a chunk's + * own size against it before `push()` ever runs, so one oversized chunk + * cannot cross the cap on its first delivery, before the queue holds + * anything to compare it against. */ +export const DEFAULT_HTTP2_BRIDGE_MAX_BUFFERED_READ_BYTES = HTTP2_BRIDGE_MAX_SESSION_MEMORY * 1024 * 1024; +/** The default bound, in milliseconds, on how long the read-side queue + * {@link wrapDuplexChannelAsNodeDuplex} holds can stay non-empty with no + * chunk draining from it. The byte cap above bounds how much memory a stuck + * reader can hold; it does not bound how long the reader can stay stuck. A + * consumer that never resumes reading would otherwise hold the channel open, + * backpressured, for as long as the queue stays under the byte cap. Each + * drained chunk renews this bound, so a consumer that keeps making real + * progress never trips it; only a consumer that stops resuming entirely + * does. */ +export const DEFAULT_HTTP2_BRIDGE_READ_BACKPRESSURE_STALL_MS = 30_000; + +/** + * Wrap a {@link CommandManagedDuplexChannel} as a Node `Duplex`, so an + * `Http2Server` can run one session directly on it (`server.emit("connection", + * duplex)`). The wrapper never buffers more than one write in flight: it calls + * the stream write callback only after the channel's own write call settles + * (the backpressure constraint), never as a delivery signal. The provider + * accepts many megabytes in milliseconds and holds them in its own buffer, so + * this direction stays governed by the channel's own write-settle timing. + * + * The read direction needs its own bound. The channel exposes no pause: once + * `onData` below is registered, the channel keeps calling it for every byte + * the sandbox sends, with no way for this wrapper to slow it down. Node's + * `Duplex.push()` reports back-pressure through its boolean return, not by + * refusing the call, so a caller that ignores a `false` return and keeps + * pushing grows the readable side's internal buffer with no limit. This + * wrapper honors that signal instead: while `push()` reports room, it pushes + * directly; once `push()` reports the readable side is full, it queues each + * later chunk instead of pushing past that signal, and drains the queue from + * `read()`, which Node calls again only once the consumer wants more. Every + * chunk, on either path, first checks against + * {@link DEFAULT_HTTP2_BRIDGE_MAX_BUFFERED_READ_BYTES} (or the caller's + * `maxBufferedReadBytes`) on its own size, and the queue checks against the + * same cap on its cumulative size: past either check the wrapper fails + * closed instead of buffering further, because the channel has no pause to + * fall back on. A second, independent bound — + * {@link DEFAULT_HTTP2_BRIDGE_READ_BACKPRESSURE_STALL_MS} (or the caller's + * `readBackpressureStallMs`) — covers the case the byte cap does not: a + * consumer that stops reading entirely, so the queue never grows past the + * byte cap but also never drains. This bound renews on every chunk the + * queue drains, so a consumer that keeps making real progress never trips + * it. + */ +export function wrapDuplexChannelAsNodeDuplex( + channel: CommandManagedDuplexChannel, + options: { maxBufferedReadBytes?: number; readBackpressureStallMs?: number } = {}, +): Duplex { + const maxBufferedReadBytes = options.maxBufferedReadBytes ?? DEFAULT_HTTP2_BRIDGE_MAX_BUFFERED_READ_BYTES; + const readBackpressureStallMs = + options.readBackpressureStallMs ?? DEFAULT_HTTP2_BRIDGE_READ_BACKPRESSURE_STALL_MS; + // Chunks `onData` already delivered that `push()` has not yet accepted, + // in arrival order. `read()` drains this queue before it lets Node pull + // any new bytes, so the delivery order the channel used stays intact. + const pendingReads: Buffer[] = []; + let pendingReadBytes = 0; + // True once `push()` last reported room for more, or before the first + // push call. `onData` pushes directly while this holds; once a `push()` + // call reports no room, later chunks queue in `pendingReads` instead. + let canPushMore = true; + // True once the channel exited. `endReadableIfDrained` pushes `null` only + // after the queue this wrapper still holds fully drains, so a chunk that + // arrived before the exit is never dropped. + let channelExited = false; + // Arms while the queue holds at least one chunk; clears once it fully + // drains. Fires `readBackpressureStallMs` after the queue's last drain (or + // its first chunk, if it never drained at all) with no further drain, so a + // consumer that stops resuming does not hold the channel open forever + // under the byte cap. + let backpressureStallTimer: ReturnType | undefined; + + function clearBackpressureStallTimer(): void { + if (backpressureStallTimer === undefined) return; + clearTimeout(backpressureStallTimer); + backpressureStallTimer = undefined; + } + + function armBackpressureStallTimer(): void { + clearBackpressureStallTimer(); + backpressureStallTimer = setTimeout(() => { + failClosed( + "Sandbox HTTP/2 channel's read backpressure queue did not drain within the stall bound; the reader appears stuck.", + ); + }, readBackpressureStallMs); + backpressureStallTimer.unref?.(); + } + + function failClosed(message: string): void { + clearBackpressureStallTimer(); + channel.stop(); + duplex.destroy(new Error(message)); + } + + function endReadableIfDrained(): void { + if (!channelExited || pendingReads.length > 0 || duplex.destroyed) return; + duplex.push(null); + } + + const duplex: Duplex = new Duplex({ + read() { + canPushMore = true; + let drainedAChunk = false; + while (canPushMore && pendingReads.length > 0) { + const next = pendingReads.shift(); + if (next === undefined) break; + pendingReadBytes -= next.byteLength; + drainedAChunk = true; + canPushMore = duplex.push(next); + } + if (pendingReads.length === 0) { + clearBackpressureStallTimer(); + } else if (drainedAChunk) { + // The queue still holds chunks, but at least one drained just now: + // real progress, so the stall bound renews instead of expiring under + // a consumer that is still reading, only slowly. + armBackpressureStallTimer(); + } + endReadableIfDrained(); + }, + write(chunk: unknown, _encoding, callback) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBufferLike); + let settleResult: unknown; + try { + settleResult = channel.write(bytes); + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error))); + return; + } + if ( + settleResult != null && + typeof (settleResult as Promise).then === "function" + ) { + (settleResult as Promise).then( + () => callback(), + (error) => callback(error instanceof Error ? error : new Error(String(error))), + ); + } else { + callback(); + } + }, + final(callback) { + channel + .close() + .then(() => callback(), (error) => callback(error instanceof Error ? error : new Error(String(error)))); + }, + }); + channel.onData((chunk) => { + if (duplex.destroyed) return; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + // Check one chunk's own size against the cap before either path below + // runs. `push()` never refuses a call on its size, so a single chunk + // larger than the whole cap would otherwise reach Node's internal + // buffer unbounded on the direct-push path, before the queue this + // wrapper owns ever holds anything to compare a later chunk against. + if (bytes.byteLength > maxBufferedReadBytes) { + failClosed( + "Sandbox HTTP/2 channel delivered one chunk larger than the bounded read backpressure buffer.", + ); + return; + } + if (canPushMore && pendingReads.length === 0) { + canPushMore = duplex.push(bytes); + return; + } + // The readable side already reported it is full, and the channel has no + // pause to slow the sandbox side down: queue this chunk instead of + // pushing past that signal. Bound the queue, so a producer that keeps + // outpacing its reader cannot grow it without limit. + if (pendingReads.length === 0) { + // The queue was empty until this chunk: arm the stall bound, so a + // consumer that never resumes reading still ends the channel, even + // though this chunk alone stays under the byte cap. + armBackpressureStallTimer(); + } + pendingReadBytes += bytes.byteLength; + if (pendingReadBytes > maxBufferedReadBytes) { + failClosed( + "Sandbox HTTP/2 channel exceeded the bounded read backpressure buffer; the reader could not keep up.", + ); + return; + } + pendingReads.push(bytes); + }); + channel.onExit(() => { + channelExited = true; + endReadableIfDrained(); + }); + // The channel exited, or `read()`/`onData` above failed the `Duplex` + // closed: either way, no further chunk will ever drain, so the stall + // timer serves no purpose and only holds a stray handle open. + duplex.once("close", clearBackpressureStallTimer); + return duplex; +} + +// --------------------------------------------------------------------------- +// Canonical `:path` parsing (accepted security fix 3) +// --------------------------------------------------------------------------- + +/** The reason {@link parseCanonicalBridgeRequestPath} rejected one request. */ +export type CanonicalBridgeRequestPathRejection = + | "missing_path" + | "duplicate_pseudo_header" + | "non_origin_form" + | "encoded_slash" + | "backslash" + | "nul_byte" + | "dot_segment"; + +/** The one parsed pathname and query. Both the route allowlist and the forward + * URL builder read this same value; the host never parses `:path` twice. */ +export interface CanonicalBridgeRequestPath { + pathname: string; + /** The query string, in `URL.search` form: empty, or a leading `?`. */ + query: string; +} + +export type CanonicalBridgeRequestPathResult = + | { ok: true; value: CanonicalBridgeRequestPath } + | { ok: false; reason: CanonicalBridgeRequestPathRejection }; + +/** The request pseudo-headers HTTP/2 allows exactly one of, per request. */ +const REQUEST_PSEUDO_HEADER_NAMES = [":method", ":scheme", ":authority", ":path"] as const; + +/** + * Parse the `:path` pseudo-header exactly one time. The caller passes the + * returned pathname and query to both the route allowlist and the forward URL + * builder — never a second, independent parse of the raw header. + * + * The parser rejects a request that carries any of: a duplicate pseudo-header, + * a missing or empty `:path`, a non-origin-form path, an encoded slash, a + * backslash, a NUL byte, or a dot segment (checked before URL normalization + * would silently remove it, and after percent-decoding each segment, so an + * encoded dot segment cannot slip through). + */ +export function parseCanonicalBridgeRequestPath( + headers: http2.IncomingHttpHeaders, +): CanonicalBridgeRequestPathResult { + for (const name of REQUEST_PSEUDO_HEADER_NAMES) { + if (Array.isArray((headers as Record)[name])) { + return { ok: false, reason: "duplicate_pseudo_header" }; + } + } + const rawPath = headers[":path"]; + if (typeof rawPath !== "string" || rawPath.length === 0) { + return { ok: false, reason: "missing_path" }; + } + // Origin-form only: a single leading "/", never "//" (network-path form) and + // never an absolute-form URI ("scheme://..."). + if (!rawPath.startsWith("/") || rawPath.startsWith("//") || rawPath.includes("://")) { + return { ok: false, reason: "non_origin_form" }; + } + if (/%2f/i.test(rawPath)) { + return { ok: false, reason: "encoded_slash" }; + } + if (rawPath.includes("\\")) { + return { ok: false, reason: "backslash" }; + } + if (rawPath.includes("\0") || /%00/i.test(rawPath)) { + return { ok: false, reason: "nul_byte" }; + } + const queryIndex = rawPath.indexOf("?"); + const rawPathname = queryIndex === -1 ? rawPath : rawPath.slice(0, queryIndex); + for (const segment of rawPathname.split("/")) { + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + return { ok: false, reason: "non_origin_form" }; + } + if (decoded === "." || decoded === "..") { + return { ok: false, reason: "dot_segment" }; + } + } + // Every raw-string check above passed, and the path carries no dot segment, + // so `URL` normalization here changes nothing but percent-encoding; it stays + // safe to build the canonical pathname and query from it. + let url: URL; + try { + url = new URL(rawPath, "http://bridge.internal"); + } catch { + return { ok: false, reason: "non_origin_form" }; + } + return { ok: true, value: { pathname: url.pathname, query: url.search } }; +} + +/** + * Build the forward URL from the one canonical parse. This mirrors + * `buildBridgeForwardUrl` in `execution-target.ts`, which the file bridge and + * the duplex bridge use today; a later phase wires the HTTP/2 host handler to + * that same forward path and can consolidate the two into one export. + */ +export function buildHttp2BridgeForwardUrl( + baseUrl: string, + request: CanonicalBridgeRequestPath, +): URL { + const url = new URL(request.pathname, baseUrl); + const query = request.query.trim(); + url.search = query.startsWith("?") ? query.slice(1) : query; + return url; +} + +// --------------------------------------------------------------------------- +// PING stall detection +// --------------------------------------------------------------------------- + +/** The default interval between two liveness PING frames, in milliseconds. */ +export const DEFAULT_HTTP2_BRIDGE_PING_INTERVAL_MS = 5_000; +/** The default bound a sent PING waits for its ack before the session counts as stalled. */ +export const DEFAULT_HTTP2_BRIDGE_PING_STALL_MS = 20_000; +/** The default idle bound on a request body read: the maximum gap between + * two received chunks (or between the token check and the first chunk) + * before the server treats the stream as stalled. Each received chunk resets + * this bound, so a slow peer that keeps making real progress completes; only + * a peer that stops sending trips it. */ +export const DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_TIMEOUT_MS = 30_000; +/** The default lifetime bound on a request body read, measured from the + * start of the read or from the most recent chunk that carried real + * progress, whichever is later. This bound is independent of + * {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_TIMEOUT_MS}: the idle bound + * resets on every chunk to catch a peer that stops sending; this bound + * renews on every chunk too, but only up to the hard ceiling below, so it + * catches a peer that never stops sending but also never finishes. See + * {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_LIFETIME_CEILING_MS} for the + * bound that keeps this renewal from running forever. */ +export const DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_MAX_LIFETIME_MS = 120_000; +/** The default hard ceiling on a request body read's total lifetime: an + * absolute bound armed once, at the start of the read, and never pushed out + * past this point no matter how much progress a later chunk reports. A peer + * cannot use a steady trickle of small chunks to keep renewing + * {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_MAX_LIFETIME_MS} and hold a + * {@link HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS} stream slot open forever, + * because this ceiling still ends the read once the read's total age passes + * it. Set well above {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_MAX_LIFETIME_MS} + * so a legitimate upload that makes real but slow progress — a chunk every + * few seconds, well inside the renewable bound above — still has room to + * finish, while a peer that never finishes is still bounded to this ceiling + * instead of running forever. This is an intentional design limit, not a + * defect and not open for removal without a replacement bound. */ +export const DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_LIFETIME_CEILING_MS = + DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_MAX_LIFETIME_MS * 4; +/** The default bound {@link Http2BridgeServerHandle.close} waits for an + * active session to close on its own before it force-destroys the session. A + * 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; + +function startHttp2BridgePingWatchdog( + session: http2.ServerHttp2Session, + input: { intervalMs: number; stallMs: number; onStall: (error: Error) => void }, +): () => void { + let stopped = false; + let pingTimer: ReturnType | undefined; + let stallTimer: ReturnType | undefined; + + function sendOnePing(): void { + if (stopped) return; + stallTimer = setTimeout(() => { + if (stopped) return; + stopped = true; + input.onStall(new Error("HTTP/2 bridge session stalled: no PING ack within the stall bound.")); + }, input.stallMs); + stallTimer.unref?.(); + try { + session.ping((error) => { + if (stopped) return; + if (stallTimer) clearTimeout(stallTimer); + if (error) { + stopped = true; + input.onStall(error instanceof Error ? error : new Error(String(error))); + return; + } + pingTimer = setTimeout(sendOnePing, input.intervalMs); + pingTimer.unref?.(); + }); + } catch (error) { + if (stallTimer) clearTimeout(stallTimer); + stopped = true; + input.onStall(error instanceof Error ? error : new Error(String(error))); + } + } + + pingTimer = setTimeout(sendOnePing, input.intervalMs); + pingTimer.unref?.(); + + return () => { + stopped = true; + if (pingTimer) clearTimeout(pingTimer); + if (stallTimer) clearTimeout(stallTimer); + }; +} + +// --------------------------------------------------------------------------- +// The server +// --------------------------------------------------------------------------- + +/** The result of one forward call. The server turns it into one stream response. */ +export interface Http2BridgeForwardResult { + status: number; + headers?: Record; + body?: Buffer | string; +} + +/** + * The one canonically-parsed, route-authorized, header-sanitized request the + * server hands to the forward handler. + */ +export interface Http2BridgeForwardRequest { + method: string; + pathname: string; + query: string; + headers: Record; + body: Buffer; +} + +export type Http2BridgeForwardHandler = ( + request: Http2BridgeForwardRequest, +) => Promise; + +/** The GOAWAY the server observed, naming the last stream ID the peer processed. */ +export interface Http2BridgeGoawayRecord { + lastStreamId: number; + errorCode: number; +} + +/** Classify one stream ID against an observed GOAWAY's last processed stream ID. */ +export function classifyStreamAgainstGoaway( + streamId: number, + lastStreamId: number, +): "accepted" | "not_accepted" { + return streamId <= lastStreamId ? "accepted" : "not_accepted"; +} + +export interface CreateHttp2BridgeServerOptions { + /** The per-run bridge token. The server compares it, constant-time, against + * the token on every stream before route or header processing. */ + bridgeToken: string; + /** The forward handler the server calls for each authorized request. */ + forwardRequest: Http2BridgeForwardHandler; + /** The route allowlist. The default is {@link DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST}. */ + routes?: readonly SandboxCallbackBridgeRouteRule[]; + /** The header allowlist. The default is {@link DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST}. */ + headerAllowlist?: readonly string[]; + /** The maximum request body size, in bytes. The default is {@link DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES}. */ + maxBodyBytes?: number; + /** The interval between two liveness PING frames, in milliseconds. */ + pingIntervalMs?: number; + /** The bound a sent PING waits for its ack before the server closes the session. */ + pingStallMs?: number; + /** The idle bound on a request body read: the maximum gap between two + * received chunks. The default is + * {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_TIMEOUT_MS}. */ + requestBodyTimeoutMs?: number; + /** The lifetime bound a request body read gets, renewed on every chunk + * that carries real progress, up to {@link requestBodyLifetimeCeilingMs}. + * See {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_MAX_LIFETIME_MS} for why the + * server enforces it. The default is + * {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_MAX_LIFETIME_MS}. */ + requestBodyMaxLifetimeMs?: number; + /** The hard ceiling on a request body read's total lifetime, armed once + * and never pushed out by progress. This bound caps worst-case + * stream-slot occupancy even for a peer that keeps renewing + * {@link requestBodyMaxLifetimeMs} with a steady trickle of chunks. See + * {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_LIFETIME_CEILING_MS} for the + * default and the reasoning behind it. */ + requestBodyLifetimeCeilingMs?: number; + /** The bound {@link Http2BridgeServerHandle.close} waits for an active + * 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 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 + * {@link wrapDuplexChannelAsNodeDuplex}. The default is + * {@link DEFAULT_HTTP2_BRIDGE_MAX_BUFFERED_READ_BYTES}. */ + maxBufferedReadBytes?: number; + /** The bound, in milliseconds, on how long the read backpressure queue + * {@link wrapDuplexChannelAsNodeDuplex} holds can stay non-empty with no + * chunk draining from it. The default is + * {@link DEFAULT_HTTP2_BRIDGE_READ_BACKPRESSURE_STALL_MS}. */ + readBackpressureStallMs?: number; + /** The sink for a GOAWAY the server observed on a session (one the sandbox + * side sent to the host). */ + onGoaway?: (record: Http2BridgeGoawayRecord) => void; + /** The sink for a session-level fault (a stall, a protocol fault). */ + onSessionError?: (error: Error) => void; + /** + * Fires once for each new session. A caller uses the live + * `ServerHttp2Session` to send its own GOAWAY (accepted security fix's + * GOAWAY-classification behavior is meaningful only from the side that + * names the last stream it processed — the host, since every stream + * originates from the sandbox). A test uses this hook to drive the + * GOAWAY test deterministically. + */ + onSession?: (session: http2.ServerHttp2Session) => void; +} + +/** The handle {@link createHttp2BridgeServer} returns. */ +export interface Http2BridgeServerHandle { + /** The underlying `Http2Server`. It is never `listen()`-ed; every session + * binds through {@link Http2BridgeServerHandle.bindChannel}. */ + readonly server: http2.Http2Server; + /** Wrap the channel as a `Duplex` and run one HTTP/2 session on it. Returns + * the wrapped `Duplex`, so a caller can also drive it directly (a test uses + * this to bind one side of a paired in-memory `Duplex`). */ + bindChannel(channel: CommandManagedDuplexChannel): Duplex; + /** Close every active session. Safe to call more than one time. */ + close(): Promise; +} + +function normalizeStreamMethod(value: string | string[] | undefined): string { + return typeof value === "string" && value.trim().length > 0 ? value.trim().toUpperCase() : "GET"; +} + +function readBridgeTokenHeader(headers: http2.IncomingHttpHeaders): string | undefined { + const raw = headers.authorization; + if (typeof raw !== "string" || !raw.startsWith("Bearer ")) return undefined; + return raw.slice("Bearer ".length); +} + +function toOutboundHeaderRecord(headers: http2.IncomingHttpHeaders): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (key.startsWith(":") || value == null) continue; + out[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : String(value); + } + return out; +} + +/** + * Read one request body, bounded on size and on two independent time + * bounds. + * + * The idle bound resets on each received chunk, so a slow peer that keeps + * making real progress completes, while a peer that stops sending + * mid-stream — the same failure a stalled network path or a hung sandbox + * process produces — still trips it. + * + * The lifetime bound also renews on each received chunk, by the same + * amount, so a slow peer that keeps sending real progress gets more time + * instead of losing the stream mid-upload. That renewal never pushes the + * deadline past `startedAt + lifetimeCeilingMs`: the hard ceiling arms once, + * when the read starts, and holds no matter how much progress a later chunk + * reports. This is an intentional design limit, not a defect, and it is not + * open for removal or relaxation without a replacement bound. It exists to + * cap the worst-case time one read can occupy a + * {@link HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS} stream slot — a cap the idle + * bound cannot provide alone, because a peer that keeps every chunk gap + * under the idle bound never trips it, no matter how long the read runs. A + * peer cannot use a steady trickle of small chunks to hold the slot forever, + * because the ceiling still ends the read once the read's total age passes + * it, regardless of how the peer paces its chunks. + * + * The `close` listener is the settle-of-last-resort: it fires whenever the + * 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. + */ +function readHttp2StreamBody( + stream: http2.ServerHttp2Stream, + maxBodyBytes: number, + idleTimeoutMs: number, + maxLifetimeMs: number, + lifetimeCeilingMs: number, +): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + let settled = false; + let idleTimer: ReturnType; + let lifetimeTimer: ReturnType; + const hardDeadline = Date.now() + lifetimeCeilingMs; + const settle = (run: () => void) => { + if (settled) return; + settled = true; + clearTimeout(idleTimer); + clearTimeout(lifetimeTimer); + run(); + }; + const armIdleTimer = () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + settle(() => reject(new Error("Bridge request body stalled before it completed."))); + stream.destroy(); + }, idleTimeoutMs); + idleTimer.unref?.(); + }; + // Renews on every chunk that carries real progress, but the delay never + // exceeds the time left before the hard ceiling armed below: see the + // function's own doc comment for why the ceiling must stay independent + // of progress. + const armLifetimeTimer = () => { + clearTimeout(lifetimeTimer); + const delayMs = Math.max(0, Math.min(maxLifetimeMs, hardDeadline - Date.now())); + lifetimeTimer = setTimeout(() => { + settle(() => reject(new Error("Bridge request body exceeded the maximum lifetime bound."))); + stream.destroy(); + }, delayMs); + lifetimeTimer.unref?.(); + }; + armLifetimeTimer(); + armIdleTimer(); + stream.on("data", (chunk: Buffer) => { + totalBytes += chunk.byteLength; + if (totalBytes > maxBodyBytes) { + settle(() => reject(new Error("Bridge request body exceeded the configured size limit."))); + stream.destroy(); + return; + } + chunks.push(chunk); + // The chunk is real progress, so the peer is not stalled: reset the + // idle bound and renew the lifetime bound, instead of letting either + // expire under a slow but active upload. The lifetime renewal above is + // still capped by the hard ceiling. + armIdleTimer(); + armLifetimeTimer(); + }); + stream.once("end", () => settle(() => resolve(Buffer.concat(chunks)))); + 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.")))); + }); +} + +function respondJson(stream: http2.ServerHttp2Stream, status: number, body: unknown): void { + if (stream.destroyed || stream.closed) return; + try { + stream.respond({ ":status": status, "content-type": "application/json" }); + stream.end(JSON.stringify(body)); + } catch { + // The peer reset the stream (RST_STREAM) before the server could answer. + // That fault stays local to this one stream; every other stream and the + // session itself stay unaffected. + } +} + +/** + * Answer a denied stream, then consume and discard its request body under + * the same idle and total lifetime bounds an authenticated request gets. + * A denied request's body content never reaches the forward handler, but + * the inbound half of the stream still needs a bound: without one, a 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. + */ +function denyRequest( + stream: http2.ServerHttp2Stream, + status: number, + body: unknown, + maxBodyBytes: number, + idleTimeoutMs: number, + maxLifetimeMs: number, + lifetimeCeilingMs: number, +): void { + respondJson(stream, status, body); + if (stream.destroyed || stream.closed) return; + readHttp2StreamBody(stream, maxBodyBytes, idleTimeoutMs, maxLifetimeMs, lifetimeCeilingMs).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. + }); +} + +/** + * Create the host HTTP/2 bridge server. The server runs no listener of its + * own: a caller wraps one duplex channel through {@link Http2BridgeServerHandle.bindChannel} + * per sandbox session. + */ +export function createHttp2BridgeServer(options: CreateHttp2BridgeServerOptions): Http2BridgeServerHandle { + const routes = options.routes ?? DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST; + const headerAllowlist = options.headerAllowlist ?? DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST; + const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES; + const pingIntervalMs = options.pingIntervalMs ?? DEFAULT_HTTP2_BRIDGE_PING_INTERVAL_MS; + const pingStallMs = options.pingStallMs ?? DEFAULT_HTTP2_BRIDGE_PING_STALL_MS; + const requestBodyTimeoutMs = options.requestBodyTimeoutMs ?? DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_TIMEOUT_MS; + const requestBodyMaxLifetimeMs = + options.requestBodyMaxLifetimeMs ?? DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_MAX_LIFETIME_MS; + const requestBodyLifetimeCeilingMs = + options.requestBodyLifetimeCeilingMs ?? DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_LIFETIME_CEILING_MS; + const closeGraceMs = options.closeGraceMs ?? DEFAULT_HTTP2_BRIDGE_CLOSE_GRACE_MS; + const maxBufferedReadBytes = options.maxBufferedReadBytes ?? DEFAULT_HTTP2_BRIDGE_MAX_BUFFERED_READ_BYTES; + const readBackpressureStallMs = + options.readBackpressureStallMs ?? DEFAULT_HTTP2_BRIDGE_READ_BACKPRESSURE_STALL_MS; + + const server = http2.createServer(HTTP2_BRIDGE_SERVER_OPTIONS); + const activeSessions = new Set(); + + async function handleStream( + stream: http2.ServerHttp2Stream, + headers: http2.IncomingHttpHeaders, + ): Promise { + // Accepted security fix 4: the constant-time bridge-token compare runs + // before route processing and before header processing. This host check + // is independent of the gateway's own token check on the sandbox side. + if (!compareBridgeTokensConstantTime(options.bridgeToken, readBridgeTokenHeader(headers))) { + denyRequest( + stream, + 401, + { error: "Invalid bridge token." }, + maxBodyBytes, + requestBodyTimeoutMs, + requestBodyMaxLifetimeMs, + requestBodyLifetimeCeilingMs, + ); + return; + } + + // Accepted security fix 3: parse `:path` exactly one time; both the route + // allowlist and the forward request below read this one result. + const parsedPath = parseCanonicalBridgeRequestPath(headers); + if (!parsedPath.ok) { + denyRequest( + stream, + 400, + { error: `Invalid request path: ${parsedPath.reason}` }, + maxBodyBytes, + requestBodyTimeoutMs, + requestBodyMaxLifetimeMs, + requestBodyLifetimeCeilingMs, + ); + return; + } + const method = normalizeStreamMethod(headers[":method"]); + + const denialReason = authorizeSandboxCallbackBridgeRequestWithRoutes( + { method, path: parsedPath.value.pathname }, + routes, + ); + if (denialReason) { + denyRequest( + stream, + 403, + { error: denialReason }, + maxBodyBytes, + requestBodyTimeoutMs, + requestBodyMaxLifetimeMs, + requestBodyLifetimeCeilingMs, + ); + return; + } + + const sanitizedHeaders = sanitizeSandboxCallbackBridgeHeaders( + toOutboundHeaderRecord(headers), + headerAllowlist, + ); + + let body: Buffer; + try { + body = await readHttp2StreamBody( + stream, + maxBodyBytes, + requestBodyTimeoutMs, + requestBodyMaxLifetimeMs, + requestBodyLifetimeCeilingMs, + ); + } catch (error) { + respondJson(stream, 413, { error: error instanceof Error ? error.message : String(error) }); + return; + } + + let result: Http2BridgeForwardResult; + try { + result = await options.forwardRequest({ + method, + pathname: parsedPath.value.pathname, + query: parsedPath.value.query, + headers: sanitizedHeaders, + body, + }); + } catch (error) { + respondJson(stream, 502, { error: error instanceof Error ? error.message : String(error) }); + return; + } + + if (stream.destroyed || stream.closed) return; + const responseHeaders: http2.OutgoingHttpHeaders = { ":status": result.status }; + for (const [key, value] of Object.entries(result.headers ?? {})) { + if (key.toLowerCase() === "content-length") continue; + responseHeaders[key] = value; + } + try { + stream.respond(responseHeaders); + stream.end(result.body); + } catch { + // The peer reset the stream (RST_STREAM) between dispatch and response. + // One stream's write fault stays local to that stream. + } + } + + server.on("session", (session) => { + activeSessions.add(session); + options.onSession?.(session); + const stopWatchdog = startHttp2BridgePingWatchdog(session, { + intervalMs: pingIntervalMs, + stallMs: pingStallMs, + // Destroying the session with an error routes back through this same + // session's own `error` listener below, which reports it exactly once. + onStall: (error) => { + if (!session.destroyed) session.destroy(error); + }, + }); + session.on("goaway", (errorCode: number, lastStreamId: number) => { + options.onGoaway?.({ lastStreamId, errorCode }); + }); + session.on("close", () => { + stopWatchdog(); + activeSessions.delete(session); + }); + session.on("error", (error) => { + stopWatchdog(); + options.onSessionError?.(error instanceof Error ? error : new Error(String(error))); + }); + }); + + server.on("stream", (stream, headers) => { + void handleStream(stream, headers).catch((error) => { + // A fault inside `handleStream` itself (not a forward-handler or + // stream-body rejection, both already caught above) is a defensive + // last resort. Destroy only this stream; the session stays open. + if (!stream.destroyed) { + stream.destroy(error instanceof Error ? error : new Error(String(error))); + } + }); + }); + + return { + server, + bindChannel(channel: CommandManagedDuplexChannel): Duplex { + const duplex = wrapDuplexChannelAsNodeDuplex(channel, { maxBufferedReadBytes, readBackpressureStallMs }); + server.emit("connection", duplex); + return duplex; + }, + async close(): Promise { + // `session.close()` sends GOAWAY and waits for every open stream to end + // on its own; a stalled stream (its body never completes, and its own + // timeout has not yet fired) would hold this wait open forever. The + // grace timer bounds it: past `closeGraceMs`, the server force-destroys + // the session, which ends its streams at once and settles their body + // reads through the `close` backstop in `readHttp2StreamBody`. + await Promise.all( + [...activeSessions].map( + (session) => + new Promise((resolve) => { + if (session.closed || session.destroyed) { + resolve(); + return; + } + const forceDestroyTimer = setTimeout(() => { + if (!session.destroyed) session.destroy(); + }, closeGraceMs); + forceDestroyTimer.unref?.(); + session.close(() => { + clearTimeout(forceDestroyTimer); + resolve(); + }); + }), + ), + ); + }, + }; +} diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index 7d67f87336..ea69788c26 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -2940,4 +2940,79 @@ describe("sandbox callback bridge", () => { expect(stderr).toContain("[paperclip-bridge] server error"); expect(stderr).toContain("EADDRINUSE"); }, 15_000); + + it("test_http2_gateway_writes_no_frame_between_ready_and_the_preface", async () => { + // Spawn the real generated gateway in http2_v1 mode and read its raw + // stdout bytes. The only frame-codec write on this path is the READY + // line; the very next bytes must be the HTTP/2 client connection preface + // with nothing in between, because the gateway hands stdout to the + // HTTP/2 client immediately after it writes READY and starts no + // heartbeat timer and writes no envelope frame on this path. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-http2-gateway-")); + cleanupDirs.push(rootDir); + const entrypoint = path.join(rootDir, "paperclip-bridge-server.mjs"); + await writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8"); + + const probe = createServer(); + const assignedPort = await new Promise((resolve, reject) => { + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + if (!address || typeof address === "string") { + reject(new Error("Could not reserve a loopback port for the test.")); + return; + } + probe.close(() => resolve(address.port)); + }); + }); + + const nonce = "test-nonce-http2"; + const child = spawn(process.execPath, [entrypoint], { + env: { + ...process.env, + PAPERCLIP_API_BRIDGE_MODE: "http2_v1", + PAPERCLIP_BRIDGE_TOKEN: "test-token", + PAPERCLIP_BRIDGE_PORT: String(assignedPort), + PAPERCLIP_BRIDGE_NONCE: nonce, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + cleanupFns.push(async () => { + child.kill(); + }); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + + const chunks: Buffer[] = []; + const preface = Buffer.from("505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", "hex"); + const firstBytes = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Timed out waiting for the http2 gateway stdout. stderr: " + stderr)), + 5000, + ); + child.stdout.on("data", (chunk: Buffer) => { + chunks.push(chunk); + const total = Buffer.concat(chunks); + const newlineIndex = total.indexOf(0x0a); + if (newlineIndex !== -1 && total.length >= newlineIndex + 1 + preface.length) { + clearTimeout(timer); + resolve(total); + } + }); + child.once("error", reject); + child.once("exit", (code) => { + clearTimeout(timer); + reject(new Error("The http2 gateway exited early with code " + String(code) + ". stderr: " + stderr)); + }); + }); + + const newlineIndex = firstBytes.indexOf(0x0a); + expect(newlineIndex).toBeGreaterThan(0); + const readyLine = firstBytes.subarray(0, newlineIndex).toString("utf8"); + expect(JSON.parse(readyLine)).toMatchObject({ type: "ready", nonce }); + const afterReady = firstBytes.subarray(newlineIndex + 1, newlineIndex + 1 + preface.length); + expect(afterReady).toEqual(preface); + }, 15_000); }); diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 42e5888e2b..f66b9bdac8 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -1,7 +1,9 @@ -import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { promises as fs } from "node:fs"; +import http2 from "node:http2"; import os from "node:os"; import path from "node:path"; +import type { Duplex } from "node:stream"; import { runWithoutActiveStep, @@ -70,12 +72,20 @@ export const SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT = "paperclip-bridge-server.mjs"; const SANDBOX_EXEC_CHANNEL_ENV = "PAPERCLIP_SANDBOX_EXEC_CHANNEL"; const SANDBOX_EXEC_CHANNEL_BRIDGE = "bridge"; -// The two bridge modes the generated gateway supports. The file mode polls a -// request/response queue on disk. The duplex mode forwards one request frame to -// stdout and resolves one response frame from stdin. The generated `.mjs` -// selects the mode from `PAPERCLIP_API_BRIDGE_MODE`. +// The bridge modes the generated gateway supports. The file mode polls a +// request/response queue on disk. The retired duplex mode forwarded one +// request frame to stdout and resolved one response frame from stdin; the +// generated gateway still defines it, but no mode dispatch selects it anymore +// — the http2 mode replaced it as the active non-file transport. The http2 +// mode runs one Node HTTP/2 client session directly on stdin/stdout, after it +// sends the one READY line the host readiness gate expects. The generated +// `.mjs` selects the mode from `PAPERCLIP_API_BRIDGE_MODE`. +// HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. const SANDBOX_CALLBACK_BRIDGE_FILE_MODE = "queue_v1"; export const SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE = "duplex_v1"; +/** The active non-file transport mode. It replaced {@link SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE} + * in the mode-selection path. */ +export const SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE = "http2_v1"; // The duplex gateway HTTP wait budget default. The gateway waits this long for a // response frame before it answers the local caller with a 502 timeout. The @@ -1832,6 +1842,209 @@ export async function startSandboxCallbackBridgeServer(input: { }; } +// --------------------------------------------------------------------------- +// Sandbox HTTP/2 client gateway +// +// This gateway turns each local loopback request into one HTTP/2 stream to +// the host server in `http2-bridge-server.ts`. It sits beside the file-mode +// and duplex-mode gateways above; it changes neither of them, and no mode +// dispatch selects it yet — a later phase wires it into the generated +// in-sandbox entrypoint and the transport-selection path. +// --------------------------------------------------------------------------- + +/** + * Constant-time bridge-token compare. Both this sandbox gateway and the host + * server in `http2-bridge-server.ts` import this one helper, so the gateway + * check and the independent host check (accepted security fix 4) apply the + * exact same comparison rule. A length mismatch returns `false` without a + * `timingSafeEqual` call, because `timingSafeEqual` throws on unequal buffer + * lengths; both operands are bridge tokens of near-fixed length, so this one + * length branch leaks no useful timing signal. + */ +export function compareBridgeTokensConstantTime( + expected: string, + received: string | null | undefined, +): boolean { + const expectedBytes = Buffer.from(expected, "utf8"); + const receivedBytes = Buffer.from(typeof received === "string" ? received : "", "utf8"); + if (expectedBytes.length !== receivedBytes.length) return false; + return timingSafeEqual(expectedBytes, receivedBytes); +} + +const SANDBOX_HTTP2_GATEWAY_DEFAULT_AUTHORITY = "bridge.internal"; + +/** One local request the gateway forwards as one HTTP/2 stream. */ +export interface SandboxHttp2BridgeGatewayRequest { + method: string; + path: string; + query: string; + headers: Record; + body: Buffer; + /** + * The token the local caller presented. The gateway check (accepted + * security fix 4 keeps this alongside the independent host check) compares + * it against the per-run bridge token before it opens a stream. + */ + receivedToken: string | null | undefined; +} + +/** The response one forwarded HTTP/2 stream carried back. */ +export interface SandboxHttp2BridgeGatewayResponse { + status: number; + headers: Record; + body: Buffer; +} + +export interface SandboxHttp2BridgeGateway { + /** Forward one local request as one HTTP/2 stream over the client session. */ + forwardRequest( + request: SandboxHttp2BridgeGatewayRequest, + ): Promise; + /** Close the HTTP/2 client session. Safe to call more than one time. */ + close(): Promise; +} + +export interface CreateSandboxHttp2BridgeGatewayOptions { + /** + * The per-run bridge token. The gateway checks every request against it, + * then attaches it to the outbound HTTP/2 stream as the `authorization` + * header, so the host can run its own independent check. + */ + bridgeToken: string; + /** + * Open the transport the HTTP/2 client session runs on. Returns a `Duplex` + * already connected to the host — the sandbox process's own channel in + * production, or one side of a paired in-memory `Duplex` in a test. + */ + createConnection: () => Duplex; + /** The `:authority` pseudo-header value. The channel carries no real network + * address, so this is a fixed label. The default is `bridge.internal`. */ + authority?: string; + /** The header allowlist applied to every outbound request. The default is + * {@link DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST}. */ + headerAllowlist?: readonly string[]; + /** + * The sink for a GOAWAY the host sends. The host names the last client + * stream ID it processed; a caller classifies each of its own dispatched + * stream IDs against it with `classifyStreamAgainstGoaway` in + * `http2-bridge-server.ts` to know which requests need a retry elsewhere. + */ + onGoaway?: (record: { lastStreamId: number; errorCode: number }) => void; +} + +function forwardOneHttp2Request( + session: http2.ClientHttp2Session, + request: { + bridgeToken: string; + method: string; + path: string; + query: string; + headers: Record; + body: Buffer; + }, +): Promise { + return new Promise((resolve, reject) => { + const query = request.query.trim(); + const pathWithQuery = + query.length === 0 ? request.path : `${request.path}${query.startsWith("?") ? query : `?${query}`}`; + const requestHeaders: http2.OutgoingHttpHeaders = { + ":method": request.method, + ":path": pathWithQuery, + authorization: `Bearer ${request.bridgeToken}`, + ...request.headers, + }; + let stream: http2.ClientHttp2Stream; + try { + stream = session.request(requestHeaders, { endStream: request.body.length === 0 }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + const chunks: Buffer[] = []; + let responseHeaders: Record = {}; + let status = 502; + let settled = false; + const settle = (run: () => void) => { + if (settled) return; + settled = true; + run(); + }; + stream.on("response", (headers) => { + const rawStatus = headers[":status"]; + status = typeof rawStatus === "number" ? rawStatus : Number(rawStatus) || 502; + responseHeaders = {}; + for (const [key, value] of Object.entries(headers)) { + if (key.startsWith(":") || value == null) continue; + responseHeaders[key] = Array.isArray(value) ? value.join(", ") : String(value); + } + }); + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.once("end", () => settle(() => resolve({ status, headers: responseHeaders, body: Buffer.concat(chunks) }))); + stream.once("error", (error) => + settle(() => reject(error instanceof Error ? error : new Error(String(error)))), + ); + stream.once("aborted", () => settle(() => reject(new Error("Bridge HTTP/2 stream aborted.")))); + if (request.body.length > 0) { + stream.end(request.body); + } else if (!stream.writableEnded) { + stream.end(); + } + }); +} + +/** + * Create the sandbox HTTP/2 client gateway. It opens one HTTP/2 client + * session on the transport `createConnection` returns, and forwards each + * local request the caller hands it (already checked against the bridge + * token — see {@link SandboxHttp2BridgeGatewayRequest.receivedToken}) as one + * HTTP/2 stream. It keeps the header allowlist on the sandbox side, exactly + * as the file-mode and duplex-mode gateways do. + */ +export function createSandboxHttp2BridgeGateway( + options: CreateSandboxHttp2BridgeGatewayOptions, +): SandboxHttp2BridgeGateway { + const authority = options.authority?.trim() || SANDBOX_HTTP2_GATEWAY_DEFAULT_AUTHORITY; + const headerAllowlist = options.headerAllowlist ?? DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST; + const session = http2.connect(`http://${authority}`, { + createConnection: options.createConnection, + }); + // A session-level fault fails every in-flight `forwardRequest` call through + // that stream's own `error`/`aborted` handler. This listener only stops + // Node from raising an unhandled `error` event for the session itself. + session.on("error", () => undefined); + session.on("goaway", (errorCode: number, lastStreamId: number) => { + options.onGoaway?.({ lastStreamId, errorCode }); + }); + + return { + forwardRequest(request: SandboxHttp2BridgeGatewayRequest): Promise { + // The gateway check (accepted security fix 4 keeps this as well as the + // independent host-side check): a request whose token does not match + // the per-run bridge token never opens a stream. + if (!compareBridgeTokensConstantTime(options.bridgeToken, request.receivedToken)) { + return Promise.reject(new Error("Invalid bridge token.")); + } + return forwardOneHttp2Request(session, { + bridgeToken: options.bridgeToken, + method: request.method, + path: request.path, + query: request.query, + headers: sanitizeSandboxCallbackBridgeHeaders(request.headers, headerAllowlist), + body: request.body, + }); + }, + close(): Promise { + return new Promise((resolve) => { + if (session.closed || session.destroyed) { + resolve(); + return; + } + session.close(() => resolve()); + }); + }, + }; +} + /** * The zero-dependency codec the generated duplex gateway embeds. It is a plain * JavaScript copy of the host codec in `duplex-frame-codec.ts`. It uses only the @@ -2084,6 +2297,8 @@ export function getSandboxCallbackBridgeServerSource(): string { import { createServer } from "node:http"; import { promises as fs } from "node:fs"; import path from "node:path"; +import http2 from "node:http2"; +import { Duplex } from "node:stream"; const bridgeMode = process.env.PAPERCLIP_API_BRIDGE_MODE || "${SANDBOX_CALLBACK_BRIDGE_FILE_MODE}"; const queueDir = process.env.PAPERCLIP_BRIDGE_QUEUE_DIR; @@ -2129,7 +2344,11 @@ const allowedHeaders = new Set(${JSON.stringify([...DEFAULT_SANDBOX_CALLBACK_BRI if (!bridgeToken) { throw new Error("PAPERCLIP_BRIDGE_TOKEN is required."); } -if (bridgeMode !== "${SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE}" && !queueDir) { +if ( + bridgeMode !== "${SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE}" && + bridgeMode !== "${SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE}" && + !queueDir +) { throw new Error("PAPERCLIP_BRIDGE_QUEUE_DIR and PAPERCLIP_BRIDGE_TOKEN are required."); } @@ -2723,7 +2942,231 @@ function runDuplexGateway() { }); } -if (bridgeMode === "${SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE}") { +// --------------------------------------------------------------------------- +// http2_v1: run one Node HTTP/2 client session directly on stdin/stdout. +// +// This gateway writes exactly one frame-codec line before it hands stdin and +// stdout to the HTTP/2 client: the READY line, so the host readiness gate +// accepts the same handshake it already accepts for every mode. After that +// one write, only the HTTP/2 client touches stdout: this function calls +// writeFrame no more, and it starts no heartbeat timer, so no non-HTTP/2 +// writer can put a byte on stdout between the READY line and the client +// connection preface. +// --------------------------------------------------------------------------- + +function createStdioDuplex() { + const duplex = new Duplex({ + read() { + // process.stdin pushes bytes through the "data" listener below; there + // is nothing to pull on demand here. + }, + write(chunk, _encoding, callback) { + const flushed = process.stdout.write(chunk); + if (flushed) { + callback(); + } else { + process.stdout.once("drain", () => callback()); + } + }, + }); + process.stdin.on("data", (chunk) => { + duplex.push(chunk); + }); + process.stdin.on("end", () => { + duplex.push(null); + }); + process.stdin.on("error", (error) => { + duplex.destroy(error instanceof Error ? error : new Error(String(error))); + }); + return duplex; +} + +function runHttp2Gateway() { + function diag(message) { + // Diagnostics go to stderr only, the same as every other mode. + process.stderr.write("[paperclip-bridge] " + message + "\\n"); + } + function writeFrame(frame) { + process.stdout.write(encodeDuplexFrame(frame)); + } + + const authority = "bridge.internal"; + let session = null; + let unavailable = false; + + function openSession() { + if (session) return session; + const stdio = createStdioDuplex(); + session = http2.connect("http://" + authority, { + createConnection: () => stdio, + }); + session.on("error", () => { + unavailable = true; + }); + session.on("close", () => { + unavailable = true; + }); + session.on("goaway", (errorCode, lastStreamId) => { + diag("host sent GOAWAY (errorCode=" + errorCode + ", lastStreamId=" + lastStreamId + ")"); + }); + return session; + } + + function forwardOverHttp2(request) { + return new Promise((resolve, reject) => { + const activeSession = openSession(); + const query = request.query || ""; + const pathWithQuery = + query.length === 0 ? request.path : request.path + (query.charAt(0) === "?" ? query : "?" + query); + const outboundHeaders = Object.assign( + { + ":method": request.method, + ":path": pathWithQuery, + authorization: "Bearer " + bridgeToken, + }, + request.headers, + ); + let stream; + try { + stream = activeSession.request(outboundHeaders, { endStream: request.body.length === 0 }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + const chunks = []; + let status = 502; + let responseHeaders = {}; + let settled = false; + const settle = (run) => { + if (settled) return; + settled = true; + run(); + }; + stream.on("response", (headers) => { + const raw = headers[":status"]; + status = typeof raw === "number" ? raw : Number(raw) || 502; + responseHeaders = {}; + for (const [key, value] of Object.entries(headers)) { + if (key.charAt(0) === ":" || value == null) continue; + responseHeaders[key] = Array.isArray(value) ? value.join(", ") : String(value); + } + }); + stream.on("data", (chunk) => chunks.push(chunk)); + stream.once("end", () => + settle(() => resolve({ status: status, headers: responseHeaders, body: Buffer.concat(chunks) })), + ); + stream.once("error", (error) => + settle(() => reject(error instanceof Error ? error : new Error(String(error)))), + ); + stream.once("aborted", () => settle(() => reject(new Error("Bridge HTTP/2 stream aborted.")))); + if (request.body.length > 0) { + stream.end(request.body); + } else if (!stream.writableEnded) { + stream.end(); + } + }); + } + + const server = createServer(async (req, res) => { + try { + const auth = req.headers.authorization || ""; + const receivedToken = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : ""; + if (!tokensMatch(receivedToken)) { + writeJsonResponse(res, 401, { error: "Invalid bridge token." }); + return; + } + if (unavailable) { + writeJsonResponse(res, 503, { error: "bridge_unavailable" }); + 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 = Buffer.from(await readBody(req), "utf8"); + let response; + try { + response = await forwardOverHttp2({ + method: req.method || "GET", + path: url.pathname, + query: url.search, + headers: normalizeHeaders(req.headers), + body: requestBodyBuffer, + }); + } catch (error) { + writeJsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) }); + return; + } + 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) }); + } + }); + + process.on("SIGINT", () => { + try { + server.close(); + } catch (error) { + diag("server close error: " + (error && error.message ? error.message : String(error))); + } + process.exit(0); + }); + process.on("SIGTERM", () => { + try { + server.close(); + } catch (error) { + diag("server close error: " + (error && error.message ? error.message : String(error))); + } + process.exit(0); + }); + + // Bind-or-exit, the same rule every mode applies: the host assigns a + // positive loopback port, and the gateway binds exactly that port or exits + // nonzero. It never selects a different port. + if (!Number.isInteger(port) || port <= 0) { + diag("http2 gateway requires a positive assigned PAPERCLIP_BRIDGE_PORT; got " + String(port)); + process.exit(1); + } + server.on("error", (error) => { + diag( + "http2 gateway could not bind port " + + String(port) + + ": " + + (error && error.message ? error.message : String(error)), + ); + process.exit(1); + }); + server.listen(port, host, () => { + const address = server.address(); + if (!address || typeof address === "string") { + diag("http2 gateway did not expose a TCP address"); + process.exit(1); + return; + } + // Send the one frame-codec line on this path: the READY line the host + // readiness gate expects. Open the HTTP/2 client session on the very next + // statement, so the client connection preface is the next byte the host + // sees after READY, with no other writer in between. + writeFrame({ version: DUPLEX_FRAME_VERSION, type: "ready", nonce: bridgeNonce }); + gatewayReady = true; + openSession(); + }); +} + +if (bridgeMode === "${SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE}") { + runHttp2Gateway(); +} else if (bridgeMode === "${SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE}") { + // No host selection path sets this mode anymore (http2_v1 replaced it), but + // the generated gateway keeps the mode reachable: it stays defined here, + // unchanged, so nothing that still spawns the gateway directly with this + // mode name breaks. runDuplexGateway(); } else { await runFileGateway(); diff --git a/packages/adapter-utils/src/workspace-restore-merge.ts b/packages/adapter-utils/src/workspace-restore-merge.ts index dd4760dcc9..16782973c3 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.ts @@ -106,29 +106,38 @@ function entriesMatch(left: SnapshotEntry | null | undefined, right: SnapshotEnt return false; } -async function isHolderAlive(lockDir: string): Promise { +const LOCK_STALE_MS = 30_000; + +async function isLockStale(lockDir: string): Promise { try { const raw = await fs.readFile(path.join(lockDir, "owner.json"), "utf8"); const owner = JSON.parse(raw) as { pid?: unknown }; const pid = typeof owner.pid === "number" && Number.isFinite(owner.pid) && owner.pid > 0 ? owner.pid : null; if (pid === null) { // Owner record is unparseable / missing pid — treat as stale. - return false; + return true; } try { process.kill(pid, 0); - return true; - } catch { return false; + } catch { + return true; } } catch { - // owner.json missing or unreadable — treat as stale. - return false; + // owner.json is missing or unreadable. A live holder also passes through + // this exact state, briefly, between its own `fs.mkdir(lockDir)` and its + // `fs.writeFile(owner.json)` below. Reading "missing" as "stale" here would + // let a concurrent acquirer delete a live holder's lock directory during + // that window. Mirror the materializePaperclipSkillCopy lock pattern: fall + // back to the lock directory's own mtime, and only call it stale once the + // directory itself has outlived the stale threshold. + const stat = await fs.stat(lockDir).catch(() => null); + return !stat || Date.now() - stat.mtimeMs > LOCK_STALE_MS; } } async function acquireDirectoryMergeLock(lockDir: string): Promise<() => Promise> { - const deadline = Date.now() + 30_000; + const deadline = Date.now() + LOCK_STALE_MS; while (true) { try { await fs.mkdir(lockDir); @@ -146,7 +155,7 @@ async function acquireDirectoryMergeLock(lockDir: string): Promise<() => Promise // Stale-lock detection: if the owner PID is dead (SIGKILL / OOM / crash), // the lockDir would otherwise persist forever and stall restores. Mirror // the materializePaperclipSkillCopy lock pattern — remove and retry. - if (!(await isHolderAlive(lockDir))) { + if (await isLockStale(lockDir)) { await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined); continue; } diff --git a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.live.test.ts b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.live.test.ts index c3a3308fea..cd7be3098d 100644 --- a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.live.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.live.test.ts @@ -24,6 +24,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { randomUUID } from "node:crypto"; +import http2 from "node:http2"; +import { Duplex } from "node:stream"; import { openDaytonaDuplexChannelSession, type DaytonaPtyProcess, @@ -117,7 +119,7 @@ describeLive("Daytona duplex channel (live)", () => { // The channel child still answers. A write returns as program output. const ping = `chan-${randomUUID()}`; - session.write(`${ping}\n`); + session.write(Buffer.from(`${ping}\n`)); await waitFor(readOutput, (text) => text.includes(ping), 30_000, "channel echo"); } finally { await session.close(); @@ -139,7 +141,7 @@ describeLive("Daytona duplex channel (live)", () => { const baseline = readOutput().length; const line = `PING-${randomUUID()}`; - session.write(`${line}\n`); + session.write(Buffer.from(`${line}\n`)); await waitFor( readOutput, (text) => text.slice(baseline).includes(line), @@ -214,7 +216,7 @@ describeLive("Daytona duplex channel (live)", () => { const line = `RTT-${index}-${randomUUID()}`; const baseline = readOutput().length; const start = Date.now(); - session.write(`${line}\n`); + session.write(Buffer.from(`${line}\n`)); await waitFor( readOutput, (text) => text.slice(baseline).includes(line), @@ -262,7 +264,7 @@ describeLive("Daytona duplex channel (live)", () => { // before its echo returns. The abrupt close models a lost provider channel. await new Promise((resolve) => setTimeout(resolve, 4_000)); const inFlight = `INFLIGHT-${randomUUID()}`; - session.write(`${inFlight}\n`); + session.write(Buffer.from(`${inFlight}\n`)); // Close at once, without waiting for the echo. The pending round trip never // settles through the stream; the channel tears down instead. await session.close(); @@ -288,4 +290,124 @@ describeLive("Daytona duplex channel (live)", () => { }, LIVE_TIMEOUT_MS, ); + + it( + "test_live_daytona_run_uses_http2_v1_end_to_end", + async () => { + // Phase 4 selects http2_v1 by running one Node HTTP/2 session directly + // on this same pseudo-terminal channel, right after the READY line. + // This package ships standalone (see the file header), so it cannot + // import the host readiness gate or the preface scan from + // `@paperclipai/adapter-utils`; this test reimplements the minimal, + // self-contained version of both, using only `node:http2`, so the + // proof runs against a real Daytona PTY end to end. + const live = sandbox!; + const nonce = randomUUID(); + + // The 24-octet HTTP/2 client connection preface (RFC 9113, Section 3.4). + const CLIENT_PREFACE = Buffer.from("505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", "hex"); + + // The sandbox-side child: send the READY line, then hand stdin/stdout to + // a real HTTP/2 client session and dispatch one request — the same + // shape `runHttp2Gateway()` in `sandbox-callback-bridge.ts` runs. + const nodeScript = [ + `process.stdout.write(JSON.stringify({version:2,type:"ready",nonce:${JSON.stringify(nonce)}})+"\\n");`, + `const http2=require("node:http2");`, + `const {Duplex}=require("node:stream");`, + `const stdio=new Duplex({read(){},write(chunk,enc,cb){const ok=process.stdout.write(chunk);if(ok)cb();else process.stdout.once("drain",cb);}});`, + `process.stdin.on("data",c=>stdio.push(c));`, + `process.stdin.on("end",()=>stdio.push(null));`, + `const session=http2.connect("http://bridge.internal",{createConnection:()=>stdio});`, + `session.on("error",()=>process.exit(1));`, + `const stream=session.request({":method":"GET",":path":"/ping"});`, + `let body="";`, + `stream.on("data",c=>{body+=c;});`, + `stream.on("error",()=>process.exit(3));`, + `stream.on("end",()=>{session.close(()=>process.exit(body==="pong"?0:2));});`, + `stream.end();`, + ].join(""); + + const session = await openDaytonaDuplexChannelSession(live.process, ["node", "-e", nodeScript]); + try { + // Reads bytes from the channel until it finds one complete newline- + // terminated READY line, then hands every later byte — including any + // already-buffered suffix of the same chunk — to `onAfterReady`. + const readyAndAfter = await new Promise<{ nonce: string; afterReady: Buffer }>((resolve, reject) => { + let buffer = Buffer.alloc(0); + const timer = setTimeout( + () => reject(new Error(`Timed out waiting for the READY line. Bytes so far: ${buffer.length}`)), + 30_000, + ); + session.onData((chunk) => { + buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + const newlineIndex = buffer.indexOf(0x0a); + if (newlineIndex === -1) return; + clearTimeout(timer); + const line = buffer.subarray(0, newlineIndex).toString("utf8"); + const decoded = JSON.parse(line) as { type?: string; nonce?: string }; + resolve({ nonce: decoded.nonce ?? "", afterReady: Buffer.from(buffer.subarray(newlineIndex + 1)) }); + }); + }); + expect(readyAndAfter.nonce).toBe(nonce); + + // Scan the retained suffix for the client preface, the same rule + // `createHttp2PrefaceScanningChannel` in `execution-target.ts` applies: + // the scan window opens only on bytes after the accepted READY line. + let sawPreface = false; + let downstream: ((chunk: Buffer) => void) | null = null; + let pendingAfterPreface = Buffer.alloc(0); + let scanBuffer = readyAndAfter.afterReady; + function deliver(chunk: Buffer): void { + if (downstream) downstream(chunk); + else pendingAfterPreface = Buffer.concat([pendingAfterPreface, chunk]); + } + function handleChunk(chunk: Buffer): void { + if (sawPreface) { + deliver(chunk); + return; + } + scanBuffer = Buffer.concat([scanBuffer, chunk]); + const offset = scanBuffer.indexOf(CLIENT_PREFACE); + if (offset === -1) return; + sawPreface = true; + const fromPreface = Buffer.from(scanBuffer.subarray(offset)); + scanBuffer = Buffer.alloc(0); + deliver(fromPreface); + } + // The already-retained suffix might already hold the preface. + handleChunk(Buffer.alloc(0)); + session.onData((chunk) => handleChunk(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + + // Wrap the channel as a Node `Duplex` starting at the preface offset, + // and bind one plaintext HTTP/2 server session on it. + const boundDuplex: Duplex = new Duplex({ + read() {}, + write(chunk: unknown, _encoding, callback) { + session.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBufferLike)); + callback(); + }, + }); + downstream = (chunk) => boundDuplex.push(chunk); + if (pendingAfterPreface.length > 0) { + boundDuplex.push(pendingAfterPreface); + pendingAfterPreface = Buffer.alloc(0); + } + + const server = http2.createServer(); + server.on("stream", (stream) => { + stream.respond({ ":status": 200 }); + stream.end("pong"); + }); + server.emit("connection", boundDuplex); + + const exit = await session.wait(); + expect(exit.exitCode).toBe(0); + // eslint-disable-next-line no-console + console.log("[duplex-live] http2_v1: READY, then the real client preface, then one full HTTP/2 round trip, all over one live Daytona PTY."); + } finally { + await session.close(); + } + }, + LIVE_TIMEOUT_MS, + ); }); diff --git a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.test.ts b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.test.ts index 19339783f5..de12f7a601 100644 --- a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.test.ts @@ -270,10 +270,10 @@ describe("openDaytonaDuplexChannelSession", () => { const received: string[] = []; const session = await openDaytonaDuplexChannelSession(process, GATEWAY); - session.onData((chunk) => received.push(chunk)); + session.onData((chunk) => received.push(new TextDecoder().decode(chunk))); // The launch wrapper is the first input; the host frame write is the second. - session.write('{"version":1,"type":"heartbeat"}\n'); + session.write(new TextEncoder().encode('{"version":1,"type":"heartbeat"}\n')); // The chunker awaits each send, so let the write settle before the assertion. await new Promise((resolve) => setImmediate(resolve)); expect(process.handle?.inputs[1]).toBe('{"version":1,"type":"heartbeat"}\n'); @@ -287,7 +287,7 @@ describe("openDaytonaDuplexChannelSession", () => { const received: string[] = []; const session = await openDaytonaDuplexChannelSession(process, GATEWAY); - session.onData((chunk) => received.push(chunk)); + session.onData((chunk) => received.push(new TextDecoder().decode(chunk))); process.handle?.emitText('{"version":1,"type":"ready","address":"127.0.0.1:8080"}\n'); process.handle?.emitText('{"version":1,"type":"heartbeat"}\n'); @@ -305,7 +305,7 @@ describe("openDaytonaDuplexChannelSession", () => { const received: string[] = []; const session = await openDaytonaDuplexChannelSession(process, GATEWAY); - session.onData((chunk) => received.push(chunk)); + session.onData((chunk) => received.push(new TextDecoder().decode(chunk))); const frame = '{"version":1,"type":"response","id":"r1","status":200,"headers":{},"body":"","outcome":"completed"}\n'; process.handle?.emitText(frame.slice(0, 20)); @@ -319,7 +319,7 @@ describe("openDaytonaDuplexChannelSession", () => { const received: string[] = []; const session = await openDaytonaDuplexChannelSession(process, GATEWAY); - session.onData((chunk) => received.push(chunk)); + session.onData((chunk) => received.push(new TextDecoder().decode(chunk))); const body = "x".repeat(500_000); const frame = `{"version":1,"type":"response","id":"big","status":200,"headers":{},"body":"${body}","outcome":"completed"}\n`; @@ -329,20 +329,41 @@ describe("openDaytonaDuplexChannelSession", () => { expect(received.join("").length).toBe(frame.length); }); - it("keeps a multibyte character whole across two output chunks", async () => { + it("test_stream_forwards_bytes_without_utf8_conversion", async () => { const process = createFakeProcess(); - const received: string[] = []; + const received: Uint8Array[] = []; const session = await openDaytonaDuplexChannelSession(process, GATEWAY); session.onData((chunk) => received.push(chunk)); - // The euro sign is three UTF-8 bytes. Split it across two chunks, so the - // stream decoder must join the bytes. + // The euro sign is three UTF-8 bytes. Split it across two chunks. The + // session no longer runs a UTF-8 stream decoder (see `duplex-command-stream.ts`), + // so it forwards each raw byte chunk unchanged; it does not wait for a whole + // character. The two chunks concatenate back to the exact original bytes — + // proof that no layer here converts the data to a string. const euro = new TextEncoder().encode("€"); process.handle?.emitBytes(euro.subarray(0, 2)); process.handle?.emitBytes(euro.subarray(2)); - expect(received.join("")).toBe("€"); + expect(received).toHaveLength(2); + expect(received[0]).toEqual(euro.subarray(0, 2)); + expect(received[1]).toEqual(euro.subarray(2)); + const joined = Buffer.concat(received); + expect(joined).toEqual(Buffer.from(euro)); + expect(new TextDecoder().decode(joined)).toBe("€"); + }); + + it("sends the full 256-value byte corpus through the channel unchanged", async () => { + const process = createFakeProcess(); + const received: Uint8Array[] = []; + + const session = await openDaytonaDuplexChannelSession(process, GATEWAY); + session.onData((chunk) => received.push(chunk)); + + const allByteValues = Uint8Array.from({ length: 256 }, (_, value) => value); + process.handle?.emitBytes(allByteValues); + + expect(Buffer.concat(received)).toEqual(Buffer.from(allByteValues)); }); it("buffers early output until the listener registers", async () => { @@ -355,7 +376,7 @@ describe("openDaytonaDuplexChannelSession", () => { process.handle?.emitText('"type":"heartbeat"}\n'); expect(received).toEqual([]); - session.onData((chunk) => received.push(chunk)); + session.onData((chunk) => received.push(new TextDecoder().decode(chunk))); // The session flushes the buffered output in order on registration. expect(received).toEqual(['{"version":1,"type":"heartbeat"}\n']); }); @@ -393,7 +414,7 @@ describe("openDaytonaDuplexChannelSession", () => { // raised chunk size above this size makes the write one send and fails this // test. The payload is ASCII, so the fake's per-send decode keeps each byte. const payload = "x".repeat(150_416); - session.write(payload); + session.write(new TextEncoder().encode(payload)); // The chunker awaits each send, so let the write settle before the assertion. await new Promise((resolve) => setImmediate(resolve)); @@ -414,7 +435,7 @@ describe("openDaytonaDuplexChannelSession", () => { if (!handle) throw new Error("The fake process opened no handle."); const before = handle.inputs.length; - session.write('{"version":1,"type":"heartbeat"}\n'); + session.write(new TextEncoder().encode('{"version":1,"type":"heartbeat"}\n')); // The chunker awaits each send, so let the write settle before the assertion. await new Promise((resolve) => setImmediate(resolve)); @@ -449,8 +470,8 @@ describe("openDaytonaDuplexChannelSession", () => { const first = "a".repeat(150_416); const second = "b".repeat(150_416); - session.write(first); - session.write(second); + session.write(new TextEncoder().encode(first)); + session.write(new TextEncoder().encode(second)); // Let both writes settle. await new Promise((resolve) => setImmediate(resolve)); @@ -514,7 +535,7 @@ describe("openDaytonaDuplexChannelSession", () => { onWriteError: (reason) => writeErrors.push(reason), }); - session.write("frame\n"); + session.write(new TextEncoder().encode("frame\n")); // Let the rejected write settle. await new Promise((resolve) => setImmediate(resolve)); @@ -553,8 +574,8 @@ describe("openDaytonaDuplexChannelSession", () => { onWriteError: (reason) => writeErrors.push(reason), }); - session.write("a"); - session.write("b"); + session.write(new TextEncoder().encode("a")); + session.write(new TextEncoder().encode("b")); await new Promise((resolve) => setImmediate(resolve)); expect(writeErrors).toEqual(["write_error"]); @@ -595,8 +616,8 @@ describe("openDaytonaDuplexChannelSession", () => { onWriteError: (reason) => writeErrors.push(reason), }); - session.write("first\n"); - session.write("second\n"); + session.write(new TextEncoder().encode("first\n")); + session.write(new TextEncoder().encode("second\n")); // Let the write chain settle both queued writes. await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); diff --git a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.ts b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.ts index 3da94be8fc..e96d5a9ef5 100644 --- a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.ts +++ b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.ts @@ -54,10 +54,10 @@ import { sendPtyInputInChunks } from "./pty-chunked-input.js"; * worker forwards the data and the exit with no adapter. */ export interface DuplexChannelSession { - /** Registers the one data listener. The session streams each raw chunk in order. */ - onData(listener: (chunk: string) => void): void; + /** Registers the one data listener. The session streams each raw byte chunk in order. */ + onData(listener: (chunk: Uint8Array) => void): void; /** Writes raw input bytes to the pseudo-terminal. */ - write(data: string): void; + write(data: Uint8Array): void; /** * Resolves when the command ends or the transport closes. A numeric `exitCode` * is a real process exit. `transportClosed` is true when the pseudo-terminal @@ -169,18 +169,16 @@ export function buildDuplexChannelLaunchWrapper( * {@link DuplexChannelSession}. The session allocates a real pseudo-terminal in * raw mode, streams the raw output, accepts host input, and stops the child. * - * The function decodes the terminal bytes as a UTF-8 stream, so a multibyte - * character that splits across two output chunks stays whole. It buffers the - * output until the transport registers the listener, so no early chunk is lost. + * The function forwards the terminal bytes unchanged. It buffers the output + * until the transport registers the listener, so no early chunk is lost. */ export async function openDaytonaDuplexChannelSession( process: DaytonaPtyProcess, command: readonly string[], options?: DaytonaDuplexChannelOptions, ): Promise { - const decoder = new TextDecoder("utf-8"); - let listener: ((chunk: string) => void) | null = null; - let buffered = ""; + let listener: ((chunk: Uint8Array) => void) | null = null; + let buffered: Buffer = Buffer.alloc(0); const diagnosticsPath = options?.diagnosticsPath ?? `/tmp/paperclip-duplex-${randomUUID()}.log`; @@ -191,13 +189,12 @@ export async function openDaytonaDuplexChannelSession( cols: DUPLEX_CHANNEL_PTY_COLS, rows: DUPLEX_CHANNEL_PTY_ROWS, onData: (data: Uint8Array): void => { - // Decode the terminal bytes as a stream, so a split multibyte character - // stays whole across two chunks. Forward the raw text; the frame codec owns - // the newline-delimited JSON parsing. - const text = decoder.decode(data, { stream: true }); - if (text.length === 0) return; - if (listener) listener(text); - else buffered += text; + // Forward the raw bytes unchanged; the frame codec owns the + // newline-delimited JSON parsing and any multi-byte character + // reassembly on the read side. + if (data.byteLength === 0) return; + if (listener) listener(data); + else buffered = buffered.length === 0 ? Buffer.from(data) : Buffer.concat([buffered, data]); }, }); @@ -233,15 +230,15 @@ export async function openDaytonaDuplexChannelSession( let writeChain: Promise = Promise.resolve(); return { - onData(next: (chunk: string) => void): void { + onData(next: (chunk: Uint8Array) => void): void { listener = next; if (buffered.length > 0) { const pending = buffered; - buffered = ""; + buffered = Buffer.alloc(0); next(pending); } }, - write(data: string): void { + write(data: Uint8Array): void { // Send the input as byte-bounded chunks under the provider message cap. A // whole payload in one message can cross the cap and take the channel down, // so the chunker slices the payload and sends each chunk in order. The chain diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index 7340a8ceb0..89ae0af1b6 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -50,6 +50,10 @@ const manifest: PaperclipPluginManifestV1 = { // over a raw pseudo-terminal. Declare the opt-in capability so the host may // select the duplex transport. The host resolves it `true` only when the // worker also verifies the `duplexChannelOpen` handler. + // + // Daytona is the first provider that runs the HTTP/2 transport over this + // channel. HTTP/2 is the preferred transport. `queue_v1` is the + // soft-deprecated fallback. sandboxCapabilities: { incrementalSessionOutput: true, concurrentSyncOperations: true, diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index d1492ce014..23e5041937 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -201,11 +201,13 @@ describe("Daytona sandbox provider plugin", () => { mockCreate.mockResolvedValue(sandbox); // Capture the data and the exit the worker forwards through `ctx.duplexChannel`. + // `ctx.duplexChannel.data` carries raw bytes; decode each chunk to text so the + // assertion below reads the plain-text payload. const dataChunks: Array<{ hostRouteId: string; workerSessionId: string; chunk: string }> = []; const restore = __setDaytonaPluginContextForTest({ duplexChannel: { - data: (hostRouteId: string, workerSessionId: string, chunk: string) => - dataChunks.push({ hostRouteId, workerSessionId, chunk }), + data: (hostRouteId: string, workerSessionId: string, chunk: Uint8Array) => + dataChunks.push({ hostRouteId, workerSessionId, chunk: Buffer.from(chunk).toString("utf8") }), exit: () => {}, }, } as unknown as PluginContext); @@ -242,10 +244,12 @@ describe("Daytona sandbox provider plugin", () => { expect(inputs[0]).toMatch(/2>'\/tmp\/paperclip-duplex-.+\.log'/); // A host write on the exact pair reaches the process on the same channel. + // `data` arrives in the wire-safe base64 form (see `ChannelBytesWireValue` + // in the plugin SDK's protocol.ts). await plugin.definition.onDuplexChannelWrite?.({ hostRouteId: "route-1", workerSessionId, - data: '{"version":1,"type":"heartbeat"}\n', + data: Buffer.from('{"version":1,"type":"heartbeat"}\n', "utf8").toString("base64"), }); expect(inputs[1]).toBe('{"version":1,"type":"heartbeat"}\n'); @@ -255,7 +259,7 @@ describe("Daytona sandbox provider plugin", () => { await plugin.definition.onDuplexChannelWrite?.({ hostRouteId: "route-foreign", workerSessionId, - data: "foreign\n", + data: Buffer.from("foreign\n", "utf8").toString("base64"), }); expect(inputs.length).toBe(inputsBeforeForeign); // A stop whose pair does not match the bound entry stops nothing. @@ -297,7 +301,7 @@ describe("Daytona sandbox provider plugin", () => { await plugin.definition.onDuplexChannelWrite?.({ hostRouteId: "route-1", workerSessionId, - data: "late\n", + data: Buffer.from("late\n", "utf8").toString("base64"), }); expect(inputs.length).toBe(inputsBefore); } finally { diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index f1993eafeb..85665d4724 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -9,7 +9,7 @@ import type { Resources, Sandbox, } from "@daytonaio/sdk"; -import { definePlugin, NOOP_PLUGIN_TRACER } from "@paperclipai/plugin-sdk"; +import { decodeChannelBytes, definePlugin, NOOP_PLUGIN_TRACER } from "@paperclipai/plugin-sdk"; import type { PluginContext, PluginTracer, @@ -2861,10 +2861,18 @@ const plugin = definePlugin({ // Write host input to an open duplex channel. Act only on the exact live pair. // A write whose pair does not match the bound entry applies no bytes. + // + // `params.data` arrives in the wire-safe base64 form (JSON carries no binary + // type; see `ChannelBytesWireValue` in the plugin SDK's protocol.ts). Decode it + // back to raw bytes before it reaches the pseudo-terminal. A malformed value + // decodes to `null`; the worker applies no bytes rather than sending an empty + // write to the sandbox. async onDuplexChannelWrite(params) { const entry = daytonaDuplexChannelBySession.get(params.workerSessionId); if (!entry || entry.hostRouteId !== params.hostRouteId) return; - entry.session.write(params.data); + const data = decodeChannelBytes(params.data); + if (data === null) return; + entry.session.write(data); }, // Stop an open duplex channel child. Act only on the exact live pair. A stop diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts index 525e81e0ae..0ff9ef74f7 100644 --- a/packages/plugins/sdk/src/define-plugin.ts +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -478,6 +478,8 @@ export interface PluginDefinition { * and the exit through worker→host notifications, never as a reply. Defining * the four `onDuplexChannel*` hooks advertises the four methods. The host reads * the open verb to gate the `duplexCommandStream` capability. + * + * HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. */ onDuplexChannelOpen?( params: PluginDuplexChannelOpenParams, diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 66b7d2e8d8..6ffe374e7d 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -86,6 +86,8 @@ export { LOGIN_PTY_EXIT_NOTIFICATION, DUPLEX_CHANNEL_DATA_NOTIFICATION, DUPLEX_CHANNEL_EXIT_NOTIFICATION, + encodeChannelBytes, + decodeChannelBytes, _resetIdCounter, } from "./protocol.js"; diff --git a/packages/plugins/sdk/src/protocol.duplex-channel.test.ts b/packages/plugins/sdk/src/protocol.duplex-channel.test.ts index d8c40f7e4c..18255eb3d6 100644 --- a/packages/plugins/sdk/src/protocol.duplex-channel.test.ts +++ b/packages/plugins/sdk/src/protocol.duplex-channel.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from "vitest"; import { + createNotification, + decodeChannelBytes, DUPLEX_CHANNEL_DATA_NOTIFICATION, DUPLEX_CHANNEL_EXIT_NOTIFICATION, + encodeChannelBytes, + parseMessage, + serializeMessage, type PluginDuplexChannelCloseParams, type PluginDuplexChannelCloseResult, type PluginDuplexChannelDataParams, @@ -219,3 +224,52 @@ describe("duplex channel notification schemas", () => { expect(exit).toBeDefined(); }); }); + +// JSON-RPC travels as one line of JSON text (see `serializeMessage`), and JSON +// carries no binary type. `chunk` and `data` cross this hop as a base64 string +// (`ChannelBytesWireValue`); `encodeChannelBytes`/`decodeChannelBytes` are the +// one shared codec both ends use. +describe("duplex channel byte-safe wire representation", () => { + it("test_json_rpc_hop_preserves_all_byte_values", () => { + const allByteValues = Uint8Array.from({ length: 256 }, (_, value) => value); + + // Encode the full byte corpus into a data notification, exactly as + // worker-rpc-host.ts does at the worker→host boundary, then send it through + // the same newline-delimited JSON serialization the host and the worker use + // over stdio. + const params: PluginDuplexChannelDataParams = { + hostRouteId: "route-1", + workerSessionId: "ws-1", + chunk: encodeChannelBytes(allByteValues), + }; + const line = serializeMessage( + createNotification(DUPLEX_CHANNEL_DATA_NOTIFICATION, params), + ); + + // Parse the line back, exactly as the reading side does, and decode the + // chunk back to raw bytes. + const parsed = parseMessage(line); + expect("params" in parsed).toBe(true); + const receivedParams = (parsed as { params: PluginDuplexChannelDataParams }).params; + const decoded = decodeChannelBytes(receivedParams.chunk); + + expect(decoded).not.toBeNull(); + expect(decoded).toEqual(allByteValues); + // The byte value zero is the case a UTF-8 string hop loses or mistreats as a + // terminator. Assert it by name, not only as part of the full-corpus check. + expect(decoded?.[0]).toBe(0); + }); + + it("round-trips an empty byte chunk through encode and decode", () => { + const empty = new Uint8Array(0); + const encoded = encodeChannelBytes(empty); + expect(encoded).toBe(""); + expect(decodeChannelBytes(encoded)).toBeNull(); + }); + + it("decodeChannelBytes rejects a malformed wire value", () => { + expect(decodeChannelBytes("not valid base64!!")).toBeNull(); + expect(decodeChannelBytes(undefined)).toBeNull(); + expect(decodeChannelBytes(42)).toBeNull(); + }); +}); diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 5ba3041618..0f7f1933ba 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -1099,6 +1099,50 @@ export const LOGIN_PTY_OUTPUT_NOTIFICATION = "loginPty.output"; /** The worker→host notification method for one pseudo-terminal exit. */ export const LOGIN_PTY_EXIT_NOTIFICATION = "loginPty.exit"; +// --------------------------------------------------------------------------- +// Byte-safe duplex channel wire representation. +// --------------------------------------------------------------------------- +// A JSON-RPC message travels as one line of JSON text (see `serializeMessage` +// below). JSON has no binary type, so a raw byte chunk cannot cross this hop +// unchanged. `ChannelBytesWireValue` is the one JSON-safe encoding this +// protocol uses for a duplex channel chunk: a base64 string. +// +// Every layer above this hop carries the chunk as `Uint8Array`. This includes +// the plugin context, the worker RPC host's public duplex methods, and the +// host-side plugin worker manager. Only the JSON-RPC message itself holds the +// base64 form, and only for the one hop between the host process and the +// worker process. +// +// This base64 form is not the sandbox provider channel's wire format. That +// channel carries raw bytes with no base64 armor: a live measurement of the +// provider transport proved that every byte value survives it unchanged. +// +// HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. + +/** The wire-safe JSON-RPC form of one duplex channel byte chunk: a base64 string. */ +export type ChannelBytesWireValue = string; + +/** Encodes raw channel bytes into the wire-safe JSON-RPC representation. */ +export function encodeChannelBytes(bytes: Uint8Array): ChannelBytesWireValue { + return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64"); +} + +/** + * Decodes the wire-safe JSON-RPC representation back to raw channel bytes. + * Returns `null` for a value that is not a well-formed base64 string, so a + * caller on the trust boundary treats a malformed frame as a protocol error + * instead of silently substituting the empty byte array. + */ +export function decodeChannelBytes(value: unknown): Uint8Array | null { + if (typeof value !== "string" || value.length === 0) return null; + // `Buffer.from(str, "base64")` silently drops an invalid character instead + // of throwing, so re-encode the decoded bytes and compare. A well-formed + // base64 string round-trips to itself; a malformed one does not. + const decoded = Buffer.from(value, "base64"); + if (decoded.toString("base64") !== value) return null; + return new Uint8Array(decoded.buffer, decoded.byteOffset, decoded.byteLength); +} + // --------------------------------------------------------------------------- // Generic duplex channel worker methods. // --------------------------------------------------------------------------- @@ -1150,8 +1194,8 @@ export interface PluginDuplexChannelWriteParams { hostRouteId: string; /** The worker session identifier that the open reply returned. */ workerSessionId: string; - /** The raw input bytes to write to the channel. */ - data: string; + /** The raw input bytes to write to the channel, in the {@link ChannelBytesWireValue} wire form. */ + data: ChannelBytesWireValue; } /** The stop request. It carries the exact route pair. */ @@ -1196,8 +1240,8 @@ export interface PluginDuplexChannelDataParams { hostRouteId: string; /** The worker session identifier that the open reply returned. */ workerSessionId: string; - /** The raw channel output bytes. */ - chunk: string; + /** The raw channel output bytes, in the {@link ChannelBytesWireValue} wire form. */ + chunk: ChannelBytesWireValue; } /** The worker→host duplex channel exit notification parameters. */ diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 6a7a50b352..6cb96790da 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -2487,7 +2487,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { }, }, duplexChannel: { - data(_hostRouteId: string, _workerSessionId: string, _chunk: string) { + data(_hostRouteId: string, _workerSessionId: string, _chunk: Uint8Array) { // No-op in test harness — the host duplex route is not wired here. }, exit(_hostRouteId: string, _workerSessionId: string, _exitCode: number | null) { diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 18618a1271..92075ce3a1 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -2056,9 +2056,9 @@ export interface PluginDuplexChannelClient { * * @param hostRouteId - The host route identifier the open request carried. The worker echoes it, so the host routes the exact pair. * @param workerSessionId - The worker session identifier the open reply returned. - * @param chunk - The raw channel output text. + * @param chunk - The raw channel output bytes. */ - data(hostRouteId: string, workerSessionId: string, chunk: string): void; + data(hostRouteId: string, workerSessionId: string, chunk: Uint8Array): void; /** * Deliver the child exit of a persistent duplex channel. * diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index c3652810f5..5630e2a8e5 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -133,6 +133,7 @@ import { isJsonRpcErrorResponse, JsonRpcParseError, JsonRpcCallError, + encodeChannelBytes, } from "./protocol.js"; // --------------------------------------------------------------------------- @@ -1403,17 +1404,25 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, duplexChannel: { - data(hostRouteId: string, workerSessionId: string, chunk: string): void { + data(hostRouteId: string, workerSessionId: string, chunk: Uint8Array): void { // Forward one raw data chunk of a persistent duplex channel. The // notification echoes the host route identifier and the worker session // identifier, so the host routes the chunk to the exact live pair while // the route is open. The host drops an unknown or a mismatched pair and // never logs the raw bytes. This notification carries no invocation id, // because it fires after the open reply returns. + // + // JSON-RPC travels as JSON text, which carries no binary type, so this + // is the one point where the chunk crosses from `Uint8Array` to the + // wire-safe base64 form. See `ChannelBytesWireValue` in protocol.ts. if (typeof hostRouteId !== "string" || hostRouteId.length === 0) return; if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return; - if (typeof chunk !== "string" || chunk.length === 0) return; - notifyHost(DUPLEX_CHANNEL_DATA_NOTIFICATION, { hostRouteId, workerSessionId, chunk }); + if (!(chunk instanceof Uint8Array) || chunk.byteLength === 0) return; + notifyHost(DUPLEX_CHANNEL_DATA_NOTIFICATION, { + hostRouteId, + workerSessionId, + chunk: encodeChannelBytes(chunk), + }); }, exit( hostRouteId: string, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 30ff21d289..8131ce5712 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -182,6 +182,8 @@ export interface SandboxProviderCapabilities { * key denies the capability, so the provider keeps the file bridge. Only a * provider that declares this key `true` and whose worker verifies the duplex * open method selects the duplex channel path. + * + * HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. */ duplexCommandStream?: boolean; } diff --git a/server/src/__tests__/environment-execution-target-duplex.test.ts b/server/src/__tests__/environment-execution-target-duplex.test.ts index a0d7b2f2d9..bc45c9ea58 100644 --- a/server/src/__tests__/environment-execution-target-duplex.test.ts +++ b/server/src/__tests__/environment-execution-target-duplex.test.ts @@ -149,8 +149,9 @@ describe("sandbox driver duplex channel wiring", () => { }); // write / stop / close map to write / kill / close on the host session. - channel.write("input-bytes"); - expect(hostSession.write).toHaveBeenCalledWith("input-bytes"); + const inputBytes = new TextEncoder().encode("input-bytes"); + channel.write(inputBytes); + expect(hostSession.write).toHaveBeenCalledWith(inputBytes); channel.stop(); expect(hostSession.kill).toHaveBeenCalledTimes(1); await channel.close(); diff --git a/server/src/__tests__/fixtures/plugin-worker-duplex-channel.cjs b/server/src/__tests__/fixtures/plugin-worker-duplex-channel.cjs index 69ac9e7e45..aebae1cb32 100644 --- a/server/src/__tests__/fixtures/plugin-worker-duplex-channel.cjs +++ b/server/src/__tests__/fixtures/plugin-worker-duplex-channel.cjs @@ -22,7 +22,15 @@ // read order — proving the host still bounds these frames on their own terms // and never lets the exit crowd a data frame out of the pre-open hold. // - `echoInput`: when true, the fixture echoes each `duplexChannelWrite` back as -// one data notification for the bound session. +// one data notification for the bound session, prefixed with `echo:` at the +// byte level (so the echo round-trips a non-UTF-8 payload unchanged). +// +// The JSON-RPC hop carries a duplex chunk as a base64 string (see +// `ChannelBytesWireValue` in packages/plugins/sdk/src/protocol.ts). This fixture +// is a hand-rolled worker, not the real SDK, so it encodes and decodes base64 +// itself with the two helpers below. A `chunk` directive value in a test is +// always the plain-text payload; the fixture is the one place that puts it on +// the wire as base64. // - `writeReplyDelayMs`: when a positive number, the fixture delays each // `duplexChannelWrite` reply by that many milliseconds, so a test proves the // host holds the pending-write reservation until the RPC settles. @@ -48,6 +56,19 @@ function send(message) { process.stdout.write(`${JSON.stringify(message)}\n`); } +// Encode one plain-text directive chunk to the wire-safe base64 form. The real +// worker (worker-rpc-host.ts, `encodeChannelBytes`) does the same encoding. +function toWireChunk(text) { + return Buffer.from(text, "utf8").toString("base64"); +} + +// Decode the wire-safe base64 form of one host→worker write back to a Buffer, +// so the echo path works on exact bytes rather than a UTF-8 string. This keeps +// the echo byte-exact for a payload that is not valid UTF-8. +function fromWireChunk(wireValue) { + return Buffer.from(wireValue, "base64"); +} + // Serialize the scripted data and exit frames as newline-delimited lines. The // batch mode writes these together with the open reply in one stdout write. Each // frame carries the exact pair, so the host binds and routes by the pair. A test @@ -62,7 +83,7 @@ function scriptedFrameLines(directive, hostRouteId, workerSessionId) { params: { hostRouteId: entry.rid ?? hostRouteId, workerSessionId: entry.sid ?? workerSessionId, - chunk: entry.chunk, + chunk: toWireChunk(entry.chunk), }, })}\n`; } @@ -89,7 +110,7 @@ function scriptedFrameLines(directive, hostRouteId, workerSessionId) { params: { hostRouteId: entry.rid ?? hostRouteId, workerSessionId: entry.sid ?? workerSessionId, - chunk: entry.chunk, + chunk: toWireChunk(entry.chunk), }, })}\n`; } @@ -223,13 +244,16 @@ rl.on("line", (line) => { if (entry.echoInput) { // Echo the input back as one data notification for the bound pair, so a // test proves the input reaches the worker and the output routes back. + // Work on the decoded Buffer, not a string, so a non-UTF-8 payload (the + // full 256-value byte corpus) echoes byte-exact under the `echo:` prefix. + const echoBytes = Buffer.concat([Buffer.from("echo:", "utf8"), fromWireChunk(params.data)]); send({ jsonrpc: "2.0", method: "duplexChannel.data", params: { hostRouteId: entry.hostRouteId, workerSessionId: entry.workerSessionId, - chunk: `echo:${params.data}`, + chunk: echoBytes.toString("base64"), }, }); } @@ -278,7 +302,7 @@ rl.on("line", (line) => { params: { hostRouteId: entry.hostRouteId, workerSessionId: entry.workerSessionId, - chunk: entry.emitAfterCloseChunk, + chunk: toWireChunk(entry.emitAfterCloseChunk), }, }); }); diff --git a/server/src/__tests__/plugin-worker-manager-duplex-byte-ledger.test.ts b/server/src/__tests__/plugin-worker-manager-duplex-byte-ledger.test.ts index 2b2215891a..97f3783d84 100644 --- a/server/src/__tests__/plugin-worker-manager-duplex-byte-ledger.test.ts +++ b/server/src/__tests__/plugin-worker-manager-duplex-byte-ledger.test.ts @@ -104,7 +104,10 @@ describe("plugin worker manager duplex aggregate byte ledger", () => { }); // A late listener drains the terminal buffered record and releases its token. const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session now streams raw `Uint8Array` chunks. Decode each one back + // to text, so the assertion below still compares the plain-text payload + // the fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); await vi.waitFor(() => { expect(chunks).toEqual(["ok"]); expect(ledger.bytesInUse).toBe(0); @@ -142,7 +145,10 @@ describe("plugin worker manager duplex aggregate byte ledger", () => { expect(ledger.liveTokenCount).toBe(2); }); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session now streams raw `Uint8Array` chunks. Decode each one back + // to text, so the assertion below still compares the plain-text payload + // the fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); await vi.waitFor(() => { expect(chunks).toEqual(["aa", "bb"]); expect(ledger.bytesInUse).toBe(0); diff --git a/server/src/__tests__/plugin-worker-manager-duplex-pending-write-ledger.test.ts b/server/src/__tests__/plugin-worker-manager-duplex-pending-write-ledger.test.ts index 4c22219eba..983925cbce 100644 --- a/server/src/__tests__/plugin-worker-manager-duplex-pending-write-ledger.test.ts +++ b/server/src/__tests__/plugin-worker-manager-duplex-pending-write-ledger.test.ts @@ -133,9 +133,10 @@ describe("plugin worker manager duplex pending-write byte ledger", () => { const route = await handle.openDuplexChannel( duplexOpenInput({ workerSessionId: "ws-a", mode: "no-write-reply" }), ); - route.write(writeData); - route.write(writeData); - route.write(writeData); + const writeBytesEncoded = new TextEncoder().encode(writeData); + route.write(writeBytesEncoded); + route.write(writeBytesEncoded); + route.write(writeBytesEncoded); // The worker reads its stdin, so each transport token flushes and releases. // Only the three held raw payloads remain, so the gauge settles at three // times the payload byte count with three live tokens. @@ -168,7 +169,7 @@ describe("plugin worker manager duplex pending-write byte ledger", () => { duplexOpenInput({ workerSessionId: "ws-d", writeReplyDelayMs: 150 }), ); const writeBytes = 1000; - route.write("x".repeat(writeBytes)); + route.write(new TextEncoder().encode("x".repeat(writeBytes))); // The transport token flushes at once, so one raw-payload token stays held for // the full write byte count while the RPC is in flight. await vi.waitFor(() => { @@ -201,7 +202,7 @@ describe("plugin worker manager duplex pending-write byte ledger", () => { const data = "a€"; expect(data.length).toBe(2); expect(Buffer.byteLength(data, "utf8")).toBe(4); - route.write(data); + route.write(new TextEncoder().encode(data)); // The transport token flushes, so one raw-payload token of four bytes remains. await vi.waitFor(() => { expect(ledger.liveTokenCount).toBe(1); @@ -232,7 +233,7 @@ describe("plugin worker manager duplex pending-write byte ledger", () => { // The single write is larger than the ceiling. The raw-payload reservation // fails, so the manager ends the route with the aggregate marker, never the // route-busy marker, and never writes the frame. - route.write("x".repeat(200)); + route.write(new TextEncoder().encode("x".repeat(200))); expect(telemetry.rejections).toBeGreaterThanOrEqual(1); expect(ledger.bytesInUse).toBe(0); expect(telemetry.peak).toBeLessThanOrEqual(ledger.ceilingBytes); diff --git a/server/src/__tests__/plugin-worker-manager-duplex-stdin-write-ledger.test.ts b/server/src/__tests__/plugin-worker-manager-duplex-stdin-write-ledger.test.ts index 27668aafd2..d20d38da32 100644 --- a/server/src/__tests__/plugin-worker-manager-duplex-stdin-write-ledger.test.ts +++ b/server/src/__tests__/plugin-worker-manager-duplex-stdin-write-ledger.test.ts @@ -125,7 +125,7 @@ function loggedWarnReasons(): string[] { const HELD_WRITE_CHARS = 200_000; describe("plugin worker manager duplex stdin transport byte ledger", () => { - it("reserves the encoded serialized-frame size, larger than the raw payload, including JSON escaping", async () => { + it("reserves the encoded serialized-frame size, larger than the raw payload, including the base64 wire inflation", async () => { const telemetry = peakTrackingTelemetry(); const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry }); const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger }); @@ -138,19 +138,24 @@ describe("plugin worker manager duplex stdin transport byte ledger", () => { exitAfterStopMs: 500, }), ); - // Every character is a quote. The JSON serialization escapes each quote to two - // bytes, so the serialized frame is at least twice the raw payload byte count. - const data = '"'.repeat(50_000); - const rawBytes = Buffer.byteLength(data, "utf8"); + // The JSON-RPC hop carries the write payload as a base64 string, because JSON + // has no binary type (see `ChannelBytesWireValue` in protocol.ts). Base64 + // encodes three raw bytes as four characters, so the serialized frame is + // larger than the raw payload by about that ratio, plus the small fixed cost + // of the surrounding JSON envelope (the method name and the route identifiers). + const rawBytes = 50_000; + const data = new Uint8Array(rawBytes).fill(0x22); // an arbitrary byte value + const encodedLength = Buffer.from(data).toString("base64").length; route.write(data); // The raw-payload token and the transport token both hold at once. The worker // does not read its stdin, so the transport token never flushes. expect(ledger.liveTokenCount).toBe(2); const transportBytes = ledger.bytesInUse - rawBytes; - // The transport reservation covers the serialized frame, so it is larger than - // the raw payload and it includes the doubled escaped quotes. + // The transport reservation covers the serialized frame: at least the base64 + // form of the raw payload, plus a bounded JSON envelope around it. expect(transportBytes).toBeGreaterThan(rawBytes); - expect(transportBytes).toBeGreaterThanOrEqual(2 * rawBytes); + expect(transportBytes).toBeGreaterThanOrEqual(encodedLength); + expect(transportBytes).toBeLessThan(encodedLength + 5_000); } finally { await handle.stop().catch(() => undefined); } @@ -182,7 +187,7 @@ describe("plugin worker manager duplex stdin transport byte ledger", () => { ); const data = "x".repeat(HELD_WRITE_CHARS); const rawBytes = Buffer.byteLength(data, "utf8"); - route.write(data); + route.write(new TextEncoder().encode(data)); // Both tokens hold at first: the raw payload and the serialized frame. expect(ledger.liveTokenCount).toBe(2); const transportBytes = ledger.bytesInUse - rawBytes; @@ -219,7 +224,7 @@ describe("plugin worker manager duplex stdin transport byte ledger", () => { ); const data = "x".repeat(HELD_WRITE_CHARS); const rawBytes = Buffer.byteLength(data, "utf8"); - route.write(data); + route.write(new TextEncoder().encode(data)); expect(ledger.liveTokenCount).toBe(2); const transportBytes = ledger.bytesInUse - rawBytes; // End the route. Route terminalization releases the route-owned tokens, but it @@ -263,12 +268,10 @@ describe("plugin worker manager duplex stdin transport byte ledger", () => { exitAfterStopMs: 900, }), ); - // Each write mixes escaped quotes and multi-byte characters near the per-write - // size. The serialized frame escapes each quote and keeps each euro sign as - // three UTF-8 bytes, so the reservation must count the encoded size. - const escaped = '"'.repeat(40_000); - const multiByte = "€".repeat(20_000); - const nearLimit = escaped + multiByte; + // Each write is near the per-write size. The JSON-RPC hop carries it as + // base64, so the reservation must count the encoded (larger) wire size, not + // the raw payload size. + const nearLimit = new Uint8Array(100_000).fill(0x22); const routes = [routeA, routeB, routeA, routeB]; for (const route of routes) { route.write(nearLimit); diff --git a/server/src/__tests__/plugin-worker-manager-duplex.test.ts b/server/src/__tests__/plugin-worker-manager-duplex.test.ts index 0b746e8bd0..40d9c3f552 100644 --- a/server/src/__tests__/plugin-worker-manager-duplex.test.ts +++ b/server/src/__tests__/plugin-worker-manager-duplex.test.ts @@ -64,7 +64,10 @@ describe("plugin worker manager duplex channel route", () => { }), ); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session streams raw `Uint8Array` chunks. Decode each one back to + // text, so the assertion below compares the plain-text payload the + // fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); // The forged pair is an ownership violation. The host reaches no listener and // retires the worker, so the wait settles with the fixed non-secret null exit. await expect(session.wait()).resolves.toEqual({ exitCode: null }); @@ -87,7 +90,10 @@ describe("plugin worker manager duplex channel route", () => { }), ); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session streams raw `Uint8Array` chunks. Decode each one back to + // text, so the assertion below compares the plain-text payload the + // fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); await expect(session.wait()).resolves.toEqual({ exitCode: null }); expect(chunks).toEqual([]); } finally { @@ -103,8 +109,11 @@ describe("plugin worker manager duplex channel route", () => { duplexOpenInput({ workerSessionId: "ws-A", echoInput: true }), ); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); - session.write("callback-payload"); + // The session streams raw `Uint8Array` chunks. Decode each one back to + // text, so the assertion below compares the plain-text payload the + // fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); + session.write(new TextEncoder().encode("callback-payload")); // The worker echoes the input as one data notification for the bound // session, so the listener receives it. await vi.waitFor(() => expect(chunks).toContain("echo:callback-payload")); @@ -127,7 +136,10 @@ describe("plugin worker manager duplex channel route", () => { // attaches. The drain then delivers them in order. await new Promise((resolve) => setTimeout(resolve, 60)); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session streams raw `Uint8Array` chunks. Decode each one back to + // text, so the assertion below compares the plain-text payload the + // fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); await vi.waitFor(() => expect(chunks.length).toBe(3)); expect(chunks).toEqual(["one", "two", "three"]); await session.close(); @@ -152,8 +164,9 @@ describe("plugin worker manager duplex channel route", () => { // does not escape the worker stdout notification handler. The later chunk // still routes and the route still settles. session.onData((chunk) => { - chunks.push(chunk); - if (chunk === "boom") throw new Error("listener failure"); + const text = new TextDecoder().decode(chunk); + chunks.push(text); + if (text === "boom") throw new Error("listener failure"); }); await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); expect(chunks).toEqual(["ok-1", "boom", "ok-2"]); @@ -176,7 +189,10 @@ describe("plugin worker manager duplex channel route", () => { }), ); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session streams raw `Uint8Array` chunks. Decode each one back to + // text, so the assertion below compares the plain-text payload the + // fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); // The discriminator survives the worker exit notification, so the host wait // resolves with the transport-close mark and no exit code. await expect(session.wait()).resolves.toEqual({ exitCode: null, transportClosed: true }); @@ -205,8 +221,9 @@ describe("plugin worker manager duplex channel route", () => { // still routes. expect(() => session.onData((chunk) => { - chunks.push(chunk); - if (chunk === "boom") throw new Error("listener failure"); + const text = new TextDecoder().decode(chunk); + chunks.push(text); + if (text === "boom") throw new Error("listener failure"); }), ).not.toThrow(); expect(chunks).toEqual(["one", "boom", "three"]); @@ -229,7 +246,10 @@ describe("plugin worker manager duplex channel route", () => { }), ); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session streams raw `Uint8Array` chunks. Decode each one back to + // text, so the assertion below compares the plain-text payload the + // fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); // The duplicate open reply never rebinds or reopens the route, so the // session runs normally on the one bind. await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); @@ -269,8 +289,8 @@ describe("plugin worker manager duplex channel route", () => { ); const aChunks: string[] = []; const bChunks: string[] = []; - routeA.onData((chunk) => aChunks.push(chunk)); - routeB.onData((chunk) => bChunks.push(chunk)); + routeA.onData((chunk) => aChunks.push(new TextDecoder().decode(chunk))); + routeB.onData((chunk) => bChunks.push(new TextDecoder().decode(chunk))); await expect(routeA.wait()).resolves.toEqual({ exitCode: 0 }); await expect(routeB.wait()).resolves.toEqual({ exitCode: 0 }); // The host routes each frame by the exact pair, so each route receives only @@ -313,7 +333,7 @@ describe("plugin worker manager duplex channel route", () => { duplexOpenInput({ workerSessionId: "ws-A", emitAfterCloseChunk: "after-close" }), ); const chunks: string[] = []; - first.onData((chunk) => chunks.push(chunk)); + first.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); // Close the route. The host installs the tombstone atomically before the slot // frees. The worker then emits one late frame for the closed pair. await first.close(); @@ -327,7 +347,7 @@ describe("plugin worker manager duplex channel route", () => { duplexOpenInput({ workerSessionId: "ws-B", data: [{ chunk: "b-1" }], exitCode: 0 }), ); const secondChunks: string[] = []; - second.onData((chunk) => secondChunks.push(chunk)); + second.onData((chunk) => secondChunks.push(new TextDecoder().decode(chunk))); await expect(second.wait()).resolves.toEqual({ exitCode: 0 }); expect(secondChunks).toEqual(["b-1"]); await second.close(); @@ -394,9 +414,9 @@ describe("plugin worker manager duplex channel route", () => { const waitResult = session.wait(); // The worker never replies to a write, so each write stays pending. The // third write passes the pending-request bound and the route ends. - session.write("one"); - session.write("two"); - session.write("three"); + session.write(new TextEncoder().encode("one")); + session.write(new TextEncoder().encode("two")); + session.write(new TextEncoder().encode("three")); await expect(waitResult).resolves.toEqual({ exitCode: null }); } finally { await handle.stop().catch(() => undefined); @@ -415,7 +435,7 @@ describe("plugin worker manager duplex channel route", () => { const waitResult = session.wait(); // One write is larger than the size bound, so the host rejects it and ends // the route before it reaches the worker. - session.write("this-write-is-too-large"); + session.write(new TextEncoder().encode("this-write-is-too-large")); await expect(waitResult).resolves.toEqual({ exitCode: null }); } finally { await handle.stop().catch(() => undefined); @@ -463,7 +483,10 @@ describe("plugin worker manager duplex channel route", () => { }), ); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session streams raw `Uint8Array` chunks. Decode each one back to + // text, so the assertion below compares the plain-text payload the + // fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); // A listener is bound, so the host forwards each chunk until the cumulative // bytes pass the cap. The third chunk passes the cap, so the host drops it // and ends the route. The listener never receives data past the cap. @@ -492,7 +515,10 @@ describe("plugin worker manager duplex channel route", () => { }), ); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session streams raw `Uint8Array` chunks. Decode each one back to + // text, so the assertion below compares the plain-text payload the + // fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); await expect(session.wait()).resolves.toEqual({ exitCode: null }); expect(chunks).toEqual(["€"]); } finally { @@ -521,7 +547,10 @@ describe("plugin worker manager duplex channel route", () => { // drop a buffered valid chunk when the route ends before a listener binds. await expect(session.wait()).resolves.toEqual({ exitCode: null }); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session streams raw `Uint8Array` chunks. Decode each one back to + // text, so the assertion below compares the plain-text payload the + // fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); expect(chunks).toEqual(["€"]); } finally { await handle.stop().catch(() => undefined); @@ -581,7 +610,10 @@ describe("plugin worker manager duplex channel route", () => { }), ); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session streams raw `Uint8Array` chunks. Decode each one back to + // text, so the assertion below compares the plain-text payload the + // fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); // A listener is bound. One inbound chunk is larger than the per-chunk // limit, so the host ends the route at once and never forwards the chunk. await expect(session.wait()).resolves.toEqual({ exitCode: null }); @@ -612,7 +644,10 @@ describe("plugin worker manager duplex channel route", () => { }), ); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session streams raw `Uint8Array` chunks. Decode each one back to + // text, so the assertion below compares the plain-text payload the + // fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); expect(chunks).toEqual(["batched-one", "batched-two"]); await session.close(); @@ -736,7 +771,10 @@ describe("plugin worker manager duplex channel route", () => { }), ); const chunks: string[] = []; - session.onData((chunk) => chunks.push(chunk)); + // The session streams raw `Uint8Array` chunks. Decode each one back to + // text, so the assertion below compares the plain-text payload the + // fixture directive scripted. + session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk))); await expect(session.wait()).resolves.toEqual({ exitCode: null }); expect(chunks).toEqual(["€"]); } finally { @@ -744,6 +782,41 @@ describe("plugin worker manager duplex channel route", () => { } }); + // ------------------------------------------------------------------------- + // Byte fidelity across the worker remote-procedure-call hop. + // ------------------------------------------------------------------------- + + it("test_worker_channel_preserves_all_byte_values", async () => { + const handle = makeDuplexHandle(); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ workerSessionId: "ws-A", echoInput: true }), + ); + const allByteValues = Uint8Array.from({ length: 256 }, (_, value) => value); + const received: Uint8Array[] = []; + session.onData((chunk) => received.push(chunk)); + + session.write(allByteValues); + + // The fixture echoes the write as one data notification, prefixed with the + // five ASCII bytes "echo:". It builds the echo on the decoded byte buffer, + // not a string, so the round trip through the base64 JSON-RPC wire form + // (`ChannelBytesWireValue`) carries every one of the 256 byte values + // unchanged, including the byte value zero, which a UTF-8 string hop would + // not preserve reliably end to end. + await vi.waitFor(() => expect(received.length).toBe(1)); + const echoPrefix = new TextEncoder().encode("echo:"); + const echoed = received[0]!; + expect(echoed.byteLength).toBe(echoPrefix.byteLength + allByteValues.byteLength); + expect(echoed.subarray(0, echoPrefix.byteLength)).toEqual(echoPrefix); + expect(echoed.subarray(echoPrefix.byteLength)).toEqual(allByteValues); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + // ------------------------------------------------------------------------- // Authoritative closure and worker retirement. // ------------------------------------------------------------------------- diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 9730708f35..5ad4642b1b 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -557,6 +557,7 @@ export async function resolveEnvironmentExecutionTarget(input: { // (resolution failed or the snapshot is not resolvable) leaves the // member undefined, so the caller keeps the file bridge. This mirrors // the syncIn/syncOut gate above and fails closed. + // HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. ...(effectiveCapabilities?.duplexCommandStream ? { openDuplexChannel: (channelInput) => diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index ebb90b6e87..4d4b152066 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -577,6 +577,8 @@ export interface EnvironmentRuntimeDriver { * driver whose lease grants the `duplexCommandStream` capability. The driver * opens the host-owned duplex route on the plugin worker and adapts it to the * cross-layer {@link CommandManagedDuplexChannel}. Other drivers omit it. + * + * HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. */ openDuplexChannel?( input: EnvironmentDriverOpenDuplexChannelInput, @@ -1137,10 +1139,10 @@ function adaptDuplexChannelHostSession( session: DuplexChannelHostSession, ): CommandManagedDuplexChannel { return { - write(data: string): void { + write(data: Uint8Array): void { session.write(data); }, - onData(listener: (chunk: string) => void): void { + onData(listener: (chunk: Uint8Array) => void): void { session.onData(listener); }, onExit(listener: (exit: { exitCode: number | null; transportClosed?: boolean }) => void): void { diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index 373a622590..43b7c820b4 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -41,6 +41,8 @@ import { LOGIN_PTY_EXIT_NOTIFICATION, DUPLEX_CHANNEL_DATA_NOTIFICATION, DUPLEX_CHANNEL_EXIT_NOTIFICATION, + encodeChannelBytes, + decodeChannelBytes, } from "@paperclipai/plugin-sdk"; import type { JsonRpcId, @@ -610,10 +612,10 @@ export interface DuplexChannelOpenInput { * stream with the same methods. */ export interface DuplexChannelHostSession { - /** Registers the one data listener. The session streams each raw chunk in order. */ - onData(listener: (chunk: string) => void): void; + /** Registers the one data listener. The session streams each raw byte chunk in order. */ + onData(listener: (chunk: Uint8Array) => void): void; /** Writes raw input bytes to the channel. */ - write(data: string): void; + write(data: Uint8Array): void; /** * Resolves when the command ends or the route ends. A numeric `exitCode` is a * real process exit. `transportClosed` is true when the provider transport @@ -913,7 +915,10 @@ export function createPluginWorkerHandle( options.loginPtyLimits?.closeTimeoutMs ?? LOGIN_PTY_CLOSE_TIMEOUT_MS; // Bounds and timeouts for the generic duplex channel route. A caller (a test) - // can lower them to exercise each bound and the terminalize paths. + // can lower them to exercise each bound and the terminalize paths. Each + // "Chars" name is a historical holdover: the channel now carries `Uint8Array` + // chunks, so every one of these bounds counts raw bytes (`.length` on a + // `Uint8Array` is its byte count), not UTF-16 code units. const maxDuplexChannelChunkChars = options.duplexChannelLimits?.maxChunkChars ?? MAX_DUPLEX_CHANNEL_CHUNK_CHARS; const maxDuplexChannelPreBindChars = @@ -1630,10 +1635,10 @@ export function createPluginWorkerHandle( // 7. route lifetime — the milliseconds from the open to the terminal end. // One buffered data chunk retained for a late listener drain. It carries the raw - // chunk string and the aggregate byte token that reserved its raw bytes. The + // chunk bytes and the aggregate byte token that reserved its raw bytes. The // token is `null` when no ledger is injected. interface BufferedDuplexChunk { - chunk: string; + chunk: Uint8Array; token: ReservationToken | null; } // One pre-bind data event, normalized to the narrow duplex-event schema. The host @@ -1643,7 +1648,7 @@ export function createPluginWorkerHandle( // fails closed. interface HeldDuplexEvent { workerSessionId: string; - chunk: string; + chunk: Uint8Array; token: ReservationToken | null; } // One pre-bind exit event, normalized to the narrow duplex-event schema. @@ -1657,7 +1662,7 @@ export function createPluginWorkerHandle( hostRouteId: string; state: RouteState; workerSessionId: string | null; - listener: ((chunk: string) => void) | null; + listener: ((chunk: Uint8Array) => void) | null; buffered: BufferedDuplexChunk[]; bufferedChars: number; pendingRequests: number; @@ -1959,8 +1964,8 @@ export function createPluginWorkerHandle( // dispatch loop nor the pre-bind drain. The manager catches the error and logs // it without the raw bytes. This mirrors the `execute.log` delivery isolation. function deliverDuplexChannelChunk( - listener: (chunk: string) => void, - chunk: string, + listener: (chunk: Uint8Array) => void, + chunk: Uint8Array, ): void { try { listener(chunk); @@ -2036,25 +2041,32 @@ export function createPluginWorkerHandle( return; } const route = resolved; - const chunk = params.chunk; - if (typeof chunk !== "string" || chunk.length === 0) { - // The exact pair matches, but the chunk is malformed. Count one per-route - // protocol error. Release a carried replay token first. + // The wire carries the chunk as a base64 string (JSON has no binary type; see + // `ChannelBytesWireValue` in protocol.ts). Decode it back to raw bytes before + // any bound check, so every bound below counts real bytes, not base64 text. + // A bind replay (`replayPreBindDuplexFrames`) reuses this function with a + // held event whose chunk the host already decoded once; it carries the + // already-decoded `Uint8Array` straight through with no second decode. + const rawChunk = params.chunk; + const chunk = rawChunk instanceof Uint8Array ? rawChunk : decodeChannelBytes(rawChunk); + if (chunk === null || chunk.byteLength === 0) { + // The exact pair matches, but the chunk is malformed or empty. Count one + // per-route protocol error. Release a carried replay token first. releaseRouteToken(route, carried?.token ?? null); recordDuplexChannelProtocolError(route); return; } - if (chunk.length > maxDuplexChannelChunkChars) { + if (chunk.byteLength > maxDuplexChannelChunkChars) { // One inbound chunk is larger than the per-chunk limit. End the route at // once. Do not count the chunk as a protocol error. releaseRouteToken(route, carried?.token ?? null); void terminalizeDuplexChannelRoute(route); return; } - // Count the bytes of the chunk. Enforce the cumulative total-byte cap before - // and after a listener attaches. End the route when the cap is exceeded, so a - // bound listener cannot receive data past the cap. - const chunkBytes = Buffer.byteLength(chunk); + // Enforce the cumulative total-byte cap before and after a listener attaches. + // End the route when the cap is exceeded, so a bound listener cannot receive + // data past the cap. + const chunkBytes = chunk.byteLength; if (route.totalDataBytes + chunkBytes > maxDuplexChannelTotalDataBytes) { releaseRouteToken(route, carried?.token ?? null); void terminalizeDuplexChannelRoute(route); @@ -2178,9 +2190,10 @@ export function createPluginWorkerHandle( return; } // A data event. Validate and normalize it to the narrow duplex-event schema - // before any retention. - const chunk = params.chunk; - if (typeof chunk !== "string" || chunk.length === 0) { + // before any retention. The wire carries the chunk as base64; decode it back + // to raw bytes before any bound check or retention. + const chunk = decodeChannelBytes(params.chunk); + if (chunk === null || chunk.byteLength === 0) { recordDuplexChannelProtocolError(route); return; } @@ -2189,7 +2202,7 @@ export function createPluginWorkerHandle( return; } // Reserve the exact retained raw byte count before the host holds the event. - const reserved = reserveRouteBytes(route, "pre_bind_event", Buffer.byteLength(chunk)); + const reserved = reserveRouteBytes(route, "pre_bind_event", chunk.byteLength); if (reserved === null) { // The aggregate ceiling rejected the reservation. The caller retains nothing // and the route fails closed with the fixed marker. @@ -2406,25 +2419,27 @@ export function createPluginWorkerHandle( >( method: M, params: HostToWorkerMethods[M][0], + // The exact raw byte count of a `duplexChannelWrite` payload, before base64 + // encoding. The caller supplies it: `params.data` on the wire is the base64 + // form (`ChannelBytesWireValue`), so its string length no longer equals the + // real payload bytes. A stop request carries no payload and omits this. + writeByteCount?: number, ): void => { if (route.state !== "open") return; if (route.pendingRequests >= maxDuplexChannelPendingRequests) { void terminalizeDuplexChannelRoute(route); return; } - // Reserve the exact UTF-8 byte count of a host→worker write against the + // Reserve the exact raw byte count of a host→worker write against the // aggregate ledger before `callInternal` retains the payload. A pending write // RPC holds `params.data` until it settles, so this reservation bounds the - // aggregate host→worker pending-write bytes across every route. Compute the - // byte count with `Buffer.byteLength`, not `data.length`, because one - // character can encode as several UTF-8 bytes. A stop request carries no - // payload, so it reserves nothing. When no ledger is present, admit the write - // with no token (a unit test constructs the handle this way). + // aggregate host→worker pending-write bytes across every route. A stop + // request carries no payload, so it reserves nothing. When no ledger is + // present, admit the write with no token (a unit test constructs the handle + // this way). let pendingWriteToken: ReservationToken | null = null; if (method === "duplexChannelWrite" && duplexAggregateByteLedger) { - const data = (params as HostToWorkerMethods["duplexChannelWrite"][0]).data; - const bytes = Buffer.byteLength(data, "utf8"); - pendingWriteToken = duplexAggregateByteLedger.reserve("pending_write", bytes); + pendingWriteToken = duplexAggregateByteLedger.reserve("pending_write", writeByteCount ?? 0); if (!pendingWriteToken) { // The reservation would pass the aggregate ceiling. Retain nothing, do // not enqueue the RPC, and end the route fail-closed with the aggregate @@ -2471,7 +2486,7 @@ export function createPluginWorkerHandle( }; return { - onData(listener: (chunk: string) => void): void { + onData(listener: (chunk: Uint8Array) => void): void { route.listener = listener; if (route.buffered.length > 0) { const pending = route.buffered; @@ -2489,20 +2504,28 @@ export function createPluginWorkerHandle( terminalDuplexRoutes.delete(route); } }, - write(data: string): void { + write(data: Uint8Array): void { const sid = route.workerSessionId; if (route.state !== "open" || !sid) return; - if (data.length > maxDuplexChannelWriteChars) { + if (data.byteLength > maxDuplexChannelWriteChars) { // The write is larger than the size bound. End the route before the // write reaches the worker. void terminalizeDuplexChannelRoute(route); return; } - sendBoundedRequest("duplexChannelWrite", { - hostRouteId: route.hostRouteId, - workerSessionId: sid, - data, - }); + // Encode the raw bytes to the wire-safe base64 form (JSON carries no + // binary type) and pass the exact raw byte count separately, so the + // pending-write ledger reservation charges the real payload bytes, not + // the inflated base64 string length. + sendBoundedRequest( + "duplexChannelWrite", + { + hostRouteId: route.hostRouteId, + workerSessionId: sid, + data: encodeChannelBytes(data), + }, + data.byteLength, + ); }, wait(): Promise<{ exitCode: number | null }> { return waitPromise;