Files
PaperClipAI/packages/adapter-utils/src/duplex-frame-codec.test.ts
T
Nicky LeachandPaperclip 8161244284 feat(sandbox): add opt-in duplex command-stream foundation (capability, protocol, bounded host route, frame codec) (#11738)
## Thinking Path

> - Paperclip provides a control plane for companies that run AI agents.
> - Sandboxed agents need a safe execution path for persistent command
streams.
> - The existing callback transport does not provide a bounded, generic
duplex route.
> - The host must control capability access, route identity, protocol
limits, and close behavior.
> - This pull request adds an opt-in duplex command-stream foundation
across the sandbox layers.
> - The feature stays inert because no current provider declares the
capability.

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting (multiple of the above)

**Problem or motivation**

Sandbox command execution needs a persistent host-to-sandbox stream. The
current callback bridge uses a file transport and does not provide this
generic route.

**Proposed solution**

Add a fail-closed provider capability, generic worker protocol messages,
a host-owned bounded route, cross-layer service mediation, and a
versioned newline-delimited frame codec.

**Alternatives considered**

Keep the file transport and add feature-specific commands. This does not
provide one reusable duplex contract or host-owned route bounds.

**Roadmap alignment**

This work supports the completed Cloud / Sandbox agents roadmap area and
the safe autonomy goal in the product definition.

**Additional context**

The change passed a two-stage security review. The final code review
verdict was approve after fixes for active-stream bounds and
service-layer capability mediation.

## What Changed

- Add the opt-in `duplexCommandStream` provider capability with
fail-closed narrowing.
- Add duplex open, write, stop, and close requests and data and exit
notifications to the plugin worker protocol.
- Add a host-owned route with bounds for chunk size, cumulative bytes,
lifetime, protocol errors, pending requests, and pre-bind buffering.
- Add close acknowledgement handling with worker retirement when the
close remains unconfirmed.
- Wire `openDuplexChannel` through the execution target, runtime
service, and plugin worker.
- Add a versioned frame codec with shared wire-compatibility vectors and
split UTF-8 handling.

## Verification

- `server/src/__tests__/plugin-worker-manager-duplex.test.ts` passes 18
tests.
- `server/src/__tests__/environment-execution-target-duplex.test.ts`
passes 11 tests.
- `packages/adapter-utils/src/duplex-frame-codec.test.ts` passes 38
tests.
- `server/src/__tests__/sandbox-capability-contract.test.ts` passes 15
tests.
- Setup-token pseudo-terminal regression tests pass 47 tests.
- Server TypeScript check passes.
- Continuous integration will run the full required test, typecheck,
build, and policy checks.

## Risks

- Providers that opt into the capability must implement the complete
worker protocol.
- Route limit defaults can close a stream when a workload exceeds the
configured bounds.
- The capability remains disabled for current providers, so current
production behavior does not change.

## Model Used

OpenAI GPT-5 (`gpt-5`), with tool use and code execution. The model
reviewed and prepared this pull request from the supplied implementation
and verification record.

## 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-19 13:39:13 -07:00

202 lines
7.1 KiB
TypeScript

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
DEFAULT_MAX_DUPLEX_FRAME_BYTES,
DUPLEX_FRAME_VERSION,
DuplexFrameDecoder,
decodeDuplexLine,
encodeDuplexFrame,
type DuplexDecodeResult,
type DuplexFrame,
} from "./duplex-frame-codec.js";
// One expected decode result in the fixture: either a decoded frame or the code
// of a protocol error. The codec must never throw on the read path.
type ExpectedResult = { frame: DuplexFrame } | { error: string };
interface Vector {
name: string;
category: "valid" | "invalid" | "partial" | "oversized" | "versionMismatch";
bytes: string;
splitByteOffsets?: number[];
maxFrameBytes?: number;
roundTrip?: boolean;
expected: ExpectedResult[];
}
interface Fixture {
frameVersion: number;
defaultMaxFrameBytes: number;
vectors: Vector[];
}
const fixturePath = fileURLToPath(
new URL("./duplex-frame-vectors.json", import.meta.url),
);
const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture;
// Cut one UTF-8 byte stream into chunks at the byte offsets. The decoder must
// keep partial bytes between chunks, so the split can fall inside a multi-byte
// character.
function toChunks(bytes: string, offsets: number[] | undefined): Buffer[] {
const buffer = Buffer.from(bytes, "utf8");
if (!offsets || offsets.length === 0) return [buffer];
const bounds = [0, ...offsets, buffer.length];
const chunks: Buffer[] = [];
for (let i = 0; i < bounds.length - 1; i += 1) {
chunks.push(buffer.subarray(bounds[i], bounds[i + 1]));
}
return chunks;
}
function pushAll(decoder: DuplexFrameDecoder, chunks: Buffer[]): DuplexDecodeResult[] {
const results: DuplexDecodeResult[] = [];
for (const chunk of chunks) results.push(...decoder.push(chunk));
return results;
}
function assertMatches(results: DuplexDecodeResult[], expected: ExpectedResult[]): void {
expect(results).toHaveLength(expected.length);
results.forEach((result, index) => {
const want = expected[index];
if ("frame" in want) {
expect(result.ok).toBe(true);
if (result.ok) expect(result.frame).toEqual(want.frame);
} else {
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.code).toBe(want.error);
}
});
}
describe("duplex frame codec fixture", () => {
it("the fixture version matches the codec version", () => {
expect(fixture.frameVersion).toBe(DUPLEX_FRAME_VERSION);
expect(fixture.defaultMaxFrameBytes).toBe(DEFAULT_MAX_DUPLEX_FRAME_BYTES);
});
it("every category has at least one vector", () => {
const categories = new Set(fixture.vectors.map((vector) => vector.category));
for (const category of ["valid", "invalid", "partial", "oversized", "versionMismatch"]) {
expect(categories).toContain(category);
}
});
for (const vector of fixture.vectors) {
it(`decodes the ${vector.name} vector`, () => {
const decoder = new DuplexFrameDecoder(
vector.maxFrameBytes ? { maxFrameBytes: vector.maxFrameBytes } : undefined,
);
const chunks = toChunks(vector.bytes, vector.splitByteOffsets);
const results = pushAll(decoder, chunks);
assertMatches(results, vector.expected);
});
}
});
describe("round-trip", () => {
const validVectors = fixture.vectors.filter((vector) => vector.roundTrip);
it("has round-trip vectors for every frame type", () => {
const types = new Set(
validVectors.flatMap((vector) =>
vector.expected.flatMap((result) =>
"frame" in result ? [result.frame.type] : [],
),
),
);
for (const type of ["request", "response", "ready", "heartbeat", "close", "error"]) {
expect(types).toContain(type);
}
});
for (const vector of validVectors) {
it(`re-encodes the ${vector.name} frame to the same value`, () => {
const want = vector.expected[0];
expect("frame" in want).toBe(true);
if (!("frame" in want)) return;
const encoded = encodeDuplexFrame(want.frame);
// Encode writes exactly one line: it ends with one newline and holds no
// interior newline, so one frame stays on one line.
expect(encoded.endsWith("\n")).toBe(true);
expect(encoded.slice(0, -1)).not.toContain("\n");
const decoded = decodeDuplexLine(encoded.slice(0, -1));
expect(decoded.ok).toBe(true);
if (decoded.ok) expect(decoded.frame).toEqual(want.frame);
});
}
});
describe("streaming decoder behavior", () => {
it("emits nothing until a full line arrives, then the complete frame", () => {
const decoder = new DuplexFrameDecoder();
const line = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
const bytes = Buffer.from(line, "utf8");
const first = decoder.push(bytes.subarray(0, 3));
expect(first).toHaveLength(0);
const second = decoder.push(bytes.subarray(3));
expect(second).toHaveLength(1);
expect(second[0].ok).toBe(true);
});
it("keeps a multi-byte UTF-8 sequence valid across a chunk boundary", () => {
const decoder = new DuplexFrameDecoder();
const frame: DuplexFrame = {
version: DUPLEX_FRAME_VERSION,
type: "request",
id: "req-emoji",
method: "POST",
path: "/x",
query: "",
headers: {},
body: "😀",
};
const bytes = Buffer.from(encodeDuplexFrame(frame), "utf8");
// Split inside the four-byte emoji, right before a continuation byte.
let cut = -1;
for (let i = 1; i < bytes.length; i += 1) {
if ((bytes[i] & 0xc0) === 0x80) {
cut = i;
break;
}
}
expect(cut).toBeGreaterThan(0);
const results = [
...decoder.push(bytes.subarray(0, cut)),
...decoder.push(bytes.subarray(cut)),
];
expect(results).toHaveLength(1);
expect(results[0].ok).toBe(true);
if (results[0].ok) expect(results[0].frame).toEqual(frame);
});
it("rejects an oversized frame with a protocol error, then resynchronizes", () => {
const decoder = new DuplexFrameDecoder({ maxFrameBytes: 32 });
const flood = `${"z".repeat(100)}\n`;
const good = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
const results = decoder.push(Buffer.from(flood + good, "utf8"));
expect(results).toHaveLength(2);
expect(results[0].ok).toBe(false);
if (!results[0].ok) expect(results[0].error.code).toBe("frame_too_large");
expect(results[1].ok).toBe(true);
});
it("rejects a version-mismatch frame with a protocol error", () => {
const decoder = new DuplexFrameDecoder();
const results = decoder.push(
Buffer.from(`${JSON.stringify({ version: 999, type: "heartbeat" })}\n`, "utf8"),
);
expect(results).toHaveLength(1);
expect(results[0].ok).toBe(false);
if (!results[0].ok) expect(results[0].error.code).toBe("version_mismatch");
});
it("never throws on a malformed read; it returns a protocol error", () => {
const decoder = new DuplexFrameDecoder();
expect(() => decoder.push(Buffer.from("this is not json\n", "utf8"))).not.toThrow();
const results = decoder.push(Buffer.from("still not json\n", "utf8"));
expect(results[0].ok).toBe(false);
});
});