mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
## 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>
202 lines
7.1 KiB
TypeScript
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);
|
|
});
|
|
});
|