feat(sandbox): add a duplex transport for Daytona behind a default-off kill switch (#11750)

## Thinking Path

> - Paperclip is an open source app that manages AI agents for work
> - Paperclip runs agents in local and remote sandbox environments
> - A sandbox needs a bounded channel for commands and asynchronous
input
> - Daytona needs a real pseudo-terminal transport for this channel
> - The sandbox gateway also needs a mode that handles channel loss
safely
> - This pull request adds the Daytona transport and gateway mode behind
a default-off kill switch
> - The benefit is a tested foundation for later transport selection

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting (multiple of the above): sandbox providers, plugin SDK,
server settings, and shared types.

**Problem or motivation**

The merged sandbox protocol has no runtime transport for Daytona. The
generated sandbox gateway also has no duplex mode. A later
transport-selection change needs both parts and a safe per-run gate.

**Proposed solution**

Add a Daytona `duplexCommandStream` transport over a raw
pseudo-terminal. Add a generated gateway mode named `duplex_v1`. Add the
`enableSandboxDuplexBridge` setting with a default value of `false`.
Keep transport selection disabled until a later pull request.

**Alternatives considered**

Keep the protocol unused until the transport-selection change. This
would delay provider tests and leave the gateway path without direct
coverage.

**Roadmap alignment**

This change supports the completed Roadmap item for cloud and sandbox
agents. It extends the merged sandbox channel foundation in pull request
#11738.

**Additional context**

The Daytona provider remains an untrusted boundary. Deployments must use
least-privilege provider credentials and provider-side quota controls.
Operators must name an owner for duplex telemetry retention before
rollout.

## What Changed

- Add the Daytona `duplexCommandStream` capability over a raw
pseudo-terminal.
- Add a launch wrapper that disables echo and newline translation for
NDJSON frames.
- Close channels on lease release, destroy, resume of a stopped worker,
and worker shutdown.
- Declare the capability in the Daytona manifest and set
`PLUGIN_VERSION` to `0.1.5`.
- Add the worker-to-host notification sink at `ctx.duplexChannel.data`
and `ctx.duplexChannel.exit`.
- Add the generated sandbox gateway mode
`PAPERCLIP_API_BRIDGE_MODE=duplex_v1`.
- Add channel-loss results of `409 outcome_indeterminate` and `503
bridge_unavailable`.
- Add the per-run setting `enableSandboxDuplexBridge`, with a default
value of `false`.
- Add unit tests, generated-source codec tests, lifecycle tests, and a
credential-gated live Daytona test.

## Verification

- Daytona suite: 185 tests pass.
- Adapter utilities: 754 tests pass and 4 tests skip.
- Plugin SDK: 62 tests pass.
- Shared package: 28 tests pass.
- Server duplex tests pass.
- Shared, plugin SDK, server, and Daytona TypeScript checks pass.
- The live Daytona test passes 3 cases when `DAYTONA_API_KEY` is set.
- The live Daytona test skips 3 cases without `DAYTONA_API_KEY`.
- CI must run the full workspace typecheck, test, and build gates after
PR creation.

## Risks

- The Daytona control plane and pseudo-terminal remain untrusted
boundaries.
- The duplex gateway changes behavior only when the mode and per-run
setting enable it.
- A lost channel fails requests without replay, so callers must handle
indeterminate outcomes.
- The transport-selection change must require both `duplexCommandStream
=== true` and `enableSandboxDuplexBridge === true`.
- The provider credential and quota limits need operator control before
rollout.

## Model Used

OpenAI Codex, GPT-5, tool use and code 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>
This commit is contained in:
Nicky Leach 2026-08-19 17:14:47 -07:00 committed by GitHub
parent d5416fde9a
commit faab2620ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 2437 additions and 133 deletions

View File

@ -19,13 +19,14 @@ import type { RuntimeProgressSink, RuntimeStatusSink } from "./runtime-progress.
import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js";
/**
* Input for a duplex channel open. The caller supplies only the command line
* the sandbox runs as the channel child process. The runner adds the lease
* scope from its own closure. This type is separate from the worker manager's
* Input for a duplex channel open. The caller supplies only the command argument
* vector the sandbox runs as the channel child process. Element 0 is the program
* and the rest are its arguments. The runner adds the lease scope from its own
* closure. This type is separate from the worker manager's
* `DuplexChannelOpenInput`, which also carries the lease scope fields.
*/
export interface DuplexChannelOpenInput {
command: string;
command: readonly string[];
}
/**

View File

@ -4,9 +4,15 @@ import { execFile, spawn } from "node:child_process";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getSandboxCallbackBridgeServerSource,
getSandboxDuplexGatewayCodecSource,
} from "./sandbox-callback-bridge.js";
import {
DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
adapterExecutionTargetSessionIdentity,
@ -2748,3 +2754,524 @@ describe("sandbox adapter execution targets", () => {
}
});
});
// One decoded stdout frame from the generated duplex gateway. The gateway writes
// newline-delimited JSON frames to stdout, so the test parses each line.
interface DecodedGatewayFrame {
version?: number;
type?: string;
id?: string;
method?: string;
path?: string;
query?: string;
headers?: Record<string, string>;
body?: string;
address?: string;
__unparsed?: string;
}
// One decode result from the embedded codec. The shape mirrors the host codec:
// a valid frame or a protocol error with a code.
interface EmbeddedDecodeResult {
ok: boolean;
frame?: unknown;
error?: { code: string; message: string };
}
// The names the embedded codec source declares. A test wraps the source and
// reads these names back.
interface EmbeddedCodec {
encodeDuplexFrame: (frame: unknown) => string;
decodeDuplexLine: (line: string | Buffer) => EmbeddedDecodeResult;
DuplexFrameDecoder: new (options?: { maxFrameBytes?: number }) => {
push: (chunk: Buffer) => EmbeddedDecodeResult[];
};
DUPLEX_FRAME_VERSION: number;
DEFAULT_MAX_DUPLEX_FRAME_BYTES: number;
}
type ExpectedVectorResult = { frame: unknown } | { error: string };
interface DuplexFrameVector {
name: string;
category: string;
bytes: string;
splitByteOffsets?: number[];
maxFrameBytes?: number;
roundTrip?: boolean;
expected: ExpectedVectorResult[];
}
interface DuplexFrameFixture {
frameVersion: number;
defaultMaxFrameBytes: number;
vectors: DuplexFrameVector[];
}
describe("sandbox duplex gateway", () => {
const duplexCleanupDirs: string[] = [];
const duplexChildren: Array<ReturnType<typeof spawn>> = [];
afterEach(async () => {
while (duplexChildren.length > 0) {
const child = duplexChildren.pop();
if (!child) continue;
child.kill("SIGKILL");
}
while (duplexCleanupDirs.length > 0) {
const dir = duplexCleanupDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
interface DuplexGatewayHandle {
baseUrl: string;
frames: DecodedGatewayFrame[];
stderr: () => string;
waitForFrame: (
predicate: (frame: DecodedGatewayFrame) => boolean,
timeoutMs?: number,
) => Promise<DecodedGatewayFrame>;
sendFrame: (frame: Record<string, unknown>) => void;
sendRaw: (text: string) => void;
endStdin: () => void;
exited: Promise<number | null>;
stop: () => Promise<void>;
}
// Start the generated gateway `.mjs` in duplex mode as a real child process.
// The test writes response frames to the child stdin and reads request frames
// from the child stdout, so it stands in for the host side of the channel.
async function startDuplexGateway(env: Record<string, string>): Promise<DuplexGatewayHandle> {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-gateway-"));
duplexCleanupDirs.push(rootDir);
const entrypoint = path.join(rootDir, "gateway.mjs");
await writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8");
const child = spawn(process.execPath, [entrypoint], {
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
PAPERCLIP_API_BRIDGE_MODE: "duplex_v1",
PAPERCLIP_BRIDGE_HOST: "127.0.0.1",
PAPERCLIP_BRIDGE_PORT: "0",
...env,
},
});
duplexChildren.push(child);
const frames: DecodedGatewayFrame[] = [];
const waiters: Array<{
predicate: (frame: DecodedGatewayFrame) => boolean;
resolve: (frame: DecodedGatewayFrame) => void;
}> = [];
let stdoutBuffer = "";
let stderrText = "";
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdoutBuffer += chunk;
let newlineIndex = stdoutBuffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = stdoutBuffer.slice(0, newlineIndex);
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
if (line.length > 0) {
let frame: DecodedGatewayFrame;
try {
frame = JSON.parse(line) as DecodedGatewayFrame;
} catch {
frame = { __unparsed: line };
}
frames.push(frame);
for (const waiter of [...waiters]) {
if (waiter.predicate(frame)) {
waiters.splice(waiters.indexOf(waiter), 1);
waiter.resolve(frame);
}
}
}
newlineIndex = stdoutBuffer.indexOf("\n");
}
});
child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk: string) => {
stderrText += chunk;
});
const exited = new Promise<number | null>((resolve) => {
child.on("exit", (code) => resolve(code));
});
const waitForFrame = (
predicate: (frame: DecodedGatewayFrame) => boolean,
timeoutMs = 5000,
): Promise<DecodedGatewayFrame> => {
const existing = frames.find(predicate);
if (existing) return Promise.resolve(existing);
return new Promise<DecodedGatewayFrame>((resolve, reject) => {
const waiter = {
predicate,
resolve: (frame: DecodedGatewayFrame) => {
clearTimeout(timer);
resolve(frame);
},
};
const timer = setTimeout(() => {
const index = waiters.indexOf(waiter);
if (index !== -1) waiters.splice(index, 1);
reject(new Error(`Timed out waiting for a gateway frame. stderr: ${stderrText}`));
}, timeoutMs);
waiters.push(waiter);
});
};
const handle: DuplexGatewayHandle = {
baseUrl: "",
frames,
stderr: () => stderrText,
waitForFrame,
sendFrame: (frame) => {
child.stdin?.write(`${JSON.stringify(frame)}\n`);
},
sendRaw: (text) => {
child.stdin?.write(text);
},
endStdin: () => {
child.stdin?.end();
},
exited,
stop: async () => {
child.kill("SIGKILL");
await exited.catch(() => null);
},
};
const ready = await waitForFrame((frame) => frame.type === "ready");
handle.baseUrl = String(ready.address);
return handle;
}
it("embedded gateway codec passes every vector in the shared fixture", async () => {
const codecFactory = new Function(
`${getSandboxDuplexGatewayCodecSource()}\nreturn { encodeDuplexFrame, decodeDuplexLine, DuplexFrameDecoder, DUPLEX_FRAME_VERSION, DEFAULT_MAX_DUPLEX_FRAME_BYTES };`,
) as unknown as () => EmbeddedCodec;
const codec = codecFactory();
const fixturePath = fileURLToPath(new URL("./duplex-frame-vectors.json", import.meta.url));
const fixture = JSON.parse(await readFile(fixturePath, "utf8")) as DuplexFrameFixture;
expect(fixture.frameVersion).toBe(codec.DUPLEX_FRAME_VERSION);
expect(fixture.defaultMaxFrameBytes).toBe(codec.DEFAULT_MAX_DUPLEX_FRAME_BYTES);
expect(fixture.vectors.length).toBeGreaterThanOrEqual(22);
const failures: string[] = [];
for (const vector of fixture.vectors) {
const decoder = new codec.DuplexFrameDecoder(
vector.maxFrameBytes ? { maxFrameBytes: vector.maxFrameBytes } : undefined,
);
const buffer = Buffer.from(vector.bytes, "utf8");
const offsets = vector.splitByteOffsets;
const bounds =
offsets && offsets.length > 0 ? [0, ...offsets, buffer.length] : [0, buffer.length];
const results: EmbeddedDecodeResult[] = [];
for (let index = 0; index < bounds.length - 1; index += 1) {
results.push(...decoder.push(buffer.subarray(bounds[index], bounds[index + 1])));
}
if (results.length !== vector.expected.length) {
failures.push(`${vector.name}: got ${results.length} results, want ${vector.expected.length}`);
continue;
}
vector.expected.forEach((want, index) => {
const got = results[index];
if ("frame" in want) {
if (!got.ok) {
failures.push(`${vector.name}[${index}]: expected a frame, got an error`);
return;
}
try {
expect(got.frame).toEqual(want.frame);
} catch {
failures.push(`${vector.name}[${index}]: frame does not match`);
}
} else {
if (got.ok) {
failures.push(`${vector.name}[${index}]: expected an error, got a frame`);
return;
}
if (got.error?.code !== want.error) {
failures.push(`${vector.name}[${index}]: error ${got.error?.code} != ${want.error}`);
}
}
});
}
expect(failures).toEqual([]);
// The encode side stays wire compatible too: one line, one newline, and the
// same frame after a decode round trip.
for (const vector of fixture.vectors.filter((entry) => entry.roundTrip)) {
const want = vector.expected[0];
if (!("frame" in want)) continue;
const encoded = codec.encodeDuplexFrame(want.frame);
expect(encoded.endsWith("\n")).toBe(true);
expect(encoded.slice(0, -1)).not.toContain("\n");
const decoded = codec.decodeDuplexLine(encoded.slice(0, -1));
expect(decoded.ok).toBe(true);
expect(decoded.frame).toEqual(want.frame);
}
});
it("returns the same HTTP response as the file gateway for a forwarded request", async () => {
const token = "duplex-token-forward";
const gateway = await startDuplexGateway({ PAPERCLIP_BRIDGE_TOKEN: token });
const responsePromise = fetch(`${gateway.baseUrl}/api/agents/me?view=compact`, {
headers: {
authorization: `Bearer ${token}`,
accept: "application/json",
"if-none-match": '"cache-key"',
"x-bridge-debug": "drop-me",
},
});
const requestFrame = await gateway.waitForFrame((frame) => frame.type === "request");
expect(requestFrame.method).toBe("GET");
expect(requestFrame.path).toBe("/api/agents/me");
expect(requestFrame.query).toBe("?view=compact");
// Only allowlisted headers forward; the bearer and the debug header drop.
expect(requestFrame.headers).toEqual({
accept: "application/json",
"if-none-match": '"cache-key"',
});
gateway.sendFrame({
version: 1,
type: "response",
id: requestFrame.id,
status: 200,
headers: { "content-type": "application/json", etag: '"rev-1"', "content-length": "999" },
body: JSON.stringify({ ok: true }),
outcome: "completed",
});
const response = await responsePromise;
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(response.headers.get("etag")).toBe('"rev-1"');
await expect(response.json()).resolves.toEqual({ ok: true });
// An indeterminate outcome maps to a non-retryable 409, the same contract the
// file gateway applies through the outcome header.
const indeterminatePromise = fetch(`${gateway.baseUrl}/api/issues/issue-1`, {
method: "PATCH",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ status: "in_progress" }),
});
const patchFrame = await gateway.waitForFrame(
(frame) => frame.type === "request" && frame.id !== requestFrame.id,
);
gateway.sendFrame({
version: 1,
type: "response",
id: patchFrame.id,
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: "outcome_indeterminate" }),
outcome: "indeterminate",
});
const indeterminate = await indeterminatePromise;
expect(indeterminate.status).toBe(409);
await gateway.stop();
}, 20000);
it("enforces the bearer check, JSON-only rule, body limit, and depth limit", async () => {
const token = "duplex-token-contract";
const gateway = await startDuplexGateway({
PAPERCLIP_BRIDGE_TOKEN: token,
PAPERCLIP_BRIDGE_MAX_QUEUE_DEPTH: "1",
PAPERCLIP_BRIDGE_MAX_BODY_BYTES: "16",
});
const badAuth = await fetch(`${gateway.baseUrl}/api/agents/me`, {
headers: { authorization: "Bearer wrong-token" },
});
expect(badAuth.status).toBe(401);
await expect(badAuth.json()).resolves.toEqual({ error: "Invalid bridge token." });
const nonJson = await fetch(`${gateway.baseUrl}/api/issues/issue-1/comments`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "text/plain" },
body: "not json",
});
expect(nonJson.status).toBe(415);
await expect(nonJson.json()).resolves.toEqual({
error: "Bridge only accepts JSON request bodies.",
});
const oversizeBody = await fetch(`${gateway.baseUrl}/api/issues/issue-1/comments`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ body: "x".repeat(64) }),
});
expect(oversizeBody.status).toBe(502);
await expect(oversizeBody.json()).resolves.toEqual({
error: "Bridge request body exceeded the configured size limit.",
});
// No request frame forwarded so far: the guards rejected before forwarding.
const framesBeforeDepth = gateway.frames.filter((frame) => frame.type === "request").length;
expect(framesBeforeDepth).toBe(0);
// One outstanding request fills the single depth slot; the host never
// responds, so the slot stays used.
const outstanding = fetch(`${gateway.baseUrl}/api/agents/me`, {
headers: { authorization: `Bearer ${token}` },
});
void outstanding.catch(() => undefined);
await gateway.waitForFrame((frame) => frame.type === "request");
const queueFull = await fetch(`${gateway.baseUrl}/api/agents/me`, {
headers: { authorization: `Bearer ${token}` },
});
expect(queueFull.status).toBe(503);
await expect(queueFull.json()).resolves.toEqual({ error: "Bridge request queue is full." });
// Only the outstanding request forwarded a frame; the queue-full request did
// not.
const framesAfterDepth = gateway.frames.filter((frame) => frame.type === "request").length;
expect(framesAfterDepth).toBe(1);
await gateway.stop();
}, 20000);
it("answers outstanding requests 409 and new requests 503 on stdin EOF, then exits", async () => {
const token = "duplex-token-eof";
const gateway = await startDuplexGateway({
PAPERCLIP_BRIDGE_TOKEN: token,
PAPERCLIP_BRIDGE_LOSS_EXIT_GRACE_MS: "3000",
});
const outstanding = fetch(`${gateway.baseUrl}/api/agents/me`, {
headers: { authorization: `Bearer ${token}` },
});
await gateway.waitForFrame((frame) => frame.type === "request");
gateway.endStdin();
const lossResponse = await outstanding;
expect(lossResponse.status).toBe(409);
expect(lossResponse.headers.get("x-paperclip-bridge-outcome")).toBe("indeterminate");
await expect(lossResponse.json()).resolves.toEqual({ error: "outcome_indeterminate" });
const afterLoss = await fetch(`${gateway.baseUrl}/api/agents/me`, {
headers: { authorization: `Bearer ${token}` },
});
expect(afterLoss.status).toBe(503);
await expect(afterLoss.json()).resolves.toEqual({ error: "bridge_unavailable" });
const exitCode = await gateway.exited;
expect(exitCode).toBe(0);
}, 20000);
it("applies the same loss behavior on a heartbeat timeout", async () => {
const token = "duplex-token-heartbeat";
const gateway = await startDuplexGateway({
PAPERCLIP_BRIDGE_TOKEN: token,
PAPERCLIP_BRIDGE_HEARTBEAT_TIMEOUT_MS: "400",
PAPERCLIP_BRIDGE_LOSS_EXIT_GRACE_MS: "3000",
PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS: "30000",
});
// Never send an inbound frame: inbound silence trips the heartbeat timeout.
const outstanding = fetch(`${gateway.baseUrl}/api/agents/me`, {
headers: { authorization: `Bearer ${token}` },
});
await gateway.waitForFrame((frame) => frame.type === "request");
const lossResponse = await outstanding;
expect(lossResponse.status).toBe(409);
await expect(lossResponse.json()).resolves.toEqual({ error: "outcome_indeterminate" });
const afterLoss = await fetch(`${gateway.baseUrl}/api/agents/me`, {
headers: { authorization: `Bearer ${token}` },
});
expect(afterLoss.status).toBe(503);
await expect(afterLoss.json()).resolves.toEqual({ error: "bridge_unavailable" });
const exitCode = await gateway.exited;
expect(exitCode).toBe(0);
}, 20000);
it("keeps diagnostics off stdout; stdout carries only frames", async () => {
const token = "duplex-token-stdout";
const gateway = await startDuplexGateway({ PAPERCLIP_BRIDGE_TOKEN: token });
const responsePromise = fetch(`${gateway.baseUrl}/api/agents/me`, {
headers: { authorization: `Bearer ${token}` },
});
const requestFrame = await gateway.waitForFrame((frame) => frame.type === "request");
// Feed a malformed inbound line and an unknown response id. Both force a
// diagnostic path; none of it may reach stdout.
gateway.sendRaw("this is not a frame\n");
gateway.sendFrame({
version: 1,
type: "response",
id: "unknown-id",
status: 200,
headers: {},
body: "",
outcome: "completed",
});
gateway.sendFrame({
version: 1,
type: "response",
id: requestFrame.id,
status: 200,
headers: { "content-type": "application/json" },
body: "{}",
outcome: "completed",
});
const response = await responsePromise;
expect(response.status).toBe(200);
// Every stdout line parsed as a frame; none was diagnostic text.
expect(gateway.frames.some((frame) => frame.__unparsed !== undefined)).toBe(false);
expect(
gateway.frames.every(
(frame) => typeof frame.version === "number" && typeof frame.type === "string",
),
).toBe(true);
const allowedTypes = new Set(["ready", "heartbeat", "request"]);
expect(gateway.frames.every((frame) => allowedTypes.has(String(frame.type)))).toBe(true);
// The malformed inbound line produced a stderr diagnostic, not a stdout one.
expect(gateway.stderr()).toContain("dropped an inbound frame");
await gateway.stop();
}, 20000);
it("defaults the wait budget to 35 s and honors the environment key override", async () => {
// The generated source carries the 35 s default.
expect(getSandboxCallbackBridgeServerSource()).toContain("35000");
const token = "duplex-token-budget";
const gateway = await startDuplexGateway({
PAPERCLIP_BRIDGE_TOKEN: token,
PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS: "150",
PAPERCLIP_BRIDGE_HEARTBEAT_TIMEOUT_MS: "30000",
});
const started = Date.now();
const response = await fetch(`${gateway.baseUrl}/api/agents/me`, {
headers: { authorization: `Bearer ${token}` },
});
expect(response.status).toBe(502);
await expect(response.json()).resolves.toEqual({
error: "Timed out waiting for host bridge response.",
});
expect(Date.now() - started).toBeLessThan(4000);
await gateway.stop();
}, 20000);
});

View File

@ -55,6 +55,28 @@ const SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT = "paperclip-bridge-server.mjs";
const SANDBOX_EXEC_CHANNEL_ENV = "PAPERCLIP_SANDBOX_EXEC_CHANNEL";
const SANDBOX_EXEC_CHANNEL_BRIDGE = "bridge";
// The two bridge modes the generated gateway supports. The file mode polls a
// request/response queue on disk. The duplex mode forwards one request frame to
// stdout and resolves one response frame from stdin. The generated `.mjs`
// selects the mode from `PAPERCLIP_API_BRIDGE_MODE`.
const SANDBOX_CALLBACK_BRIDGE_FILE_MODE = "queue_v1";
const SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE = "duplex_v1";
// The duplex gateway HTTP wait budget default. The gateway waits this long for a
// response frame before it answers the local caller with a 502 timeout. The
// value stays configurable through `PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS`, the
// same environment key the file mode reads.
const DEFAULT_DUPLEX_GATEWAY_WAIT_BUDGET_MS = 35_000;
// How often the duplex gateway writes a heartbeat frame to stdout.
const DEFAULT_DUPLEX_GATEWAY_HEARTBEAT_INTERVAL_MS = 5_000;
// The duplex gateway treats the channel as lost when no inbound frame arrives on
// stdin within this window.
const DEFAULT_DUPLEX_GATEWAY_HEARTBEAT_TIMEOUT_MS = 20_000;
// After a loss, the duplex gateway answers each new local request with a 503,
// then exits when this grace ends. The grace gives the local caller time to read
// the 409 for an outstanding request and a 503 for a new request.
const DEFAULT_DUPLEX_GATEWAY_LOSS_EXIT_GRACE_MS = 1_000;
/** Span name that wraps one Paperclip-API callback request read the request,
* write the response, and remove the request file. */
const CALLBACK_BRIDGE_RELAY_REQUEST_SPAN = "sandbox.callbackBridge.relayRequest";
@ -413,7 +435,7 @@ export function buildSandboxCallbackBridgeEnv(input: {
maxBodyBytes?: number | null;
}): Record<string, string> {
return {
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_FILE_MODE,
PAPERCLIP_BRIDGE_QUEUE_DIR: input.queueDir,
PAPERCLIP_BRIDGE_TOKEN: input.bridgeToken,
PAPERCLIP_BRIDGE_HOST: input.host?.trim() || "127.0.0.1",
@ -1710,30 +1732,230 @@ export async function startSandboxCallbackBridgeServer(input: {
};
}
/**
* The zero-dependency codec the generated duplex gateway embeds. It is a plain
* JavaScript copy of the host codec in `duplex-frame-codec.ts`. It uses only the
* `Buffer`, `JSON`, `Object`, `Set`, and `Array` globals, so the generated
* `.mjs` needs no workspace import. It carries no template literal and no `${`
* sequence, so it embeds inside the gateway template literal with no escape.
*
* A shared fixture file (`duplex-frame-vectors.json`) proves this copy stays wire
* compatible with the host codec. {@link getSandboxDuplexGatewayCodecSource}
* returns this exact source so a test can run every fixture vector against it.
*/
const DUPLEX_GATEWAY_CODEC_SOURCE = `const DUPLEX_FRAME_VERSION = 1;
const DEFAULT_MAX_DUPLEX_FRAME_BYTES = 1000000;
const DUPLEX_NEWLINE_BYTE = 0x0a;
const DUPLEX_EMPTY = Buffer.alloc(0);
const DUPLEX_RESPONSE_OUTCOMES = new Set(["completed", "indeterminate", "unavailable"]);
function duplexOk(frame) {
return { ok: true, frame: frame };
}
function duplexFail(code, message) {
return { ok: false, error: { code: code, message: message } };
}
function duplexIsPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function duplexIsStringRecord(value) {
if (!duplexIsPlainObject(value)) return false;
for (const entry of Object.values(value)) {
if (typeof entry !== "string") return false;
}
return true;
}
function encodeDuplexFrame(frame) {
return JSON.stringify(frame) + "\\n";
}
function decodeDuplexLine(line) {
const text = typeof line === "string" ? line : line.toString("utf8");
let parsed;
try {
parsed = JSON.parse(text);
} catch (error) {
return duplexFail("malformed_frame", "frame is not valid JSON");
}
if (!duplexIsPlainObject(parsed)) {
return duplexFail("malformed_frame", "frame is not a JSON object");
}
if (parsed.version !== DUPLEX_FRAME_VERSION) {
return duplexFail("version_mismatch", "frame version " + String(parsed.version) + " is not " + DUPLEX_FRAME_VERSION);
}
return duplexValidateFrame(parsed);
}
function duplexValidateFrame(frame) {
switch (frame.type) {
case "request":
return duplexValidateRequest(frame);
case "response":
return duplexValidateResponse(frame);
case "ready":
return duplexValidateReady(frame);
case "heartbeat":
return duplexOk(frame);
case "close":
return duplexOk(frame);
case "error":
return duplexValidateError(frame);
default:
return duplexFail("unknown_type", "unknown frame type " + JSON.stringify(frame.type));
}
}
function duplexValidateRequest(frame) {
if (
typeof frame.id !== "string" ||
typeof frame.method !== "string" ||
typeof frame.path !== "string" ||
typeof frame.query !== "string" ||
typeof frame.body !== "string" ||
!duplexIsStringRecord(frame.headers)
) {
return duplexFail("malformed_frame", "request frame has a missing or wrong-typed field");
}
return duplexOk(frame);
}
function duplexValidateResponse(frame) {
if (
typeof frame.id !== "string" ||
typeof frame.status !== "number" ||
typeof frame.body !== "string" ||
!duplexIsStringRecord(frame.headers) ||
typeof frame.outcome !== "string" ||
!DUPLEX_RESPONSE_OUTCOMES.has(frame.outcome)
) {
return duplexFail("malformed_frame", "response frame has a missing or wrong-typed field");
}
return duplexOk(frame);
}
function duplexValidateReady(frame) {
if (typeof frame.address !== "string") {
return duplexFail("malformed_frame", "ready frame has a missing or wrong-typed address");
}
return duplexOk(frame);
}
function duplexValidateError(frame) {
if (typeof frame.code !== "string") {
return duplexFail("malformed_frame", "error frame has a missing or wrong-typed code");
}
if (frame.message !== undefined && typeof frame.message !== "string") {
return duplexFail("malformed_frame", "error frame has a wrong-typed message");
}
return duplexOk(frame);
}
class DuplexFrameDecoder {
constructor(options) {
this.buffer = DUPLEX_EMPTY;
this.discarding = false;
this.maxFrameBytes =
options && options.maxFrameBytes != null ? options.maxFrameBytes : DEFAULT_MAX_DUPLEX_FRAME_BYTES;
}
push(chunk) {
const incoming = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
this.buffer = this.buffer.length === 0 ? incoming : Buffer.concat([this.buffer, incoming]);
const results = [];
for (;;) {
if (this.discarding) {
const newlineIndex = this.buffer.indexOf(DUPLEX_NEWLINE_BYTE);
if (newlineIndex === -1) {
this.buffer = DUPLEX_EMPTY;
break;
}
this.buffer = this.buffer.subarray(newlineIndex + 1);
this.discarding = false;
continue;
}
const newlineIndex = this.buffer.indexOf(DUPLEX_NEWLINE_BYTE);
if (newlineIndex === -1) {
if (this.buffer.length > this.maxFrameBytes) {
results.push(duplexFail("frame_too_large", "frame exceeds the maximum size"));
this.discarding = true;
this.buffer = DUPLEX_EMPTY;
}
break;
}
const line = this.buffer.subarray(0, newlineIndex);
this.buffer = this.buffer.subarray(newlineIndex + 1);
if (line.length === 0) continue;
if (line.length > this.maxFrameBytes) {
results.push(duplexFail("frame_too_large", "frame exceeds the maximum size"));
continue;
}
results.push(decodeDuplexLine(line));
}
return results;
}
}`;
/**
* Return the exact zero-dependency codec source the generated duplex gateway
* embeds. A test runs every fixture vector against this source, so it proves the
* embedded copy decodes the same bytes as the host codec. The source declares
* `encodeDuplexFrame`, `decodeDuplexLine`, `DuplexFrameDecoder`,
* `DUPLEX_FRAME_VERSION`, and `DEFAULT_MAX_DUPLEX_FRAME_BYTES`, but exports none
* of them; a caller wraps it to read those names.
*/
export function getSandboxDuplexGatewayCodecSource(): string {
return DUPLEX_GATEWAY_CODEC_SOURCE;
}
export function getSandboxCallbackBridgeServerSource(): string {
return `import { randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { promises as fs } from "node:fs";
import path from "node:path";
const bridgeMode = process.env.PAPERCLIP_API_BRIDGE_MODE || "${SANDBOX_CALLBACK_BRIDGE_FILE_MODE}";
const queueDir = process.env.PAPERCLIP_BRIDGE_QUEUE_DIR;
const bridgeToken = process.env.PAPERCLIP_BRIDGE_TOKEN;
const host = process.env.PAPERCLIP_BRIDGE_HOST || "127.0.0.1";
const port = Number(process.env.PAPERCLIP_BRIDGE_PORT || "0");
const pollIntervalMs = Number(process.env.PAPERCLIP_BRIDGE_POLL_INTERVAL_MS || "100");
const responseTimeoutMs = Number(process.env.PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS || "30000");
const responseTimeoutMs = Number(
process.env.PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS ||
(bridgeMode === "${SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE}"
? "${DEFAULT_DUPLEX_GATEWAY_WAIT_BUDGET_MS}"
: "${DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS}"),
);
const maxQueueDepth = Number(process.env.PAPERCLIP_BRIDGE_MAX_QUEUE_DEPTH || "${DEFAULT_BRIDGE_MAX_QUEUE_DEPTH}");
const maxBodyBytes = Number(process.env.PAPERCLIP_BRIDGE_MAX_BODY_BYTES || "${DEFAULT_BRIDGE_MAX_BODY_BYTES}");
const heartbeatIntervalMs = Number(
process.env.PAPERCLIP_BRIDGE_HEARTBEAT_INTERVAL_MS || "${DEFAULT_DUPLEX_GATEWAY_HEARTBEAT_INTERVAL_MS}",
);
const heartbeatTimeoutMs = Number(
process.env.PAPERCLIP_BRIDGE_HEARTBEAT_TIMEOUT_MS || "${DEFAULT_DUPLEX_GATEWAY_HEARTBEAT_TIMEOUT_MS}",
);
const lossExitGraceMs = Number(
process.env.PAPERCLIP_BRIDGE_LOSS_EXIT_GRACE_MS || "${DEFAULT_DUPLEX_GATEWAY_LOSS_EXIT_GRACE_MS}",
);
// The header allowlist. Both the file gateway and the duplex gateway strip an
// inbound request to these headers before they forward it. One copy serves both
// modes. The route allowlist stays on the host: both modes forward a request to
// the host, and the host enforces the same route allowlist for each.
const allowedHeaders = new Set(${JSON.stringify([...DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST])});
if (!queueDir || !bridgeToken) {
if (!bridgeToken) {
throw new Error("PAPERCLIP_BRIDGE_TOKEN is required.");
}
if (bridgeMode !== "${SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE}" && !queueDir) {
throw new Error("PAPERCLIP_BRIDGE_QUEUE_DIR and PAPERCLIP_BRIDGE_TOKEN are required.");
}
const requestsDir = path.posix.join(queueDir, "requests");
const responsesDir = path.posix.join(queueDir, "responses");
const logsDir = path.posix.join(queueDir, "logs");
const readyFile = path.posix.join(queueDir, "ready.json");
// The embedded zero-dependency frame codec. The duplex gateway uses it; the file
// gateway ignores it.
${DUPLEX_GATEWAY_CODEC_SOURCE}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@ -1766,11 +1988,6 @@ async function readBody(req) {
return Buffer.concat(chunks).toString("utf8");
}
async function queueDepth() {
const entries = await fs.readdir(requestsDir, { withFileTypes: true }).catch(() => []);
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).length;
}
function tokensMatch(received) {
const expected = Buffer.from(bridgeToken, "utf8");
const actual = Buffer.from(typeof received === "string" ? received : "", "utf8");
@ -1778,116 +1995,327 @@ function tokensMatch(received) {
return timingSafeEqual(expected, actual);
}
async function waitForResponse(requestId) {
const responsePath = path.posix.join(responsesDir, \`\${requestId}.json\`);
const deadline = Date.now() + responseTimeoutMs;
while (Date.now() < deadline) {
const body = await fs.readFile(responsePath, "utf8").catch(() => null);
if (body != null) {
await fs.rm(responsePath, { force: true }).catch(() => undefined);
return JSON.parse(body);
}
await sleep(pollIntervalMs);
}
throw new Error("Timed out waiting for host bridge response.");
function writeJsonResponse(res, status, body) {
res.statusCode = status;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(body));
}
const server = createServer(async (req, res) => {
try {
const auth = req.headers.authorization || "";
const receivedToken = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
if (!tokensMatch(receivedToken)) {
res.statusCode = 401;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: "Invalid bridge token." }));
return;
}
async function runFileGateway() {
const requestsDir = path.posix.join(queueDir, "requests");
const responsesDir = path.posix.join(queueDir, "responses");
const logsDir = path.posix.join(queueDir, "logs");
const readyFile = path.posix.join(queueDir, "ready.json");
if (await queueDepth() >= maxQueueDepth) {
res.statusCode = 503;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: "Bridge request queue is full." }));
return;
}
const url = new URL(req.url || "/", "http://127.0.0.1");
const contentType = typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "";
if (req.method && req.method !== "GET" && req.method !== "HEAD" && !/json/i.test(contentType)) {
res.statusCode = 415;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: "Bridge only accepts JSON request bodies." }));
return;
}
const requestId = randomUUID();
const requestBody = await readBody(req);
const payload = {
id: requestId,
method: req.method || "GET",
path: url.pathname,
query: url.search,
headers: normalizeHeaders(req.headers),
body: requestBody,
createdAt: new Date().toISOString(),
};
const requestPath = path.posix.join(requestsDir, \`\${requestId}.json\`);
const tempPath = \`\${requestPath}.tmp\`;
await fs.writeFile(tempPath, \`\${JSON.stringify(payload)}\\n\`, "utf8");
await fs.rename(tempPath, requestPath);
const response = await waitForResponse(requestId);
const responseHeaders = response.headers || {};
// The host marks a possibly-committed mutation with an indeterminate outcome.
// The host cannot cancel a host operation that is in flight, so the mutation
// may have committed before the worker aborted the handler. A 5xx status is
// retryable by convention, so a caller that retries 5xx would apply the
// mutation twice. Map the indeterminate outcome to a non-retryable 409, so a
// standard retry policy does not repeat the request. The outcome header and
// body stay, so a caller that reads them still sees the indeterminate result.
const bridgeOutcome = responseHeaders["x-paperclip-bridge-outcome"];
if (bridgeOutcome === "indeterminate") {
res.statusCode = 409;
} else {
res.statusCode = typeof response.status === "number" ? response.status : 200;
}
for (const [key, value] of Object.entries(responseHeaders)) {
if (typeof value !== "string" || key.toLowerCase() === "content-length") continue;
res.setHeader(key, value);
}
res.end(typeof response.body === "string" ? response.body : "");
} catch (error) {
res.statusCode = 502;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
async function queueDepth() {
const entries = await fs.readdir(requestsDir, { withFileTypes: true }).catch(() => []);
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).length;
}
});
async function shutdown() {
server.close(() => {
process.exit(0);
async function waitForResponse(requestId) {
const responsePath = path.posix.join(responsesDir, \`\${requestId}.json\`);
const deadline = Date.now() + responseTimeoutMs;
while (Date.now() < deadline) {
const body = await fs.readFile(responsePath, "utf8").catch(() => null);
if (body != null) {
await fs.rm(responsePath, { force: true }).catch(() => undefined);
return JSON.parse(body);
}
await sleep(pollIntervalMs);
}
throw new Error("Timed out waiting for host bridge response.");
}
const server = createServer(async (req, res) => {
try {
const auth = req.headers.authorization || "";
const receivedToken = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
if (!tokensMatch(receivedToken)) {
writeJsonResponse(res, 401, { error: "Invalid bridge token." });
return;
}
if (await queueDepth() >= maxQueueDepth) {
writeJsonResponse(res, 503, { error: "Bridge request queue is full." });
return;
}
const url = new URL(req.url || "/", "http://127.0.0.1");
const contentType = typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "";
if (req.method && req.method !== "GET" && req.method !== "HEAD" && !/json/i.test(contentType)) {
writeJsonResponse(res, 415, { error: "Bridge only accepts JSON request bodies." });
return;
}
const requestId = randomUUID();
const requestBody = await readBody(req);
const payload = {
id: requestId,
method: req.method || "GET",
path: url.pathname,
query: url.search,
headers: normalizeHeaders(req.headers),
body: requestBody,
createdAt: new Date().toISOString(),
};
const requestPath = path.posix.join(requestsDir, \`\${requestId}.json\`);
const tempPath = \`\${requestPath}.tmp\`;
await fs.writeFile(tempPath, \`\${JSON.stringify(payload)}\\n\`, "utf8");
await fs.rename(tempPath, requestPath);
const response = await waitForResponse(requestId);
const responseHeaders = response.headers || {};
// The host marks a possibly-committed mutation with an indeterminate outcome.
// The host cannot cancel a host operation that is in flight, so the mutation
// may have committed before the worker aborted the handler. A 5xx status is
// retryable by convention, so a caller that retries 5xx would apply the
// mutation twice. Map the indeterminate outcome to a non-retryable 409, so a
// standard retry policy does not repeat the request. The outcome header and
// body stay, so a caller that reads them still sees the indeterminate result.
const bridgeOutcome = responseHeaders["x-paperclip-bridge-outcome"];
if (bridgeOutcome === "indeterminate") {
res.statusCode = 409;
} else {
res.statusCode = typeof response.status === "number" ? response.status : 200;
}
for (const [key, value] of Object.entries(responseHeaders)) {
if (typeof value !== "string" || key.toLowerCase() === "content-length") continue;
res.setHeader(key, value);
}
res.end(typeof response.body === "string" ? response.body : "");
} catch (error) {
writeJsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) });
}
});
async function shutdown() {
server.close(() => {
process.exit(0);
});
}
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
await fs.mkdir(requestsDir, { recursive: true });
await fs.mkdir(responsesDir, { recursive: true });
await fs.mkdir(logsDir, { recursive: true });
server.listen(port, host, async () => {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Bridge server did not expose a TCP address.");
}
const ready = {
pid: process.pid,
host,
port: address.port,
baseUrl: \`http://\${host}:\${address.port}\`,
startedAt: new Date().toISOString(),
};
const tempReadyFile = \`\${readyFile}.tmp\`;
await fs.writeFile(tempReadyFile, JSON.stringify(ready), "utf8");
await fs.rename(tempReadyFile, readyFile);
});
}
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
function runDuplexGateway() {
// One outstanding local request per id. Each entry holds the HTTP resolver and
// the wait-budget timer.
const pending = new Map();
let unavailable = false;
let lossTriggered = false;
let lastInboundAt = Date.now();
await fs.mkdir(requestsDir, { recursive: true });
await fs.mkdir(responsesDir, { recursive: true });
await fs.mkdir(logsDir, { recursive: true });
server.listen(port, host, async () => {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Bridge server did not expose a TCP address.");
function diag(message) {
// Diagnostics go to stderr only. Stdout carries frames.
process.stderr.write("[paperclip-bridge] " + message + "\\n");
}
const ready = {
pid: process.pid,
host,
port: address.port,
baseUrl: \`http://\${host}:\${address.port}\`,
startedAt: new Date().toISOString(),
};
const tempReadyFile = \`\${readyFile}.tmp\`;
await fs.writeFile(tempReadyFile, JSON.stringify(ready), "utf8");
await fs.rename(tempReadyFile, readyFile);
});`;
function writeFrame(frame) {
process.stdout.write(encodeDuplexFrame(frame));
}
function stopTimers() {
clearInterval(heartbeatSendTimer);
clearInterval(heartbeatWatchTimer);
}
// Loss behavior. On stdin end, a close frame, or a heartbeat timeout, answer
// every outstanding request with a non-retryable 409 (the request may have
// reached the host), answer every new request with a retryable 503 (the
// request never left the gateway), then exit. The gateway never replays a
// request.
function triggerLoss(reason) {
if (lossTriggered) return;
lossTriggered = true;
unavailable = true;
diag("duplex channel lost: " + reason);
stopTimers();
for (const entry of pending.values()) {
clearTimeout(entry.timer);
entry.resolve({
status: 409,
headers: {
"content-type": "application/json",
"x-paperclip-bridge-outcome": "indeterminate",
},
body: JSON.stringify({ error: "outcome_indeterminate" }),
});
}
pending.clear();
const exitTimer = setTimeout(() => process.exit(0), lossExitGraceMs);
if (typeof exitTimer.unref === "function") exitTimer.unref();
}
function handleInboundFrame(frame) {
if (frame.type === "response") {
const entry = pending.get(frame.id);
if (!entry) return;
pending.delete(frame.id);
clearTimeout(entry.timer);
// Map an indeterminate outcome to a non-retryable 409, the same contract
// the file gateway applies through the outcome header.
const statusCode =
frame.outcome === "indeterminate" ? 409 : typeof frame.status === "number" ? frame.status : 200;
entry.resolve({
status: statusCode,
headers: frame.headers || {},
body: typeof frame.body === "string" ? frame.body : "",
});
return;
}
if (frame.type === "close") {
triggerLoss("host sent a close frame");
return;
}
// A heartbeat, ready, error, or request frame proves liveness but needs no
// local action here.
}
const decoder = new DuplexFrameDecoder();
process.stdin.on("data", (chunk) => {
lastInboundAt = Date.now();
for (const result of decoder.push(chunk)) {
if (!result.ok) {
diag("dropped an inbound frame: " + result.error.code);
continue;
}
handleInboundFrame(result.frame);
}
});
process.stdin.on("end", () => triggerLoss("stdin reached end of file"));
process.stdin.on("error", (error) =>
triggerLoss("stdin error: " + (error && error.message ? error.message : String(error))),
);
const heartbeatSendTimer = setInterval(() => {
writeFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" });
}, heartbeatIntervalMs);
const heartbeatWatchTimer = setInterval(() => {
if (Date.now() - lastInboundAt > heartbeatTimeoutMs) {
triggerLoss("no inbound frame within the heartbeat timeout");
}
}, Math.max(200, Math.floor(heartbeatTimeoutMs / 4)));
const server = createServer(async (req, res) => {
try {
const auth = req.headers.authorization || "";
const receivedToken = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
if (!tokensMatch(receivedToken)) {
writeJsonResponse(res, 401, { error: "Invalid bridge token." });
return;
}
if (unavailable) {
writeJsonResponse(res, 503, { error: "bridge_unavailable" });
return;
}
if (pending.size >= maxQueueDepth) {
writeJsonResponse(res, 503, { error: "Bridge request queue is full." });
return;
}
const url = new URL(req.url || "/", "http://127.0.0.1");
const contentType = typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "";
if (req.method && req.method !== "GET" && req.method !== "HEAD" && !/json/i.test(contentType)) {
writeJsonResponse(res, 415, { error: "Bridge only accepts JSON request bodies." });
return;
}
const requestId = randomUUID();
const requestBody = await readBody(req);
if (unavailable) {
writeJsonResponse(res, 503, { error: "bridge_unavailable" });
return;
}
const requestFrame = {
version: DUPLEX_FRAME_VERSION,
type: "request",
id: requestId,
method: req.method || "GET",
path: url.pathname,
query: url.search,
headers: normalizeHeaders(req.headers),
body: requestBody,
};
const response = await new Promise((resolve) => {
const timer = setTimeout(() => {
if (pending.delete(requestId)) {
resolve({
status: 502,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: "Timed out waiting for host bridge response." }),
});
}
}, responseTimeoutMs);
pending.set(requestId, { resolve: resolve, timer: timer });
writeFrame(requestFrame);
});
res.statusCode = typeof response.status === "number" ? response.status : 200;
for (const [key, value] of Object.entries(response.headers || {})) {
if (typeof value !== "string" || key.toLowerCase() === "content-length") continue;
res.setHeader(key, value);
}
res.end(typeof response.body === "string" ? response.body : "");
} catch (error) {
writeJsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) });
}
});
process.on("SIGINT", () => {
try {
server.close();
} catch (error) {
diag("server close error: " + (error && error.message ? error.message : String(error)));
}
process.exit(0);
});
process.on("SIGTERM", () => {
try {
server.close();
} catch (error) {
diag("server close error: " + (error && error.message ? error.message : String(error)));
}
process.exit(0);
});
server.listen(port, host, () => {
const address = server.address();
if (!address || typeof address === "string") {
diag("duplex gateway did not expose a TCP address");
process.exit(1);
return;
}
// The one READY frame carries the validated listener address. Stdout carries
// only frames.
writeFrame({
version: DUPLEX_FRAME_VERSION,
type: "ready",
address: "http://" + host + ":" + address.port,
});
});
}
if (bridgeMode === "${SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE}") {
runDuplexGateway();
} else {
await runFileGateway();
}`;
}

View File

@ -0,0 +1,194 @@
// Live characterization of the Daytona duplex channel against the real provider.
//
// The test is credential-gated by design. It runs only when the run shell holds a
// Daytona API key, and it skips cleanly when the key is absent. The key comes from
// `DAYTONA_API_KEY`, which the plugin also reads as the credential fallback
// (`plugin.ts`). A skip is acceptable: the merged live coverage re-runs at a later
// phase and at rollout.
//
// What it proves on one live sandbox:
// 1. The duplex channel and an ordinary command run at the same time.
// 2. Host input sent after the child starts arrives, with no terminal echo and
// no frame corruption.
// 3. Channel teardown leaves no leaked provider session.
//
// The channel runs `cat` as the child. `cat` copies its input to its output, so a
// host write returns one time as program output. The launch wrapper sets the
// pseudo-terminal to raw mode with echo off, so the terminal adds no second copy
// and no newline translation. A returned line that equals the sent line, one time,
// proves both the delivery and the clean stream.
//
// The Daytona SDK is a runtime dependency of this live test only. The module loads
// it with a dynamic import inside the gated block, so the file imports with no SDK
// present and the other Daytona tests still run without it.
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { randomUUID } from "node:crypto";
import {
openDaytonaDuplexChannelSession,
type DaytonaPtyProcess,
type DuplexChannelSession,
} from "./duplex-command-stream.js";
const DAYTONA_API_KEY = process.env.DAYTONA_API_KEY?.trim() ?? "";
const HAS_DAYTONA_CREDENTIAL = DAYTONA_API_KEY.length > 0;
const describeLive = HAS_DAYTONA_CREDENTIAL ? describe : describe.skip;
if (!HAS_DAYTONA_CREDENTIAL) {
// eslint-disable-next-line no-console
console.warn(
"Skipping the Daytona duplex channel live test: DAYTONA_API_KEY is not set in the run shell.",
);
}
// A live sandbox create and destroy needs a generous timeout. The provider round
// trip dominates the wall-clock time.
const LIVE_TIMEOUT_MS = 180_000;
// The minimal shape of the live Daytona sandbox that the test uses. The dynamic
// import returns the real SDK types; this local shape keeps the file type-safe
// without a static SDK import.
interface LiveDaytonaSandbox {
process: DaytonaPtyProcess & {
executeCommand(command: string): Promise<{ exitCode?: number; result?: string }>;
listSessions?: () => Promise<Array<{ sessionId?: string }>>;
};
delete(): Promise<void>;
}
/**
* Wait until `predicate(text)` is true for the running output, or reject after
* `timeoutMs`. The poll interval is short, so the wait ends soon after the output
* arrives.
*/
async function waitFor(
readOutput: () => string,
predicate: (text: string) => boolean,
timeoutMs: number,
label: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (predicate(readOutput())) return;
if (Date.now() > deadline) {
throw new Error(`Timed out waiting for ${label}. Output so far: ${JSON.stringify(readOutput())}`);
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/** Open a `cat` duplex channel and collect its output into a growing string. */
async function openCatChannel(
sandbox: LiveDaytonaSandbox,
): Promise<{ session: DuplexChannelSession; readOutput: () => string }> {
let output = "";
const session = await openDaytonaDuplexChannelSession(sandbox.process, "cat");
session.onData((chunk) => {
output += chunk;
});
return { session, readOutput: () => output };
}
describeLive("Daytona duplex channel (live)", () => {
let sandbox: LiveDaytonaSandbox | null = null;
beforeAll(async () => {
const { Daytona } = await import("@daytonaio/sdk");
const client = new Daytona({ apiKey: DAYTONA_API_KEY });
sandbox = (await client.create()) as unknown as LiveDaytonaSandbox;
}, LIVE_TIMEOUT_MS);
afterAll(async () => {
await sandbox?.delete().catch(() => undefined);
}, LIVE_TIMEOUT_MS);
it(
"runs the duplex channel and an ordinary command on one live sandbox",
async () => {
const live = sandbox!;
const { session, readOutput } = await openCatChannel(live);
try {
// Run an ordinary command while the channel child stays alive. Both use the
// same live sandbox, so a success on both proves they coexist.
const token = `ordinary-${randomUUID()}`;
const ordinary = await live.process.executeCommand(`printf %s ${token}`);
expect(ordinary.exitCode ?? 0).toBe(0);
expect(ordinary.result ?? "").toContain(token);
// The channel child still answers. A write returns as program output.
const ping = `chan-${randomUUID()}`;
session.write(`${ping}\n`);
await waitFor(readOutput, (text) => text.includes(ping), 30_000, "channel echo");
} finally {
await session.close();
}
},
LIVE_TIMEOUT_MS,
);
it(
"delivers host input sent after startup with no echo and no frame corruption",
async () => {
const live = sandbox!;
const { session, readOutput } = await openCatChannel(live);
try {
// Let the launch wrapper run `stty raw -echo` before the host input. The
// wrapper output echoes in cooked mode, so wait for the raw-mode switch
// before the measured write.
await new Promise((resolve) => setTimeout(resolve, 4_000));
const baseline = readOutput().length;
const line = `PING-${randomUUID()}`;
session.write(`${line}\n`);
await waitFor(
readOutput,
(text) => text.slice(baseline).includes(line),
30_000,
"delayed host input",
);
// Only `cat` writes the line, one time. Raw mode with echo off adds no
// second copy, so the sent token appears exactly one time after the write.
const afterWrite = readOutput().slice(baseline);
const occurrences = afterWrite.split(line).length - 1;
expect(occurrences).toBe(1);
// The stream carries the frame bytes with no newline translation. The
// returned line keeps its single line-feed and gains no carriage return.
expect(afterWrite).toContain(`${line}\n`);
expect(afterWrite).not.toContain(`${line}\r`);
} finally {
await session.close();
}
},
LIVE_TIMEOUT_MS,
);
it(
"leaves no leaked provider session after channel close",
async () => {
const live = sandbox!;
const listSessions = live.process.listSessions?.bind(live.process);
const baseline = listSessions ? (await listSessions()).length : 0;
const { session } = await openCatChannel(live);
await session.close();
if (listSessions) {
// The channel uses a pseudo-terminal, not an ordinary session. So the
// session list stays at the baseline. A leaked session would raise the
// count.
const after = (await listSessions()).length;
expect(after).toBe(baseline);
}
// The child exits after close, so a fresh ordinary command still succeeds on
// the same sandbox. A leaked channel would block or corrupt the sandbox.
const token = `after-close-${randomUUID()}`;
const probe = await live.process.executeCommand(`printf %s ${token}`);
expect(probe.exitCode ?? 0).toBe(0);
expect(probe.result ?? "").toContain(token);
},
LIVE_TIMEOUT_MS,
);
});

View File

@ -0,0 +1,416 @@
import { describe, expect, it } from "vitest";
import {
buildDuplexChannelLaunchWrapper,
createDaytonaDuplexChannelSessionOpener,
openDaytonaDuplexChannelSession,
type DaytonaPtyCreateOptions,
type DaytonaPtyHandle,
type DaytonaPtyProcess,
} from "./duplex-command-stream.js";
// The carriage return that submits the launch wrapper line to the terminal.
const ENTER = "\r";
// The gateway command the tests run on the channel. The command is an argument
// vector: element 0 is the program and the rest are its arguments. The real
// command is the generated duplex gateway; the tests use a placeholder, because
// the fake PTY runs no process.
const GATEWAY = ["node", "/paperclip/gateway.mjs"];
// The gateway argument vector after the wrapper quotes each element. The wrapper
// quotes every argument as a single-quoted shell word.
const GATEWAY_QUOTED = "'node' '/paperclip/gateway.mjs'";
/**
* A fake Daytona PTY handle. It records each input write, drives the output
* stream on demand, and records the kill and the disconnect. The tests use it in
* place of the real SDK `PtyHandle`, so the session runs with no sandbox. The fake
* never echoes input back as output, so a test proves the channel adds no echo.
*/
function createFakePtyHandle(onData: (data: Uint8Array) => void | Promise<void>): DaytonaPtyHandle & {
inputs: string[];
emitText: (text: string) => void;
emitBytes: (bytes: Uint8Array) => void;
finish: (exitCode: number | undefined) => void;
killed: number;
disconnected: number;
} {
const encoder = new TextEncoder();
const inputs: string[] = [];
let resolveWait: ((value: { exitCode?: number; error?: string }) => void) | null = null;
const waitPromise = new Promise<{ exitCode?: number; error?: string }>((resolve) => {
resolveWait = resolve;
});
let killed = 0;
let disconnected = 0;
return {
async waitForConnection(): Promise<void> {},
async sendInput(data: string | Uint8Array): Promise<void> {
inputs.push(typeof data === "string" ? data : new TextDecoder().decode(data));
},
wait(): Promise<{ exitCode?: number; error?: string }> {
return waitPromise;
},
async kill(): Promise<void> {
killed += 1;
},
async disconnect(): Promise<void> {
disconnected += 1;
},
// Test control below. The session never reads these fields.
emitText(text: string): void {
onData(encoder.encode(text));
},
emitBytes(bytes: Uint8Array): void {
onData(bytes);
},
finish(exitCode: number | undefined): void {
resolveWait?.({ exitCode });
},
get inputs(): string[] {
return inputs;
},
get killed(): number {
return killed;
},
get disconnected(): number {
return disconnected;
},
};
}
/**
* A fake Daytona process. It opens one fake PTY handle and records the create
* options, so a test asserts the terminal size and the launch wrapper.
*/
function createFakeProcess(): DaytonaPtyProcess & {
handle: ReturnType<typeof createFakePtyHandle> | null;
createOptions: DaytonaPtyCreateOptions | null;
} {
const state: {
handle: ReturnType<typeof createFakePtyHandle> | null;
createOptions: DaytonaPtyCreateOptions | null;
} = { handle: null, createOptions: null };
return {
get handle() {
return state.handle;
},
get createOptions() {
return state.createOptions;
},
async createPty(options: DaytonaPtyCreateOptions): Promise<DaytonaPtyHandle> {
state.createOptions = options;
const handle = createFakePtyHandle(options.onData);
state.handle = handle;
return handle;
},
};
}
// ---------------------------------------------------------------------------
// Characterization: which Daytona primitive carries a clean framed stream.
// ---------------------------------------------------------------------------
// The Daytona SDK proves two primitives. The ordinary session primitive
// dispatches a command and tails its output, split into stdout and stderr. It
// exposes no call that writes bytes to a running command's stdin, so it cannot
// carry host input on the same channel as the output. The PTY primitive exposes
// `sendInput` for host input and one `onData` callback for the output, on one live
// socket. Only the PTY carries a bidirectional stream on one connection, so the
// duplex channel uses the PTY. These tests record that decision against the two
// SDK surface shapes.
/** The subset of the Daytona session command surface, for the characterization. */
interface DaytonaSessionCommandSurface {
createSession(sessionId: string): Promise<void>;
executeSessionCommand(sessionId: string, command: unknown): Promise<{ cmdId?: string }>;
getSessionCommandLogs(
sessionId: string,
commandId: string,
onStdout: (chunk: string) => void,
onStderr: (chunk: string) => void,
): Promise<void>;
deleteSession(sessionId: string): Promise<void>;
}
describe("duplex primitive characterization", () => {
it("the PTY surface carries host input and process output on one connection", () => {
const process = createFakeProcess();
// The PTY surface exposes both directions: `sendInput` for host bytes and the
// `onData` create option for process bytes. This is the bidirectional shape a
// duplex channel needs.
expect(typeof process.createPty).toBe("function");
const handleKeys: Array<keyof DaytonaPtyHandle> = [
"waitForConnection",
"sendInput",
"wait",
"kill",
"disconnect",
];
// `sendInput` is the host-to-process direction; the create option `onData` is
// the process-to-host direction. Both exist on the PTY primitive.
expect(handleKeys).toContain("sendInput");
});
it("the session command surface exposes no write to a running command's stdin", () => {
// The session surface dispatches a command and tails split stdout/stderr. No
// member writes bytes to a running command's stdin, so it cannot carry the
// host input on the output channel. The duplex channel therefore uses the PTY,
// not a session.
const sessionMembers: Array<keyof DaytonaSessionCommandSurface> = [
"createSession",
"executeSessionCommand",
"getSessionCommandLogs",
"deleteSession",
];
expect(sessionMembers).not.toContain("sendInput" as never);
expect(sessionMembers).not.toContain("write" as never);
// The log tail splits the output into two streams; a single framed duplex
// stream must stay on one path, which the PTY `onData` provides.
expect(sessionMembers).toContain("getSessionCommandLogs");
});
});
// ---------------------------------------------------------------------------
// Launch wrapper: raw mode, echo off, diagnostics to a file, direct exec.
// ---------------------------------------------------------------------------
describe("buildDuplexChannelLaunchWrapper", () => {
it("sets raw mode with echo off, redirects diagnostics to a file, and execs the gateway", () => {
const wrapper = buildDuplexChannelLaunchWrapper(GATEWAY, "/tmp/diag.log");
// Raw mode with echo off stops the terminal echoing host input as data and
// stops newline translation, so the newline-delimited frames stay intact.
expect(wrapper).toContain("stty raw -echo");
// The diagnostics go to the file, never to the stdout frame stream. The
// wrapper quotes the path as a single-quoted shell word.
expect(wrapper).toContain("2>'/tmp/diag.log'");
// The gateway starts with `exec`, so the PTY runs it directly and its exit
// code becomes the PTY exit code.
expect(wrapper).toContain(`exec ${GATEWAY_QUOTED}`);
// The line ends with the Enter byte, so the shell runs it.
expect(wrapper.endsWith(ENTER)).toBe(true);
// Raw mode is set before the gateway starts.
expect(wrapper.indexOf("stty raw -echo")).toBeLessThan(
wrapper.indexOf(`exec ${GATEWAY_QUOTED}`),
);
});
it("quotes a diagnostics path that holds shell metacharacters, so it cannot inject a command", () => {
const wrapper = buildDuplexChannelLaunchWrapper(GATEWAY, "/tmp/x; rm -rf ~");
// The path is one single-quoted word, so the shell reads the metacharacters as
// literal text. The dangerous `rm` never becomes its own command.
expect(wrapper).toContain("2>'/tmp/x; rm -rf ~'");
expect(wrapper).not.toContain("2>/tmp/x; rm -rf ~");
});
it("quotes a single quote in the diagnostics path", () => {
const wrapper = buildDuplexChannelLaunchWrapper(GATEWAY, "/tmp/o'clock.log");
// A single quote closes the word, adds an escaped quote, and reopens the word.
expect(wrapper).toContain(`2>'/tmp/o'"'"'clock.log'`);
});
it("quotes each command argument that holds shell metacharacters", () => {
const wrapper = buildDuplexChannelLaunchWrapper(
["node", "/paperclip/gateway.mjs", "; rm -rf ~"],
"/tmp/diag.log",
);
// Each argument is one single-quoted word, so the metacharacters stay literal
// text. The dangerous `rm` argument never becomes its own command.
expect(wrapper).toContain(`exec 'node' '/paperclip/gateway.mjs' '; rm -rf ~'`);
expect(wrapper).not.toContain("; rm -rf ~;");
expect(wrapper).not.toContain("exec node /paperclip/gateway.mjs ; rm -rf ~");
});
it("rejects an empty command argument vector", () => {
// An empty vector has no program to exec, so the wrapper fails loudly.
expect(() => buildDuplexChannelLaunchWrapper([], "/tmp/diag.log")).toThrow(
/at least one argument/,
);
});
});
describe("openDaytonaDuplexChannelSession", () => {
it("starts the gateway through the raw-mode wrapper on a fixed-size terminal", async () => {
const process = createFakeProcess();
await openDaytonaDuplexChannelSession(process, GATEWAY);
expect(process.createOptions?.cols).toBe(120);
expect(process.createOptions?.rows).toBe(30);
const launch = process.handle?.inputs[0] ?? "";
expect(launch).toContain("stty raw -echo");
expect(launch).toContain(`exec ${GATEWAY_QUOTED}`);
expect(launch.endsWith(ENTER)).toBe(true);
});
it("redirects diagnostics to the given file path", async () => {
const process = createFakeProcess();
await openDaytonaDuplexChannelSession(process, GATEWAY, {
diagnosticsPath: "/tmp/paperclip-duplex-fixed.log",
});
expect(process.handle?.inputs[0]).toContain("2>'/tmp/paperclip-duplex-fixed.log'");
});
it("defaults the diagnostics path under /tmp when the caller gives none", async () => {
const process = createFakeProcess();
await openDaytonaDuplexChannelSession(process, GATEWAY);
expect(process.handle?.inputs[0]).toMatch(/2>'\/tmp\/paperclip-duplex-[0-9a-f-]+\.log'/);
});
it("delivers a host write to the process and does not echo it back as data", async () => {
const process = createFakeProcess();
const received: string[] = [];
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
session.onData((chunk) => received.push(chunk));
// The launch wrapper is the first input; the host frame write is the second.
session.write('{"version":1,"type":"heartbeat"}\n');
expect(process.handle?.inputs[1]).toBe('{"version":1,"type":"heartbeat"}\n');
// The fake terminal echoes nothing, so the write produces no data callback.
// A real terminal in raw mode with echo off behaves the same way.
expect(received).toEqual([]);
});
it("keeps the data order and the frame newlines intact", async () => {
const process = createFakeProcess();
const received: string[] = [];
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
session.onData((chunk) => received.push(chunk));
process.handle?.emitText('{"version":1,"type":"ready","address":"127.0.0.1:8080"}\n');
process.handle?.emitText('{"version":1,"type":"heartbeat"}\n');
// The order is preserved and no newline is translated (no stray "\r").
expect(received).toEqual([
'{"version":1,"type":"ready","address":"127.0.0.1:8080"}\n',
'{"version":1,"type":"heartbeat"}\n',
]);
expect(received.join("")).not.toContain("\r");
});
it("reassembles a frame split across two output chunks", async () => {
const process = createFakeProcess();
const received: string[] = [];
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
session.onData((chunk) => received.push(chunk));
const frame = '{"version":1,"type":"response","id":"r1","status":200,"headers":{},"body":"","outcome":"completed"}\n';
process.handle?.emitText(frame.slice(0, 20));
process.handle?.emitText(frame.slice(20));
expect(received.join("")).toBe(frame);
});
it("carries a large frame without loss", async () => {
const process = createFakeProcess();
const received: string[] = [];
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
session.onData((chunk) => received.push(chunk));
const body = "x".repeat(500_000);
const frame = `{"version":1,"type":"response","id":"big","status":200,"headers":{},"body":"${body}","outcome":"completed"}\n`;
process.handle?.emitText(frame);
expect(received.join("")).toBe(frame);
expect(received.join("").length).toBe(frame.length);
});
it("keeps a multibyte character whole across two output chunks", async () => {
const process = createFakeProcess();
const received: string[] = [];
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
session.onData((chunk) => received.push(chunk));
// The euro sign is three UTF-8 bytes. Split it across two chunks, so the
// stream decoder must join the bytes.
const euro = new TextEncoder().encode("€");
process.handle?.emitBytes(euro.subarray(0, 2));
process.handle?.emitBytes(euro.subarray(2));
expect(received.join("")).toBe("€");
});
it("buffers early output until the listener registers", async () => {
const process = createFakeProcess();
const received: string[] = [];
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
// Output arrives before the transport registers the listener.
process.handle?.emitText('{"version":1,');
process.handle?.emitText('"type":"heartbeat"}\n');
expect(received).toEqual([]);
session.onData((chunk) => received.push(chunk));
// The session flushes the buffered output in order on registration.
expect(received).toEqual(['{"version":1,"type":"heartbeat"}\n']);
});
it("resolves wait with the gateway exit code", async () => {
const process = createFakeProcess();
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
process.handle?.finish(3);
await expect(session.wait()).resolves.toEqual({ exitCode: 3 });
});
it("maps an absent exit code to null", async () => {
const process = createFakeProcess();
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
process.handle?.finish(undefined);
await expect(session.wait()).resolves.toEqual({ exitCode: null });
});
it("kills the child on stop and releases the socket on close", async () => {
const process = createFakeProcess();
const session = await openDaytonaDuplexChannelSession(process, GATEWAY);
session.kill();
expect(process.handle?.killed).toBe(1);
await session.close();
// Close stops the child and releases the socket, so a closed channel holds no
// live terminal.
expect(process.handle?.killed).toBeGreaterThanOrEqual(1);
expect(process.handle?.disconnected).toBe(1);
});
it("passes the working directory to the pseudo-terminal", async () => {
const process = createFakeProcess();
await openDaytonaDuplexChannelSession(process, GATEWAY, { cwd: "/paperclip-workspace" });
expect(process.createOptions?.cwd).toBe("/paperclip-workspace");
});
});
describe("createDaytonaDuplexChannelSessionOpener", () => {
it("opens a session for the command on each call", async () => {
const process = createFakeProcess();
const opener = createDaytonaDuplexChannelSessionOpener(process, { cwd: "/paperclip-workspace" });
const session = await opener(GATEWAY);
expect(process.createOptions?.cwd).toBe("/paperclip-workspace");
expect(process.handle?.inputs[0]).toContain(`exec ${GATEWAY_QUOTED}`);
expect(typeof session.onData).toBe("function");
expect(typeof session.write).toBe("function");
expect(typeof session.wait).toBe("function");
expect(typeof session.kill).toBe("function");
expect(typeof session.close).toBe("function");
});
});

View File

@ -0,0 +1,229 @@
// The Daytona duplex command stream for the sandbox callback bridge. The bridge
// needs one live bidirectional byte stream: the host writes request frames to the
// process, and the process writes response frames back, on one connection while
// the command runs. This module binds a Daytona pseudo-terminal (PTY) to a small
// session that the duplex transport consumes, so the gateway runs on a real
// terminal, streams its output, and receives host input.
//
// Primitive choice (the first acceptance criterion of the phase): the Daytona SDK
// proves two primitives. The ordinary session primitive (`createSession` plus
// `executeSessionCommand` plus `getSessionCommandLogs`) dispatches a command and
// tails its output, split into separate stdout and stderr streams. It exposes no
// call that writes bytes to a running command's stdin. The PTY primitive
// (`createPty`) exposes `sendInput` for host-to-process bytes and one `onData`
// callback for process-to-host bytes, on one live socket. Only the PTY carries a
// bidirectional stream on one connection, so this module uses the PTY. The
// characterization test in `duplex-command-stream.test.ts` records the evidence.
//
// Clean stream: a PTY echoes its input and translates newlines by default
// (`ICRNL` on input, `ONLCR` on output). The frames are newline-delimited JSON, so
// echo and newline translation corrupt the stream. The launch wrapper runs
// `stty raw -echo` before it starts the gateway, so the terminal becomes an 8-bit
// transparent path with no echo and no newline translation. The wrapper also
// redirects the gateway diagnostics to a file, so a diagnostic line never reaches
// the stdout frame stream. The wrapper starts the gateway with `exec`, so the PTY
// runs the gateway directly and the gateway exit code becomes the PTY exit code.
//
// Dependency boundary: this provider plugin ships standalone (the workspace
// excludes `packages/plugins/sandbox-providers/**`). So the module imports no
// workspace package. It reuses the narrow Daytona PTY surface that
// `setup-token-pty.ts` declares (`DaytonaPtyProcess`, `DaytonaPtyHandle`,
// `DaytonaPtyCreateOptions`), so a caller passes `sandbox.process` with no
// adapter. The narrow surface keeps the module unit-testable with a fake PTY.
import { randomUUID } from "node:crypto";
import type {
DaytonaPtyCreateOptions,
DaytonaPtyHandle,
DaytonaPtyProcess,
} from "./setup-token-pty.js";
export type {
DaytonaPtyCreateOptions,
DaytonaPtyHandle,
DaytonaPtyProcess,
} from "./setup-token-pty.js";
/**
* A live duplex channel session for one command. The session allocates a real
* pseudo-terminal in raw mode, streams the raw output, accepts host input, and
* stops the child. The shape matches the worker duplex channel session, so the
* worker forwards the data and the exit with no adapter.
*/
export interface DuplexChannelSession {
/** Registers the one data listener. The session streams each raw chunk in order. */
onData(listener: (chunk: string) => void): void;
/** Writes raw input bytes to the pseudo-terminal. */
write(data: string): void;
/** Resolves with the child exit code when the command ends. */
wait(): Promise<{ exitCode: number | null }>;
/** Stops the child process. Safe to call more than one time. */
kill(): void;
/** Releases the session resources. Safe to call more than one time. */
close(): Promise<void>;
}
/**
* Opens a {@link DuplexChannelSession} for `command`. The transport calls it one
* time. `command` is an argument vector: element 0 is the program and the rest
* are its arguments. The session quotes each element, so a shell metacharacter in
* an element cannot inject a shell command.
*/
export type DuplexChannelSessionOpener = (
command: readonly string[],
) => Promise<DuplexChannelSession>;
/** The options for the Daytona duplex channel session. */
export interface DaytonaDuplexChannelOptions {
/** The working directory for the duplex channel PTY. Defaults to the sandbox default. */
cwd?: string;
/**
* The absolute sandbox path for the gateway diagnostics. The wrapper redirects
* the terminal's stderr to this path, so a diagnostic line never reaches the
* stdout frame stream. Defaults to a per-channel path under `/tmp`.
*/
diagnosticsPath?: string;
}
// The terminal size for the duplex channel PTY. The channel carries bytes, not a
// visible UI, so a fixed standard size is enough. A fixed size keeps the launch
// deterministic.
const DUPLEX_CHANNEL_PTY_COLS = 120;
const DUPLEX_CHANNEL_PTY_ROWS = 30;
/**
* The input terminator that submits the launch wrapper line. The terminal reads
* the carriage return as the Enter key, so the shell runs the wrapper line.
*/
const PTY_COMMAND_TERMINATOR = "\r";
/**
* Quotes one value for a POSIX shell. The result is one single-quoted word, so
* the shell reads it as literal text and never expands or splits it. The function
* closes the quote, adds an escaped single quote, and reopens the quote for each
* single quote in the value.
*/
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
/**
* Builds the launch wrapper input line for the duplex channel. `command` is an
* argument vector: element 0 is the program and the rest are its arguments.
*
* The wrapper does three steps in order:
* 1. `exec 2>'<diagnosticsPath>'` redirects the shell's stderr to the file. The
* later `exec` inherits it, so the gateway diagnostics land in the file and
* never on the stdout frame stream.
* 2. `stty raw -echo` sets the terminal to raw mode with echo off, so the
* terminal neither echoes host input as data nor translates newlines.
* 3. `exec '<program>' '<arg>'...` replaces the shell with the gateway, so the
* PTY runs the gateway directly and the gateway exit code becomes the PTY
* exit code.
*
* The wrapper quotes the diagnostics path and every command argument as a
* single-quoted shell word. So a shell metacharacter in a path or an argument
* stays literal text and cannot inject a shell command.
*/
export function buildDuplexChannelLaunchWrapper(
command: readonly string[],
diagnosticsPath: string,
): string {
if (command.length === 0) {
throw new Error("The duplex channel launch command needs at least one argument.");
}
const quotedCommand = command.map(shellQuote).join(" ");
return (
`exec 2>${shellQuote(diagnosticsPath)}; stty raw -echo; ` +
`exec ${quotedCommand}${PTY_COMMAND_TERMINATOR}`
);
}
/**
* Opens a Daytona duplex channel PTY session for `command` and returns it as a
* {@link DuplexChannelSession}. The session allocates a real pseudo-terminal in
* raw mode, streams the raw output, accepts host input, and stops the child.
*
* The function decodes the terminal bytes as a UTF-8 stream, so a multibyte
* character that splits across two output chunks stays whole. It buffers the
* output until the transport registers the listener, so no early chunk is lost.
*/
export async function openDaytonaDuplexChannelSession(
process: DaytonaPtyProcess,
command: readonly string[],
options?: DaytonaDuplexChannelOptions,
): Promise<DuplexChannelSession> {
const decoder = new TextDecoder("utf-8");
let listener: ((chunk: string) => void) | null = null;
let buffered = "";
const diagnosticsPath =
options?.diagnosticsPath ?? `/tmp/paperclip-duplex-${randomUUID()}.log`;
const handle = await process.createPty({
id: `paperclip-duplex-${randomUUID()}`,
...(options?.cwd ? { cwd: options.cwd } : {}),
cols: DUPLEX_CHANNEL_PTY_COLS,
rows: DUPLEX_CHANNEL_PTY_ROWS,
onData: (data: Uint8Array): void => {
// Decode the terminal bytes as a stream, so a split multibyte character
// stays whole across two chunks. Forward the raw text; the frame codec owns
// the newline-delimited JSON parsing.
const text = decoder.decode(data, { stream: true });
if (text.length === 0) return;
if (listener) listener(text);
else buffered += text;
},
});
await handle.waitForConnection();
// Start the gateway through the raw-mode wrapper. Raw mode with echo off stops
// the terminal from echoing host input back as data and from translating the
// frame newlines. The diagnostics redirect keeps the stdout frame stream clean.
await handle.sendInput(buildDuplexChannelLaunchWrapper(command, diagnosticsPath));
return {
onData(next: (chunk: string) => void): void {
listener = next;
if (buffered.length > 0) {
const pending = buffered;
buffered = "";
next(pending);
}
},
write(data: string): void {
// Fire the input write. A write error must not throw into the transport, so
// the transport's stream stays the single result path.
void handle.sendInput(data).catch(() => undefined);
},
async wait(): Promise<{ exitCode: number | null }> {
const result = await handle.wait();
return { exitCode: typeof result.exitCode === "number" ? result.exitCode : null };
},
kill(): void {
void handle.kill().catch(() => undefined);
},
async close(): Promise<void> {
// Stop the child and release the socket. `kill` ends the gateway and `wait`
// resolves with the exit; `disconnect` releases the PTY socket. Both are
// safe to call more than one time, so close stays idempotent.
await handle.kill().catch(() => undefined);
await handle.disconnect().catch(() => undefined);
},
};
}
/**
* Creates a {@link DuplexChannelSessionOpener} bound to a Daytona `process`. Pass
* `sandbox.process` from the Daytona SDK. The opener runs the gateway command on a
* raw pseudo-terminal, streams the output, accepts host input, and stops the child
* for a terminal state.
*/
export function createDaytonaDuplexChannelSessionOpener(
process: DaytonaPtyProcess,
options?: DaytonaDuplexChannelOptions,
): DuplexChannelSessionOpener {
return (command: readonly string[]) =>
openDaytonaDuplexChannelSession(process, command, options);
}

