diff --git a/packages/adapter-utils/src/duplex-frame-codec.test.ts b/packages/adapter-utils/src/duplex-frame-codec.test.ts index 539d4898ec5..c0c254b51d0 100644 --- a/packages/adapter-utils/src/duplex-frame-codec.test.ts +++ b/packages/adapter-utils/src/duplex-frame-codec.test.ts @@ -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(); diff --git a/packages/adapter-utils/src/duplex-frame-codec.ts b/packages/adapter-utils/src/duplex-frame-codec.ts index 2cc4d57c988..056c86641a0 100644 --- a/packages/adapter-utils/src/duplex-frame-codec.ts +++ b/packages/adapter-utils/src/duplex-frame-codec.ts @@ -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): DuplexDecodeResult { } function validateReady(frame: Record): 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); } diff --git a/packages/adapter-utils/src/duplex-frame-vectors.json b/packages/adapter-utils/src/duplex-frame-vectors.json index e1e07fa0c1c..d3a1df298b6 100644 --- a/packages/adapter-utils/src/duplex-frame-vectors.json +++ b/packages/adapter-utils/src/duplex-frame-vectors.json @@ -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", diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index 657bb76de4e..8e5f8130c7f 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -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((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) => 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 & { + openDuplexChannel: (openInput: { command: readonly string[] }) => Promise; + }; + 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 => { + 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) => + 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 { + 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; + }> { + 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((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((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((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((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; body?: string; - address?: string; + nonce?: string; __unparsed?: string; } @@ -2856,22 +3312,50 @@ describe("sandbox duplex gateway", () => { stop: () => Promise; } + // 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 { + return new Promise((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): Promise { + // 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, + options: { port?: number; nonce?: string } = {}, + ): Promise { 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).address).toBeUndefined(); + handle.baseUrl = `http://127.0.0.1:${assignedPort}`; return handle; } diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index f6dc32a4f92..05cd543ae9d 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -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 { + return new Promise((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[] { + 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; + /** + * 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((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 { + try { + let timer: ReturnType | undefined; + const budget = new Promise((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; body: string }, + signal?: AbortSignal, + ): Promise<{ status: number; headers: Record; 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 = { + 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 => { + 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> | null = null; let worker: Awaited> | 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, diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 2c28dfe6350..1f807e9fc04 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -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, }); }); }