Files
PaperClipAI/server/src/__tests__/fixtures/plugin-worker-duplex-channel.cjs
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

175 lines
5.7 KiB
JavaScript

// Test worker fixture for the host-owned duplex channel route state machine. The
// fixture drives the manager route state machine through the four typed methods
// (open, write, stop, close) and the data and exit notifications.
//
// The duplex channel is generic. It carries no command allowlist. The test
// encodes a JSON directive in the forwarded `providerLeaseId`, so one fixture
// serves every route case:
// - `mode`: "normal" | "malformed-open" | "no-open-reply" | "duplicate-open-reply" |
// "no-write-reply"
// - `workerSessionId`: the worker session id the open reply returns (default "ws-1")
// - `data`: an array of `{ chunk, sid? }`. The fixture emits each as a data
// notification after the open reply. `sid` defaults to the real worker session
// id; a test sets a wrong `sid` to prove the host drops a mismatched
// notification and counts a protocol error.
// - `exitCode`: when set, the fixture emits an exit notification after the data.
// - `echoInput`: when true, the fixture echoes each `duplexChannelWrite` back as
// one data notification for the bound session.
// - `closeMode`: "ack" | "bad-ack" | "no-ack" (default "ack"). It controls the
// close reply, so a test proves the host retires the worker on an unconfirmed
// close.
const readline = require("node:readline");
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
// The registered channels, keyed by the host route id. Each entry records the
// bound worker session id and the close directive.
const routes = new Map();
function parseDirective(raw) {
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
rl.on("line", (line) => {
if (!line.trim()) return;
const message = JSON.parse(line);
const method = message && typeof message.method === "string" ? message.method : null;
const params = message.params ?? {};
if (method === "initialize") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
ok: true,
supportedMethods: [
"duplexChannelOpen",
"duplexChannelWrite",
"duplexChannelStop",
"duplexChannelClose",
],
},
});
return;
}
if (method === "duplexChannelOpen") {
const directive = parseDirective(params.providerLeaseId);
const mode = directive.mode ?? "normal";
const workerSessionId = directive.workerSessionId ?? "ws-1";
const closeMode = directive.closeMode ?? "ack";
routes.set(params.hostRouteId, {
workerSessionId,
closeMode,
echoInput: directive.echoInput === true,
noWriteReply: mode === "no-write-reply",
});
if (mode === "no-open-reply") {
// Never reply, so the host open call times out.
return;
}
if (mode === "malformed-open") {
// Reply with no worker session id, so the host terminalizes the route.
send({ jsonrpc: "2.0", id: message.id, result: {} });
return;
}
const reply = () =>
send({ jsonrpc: "2.0", id: message.id, result: { workerSessionId } });
reply();
if (mode === "duplicate-open-reply") {
// Send a second open reply for the same request id. The host drops it.
reply();
}
// Emit the scripted data and the exit after the open reply, so the host
// binds the route first.
setImmediate(() => {
const data = Array.isArray(directive.data) ? directive.data : [];
for (const entry of data) {
send({
jsonrpc: "2.0",
method: "duplexChannel.data",
params: {
workerSessionId: entry.sid ?? workerSessionId,
chunk: entry.chunk,
},
});
}
if (typeof directive.exitCode === "number") {
send({
jsonrpc: "2.0",
method: "duplexChannel.exit",
params: { workerSessionId, exitCode: directive.exitCode },
});
}
});
return;
}
if (method === "duplexChannelWrite") {
const entry = [...routes.values()].find(
(route) => route.workerSessionId === params.workerSessionId,
);
if (entry && entry.noWriteReply) {
// Never reply, so the host write call stays pending. The test proves the
// host ends the route on the pending-request bound.
return;
}
if (entry && entry.echoInput) {
// Echo the input back as one data notification for the bound session, so a
// test proves the input reaches the worker and the output routes back.
send({
jsonrpc: "2.0",
method: "duplexChannel.data",
params: { workerSessionId: entry.workerSessionId, chunk: `echo:${params.data}` },
});
}
send({ jsonrpc: "2.0", id: message.id, result: null });
return;
}
if (method === "duplexChannelStop") {
send({ jsonrpc: "2.0", id: message.id, result: null });
return;
}
if (method === "duplexChannelClose") {
const entry = routes.get(params.hostRouteId);
routes.delete(params.hostRouteId);
const closeMode = entry ? entry.closeMode : "ack";
if (closeMode === "no-ack") {
// Never reply, so the host close call times out and the host retires us.
return;
}
if (closeMode === "bad-ack") {
send({ jsonrpc: "2.0", id: message.id, result: { hostRouteId: "mismatched-route" } });
return;
}
send({ jsonrpc: "2.0", id: message.id, result: { hostRouteId: params.hostRouteId } });
return;
}
if (method === "shutdown") {
send({ jsonrpc: "2.0", id: message.id, result: {} });
setImmediate(() => process.exit(0));
return;
}
send({
jsonrpc: "2.0",
id: message.id,
error: { code: -32601, message: `Unhandled method: ${method}` },
});
});