mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## 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>
224 lines
8.7 KiB
TypeScript
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();
|
|
});
|
|
});
|