Files
PaperClipAI/server/src/__tests__/plugin-worker-manager-duplex-pending-write-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

253 lines
9.9 KiB
TypeScript

import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,
DuplexAggregateByteLedger,
type DuplexAggregateByteLedgerTelemetry,
} from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
// Mock the shared logger, so a test reads the fixed rejection marker the manager
// logs when a pending-write reservation fails. The child logger returns the same
// object, so `log.warn` is this mock's `warn`.
vi.mock("../middleware/logger.js", () => {
const mockLogger: Record<string, unknown> = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
child: vi.fn(() => mockLogger),
};
return { logger: mockLogger, httpLogger: vi.fn() };
});
import { logger } from "../middleware/logger.js";
import { createPluginWorkerHandle } from "../services/plugin-worker-manager.js";
// This suite proves the plugin worker manager charges every host→worker write raw
// payload against the injected aggregate byte ledger under the `pending_write`
// owner, and releases each token one time when the write RPC settles. The child
// reads its stdin here, so the separate `stdin_write` transport token flushes and
// releases at once. Each test waits for that flush, so the assertions isolate the
// raw-payload token. The transport token has its own suite.
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const DUPLEX_CHANNEL_WORKER_ENTRYPOINT = path.join(
FIXTURES_DIR,
"plugin-worker-duplex-channel.cjs",
);
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
id: "test.plugin",
apiVersion: 1,
version: "1.0.0",
displayName: "Test plugin",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "dist/worker.js" },
};
function makeDuplexHandle(extra?: Record<string, unknown>) {
return createPluginWorkerHandle("test.plugin", {
entrypointPath: DUPLEX_CHANNEL_WORKER_ENTRYPOINT,
manifest: TEST_MANIFEST,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
apiVersion: 1,
hostHandlers: {},
...extra,
});
}
// The test directive rides in `providerLeaseId`, an opaque field the manager
// forwards to the worker unchanged.
function duplexOpenInput(directive: unknown, companyId = "company-1") {
return {
driverKey: "daytona",
companyId,
environmentId: "env-1",
providerLeaseId: JSON.stringify(directive),
command: "bridge-callback",
};
}
// A telemetry surface that tracks the peak gauge value and the two counters. A
// test asserts the peak never passes the ceiling, so it proves aggregate admission
// stops at the ceiling instead of overshooting it.
function peakTrackingTelemetry(): DuplexAggregateByteLedgerTelemetry & {
gauge: number;
peak: number;
rejections: number;
underflows: number;
} {
const state = {
gauge: 0,
peak: 0,
rejections: 0,
underflows: 0,
setBytesInUse(bytes: number) {
state.gauge = bytes;
if (bytes > state.peak) state.peak = bytes;
},
recordReservationRejection() {
state.rejections += 1;
},
recordAccountingUnderflow() {
state.underflows += 1;
},
};
return state;
}
// Return every reason string the manager logged through the mock `warn` sink.
function loggedWarnReasons(): string[] {
const calls = (logger.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls;
return calls
.map((call) => {
const first = call[0];
return first && typeof first === "object"
? (first as { reason?: unknown }).reason
: undefined;
})
.filter((reason): reason is string => typeof reason === "string");
}
describe("plugin worker manager duplex pending-write byte ledger", () => {
it("charges each held raw payload and returns to zero after worker exit", async () => {
const telemetry = peakTrackingTelemetry();
// The ceiling has ample room, so a transient transport reservation never
// rejects. Each write holds its raw payload until the RPC settles.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
const writeBytes = 1000;
const writeData = "x".repeat(writeBytes);
try {
await handle.start();
// The worker reads its stdin but never replies to a write, so each write RPC
// stays pending and holds its raw payload.
const route = await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: "ws-a", mode: "no-write-reply" }),
);
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.
await vi.waitFor(() => {
expect(ledger.liveTokenCount).toBe(3);
expect(ledger.bytesInUse).toBe(3 * writeBytes);
});
expect(telemetry.peak).toBeLessThanOrEqual(ledger.ceilingBytes);
} finally {
await handle.stop().catch(() => undefined);
}
// The worker exit settles each pending write, so each token releases one time
// and the ledger ends at zero with no accounting defect.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
it("releases the raw-payload token after a delayed write RPC settles, with no worker exit", async () => {
const telemetry = peakTrackingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
// The worker reads its stdin and delays its write reply, so the host holds the
// raw-payload reservation for a measurable time before the RPC settles.
const route = await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: "ws-d", writeReplyDelayMs: 150 }),
);
const writeBytes = 1000;
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(() => {
expect(ledger.liveTokenCount).toBe(1);
expect(ledger.bytesInUse).toBe(writeBytes);
});
// The delayed reply settles the RPC, and the `finally` releases the token.
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
} finally {
await handle.stop().catch(() => undefined);
}
expect(ledger.bytesInUse).toBe(0);
expect(telemetry.underflows).toBe(0);
});
it("reserves the exact UTF-8 byte count of the raw payload, not the character length", async () => {
const telemetry = peakTrackingTelemetry();
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1 << 20, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
const route = await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: "ws-u", mode: "no-write-reply" }),
);
// "a€" is two characters but four UTF-8 bytes (one plus three). The raw-payload
// reservation must charge four bytes, so it uses the UTF-8 byte count.
const data = "a€";
expect(data.length).toBe(2);
expect(Buffer.byteLength(data, "utf8")).toBe(4);
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);
expect(ledger.bytesInUse).toBe(4);
});
} finally {
await handle.stop().catch(() => undefined);
}
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
it("ends an over-ceiling single write fail-closed with the aggregate marker, not the route-busy marker", async () => {
vi.mocked(logger.warn).mockClear();
const telemetry = peakTrackingTelemetry();
// The ceiling is smaller than one raw payload, so the raw-payload reservation
// fails before the frame reaches the transport. The manager retains nothing.
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 100, telemetry });
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
try {
await handle.start();
const route = await handle.openDuplexChannel(
duplexOpenInput({ workerSessionId: "ws-o", mode: "no-write-reply" }),
);
// 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(new TextEncoder().encode("x".repeat(200)));
expect(telemetry.rejections).toBeGreaterThanOrEqual(1);
expect(ledger.bytesInUse).toBe(0);
expect(telemetry.peak).toBeLessThanOrEqual(ledger.ceilingBytes);
const reasons = loggedWarnReasons();
expect(reasons).toContain(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
expect(reasons).not.toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
} finally {
await handle.stop().catch(() => undefined);
}
await vi.waitFor(() => {
expect(ledger.bytesInUse).toBe(0);
expect(ledger.liveTokenCount).toBe(0);
});
expect(telemetry.underflows).toBe(0);
});
});