Files
PaperClipAI/packages/plugins/sdk/src/protocol.duplex-channel.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

157 lines
5.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
DUPLEX_CHANNEL_DATA_NOTIFICATION,
DUPLEX_CHANNEL_EXIT_NOTIFICATION,
type PluginDuplexChannelCloseParams,
type PluginDuplexChannelCloseResult,
type PluginDuplexChannelDataParams,
type PluginDuplexChannelExitParams,
type PluginDuplexChannelOpenParams,
type PluginDuplexChannelOpenResult,
type PluginDuplexChannelStopParams,
type PluginDuplexChannelWriteParams,
} from "./protocol.js";
// The generic duplex channel messages model the setup-token pseudo-terminal
// contract: open, write, stop, and close requests, plus data and exit
// notifications. The host owns the route identifier. The worker returns a worker
// session identifier that binds the data and the exit notification only. A close
// keys on the host route identifier, so a lost open reply still permits a
// host-keyed close.
//
// The messages are static types, so a valid payload assigns to its type and an
// invalid payload fails the type check. The `@ts-expect-error` directives make
// the compiler reject each invalid payload. The `tsc --noEmit` check validates
// the directives, so an invalid payload that the type accepts fails the build.
describe("duplex channel request schemas", () => {
it("accepts a valid open request and its reply", () => {
const open: PluginDuplexChannelOpenParams = {
hostRouteId: "route-1",
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
command: "paperclip-bridge",
};
const reply: PluginDuplexChannelOpenResult = { workerSessionId: "ws-1" };
expect(open.hostRouteId).toBe("route-1");
expect(reply.workerSessionId).toBe("ws-1");
});
it("rejects an open request that omits the host route identifier", () => {
// @ts-expect-error — hostRouteId is required.
const open: PluginDuplexChannelOpenParams = {
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
command: "paperclip-bridge",
};
expect(open).toBeDefined();
});
it("rejects an open request with a non-string command", () => {
const open: PluginDuplexChannelOpenParams = {
hostRouteId: "route-1",
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
// @ts-expect-error — command must be a string.
command: 42,
};
expect(open).toBeDefined();
});
it("accepts a valid write request", () => {
const write: PluginDuplexChannelWriteParams = {
workerSessionId: "ws-1",
data: "payload",
};
expect(write.data).toBe("payload");
});
it("rejects a write request that omits the data", () => {
// @ts-expect-error — data is required.
const write: PluginDuplexChannelWriteParams = { workerSessionId: "ws-1" };
expect(write).toBeDefined();
});
it("accepts a valid stop request", () => {
const stop: PluginDuplexChannelStopParams = { workerSessionId: "ws-1" };
expect(stop.workerSessionId).toBe("ws-1");
});
it("rejects a stop request that omits the worker session identifier", () => {
// @ts-expect-error — workerSessionId is required.
const stop: PluginDuplexChannelStopParams = {};
expect(stop).toBeDefined();
});
it("accepts a close request keyed only by the host route identifier", () => {
const close: PluginDuplexChannelCloseParams = { hostRouteId: "route-1" };
const reply: PluginDuplexChannelCloseResult = { hostRouteId: "route-1" };
expect(close.hostRouteId).toBe("route-1");
expect(reply.hostRouteId).toBe("route-1");
});
it("accepts a close request that also carries the worker session identifier", () => {
const close: PluginDuplexChannelCloseParams = {
hostRouteId: "route-1",
workerSessionId: "ws-1",
};
expect(close.workerSessionId).toBe("ws-1");
});
it("rejects a close request that omits the host route identifier", () => {
// @ts-expect-error — hostRouteId is the authoritative close key and is required.
const close: PluginDuplexChannelCloseParams = { workerSessionId: "ws-1" };
expect(close).toBeDefined();
});
});
describe("duplex channel notification schemas", () => {
it("uses the generic notification method names", () => {
expect(DUPLEX_CHANNEL_DATA_NOTIFICATION).toBe("duplexChannel.data");
expect(DUPLEX_CHANNEL_EXIT_NOTIFICATION).toBe("duplexChannel.exit");
});
it("accepts a valid data notification", () => {
const data: PluginDuplexChannelDataParams = {
workerSessionId: "ws-1",
chunk: "output bytes",
};
expect(data.chunk).toBe("output bytes");
});
it("rejects a data notification that omits the chunk", () => {
// @ts-expect-error — chunk is required.
const data: PluginDuplexChannelDataParams = { workerSessionId: "ws-1" };
expect(data).toBeDefined();
});
it("accepts an exit notification with a numeric code and with null", () => {
const exit: PluginDuplexChannelExitParams = {
workerSessionId: "ws-1",
exitCode: 0,
};
const exitNull: PluginDuplexChannelExitParams = {
workerSessionId: "ws-1",
exitCode: null,
};
expect(exit.exitCode).toBe(0);
expect(exitNull.exitCode).toBeNull();
});
it("rejects an exit notification with a non-numeric, non-null exit code", () => {
const exit: PluginDuplexChannelExitParams = {
workerSessionId: "ws-1",
// @ts-expect-error — exitCode must be a number or null.
exitCode: "0",
};
expect(exit).toBeDefined();
});
});