mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +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>
253 lines
9.9 KiB
TypeScript
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);
|
|
});
|
|
});
|