mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
feat(sandbox): select the duplex transport behind two gates with a liveness-only readiness contract
Select the duplex transport only when the experimental setting enableSandboxDuplexBridge is exactly true and the resolved capability duplexCommandStream is exactly true. Any other value of either gate selects the file bridge. The host assigns the loopback port and one random per-open nonce, and passes both to the gateway only through the launch environment. The host builds the sandbox-facing origin http://127.0.0.1:<assigned port> from its own stored port. No field of any channel frame contributes to the endpoint. Change the READY frame from an address string to a strict version-plus-nonce schema in the host codec, the embedded gateway codec, and the shared fixture; the gateway echoes the nonce and sends READY only after it binds the assigned port. The gateway binds exactly that port or writes a diagnostic and exits nonzero. Readiness fails closed. On a mismatched nonce, an address-bearing or incomplete READY frame, protocol contamination before READY, a bind failure, or a timeout, the host closes the partial channel inside a bounded budget and selects the file bridge, and leaves no live provider session. The broker starts only after readiness passes, so no request that carries the bridge token reaches a rejected endpoint. Teardown closes the channel before lease release. Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
6 files changed
+1059
-70
No files matched your search
@@ -128,6 +128,49 @@ describe("round-trip", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("ready frame schema", () => {
|
||||
// READY is a liveness signal, not an address source. The strict schema holds
|
||||
// exactly the frame version and the nonce.
|
||||
it("accepts a READY frame that carries exactly the version and the nonce", () => {
|
||||
const result = decodeDuplexLine(
|
||||
JSON.stringify({ version: DUPLEX_FRAME_VERSION, type: "ready", nonce: "a1b2c3d4e5f6a7b8" }),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.frame).toEqual({
|
||||
version: DUPLEX_FRAME_VERSION,
|
||||
type: "ready",
|
||||
nonce: "a1b2c3d4e5f6a7b8",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "an absent nonce", frame: { version: DUPLEX_FRAME_VERSION, type: "ready" } },
|
||||
{
|
||||
name: "a wrong-typed nonce",
|
||||
frame: { version: DUPLEX_FRAME_VERSION, type: "ready", nonce: 42 },
|
||||
},
|
||||
{
|
||||
name: "an extra address field",
|
||||
frame: {
|
||||
version: DUPLEX_FRAME_VERSION,
|
||||
type: "ready",
|
||||
nonce: "a1b2c3d4e5f6a7b8",
|
||||
address: "http://127.0.0.1:47215",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "an extra port field",
|
||||
frame: { version: DUPLEX_FRAME_VERSION, type: "ready", nonce: "a1b2c3d4e5f6a7b8", port: 47215 },
|
||||
},
|
||||
])("rejects a READY frame with $name", ({ frame }) => {
|
||||
const result = decodeDuplexLine(JSON.stringify(frame));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error.code).toBe("malformed_frame");
|
||||
});
|
||||
});
|
||||
|
||||
describe("streaming decoder behavior", () => {
|
||||
it("emits nothing until a full line arrives, then the complete frame", () => {
|
||||
const decoder = new DuplexFrameDecoder();
|
||||
|
||||
@@ -67,13 +67,18 @@ export interface DuplexResponseFrame {
|
||||
}
|
||||
|
||||
/**
|
||||
* The READY control frame. The gateway sends it one time after it validates its
|
||||
* local listener address. The `address` field carries that validated address.
|
||||
* The READY control frame. The gateway sends it one time after it binds the
|
||||
* host-assigned listener port. READY is a liveness signal, not an address
|
||||
* source. The frame carries exactly the frame version and the `nonce` string.
|
||||
* The gateway echoes the nonce the host passed through the launch environment,
|
||||
* so the host correlates the READY frame with this channel open. The frame
|
||||
* carries no address data; the host builds the endpoint from its own stored
|
||||
* port, never from the channel.
|
||||
*/
|
||||
export interface DuplexReadyFrame {
|
||||
version: number;
|
||||
type: "ready";
|
||||
address: string;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
/** The heartbeat control frame. Each side sends it on an interval to prove liveness. */
|
||||
@@ -238,8 +243,17 @@ function validateResponse(frame: Record<string, unknown>): DuplexDecodeResult {
|
||||
}
|
||||
|
||||
function validateReady(frame: Record<string, unknown>): DuplexDecodeResult {
|
||||
if (typeof frame.address !== "string") {
|
||||
return fail("malformed_frame", "ready frame has a missing or wrong-typed address");
|
||||
// READY carries a liveness nonce, not an address. The schema is strict: a valid
|
||||
// READY frame holds exactly `version`, `type`, and `nonce`. The decoder rejects
|
||||
// an absent nonce, a wrong-typed nonce, or any extra field, so a READY frame
|
||||
// that smuggles an `address`, a `port`, a `host`, or a URL never decodes.
|
||||
if (typeof frame.nonce !== "string") {
|
||||
return fail("malformed_frame", "ready frame has a missing or wrong-typed nonce");
|
||||
}
|
||||
for (const key of Object.keys(frame)) {
|
||||
if (key !== "version" && key !== "type" && key !== "nonce") {
|
||||
return fail("malformed_frame", "ready frame has an unexpected field");
|
||||
}
|
||||
}
|
||||
return ok(frame as unknown as DuplexReadyFrame);
|
||||
}
|
||||
|
||||
@@ -92,18 +92,48 @@
|
||||
{
|
||||
"name": "valid-ready",
|
||||
"category": "valid",
|
||||
"bytes": "{\"version\":1,\"type\":\"ready\",\"address\":\"127.0.0.1:47215\"}\n",
|
||||
"bytes": "{\"version\":1,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\"}\n",
|
||||
"roundTrip": true,
|
||||
"expected": [
|
||||
{
|
||||
"frame": {
|
||||
"version": 1,
|
||||
"type": "ready",
|
||||
"address": "127.0.0.1:47215"
|
||||
"nonce": "9f8e7d6c5b4a39281706f5e4d3c2b1a0"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "invalid-ready-missing-nonce",
|
||||
"category": "invalid",
|
||||
"bytes": "{\"version\":1,\"type\":\"ready\"}\n",
|
||||
"expected": [
|
||||
{
|
||||
"error": "malformed_frame"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "invalid-ready-with-address",
|
||||
"category": "invalid",
|
||||
"bytes": "{\"version\":1,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\",\"address\":\"http://127.0.0.1:47215\"}\n",
|
||||
"expected": [
|
||||
{
|
||||
"error": "malformed_frame"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "invalid-ready-with-port",
|
||||
"category": "invalid",
|
||||
"bytes": "{\"version\":1,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\",\"port\":47215}\n",
|
||||
"expected": [
|
||||
{
|
||||
"error": "malformed_frame"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "valid-heartbeat",
|
||||
"category": "valid",
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
startAdapterExecutionTargetProcessSessionBridge,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
type AdapterSandboxExecutionTarget,
|
||||
type EffectiveSandboxCapabilities,
|
||||
} from "./execution-target.js";
|
||||
import {
|
||||
createRuntimeSpanRunner,
|
||||
@@ -2769,6 +2770,461 @@ describe("sandbox adapter execution targets", () => {
|
||||
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
// The full effective-capability snapshot with one flag set. The two strict
|
||||
// gates read `duplexCommandStream`; the other flags stay false.
|
||||
function duplexCapabilities(duplexCommandStream: boolean): EffectiveSandboxCapabilities {
|
||||
return {
|
||||
reusableLeases: false,
|
||||
nativeSyncIn: false,
|
||||
nativeSyncOut: false,
|
||||
persistentProcessSessions: false,
|
||||
independentControlCommands: false,
|
||||
incrementalSessionOutput: false,
|
||||
concurrentSyncOperations: false,
|
||||
duplexCommandStream,
|
||||
};
|
||||
}
|
||||
|
||||
// The control surface a test uses to drive one fake duplex channel and read
|
||||
// what the broker wrote back through it.
|
||||
interface DuplexSelectionControl {
|
||||
openCount: number;
|
||||
written: DuplexResponseFrame[];
|
||||
writtenTypes: string[];
|
||||
stopCount: number;
|
||||
closeCount: number;
|
||||
emitData: (chunk: string) => void;
|
||||
}
|
||||
|
||||
// The hook a test supplies to script the first frames the fake gateway sends
|
||||
// after the host binds the readiness gate. The default hook echoes a valid
|
||||
// READY frame with the launch nonce, so the happy path needs no hook.
|
||||
interface DuplexOpenContext {
|
||||
nonce: string;
|
||||
port: string;
|
||||
emitRaw: (text: string) => void;
|
||||
emitFrame: (frame: Record<string, unknown>) => void;
|
||||
emitExit: (exit: { exitCode: number | null }) => void;
|
||||
}
|
||||
|
||||
// Build a runner that runs real shell commands for the asset upload and the
|
||||
// file bridge, and a fake `openDuplexChannel`. The fake parses the nonce and
|
||||
// the port out of the launch command, so the test proves the host passes both
|
||||
// only through the launch environment.
|
||||
function makeDuplexSelectionRunner(onOpen?: (ctx: DuplexOpenContext) => void): {
|
||||
runner: ReturnType<typeof createLocalSandboxRunner> & {
|
||||
openDuplexChannel: (openInput: { command: readonly string[] }) => Promise<CommandManagedDuplexChannel>;
|
||||
};
|
||||
control: DuplexSelectionControl;
|
||||
} {
|
||||
const base = createLocalSandboxRunner();
|
||||
const control: DuplexSelectionControl = {
|
||||
openCount: 0,
|
||||
written: [],
|
||||
writtenTypes: [],
|
||||
stopCount: 0,
|
||||
closeCount: 0,
|
||||
emitData: () => {},
|
||||
};
|
||||
const openDuplexChannel = async (openInput: {
|
||||
command: readonly string[];
|
||||
}): Promise<CommandManagedDuplexChannel> => {
|
||||
control.openCount += 1;
|
||||
const joined = openInput.command.join(" ");
|
||||
const nonce = /PAPERCLIP_BRIDGE_NONCE='([^']*)'/.exec(joined)?.[1] ?? "";
|
||||
const port = /PAPERCLIP_BRIDGE_PORT='([^']*)'/.exec(joined)?.[1] ?? "";
|
||||
let dataListener: ((chunk: string) => void) | null = null;
|
||||
let exitListener: ((exit: { exitCode: number | null }) => void) | null = null;
|
||||
const channel: CommandManagedDuplexChannel = {
|
||||
write(data: string): void {
|
||||
const decoded = decodeDuplexLine(data.replace(/\n$/, ""));
|
||||
if (decoded.ok) {
|
||||
control.writtenTypes.push(decoded.frame.type);
|
||||
if (decoded.frame.type === "response") control.written.push(decoded.frame);
|
||||
}
|
||||
},
|
||||
onData(listener: (chunk: string) => void): void {
|
||||
dataListener = listener;
|
||||
control.emitData = (chunk) => dataListener?.(chunk);
|
||||
// Drive the readiness emission on the next tick, after the gate also
|
||||
// registers its exit listener.
|
||||
setImmediate(() => {
|
||||
const emitRaw = (text: string) => dataListener?.(text);
|
||||
const emitFrame = (frame: Record<string, unknown>) =>
|
||||
dataListener?.(`${JSON.stringify(frame)}\n`);
|
||||
const emitExit = (exit: { exitCode: number | null }) => exitListener?.(exit);
|
||||
if (onOpen) {
|
||||
onOpen({ nonce, port, emitRaw, emitFrame, emitExit });
|
||||
} else {
|
||||
emitFrame({ version: 1, type: "ready", nonce });
|
||||
}
|
||||
});
|
||||
},
|
||||
onExit(listener: (exit: { exitCode: number | null }) => void): void {
|
||||
exitListener = listener;
|
||||
},
|
||||
stop(): void {
|
||||
control.stopCount += 1;
|
||||
},
|
||||
close(): Promise<void> {
|
||||
control.closeCount += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
return channel;
|
||||
};
|
||||
return { runner: { ...base, openDuplexChannel }, control };
|
||||
}
|
||||
|
||||
// Start a host API server that records each forwarded request, so a test can
|
||||
// assert the real token and the run id reach the host, or that a rejected
|
||||
// request never forwards.
|
||||
async function startRecordingApiServer(): Promise<{
|
||||
origin: string;
|
||||
requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }>;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = [];
|
||||
const server = createServer((req, res) => {
|
||||
requests.push({
|
||||
method: req.method ?? "GET",
|
||||
url: req.url ?? "/",
|
||||
auth: req.headers.authorization ?? null,
|
||||
runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null,
|
||||
});
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected the recording API server to listen on a TCP port.");
|
||||
}
|
||||
return {
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
it("selects the duplex transport when both strict gates pass and readiness completes", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-select-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const remoteCwd = path.join(rootDir, "workspace");
|
||||
await mkdir(remoteCwd, { recursive: true });
|
||||
const api = await startRecordingApiServer();
|
||||
const { runner, control } = makeDuplexSelectionRunner();
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "e2b",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd,
|
||||
timeoutMs: 30_000,
|
||||
runner,
|
||||
effectiveCapabilities: duplexCapabilities(true),
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
runId: "run-duplex",
|
||||
target,
|
||||
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
|
||||
adapterKey: "codex",
|
||||
hostApiToken: "real-run-jwt",
|
||||
hostApiUrl: api.origin,
|
||||
enableSandboxDuplexBridge: true,
|
||||
});
|
||||
try {
|
||||
expect(bridge).not.toBeNull();
|
||||
expect(control.openCount).toBe(1);
|
||||
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1");
|
||||
// The host builds the origin from the port it assigned, never from a frame.
|
||||
expect(bridge?.env.PAPERCLIP_API_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
expect(bridge?.env.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
|
||||
|
||||
// The gateway forwards one agent request as a request frame. The broker
|
||||
// forwards it on the host path with the real token and the run id, then
|
||||
// writes one response frame back.
|
||||
control.emitData(
|
||||
encodeDuplexFrame({
|
||||
version: DUPLEX_FRAME_VERSION,
|
||||
type: "request",
|
||||
id: "req-1",
|
||||
method: "GET",
|
||||
path: "/api/agents/me",
|
||||
query: "",
|
||||
headers: { authorization: "Bearer bridge-token" },
|
||||
body: "",
|
||||
}),
|
||||
);
|
||||
await waitForCondition(
|
||||
() => control.written.length >= 1,
|
||||
"the broker to write a duplex response frame",
|
||||
4000,
|
||||
);
|
||||
expect(control.written[0]).toMatchObject({
|
||||
type: "response",
|
||||
id: "req-1",
|
||||
status: 200,
|
||||
outcome: "completed",
|
||||
});
|
||||
expect(api.requests).toHaveLength(1);
|
||||
expect(api.requests[0]).toMatchObject({
|
||||
method: "GET",
|
||||
url: "/api/agents/me",
|
||||
auth: "Bearer real-run-jwt",
|
||||
runId: "run-duplex",
|
||||
});
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
await api.close();
|
||||
}
|
||||
// Teardown closed the channel before lease release, then stopped the child.
|
||||
expect(control.closeCount).toBeGreaterThanOrEqual(1);
|
||||
expect(control.stopCount).toBeGreaterThanOrEqual(1);
|
||||
}, 20000);
|
||||
|
||||
it.each([
|
||||
{ name: "the kill switch is off with the capability granted", enable: false, capability: true },
|
||||
{ name: "the capability is absent with the kill switch on", enable: true, capability: false },
|
||||
])("selects the file bridge when $name", async ({ enable, capability }) => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-gate-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const remoteCwd = path.join(rootDir, "workspace");
|
||||
await mkdir(remoteCwd, { recursive: true });
|
||||
const api = await startRecordingApiServer();
|
||||
const { runner, control } = makeDuplexSelectionRunner();
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "e2b",
|
||||
remoteCwd,
|
||||
timeoutMs: 30_000,
|
||||
runner,
|
||||
effectiveCapabilities: duplexCapabilities(capability),
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
runId: "run-gate",
|
||||
target,
|
||||
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
|
||||
adapterKey: "codex",
|
||||
hostApiToken: "real-run-jwt",
|
||||
hostApiUrl: api.origin,
|
||||
enableSandboxDuplexBridge: enable,
|
||||
});
|
||||
try {
|
||||
expect(bridge).not.toBeNull();
|
||||
// Neither gate combination opened a duplex channel; the file bridge serves.
|
||||
expect(control.openCount).toBe(0);
|
||||
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
await api.close();
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "a mismatched nonce",
|
||||
onOpen: (ctx: DuplexOpenContext) =>
|
||||
ctx.emitFrame({ version: 1, type: "ready", nonce: "00000000000000000000000000000000" }),
|
||||
},
|
||||
{
|
||||
name: "an incomplete READY frame",
|
||||
onOpen: (ctx: DuplexOpenContext) => ctx.emitRaw('{"version":1,"type":"ready"}\n'),
|
||||
},
|
||||
{
|
||||
name: "protocol contamination before READY",
|
||||
onOpen: (ctx: DuplexOpenContext) => ctx.emitFrame({ version: 1, type: "heartbeat" }),
|
||||
},
|
||||
{
|
||||
name: "a gateway bind failure with no READY frame",
|
||||
onOpen: (ctx: DuplexOpenContext) => ctx.emitExit({ exitCode: 1 }),
|
||||
},
|
||||
{
|
||||
name: "a readiness timeout with no frame at all",
|
||||
onOpen: () => {},
|
||||
},
|
||||
])("fails closed to the file bridge on $name and leaves no live session", async ({ onOpen }) => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-fail-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const remoteCwd = path.join(rootDir, "workspace");
|
||||
await mkdir(remoteCwd, { recursive: true });
|
||||
const api = await startRecordingApiServer();
|
||||
const { runner, control } = makeDuplexSelectionRunner(onOpen);
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "e2b",
|
||||
remoteCwd,
|
||||
timeoutMs: 30_000,
|
||||
runner,
|
||||
effectiveCapabilities: duplexCapabilities(true),
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
runId: "run-fail",
|
||||
target,
|
||||
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
|
||||
adapterKey: "codex",
|
||||
hostApiToken: "real-run-jwt",
|
||||
hostApiUrl: api.origin,
|
||||
enableSandboxDuplexBridge: true,
|
||||
duplexReadinessTimeoutMs: 400,
|
||||
});
|
||||
try {
|
||||
expect(bridge).not.toBeNull();
|
||||
expect(control.openCount).toBe(1);
|
||||
// Fail closed: the file bridge serves after the bounded cleanup.
|
||||
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
||||
// The bounded cleanup left no live provider session.
|
||||
expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
await api.close();
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "an attacker-owned numeric local port",
|
||||
buildReady: (nonce: string, attackerPort: number) =>
|
||||
`{"version":1,"type":"ready","nonce":"${nonce}","port":${attackerPort}}\n`,
|
||||
},
|
||||
{
|
||||
name: "a channel-supplied host URL",
|
||||
buildReady: (nonce: string, attackerPort: number) =>
|
||||
`{"version":1,"type":"ready","nonce":"${nonce}","address":"http://127.0.0.1:${attackerPort}"}\n`,
|
||||
},
|
||||
])(
|
||||
"rejects a READY frame that carries $name and never sends the bridge token there",
|
||||
async ({ buildReady }) => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-addr-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const remoteCwd = path.join(rootDir, "workspace");
|
||||
await mkdir(remoteCwd, { recursive: true });
|
||||
|
||||
// An endpoint an attacker controls. No request that carries the bridge
|
||||
// token may reach it, because the host never derives the endpoint from a
|
||||
// channel frame.
|
||||
const attackerHits: string[] = [];
|
||||
const attacker = createServer((req, res) => {
|
||||
attackerHits.push(req.headers.authorization ?? "");
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end("{}");
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
attacker.once("error", reject);
|
||||
attacker.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const attackerAddress = attacker.address();
|
||||
if (!attackerAddress || typeof attackerAddress === "string") {
|
||||
throw new Error("Expected the attacker server to listen on a TCP port.");
|
||||
}
|
||||
const attackerPort = attackerAddress.port;
|
||||
|
||||
const api = await startRecordingApiServer();
|
||||
const { runner, control } = makeDuplexSelectionRunner((ctx) =>
|
||||
ctx.emitRaw(buildReady(ctx.nonce, attackerPort)),
|
||||
);
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "e2b",
|
||||
remoteCwd,
|
||||
timeoutMs: 30_000,
|
||||
runner,
|
||||
effectiveCapabilities: duplexCapabilities(true),
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
runId: "run-addr",
|
||||
target,
|
||||
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
|
||||
adapterKey: "codex",
|
||||
hostApiToken: "real-run-jwt",
|
||||
hostApiUrl: api.origin,
|
||||
enableSandboxDuplexBridge: true,
|
||||
duplexReadinessTimeoutMs: 400,
|
||||
});
|
||||
try {
|
||||
expect(bridge).not.toBeNull();
|
||||
// The address-bearing READY frame failed the strict schema, so the host
|
||||
// fell closed to the file bridge and built no channel-supplied endpoint.
|
||||
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
||||
expect(bridge?.env.PAPERCLIP_API_URL).not.toContain(String(attackerPort));
|
||||
expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1);
|
||||
// Give any stray forward a moment, then assert the attacker got nothing.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(attackerHits).toEqual([]);
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
await api.close();
|
||||
await new Promise<void>((resolve) => attacker.close(() => resolve()));
|
||||
}
|
||||
},
|
||||
20000,
|
||||
);
|
||||
|
||||
it("answers an unlisted route 403 over the duplex path without forwarding it", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-403-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const remoteCwd = path.join(rootDir, "workspace");
|
||||
await mkdir(remoteCwd, { recursive: true });
|
||||
const api = await startRecordingApiServer();
|
||||
const { runner, control } = makeDuplexSelectionRunner();
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "e2b",
|
||||
remoteCwd,
|
||||
timeoutMs: 30_000,
|
||||
runner,
|
||||
effectiveCapabilities: duplexCapabilities(true),
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
runId: "run-403",
|
||||
target,
|
||||
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
|
||||
adapterKey: "codex",
|
||||
hostApiToken: "real-run-jwt",
|
||||
hostApiUrl: api.origin,
|
||||
enableSandboxDuplexBridge: true,
|
||||
});
|
||||
try {
|
||||
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1");
|
||||
control.emitData(
|
||||
encodeDuplexFrame({
|
||||
version: DUPLEX_FRAME_VERSION,
|
||||
type: "request",
|
||||
id: "req-forbidden",
|
||||
method: "POST",
|
||||
path: "/api/secret-admin-route",
|
||||
query: "",
|
||||
headers: { authorization: "Bearer bridge-token", "content-type": "application/json" },
|
||||
body: JSON.stringify({ escalate: true }),
|
||||
}),
|
||||
);
|
||||
await waitForCondition(
|
||||
() => control.written.length >= 1,
|
||||
"the broker to write a 403 response frame",
|
||||
4000,
|
||||
);
|
||||
expect(control.written[0]).toMatchObject({ type: "response", id: "req-forbidden", status: 403 });
|
||||
// The route allowlist rejected the request, so it never forwarded.
|
||||
expect(api.requests).toHaveLength(0);
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
await api.close();
|
||||
}
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
// One decoded stdout frame from the generated duplex gateway. The gateway writes
|
||||
@@ -2782,7 +3238,7 @@ interface DecodedGatewayFrame {
|
||||
query?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
address?: string;
|
||||
nonce?: string;
|
||||
__unparsed?: string;
|
||||
}
|
||||
|
||||
@@ -2856,22 +3312,50 @@ describe("sandbox duplex gateway", () => {
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Reserve a free loopback port. The host assigns a positive port to the duplex
|
||||
// gateway; the gateway binds exactly that port. The test opens an ephemeral
|
||||
// listener, reads its port, then closes it, so the number is very likely free
|
||||
// when the gateway binds it a moment later.
|
||||
async function reserveLoopbackPort(): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const probe = net.createServer();
|
||||
probe.once("error", reject);
|
||||
probe.listen(0, "127.0.0.1", () => {
|
||||
const address = probe.address();
|
||||
if (!address || typeof address === "string") {
|
||||
probe.close(() => reject(new Error("Could not reserve a loopback port.")));
|
||||
return;
|
||||
}
|
||||
const reserved = address.port;
|
||||
probe.close(() => resolve(reserved));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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> {
|
||||
// The host assigns the port and the nonce; the test builds the base URL from
|
||||
// the assigned port, never from the READY frame.
|
||||
async function startDuplexGateway(
|
||||
env: Record<string, string>,
|
||||
options: { port?: number; nonce?: 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 assignedPort = options.port ?? (await reserveLoopbackPort());
|
||||
const nonce = options.nonce ?? "d0c1b2a3e4f5061728394a5b6c7d8e9f";
|
||||
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",
|
||||
PAPERCLIP_BRIDGE_PORT: String(assignedPort),
|
||||
PAPERCLIP_BRIDGE_NONCE: nonce,
|
||||
...env,
|
||||
},
|
||||
});
|
||||
@@ -2964,7 +3448,11 @@ describe("sandbox duplex gateway", () => {
|
||||
};
|
||||
|
||||
const ready = await waitForFrame((frame) => frame.type === "ready");
|
||||
handle.baseUrl = String(ready.address);
|
||||
// READY carries the echoed nonce and no address data. The host builds the
|
||||
// origin from the port it assigned, never from the frame.
|
||||
expect(ready.nonce).toBe(nonce);
|
||||
expect((ready as Record<string, unknown>).address).toBeUndefined();
|
||||
handle.baseUrl = `http://127.0.0.1:${assignedPort}`;
|
||||
return handle;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import type { SshRemoteExecutionSpec } from "./ssh.js";
|
||||
import {
|
||||
prepareCommandManagedRuntime,
|
||||
type CommandManagedDuplexChannel,
|
||||
type CommandManagedRuntimeAsset,
|
||||
type CommandManagedRuntimeRunner,
|
||||
} from "./command-managed-runtime.js";
|
||||
@@ -23,20 +24,33 @@ export type {
|
||||
SandboxAdditionalSource,
|
||||
} from "./sandbox-managed-runtime.js";
|
||||
import {
|
||||
authorizeSandboxCallbackBridgeRequestWithRoutes,
|
||||
createCommandManagedSandboxCallbackBridgeQueueClient,
|
||||
createSandboxCallbackBridgeAsset,
|
||||
createSandboxCallbackBridgeToken,
|
||||
DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES,
|
||||
SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE,
|
||||
SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT,
|
||||
sandboxCallbackBridgeDirectories,
|
||||
startSandboxCallbackBridgeServer,
|
||||
startSandboxCallbackBridgeWorker,
|
||||
syncRemoteTextFileWithHashSkip,
|
||||
syncSandboxCallbackBridgeEntrypoint,
|
||||
} from "./sandbox-callback-bridge.js";
|
||||
import {
|
||||
createSandboxRunLogTailFactory,
|
||||
type SandboxRunLogTailFactory,
|
||||
} from "./sandbox-run-log-stream.js";
|
||||
import { DEFAULT_DUPLEX_BROKER_BUDGETS } from "./duplex-bridge-broker.js";
|
||||
import {
|
||||
createDuplexBridgeBroker,
|
||||
DEFAULT_DUPLEX_BROKER_BUDGETS,
|
||||
type DuplexBridgeBroker,
|
||||
type DuplexBrokerForwardResult,
|
||||
} from "./duplex-bridge-broker.js";
|
||||
import {
|
||||
decodeDuplexLine,
|
||||
type DuplexRequestFrame,
|
||||
} from "./duplex-frame-codec.js";
|
||||
import { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
|
||||
import {
|
||||
ensureCommandResolvable,
|
||||
@@ -2185,6 +2199,226 @@ child.on("close", (code, signal) => void writeEvent({ type: "exit", code, signal
|
||||
${PROCESS_SESSION_STDIN_POLL_TAIL}`;
|
||||
}
|
||||
|
||||
/** The default deadline for the duplex readiness handshake, in milliseconds. */
|
||||
const DEFAULT_DUPLEX_READINESS_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** The default bounded budget to close a partial duplex channel, in milliseconds. */
|
||||
const DEFAULT_DUPLEX_CLEANUP_BUDGET_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Reserve a loopback port the host assigns to the duplex gateway. The host binds
|
||||
* an ephemeral listener on `127.0.0.1`, reads the port the operating system
|
||||
* chose, then closes the listener. The number is very likely free when the
|
||||
* gateway binds it a moment later. The gateway binds exactly this port or exits
|
||||
* nonzero, so a taken port fails closed to the file bridge and never steers the
|
||||
* endpoint.
|
||||
*/
|
||||
async function reserveHostAssignedLoopbackPort(): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const probe = net.createServer();
|
||||
probe.once("error", reject);
|
||||
probe.listen(0, "127.0.0.1", () => {
|
||||
const address = probe.address();
|
||||
if (!address || typeof address === "string") {
|
||||
probe.close(() => reject(new Error("Could not reserve a loopback port for the duplex gateway.")));
|
||||
return;
|
||||
}
|
||||
const reserved = address.port;
|
||||
probe.close(() => resolve(reserved));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the argument vector that launches the duplex gateway in the sandbox. The
|
||||
* host passes the assigned port and the per-open nonce only through the launch
|
||||
* environment, so the argument vector sets them as environment assignments in
|
||||
* front of the node command. No addressing data comes from the channel.
|
||||
*/
|
||||
function buildDuplexGatewayLaunchArgv(input: {
|
||||
shellCommand: "bash" | "sh";
|
||||
remoteEntrypoint: string;
|
||||
nodeCommand?: string | null;
|
||||
env: Record<string, string>;
|
||||
}): string[] {
|
||||
const assignments = Object.entries(input.env)
|
||||
.map(([key, value]) => `${key}=${shellQuote(value)}`)
|
||||
.join(" ");
|
||||
const nodeCommand = input.nodeCommand?.trim() || "node";
|
||||
const script = `exec ${assignments} ${shellQuote(nodeCommand)} ${shellQuote(input.remoteEntrypoint)}`;
|
||||
return [input.shellCommand, ...shellCommandArgs(script)];
|
||||
}
|
||||
|
||||
/** The reason the duplex readiness handshake did not pass. */
|
||||
type DuplexReadinessFailure =
|
||||
| "protocol_contamination"
|
||||
| "nonce_mismatch"
|
||||
| "channel_exit"
|
||||
| "timeout";
|
||||
|
||||
/** The outcome of the duplex readiness handshake. */
|
||||
type DuplexReadinessResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: DuplexReadinessFailure };
|
||||
|
||||
/**
|
||||
* The duplex readiness gate. The gate owns the single data listener and the
|
||||
* single exit listener of the channel while the host waits for a valid READY
|
||||
* frame. It resolves the handshake, then hands the channel to the broker.
|
||||
*
|
||||
* Readiness passes only when the first non-empty line is a valid versioned READY
|
||||
* frame with the matching nonce and no address data. Any earlier decode failure,
|
||||
* any earlier non-READY frame, a mismatched nonce, an early exit, or a timeout
|
||||
* fails the handshake. The gate never dispatches a request; the broker does that
|
||||
* after readiness passes.
|
||||
*/
|
||||
interface DuplexReadinessGate {
|
||||
/** Resolves with the handshake outcome. It never rejects. */
|
||||
readonly ready: Promise<DuplexReadinessResult>;
|
||||
/**
|
||||
* The channel view the broker consumes after readiness passes. It replays the
|
||||
* bytes that followed the READY frame, then forwards each later chunk and the
|
||||
* exit. The gate keeps one real data listener, so the broker never double-binds
|
||||
* the channel.
|
||||
*/
|
||||
readonly brokerChannel: CommandManagedDuplexChannel;
|
||||
}
|
||||
|
||||
function createDuplexReadinessGate(
|
||||
channel: CommandManagedDuplexChannel,
|
||||
options: { nonce: string; timeoutMs: number },
|
||||
): DuplexReadinessGate {
|
||||
let settled = false;
|
||||
let readyOk = false;
|
||||
// The raw bytes the host reads before the READY frame completes.
|
||||
let buffer = "";
|
||||
// The bytes that followed the READY frame, held until the broker binds.
|
||||
let pending = "";
|
||||
// The exit that arrived after READY but before the broker bound, if any.
|
||||
let pendingExit: { exitCode: number | null } | null = null;
|
||||
let dataSink: ((chunk: string) => void) | null = null;
|
||||
let exitSink: ((exit: { exitCode: number | null }) => void) | null = null;
|
||||
let resolveReady!: (result: DuplexReadinessResult) => void;
|
||||
const ready = new Promise<DuplexReadinessResult>((resolve) => {
|
||||
resolveReady = resolve;
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => finish({ ok: false, reason: "timeout" }), options.timeoutMs);
|
||||
timer.unref?.();
|
||||
|
||||
function finish(result: DuplexReadinessResult): void {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (result.ok) readyOk = true;
|
||||
resolveReady(result);
|
||||
}
|
||||
|
||||
channel.onData((chunk) => {
|
||||
if (dataSink) {
|
||||
dataSink(chunk);
|
||||
return;
|
||||
}
|
||||
if (readyOk) {
|
||||
// READY already passed; hold the bytes until the broker binds.
|
||||
pending += chunk;
|
||||
return;
|
||||
}
|
||||
buffer += chunk;
|
||||
for (;;) {
|
||||
const newlineIndex = buffer.indexOf("\n");
|
||||
if (newlineIndex === -1) return;
|
||||
const line = buffer.slice(0, newlineIndex);
|
||||
const rest = buffer.slice(newlineIndex + 1);
|
||||
if (line.length === 0) {
|
||||
// Skip a leading blank line, the same as the frame decoder.
|
||||
buffer = rest;
|
||||
continue;
|
||||
}
|
||||
const decoded = decodeDuplexLine(line);
|
||||
if (!decoded.ok || decoded.frame.type !== "ready") {
|
||||
// The first frame before READY must be a valid READY frame. Anything
|
||||
// else is protocol contamination.
|
||||
finish({ ok: false, reason: "protocol_contamination" });
|
||||
return;
|
||||
}
|
||||
if (decoded.frame.nonce !== options.nonce) {
|
||||
finish({ ok: false, reason: "nonce_mismatch" });
|
||||
return;
|
||||
}
|
||||
pending = rest;
|
||||
finish({ ok: true });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
channel.onExit((exit) => {
|
||||
if (exitSink) {
|
||||
exitSink(exit);
|
||||
return;
|
||||
}
|
||||
if (readyOk) {
|
||||
// The channel exited after READY but before the broker bound. Hold the
|
||||
// exit so the broker still learns of the loss.
|
||||
pendingExit = exit;
|
||||
return;
|
||||
}
|
||||
finish({ ok: false, reason: "channel_exit" });
|
||||
});
|
||||
|
||||
const brokerChannel: CommandManagedDuplexChannel = {
|
||||
write: (data: string) => channel.write(data),
|
||||
onData: (listener: (chunk: string) => void) => {
|
||||
dataSink = listener;
|
||||
if (pending.length > 0) {
|
||||
const replay = pending;
|
||||
pending = "";
|
||||
listener(replay);
|
||||
}
|
||||
},
|
||||
onExit: (listener: (exit: { exitCode: number | null }) => void) => {
|
||||
exitSink = listener;
|
||||
if (pendingExit) {
|
||||
const exit = pendingExit;
|
||||
pendingExit = null;
|
||||
listener(exit);
|
||||
}
|
||||
},
|
||||
stop: () => channel.stop(),
|
||||
close: () => channel.close(),
|
||||
};
|
||||
|
||||
return { ready, brokerChannel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a partial duplex channel inside a bounded budget, then stop the child.
|
||||
* The host runs this on any readiness failure, so a failed duplex attempt leaves
|
||||
* no live provider session before the fallback to the file bridge.
|
||||
*/
|
||||
async function closeDuplexChannelWithinBudget(
|
||||
channel: CommandManagedDuplexChannel,
|
||||
budgetMs: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const budget = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, budgetMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
await Promise.race([channel.close().catch(() => undefined), budget]);
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
} catch {
|
||||
// Best effort: the stop below still removes the child.
|
||||
} finally {
|
||||
try {
|
||||
channel.stop();
|
||||
} catch {
|
||||
// The channel is already gone; nothing more to do.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
runId: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
@@ -2200,6 +2434,16 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
// in `DEFAULT_DUPLEX_BROKER_BUDGETS` (30 s), so the current behavior does not
|
||||
// change when the caller sets no option.
|
||||
forwardTimeoutMs?: number | null;
|
||||
// The first strict gate for the duplex transport. The host selects duplex only
|
||||
// when this is exactly `true` and the resolved capability
|
||||
// `duplexCommandStream` is exactly `true`. Any other value of either gate
|
||||
// selects the file bridge. The caller reads this from the experimental instance
|
||||
// setting `enableSandboxDuplexBridge`. The default is the file bridge.
|
||||
enableSandboxDuplexBridge?: boolean | null;
|
||||
// The deadline for the duplex readiness handshake, in milliseconds. On a
|
||||
// timeout the host closes the partial channel and selects the file bridge. The
|
||||
// default is `DEFAULT_DUPLEX_READINESS_TIMEOUT_MS`.
|
||||
duplexReadinessTimeoutMs?: number | null;
|
||||
// Return the current-run parent-context token. The factory threads it into the
|
||||
// callback bridge worker, which reads it per request so each request
|
||||
// `sandbox.exec` span parents to the live run span. When it is absent, the
|
||||
@@ -2267,6 +2511,201 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
);
|
||||
|
||||
const bridgeAsset = await createSandboxCallbackBridgeAsset();
|
||||
|
||||
// PAPERCLIP_BRIDGE_DEBUG opts into verbose stdout logs of every bridge proxy
|
||||
// request/response. The query string is logged verbatim, so callers who pass
|
||||
// auth tokens or other sensitive values as query parameters should be aware
|
||||
// those values appear in the host process's stdout when this flag is enabled.
|
||||
// Only intended for active debugging in trusted environments.
|
||||
const bridgeDebugEnabled = isBridgeDebugEnabled(process.env);
|
||||
|
||||
// One forward of a relayed sandbox request onto the existing Paperclip API
|
||||
// path. The forward applies the real host token and the signed run id, so the
|
||||
// token replacement and the run attribution stay in one place for both the
|
||||
// file bridge and the duplex broker. The sandbox request carries only the
|
||||
// bridge token; the real agent token never leaves the host.
|
||||
const forwardBridgeRequest = async (
|
||||
request: { method: string; path: string; query: string; headers: Record<string, string>; body: string },
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ status: number; headers: Record<string, string>; body: string }> => {
|
||||
const method = request.method.trim().toUpperCase() || "GET";
|
||||
if (bridgeDebugEnabled) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Bridge proxy ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
|
||||
);
|
||||
}
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(request.headers)) {
|
||||
if (value.trim().length === 0) continue;
|
||||
headers.set(key, value);
|
||||
}
|
||||
headers.set("authorization", `Bearer ${hostApiToken}`);
|
||||
headers.set("x-paperclip-run-id", input.runId);
|
||||
// Abort the forward when the caller aborts the request (its per-iteration
|
||||
// timeout or watchdog fired, or the broker's forward budget ended), or after
|
||||
// the forward budget here, whichever comes first.
|
||||
const timeoutSignal = AbortSignal.timeout(forwardTimeoutMs);
|
||||
const forwardSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), {
|
||||
method,
|
||||
headers,
|
||||
...(method === "GET" || method === "HEAD" ? {} : { body: request.body }),
|
||||
signal: forwardSignal,
|
||||
});
|
||||
if (bridgeDebugEnabled) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Bridge proxy response ${response.status} for ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
|
||||
);
|
||||
}
|
||||
const responseBody = await readBridgeForwardResponseBody(response, maxBodyBytes);
|
||||
const commentMarker = postedIssueCommentLogMarker(method, request.path, response.status, responseBody);
|
||||
if (commentMarker) await onLog("stdout", commentMarker);
|
||||
return {
|
||||
status: response.status,
|
||||
headers: buildBridgeResponseHeaders(response),
|
||||
body: responseBody,
|
||||
};
|
||||
};
|
||||
|
||||
// Two strict gates guard the duplex transport. Select duplex only when the
|
||||
// experimental setting is exactly `true`, the resolved capability
|
||||
// `duplexCommandStream` is exactly `true`, and the runner exposes the duplex
|
||||
// channel. Any other value of either gate selects the file bridge below.
|
||||
const duplexRequested = input.enableSandboxDuplexBridge === true;
|
||||
const capabilityGranted =
|
||||
"effectiveCapabilities" in target &&
|
||||
target.effectiveCapabilities?.duplexCommandStream === true;
|
||||
const openDuplexChannel = runner.openDuplexChannel?.bind(runner);
|
||||
if (duplexRequested && capabilityGranted && typeof openDuplexChannel === "function") {
|
||||
const readinessTimeoutMs =
|
||||
typeof input.duplexReadinessTimeoutMs === "number" &&
|
||||
Number.isFinite(input.duplexReadinessTimeoutMs) &&
|
||||
input.duplexReadinessTimeoutMs > 0
|
||||
? Math.trunc(input.duplexReadinessTimeoutMs)
|
||||
: DEFAULT_DUPLEX_READINESS_TIMEOUT_MS;
|
||||
|
||||
// The host assigns the loopback port before it opens the channel. It passes
|
||||
// the port and one random per-open nonce to the gateway only through the
|
||||
// launch environment. It builds the sandbox-facing origin from its own
|
||||
// stored port. No field of any channel frame contributes to the endpoint.
|
||||
//
|
||||
// Provider-boundary rationale (a security-review condition): the Daytona SDK
|
||||
// surface the plugin uses (`sandbox.process` sessions, PTY, and exec;
|
||||
// `sandbox.fs`) exposes no listener identity that the provider control plane
|
||||
// binds to a launched process. No channel-supplied address can be attested,
|
||||
// so this design uses zero channel-supplied addressing data. The only actor
|
||||
// that can pre-bind the host-assigned loopback port before the agent starts
|
||||
// is the provider itself, which already delivers the agent launch
|
||||
// environment. The gateway bind-or-exit contract fails closed. This is an
|
||||
// accepted provider-untrusted residual, not endpoint authentication: the
|
||||
// nonce is a liveness signal, because a compromised channel can read the
|
||||
// launch environment; the endpoint stays safe because it never derives from
|
||||
// the channel.
|
||||
const assignedPort = await reserveHostAssignedLoopbackPort();
|
||||
const nonce = randomBytes(16).toString("hex");
|
||||
const sandboxOrigin = `http://127.0.0.1:${assignedPort}`;
|
||||
|
||||
let channel: CommandManagedDuplexChannel | null = null;
|
||||
try {
|
||||
const assetSync = await syncSandboxCallbackBridgeEntrypoint({
|
||||
runner,
|
||||
remoteCwd: target.remoteCwd,
|
||||
assetRemoteDir,
|
||||
bridgeAsset,
|
||||
timeoutMs: bridgeTimeoutMs,
|
||||
shellCommand,
|
||||
});
|
||||
const gatewayEnv: Record<string, string> = {
|
||||
PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE,
|
||||
PAPERCLIP_BRIDGE_TOKEN: bridgeToken,
|
||||
PAPERCLIP_BRIDGE_HOST: "127.0.0.1",
|
||||
PAPERCLIP_BRIDGE_PORT: String(assignedPort),
|
||||
PAPERCLIP_BRIDGE_NONCE: nonce,
|
||||
PAPERCLIP_BRIDGE_MAX_BODY_BYTES: String(maxBodyBytes),
|
||||
};
|
||||
const command = buildDuplexGatewayLaunchArgv({
|
||||
shellCommand,
|
||||
remoteEntrypoint: assetSync.remoteEntrypoint,
|
||||
env: gatewayEnv,
|
||||
});
|
||||
channel = await openDuplexChannel({ command });
|
||||
} catch (error) {
|
||||
// The channel never opened, so no request could carry the bridge token.
|
||||
// Fall through to the file bridge below.
|
||||
if (channel) await closeDuplexChannelWithinBudget(channel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Could not open the sandbox duplex channel: ${error instanceof Error ? error.message : String(error)}. Using the file bridge.\n`,
|
||||
);
|
||||
channel = null;
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
const gate = createDuplexReadinessGate(channel, { nonce, timeoutMs: readinessTimeoutMs });
|
||||
const readiness = await gate.ready;
|
||||
if (!readiness.ok) {
|
||||
// Fail closed. Close the partial channel inside a bounded budget, then
|
||||
// select the file bridge. The broker never started, so no request that
|
||||
// carries the bridge token reached the channel or any endpoint.
|
||||
await closeDuplexChannelWithinBudget(channel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Sandbox duplex readiness failed (${readiness.reason}). Using the file bridge.\n`,
|
||||
);
|
||||
} else {
|
||||
// Readiness passed. Start the broker on the handed-off channel. The
|
||||
// broker enforces the same route allowlist as the file bridge, then
|
||||
// forwards each request with the real token and the run id. The agent
|
||||
// environment below receives the bridge URL and token only now, after
|
||||
// readiness passed.
|
||||
const broker: DuplexBridgeBroker = createDuplexBridgeBroker({
|
||||
channel: gate.brokerChannel,
|
||||
budgets: { forwardTimeoutMs },
|
||||
forwardRequest: async (
|
||||
request: DuplexRequestFrame,
|
||||
options: { signal: AbortSignal },
|
||||
): Promise<DuplexBrokerForwardResult> => {
|
||||
const denialReason = authorizeSandboxCallbackBridgeRequestWithRoutes(request);
|
||||
if (denialReason) {
|
||||
return {
|
||||
status: 403,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ error: denialReason }),
|
||||
};
|
||||
}
|
||||
return forwardBridgeRequest(request, options.signal);
|
||||
},
|
||||
logger: (message) => {
|
||||
void onLog("stderr", `[paperclip] ${message}\n`);
|
||||
},
|
||||
});
|
||||
broker.start();
|
||||
await onLog(
|
||||
"stdout",
|
||||
"[paperclip] Sandbox duplex transport ready; serving the host-assigned origin.\n",
|
||||
);
|
||||
return {
|
||||
env: {
|
||||
PAPERCLIP_API_URL: sandboxOrigin,
|
||||
PAPERCLIP_API_KEY: bridgeToken,
|
||||
PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE,
|
||||
},
|
||||
runLogTail: null,
|
||||
stop: async () => {
|
||||
// Close the channel before lease release. The broker sends an orderly
|
||||
// close and releases the route, then stops the child, so no live
|
||||
// provider session remains when the caller releases the lease.
|
||||
await broker.close();
|
||||
broker.stop();
|
||||
await bridgeAsset.cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let server: Awaited<ReturnType<typeof startSandboxCallbackBridgeServer>> | null = null;
|
||||
let worker: Awaited<ReturnType<typeof startSandboxCallbackBridgeWorker>> | null = null;
|
||||
try {
|
||||
@@ -2276,13 +2715,6 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
timeoutMs: bridgeTimeoutMs,
|
||||
shellCommand,
|
||||
});
|
||||
// PAPERCLIP_BRIDGE_DEBUG opts into verbose stdout logs of every bridge
|
||||
// proxy request/response. The query string is logged verbatim, so callers
|
||||
// who pass auth tokens or other sensitive values as query parameters
|
||||
// should be aware those values appear in the host process's stdout when
|
||||
// this flag is enabled. Only intended for active debugging in trusted
|
||||
// environments.
|
||||
const bridgeDebugEnabled = isBridgeDebugEnabled(process.env);
|
||||
// `startSandboxCallbackBridgeWorker` keeps its awaited queue-directory
|
||||
// setup on the active `bridge.paperclip` step, and runs each request under
|
||||
// the run parent context (see `runWithRuntimeParent` inside that function).
|
||||
@@ -2294,50 +2726,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
maxBodyBytes,
|
||||
getRuntimeParentContext: input.getRuntimeParentContext,
|
||||
runtimeSpan: input.runtimeSpan,
|
||||
handleRequest: async (request, options) => {
|
||||
const method = request.method.trim().toUpperCase() || "GET";
|
||||
if (bridgeDebugEnabled) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Bridge proxy ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
|
||||
);
|
||||
}
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(request.headers)) {
|
||||
if (value.trim().length === 0) continue;
|
||||
headers.set(key, value);
|
||||
}
|
||||
headers.set("authorization", `Bearer ${hostApiToken}`);
|
||||
headers.set("x-paperclip-run-id", input.runId);
|
||||
// Abort the forward when the worker aborts the request (its per-iteration
|
||||
// timeout or watchdog fired), or after the forward budget, whichever comes
|
||||
// first. The worker abort lets the bridge fail a hung forward fast
|
||||
// instead of stranding the request until the forward budget.
|
||||
const timeoutSignal = AbortSignal.timeout(forwardTimeoutMs);
|
||||
const forwardSignal = options?.signal
|
||||
? AbortSignal.any([options.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), {
|
||||
method,
|
||||
headers,
|
||||
...(method === "GET" || method === "HEAD" ? {} : { body: request.body }),
|
||||
signal: forwardSignal,
|
||||
});
|
||||
if (bridgeDebugEnabled) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Bridge proxy response ${response.status} for ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
|
||||
);
|
||||
}
|
||||
const responseBody = await readBridgeForwardResponseBody(response, maxBodyBytes);
|
||||
const commentMarker = postedIssueCommentLogMarker(method, request.path, response.status, responseBody);
|
||||
if (commentMarker) await onLog("stdout", commentMarker);
|
||||
return {
|
||||
status: response.status,
|
||||
headers: buildBridgeResponseHeaders(response),
|
||||
body: responseBody,
|
||||
};
|
||||
},
|
||||
handleRequest: async (request, options) => forwardBridgeRequest(request, options?.signal),
|
||||
});
|
||||
server = await startSandboxCallbackBridgeServer({
|
||||
runner,
|
||||
|
||||
@@ -51,7 +51,7 @@ const MAX_BACKSTOP_WRITE_ATTEMPTS = 3;
|
||||
// finish well under the in-sandbox 30s response deadline.
|
||||
const BACKSTOP_WRITE_RETRY_MS = 50;
|
||||
const REMOTE_WRITE_BASE64_CHUNK_SIZE = 32 * 1024;
|
||||
const SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT = "paperclip-bridge-server.mjs";
|
||||
export const SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT = "paperclip-bridge-server.mjs";
|
||||
const SANDBOX_EXEC_CHANNEL_ENV = "PAPERCLIP_SANDBOX_EXEC_CHANNEL";
|
||||
const SANDBOX_EXEC_CHANNEL_BRIDGE = "bridge";
|
||||
|
||||
@@ -60,7 +60,7 @@ const SANDBOX_EXEC_CHANNEL_BRIDGE = "bridge";
|
||||
// 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";
|
||||
export 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
|
||||
@@ -1838,8 +1838,13 @@ function duplexValidateResponse(frame) {
|
||||
}
|
||||
|
||||
function duplexValidateReady(frame) {
|
||||
if (typeof frame.address !== "string") {
|
||||
return duplexFail("malformed_frame", "ready frame has a missing or wrong-typed address");
|
||||
if (typeof frame.nonce !== "string") {
|
||||
return duplexFail("malformed_frame", "ready frame has a missing or wrong-typed nonce");
|
||||
}
|
||||
for (const key of Object.keys(frame)) {
|
||||
if (key !== "version" && key !== "type" && key !== "nonce") {
|
||||
return duplexFail("malformed_frame", "ready frame has an unexpected field");
|
||||
}
|
||||
}
|
||||
return duplexOk(frame);
|
||||
}
|
||||
@@ -1922,6 +1927,12 @@ 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");
|
||||
// The host assigns the loopback port and passes it through the launch
|
||||
// environment. The duplex gateway binds exactly this port; it never selects a
|
||||
// different one. The host also passes one random per-open nonce here. The
|
||||
// gateway echoes it in the READY frame so the host correlates READY with this
|
||||
// channel open. The nonce is a liveness signal, not authentication.
|
||||
const bridgeNonce = process.env.PAPERCLIP_BRIDGE_NONCE || "";
|
||||
const pollIntervalMs = Number(process.env.PAPERCLIP_BRIDGE_POLL_INTERVAL_MS || "100");
|
||||
const responseTimeoutMs = Number(
|
||||
process.env.PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS ||
|
||||
@@ -2296,6 +2307,18 @@ function runDuplexGateway() {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Bind-or-exit. The host assigns a positive loopback port. The gateway binds
|
||||
// exactly that port. On a non-positive assigned port or a bind failure it
|
||||
// writes a diagnostic and exits with a nonzero code. It never selects a
|
||||
// different port, so no untrusted workload can steer the endpoint.
|
||||
if (!Number.isInteger(port) || port <= 0) {
|
||||
diag("duplex gateway requires a positive assigned PAPERCLIP_BRIDGE_PORT; got " + String(port));
|
||||
process.exit(1);
|
||||
}
|
||||
server.on("error", (error) => {
|
||||
diag("duplex gateway could not bind port " + String(port) + ": " + (error && error.message ? error.message : String(error)));
|
||||
process.exit(1);
|
||||
});
|
||||
server.listen(port, host, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
@@ -2303,12 +2326,14 @@ function runDuplexGateway() {
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
// The one READY frame carries the validated listener address. Stdout carries
|
||||
// The gateway sends READY only after the listener binds. READY is a liveness
|
||||
// signal: it carries the frame version and the echoed nonce, and no address
|
||||
// data. The host builds the endpoint from its own stored port. Stdout carries
|
||||
// only frames.
|
||||
writeFrame({
|
||||
version: DUPLEX_FRAME_VERSION,
|
||||
type: "ready",
|
||||
address: "http://" + host + ":" + address.port,
|
||||
nonce: bridgeNonce,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user