View File

@ -10,7 +10,8 @@ const PLUGIN_ID = "paperclip.daytona-sandbox-provider";
// 0.1.3 renamed the login transport flag from `supportsSetupTokenLogin` to the
// neutral `supportsLoginPty`.
// 0.1.4 adds the `concurrentSyncOperations` sandbox capability to the driver.
const PLUGIN_VERSION = "0.1.4";
// 0.1.5 adds the `duplexCommandStream` sandbox capability to the driver.
const PLUGIN_VERSION = "0.1.5";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
@ -44,9 +45,15 @@ const manifest: PaperclipPluginManifestV1 = {
// waits for all active calls. Declare the opt-in capability so the host may
// schedule sync operations concurrently. The host resolves it `true` only
// when the worker also verifies both sync verbs.
//
// Daytona carries the sandbox callback bridge on one live duplex channel
// over a raw pseudo-terminal. Declare the opt-in capability so the host may
// select the duplex transport. The host resolves it `true` only when the
// worker also verifies the `duplexChannelOpen` handler.
sandboxCapabilities: {
incrementalSessionOutput: true,
concurrentSyncOperations: true,
duplexCommandStream: true,
},
supportsInteractiveSetup: true,
interactiveSetupConnectionTypes: ["ssh"],

View File

@ -148,6 +148,139 @@ describe("Daytona sandbox provider plugin", () => {
});
});
it("declares the duplex-command-stream capability and the four channel handlers", () => {
// Daytona carries the callback bridge on one duplex channel, so it declares
// the opt-in capability. The host resolves it `true` only when the worker also
// verifies the `duplexChannelOpen` handler, which the four handlers provide.
expect(manifest.environmentDrivers?.[0]?.sandboxCapabilities).toMatchObject({
duplexCommandStream: true,
});
expect(plugin.definition.onDuplexChannelOpen).toBeTypeOf("function");
expect(plugin.definition.onDuplexChannelWrite).toBeTypeOf("function");
expect(plugin.definition.onDuplexChannelStop).toBeTypeOf("function");
expect(plugin.definition.onDuplexChannelClose).toBeTypeOf("function");
});
it("bumps the plugin version so the server reconciles the stored manifest", () => {
// The bundled-plugin boot reconcile refreshes the stored manifest for an
// existing install only when the version changes. The duplex capability needs
// the bump to reach an existing install.
expect(manifest.version).toBe("0.1.5");
});
it("opens a duplex channel, forwards a host write, and closes it on lease release", async () => {
process.env.DAYTONA_API_KEY = "host-key";
// A fake PTY handle records each host write, drives the data stream on demand,
// and records the kill and the disconnect.
const inputs: string[] = [];
let killed = 0;
let disconnected = 0;
let ptyOnData: ((data: Uint8Array) => void) | null = null;
const handle = {
async waitForConnection() {},
async sendInput(data: string | Uint8Array) {
inputs.push(typeof data === "string" ? data : new TextDecoder().decode(data));
},
wait() {
return new Promise<{ exitCode?: number }>(() => {});
},
async kill() {
killed += 1;
},
async disconnect() {
disconnected += 1;
},
};
const sandbox = createMockSandbox();
(sandbox.process as Record<string, unknown>).createPty = vi.fn(
async (options: { onData: (data: Uint8Array) => void }) => {
ptyOnData = options.onData;
return handle;
},
);
mockCreate.mockResolvedValue(sandbox);
// Capture the data and the exit the worker forwards through `ctx.duplexChannel`.
const dataChunks: Array<{ workerSessionId: string; chunk: string }> = [];
const restore = __setDaytonaPluginContextForTest({
duplexChannel: {
data: (workerSessionId: string, chunk: string) =>
dataChunks.push({ workerSessionId, chunk }),
exit: () => {},
},
} as unknown as PluginContext);
try {
await plugin.definition.onEnvironmentAcquireLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
runId: "run-1",
agentId: "agent-1",
executionWorkspaceId: "workspace-1",
adapterType: "codex_local",
config: { image: "node:20", timeoutMs: 300000, reuseLease: true },
});
const open = await plugin.definition.onDuplexChannelOpen?.({
hostRouteId: "route-1",
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "sandbox-123",
command: ["node", "/paperclip/gateway.mjs"],
});
expect(open?.workerSessionId).toMatch(/^duplex-/);
const workerSessionId = open?.workerSessionId ?? "";
// The launch wrapper sets raw mode with echo off and redirects diagnostics.
// It quotes each command argument and the diagnostics path as a shell word.
expect(inputs[0]).toContain("stty raw -echo");
expect(inputs[0]).toContain("exec 'node' '/paperclip/gateway.mjs'");
expect(inputs[0]).toMatch(/2>'\/tmp\/paperclip-duplex-.+\.log'/);
// A host write reaches the process on the same channel.
await plugin.definition.onDuplexChannelWrite?.({
workerSessionId,
data: '{"version":1,"type":"heartbeat"}\n',
});
expect(inputs[1]).toBe('{"version":1,"type":"heartbeat"}\n');
// Process output reaches the host as a data notification bound to the
// worker session id.
ptyOnData?.(new TextEncoder().encode('{"version":1,"type":"ready","address":"127.0.0.1:1"}\n'));
expect(dataChunks).toEqual([
{
workerSessionId,
chunk: '{"version":1,"type":"ready","address":"127.0.0.1:1"}\n',
},
]);
// Lease release closes the channel: it kills the child and releases the
// pseudo-terminal socket.
await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "sandbox-123",
config: { image: "node:20", timeoutMs: 300000, reuseLease: true },
});
expect(killed).toBeGreaterThanOrEqual(1);
expect(disconnected).toBe(1);
// The channel entry is gone, so a later write is a no-op and reaches no
// process.
const inputsBefore = inputs.length;
await plugin.definition.onDuplexChannelWrite?.({
workerSessionId,
data: "late\n",
});
expect(inputs.length).toBe(inputsBefore);
} finally {
restore();
}
});
it("normalizes config and validates the API key fallback", async () => {
process.env.DAYTONA_API_KEY = "host-key";

View File

@ -66,6 +66,24 @@ import type {
DaytonaPtyProcess,
} from "./setup-token-pty.js";
// The Daytona duplex command stream for the sandbox callback bridge. The channel
// runs the gateway command on a raw pseudo-terminal, streams the frames, and
// accepts host input. The worker resolves the sandbox by the provider lease id,
// registers the channel under the host route id, and streams the data and the
// exit through `ctx.duplexChannel`.
export {
createDaytonaDuplexChannelSessionOpener,
openDaytonaDuplexChannelSession,
buildDuplexChannelLaunchWrapper,
} from "./duplex-command-stream.js";
export type {
DuplexChannelSession,
DuplexChannelSessionOpener,
DaytonaDuplexChannelOptions,
} from "./duplex-command-stream.js";
import { openDaytonaDuplexChannelSession as openDuplexChannelSession } from "./duplex-command-stream.js";
import type { DuplexChannelSession } from "./duplex-command-stream.js";
// Injectable monotonic clock for provider-boundary timing (Open Q1). Defaults
// to the real wall clock; `plugin.test.ts` overrides it via
// `setDaytonaTimingClockForTest` so the measured `durationMs`/`getDurationMs`
@ -1899,6 +1917,41 @@ function forgetDaytonaSetupTokenPty(entry: DaytonaSetupTokenPtyEntry): void {
daytonaSetupTokenPtyBySession.delete(entry.workerSessionId);
}
// The worker-side registry of live duplex channels. The worker registers each
// channel under the host-owned route identifier at open time, so the host closes
// the exact channel by that identifier even when the open reply was lost. It also
// indexes by the worker session identifier for write and stop. Each entry records
// the provider lease id, so a lease teardown closes only its own channels. The
// `onShutdown` hook closes every open channel here.
interface DaytonaDuplexChannelEntry {
hostRouteId: string;
workerSessionId: string;
providerLeaseId: string;
session: DuplexChannelSession;
}
const daytonaDuplexChannelByRoute = new Map<string, DaytonaDuplexChannelEntry>();
const daytonaDuplexChannelBySession = new Map<string, DaytonaDuplexChannelEntry>();
function forgetDaytonaDuplexChannel(entry: DaytonaDuplexChannelEntry): void {
daytonaDuplexChannelByRoute.delete(entry.hostRouteId);
daytonaDuplexChannelBySession.delete(entry.workerSessionId);
}
// Close every open duplex channel that belongs to one provider lease and drop its
// entry. The lease teardown hooks (release, destroy, resume) call this, so a
// channel never outlives the sandbox that carries it. The close kills the child
// and releases the pseudo-terminal socket, so no live channel survives the
// teardown. The stored identifiers are always cleared, so no orphan id survives.
async function closeDaytonaDuplexChannelsForLease(providerLeaseId: string): Promise<void> {
const matches = [...daytonaDuplexChannelByRoute.values()].filter(
(entry) => entry.providerLeaseId === providerLeaseId,
);
for (const entry of matches) {
forgetDaytonaDuplexChannel(entry);
await entry.session.close().catch(() => undefined);
}
}
const plugin = definePlugin({
async setup(ctx) {
// Hoist the context to a module variable so the lifecycle hooks and the
@ -2106,6 +2159,10 @@ const plugin = definePlugin({
// session and leak its shell until sandbox reaping.
if (sandbox.state !== "started") {
sandboxHandleSessionStore.clear(scope);
// A stopped sandbox loses its pseudo-terminals, so a stored duplex channel
// is dead after a real restart. Close and drop every channel on this lease
// before the restart, so no stale channel id survives the resume.
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
}
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
try {
@ -2170,6 +2227,9 @@ const plugin = definePlugin({
evictSandboxHandle(scope);
await sandboxHandleActivityGates.waitForIdle(scope);
await teardownSession(sandbox, scope);
// Close every duplex channel on this lease before the stop or the delete,
// so no channel outlives the sandbox and no stored channel id survives.
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
if (config.reuseLease) {
if (sandbox.state !== "stopped") {
@ -2234,6 +2294,9 @@ const plugin = definePlugin({
evictSandboxHandle(scope);
await sandboxHandleActivityGates.waitForIdle(scope);
await teardownSession(sandbox, scope);
// Close every duplex channel on this lease before the delete, so no channel
// outlives the sandbox and no stored channel id survives.
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
await sandbox.delete(toTimeoutSeconds(config.timeoutMs));
} finally {
sandboxHandleTeardownGates.end(scope, teardownGate);
@ -2734,8 +2797,76 @@ const plugin = definePlugin({
return { hostRouteId: params.hostRouteId };
},
// Close every open login pseudo-terminal on an orderly shutdown, then drain the
// sandbox handle cache, so a graceful shutdown holds no live login terminal.
// Open one persistent duplex channel. Resolve the cached sandbox by the provider
// lease id, run the gateway command on a raw pseudo-terminal, and register the
// channel under the host route id. Stream the raw data and the exit through
// `ctx.duplexChannel`, bound to the returned worker session id. Fail closed when
// no cached sandbox matches the lease.
async onDuplexChannelOpen(params) {
const sandbox = await sandboxHandleCache.findByProviderLeaseId(params.providerLeaseId);
if (!sandbox) {
throw new Error(
"Daytona duplex channel: no cached sandbox resolves the provider lease.",
);
}
const session = await openDuplexChannelSession(
sandbox.process as unknown as DaytonaPtyProcess,
params.command,
);
const workerSessionId = `duplex-${randomUUID()}`;
const entry: DaytonaDuplexChannelEntry = {
hostRouteId: params.hostRouteId,
workerSessionId,
providerLeaseId: params.providerLeaseId,
session,
};
daytonaDuplexChannelByRoute.set(params.hostRouteId, entry);
daytonaDuplexChannelBySession.set(workerSessionId, entry);
// Register the data listener before the first write, so no early data chunk is
// lost. The client stamps the worker session id, so the host binds the data to
// the open route.
session.onData((chunk) => {
pluginContext?.duplexChannel.data(workerSessionId, chunk);
});
// Forward the child exit one time. The host resolves the open route on it.
void session.wait().then(
(result) => pluginContext?.duplexChannel.exit(workerSessionId, result.exitCode),
() => pluginContext?.duplexChannel.exit(workerSessionId, null),
);
return { workerSessionId };
},
// Write host input to an open duplex channel, keyed by the worker session id.
// Drop the input for an unknown session.
async onDuplexChannelWrite(params) {
const entry = daytonaDuplexChannelBySession.get(params.workerSessionId);
if (!entry) return;
entry.session.write(params.data);
},
// Stop an open duplex channel child, keyed by the worker session id.
async onDuplexChannelStop(params) {
const entry = daytonaDuplexChannelBySession.get(params.workerSessionId);
if (!entry) return;
entry.session.kill();
},
// Close an open duplex channel by the host route id and acknowledge the close
// with the same identifier. The close is idempotent: it returns the
// acknowledgement even when the entry is already gone, so the host confirms the
// channel is closed. The worker never keys the close on the worker session id.
async onDuplexChannelClose(params) {
const entry = daytonaDuplexChannelByRoute.get(params.hostRouteId);
if (entry) {
forgetDaytonaDuplexChannel(entry);
await entry.session.close().catch(() => undefined);
}
return { hostRouteId: params.hostRouteId };
},
// Close every open login pseudo-terminal and every open duplex channel on an
// orderly shutdown, then drain the sandbox handle cache, so a graceful shutdown
// holds no live terminal and no live channel.
async onShutdown() {
const openSessions = [...daytonaSetupTokenPtyByRoute.values()];
daytonaSetupTokenPtyByRoute.clear();
@ -2743,6 +2874,12 @@ const plugin = definePlugin({
for (const entry of openSessions) {
await entry.session.close().catch(() => undefined);
}
const openChannels = [...daytonaDuplexChannelByRoute.values()];
daytonaDuplexChannelByRoute.clear();
daytonaDuplexChannelBySession.clear();
for (const entry of openChannels) {
await entry.session.close().catch(() => undefined);
}
sandboxHandleCache.reset();
},
});

View File

@ -33,7 +33,7 @@ describe("duplex channel request schemas", () => {
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
command: "paperclip-bridge",
command: ["paperclip-bridge"],
};
const reply: PluginDuplexChannelOpenResult = { workerSessionId: "ws-1" };
expect(open.hostRouteId).toBe("route-1");
@ -47,20 +47,20 @@ describe("duplex channel request schemas", () => {
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
command: "paperclip-bridge",
command: ["paperclip-bridge"],
};
expect(open).toBeDefined();
});
it("rejects an open request with a non-string command", () => {
it("rejects an open request whose command is not a string array", () => {
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,
// @ts-expect-error — command must be a string array.
command: "paperclip-bridge",
};
expect(open).toBeDefined();
});

View File

@ -1111,8 +1111,12 @@ export interface PluginDuplexChannelOpenParams {
environmentId: string;
/** The provider lease the sandbox is cached under. The worker resolves the sandbox by it. */
providerLeaseId: string;
/** The command the worker runs on the channel. */
command: string;
/**
* The command argument vector the worker runs on the channel. Element 0 is the
* program and the rest are its arguments. The worker quotes each element for the
* shell, so a shell metacharacter in an element cannot inject a shell command.
*/
command: readonly string[];
}
/** The open reply. It returns the worker session identifier for data binding only. */

View File

@ -2486,6 +2486,14 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
// No-op in test harness — the host login route is not wired here.
},
},
duplexChannel: {
data(_workerSessionId: string, _chunk: string) {
// No-op in test harness — the host duplex route is not wired here.
},
exit(_workerSessionId: string, _exitCode: number | null) {
// No-op in test harness — the host duplex route is not wired here.
},
},
tools: {
register(name, _decl, fn) {
requireCapability(manifest, capabilitySet, "agent.tools.register");

View File

@ -2037,6 +2037,36 @@ export interface PluginSetupTokenPtyClient {
exit(workerSessionId: string, exitCode: number | null): void;
}
/**
* `ctx.duplexChannel` stream one persistent duplex channel's data and exit from
* a sandbox provider worker to the host.
*
* The worker registers the data listener on the channel and forwards each raw
* chunk through `data(workerSessionId, chunk)`. It forwards the child exit through
* `exit(workerSessionId, exitCode)`. Each call carries the worker session
* identifier the open reply returned, so the host binds the data to the open route
* by that identifier while the route is open. The host drops a chunk or an exit
* that carries an unknown or a mismatched identifier, and it never logs the raw
* bytes. The default is a no-op that never throws. This client models the
* `setupTokenPty` client, but it carries no login command allowlist.
*/
export interface PluginDuplexChannelClient {
/**
* Deliver one raw data chunk of a persistent duplex channel.
*
* @param workerSessionId - The worker session identifier the open reply returned.
* @param chunk - The raw channel output text.
*/
data(workerSessionId: string, chunk: string): void;
/**
* Deliver the child exit of a persistent duplex channel.
*
* @param workerSessionId - The worker session identifier the open reply returned.
* @param exitCode - The child exit code, or null when the child ended with no code.
*/
exit(workerSessionId: string, exitCode: number | null): void;
}
// ---------------------------------------------------------------------------
// Full plugin context
// ---------------------------------------------------------------------------
@ -2157,6 +2187,10 @@ export interface PluginContext {
* pseudo-terminal. */
setupTokenPty: PluginSetupTokenPtyClient;
/** Stream one persistent duplex channel's data and exit to the host. The
* default is a no-op for a provider that opens no duplex channel. */
duplexChannel: PluginDuplexChannelClient;
/** Register agent tool handlers. Requires `agent.tools.register`. */
tools: PluginToolsClient;

View File

@ -115,6 +115,8 @@ import type {
import {
SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION,
SETUP_TOKEN_PTY_EXIT_NOTIFICATION,
DUPLEX_CHANNEL_DATA_NOTIFICATION,
DUPLEX_CHANNEL_EXIT_NOTIFICATION,
JSONRPC_VERSION,
JSONRPC_ERROR_CODES,
PLUGIN_RPC_ERROR_CODES,
@ -1400,6 +1402,30 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
},
duplexChannel: {
data(workerSessionId: string, chunk: string): void {
// Forward one raw data chunk of a persistent duplex channel. The
// notification carries the worker session identifier, so the host binds
// the chunk to the open route by that identifier while the route is
// open. The host drops an unknown or a mismatched identifier and never
// logs the raw bytes. This notification carries no invocation id,
// because it fires after the open reply returns.
if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return;
if (typeof chunk !== "string" || chunk.length === 0) return;
notifyHost(DUPLEX_CHANNEL_DATA_NOTIFICATION, { workerSessionId, chunk });
},
exit(workerSessionId: string, exitCode: number | null): void {
// Forward the child exit of a persistent duplex channel. The host
// resolves the open route's wait promise by the worker session
// identifier while the route is open.
if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return;
notifyHost(DUPLEX_CHANNEL_EXIT_NOTIFICATION, {
workerSessionId,
exitCode: typeof exitCode === "number" ? exitCode : null,
});
},
},
tools: {
register(
name: string,

View File

@ -925,7 +925,7 @@ describe("worker duplex channel dispatch", () => {
async setup() {},
async onDuplexChannelOpen(params) {
expect(params.hostRouteId).toBe("route-1");
expect(params.command).toBe("paperclip-bridge");
expect(params.command).toEqual(["paperclip-bridge"]);
expect(params.providerLeaseId).toBe("lease-1");
return { workerSessionId: "ws-1" };
},
@ -1004,7 +1004,7 @@ describe("worker duplex channel dispatch", () => {
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-1",
command: "paperclip-bridge",
command: ["paperclip-bridge"],
}),
).resolves.toEqual({ workerSessionId: "ws-1" });

View File

@ -253,6 +253,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: true,
selfHostedDefault: false,
},
enableSandboxDuplexBridge: {
title: "Sandbox Duplex Bridge",
description:
"Let a run open the sandbox duplex command-stream bridge when the provider grants the capability. The host reads this per run before it selects the transport. Off keeps the file bridge for every run.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableWorktreeRunExecution: {
title: "Worktree Run Execution",
description:

View File

@ -89,6 +89,13 @@ export interface InstanceExperimentalSettings {
* instances, which have no trusted cloud tenant path.
*/
enableOwnerInstanceAdmin: boolean;
/**
* Kill switch for the sandbox duplex command-stream bridge. Default off. The
* host reads this per run before it selects the callback bridge transport.
* Off forces the file bridge for every run with no manifest change and no
* redeploy.
*/
enableSandboxDuplexBridge: boolean;
/**
* Worktree preview instances (`PAPERCLIP_IN_WORKTREE=true`) suppress the
* heartbeat run engine by default so previews never self-execute tasks. When

View File

@ -24,6 +24,29 @@ describe("instance experimental settings validators", () => {
expect(settings.enableGoalsSidebarLink).toBe(false);
});
it("defaults the sandbox duplex bridge kill switch off", () => {
const settings = instanceExperimentalSettingsSchema.parse({});
expect(settings.enableSandboxDuplexBridge).toBe(false);
});
it("accepts an explicit sandbox duplex bridge kill switch value", () => {
expect(
instanceExperimentalSettingsSchema.parse({ enableSandboxDuplexBridge: true })
.enableSandboxDuplexBridge,
).toBe(true);
expect(
instanceExperimentalSettingsSchema.parse({ enableSandboxDuplexBridge: false })
.enableSandboxDuplexBridge,
).toBe(false);
});
it("accepts the sandbox duplex bridge kill switch in a patch", () => {
expect(
patchInstanceExperimentalSettingsSchema.parse({ enableSandboxDuplexBridge: true }),
).toEqual({ enableSandboxDuplexBridge: true });
});
it("defaults worktree run execution off", () => {
const settings = instanceExperimentalSettingsSchema.parse({});

View File

@ -66,6 +66,10 @@ export const instanceExperimentalSettingsSchema = z.object({
enableWorkspaceBranchReconcileForward: z.boolean().default(true),
enableWorkspaceDirtyQuarantineRepair: z.boolean().default(true),
enableOwnerInstanceAdmin: z.boolean().default(false),
// Kill switch for the sandbox duplex command-stream bridge. Default off. When
// off the host keeps the file bridge for every run with no manifest change and
// no redeploy. The host reads this per run before it selects the transport.
enableSandboxDuplexBridge: z.boolean().default(false),
enableWorktreeRunExecution: z.boolean().default(false),
worktreeRunExecutionActivatedAt: z.string().datetime().nullable().default(null),
worktreeRunExecutionActivationInstanceId: z.string().min(1).nullable().default(null),

View File

@ -0,0 +1,69 @@
import { beforeAll, describe, expect, it } from "vitest";
import { createDb } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
environmentRuntimeService,
resolveSandboxDuplexBridgeInput,
} from "../services/environment-runtime.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres duplex bridge input tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("resolveSandboxDuplexBridgeInput", () => {
it("carries an enabled kill switch into the bridge input", () => {
expect(resolveSandboxDuplexBridgeInput({ enableSandboxDuplexBridge: true })).toEqual({
enableDuplexBridge: true,
});
});
it("keeps the file bridge when the kill switch is off", () => {
expect(resolveSandboxDuplexBridgeInput({ enableSandboxDuplexBridge: false })).toEqual({
enableDuplexBridge: false,
});
});
});
describeEmbeddedPostgres("environmentRuntimeService.readSandboxDuplexBridgeInput", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
let runtime!: ReturnType<typeof environmentRuntimeService>;
let settings!: ReturnType<typeof instanceSettingsService>;
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("environment-runtime-duplex-bridge");
stopDb = started.stop;
db = createDb(started.connectionString);
runtime = environmentRuntimeService(db);
settings = instanceSettingsService(db);
return async () => {
await stopDb?.();
};
});
it("defaults the per-run bridge input to the file bridge", async () => {
expect(await runtime.readSandboxDuplexBridgeInput()).toEqual({ enableDuplexBridge: false });
});
it("reads the enabled kill switch from instance settings for a run", async () => {
await settings.updateExperimental({ enableSandboxDuplexBridge: true });
expect(await runtime.readSandboxDuplexBridgeInput()).toEqual({ enableDuplexBridge: true });
});
it("returns to the file bridge when the kill switch is turned back off", async () => {
await settings.updateExperimental({ enableSandboxDuplexBridge: true });
await settings.updateExperimental({ enableSandboxDuplexBridge: false });
expect(await runtime.readSandboxDuplexBridgeInput()).toEqual({ enableDuplexBridge: false });
});
});

View File

@ -52,6 +52,7 @@ describe("instance settings service", () => {
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: false,
enableOwnerInstanceAdmin: false,
enableSandboxDuplexBridge: false,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,

View File

@ -7,6 +7,7 @@ import type {
EnvironmentLease,
EnvironmentLeaseStatus,
ExecutionWorkspace,
InstanceExperimentalSettings,
IssueExecutionWorkspaceSettings,
PluginEnvironmentConfig,
SandboxEnvironmentConfig,
@ -32,6 +33,7 @@ import {
type StartupSpanContext,
} from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
import { environmentService } from "./environments.js";
import { instanceSettingsService } from "./instance-settings.js";
import {
collectEnvironmentSecretRefs,
parseEnvironmentDriverConfig,
@ -321,6 +323,29 @@ export function buildEnvironmentLeaseContext(input: {
};
}
/**
* The per-run duplex bridge input. The host resolves it for each new run before
* it selects the sandbox callback bridge transport. When `enableDuplexBridge` is
* false the host keeps the file bridge with no manifest change and no redeploy.
* The transport selection reads this input in a later phase; this phase only
* delivers the setting and the per-run read.
*/
export interface ResolvedSandboxDuplexBridgeInput {
/** True only when the instance opts in to the sandbox duplex command stream. */
enableDuplexBridge: boolean;
}
/**
* Map the experimental instance setting `enableSandboxDuplexBridge` into the
* per-run duplex bridge input. The setting is the kill switch. It defaults off,
* so an absent or false setting keeps the file bridge.
*/
export function resolveSandboxDuplexBridgeInput(
experimental: Pick<InstanceExperimentalSettings, "enableSandboxDuplexBridge">,
): ResolvedSandboxDuplexBridgeInput {
return { enableDuplexBridge: experimental.enableSandboxDuplexBridge === true };
}
function stripSecretRefValuesFromPluginLeaseMetadata(input: {
metadata: Record<string, unknown> | null | undefined;
schema: Record<string, unknown> | null | undefined;
@ -498,8 +523,12 @@ export interface EnvironmentDriverSyncInput extends EnvironmentDriverLeaseInput
}
export interface EnvironmentDriverOpenDuplexChannelInput extends EnvironmentDriverLeaseInput {
/** The command line the sandbox runs as the duplex channel child process. */
command: string;
/**
* The command argument vector the sandbox runs as the duplex channel child
* process. Element 0 is the program. The worker runs the vector with no
* shell, so a shell metacharacter in an element cannot inject a command.
*/
command: readonly string[];
}
export interface EnvironmentRuntimeDriver {
@ -3138,6 +3167,17 @@ export function environmentRuntimeService(
return {
getDriver,
/**
* Read the sandbox duplex bridge kill switch for a new run. The host calls it
* per run before it selects the callback bridge transport. It reads the
* experimental instance setting `enableSandboxDuplexBridge` and maps it into
* the resolved bridge input. The default-off setting keeps the file bridge.
*/
async readSandboxDuplexBridgeInput(): Promise<ResolvedSandboxDuplexBridgeInput> {
const experimental = await instanceSettingsService(db).getExperimental();
return resolveSandboxDuplexBridgeInput(experimental);
},
async acquireRunLease(input: {
companyId: string;
environment: Environment;

View File

@ -245,6 +245,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true,
enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true,
enableOwnerInstanceAdmin: parsed.data.enableOwnerInstanceAdmin ?? false,
enableSandboxDuplexBridge: parsed.data.enableSandboxDuplexBridge ?? false,
enableWorktreeRunExecution: parsed.data.enableWorktreeRunExecution ?? false,
worktreeRunExecutionActivatedAt: parsed.data.worktreeRunExecutionActivatedAt ?? null,
worktreeRunExecutionActivationInstanceId:
@ -282,6 +283,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableOwnerInstanceAdmin: false,
enableSandboxDuplexBridge: false,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,

View File

@ -490,7 +490,12 @@ export interface DuplexChannelOpenInput {
companyId: string;
environmentId: string;
providerLeaseId: string;
command: string;
/**
* The command argument vector the worker runs on the channel. Element 0 is the
* program. The worker runs the vector with no shell, so a shell metacharacter
* in an element cannot inject a command.
*/
command: readonly string[];
}
/**

View File

@ -99,6 +99,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableWorkspaceBranchReconcileForward: true,
enableWorkspaceDirtyQuarantineRepair: true,
enableOwnerInstanceAdmin: false,
enableSandboxDuplexBridge: false,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,