mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +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>
301 lines
12 KiB
TypeScript
301 lines
12 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 transport 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 the child-stdin transport
|
|
// buffer for every host→worker duplex write, under the `stdin_write` owner. The
|
|
// worker completes the open, then stops reading its stdin. The host write then
|
|
// stays in the host stdin write buffer. The tests prove the reservation covers the
|
|
// serialized frame, holds until the flush or the worker exit, survives the write
|
|
// RPC timeout and the route terminalization, and bounds the aggregate transport
|
|
// bytes across several routes and workers.
|
|
|
|
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");
|
|
}
|
|
|
|
// A frame the host keeps in its stdin write buffer must pass the operating-system
|
|
// pipe buffer (about 64 KiB), so the write callback stays pending while the child
|
|
// does not read. Each write here is larger than that, so the host holds the
|
|
// transport token until the worker exit.
|
|
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 the base64 wire inflation", async () => {
|
|
const telemetry = peakTrackingTelemetry();
|
|
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry });
|
|
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
|
try {
|
|
await handle.start();
|
|
const route = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-a",
|
|
stopReadingStdinAfterOpen: true,
|
|
exitAfterStopMs: 500,
|
|
}),
|
|
);
|
|
// 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: at least the base64
|
|
// form of the raw payload, plus a bounded JSON envelope around it.
|
|
expect(transportBytes).toBeGreaterThan(rawBytes);
|
|
expect(transportBytes).toBeGreaterThanOrEqual(encodedLength);
|
|
expect(transportBytes).toBeLessThan(encodedLength + 5_000);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
// The worker exit discards the stdin buffer, so every token releases one time.
|
|
await vi.waitFor(() => {
|
|
expect(ledger.bytesInUse).toBe(0);
|
|
expect(ledger.liveTokenCount).toBe(0);
|
|
});
|
|
expect(telemetry.underflows).toBe(0);
|
|
});
|
|
|
|
it("holds the transport token across a write-RPC timeout and releases it on worker exit", async () => {
|
|
const telemetry = peakTrackingTelemetry();
|
|
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry });
|
|
// The write RPC times out fast; the worker exits later. The gap lets a test
|
|
// prove the timeout does not release the transport token.
|
|
const handle = makeDuplexHandle({
|
|
duplexAggregateByteLedger: ledger,
|
|
duplexChannelLimits: { openTimeoutMs: 150 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const route = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-t",
|
|
stopReadingStdinAfterOpen: true,
|
|
exitAfterStopMs: 900,
|
|
}),
|
|
);
|
|
const data = "x".repeat(HELD_WRITE_CHARS);
|
|
const rawBytes = Buffer.byteLength(data, "utf8");
|
|
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;
|
|
expect(transportBytes).toBeGreaterThan(0);
|
|
// The write RPC times out. The RPC settle releases the raw-payload token, so
|
|
// one token remains: the transport token. The timeout never releases it.
|
|
await vi.waitFor(() => {
|
|
expect(ledger.liveTokenCount).toBe(1);
|
|
});
|
|
expect(ledger.bytesInUse).toBe(transportBytes);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
// The worker exit releases the still-held transport token.
|
|
await vi.waitFor(() => {
|
|
expect(ledger.bytesInUse).toBe(0);
|
|
expect(ledger.liveTokenCount).toBe(0);
|
|
});
|
|
expect(telemetry.underflows).toBe(0);
|
|
});
|
|
|
|
it("does not release the transport token when the route terminalizes", async () => {
|
|
const telemetry = peakTrackingTelemetry();
|
|
const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 8 << 20, telemetry });
|
|
const handle = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
|
try {
|
|
await handle.start();
|
|
const route = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-c",
|
|
stopReadingStdinAfterOpen: true,
|
|
exitAfterStopMs: 700,
|
|
}),
|
|
);
|
|
const data = "x".repeat(HELD_WRITE_CHARS);
|
|
const rawBytes = Buffer.byteLength(data, "utf8");
|
|
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
|
|
// must never release the transport token, so at least the serialized frame
|
|
// stays charged.
|
|
void route.close();
|
|
expect(ledger.bytesInUse).toBeGreaterThanOrEqual(transportBytes);
|
|
} 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("bounds the aggregate transport bytes across several routes and workers, ending an over-ceiling near-limit write with the aggregate marker", async () => {
|
|
vi.mocked(logger.warn).mockClear();
|
|
const telemetry = peakTrackingTelemetry();
|
|
// The ceiling admits only a couple of held serialized frames, so a near-limit
|
|
// write across the routes passes it and rejects.
|
|
const ceilingBytes = 600_000;
|
|
const ledger = new DuplexAggregateByteLedger({ ceilingBytes, telemetry });
|
|
const handleA = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
|
const handleB = makeDuplexHandle({ duplexAggregateByteLedger: ledger });
|
|
try {
|
|
await handleA.start();
|
|
await handleB.start();
|
|
const routeA = await handleA.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-a",
|
|
stopReadingStdinAfterOpen: true,
|
|
exitAfterStopMs: 900,
|
|
}),
|
|
);
|
|
const routeB = await handleB.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-b",
|
|
stopReadingStdinAfterOpen: true,
|
|
exitAfterStopMs: 900,
|
|
}),
|
|
);
|
|
// 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);
|
|
// The aggregate bytes never pass the ceiling. Admission stops at the ceiling.
|
|
expect(ledger.bytesInUse).toBeLessThanOrEqual(ceilingBytes);
|
|
}
|
|
// At least one near-limit write passed the ceiling and rejected. The manager
|
|
// ended that route with the aggregate marker, never the route-busy marker.
|
|
expect(telemetry.rejections).toBeGreaterThanOrEqual(1);
|
|
expect(telemetry.peak).toBeLessThanOrEqual(ceilingBytes);
|
|
const reasons = loggedWarnReasons();
|
|
expect(reasons).toContain(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
|
|
expect(reasons).not.toContain("DUPLEX_CHANNEL_ROUTE_BUSY");
|
|
} finally {
|
|
await handleA.stop().catch(() => undefined);
|
|
await handleB.stop().catch(() => undefined);
|
|
}
|
|
// Every worker exit releases its held transport tokens, so 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);
|
|
});
|
|
});
|