Files
PaperClipAI/packages/adapter-utils/src/duplex-aggregate-byte-ledger.test.ts
T
Nicky LeachandPaperclip 445547c989 feat(duplex): run the Daytona sandbox callback bridge over Node HTTP/2 (#12120)
## 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 <noreply@paperclip.ing>
2026-08-25 07:35:39 -07:00

224 lines
8.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
DUPLEX_AGGREGATE_TOKEN_OWNERS,
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
DuplexAggregateByteLedger,
MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX,
assertDuplexAggregateCeilingBytes,
resolveDuplexAggregateCeilingBytes,
type DuplexAggregateByteLedgerTelemetry,
} from "./duplex-aggregate-byte-ledger.js";
// A telemetry surface that counts each fixed record, so a test asserts the exact
// gauge value and the exact counter counts.
function createCountingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
gauge: number;
rejections: number;
underflows: number;
} {
const state = { gauge: 0, rejections: 0, underflows: 0 };
return {
...state,
setBytesInUse(bytes: number) {
this.gauge = bytes;
},
recordReservationRejection() {
this.rejections += 1;
},
recordAccountingUnderflow() {
this.underflows += 1;
},
};
}
describe("DuplexAggregateByteLedger", () => {
it("reserves under the ceiling and tracks the gauge and the live-token count", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
const a = ledger.reserve("buffered_chunk", 400);
const b = ledger.reserve("buffered_chunk", 500);
expect(a).not.toBeNull();
expect(b).not.toBeNull();
expect(ledger.bytesInUse).toBe(900);
expect(ledger.liveTokenCount).toBe(2);
expect(telemetry.gauge).toBe(900);
expect(a?.bytes).toBe(400);
expect(a?.owner).toBe("buffered_chunk");
expect(a?.state).toBe("held");
});
it("rejects a one-byte-over reservation, retains nothing, and increments the rejection counter", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
ledger.reserve("buffered_chunk", 1000);
const over = ledger.reserve("buffered_chunk", 1);
expect(over).toBeNull();
expect(ledger.bytesInUse).toBe(1000);
expect(ledger.liveTokenCount).toBe(1);
expect(telemetry.rejections).toBe(1);
// The public rejection marker is the fixed string the caller reports.
expect(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED).toBe("DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED");
});
it("transfers ownership without a decrement or a re-reserve, so no admission gap opens", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 100, telemetry });
// Reserve the whole ceiling as a pre-bind event, then transfer to buffered.
const token = ledger.reserve("pre_bind_event", 100);
expect(token).not.toBeNull();
// A second reservation cannot fit while the token stays held.
expect(ledger.reserve("buffered_chunk", 1)).toBeNull();
ledger.transfer(token!, "buffered_chunk");
// The transfer changed only the owner. The gauge never moved, so no window
// opened where the bytes were released and re-admitted.
expect(ledger.bytesInUse).toBe(100);
expect(ledger.liveTokenCount).toBe(1);
expect(token?.owner).toBe("buffered_chunk");
expect(telemetry.gauge).toBe(100);
// No reservation fit during the transfer, so no rejection or underflow fired
// beyond the one rejection above.
expect(telemetry.rejections).toBe(1);
expect(telemetry.underflows).toBe(0);
});
it("releases a token one time and decrements the gauge", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
const token = ledger.reserve("response_body", 250);
expect(token).not.toBeNull();
ledger.release(token!);
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
expect(token?.state).toBe("released");
expect(telemetry.gauge).toBe(0);
expect(telemetry.underflows).toBe(0);
});
it("records the underflow counter on a second release and does not clamp the gauge again", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
const kept = ledger.reserve("request_frame", 300);
const doubled = ledger.reserve("request_frame", 200);
expect(kept).not.toBeNull();
expect(doubled).not.toBeNull();
ledger.release(doubled!);
expect(ledger.bytesInUse).toBe(300);
// A forced second release of the same token is an accounting defect.
ledger.release(doubled!);
expect(telemetry.underflows).toBe(1);
// The gauge did not drop a second time, so the defect stays visible.
expect(ledger.bytesInUse).toBe(300);
expect(ledger.liveTokenCount).toBe(1);
});
it("records the underflow counter on a transfer of a released token", () => {
const telemetry = createCountingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000, telemetry });
const token = ledger.reserve("pre_bind_event", 100);
ledger.release(token!);
ledger.transfer(token!, "buffered_chunk");
expect(telemetry.underflows).toBe(1);
expect(ledger.bytesInUse).toBe(0);
});
it("throws on a non-integer or negative reservation, so a programming error fails loud", () => {
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000 });
expect(() => ledger.reserve("buffered_chunk", -1)).toThrow();
expect(() => ledger.reserve("buffered_chunk", 1.5)).toThrow();
expect(() => ledger.reserve("buffered_chunk", Number.NaN)).toThrow();
});
it("admits a zero-byte reservation as a valid held token", () => {
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1000 });
const token = ledger.reserve("seen_request_id", 0);
expect(token).not.toBeNull();
expect(token?.bytes).toBe(0);
expect(ledger.liveTokenCount).toBe(1);
});
it("exposes the closed owner-label set", () => {
expect([...DUPLEX_AGGREGATE_TOKEN_OWNERS]).toEqual([
"pre_bind_event",
"buffered_chunk",
"terminal_buffered",
"request_frame",
"request_payload",
"response_body",
"seen_request_id",
"decoder_buffer",
"readiness_buffer",
"readiness_replay",
"http2_preface_scan",
"http2_preface_replay",
"pending_write",
"stdin_write",
]);
});
});
describe("aggregate ceiling configuration", () => {
it("uses the default when the override is absent", () => {
expect(resolveDuplexAggregateCeilingBytes()).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
expect(resolveDuplexAggregateCeilingBytes(null)).toBe(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES);
expect(DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES).toBe(256 * 1024 * 1024);
});
it("accepts a valid positive override under the process allocation minus the reserve", () => {
expect(resolveDuplexAggregateCeilingBytes(64 * 1024 * 1024)).toBe(64 * 1024 * 1024);
const maxCeiling =
MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX - DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES;
expect(resolveDuplexAggregateCeilingBytes(maxCeiling)).toBe(maxCeiling);
});
it("rejects an invalid override to the safe default and reports each rejection", () => {
const overMaximum = MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX;
const invalidOverrides = [0, -1, 1.5, Number.POSITIVE_INFINITY, Number.NaN, overMaximum];
for (const invalid of invalidOverrides) {
const rejected: number[] = [];
// An invalid present override returns the safe default. It never throws, so a
// single bad environment value never fails host startup.
expect(resolveDuplexAggregateCeilingBytes(invalid, (value) => rejected.push(value))).toBe(
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
);
// The resolver reports the rejection one time with the rejected numeric value.
expect(rejected).toEqual([invalid]);
}
});
it("does not report a rejection for a valid or a missing override", () => {
const rejected: number[] = [];
const reporter = (value: number): void => {
rejected.push(value);
};
expect(resolveDuplexAggregateCeilingBytes(64 * 1024 * 1024, reporter)).toBe(64 * 1024 * 1024);
expect(resolveDuplexAggregateCeilingBytes(undefined, reporter)).toBe(
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
);
expect(resolveDuplexAggregateCeilingBytes(null, reporter)).toBe(
DEFAULT_MAX_AGGREGATE_DUPLEX_ROUTE_BYTES,
);
expect(rejected).toEqual([]);
});
it("still throws for a direct ceiling assertion on an unsafe integer", () => {
// The ledger constructor asserts its ceiling and must fail loud on a
// programming error, so `assertDuplexAggregateCeilingBytes` keeps throwing.
expect(() => assertDuplexAggregateCeilingBytes(Number.MAX_SAFE_INTEGER + 2)).toThrow();
});
});