import { createServer } from "node:http"; import http2 from "node:http2"; import net from "node:net"; import { duplexPair, type Duplex } from "node:stream"; import { execFile, spawn } from "node:child_process"; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; import { getSandboxDuplexGatewayCodecSource } from "./sandbox-callback-bridge.js"; import { createBridgeBodyReservation, getBridgeBodyReservedBytesForTest, resetBridgeBodyReservationsForTest, HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS, HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES, } from "./http2-bridge-server.js"; import { __duplexReadinessTesting, __http2PrefaceScanTesting, DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, adapterExecutionTargetDuplexObservabilityRecorder, adapterExecutionTargetEnablesSandboxDuplexBridge, adapterExecutionTargetSessionIdentity, adapterExecutionTargetToRemoteSpec, adapterExecutionTargetUsesPaperclipBridge, ensureAdapterExecutionTargetCommandResolvable, formatAdapterExecutionTimeoutErrorMessage, formatAdapterExecutionTimeoutStartLogLine, parseAdapterExecutionTarget, postedIssueCommentLogMarker, resolveAdapterExecutionTargetTimeout, resolveAdapterExecutionTargetTimeoutSec, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, startAdapterExecutionTargetProcessSessionBridge, startAdapterExecutionTargetPaperclipBridge, type AdapterSandboxExecutionTarget, type EffectiveExecutionCapabilities, type EffectiveSandboxCapabilities, } from "./execution-target.js"; import { createRuntimeSpanRunner, getActiveStepContext, type StartupSpan, type StartupTraceContext, type StartupTracer, } from "./acpx-engine/startup-timing.js"; import { createSandboxRunLogTailFactory, type SandboxRunLogTailFactory } from "./sandbox-run-log-stream.js"; import { runChildProcess } from "./server-utils.js"; import { shellQuote } from "./ssh.js"; import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js"; import { DEFAULT_MAX_DUPLEX_FRAME_BYTES, DUPLEX_FRAME_VERSION, decodeDuplexLine, encodeDuplexFrame, } from "./duplex-frame-codec.js"; import { DUPLEX_CHANNEL_LOST_ERROR_CODE } from "./bridge-transport-contract.js"; import { DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL, DUPLEX_COUNTER_FALLBACK_TOTAL, DUPLEX_COUNTER_LOSS_TOTAL, DUPLEX_DIMENSION_KEYS, DUPLEX_SPAN_CHANNEL_OPEN, DUPLEX_SPAN_REQUEST, DUPLEX_TRANSPORT_EVENT, type DuplexLossReason, type DuplexObservabilityCounterRecord, type DuplexObservabilityDimensions, type DuplexObservabilityEventRecord, type DuplexObservabilityRecorder, type DuplexObservabilitySpanRecord, } from "./duplex-observability.js"; const execFileAsync = promisify(execFile); type RecordedSpan = { name: string; parentName: string | null; ended: boolean }; /** * A structural tracer that records each opened span's name, parent, and end * state, so a test can assert the trace shape a runtime span runner produces. * Mirrors the recorder used for the `pack`/`stage.sync` nesting tests. */ function createRecordingTraceContext(): { traceContext: StartupTraceContext; spans: RecordedSpan[]; } { const spans: RecordedSpan[] = []; const byHandle = new WeakMap(); const tracer: StartupTracer = { startSpan(name, _options, context) { const parent = context as RecordedSpan | undefined; const record: RecordedSpan = { name, parentName: parent?.name ?? null, ended: false }; spans.push(record); const handle: StartupSpan = { setAttribute() {}, setStatus() {}, end() { record.ended = true; }, }; byHandle.set(handle, record); return handle; }, }; const traceContext: StartupTraceContext = { tracer, contextWithSpan: (span) => byHandle.get(span), }; return { traceContext, spans }; } describe("sandbox adapter execution targets", () => { const cleanupDirs: string[] = []; it("records successful issue comment ids for attribution recovery", () => { expect(postedIssueCommentLogMarker("POST", "/api/issues/issue-1/comments", 201, '{"id":"comment-1"}')) .toBe("comment id: comment-1\n"); expect(postedIssueCommentLogMarker("POST", "/api/issues/issue-1/comments", 401, '{"id":"comment-1"}')) .toBeNull(); }); afterEach(async () => { vi.unstubAllEnvs(); while (cleanupDirs.length > 0) { const dir = cleanupDirs.pop(); if (!dir) continue; await rm(dir, { recursive: true, force: true }).catch(() => undefined); } }); function createLocalSandboxRunner() { let counter = 0; return { execute: async (input: { command: string; args?: string[]; cwd?: string; env?: Record; stdin?: string; timeoutMs?: number; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; }) => { counter += 1; const command = input.command === "bash" ? "/bin/bash" : input.command; return runChildProcess(`sandbox-run-${counter}`, command, input.args ?? [], { cwd: input.cwd ?? process.cwd(), env: input.env ?? {}, stdin: input.stdin, timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)), graceSec: 5, onLog: input.onLog ?? (async () => {}), onSpawn: input.onSpawn ? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt }) : undefined, }); }, }; } async function readRuntimeTextFiles(rootDir: string): Promise { const entries = await readdir(rootDir, { withFileTypes: true }).catch(() => []); const contents: string[] = []; for (const entry of entries) { const entryPath = path.join(rootDir, entry.name); if (entry.isDirectory()) { contents.push(...await readRuntimeTextFiles(entryPath)); } else if (entry.isFile()) { contents.push(await readFile(entryPath, "utf8").catch(() => "")); } } return contents; } function encodeTailTick(stdout: Buffer, stderr: Buffer): string { return [ "__PAPERCLIP_RUN_LOG_STDOUT__", stdout.toString("base64"), "__PAPERCLIP_RUN_LOG_STDERR__", stderr.toString("base64"), "__PAPERCLIP_RUN_LOG_END__", "", ].join("\n"); } async function waitForCondition(predicate: () => boolean, message: string, timeoutMs = 1000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (predicate()) return; await new Promise((resolve) => setTimeout(resolve, 5)); } throw new Error(message); } type ProxyRunResult = { stdout: string; stderr: string; code: number | null; /** * How long the exchange took. The bridge and the proxy both run on 5s * budgets, which is generous locally and tight on a CI runner sharing a * box with 19 other lanes. A run that returns fast and empty is a * different fault from one that nearly hit the ceiling, and the numbers * are the only way to tell them apart after the fact. */ elapsedMs: number; }; async function runProxyWithInput(command: string, input: string): Promise { const startedAt = performance.now(); const child = spawn(command, [], { stdio: ["pipe", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.stdin.end(input); const code = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("Timed out waiting for process session proxy.")); }, 5000); child.on("error", (error) => { clearTimeout(timeout); reject(error); }); child.on("exit", (exitCode) => { clearTimeout(timeout); resolve(exitCode); }); }); return { stdout, stderr, code, elapsedMs: Math.round(performance.now() - startedAt) }; } /** * A failure report for a proxy exchange, attached to the assertions below. * * `execution-target-sandbox` has failed twice in CI and never once in a few * hundred local runs, so the next occurrence has to carry its own evidence - * a second unreproducible failure teaches nothing. The observed signature was * an empty stdout with exit code 0, meaning the child exited cleanly having * produced nothing, which is what a lost stdin frame looks like from here. * * The runtime tree is the part that discriminates. The stdin queue files are * written by the host and deleted by the wrapper once parsed, so what remains * says whether the frame was never written, written and never consumed, or * consumed normally and the reply lost on the way back. */ async function describeProxyRun(result: ProxyRunResult, runtimeRootDir: string): Promise { const lines = [ `proxy exit=${result.code} elapsedMs=${result.elapsedMs}`, `proxy stdout=${JSON.stringify(result.stdout)}`, `proxy stderr=${JSON.stringify(result.stderr)}`, ]; const walk = async (dir: string, depth: number): Promise => { // Deep enough to reach the queue frames, which are the point. They sit // at process-sessions//stdin/.json — depth 4 from the runtime // root — so a cap of 3 listed the `stdin/` directory and stopped, making // "the queue is empty" and "the walk never looked" print identically. if (depth > 5) return; let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch (error) { lines.push(`${" ".repeat(depth)}`); return; } for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { lines.push(`${" ".repeat(depth)}${entry.name}/`); await walk(full, depth + 1); continue; } // Small files are the queue and event frames, and their contents are // the point. Anything larger is a child script or a log; the size is // enough to say it exists. let detail = ""; try { const raw = await readFile(full, "utf8"); detail = raw.length <= 400 ? ` ${JSON.stringify(raw)}` : ` <${raw.length}B>`; } catch (error) { detail = ` `; } lines.push(`${" ".repeat(depth)}${entry.name}${detail}`); } }; lines.push(`runtime tree under ${runtimeRootDir}:`); await walk(runtimeRootDir, 1); return lines.join("\n"); } function combinedStream( events: Array<{ stream: "stdout" | "stderr"; chunk: string }>, stream: "stdout" | "stderr", ): string { return events.filter((event) => event.stream === stream).map((event) => event.chunk).join(""); } it("executes through the provider-neutral runner without a remote spec", async () => { const runner = { execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "ok\n", stderr: "", pid: null, startedAt: new Date().toISOString(), })), }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "acme-sandbox", environmentId: "env-1", leaseId: "lease-1", remoteCwd: "/workspace", timeoutMs: 30_000, runner, }; expect(adapterExecutionTargetToRemoteSpec(target)).toBeNull(); const result = await runAdapterExecutionTargetProcess("run-1", target, "agent-cli", ["--json"], { cwd: "/local/workspace", env: { TOKEN: "token" }, stdin: "prompt", timeoutSec: 5, graceSec: 1, onLog: async () => {}, }); expect(result.stdout).toBe("ok\n"); expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({ command: "agent-cli", args: ["--json"], cwd: "/workspace", env: { TOKEN: "token" }, stdin: "prompt", timeoutMs: 5000, })); expect(adapterExecutionTargetSessionIdentity(target)).toEqual({ transport: "sandbox", providerKey: "acme-sandbox", environmentId: "env-1", leaseId: "lease-1", remoteCwd: "/workspace", }); }); it("preserves stdin when wrapping sandbox adapter commands for run-log streaming", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-run-log-stdin-")); cleanupDirs.push(rootDir); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, streamRunLogs: true, runner: createLocalSandboxRunner(), }; const logsDir = path.posix.join(rootDir, ".paperclip-runtime", "bridge", "logs"); const runLogTail = createSandboxRunLogTailFactory({ runner: target.runner!, remoteCwd: rootDir, logsDir, shellCommand: "bash", }).create(); const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; const result = await runAdapterExecutionTargetProcess( "run-log-stdin", target, process.execPath, ["-e", "process.stdin.setEncoding('utf8'); let s=''; process.stdin.on('data', c => s += c); process.stdin.on('end', () => process.stdout.write('stdin=' + s));"], { cwd: rootDir, env: {}, stdin: "hello-through-wrapper", timeoutSec: 5, graceSec: 1, runLogTail: { create: () => runLogTail }, onLog: async (stream, chunk) => { events.push({ stream, chunk }); }, }, ); expect(result.exitCode).toBe(0); expect(result.stdout).toBe("stdin=hello-through-wrapper"); expect(combinedStream(events, "stdout")).toContain("stdin=hello-through-wrapper"); }); it("creates the process session directories only in the launch exec, not in upfront makeDir execs", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-makedir-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "noop-acp-child.mjs"); await writeFile(childPath, "process.stdin.on('data', () => {});\n", "utf8"); const delegate = createLocalSandboxRunner(); const execScripts: string[] = []; const runner = { execute: vi.fn(async (input: Parameters[0]) => { execScripts.push(input.args?.[1] ?? ""); return delegate.execute(input); }), }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner, }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-process-session-makedir", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, }); expect(bridge).not.toBeNull(); try { // No standalone `mkdir -p '/stdin'` or `.../events` exec runs before launch. const standaloneSessionDirExecs = execScripts.filter((script) => /^mkdir -p '[^']*\/(stdin|events)'\s*$/.test(script), ); expect(standaloneSessionDirExecs).toEqual([]); // The launch exec creates both directories in one `mkdir -p` line. const launchExecs = execScripts.filter( (script) => script.includes("nohup") && /mkdir -p [^\n]*\/stdin[^\n]*\/events/.test(script), ); expect(launchExecs.length).toBe(1); } finally { await bridge?.stop(); } }); it.each([ { outputMode: "polled", streamOutputViaSession: false }, { outputMode: "streamed", streamOutputViaSession: true }, ])( "preserves an explicit remote PATH equal to host PATH in $outputMode mode", async ({ outputMode, streamOutputViaSession }) => { const rootDir = await mkdtemp( path.join(os.tmpdir(), `paperclip-process-session-${outputMode}-path-`), ); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "print-path-child.mjs"); await writeFile( childPath, 'process.stdout.write(process.env.PATH ?? "");\n', "utf8", ); const nodeBinDir = path.dirname(process.execPath); const explicitHostPath = `${nodeBinDir}:/explicit-host-bin`; const sandboxNativePath = `/usr/bin:/bin:${nodeBinDir}`; vi.stubEnv("PATH", explicitHostPath); const delegate = createLocalSandboxRunner(); const runner = { execute: vi.fn( async (input: Parameters[0]) => delegate.execute({ ...input, // The local fake otherwise inherits the test host PATH. Give the // wrapper a distinct sandbox-native PATH so the child proves the // explicit equal-to-host value survived payload serialization. env: { ...input.env, PATH: sandboxNativePath }, }), ), }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner, }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: `run-process-session-${outputMode}-path`, target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: { PATH: explicitHostPath }, timeoutSec: 5, onLog: async () => {}, streamOutputViaSession, }); expect(bridge).not.toBeNull(); try { const result = await runProxyWithInput(bridge!.agentCommand, ""); expect(result.code).toBe(0); expect(result.stdout).toBe(explicitHostPath); } finally { await bridge?.stop(); } }, ); it("test_process_session_poll_exec_parents_to_run_context", async () => { // The poll timer runs run-time execs for the whole run. Its `sandbox.exec` // span must parent to the live run span, not to the ended startup step. The // bridge reads `getRuntimeParentContext` per tick and runs the poll under // that token. This test drives the bridge with a getter that returns a known // token, lets the first poll tick fire, and proves the poll exec reads that // token from the active step store. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-poll-parent-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "noop-acp-child.mjs"); await writeFile(childPath, "process.stdin.on('data', () => {});\n", "utf8"); const runParentToken = { marker: "process-session-run-parent" }; let bridgeStarted = false; let pollStep: ReturnType | "unset" = "unset"; let resolvePoll: () => void = () => {}; const pollObserved = new Promise((resolve) => { resolvePoll = resolve; }); const delegate = createLocalSandboxRunner(); const runner = { execute: async (input: Parameters[0]) => { // Record the active step for the first exec that runs after the bridge // start resolves. The setup execs run during the measured start; the // poll timer fires later, under the run parent context. if (bridgeStarted && pollStep === "unset") { pollStep = getActiveStepContext(); resolvePoll(); } return delegate.execute(input); }, }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner, }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-process-session-poll-parent", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, getRuntimeParentContext: () => runParentToken, }); expect(bridge).not.toBeNull(); bridgeStarted = true; try { await pollObserved; // The poll exec ran under the run parent context, so its exec span parents // to the run token, not to a detached root or an ended startup step. expect(pollStep).not.toBe("unset"); expect(pollStep).not.toBeNull(); expect((pollStep as { parentContext?: unknown }).parentContext).toBe(runParentToken); expect((pollStep as { criticalPath?: boolean }).criticalPath).toBe(false); } finally { await bridge?.stop(); } }); it("test_process_session_poll_exec_stays_unparented_without_getter", async () => { // With no `getRuntimeParentContext`, the poll tick runs with an empty active // step store, exactly like the earlier `runWithoutActiveStep` behavior. So a // poll `sandbox.exec` span opens unparented with no stale startup flag. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-poll-nogetter-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "noop-acp-child.mjs"); await writeFile(childPath, "process.stdin.on('data', () => {});\n", "utf8"); let bridgeStarted = false; let pollStep: ReturnType | "unset" = "unset"; let resolvePoll: () => void = () => {}; const pollObserved = new Promise((resolve) => { resolvePoll = resolve; }); const delegate = createLocalSandboxRunner(); const runner = { execute: async (input: Parameters[0]) => { if (bridgeStarted && pollStep === "unset") { pollStep = getActiveStepContext(); resolvePoll(); } return delegate.execute(input); }, }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner, }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-process-session-poll-nogetter", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, }); expect(bridge).not.toBeNull(); bridgeStarted = true; try { await pollObserved; expect(pollStep).toBeNull(); } finally { await bridge?.stop(); } }); it("test_process_session_stdin_exec_reads_send_time_run_parent", async () => { // A persistent socket can open under one run parent and receive stdin later, // under a different parent. The stdin-write `sandbox.exec` span must parent // to the parent that is live at send time, not to the parent that was live // when the socket opened. The bridge reads `getRuntimeParentContext` per // message in the `data` handler, not once at connect time. This test opens a // socket while `connectParent` is live, switches the getter to `turnParent`, // sends one stdin line, and proves the stdin write ran under `turnParent`. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stdin-parent-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "noop-acp-child.mjs"); await writeFile(childPath, "process.stdin.on('data', () => {});\n", "utf8"); const connectParent = { marker: "process-session-connect-parent" }; const turnParent = { marker: "process-session-turn-parent" }; let currentParent: unknown = connectParent; let stdinWriteStep: ReturnType | "unset" = "unset"; let resolveStdinWrite: () => void = () => {}; const stdinWriteObserved = new Promise((resolve) => { resolveStdinWrite = resolve; }); const delegate = createLocalSandboxRunner(); const runner = { execute: async (input: Parameters[0]) => { // Record the active step for the first exec that writes the stdin file. // The `.paperclip-upload` temp path under the `stdin` directory is unique // to the stdin-write path; the poll loop reads the `events` directory. const script = (input.args ?? []).join("\n"); if (stdinWriteStep === "unset" && /\/stdin\/[^\s']*paperclip-upload/.test(script)) { stdinWriteStep = getActiveStepContext(); resolveStdinWrite(); } return delegate.execute(input); }, }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner, }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-process-session-stdin-parent", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, getRuntimeParentContext: () => currentParent as never, }); expect(bridge).not.toBeNull(); let peer: net.Socket | null = null; try { const proxySource = await readFile(bridge!.agentCommand, "utf8"); const port = Number(/port: (\d+)/.exec(proxySource)?.[1] ?? Number.NaN); const tokenLiteral = /const token = (".*?");/.exec(proxySource)?.[1]; expect(Number.isFinite(port)).toBe(true); expect(typeof tokenLiteral).toBe("string"); const token = JSON.parse(tokenLiteral as string) as string; // Open the socket while `connectParent` is the live run parent. const peerSocket = net.createConnection({ host: "127.0.0.1", port }); peer = peerSocket; peerSocket.on("error", () => undefined); await new Promise((resolve, reject) => { peerSocket.once("connect", () => resolve()); peerSocket.once("error", reject); }); // Let the server accept the connection and register the `data` handler // under the connect-time parent before the getter switches. await new Promise((resolve) => setImmediate(resolve)); // The run enters an agent turn: the live run parent switches. currentParent = turnParent; // Send one stdin line. The first token-bearing message authenticates and // writes the stdin file. That write must read `turnParent` at send time. peerSocket.write(`${JSON.stringify({ token, type: "stdin", data: Buffer.from("hi").toString("base64") })}\n`); await stdinWriteObserved; // The stdin write ran under the send-time parent, not the connect-time // parent captured when the socket opened. expect(stdinWriteStep).not.toBe("unset"); expect(stdinWriteStep).not.toBeNull(); expect((stdinWriteStep as { parentContext?: unknown }).parentContext).toBe(turnParent); expect((stdinWriteStep as { parentContext?: unknown }).parentContext).not.toBe(connectParent); expect((stdinWriteStep as { criticalPath?: boolean }).criticalPath).toBe(false); } finally { peer?.destroy(); await bridge?.stop(); } }); it("wraps a stdin write in a sandbox.agentSession.sendInput span", async () => { // With a span runner injected, the socket handler wraps one outbound ACP // message to the agent in a `sandbox.agentSession.sendInput` span. This test // connects a socket, sends one stdin line, and proves the handler opens that // wrapper span around the write. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-sendinput-span-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "noop-acp-child.mjs"); await writeFile(childPath, "process.stdin.on('data', () => {});\n", "utf8"); const spanNames: string[] = []; let resolveSendInput: () => void = () => {}; const sendInputObserved = new Promise((resolve) => { resolveSendInput = resolve; }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-process-session-sendinput-span", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, // Record each wrapper span name, then run the wrapped work. runtimeSpan: async (name, work) => { spanNames.push(name); if (name === "sandbox.agentSession.sendInput") resolveSendInput(); return work(); }, }); expect(bridge).not.toBeNull(); let peer: net.Socket | null = null; try { const proxySource = await readFile(bridge!.agentCommand, "utf8"); const port = Number(/port: (\d+)/.exec(proxySource)?.[1] ?? Number.NaN); const tokenLiteral = /const token = (".*?");/.exec(proxySource)?.[1]; const token = JSON.parse(tokenLiteral as string) as string; const peerSocket = net.createConnection({ host: "127.0.0.1", port }); peer = peerSocket; peerSocket.on("error", () => undefined); await new Promise((resolve, reject) => { peerSocket.once("connect", () => resolve()); peerSocket.once("error", reject); }); // The first token-bearing message authenticates and writes the stdin file. peerSocket.write( `${JSON.stringify({ token, type: "stdin", data: Buffer.from("hi").toString("base64") })}\n`, ); await sendInputObserved; expect(spanNames).toContain("sandbox.agentSession.sendInput"); } finally { peer?.destroy(); await bridge?.stop(); } }); it("wraps each poll tick in a sandbox.agentSession.pollOutput span", async () => { // With a span runner injected, the poll timer wraps each 100 ms poll tick in // a `sandbox.agentSession.pollOutput` span. This test lets the first poll tick // fire and proves the timer opens that wrapper span. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-poll-span-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "noop-acp-child.mjs"); await writeFile(childPath, "process.stdin.on('data', () => {});\n", "utf8"); const spanNames: string[] = []; let resolvePoll: () => void = () => {}; const pollObserved = new Promise((resolve) => { resolvePoll = resolve; }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-process-session-poll-span", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, // Record each wrapper span name, then run the wrapped work. runtimeSpan: async (name, work) => { spanNames.push(name); if (name === "sandbox.agentSession.pollOutput") resolvePoll(); return work(); }, }); expect(bridge).not.toBeNull(); try { await pollObserved; expect(spanNames).toContain("sandbox.agentSession.pollOutput"); } finally { await bridge?.stop(); } }); it("bridges bidirectional sandbox process sessions through a local ACPX-spawnable proxy", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "fake-acp-child.mjs"); await writeFile( childPath, [ "process.stdin.on('data', (chunk) => {", " process.stdout.write('out:' + chunk.toString());", " process.stderr.write('err:' + chunk.toString());", "});", ].join("\n"), "utf8", ); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-process-session", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, }); expect(bridge).not.toBeNull(); try { const result = await runProxyWithInput(bridge!.agentCommand, "hello\n"); const report = await describeProxyRun(result, path.posix.join(rootDir, ".paperclip-runtime", "acpx")); expect(result.code, report).toBe(0); expect(result.stdout, report).toBe("out:hello\n"); expect(result.stderr, report).toBe("err:hello\n"); } finally { await bridge?.stop(); } }); it("buffers sandbox process session output until the local proxy connects", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-buffer-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "fast-acp-child.mjs"); await writeFile( childPath, [ "process.stdout.write('early-out\\n');", "process.stderr.write('early-err\\n');", "setTimeout(() => process.exit(0), 20);", ].join("\n"), "utf8", ); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-process-session-buffer", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, }); expect(bridge).not.toBeNull(); try { await new Promise((resolve) => setTimeout(resolve, 300)); const result = await runProxyWithInput(bridge!.agentCommand, ""); expect(result.code).toBe(0); expect(result.stdout).toBe("early-out\n"); expect(result.stderr).toBe("early-err\n"); } finally { await bridge?.stop(); } }); it("delivers full output when the sandbox child exits immediately after writing", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-fast-exit-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "instant-exit-acp-child.mjs"); await writeFile( childPath, [ "process.stdout.write('final-out\\n');", "process.stderr.write('final-err\\n');", ].join("\n"), "utf8", ); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-process-session-fast-exit", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, }); expect(bridge).not.toBeNull(); try { const result = await runProxyWithInput(bridge!.agentCommand, ""); expect(result.code).toBe(0); expect(result.stdout).toBe("final-out\n"); expect(result.stderr).toBe("final-err\n"); } finally { await bridge?.stop(); } }); it("ignores unauthenticated connections to the process session bridge", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-auth-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "guarded-acp-child.mjs"); await writeFile(childPath, "process.stdout.write('guarded-out\\n');", "utf8"); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-process-session-auth", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, }); expect(bridge).not.toBeNull(); let squatter: net.Socket | null = null; try { const proxySource = await readFile(bridge!.agentCommand, "utf8"); const port = Number(/port: (\d+)/.exec(proxySource)?.[1] ?? Number.NaN); expect(Number.isFinite(port)).toBe(true); // An idle local connection must not claim the session or see buffered output. const squatterSocket = net.createConnection({ host: "127.0.0.1", port }); squatter = squatterSocket; let squatterReceived = ""; squatterSocket.setEncoding("utf8"); squatterSocket.on("data", (chunk: string) => { squatterReceived += chunk; }); squatterSocket.on("error", () => undefined); await new Promise((resolve, reject) => { squatterSocket.once("connect", () => resolve()); squatterSocket.once("error", reject); }); // A peer presenting the wrong token is disconnected outright. const badPeer = net.createConnection({ host: "127.0.0.1", port }); badPeer.on("error", () => undefined); const badPeerClosed = new Promise((resolve) => badPeer.once("close", () => resolve())); badPeer.once("connect", () => badPeer.write(`${JSON.stringify({ token: "wrong-token", type: "stdinEnd" })}\n`)); await badPeerClosed; // The authenticated proxy still attaches and receives the buffered output. const result = await runProxyWithInput(bridge!.agentCommand, ""); expect(result.code).toBe(0); expect(result.stdout).toBe("guarded-out\n"); expect(squatterReceived).toBe(""); } finally { squatter?.destroy(); await bridge?.stop(); } }); it("streams sandbox process session output before the remote child exits", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "streaming-acp-child.mjs"); await writeFile( childPath, [ "process.stdin.setEncoding('utf8');", "process.stdin.on('data', (chunk) => {", " if (chunk.includes('ping')) {", " process.stdout.write('delta:ping\\n');", " process.stderr.write('trace:ping\\n');", " }", " if (chunk.includes('finish')) process.exit(0);", "});", "process.stdin.resume();", ].join("\n"), "utf8", ); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-process-session-stream", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, }); expect(bridge).not.toBeNull(); const child = spawn(bridge!.agentCommand, [], { stdio: ["pipe", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; let exited = false; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); const exitPromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("Timed out waiting for streaming process session proxy.")); }, 5000); child.on("error", (error) => { clearTimeout(timeout); reject(error); }); child.on("exit", (exitCode) => { exited = true; clearTimeout(timeout); resolve(exitCode); }); }); try { child.stdin.write("ping\n"); await waitForCondition( () => stdout.includes("delta:ping\n") && stderr.includes("trace:ping\n"), "Timed out waiting for live process session output.", 3000, ); expect(exited).toBe(false); child.stdin.end("finish\n"); await expect(exitPromise).resolves.toBe(0); } finally { if (!exited) { child.kill("SIGKILL"); await exitPromise.catch(() => undefined); } await bridge?.stop(); } }); describe("streamed output (streamOutputViaSession)", () => { it("bridges bidirectional sessions when the wrapper streams output to stdout", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-echo-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "echo-acp-child.mjs"); await writeFile( childPath, [ "process.stdin.on('data', (chunk) => {", " process.stdout.write('out:' + chunk.toString());", " process.stderr.write('err:' + chunk.toString());", "});", ].join("\n"), "utf8", ); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-stream-echo", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, streamOutputViaSession: true, }); expect(bridge).not.toBeNull(); try { const result = await runProxyWithInput(bridge!.agentCommand, "hello\n"); const report = await describeProxyRun(result, path.posix.join(rootDir, ".paperclip-runtime", "acpx")); expect(result.code, report).toBe(0); expect(result.stdout, report).toBe("out:hello\n"); expect(result.stderr, report).toBe("err:hello\n"); } finally { await bridge?.stop(); } }); it("wraps the long-lived streamed launch in a sandbox.agentProcess span", async () => { // The streamed launch is fire-and-forget and lives for the whole run, so // its span must open under the live run root (not the ephemeral bring-up // step) and stay open around the launch. Record the opened span names and // prove `sandbox.agentProcess` is among them, and that a normal exchange // still works through the wrap. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-span-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "echo-acp-child.mjs"); await writeFile( childPath, [ "process.stdin.on('data', (chunk) => {", " process.stdout.write('out:' + chunk.toString());", "});", ].join("\n"), "utf8", ); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const spanNames: string[] = []; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-stream-span", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, streamOutputViaSession: true, // Record each wrapper span name, then run the wrapped work. runtimeSpan: async (name, work) => { spanNames.push(name); return work(); }, }); expect(bridge).not.toBeNull(); try { // The launch span opens synchronously as the bridge starts, before any // frame flows, so it is observable as soon as the handle resolves. expect(spanNames).toContain("sandbox.agentProcess"); const result = await runProxyWithInput(bridge!.agentCommand, "hello\n"); const report = await describeProxyRun(result, path.posix.join(rootDir, ".paperclip-runtime", "acpx")); expect(result.code, report).toBe(0); expect(result.stdout, report).toBe("out:hello\n"); } finally { await bridge?.stop(); } }); it("parents the sandbox.agentProcess span to the live run root, not the bring-up step", async () => { // The launch runs for the whole run, so its span must parent to the live // run root (here a stand-in `task.run`) rather than the ephemeral // `bridge.process-session` bring-up step — otherwise it dangles past its // parent and overlaps `agent.turn`. Build the real run-rooted runner from a // recording trace context and assert the recorded parent. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-parent-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "noop-acp-child.mjs"); await writeFile(childPath, "process.stdin.on('data', () => {});\n", "utf8"); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const { traceContext, spans } = createRecordingTraceContext(); // The run root stands in for `task.run` — the parent the run-rooted runner // resolves at launch time, since no turn has started yet. const runRoot = traceContext.tracer.startSpan("task.run", undefined, undefined); const runRootContext = traceContext.contextWithSpan(runRoot); const runtimeSpan = createRuntimeSpanRunner(traceContext, () => runRootContext); const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-stream-parent", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, streamOutputViaSession: true, runtimeSpan, }); expect(bridge).not.toBeNull(); try { const agentProcess = spans.find((span) => span.name === "sandbox.agentProcess"); expect(agentProcess).toBeDefined(); expect(agentProcess!.parentName).toBe("task.run"); } finally { await bridge?.stop(); } }); it("ends the sandbox.agentProcess span at stop() even when the process lingers", async () => { // The span must not outlive the run root. When the remote process lingers // past bridge teardown (`execute` has no cancel), the span still has to end // at `stop()`, which the caller awaits before it ends `task.run`. Use a // child that ignores stdin and never exits on its own, so the launch // command stays pending across `stop()`, and prove the span ends anyway. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-linger-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "linger-acp-child.mjs"); await writeFile( childPath, [ "process.stdin.on('data', () => {});", // Stay alive well past the assertions, then self-exit so the test // leaves no lingering process. "setTimeout(() => process.exit(0), 3000);", ].join("\n"), "utf8", ); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; // Track when each wrapper span's work settles (i.e. when its span ends). const spanRecords: Array<{ name: string; ended: boolean }> = []; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-stream-linger", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 10, onLog: async () => {}, streamOutputViaSession: true, runtimeSpan: (name, work) => { const record = { name, ended: false }; spanRecords.push(record); const promise = work(); void promise.then( () => { record.ended = true; }, () => { record.ended = true; }, ); return promise; }, }); expect(bridge).not.toBeNull(); const record = spanRecords.find((span) => span.name === "sandbox.agentProcess"); expect(record).toBeDefined(); // The launch command is still running, so the span is still open. expect(record!.ended).toBe(false); // Teardown ends the span promptly, without waiting for the lingering command. await bridge!.stop(); await new Promise((resolve) => setTimeout(resolve, 50)); expect(record!.ended).toBe(true); }); it("buffers streamed output until the local proxy connects", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-buffer-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "fast-stream-child.mjs"); await writeFile( childPath, [ "process.stdout.write('early-out\\n');", "process.stderr.write('early-err\\n');", "setTimeout(() => process.exit(0), 20);", ].join("\n"), "utf8", ); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-stream-buffer", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, streamOutputViaSession: true, }); expect(bridge).not.toBeNull(); try { await new Promise((resolve) => setTimeout(resolve, 300)); const result = await runProxyWithInput(bridge!.agentCommand, ""); expect(result.code).toBe(0); // The seq guard delivers the early output exactly once even though the // live stream and the terminal result both carry it. expect(result.stdout).toBe("early-out\n"); expect(result.stderr).toBe("early-err\n"); } finally { await bridge?.stop(); } }); it("delivers full streamed output when the sandbox child exits immediately", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-fast-exit-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "instant-stream-child.mjs"); await writeFile( childPath, [ "process.stdout.write('final-out\\n');", "process.stderr.write('final-err\\n');", ].join("\n"), "utf8", ); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-stream-fast-exit", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, streamOutputViaSession: true, }); expect(bridge).not.toBeNull(); try { const result = await runProxyWithInput(bridge!.agentCommand, ""); expect(result.code).toBe(0); expect(result.stdout).toBe("final-out\n"); expect(result.stderr).toBe("final-err\n"); } finally { await bridge?.stop(); } }); it("streams live output before the child exits and never writes output event files", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-live-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "live-stream-child.mjs"); await writeFile( childPath, [ "process.stdin.setEncoding('utf8');", "process.stdin.on('data', (chunk) => {", " if (chunk.includes('ping')) {", " process.stdout.write('delta:ping\\n');", " process.stderr.write('trace:ping\\n');", " }", " if (chunk.includes('finish')) process.exit(0);", "});", "process.stdin.resume();", ].join("\n"), "utf8", ); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner: createLocalSandboxRunner(), }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-stream-live", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, streamOutputViaSession: true, }); expect(bridge).not.toBeNull(); const child = spawn(bridge!.agentCommand, [], { stdio: ["pipe", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; let exited = false; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); const exitPromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("Timed out waiting for streamed process session proxy.")); }, 5000); child.on("error", (error) => { clearTimeout(timeout); reject(error); }); child.on("exit", (exitCode) => { exited = true; clearTimeout(timeout); resolve(exitCode); }); }); try { child.stdin.write("ping\n"); await waitForCondition( () => stdout.includes("delta:ping\n") && stderr.includes("trace:ping\n"), "Timed out waiting for live streamed process session output.", 3000, ); expect(exited).toBe(false); child.stdin.end("finish\n"); await expect(exitPromise).resolves.toBe(0); // The streamed path uses the stdout wrapper, not the output-file poll, so // no `events` directory is ever created under the session runtime tree. const hasEventsDir = await readdir( path.posix.join(rootDir, ".paperclip-runtime", "acpx", "process-sessions"), { withFileTypes: true, recursive: true }, ) .then((entries) => entries.some((entry) => entry.isDirectory() && entry.name === "events")) .catch(() => false); expect(hasEventsDir).toBe(false); } finally { if (!exited) { child.kill("SIGKILL"); await exitPromise.catch(() => undefined); } await bridge?.stop(); } }); it("keeps the agent command on the persistent session and forces bridge control execs off it", async () => { // Regression guard for the streamed-mode startup deadlock. The persistent // session is one serialized shell. In streamed mode the agent runs as a // long-lived foreground session command that holds the session for the // whole run. The bridge control-plane execs (script sync, stdin delivery, // teardown) must run concurrently with the agent, so each must force // itself off the session. On the session they queue behind the agent // command that never returns, and the first handshake write never drains. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-isolation-")); cleanupDirs.push(rootDir); const childPath = path.join(rootDir, "echo-acp-child.mjs"); await writeFile( childPath, [ "process.stdin.on('data', (chunk) => {", " process.stdout.write('out:' + chunk.toString());", "});", ].join("\n"), "utf8", ); const delegate = createLocalSandboxRunner(); const execs: Array<{ useSession?: boolean; bypassSession?: boolean; script: string }> = []; const runner = { execute: vi.fn( async ( input: Parameters[0] & { useSession?: boolean; bypassSession?: boolean; }, ) => { execs.push({ useSession: input.useSession, bypassSession: input.bypassSession, script: input.args?.[1] ?? "", }); return delegate.execute(input); }, ), }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, timeoutMs: 30_000, runner, }; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ runId: "run-stream-isolation", target, runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {}, timeoutSec: 5, onLog: async () => {}, streamOutputViaSession: true, }); expect(bridge).not.toBeNull(); try { // Round-trip one input so a stdin-delivery control exec runs and gets // recorded before the assertions below. const result = await runProxyWithInput(bridge!.agentCommand, "hello\n"); expect( result.stdout, await describeProxyRun(result, path.posix.join(rootDir, ".paperclip-runtime", "acpx")), ).toBe("out:hello\n"); // Exactly one exec runs on the persistent session: the long-lived agent // command. It streams its output through the session log stream, so it // must not also bypass the session. const sessionExecs = execs.filter((exec) => exec.useSession === true); expect(sessionExecs).toHaveLength(1); expect(sessionExecs[0]!.bypassSession).not.toBe(true); expect(sessionExecs[0]!.script).toContain("node "); // Every other exec is bridge control-plane plumbing. Each must force // itself off the persistent session so it never queues behind the agent // command that holds it. const controlExecs = execs.filter((exec) => exec.useSession !== true); expect(controlExecs.length).toBeGreaterThan(0); for (const exec of controlExecs) { expect(exec.bypassSession).toBe(true); } } finally { await bridge?.stop(); } }); }); it("applies the remote sandbox fallback when adapter timeoutSec is unset", () => { const sandboxTarget: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", runner: createLocalSandboxRunner(), }; // The sandbox default is a 4h wall-clock backstop matching the recovery // watchdog critical threshold (ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS); // the output-inactivity monitor remains the primary hang detector. expect(DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC).toBe(4 * 60 * 60); expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 0)).toBe( DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, ); expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 90)).toBe(90); expect(resolveAdapterExecutionTargetTimeoutSec({ kind: "remote", transport: "ssh", remoteCwd: "/workspace", spec: { host: "127.0.0.1", port: 22, username: "fixture", remoteWorkspacePath: "/workspace", remoteCwd: "/workspace", privateKey: "KEY", knownHosts: "host key", strictHostKeyChecking: true, }, }, 0)).toBe(0); expect(resolveAdapterExecutionTargetTimeoutSec({ kind: "local" }, 0)).toBe(0); }); it("reports which knob produced the resolved timeout", () => { const sandboxTarget: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", runner: createLocalSandboxRunner(), }; expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 0)).toEqual({ timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default", }); expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 90)).toEqual({ timeoutSec: 90, source: "configured", }); expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, 0)).toEqual({ timeoutSec: 0, source: "unlimited", }); // Fractional (sub-second) configured timeouts are preserved rather than // floored to 0, which would silently mean "no timeout". expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, 0.01)).toEqual({ timeoutSec: 0.01, source: "configured", }); expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 0.5)).toEqual({ timeoutSec: 0.5, source: "configured", }); }); it("treats a negative timeoutSec as the explicit no-timeout opt-out, even on sandbox targets", () => { const sandboxTarget: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", runner: createLocalSandboxRunner(), }; expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, -1)).toEqual({ timeoutSec: 0, source: "configured", }); expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, -1)).toEqual({ timeoutSec: 0, source: "configured", }); expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, -1)).toBe(0); // Explicit zero intentionally does NOT opt out: the adapter config UI // persists the schema default of 0 for untouched fields, so a stored // timeoutSec=0 cannot be read as operator intent. It keeps the sandbox // backstop; the documented opt-out is a negative value. expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 0)).toEqual({ timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default", }); // Unset behaves like zero. expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, undefined)).toEqual({ timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default", }); expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, undefined)).toEqual({ timeoutSec: 0, source: "unlimited", }); }); it("formats self-describing timeout errors naming the timer and knob", () => { expect( formatAdapterExecutionTimeoutErrorMessage({ timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default", }), ).toBe( "Run exceeded the adapter execution timeout (timeoutSec=14400, sandbox default). " + "Set adapterConfig.timeoutSec to raise it.", ); expect( formatAdapterExecutionTimeoutErrorMessage({ timeoutSec: 1800, source: "configured" }), ).toBe( "Run exceeded the adapter execution timeout (timeoutSec=1800, configured via adapterConfig.timeoutSec). " + "Set adapterConfig.timeoutSec to raise it.", ); }); it("formats the start-of-run timeout log line with the resolved value and source", () => { expect( formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default", }), ).toBe( "Adapter execution timeout: timeoutSec=14400 (sandbox default; set adapterConfig.timeoutSec to override).", ); expect( formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: 900, source: "configured" }), ).toBe( "Adapter execution timeout: timeoutSec=900 (configured via adapterConfig.timeoutSec; set adapterConfig.timeoutSec to override).", ); expect( formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: 0, source: "unlimited" }), ).toBe( "Adapter execution timeout: none (no adapter wall-clock timeout for this target; set adapterConfig.timeoutSec to add one).", ); // Negative opt-out resolves to { timeoutSec: 0, source: "configured" }. expect( formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: 0, source: "configured" }), ).toBe( "Adapter execution timeout: none (explicitly disabled via adapterConfig.timeoutSec; set it to a positive value to add one).", ); }); it("uses the caller timeout override when installing a missing sandbox command", async () => { const runner = { execute: vi.fn() .mockResolvedValueOnce({ exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: new Date().toISOString(), }) .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: new Date().toISOString(), }) .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "/usr/bin/opencode\n", stderr: "", pid: null, startedAt: new Date().toISOString(), }), }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", timeoutMs: 300_000, runner, }; await ensureAdapterExecutionTargetCommandResolvable( "opencode", target, "/local/workspace", {}, { installCommand: "npm install -g opencode", timeoutSec: 1800 }, ); expect(runner.execute).toHaveBeenNthCalledWith(2, expect.objectContaining({ command: "sh", args: ["-c", "npm install -g opencode"], timeoutMs: 1_800_000, })); }); it("runs shell commands through the same runner", async () => { const runner = { execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "/home/sandbox", stderr: "", pid: null, startedAt: new Date().toISOString(), })), }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", runner, }; await runAdapterExecutionTargetShellCommand("run-2", target, 'printf %s "$HOME"', { cwd: "/local/workspace", env: {}, timeoutSec: 7, }); expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({ command: "sh", args: ["-c", 'printf %s "$HOME"'], cwd: "/workspace", timeoutMs: 7000, })); }); it("strips inherited host identity env before sandbox execution", async () => { vi.stubEnv("PATH", "/host/bin:/usr/bin"); vi.stubEnv("HOME", "/Users/local"); vi.stubEnv("TMPDIR", "/var/folders/local/T"); const runner = { execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "ok\n", stderr: "", pid: null, startedAt: new Date().toISOString(), })), }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", runner, }; await runAdapterExecutionTargetProcess("run-1b", target, "agent-cli", ["--json"], { cwd: "/local/workspace", env: { PATH: "/host/bin:/usr/bin", HOME: "/Users/local", TMPDIR: "/var/folders/local/T", SAFE_VALUE: "visible", }, timeoutSec: 5, graceSec: 1, onLog: async () => {}, }); expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({ env: { SAFE_VALUE: "visible", }, })); }); it("preserves explicit remote identity env overrides for sandbox execution", async () => { vi.stubEnv("PATH", "/host/bin:/usr/bin"); vi.stubEnv("HOME", "/Users/local"); const runner = { execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "ok\n", stderr: "", pid: null, startedAt: new Date().toISOString(), })), }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", runner, }; await runAdapterExecutionTargetProcess("run-1c", target, "agent-cli", ["--json"], { cwd: "/local/workspace", env: { PATH: "/custom/remote/bin:/usr/bin", HOME: "/home/sandbox", SAFE_VALUE: "visible", }, timeoutSec: 5, graceSec: 1, onLog: async () => {}, }); expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({ env: { PATH: "/custom/remote/bin:/usr/bin", HOME: "/home/sandbox", SAFE_VALUE: "visible", }, })); }); it("treats SSH targets as bridge-only", () => { const target = { kind: "remote" as const, transport: "ssh" as const, remoteCwd: "/workspace", spec: { host: "ssh.example.test", port: 22, username: "paperclip", remoteWorkspacePath: "/workspace", remoteCwd: "/workspace", privateKey: null, knownHosts: null, strictHostKeyChecking: true, }, }; expect(adapterExecutionTargetUsesPaperclipBridge(target)).toBe(true); expect(adapterExecutionTargetSessionIdentity(target)).toEqual({ transport: "ssh", host: "ssh.example.test", port: 22, username: "paperclip", remoteCwd: "/workspace", }); }); it("uses the provider-declared shell for sandbox helper commands", async () => { const runner = { execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "/home/sandbox", stderr: "", pid: null, startedAt: new Date().toISOString(), })), }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "custom-provider", shellCommand: "bash", remoteCwd: "/workspace", runner, }; await runAdapterExecutionTargetShellCommand("run-2b", target, 'printf %s "$HOME"', { cwd: "/local/workspace", env: {}, timeoutSec: 7, }); expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({ command: "bash", args: ["-c", 'printf %s "$HOME"'], cwd: "/workspace", timeoutMs: 7000, })); }); it("starts a localhost Paperclip bridge for sandbox targets in bridge mode", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); await mkdir(runtimeRootDir, { recursive: true }); const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = []; const apiServer = 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) => { apiServer.once("error", reject); apiServer.listen(0, "127.0.0.1", () => resolve()); }); const address = apiServer.address(); if (!address || typeof address === "string") { throw new Error("Expected the bridge test API server to listen on a TCP port."); } const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "e2b", environmentId: "env-1", leaseId: "lease-1", remoteCwd, runner: createLocalSandboxRunner(), timeoutMs: 30_000, }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-bridge", target, runtimeRootDir, adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: `http://127.0.0.1:${address.port}`, }); try { expect(bridge).not.toBeNull(); expect(bridge?.env.PAPERCLIP_API_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(bridge?.env.PAPERCLIP_API_KEY).not.toBe("real-run-jwt"); expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/agents/me`, { headers: { authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`, accept: "application/json", }, }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ ok: true }); expect(requests).toEqual([{ method: "GET", url: "/api/agents/me", auth: "Bearer real-run-jwt", runId: "run-bridge", }]); } finally { await bridge?.stop(); await new Promise((resolve) => apiServer.close(() => resolve())); } }); it("creates a sandbox run log tail factory when bridge streaming is enabled", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-stream-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); await mkdir(runtimeRootDir, { recursive: true }); const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "e2b", environmentId: "env-1", leaseId: "lease-1", remoteCwd, runner: createLocalSandboxRunner(), streamRunLogs: true, timeoutMs: 30_000, }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-bridge-stream", target, runtimeRootDir, adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: "http://127.0.0.1:9", onLog: async (stream, chunk) => { logs.push({ stream, chunk }); }, }); try { expect(bridge?.runLogTail).toBeTruthy(); expect(combinedStream(logs, "stdout")).toContain("Sandbox run log streaming enabled"); const wrapped = bridge!.runLogTail!.create().wrapCommand("agent-cli", ["--message", "hello world"]); expect(wrapped.command).toBe("sh"); expect(wrapped.args.join("\n")).toContain("tee -a"); expect(wrapped.args.join("\n")).toContain("agent-cli"); } finally { await bridge?.stop(); } }); it("defaults sandbox run log streaming on and honors the explicit opt-out", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-stream-default-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); await mkdir(runtimeRootDir, { recursive: true }); const baseTarget: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "e2b", environmentId: "env-1", leaseId: "lease-1", remoteCwd, runner: createLocalSandboxRunner(), timeoutMs: 30_000, }; const defaultBridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-bridge-stream-default", target: baseTarget, runtimeRootDir, adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: "http://127.0.0.1:9", }); try { expect(defaultBridge?.runLogTail).toBeTruthy(); } finally { await defaultBridge?.stop(); } const optOutBridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-bridge-stream-opt-out", target: { ...baseTarget, streamRunLogs: false }, runtimeRootDir, adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: "http://127.0.0.1:9", }); try { expect(optOutBridge?.runLogTail ?? null).toBeNull(); } finally { await optOutBridge?.stop(); } }); it("tails sandbox run log chunks with byte offsets and dedupes the final batch", async () => { const stdoutText = "stdout-abc\n"; const stderrText = "stderr-xyz\n"; const stdoutBytes = Buffer.from(stdoutText, "utf8"); const stderrBytes = Buffer.from(stderrText, "utf8"); const stdoutOffsets: number[] = []; const stderrOffsets: number[] = []; const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; const runner = { execute: vi.fn(async (input: { command: string; args?: string[]; cwd?: string; env?: Record; timeoutMs?: number; }) => { const script = input.args?.[1] ?? ""; const offsets = [...script.matchAll(/tail -c \+(\d+) /g)].map((match) => Number(match[1])); const stdoutStart = Math.max(0, (offsets[0] ?? 1) - 1); const stderrStart = Math.max(0, (offsets[1] ?? 1) - 1); stdoutOffsets.push(stdoutStart + 1); stderrOffsets.push(stderrStart + 1); return { exitCode: 0, signal: null, timedOut: false, stdout: encodeTailTick( stdoutBytes.subarray(stdoutStart, stdoutStart + 4), stderrBytes.subarray(stderrStart, stderrStart + 4), ), stderr: "", pid: null, startedAt: new Date().toISOString(), }; }), }; const tail = createSandboxRunLogTailFactory({ runner, remoteCwd: "/workspace", logsDir: "/workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs", pollIntervalMs: 1, maxChunkBytesPerTick: 4, tickTimeoutMs: 50, }).create(); tail.start(async (stream, chunk) => { events.push({ stream, chunk }); }); await waitForCondition( () => combinedStream(events, "stdout") === stdoutText && combinedStream(events, "stderr") === stderrText, "run log tail did not stream expected stdout/stderr chunks", ); await tail.finish({ stdout: stdoutText, stderr: stderrText }); expect(combinedStream(events, "stdout")).toBe(stdoutText); expect(combinedStream(events, "stderr")).toBe(stderrText); expect(stdoutOffsets.slice(0, 3)).toEqual([1, 5, 9]); expect(stderrOffsets.slice(0, 3)).toEqual([1, 5, 9]); expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({ command: "sh", cwd: "/workspace", env: { PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge" }, timeoutMs: 50, })); }); it("emits only the unstreamed final suffix when the tail loop stops early", async () => { const finalStdout = "prefix suffix\n"; const finalBytes = Buffer.from(finalStdout, "utf8"); const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; const runner = { execute: vi.fn(async (input: { args?: string[] }) => { const script = input.args?.[1] ?? ""; const offsets = [...script.matchAll(/tail -c \+(\d+) /g)].map((match) => Number(match[1])); const stdoutStart = Math.max(0, (offsets[0] ?? 1) - 1); return { exitCode: 0, signal: null, timedOut: false, stdout: encodeTailTick(finalBytes.subarray(stdoutStart, stdoutStart + 7), Buffer.alloc(0)), stderr: "", pid: null, startedAt: new Date().toISOString(), }; }), }; const tail = createSandboxRunLogTailFactory({ runner, remoteCwd: "/workspace", logsDir: "/workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs", pollIntervalMs: 1, maxChunkBytesPerTick: 7, tickTimeoutMs: 50, }).create(); tail.start(async (stream, chunk) => { events.push({ stream, chunk }); }); await waitForCondition(() => combinedStream(events, "stdout").length >= 7, "run log tail did not emit prefix"); await tail.finish({ stdout: finalStdout, stderr: "" }); expect(combinedStream(events, "stdout")).toBe(finalStdout); expect(events.filter((event) => event.stream === "stdout").map((event) => event.chunk).join("|")) .toBe("prefix |suffix\n"); }); it("delivers the final batch and a warning when run log polling degrades", async () => { const events: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; const runner = { execute: vi.fn(async () => ({ exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "tail failed", pid: null, startedAt: new Date().toISOString(), })), }; const tail = createSandboxRunLogTailFactory({ runner, remoteCwd: "/workspace", logsDir: "/workspace/.paperclip-runtime/codex/paperclip-bridge/queue/logs", pollIntervalMs: 1, tickTimeoutMs: 50, maxConsecutiveFailures: 1, }).create(); tail.start(async (stream, chunk) => { events.push({ stream, chunk }); }); await waitForCondition(() => runner.execute.mock.calls.length >= 1, "run log tail did not poll before finish"); await new Promise((resolve) => setTimeout(resolve, 5)); await tail.finish({ stdout: "final out\n", stderr: "final err\n" }); expect(combinedStream(events, "stdout")).toBe("final out\n"); expect(combinedStream(events, "stderr")).toBe( "final err\n[paperclip] Run log streaming degraded during the run; remaining output was delivered at completion.\n", ); }); it("exposes the Paperclip bridge to the sandbox shell surface", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-shell-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "claude"); await mkdir(runtimeRootDir, { recursive: true }); const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = []; const apiServer = 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) => { apiServer.once("error", reject); apiServer.listen(0, "127.0.0.1", () => resolve()); }); const address = apiServer.address(); if (!address || typeof address === "string") { throw new Error("Expected the bridge shell test API server to listen on a TCP port."); } const delegateRunner = createLocalSandboxRunner(); const runner = { execute: vi.fn(async (input: Parameters[0]) => delegateRunner.execute(input)), }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", environmentId: "env-1", leaseId: "lease-1", remoteCwd, runner, timeoutMs: 30_000, }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-bridge-shell", target, runtimeRootDir, adapterKey: "claude", hostApiToken: "real-run-jwt", hostApiUrl: `http://127.0.0.1:${address.port}`, }); try { expect(bridge).not.toBeNull(); const shellProbe = [ "const url = `${process.env.PAPERCLIP_API_URL}/api/agents/me`;", "fetch(url, { headers: { authorization: `Bearer ${process.env.PAPERCLIP_API_KEY}`, accept: 'application/json' } })", " .then(async (response) => {", " const body = await response.json();", " process.stdout.write(JSON.stringify({", " status: response.status,", " body,", " bridgeMode: process.env.PAPERCLIP_API_BRIDGE_MODE,", " }));", " })", " .catch((error) => {", " console.error(error instanceof Error ? error.stack : String(error));", " process.exit(1);", " });", ].join("\n"); const result = await runAdapterExecutionTargetShellCommand( "run-bridge-shell", target, `${shellQuote(process.execPath)} -e ${shellQuote(shellProbe)}`, { cwd: remoteCwd, env: bridge!.env, timeoutSec: 15, graceSec: 5, onLog: async () => {}, }, ); expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); expect(JSON.parse(result.stdout)).toEqual({ status: 200, body: { ok: true }, bridgeMode: "queue_v1", }); expect(`${result.stdout}\n${result.stderr}`).not.toContain("real-run-jwt"); expect(`${result.stdout}\n${result.stderr}`).not.toContain(bridge!.env.PAPERCLIP_API_KEY); const runnerCommandText = JSON.stringify( runner.execute.mock.calls.map(([call]) => ({ command: call.command, args: call.args, })), ); expect(runnerCommandText).not.toContain("real-run-jwt"); expect(runnerCommandText).not.toContain(bridge!.env.PAPERCLIP_API_KEY); const runtimeFiles = (await readRuntimeTextFiles(runtimeRootDir)).join("\n"); expect(runtimeFiles).not.toContain("real-run-jwt"); expect(runtimeFiles).not.toContain(bridge!.env.PAPERCLIP_API_KEY); expect(requests).toEqual([{ method: "GET", url: "/api/agents/me", auth: "Bearer real-run-jwt", runId: "run-bridge-shell", }]); } finally { await bridge?.stop(); await new Promise((resolve) => apiServer.close(() => resolve())); } }); it("uses the effective adapter timeout when starting the sandbox callback bridge", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-timeout-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); await mkdir(runtimeRootDir, { recursive: true }); const delegateRunner = createLocalSandboxRunner(); const runner = { execute: vi.fn(async (input: Parameters[0]) => delegateRunner.execute(input)), }; const apiServer = createServer((req, res) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); }); await new Promise((resolve, reject) => { apiServer.once("error", reject); apiServer.listen(0, "127.0.0.1", () => resolve()); }); const address = apiServer.address(); if (!address || typeof address === "string") { throw new Error("Expected the bridge timeout test API server to listen on a TCP port."); } const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "cloudflare", environmentId: "env-1", leaseId: "lease-1", remoteCwd, runner, timeoutMs: 30_000, }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-bridge-timeout", target, runtimeRootDir, adapterKey: "codex", timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, hostApiToken: "real-run-jwt", hostApiUrl: `http://127.0.0.1:${address.port}`, }); try { expect(bridge).not.toBeNull(); expect(runner.execute).toHaveBeenCalled(); expect( runner.execute.mock.calls.some(([input]) => input.timeoutMs === DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC * 1000), ).toBe(true); } finally { await bridge?.stop(); await new Promise((resolve) => apiServer.close(() => resolve())); } }); it("fails an oversized host response with a non-retryable 409 so a committed mutation never repeats", async () => { // The host receives the request and commits the mutation, then sends a // response body over the size limit. The forward reads the body after the // fetch resolves, so the read failure happens after the host commit. The // forward must return a non-retryable 504 with the indeterminate outcome, not // a retryable 502. The in-sandbox server maps the indeterminate 504 to a // non-retryable 409. A retryable status would repeat the mutation with a new // request id outside the broker deduplication set. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-limit-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); await mkdir(runtimeRootDir, { recursive: true }); const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = []; // The host body sits over the size limit, so the forward read fails. The // limit stays above the small indeterminate marker the forward returns, so the // marker still reaches the server for the 504-to-409 map. const largeBody = "x".repeat(1024); const apiServer = 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(201, { "content-type": "application/json", "content-length": String(Buffer.byteLength(largeBody, "utf8")), }); res.end(largeBody); }); await new Promise((resolve, reject) => { apiServer.once("error", reject); apiServer.listen(0, "127.0.0.1", () => resolve()); }); const address = apiServer.address(); if (!address || typeof address === "string") { throw new Error("Expected the bridge test API server to listen on a TCP port."); } const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "e2b", environmentId: "env-1", leaseId: "lease-1", remoteCwd, runner: createLocalSandboxRunner(), timeoutMs: 30_000, }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-bridge-limit", target, runtimeRootDir, adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: `http://127.0.0.1:${address.port}`, maxBodyBytes: 512, }); try { const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/issues/issue-1/comments`, { method: "POST", headers: { authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ body: "Status update." }), }); // The indeterminate 504 maps to a non-retryable 409, so the caller does not // retry the committed mutation. expect(response.status).toBe(409); expect(response.headers.get("x-paperclip-bridge-outcome")).toBe("indeterminate"); await expect(response.json()).resolves.toEqual({ error: "Bridge response body exceeded the configured size limit of 512 bytes.", outcome: "indeterminate", retryable: false, }); // The host ran the mutation exactly once. It never receives a retry. expect(requests).toEqual([{ method: "POST", url: "/api/issues/issue-1/comments", auth: "Bearer real-run-jwt", runId: "run-bridge-limit", }]); } finally { await bridge?.stop(); await new Promise((resolve) => apiServer.close(() => resolve())); } }); it("keeps an oversized host response for a safe method retryable so the read failure does not turn terminal", async () => { // A GET never changes host state, so a retry cannot double-apply a mutation. // The host sends a response body over the size limit, so the forward read // fails after the fetch resolves. For a safe method the forward must return a // retryable 502 with no indeterminate marker, not the non-retryable 504 the // forward returns for a mutating method. The in-sandbox server passes the 502 // through, so the caller can retry the safe read. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-safe-limit-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); await mkdir(runtimeRootDir, { recursive: true }); const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = []; const largeBody = "x".repeat(1024); const apiServer = 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", "content-length": String(Buffer.byteLength(largeBody, "utf8")), }); res.end(largeBody); }); await new Promise((resolve, reject) => { apiServer.once("error", reject); apiServer.listen(0, "127.0.0.1", () => resolve()); }); const address = apiServer.address(); if (!address || typeof address === "string") { throw new Error("Expected the bridge test API server to listen on a TCP port."); } const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "e2b", environmentId: "env-1", leaseId: "lease-1", remoteCwd, runner: createLocalSandboxRunner(), timeoutMs: 30_000, }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-bridge-safe-limit", target, runtimeRootDir, adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: `http://127.0.0.1:${address.port}`, maxBodyBytes: 512, }); try { const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/issues/issue-1`, { method: "GET", headers: { authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`, }, }); // The forward returns a retryable 502 with no indeterminate marker, so the // server passes it through instead of mapping it to a terminal 409. expect(response.status).toBe(502); expect(response.headers.get("x-paperclip-bridge-outcome")).toBeNull(); await expect(response.json()).resolves.toEqual({ error: "Bridge response body exceeded the configured size limit of 512 bytes.", }); expect(requests).toEqual([{ method: "GET", url: "/api/issues/issue-1", auth: "Bearer real-run-jwt", runId: "run-bridge-safe-limit", }]); } finally { await bridge?.stop(); await new Promise((resolve) => apiServer.close(() => resolve())); } }); it("forwards the host indeterminate-outcome header so the sandbox server maps the 504 to a non-retryable 409", async () => { // The host marks a possibly-committed mutation with a 504 and the // `x-paperclip-bridge-outcome: indeterminate` header. The forward must keep // that header, so the in-sandbox server maps the 504 to a non-retryable 409. // If the forward drops the header, the client sees a retryable 504 and a // retry repeats a mutation that already committed. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-outcome-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); await mkdir(runtimeRootDir, { recursive: true }); const responseBody = JSON.stringify({ error: "Mutation outcome is indeterminate.", outcome: "indeterminate", retryable: false }); const apiServer = createServer((_req, res) => { res.writeHead(504, { "content-type": "application/json", "x-paperclip-bridge-outcome": "indeterminate", }); res.end(responseBody); }); await new Promise((resolve, reject) => { apiServer.once("error", reject); apiServer.listen(0, "127.0.0.1", () => resolve()); }); const address = apiServer.address(); if (!address || typeof address === "string") { throw new Error("Expected the bridge outcome test API server to listen on a TCP port."); } const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "e2b", environmentId: "env-1", leaseId: "lease-1", remoteCwd, runner: createLocalSandboxRunner(), timeoutMs: 30_000, }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-bridge-outcome", target, runtimeRootDir, adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: `http://127.0.0.1:${address.port}`, }); try { const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/issues/issue-1/comments`, { method: "POST", headers: { authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ body: "Status update." }), }); // The sandbox server maps the indeterminate 504 to a non-retryable 409. expect(response.status).toBe(409); // The outcome header and body still reach the client, so a caller that // reads them still sees the indeterminate result. expect(response.headers.get("x-paperclip-bridge-outcome")).toBe("indeterminate"); await expect(response.json()).resolves.toEqual({ error: "Mutation outcome is indeterminate.", outcome: "indeterminate", retryable: false, }); } finally { await bridge?.stop(); await new Promise((resolve) => apiServer.close(() => resolve())); } }); it("forwards bridge traffic to the local listen origin even when public API URLs are configured", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-local-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "claude"); await mkdir(runtimeRootDir, { recursive: true }); const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = []; const apiServer = 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) => { apiServer.once("error", reject); apiServer.listen(0, "127.0.0.1", () => resolve()); }); const address = apiServer.address(); if (!address || typeof address === "string") { throw new Error("Expected the bridge local-origin test API server to listen on a TCP port."); } // Simulate a deployment where a public base URL is configured: server boot // exports the public origin via PAPERCLIP_RUNTIME_API_URL / PAPERCLIP_API_URL // and the local listen host/port via PAPERCLIP_LISTEN_HOST / PAPERCLIP_LISTEN_PORT. // The wildcard listen host must map to the loopback address of the same // family (0.0.0.0 -> 127.0.0.1), where the test API server is bound. vi.stubEnv("PAPERCLIP_RUNTIME_API_URL", "https://public.example.invalid"); vi.stubEnv("PAPERCLIP_API_URL", "https://public.example.invalid"); vi.stubEnv("PAPERCLIP_LISTEN_HOST", "0.0.0.0"); vi.stubEnv("PAPERCLIP_LISTEN_PORT", String(address.port)); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "e2b", environmentId: "env-1", leaseId: "lease-1", remoteCwd, runner: createLocalSandboxRunner(), timeoutMs: 30_000, }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-bridge-local", target, runtimeRootDir, adapterKey: "claude", hostApiToken: "real-run-jwt", }); try { expect(bridge).not.toBeNull(); const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/agents/me`, { headers: { authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`, accept: "application/json", }, }); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ ok: true }); expect(requests).toEqual([{ method: "GET", url: "/api/agents/me", auth: "Bearer real-run-jwt", runId: "run-bridge-local", }]); } finally { await bridge?.stop(); await new Promise((resolve) => apiServer.close(() => resolve())); } }); it("lets an explicit hostApiUrl input override the bridge forward target", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-override-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "claude"); await mkdir(runtimeRootDir, { recursive: true }); const requests: string[] = []; const apiServer = createServer((req, res) => { requests.push(req.url ?? "/"); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); }); await new Promise((resolve, reject) => { apiServer.once("error", reject); apiServer.listen(0, "127.0.0.1", () => resolve()); }); const address = apiServer.address(); if (!address || typeof address === "string") { throw new Error("Expected the bridge override test API server to listen on a TCP port."); } // Neither the public URL envs nor the listen host/port should matter when // the caller passes an explicit hostApiUrl. vi.stubEnv("PAPERCLIP_RUNTIME_API_URL", "https://public.example.invalid"); vi.stubEnv("PAPERCLIP_API_URL", "https://public.example.invalid"); vi.stubEnv("PAPERCLIP_LISTEN_HOST", "203.0.113.1"); vi.stubEnv("PAPERCLIP_LISTEN_PORT", "9"); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "e2b", environmentId: "env-1", leaseId: "lease-1", remoteCwd, runner: createLocalSandboxRunner(), timeoutMs: 30_000, }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-bridge-override", target, runtimeRootDir, adapterKey: "claude", hostApiToken: "real-run-jwt", hostApiUrl: `http://127.0.0.1:${address.port}`, }); try { expect(bridge).not.toBeNull(); const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/agents/me`, { headers: { authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`, accept: "application/json", }, }); expect(response.status).toBe(200); expect(requests).toEqual(["/api/agents/me"]); } finally { await bridge?.stop(); 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): EffectiveExecutionCapabilities { return { reusableLeases: false, nativeSyncIn: false, nativeSyncOut: false, persistentProcessSessions: false, independentControlCommands: false, incrementalSessionOutput: false, concurrentSyncOperations: false, duplexCommandStream, runnerWebSocketIngress: false, }; } // 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; 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, 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: Uint8Array) => void) | null = null; let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; const channel: CommandManagedDuplexChannel = { write(data: Uint8Array): void { const decoded = decodeDuplexLine(Buffer.from(data).toString("utf8").replace(/\n$/, "")); if (decoded.ok) { control.writtenTypes.push(decoded.frame.type); } }, onData(listener: (chunk: Uint8Array) => void): void { dataListener = listener; control.emitData = (chunk) => dataListener?.(new TextEncoder().encode(chunk)); // Drive the readiness emission on the next tick, after the gate also // registers its exit listener. setImmediate(() => { const emitRaw = (text: string) => dataListener?.(new TextEncoder().encode(text)); const emitFrame = (frame: Record) => dataListener?.(new TextEncoder().encode(`${JSON.stringify(frame)}\n`)); const emitExit = (exit: { exitCode: number | null }) => exitListener?.(exit); if (onOpen) { onOpen({ nonce, port, emitRaw, emitFrame, emitExit }); } else { emitFrame({ version: 2, 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 }; } // The HTTP/2 client connection preface, 24 octets (RFC 9113, Section 3.4). // A test writes this literal to script a preface look-alike; it does not // import the production constant, so the test proves the real wire bytes // match, not only that the two source files agree on a name. const HTTP2_TEST_CLIENT_PREFACE = Buffer.from("505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", "hex"); interface Http2SelectionControl { openCount: number; stopCount: number; closeCount: number; } // The hook a test supplies to script the bytes a fake sandbox gateway sends // after the host binds the readiness gate, and to open a real HTTP/2 client // session on the same channel. The default hook sends one valid READY line, // then opens the client session and leaves it idle — the happy path needs // no hook. interface Http2OpenContext { nonce: string; port: string; /** The real per-run bridge token the host generated for this open. A * test attaches it as the `authorization` header on every real HTTP/2 * request it dispatches, the same way the sandbox gateway does. */ bridgeToken: string; /** Write raw bytes onto the channel, ahead of or instead of a READY line * or the client preface. */ emitRaw: (bytes: Buffer | string) => void; /** Write one READY line. Echoes the launch nonce by default. */ emitReady: (nonce?: string) => void; /** Open a real HTTP/2 client session on the channel and return it, the * same session type the generated sandbox gateway opens in production. */ connectHttp2: () => http2.ClientHttp2Session; /** End the channel from the sandbox side, simulating a provider process exit. */ emitExit: () => void; } // Build a runner whose fake `openDuplexChannel` returns one side of a real // paired in-memory `Duplex` (`node:stream`'s `duplexPair`). A test drives // the other side directly, including opening a real `http2.connect()` // client session on it, so the host's HTTP/2 server under test speaks one // real, wire-compatible HTTP/2 session — the same proof // `http2-bridge-server.test.ts` uses for the server and the gateway in // isolation, exercised here through the full transport-selection path. function makeHttp2SelectionRunner(onOpen?: (ctx: Http2OpenContext) => void): { runner: ReturnType & { openDuplexChannel: (openInput: { command: readonly string[] }) => Promise; }; control: Http2SelectionControl; } { const base = createLocalSandboxRunner(); const control: Http2SelectionControl = { openCount: 0, stopCount: 0, closeCount: 0 }; 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] ?? ""; const bridgeToken = /PAPERCLIP_BRIDGE_TOKEN='([^']*)'/.exec(joined)?.[1] ?? ""; const [hostSide, sandboxSide] = duplexPair(); const dataListeners: Array<(chunk: Uint8Array) => void> = []; const exitListeners: Array<(exit: { exitCode: number | null }) => void> = []; hostSide.on("data", (chunk: Buffer) => { for (const listener of dataListeners) listener(chunk); }); hostSide.on("end", () => { for (const listener of exitListeners) listener({ exitCode: 0 }); }); const channel: CommandManagedDuplexChannel = { write: (data: Uint8Array) => { hostSide.write(Buffer.from(data)); }, onData: (listener: (chunk: Uint8Array) => void) => { dataListeners.push(listener); }, onExit: (listener: (exit: { exitCode: number | null }) => void) => { exitListeners.push(listener); }, stop: () => { control.stopCount += 1; hostSide.destroy(); }, close: async () => { control.closeCount += 1; hostSide.end(); }, }; setImmediate(() => { const ctx: Http2OpenContext = { nonce, port, bridgeToken, emitRaw: (bytes) => sandboxSide.write(typeof bytes === "string" ? Buffer.from(bytes) : bytes), emitReady: (readyNonce = nonce) => sandboxSide.write( encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "ready", nonce: readyNonce }), ), connectHttp2: () => http2.connect("http://bridge.internal", { createConnection: () => sandboxSide }), emitExit: () => sandboxSide.end(), }; if (onOpen) { onOpen(ctx); } else { ctx.emitReady(); ctx.connectHttp2(); } }); return channel; }; return { runner: { ...base, openDuplexChannel }, control }; } /** * Forward one request over a real HTTP/2 client session and resolve with * the response the host sends back. Mirrors the shape of the sandbox * gateway's own request forward, so a test drives the transport exactly * like production does. */ function http2TestRequest( session: http2.ClientHttp2Session, request: { method: string; path: string; headers?: Record; body?: Buffer }, ): Promise<{ status: number; headers: Record; body: Buffer }> { return new Promise((resolve, reject) => { const body = request.body ?? Buffer.alloc(0); const stream = session.request( { ":method": request.method, ":path": request.path, ...request.headers }, { endStream: body.length === 0 }, ); const chunks: Buffer[] = []; let status = 502; let headers: Record = {}; stream.on("response", (h) => { status = Number(h[":status"]) || 502; headers = {}; for (const [key, value] of Object.entries(h)) { if (key.startsWith(":") || value == null) continue; headers[key] = Array.isArray(value) ? value.join(", ") : String(value); } }); stream.on("data", (chunk: Buffer) => chunks.push(chunk)); stream.once("end", () => resolve({ status, headers, body: Buffer.concat(chunks) })); stream.once("error", (error) => reject(error)); if (body.length > 0) stream.end(body); else if (!stream.writableEnded) stream.end(); }); } // Fixed binary payload for an attachment-content download. Byte 0x89 opens // the PNG signature and is not valid UTF-8 on its own, so a round trip // through a text decode step would corrupt it. const ATTACHMENT_DOWNLOAD_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0x00, 0x7f]); // Build a minimal multipart/form-data request body with one binary file // part, plus the matching `content-type` header value. function buildMultipartAttachmentUpload(fileBytes: Buffer): { body: Buffer; contentType: string } { const boundary = "paperclip-test-boundary"; const head = Buffer.from( `--${boundary}\r\n` + `Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n` + `Content-Type: application/octet-stream\r\n\r\n`, "utf8", ); const tail = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"); return { body: Buffer.concat([head, fileBytes, tail]), contentType: `multipart/form-data; boundary=${boundary}`, }; } // 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; headers: Record; body: Buffer; }>; close: () => Promise; }> { const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null; headers: Record; body: Buffer; }> = []; const server = createServer((req, res) => { const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { if (typeof value === "string") headers[key] = value; } const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("end", () => { 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, headers, body: Buffer.concat(chunks), }); // An attachment-content download answers with a binary body, so a // test can assert the bytes reach the caller unchanged. Every other // route keeps the fixed JSON acknowledgement. if (req.method === "GET" && /^\/api\/attachments\/[^/]+\/content$/.test(req.url ?? "")) { res.writeHead(200, { "content-type": "application/octet-stream" }); res.end(ATTACHMENT_DOWNLOAD_BYTES); return; } 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("test_startup_failure_falls_back_to_queue_v1", async () => { // The channel open itself fails (a provider startup fault, before any // READY line or preface is possible). The host must fall back to the // file bridge and record the typed open-failure reason, never hang. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-startup-fail-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const base = createLocalSandboxRunner(); let openCount = 0; const openDuplexChannel = async (): Promise => { openCount += 1; throw new Error("provider could not start the sandbox process"); }; const runner = { ...base, openDuplexChannel }; const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-startup-fail", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(openCount).toBe(1); expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback?.dimensions.fallback_reason).toBe("channel_open_failed"); // The queue fallback keeps the 415 gate for the attachment upload path: // it never admits a binary body, and it never forwards the request to // the host. const uploadResponse = await fetch( `${bridge!.env.PAPERCLIP_API_URL}/api/companies/co-1/issues/issue-1/attachments`, { method: "POST", headers: { authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`, "content-type": "application/octet-stream", }, body: Buffer.from([0x50, 0x4b, 0x03, 0x04]), }, ); expect(uploadResponse.status).toBe(415); expect(api.requests).toHaveLength(0); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("test_daytona_selects_http2_v1", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-select-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner, control } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", environmentId: "env-1", leaseId: "lease-1", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-http2", 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("http2_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 sandbox gateway forwards one agent request as one real HTTP/2 // stream, over the one session that runs directly on the sandbox // channel. The host serves it with the real token and the run id. await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/agents/me", headers: { authorization: `Bearer ${bridgeToken}` }, }); expect(response.status).toBe(200); expect(api.requests).toHaveLength(1); expect(api.requests[0]).toMatchObject({ method: "GET", url: "/api/agents/me", auth: "Bearer real-run-jwt", runId: "run-http2", }); } finally { sessionRef.current?.close(); 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("streams run logs on the http2 path under the same gate and log line as the file path", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-runlog-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const { runner } = makeHttp2SelectionRunner(); const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, streamRunLogs: true, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-duplex-log", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, onLog: async (stream, chunk) => { logs.push({ stream, chunk }); }, }); try { // The http2 transport served, and it still streams run logs with the same // gate and the same log line as the file path. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); expect(bridge?.runLogTail).toBeTruthy(); expect(combinedStream(logs, "stdout")).toContain("Sandbox run log streaming enabled"); const wrapped = bridge!.runLogTail!.create().wrapCommand("agent-cli", ["--message", "hello world"]); expect(wrapped.args.join("\n")).toContain("tee -a"); expect(wrapped.args.join("\n")).toContain("agent-cli"); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("returns no run-log tail on the http2 path when streaming is opted out", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-runlog-off-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const { runner } = makeHttp2SelectionRunner(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, streamRunLogs: false, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-http2-log-off", 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("http2_v1"); expect(bridge?.runLogTail ?? null).toBeNull(); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("routes http2 channel-open and fallback records to a recorder attached on the server seam", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-recorder-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const counters: DuplexObservabilityCounterRecord[] = []; const recorder: DuplexObservabilityRecorder = { recordSpan() {}, incrementCounter(record) { counters.push(record); }, emitEvent() {}, }; // A channel open reaches the recorder on the http2 success path. The host // attaches the recorder to the sandbox target on the same seam as the // runner; the caller reads it with the accessor and passes it to the bridge. const openTarget: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner: makeHttp2SelectionRunner().runner, effectiveCapabilities: duplexCapabilities(true), duplexObservabilityRecorder: recorder, }; const openBridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-http2-open", target: openTarget, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexObservabilityRecorder: adapterExecutionTargetDuplexObservabilityRecorder(openTarget), }); try { expect(openBridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); const open = counters.find((record) => record.metric === DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL); expect(open?.dimensions.transport).toBe("http2"); expect(open?.dimensions.provider).toBe("daytona"); } finally { await openBridge?.stop(); } // A fallback reaches the same recorder. The kill switch off records a // gate_off fallback with the file transport. counters.length = 0; const fallbackTarget: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner: makeDuplexSelectionRunner().runner, effectiveCapabilities: duplexCapabilities(true), duplexObservabilityRecorder: recorder, }; const fallbackBridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-duplex-fallback", target: fallbackTarget, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: false, duplexObservabilityRecorder: adapterExecutionTargetDuplexObservabilityRecorder(fallbackTarget), }); try { expect(fallbackBridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); const fallback = counters.find((record) => record.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback?.dimensions.fallback_reason).toBe("gate_off"); expect(fallback?.dimensions.transport).toBe("file"); } finally { await fallbackBridge?.stop(); await api.close(); } }, 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: 2, type: "ready", nonce: "00000000000000000000000000000000" }), }, { name: "an incomplete READY frame", onOpen: (ctx: DuplexOpenContext) => ctx.emitRaw('{"version":2,"type":"ready"}\n'), }, { name: "protocol contamination before READY", onOpen: (ctx: DuplexOpenContext) => ctx.emitFrame({ version: 2, 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":2,"type":"ready","nonce":"${nonce}","port":${attackerPort}}\n`, }, { name: "a channel-supplied host URL", buildReady: (nonce: string, attackerPort: number) => `{"version":2,"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("test_route_allowlist_header_cleanup_and_token_replacement_hold_on_http2", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-403-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-http2-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("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); // An unlisted route answers 403 over the real HTTP/2 stream and never // reaches the host API — the route allowlist holds on the http2 path. const forbidden = await http2TestRequest(sessionRef.current!, { method: "POST", path: "/api/secret-admin-route", headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" }, body: Buffer.from(JSON.stringify({ escalate: true }), "utf8"), }); expect(forbidden.status).toBe(403); expect(api.requests).toHaveLength(0); // An allowed route forwards with the real host token and the run id — // the token replacement and the run-id injection hold on the http2 path. // A header outside the allowlist (`x-not-allowed`) never reaches the host. const allowed = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/agents/me", headers: { authorization: `Bearer ${bridgeToken}`, "x-not-allowed": "should-be-dropped" }, }); expect(allowed.status).toBe(200); expect(api.requests).toHaveLength(1); expect(api.requests[0]).toMatchObject({ method: "GET", url: "/api/agents/me", auth: "Bearer real-run-jwt", runId: "run-http2-403", }); expect(api.requests[0].headers["x-not-allowed"]).toBeUndefined(); // The two attachment routes are admitted on the http2 path, and a // binary body reaches the host and returns unchanged in both // directions: no re-encoding step touches the multipart upload or the // binary download. const uploadBytes = Buffer.from([0x00, 0x01, 0xff, 0x7f, 0x80, 0x0d, 0x0a]); const upload = buildMultipartAttachmentUpload(uploadBytes); const uploadResponse = await http2TestRequest(sessionRef.current!, { method: "POST", path: "/api/companies/co-1/issues/issue-1/attachments", headers: { authorization: `Bearer ${bridgeToken}`, "content-type": upload.contentType, }, body: upload.body, }); expect(uploadResponse.status).toBe(200); expect(api.requests).toHaveLength(2); expect(api.requests[1]).toMatchObject({ method: "POST", url: "/api/companies/co-1/issues/issue-1/attachments", }); expect(api.requests[1].headers["content-type"]).toBe(upload.contentType); expect(api.requests[1].body.equals(upload.body)).toBe(true); const downloadResponse = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/attachments/att-1/content", headers: { authorization: `Bearer ${bridgeToken}` }, }); expect(downloadResponse.status).toBe(200); expect(api.requests).toHaveLength(3); expect(downloadResponse.body.equals(ATTACHMENT_DOWNLOAD_BYTES)).toBe(true); } finally { sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("test_http2_forward_preserves_request_and_response_bytes", async () => { // Byte 0xC3 opens a two-byte UTF-8 sequence; 0x28 is not a valid // continuation byte, so this body is not valid UTF-8. The forward path // must carry these exact bytes on the way in, and the host's own // response bytes on the way out, with no re-encoding step on either leg. const malformedBytes = Buffer.from([0x7b, 0x22, 0x61, 0x22, 0x3a, 0xc3, 0x28, 0x7d]); const receivedRequestBodies: Buffer[] = []; const echoServer = createServer((req, res) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("end", () => { receivedRequestBodies.push(Buffer.concat(chunks)); res.writeHead(200, { "content-type": "application/octet-stream" }); res.end(malformedBytes); }); }); await new Promise((resolve, reject) => { echoServer.once("error", reject); echoServer.listen(0, "127.0.0.1", () => resolve()); }); const echoAddress = echoServer.address(); if (!echoAddress || typeof echoAddress === "string") { throw new Error("Expected the echo server to listen on a TCP port."); } const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-raw-bytes-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-raw-bytes", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: `http://127.0.0.1:${echoAddress.port}`, enableSandboxDuplexBridge: true, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "POST", path: "/api/issues/issue-1/comments", headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/octet-stream" }, body: malformedBytes, }); expect(response.status).toBe(200); expect(receivedRequestBodies).toHaveLength(1); expect(receivedRequestBodies[0]?.equals(malformedBytes)).toBe(true); expect(response.body.equals(malformedBytes)).toBe(true); } finally { sessionRef.current?.close(); await bridge?.stop(); await new Promise((resolve) => echoServer.close(() => resolve())); } }, 20000); // One recording telemetry recorder. It captures every span, counter, and event // the fixed duplex surface produces, so a test asserts the exact names, // dimensions, and values. An optional `failEvery` flag makes every method throw, // so a test proves a telemetry failure never breaks the request path. function createRecordingDuplexRecorder(options: { failEvery?: boolean } = {}): { recorder: DuplexObservabilityRecorder; spans: DuplexObservabilitySpanRecord[]; counters: DuplexObservabilityCounterRecord[]; events: DuplexObservabilityEventRecord[]; } { const spans: DuplexObservabilitySpanRecord[] = []; const counters: DuplexObservabilityCounterRecord[] = []; const events: DuplexObservabilityEventRecord[] = []; const recorder: DuplexObservabilityRecorder = { recordSpan(record) { if (options.failEvery) throw new Error("telemetry sink down"); spans.push(record); }, incrementCounter(record) { if (options.failEvery) throw new Error("telemetry sink down"); counters.push(record); }, emitEvent(record) { if (options.failEvery) throw new Error("telemetry sink down"); events.push(record); }, }; return { recorder, spans, counters, events }; } // Every dimension key a record carries must be one of the fixed keys. The set is // closed, so a new key never reaches a sink by accident. function assertOnlyFixedDimensionKeys(dimensions: DuplexObservabilityDimensions | undefined): void { expect(dimensions).toBeDefined(); for (const key of Object.keys(dimensions ?? {})) { expect(DUPLEX_DIMENSION_KEYS).toContain(key as (typeof DUPLEX_DIMENSION_KEYS)[number]); } } it("pins the exact fixed duplex dimension-key set", () => { // The dimension-key set is closed. This test locks the exact contract, so a // new key never reaches a sink without an explicit change here. expect([...DUPLEX_DIMENSION_KEYS]).toEqual([ "provider", "transport", "outcome", "fallback_reason", "loss_class", "loss_reason", ]); }); it("records an http2 request span with latency and the fixed dimension keys", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-obs-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const { recorder, spans, counters, events } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-obs", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexObservabilityRecorder: recorder, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/agents/me", headers: { authorization: `Bearer ${bridgeToken}` }, }); expect(response.status).toBe(200); // The channel-open surface: the span, the counter, and the transport event. const openSpan = spans.find((span) => span.name === DUPLEX_SPAN_CHANNEL_OPEN); expect(openSpan).toBeDefined(); expect(openSpan?.dimensions).toMatchObject({ provider: "daytona", transport: "http2", outcome: "ok" }); expect(counters.some((c) => c.metric === DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL)).toBe(true); expect( events.some( (e) => e.name === DUPLEX_TRANSPORT_EVENT && e.dimensions.transport === "http2" && e.dimensions.outcome === "ok", ), ).toBe(true); // The request span carries a numeric latency and only the fixed keys. const requestSpan = spans.find((span) => span.name === DUPLEX_SPAN_REQUEST); expect(requestSpan).toBeDefined(); expect(typeof requestSpan?.latencyMs).toBe("number"); expect(requestSpan?.latencyMs).toBeGreaterThanOrEqual(0); expect(requestSpan?.dimensions).toMatchObject({ provider: "daytona", transport: "http2", outcome: "ok" }); assertOnlyFixedDimensionKeys(requestSpan?.dimensions); } finally { sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("increments the fallback counter with an approved reason when the capability is absent", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-fb-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const { runner } = makeDuplexSelectionRunner(); const { recorder, counters, events } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(false), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-fb", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexObservabilityRecorder: recorder, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback).toBeDefined(); const approvedReasons = [ "gate_off", "capability_absent", "route_busy", "entrypoint_sync_failed", "broker_construction_failed", "channel_open_failed", "ready_invalid", "ready_nonce_mismatch", "ready_timeout", "contaminated", ]; expect(approvedReasons).toContain(fallback?.dimensions.fallback_reason); expect(fallback?.dimensions).toMatchObject({ transport: "file", outcome: "error" }); assertOnlyFixedDimensionKeys(fallback?.dimensions); // The transport event mirrors the fallback. expect( events.some( (e) => e.name === DUPLEX_TRANSPORT_EVENT && e.dimensions.transport === "file", ), ).toBe(true); } finally { await bridge?.stop(); await api.close(); } }, 20000); it.each([ { name: "a full process-scoped route ceiling", error: new Error("worker route rejected: DUPLEX_CHANNEL_ROUTE_BUSY"), expectedReason: "route_busy", }, { name: "a generic channel-open failure", error: new Error("provider channel open failed"), expectedReason: "channel_open_failed", }, ])( "names the open-failure stage $expectedReason and falls back to the file bridge on $name", async ({ error, expectedReason }) => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-stage-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // A runner whose duplex open rejects. The host binds the caught error and // names the exact open-failure stage. const base = createLocalSandboxRunner(); const openDuplexChannel = async (): Promise => { throw error; }; const runner = { ...base, openDuplexChannel }; const { recorder, spans, counters, events } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-stage", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexObservabilityRecorder: recorder, }); try { // The channel never opened, so the host serves the file bridge. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); // The channel-open span and the fallback counter name the exact stage. const openSpan = spans.find( (s) => s.name === DUPLEX_SPAN_CHANNEL_OPEN && s.dimensions.outcome === "error", ); expect(openSpan?.dimensions.fallback_reason).toBe(expectedReason); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback?.dimensions.fallback_reason).toBe(expectedReason); assertOnlyFixedDimensionKeys(fallback?.dimensions); expect( events.some( (e) => e.name === DUPLEX_TRANSPORT_EVENT && e.dimensions.transport === "file", ), ).toBe(true); } finally { await bridge?.stop(); await api.close(); } }, 20000, ); it.each([ { name: "before any dispatch", dispatchFirst: false, expectedClass: "pre_dispatch" }, { name: "after a dispatch", dispatchFirst: true, expectedClass: "post_dispatch" }, ])("increments the loss counter with the loss class $name", async ({ dispatchFirst, expectedClass }) => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-loss-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; let emitExit: (() => void) | null = null; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; emitExit = ctx.emitExit; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-loss", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexObservabilityRecorder: recorder, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); if (dispatchFirst) { const response = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/agents/me", headers: { authorization: `Bearer ${bridgeToken}` }, }); expect(response.status).toBe(200); } // The pseudo-terminal channel exits. The host records a terminal loss. emitExit!(); await waitForCondition( () => counters.some((c) => c.metric === DUPLEX_COUNTER_LOSS_TOTAL), "the host to record a loss counter", 4000, ); const loss = counters.find((c) => c.metric === DUPLEX_COUNTER_LOSS_TOTAL); expect(loss?.dimensions.loss_class).toBe(expectedClass); expect(loss?.dimensions).toMatchObject({ transport: "http2", outcome: "error", loss_reason: "provider_exit" }); assertOnlyFixedDimensionKeys(loss?.dimensions); } finally { sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("keeps serving the request path when the telemetry recorder throws", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-guard-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const { recorder } = createRecordingDuplexRecorder({ failEvery: true }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-guard", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexObservabilityRecorder: recorder, }); try { // The throwing recorder never blocked the http2 selection. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/agents/me", headers: { authorization: `Bearer ${bridgeToken}` }, }); // The request path still delivered a real host response. expect(response.status).toBe(200); expect(api.requests).toHaveLength(1); } finally { sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("keeps sentinel route, query, body, and tokens off the http2 telemetry and logs", async () => { // `recordHttp2Loss` (execution-target.ts) never accepts a raw error // message: it maps every host-observed HTTP/2 event to one fixed, closed // `DuplexLossReason` value before any sink reads it (accepted security // fix 7, `duplex-observability.test.ts` pins the closed map). A raw provider // error string has no code path into a sink on the http2_v1 transport, so // this test proves the property that does need a live run: the route, // the query, the body, and both tokens never ride a sink either. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-redact-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const ROUTE_SENTINEL = "sentinelroute8f21"; const QUERY_SENTINEL = "sentinelquery3d90"; const BODY_SENTINEL = "sentinelbodya17c"; const AGENT_TOKEN_SENTINEL = "sentinelagenttoke91b4"; const sentinels = [ROUTE_SENTINEL, QUERY_SENTINEL, BODY_SENTINEL, AGENT_TOKEN_SENTINEL]; const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const logLines: string[] = []; const { recorder, spans, counters, events } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const previousDebug = process.env.PAPERCLIP_BRIDGE_DEBUG; process.env.PAPERCLIP_BRIDGE_DEBUG = "1"; let bridge: Awaited> = null; try { bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-redact", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: AGENT_TOKEN_SENTINEL, hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexObservabilityRecorder: recorder, onLog: async (_stream, chunk) => { logLines.push(chunk); }, }); expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); // Dispatch one real HTTP/2 stream that carries the sentinel route, // query, and body. The route allowlist accepts an arbitrary issue id // segment, so the sentinel rides an allowed route. The real agent // token (also a sentinel) never leaves the host. const response = await http2TestRequest(sessionRef.current!, { method: "POST", path: `/api/issues/${ROUTE_SENTINEL}/comments?secret=${QUERY_SENTINEL}`, headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" }, body: Buffer.from(JSON.stringify({ body: BODY_SENTINEL }), "utf8"), }); expect(response.status).toBe(200); await waitForCondition( () => spans.some((s) => s.name === DUPLEX_SPAN_REQUEST), "the host to record a request span", 4000, ); // Serialize every telemetry record and every log line, then assert that no // sentinel reaches any of them on the http2 path. const telemetryDump = JSON.stringify({ spans, counters, events }); const logDump = logLines.join(""); for (const sentinel of sentinels) { expect(telemetryDump).not.toContain(sentinel); expect(logDump).not.toContain(sentinel); } // The real agent token replaced the bridge token on the forward; the // recording API server saw only the real token, never the bridge token. expect(api.requests[0]?.auth).toBe(`Bearer ${AGENT_TOKEN_SENTINEL}`); } finally { if (previousDebug === undefined) delete process.env.PAPERCLIP_BRIDGE_DEBUG; else process.env.PAPERCLIP_BRIDGE_DEBUG = previousDebug; sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("maps a sentinel provider key to the constant other across every sink", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-prov-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const { recorder, spans, counters, events } = createRecordingDuplexRecorder(); const PROVIDER_SENTINEL = "sentinel-plugin-provider-key-9c2a"; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: PROVIDER_SENTINEL, remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-prov", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexObservabilityRecorder: recorder, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/agents/me", headers: { authorization: `Bearer ${bridgeToken}` }, }); expect(response.status).toBe(200); await waitForCondition( () => spans.some((s) => s.name === DUPLEX_SPAN_REQUEST), "the host to record a request span", 4000, ); // Every recorded provider dimension is the constant `other`, never the key. const allDimensions = [ ...spans.map((s) => s.dimensions), ...counters.map((c) => c.dimensions), ...events.map((e) => e.dimensions), ]; expect(allDimensions.length).toBeGreaterThan(0); for (const dimensions of allDimensions) { expect(dimensions.provider).toBe("other"); } // The raw key reaches no sink. const telemetryDump = JSON.stringify({ spans, counters, events }); expect(telemetryDump).not.toContain(PROVIDER_SENTINEL); } finally { sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("caps the pre-READY readiness buffer and falls back with a contaminated reason", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-cap-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // The fake gateway sends a large pre-READY blob with no newline, then one // more blob after the gate settles. The gate must cap the buffer, finish with // protocol contamination, and drop the later blob. The blob is larger than // the codec frame-size bound, so it passes the readiness buffer cap. const oversizedBlob = "x".repeat(DEFAULT_MAX_DUPLEX_FRAME_BYTES * 2); const { runner, control } = makeDuplexSelectionRunner((ctx) => { ctx.emitRaw(oversizedBlob); ctx.emitRaw(oversizedBlob); }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-cap", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, // A long readiness timeout, so the buffer cap, not the timeout, drives the // failure. duplexReadinessTimeoutMs: 5_000, duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); // The cap drove the failure, so the file bridge serves after the bounded cleanup. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback?.dimensions.fallback_reason).toBe("contaminated"); // The bounded cleanup left no live provider session. expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("caps the pre-READY buffer under many small newline-less chunks", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-cap-small-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // An adversarial provider controls the chunk size. It sends many small // newline-less chunks that together pass the cap. The gate must scan each // chunk in O(1) of the buffer length, so the pre-READY window stays bounded. // The gate caps the buffer, finishes with protocol contamination, and falls // back to the file bridge. const readinessBufferCapBytes = DEFAULT_MAX_DUPLEX_FRAME_BYTES + 4_096; const smallChunk = "x".repeat(64); const chunkCount = Math.ceil(readinessBufferCapBytes / smallChunk.length) + 1; const { runner, control } = makeDuplexSelectionRunner((ctx) => { for (let i = 0; i < chunkCount; i += 1) { ctx.emitRaw(smallChunk); } }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-cap-small", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, // A long readiness timeout, so the buffer cap, not the timeout, drives the // failure. duplexReadinessTimeoutMs: 5_000, duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); // The cap drove the failure, so the file bridge serves after the bounded cleanup. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback?.dimensions.fallback_reason).toBe("contaminated"); expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("bounds the pre-READY newline-scan work by the bytes received", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-scan-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // An adversarial provider sends many small newline-less chunks before the // cap fires. Each chunk must scan only the new bytes, not the whole buffer, // so the total newline-scan work stays linear in the bytes received. A // per-chunk full rescan makes the work quadratic. const readinessBufferCapBytes = DEFAULT_MAX_DUPLEX_FRAME_BYTES + 4_096; const smallChunk = "x".repeat(64); const chunkCount = Math.ceil(readinessBufferCapBytes / smallChunk.length) + 1; const totalBytes = chunkCount * smallChunk.length; const { runner, control } = makeDuplexSelectionRunner((ctx) => { for (let i = 0; i < chunkCount; i += 1) { ctx.emitRaw(smallChunk); } }); const { recorder } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; __duplexReadinessTesting.resetNewlineScanUnits(); const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-scan-bound", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 5_000, duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); const scanUnits = __duplexReadinessTesting.readNewlineScanUnits(); // Linear scan work reads each byte one time, so the count stays near // totalBytes. A per-chunk full rescan is quadratic (about // totalBytes^2 / (2 * chunkSize)), far above this bound. expect(scanUnits).toBeLessThanOrEqual(4 * totalBytes); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("bounds the pre-READY buffer growth-copy work by the bytes received", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-growth-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // An adversarial provider sends many small newline-less fragments before the // cap fires. A one-copy-per-fragment append copies the whole retained buffer // on every fragment, so the total copy work is quadratic in the number of // fragments. The gate must instead grow its backing storage by doubling, so // the total copy work stays linear in the bytes received. const readinessBufferCapBytes = DEFAULT_MAX_DUPLEX_FRAME_BYTES + 4_096; const smallChunk = "x".repeat(64); const chunkCount = Math.ceil(readinessBufferCapBytes / smallChunk.length) + 1; const totalBytes = chunkCount * smallChunk.length; const { runner, control } = makeDuplexSelectionRunner((ctx) => { for (let i = 0; i < chunkCount; i += 1) { ctx.emitRaw(smallChunk); } }); const { recorder } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; __duplexReadinessTesting.resetBufferGrowthCopyUnits(); const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-growth-bound", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 5_000, duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); const copyUnits = __duplexReadinessTesting.readBufferGrowthCopyUnits(); // Doubling growth copies a logarithmic number of times, each at most the // current buffer length, so the total stays within a small multiple of // totalBytes. A per-fragment full-buffer copy is quadratic (about // totalBytes^2 / (2 * chunkSize)), far above this bound. expect(copyUnits).toBeLessThanOrEqual(4 * totalBytes); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("bounds the pre-READY skip scan work by the bytes received", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-blank-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // An adversarial provider sends one pre-READY chunk of many blank lines and a // noise line, then a valid READY frame. The gate must skip each blank line and // the noise line in O(1), so the total newline-scan work stays linear in the // bytes received. A per-line full rescan or a per-line buffer copy makes the // work quadratic. The READY frame then settles the gate ready. const blankLineCount = 15_000; const noisePrefix = "\n".repeat(blankLineCount) + "a non-frame echo line\n"; const { runner, control } = makeHttp2SelectionRunner((ctx) => { ctx.emitRaw(noisePrefix); ctx.emitReady(); ctx.connectHttp2(); }); const readyLine = '{"version":2,"type":"ready","nonce":""}\n'; const totalBytes = noisePrefix.length + readyLine.length; const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; __duplexReadinessTesting.resetNewlineScanUnits(); const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-blank-scan", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 5_000, duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); const scanUnits = __duplexReadinessTesting.readNewlineScanUnits(); // Incremental skip handling reads each byte one time, so the count stays // near totalBytes. A per-line full rescan is quadratic (about // blankLineCount^2 / 2), far above this bound. expect(scanUnits).toBeLessThanOrEqual(4 * totalBytes); // The gate skipped the noise and accepted the READY frame, so the http2 // transport serves and no fallback fired. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback).toBeUndefined(); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("test_a_prologue_of_any_length_before_the_ready_line_is_discarded", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-noise-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // A PTY channel echoes the launch wrapper line before it sets raw mode, so the // first line the host reads is a non-frame echo, not the READY frame. The gate // must skip the echo line and a partial-JSON line, then accept the READY // frame — no matter how long the prologue is, and with no length held anywhere // in the code. The preface scan then starts only on the bytes the gate // retained after that accepted line. const { runner, control } = makeHttp2SelectionRunner((ctx) => { ctx.emitRaw("sh -c exec env PAPERCLIP_BRIDGE_NONCE=... node gateway.mjs\n"); ctx.emitRaw('{"version":2,"type":"ready"}\n'); ctx.emitRaw("x".repeat(50_000)); // an arbitrarily long prologue, no fixed length ctx.emitReady(); ctx.connectHttp2(); }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-noise-ready", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 5_000, duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); // The gate skipped the echo, the partial frame, and the long prologue, // then accepted the READY frame, so the http2 transport serves and no // fallback fired. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback).toBeUndefined(); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("settles a wrong-nonce READY frame as a nonce mismatch, even after a noise line", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-noise-nonce-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // The gate skips the echo line, then reads a READY frame that decodes cleanly // but carries a wrong nonce. A wrong-nonce READY authenticates as a failure, // not as noise, so the gate settles the handshake failed and falls back with // the `ready_nonce_mismatch` reason. const { runner, control } = makeDuplexSelectionRunner((ctx) => { ctx.emitRaw("a non-frame echo line\n"); ctx.emitFrame({ version: 2, type: "ready", nonce: "00000000000000000000000000000000" }); }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-noise-nonce", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 5_000, duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); // The wrong nonce failed the handshake, so the file bridge serves. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback?.dimensions.fallback_reason).toBe("ready_nonce_mismatch"); // The bounded cleanup left no live provider session. expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("enforces the buffer cap on an over-cap blank prefix before it accepts a valid READY frame", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-capbypass-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // The cap must bound every pre-READY path, including a skipped blank line. An // adversarial provider sends one chunk: an over-cap blank prefix followed by a // valid nonce-bound READY frame. The gate must reject on the cap before READY // acceptance, so it falls back to the file bridge with the contaminated reason // and the bounded cleanup. Without the per-skip cap check the blank prefix // reaches the valid READY line in the same chunk, and the duplex transport // opens, which is the cap bypass. A single chunk keeps the trailing READY // newline in the buffer, so the no-newline cap check never fires here; only the // per-skip cap check stops the bypass. const readinessBufferCapBytes = DEFAULT_MAX_DUPLEX_FRAME_BYTES + 4_096; const { runner, control } = makeDuplexSelectionRunner((ctx) => { const readyLine = `${JSON.stringify({ version: 2, type: "ready", nonce: ctx.nonce })}\n`; ctx.emitRaw("\n".repeat(readinessBufferCapBytes + 1) + readyLine); }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-cap-bypass", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, // A long readiness timeout, so the cap, not the timeout, drives the failure. duplexReadinessTimeoutMs: 5_000, duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); // The cap drove the failure before READY acceptance, so the file bridge serves. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback?.dimensions.fallback_reason).toBe("contaminated"); // The bounded cleanup left no live provider session. expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("records the channel-open span with the fallback_reason dimension on the fallback path", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-openspan-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // A wrong-nonce READY frame fails the handshake, so the gate falls back. The // channel-open span records the failed attempt on the duplex transport and now // carries the closed `fallback_reason` dimension, so a reader can group the // failed opens by reason. const { runner } = makeDuplexSelectionRunner((ctx) => ctx.emitFrame({ version: 2, type: "ready", nonce: "00000000000000000000000000000000" }), ); const { recorder, spans } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-open-span", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 5_000, duplexObservabilityRecorder: recorder, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); const openSpan = spans.find((span) => span.name === DUPLEX_SPAN_CHANNEL_OPEN); expect(openSpan).toBeDefined(); expect(openSpan?.dimensions).toMatchObject({ provider: "daytona", transport: "http2", outcome: "error", fallback_reason: "ready_nonce_mismatch", }); // The span carries only closed dimension keys. assertOnlyFixedDimensionKeys(openSpan?.dimensions); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("drops a header outside the allowlist on the host http2 forward path", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-hdr-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-hdr", 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("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/agents/me", headers: { authorization: `Bearer ${bridgeToken}`, // An allowlisted header the host must keep. accept: "application/json", // A header outside the allowlist the host must drop. "x-injected-header": "attacker", }, }); expect(response.status).toBe(200); await waitForCondition(() => api.requests.length >= 1, "the host to forward the http2 request", 4000); const forwarded = api.requests[0]; // The allowlisted header reaches the host. expect(forwarded.headers.accept).toBe("application/json"); // The header outside the allowlist never reaches the authenticated fetch. expect(forwarded.headers["x-injected-header"]).toBeUndefined(); // The host applied the real token and the run id in place of the frame values. expect(forwarded.auth).toBe("Bearer real-run-jwt"); expect(forwarded.runId).toBe("run-hdr"); } finally { sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("selects the http2 transport for a large forward budget", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-budget-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; // A forward budget past the default response budget (32 s). The http2 path // holds no nested-budget derivation (that budget set belonged to the retired // duplex_v1 broker only), so a large forward budget must still select and // serve normally. const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-budget", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, forwardTimeoutMs: 60_000, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/agents/me", headers: { authorization: `Bearer ${bridgeToken}` }, }); expect(response.status).toBe(200); } finally { sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("test_a_missing_preface_aborts_the_open_and_falls_back_to_queue_v1", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-no-preface-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // The gateway sends a valid READY line, but no HTTP/2 client preface ever // follows (a suppressed or a stalled sandbox client). Readiness passes, // then the bounded preface scan finds nothing before its bound; the host // must abort the open and select the file bridge instead of hanging or // leaking the channel. const { runner, control } = makeHttp2SelectionRunner((ctx) => { ctx.emitReady(); // No connectHttp2() call: the client preface never arrives. }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-no-preface", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, // A short readiness timeout, so the preface-missing bound (which reapplies // this same value) fires quickly in the test. duplexReadinessTimeoutMs: 500, duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); // The missing preface aborted the open; the file bridge serves. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback?.dimensions.fallback_reason).toBe("preface_missing"); // The bounded cleanup left no live provider session. expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("test_disabled_flag_selects_queue_v1", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-disabled-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const { runner, control } = makeHttp2SelectionRunner(); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; // The kill switch is off. The host must never open the channel at all. const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-disabled", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: false, duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(0); expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback?.dimensions.fallback_reason).toBe("gate_off"); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("test_fallback_to_queue_v1_happens_at_most_once_per_run", async () => { // The open attempt runs once per run, with no retry loop: a preface // failure falls through to the file bridge exactly one time, and the // host never re-attempts http2_v1 afterward in the same run. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-one-way-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const { runner, control } = makeHttp2SelectionRunner((ctx) => { ctx.emitReady(); // No connectHttp2() call: the preface never arrives, so the open fails. }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-one-way", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 500, duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); // The host opened the channel exactly once for the whole run — no retry // loop re-attempted http2_v1 after the fallback. expect(control.openCount).toBe(1); expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); // Exactly one fallback record — the transition never repeats. const fallbacks = counters.filter((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallbacks).toHaveLength(1); expect(fallbacks[0]?.dimensions.fallback_reason).toBe("preface_missing"); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("test_the_host_writes_no_byte_before_the_ready_line_is_accepted", async () => { // The launch wrapper gives the gateway no start acknowledgment (accepted // fact from PAP-5065): a host byte written before the READY line is // accepted can reach the shell instead of the child. This test proves // the host writes nothing to the channel until well after it accepted // READY: it holds the channel open, with no client preface arriving // (so the http2 server, if bound, would try to write its own SETTINGS // frame), and asserts zero bytes crossed the channel the whole time. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-no-early-write-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const base = createLocalSandboxRunner(); const hostWrites: Buffer[] = []; let readyLineSent = false; const readySentAtMs = { value: 0 }; const openDuplexChannel = async (openInput: { command: readonly string[]; }): Promise => { const joined = openInput.command.join(" "); const nonce = /PAPERCLIP_BRIDGE_NONCE='([^']*)'/.exec(joined)?.[1] ?? ""; let dataListener: ((chunk: Uint8Array) => void) | null = null; const channel: CommandManagedDuplexChannel = { write: (data: Uint8Array) => { // Any host write before READY is accepted is exactly the fault // this test guards against. hostWrites.push(Buffer.from(data)); }, onData: (listener: (chunk: Uint8Array) => void) => { dataListener = listener; setImmediate(() => { readySentAtMs.value = Date.now(); readyLineSent = true; dataListener?.( new TextEncoder().encode(`${JSON.stringify({ version: 2, type: "ready", nonce })}\n`), ); // No client preface follows: the host must still write nothing // while it waits out the preface bound, proving the READY-accept // gate — not a timer — is what would unlock a host write. }); }, onExit: (_listener: (exit: { exitCode: number | null }) => void) => {}, stop: () => {}, close: async () => Promise.resolve(), }; return channel; }; const runner = { ...base, openDuplexChannel }; const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-no-early-write", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 300, }); try { expect(readyLineSent).toBe(true); // The preface never arrived, so the open fell back to the file bridge — // and across the whole open attempt, including the wait after READY, // the host wrote zero bytes to the channel. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(hostWrites).toHaveLength(0); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("test_invalid_ready_line_bytes_do_not_reach_a_log_line", async () => { // Fix 5: decode only the bounded READY line as strict protocol text, and // never log raw channel bytes. This test sends a pre-READY line whose // bytes are deliberately sentinel-marked and syntactically invalid (not // valid UTF-8 JSON), then a valid READY frame. No log line — on any // stream — may contain the sentinel bytes. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-invalid-ready-bytes-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const INVALID_LINE_SENTINEL = "sentinelinvalidreadyline62fa"; const logLines: string[] = []; const { runner } = makeHttp2SelectionRunner((ctx) => { // Invalid bytes: not valid UTF-8 (a lone continuation byte), immediately // followed by a sentinel and a newline — an invalid candidate line. ctx.emitRaw(Buffer.concat([Buffer.from([0x80]), Buffer.from(INVALID_LINE_SENTINEL), Buffer.from("\n")])); ctx.emitReady(); ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-invalid-ready-bytes", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, onLog: async (_stream, chunk) => { logLines.push(chunk); }, }); try { // The invalid line was skipped as noise, and READY still passed. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); const logDump = logLines.join(""); expect(logDump).not.toContain(INVALID_LINE_SENTINEL); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("test_close_after_orderly_completion_keeps_the_run_result", async () => { // A loss ordered after a host-observed orderly completion is a normal // teardown, not a failure: the run already completed. The disposition // latch must keep the success and emit no loss event for it. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-orderly-close-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); let emitExit: (() => void) | null = null; const { runner } = makeHttp2SelectionRunner((ctx) => { emitExit = ctx.emitExit; ctx.emitReady(); ctx.connectHttp2(); }); const { recorder, events, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-orderly-close", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexObservabilityRecorder: recorder, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); // The agent turn completes cleanly before the channel ends. expect(bridge?.settleRunDisposition?.()).toEqual({ failed: false, lossReason: null }); emitExit!(); await new Promise((resolve) => setTimeout(resolve, 100)); // The disposition still reports success after the teardown loss. expect(bridge?.readRunDisposition?.()).toEqual({ failed: false, lossReason: null }); // A normal teardown after completion is not a loss: no loss event, no // loss counter. expect(events.some((e) => e.dimensions.loss_reason !== undefined)).toBe(false); expect(counters.some((c) => c.metric === DUPLEX_COUNTER_LOSS_TOTAL)).toBe(false); } finally { await bridge?.stop(); await api.close(); } }, 20000); it("test_safe_request_during_channel_loss_stays_retryable", async () => { // A GET never changes host state, so a response-body read failure stays // retryable: the host answers 502 with no indeterminate marker, the same // rule `forwardBridgeRequest` already applies on every transport. This // proves the http2_v1 forward handler reuses that one function unchanged. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-safe-retry-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-safe-retry", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, // A ceiling far under any real response, so every response-body read // fails the size check deterministically. maxBodyBytes: 1, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/agents/me", headers: { authorization: `Bearer ${bridgeToken}` }, }); expect(response.status).toBe(502); expect(response.headers["x-paperclip-bridge-outcome"]).toBeUndefined(); } finally { sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("test_unsafe_request_during_channel_loss_is_indeterminate", async () => { // A POST may have committed on the host before the response-body read // failed, so a retry could double-apply it. The host answers a // non-retryable 504 with the indeterminate marker instead — the same // rule `forwardBridgeRequest` already applies on every transport. // // `maxBodyBytes: 1` below now bounds the request body too, since the // host and the gateway share one resolved ceiling: this request carries // no body, so only the mock host's own response — comfortably over one // byte — trips the size check this test exists to force. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-unsafe-indeterminate-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-unsafe-indeterminate", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, maxBodyBytes: 1, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "POST", path: "/api/issues/issue-1/comments", headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" }, }); expect(response.status).toBe(504); expect(response.headers["x-paperclip-bridge-outcome"]).toBe("indeterminate"); } finally { sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("aborts the host forward and its response-body read when the sandbox stream closes", async () => { // The mock host answers with headers at once, then holds the response // body open with no further chunk and no end. The read stays pending // until the outbound fetch itself aborts. If the abort never reaches // this connection, `hostConnectionClosed` never resolves and the test // times out instead of failing fast — the assertion is: it does resolve, // quickly, once the sandbox stream closes. let sawRequest = false; let resolveHostConnectionClosed: (() => void) | undefined; const hostConnectionClosed = new Promise((resolve) => { resolveHostConnectionClosed = resolve; }); const api = createServer((req, res) => { sawRequest = true; res.writeHead(200, { "content-type": "application/json" }); res.on("close", () => resolveHostConnectionClosed!()); }); await new Promise((resolve, reject) => { api.once("error", reject); api.listen(0, "127.0.0.1", () => resolve()); }); const apiAddress = api.address(); if (!apiAddress || typeof apiAddress === "string") { throw new Error("Expected the mock host server to listen on a TCP port."); } const apiOrigin = `http://127.0.0.1:${apiAddress.port}`; const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-abort-forward-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-abort-forward", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: apiOrigin, enableSandboxDuplexBridge: true, // Far longer than this test waits, so only the sandbox-side stream // close — not this ceiling — can end the forward here. forwardTimeoutMs: 60_000, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const clientStream = sessionRef.current!.request({ ":method": "GET", ":path": "/api/agents/me", authorization: `Bearer ${bridgeToken}`, }); clientStream.end(); await waitForCondition(() => sawRequest, "the mock host to receive the forwarded request", 4000); // The sandbox side closes its stream while the host forward and its // response-body read are both still in flight. clientStream.close(http2.constants.NGHTTP2_CANCEL); await hostConnectionClosed; } finally { sessionRef.current?.close(); await bridge?.stop(); await new Promise((resolve) => api.close(() => resolve())); } }, 20000); it("test_a_denied_response_chunk_cancels_the_reader_before_it_copies_the_chunk", async () => { // Fill the process ledger so only a sliver of headroom remains, then let // the mock host answer with a response chunk far bigger than that // sliver. `readBridgeForwardResponseBody` (`execution-target.ts`) must // deny that chunk's reservation, cancel its reader (closing the // outbound connection to the mock host), and retain no copy of it. resetBridgeBodyReservationsForTest(); const filler = createBridgeBodyReservation(); expect(filler.reserve(HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES - 100)).toBe(true); let resolveHostConnectionClosed: (() => void) | undefined; const hostConnectionClosed = new Promise((resolve) => { resolveHostConnectionClosed = resolve; }); const api = createServer((_req, res) => { res.writeHead(200, { "content-type": "application/octet-stream" }); // Far bigger than the 100 bytes of headroom the filler above left: // this one chunk alone must pass the process ceiling. res.write(Buffer.alloc(1_000, "a")); res.on("close", () => resolveHostConnectionClosed!()); }); await new Promise((resolve, reject) => { api.once("error", reject); api.listen(0, "127.0.0.1", () => resolve()); }); const apiAddress = api.address(); if (!apiAddress || typeof apiAddress === "string") { throw new Error("Expected the mock host server to listen on a TCP port."); } const apiOrigin = `http://127.0.0.1:${apiAddress.port}`; const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-denied-response-chunk-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-denied-response-chunk", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: apiOrigin, enableSandboxDuplexBridge: true, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); // A capacity denial reaches the client as the retryable 503 the // HTTP/2 bridge server's own capacity-denial path answers // (`forwardBridgeRequest` rethrows `BridgeProcessCapacityError` before // the method-safety classification runs), not the generic 502 that // classification would give any other response-body read fault. const response = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/agents/me", headers: { authorization: `Bearer ${bridgeToken}` }, }); expect(response.status).toBe(503); // The reader actually cancelled: the mock host observes its // connection close, instead of staying open with the chunk // unacknowledged. await hostConnectionClosed; } finally { sessionRef.current?.close(); await bridge?.stop(); await new Promise((resolve) => api.close(() => resolve())); // The denied chunk was never retained: releasing the filler is the // only release this test needs to reach zero. If the denied response // copy had reserved anything despite being denied, this would be // nonzero. filler.release(); expect(getBridgeBodyReservedBytesForTest()).toBe(0); } }, 20000); it("test_a_denied_concatenated_response_body_never_allocates_the_copy", async () => { // Leave room for the one response chunk but not for the second, // concatenated copy `readBridgeForwardResponseBody` (`execution-target.ts`) // builds from it: a correct reader checks the reservation before // `Buffer.concat` allocates the copy, so the denied concatenated copy // must never call `Buffer.concat` at all. The same denial must also // cancel the upstream response reader, instead of leaving it open after // the throw. resetBridgeBodyReservationsForTest(); const chunkBytes = 200_000; const filler = createBridgeBodyReservation(); expect(filler.reserve(HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES - Math.floor(chunkBytes * 1.5))).toBe(true); const api = createServer((_req, res) => { res.writeHead(200, { "content-type": "application/octet-stream" }); res.end(Buffer.alloc(chunkBytes, "a")); }); await new Promise((resolve, reject) => { api.once("error", reject); api.listen(0, "127.0.0.1", () => resolve()); }); const apiAddress = api.address(); if (!apiAddress || typeof apiAddress === "string") { throw new Error("Expected the mock host server to listen on a TCP port."); } const apiOrigin = `http://127.0.0.1:${apiAddress.port}`; const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-denied-response-concat-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-denied-response-concat", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: apiOrigin, enableSandboxDuplexBridge: true, }); const concatSpy = vi.spyOn(Buffer, "concat"); const readerCancelSpy = vi.spyOn(ReadableStreamDefaultReader.prototype, "cancel"); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); // A capacity denial must reach the client as the retryable 503 the // HTTP/2 bridge server's own capacity-denial path answers, not the // generic 502 the method-safety classification would otherwise apply // to any other response-body read fault (`forwardBridgeRequest` // rethrows `BridgeProcessCapacityError` before that classification // runs). This reads the response with a plain string accumulator, not // `Buffer.concat`, so the spy below counts only the calls the bridge // code under test makes. const response = await new Promise<{ status: number; body: string }>((resolve, reject) => { const stream = sessionRef.current!.request({ ":method": "GET", ":path": "/api/agents/me", authorization: `Bearer ${bridgeToken}`, }); let status = 0; let body = ""; stream.setEncoding("utf8"); stream.on("response", (headers) => { status = Number(headers[":status"]) || 0; }); stream.on("data", (chunk) => (body += chunk)); stream.once("end", () => resolve({ status, body })); stream.once("error", reject); stream.end(); }); expect(response.status).toBe(503); expect(JSON.parse(response.body)).toEqual({ error: "The bridge host reached its reserved process body byte ceiling. Retry later.", }); // The GET request itself carries no body, so the host's own read of // that empty request body still calls `Buffer.concat` on an empty // array — that call is unrelated to this test. No call may carry any // response byte, since the denied concatenated response copy must // never allocate. for (const [chunks] of concatSpy.mock.calls) { expect((chunks as Buffer[]).reduce((sum, chunk) => sum + chunk.length, 0)).toBe(0); } // The denied concatenated-body reservation must cancel the upstream // response reader before it throws, instead of leaving it open. expect(readerCancelSpy).toHaveBeenCalled(); } finally { readerCancelSpy.mockRestore(); concatSpy.mockRestore(); sessionRef.current?.close(); await bridge?.stop(); await new Promise((resolve) => api.close(() => resolve())); // The denied concatenated copy reserved nothing: releasing the filler // is the only release this test needs to reach zero. filler.release(); expect(getBridgeBodyReservedBytesForTest()).toBe(0); } }, 20000); it("test_a_denied_response_chunk_on_a_mutating_method_is_indeterminate_not_retryable", async () => { // A POST may already have committed on the host by the time the // response-body read hits the process capacity ceiling: the host // delivered response headers before the read even starts. Unlike the // safe-method case above, this denial must not reach the client as a // retryable 503 — a caller that retries would apply the mutation twice. // It must fall through to the same non-retryable indeterminate 504 any // other response-body read fault on a mutating method gets. resetBridgeBodyReservationsForTest(); const filler = createBridgeBodyReservation(); expect(filler.reserve(HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES - 100)).toBe(true); const api = createServer((_req, res) => { res.writeHead(200, { "content-type": "application/octet-stream" }); // Far bigger than the 100 bytes of headroom the filler above left. res.end(Buffer.alloc(1_000, "a")); }); await new Promise((resolve, reject) => { api.once("error", reject); api.listen(0, "127.0.0.1", () => resolve()); }); const apiAddress = api.address(); if (!apiAddress || typeof apiAddress === "string") { throw new Error("Expected the mock host server to listen on a TCP port."); } const apiOrigin = `http://127.0.0.1:${apiAddress.port}`; const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-denied-response-mutation-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-denied-response-mutation", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: apiOrigin, enableSandboxDuplexBridge: true, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "POST", path: "/api/issues/issue-1/comments", headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" }, }); expect(response.status).toBe(504); expect(response.headers["x-paperclip-bridge-outcome"]).toBe("indeterminate"); expect(JSON.parse(response.body.toString("utf8"))).toMatchObject({ outcome: "indeterminate", retryable: false, }); } finally { sessionRef.current?.close(); await bridge?.stop(); await new Promise((resolve) => api.close(() => resolve())); filler.release(); expect(getBridgeBodyReservedBytesForTest()).toBe(0); } }, 20000); it("test_concurrent_request_and_response_bodies_never_pass_the_process_ceiling", async () => { resetBridgeBodyReservationsForTest(); const bodyBytes = 2 * 1024 * 1024; const requestBody = Buffer.alloc(bodyBytes, "a"); const samples: number[] = []; const api = createServer((req, res) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("end", () => { // Sampled while this stream's own request-body copies are still // live and at least one sibling stream may also be mid-flight: the // real, concurrent, multi-stream shape this test exists to prove. samples.push(getBridgeBodyReservedBytesForTest()); res.writeHead(200, { "content-type": "application/octet-stream" }); res.end(Buffer.alloc(bodyBytes, "b")); }); }); await new Promise((resolve, reject) => { api.once("error", reject); api.listen(0, "127.0.0.1", () => resolve()); }); const apiAddress = api.address(); if (!apiAddress || typeof apiAddress === "string") { throw new Error("Expected the mock host server to listen on a TCP port."); } const apiOrigin = `http://127.0.0.1:${apiAddress.port}`; const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-ceiling-aggregate-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-ceiling-aggregate", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: apiOrigin, enableSandboxDuplexBridge: true, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const responses = await Promise.all( Array.from({ length: HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS }, () => http2TestRequest(sessionRef.current!, { method: "POST", path: "/api/issues/abc/comments", headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/octet-stream" }, body: requestBody, }), ), ); for (const response of responses) { expect(response.status).toBe(200); expect(response.body.byteLength).toBe(bodyBytes); } expect(samples).toHaveLength(HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS); for (const sample of samples) { expect(sample).toBeGreaterThan(0); expect(sample).toBeLessThanOrEqual(HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES); } // Every stream's owner released once its forward settled. await waitForCondition( () => getBridgeBodyReservedBytesForTest() === 0, "the process reservation total to return to zero", 2_000, ); } finally { sessionRef.current?.close(); await bridge?.stop(); await new Promise((resolve) => api.close(() => resolve())); } }, 20000); it("test_the_host_denies_a_body_over_the_resolved_limit_not_only_the_gateway", async () => { const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-host-body-limit-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-host-body-limit", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, // A ceiling far under the body this test sends, resolved for this // run. `createHttp2BridgeServer()` must receive this same resolved // value, so the host itself enforces it on the raw wire — this test // talks to the host directly, with no sandbox-side gateway script in // between to enforce anything on its own. maxBodyBytes: 100, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "POST", path: "/api/issues/abc/comments", headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" }, body: Buffer.alloc(1_000, "a"), }); // The oversized body never reaches the host API: the resolved ceiling // rejects it before any forward call runs, so the mock host records // no request. (The host's own size-violation path resets the stream // before it can write a status line, so the client sees no ordinary // response — this test asserts the one thing that channel does prove: // the forward call itself never ran.) expect(api.requests).toHaveLength(0); expect(response.status).not.toBe(200); } finally { sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("test_the_queue_transport_still_forwards_with_no_reservation_owner", async () => { // The queue transport's `handleRequest` callback // (`execution-target.ts`'s queue callback) calls `forwardBridgeRequest` // with no `reservation` option at all. `readBridgeForwardResponseBody` // must behave exactly as it did before that option existed: it still // forwards the request and still returns the host's body. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-queue-no-reservation-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); await mkdir(runtimeRootDir, { recursive: true }); const apiServer = createServer((req, res) => { res.writeHead(201, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true, echoedMethod: req.method })); }); await new Promise((resolve, reject) => { apiServer.once("error", reject); apiServer.listen(0, "127.0.0.1", () => resolve()); }); const address = apiServer.address(); if (!address || typeof address === "string") { throw new Error("Expected the queue-transport test API server to listen on a TCP port."); } const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "e2b", environmentId: "env-1", leaseId: "lease-1", remoteCwd, runner: createLocalSandboxRunner(), timeoutMs: 30_000, }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-queue-no-reservation", target, runtimeRootDir, adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: `http://127.0.0.1:${address.port}`, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/issues/abc/comments`, { method: "POST", headers: { authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ body: "hello" }), }); expect(response.status).toBe(201); expect(await response.json()).toEqual({ ok: true, echoedMethod: "POST" }); } finally { await bridge?.stop(); await new Promise((resolve) => apiServer.close(() => resolve())); } }); // --------------------------------------------------------------------------- // Real-PTY replay. // // The earlier PTY-echo defect shipped because a fake PTY does not echo the way // a real terminal does. These cases replay byte sequences captured from an // actual `pty.fork()` + bash session driven through the same launch wrapper the // Daytona plugin builds, so the gate is exercised against terminal output // rather than against synthetic frames. // --------------------------------------------------------------------------- async function runReadinessReplay(emit: (ctx: Http2OpenContext) => void) { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pty-replay-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const { runner, control } = makeHttp2SelectionRunner(emit); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", environmentId: "env-1", leaseId: "lease-1", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-pty-replay", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 2_000, }); const mode = bridge?.env.PAPERCLIP_API_BRIDGE_MODE; await bridge?.stop(); await api.close(); return { mode, control }; } // Case 1: the shape observed on a real Daytona PTY. bash echoes its prompt and // the wrapper line, terminated by CRLF, then the gateway's READY frame follows // on its own clean line. it("PTY replay: accepts READY after an echoed prompt and wrapper line", async () => { const { mode } = await runReadinessReplay((ctx) => { ctx.emitRaw( "daytona@212487a7f3c9:~$ exec 2>'/tmp/paperclip-duplex-x.log'; stty raw -echo; " + "exec 'bash' '-c' 'exec env PAPERCLIP_BRIDGE_NONCE=" + ctx.nonce + " node gateway.mjs'\r\n", ); ctx.emitRaw('{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); ctx.connectHttp2(); }); expect(mode).toBe("http2_v1"); }, 20000); // Case 2: captured from a local pty.fork() + bash on a host whose bash enables // bracketed paste. The disable sequence and a bare CR land immediately before // the READY frame, on the same line with no newline between them, so the whole // line does not decode. The Daytona image in use today does not do this; another // image or another provider can, and the gate must not depend on it. it("PTY replay: accepts READY prefixed by a bracketed-paste disable sequence", async () => { const { mode } = await runReadinessReplay((ctx) => { ctx.emitRaw( "\x1b[?2004h\x1b]0;user@host: /srv\x07user@host:/srv$ exec 2>'/tmp/d.log'; " + "stty raw -echo; exec 'bash' '-c' 'exec env node gateway.mjs'\r\n", ); // No newline between the escape sequence and the frame: same line. ctx.emitRaw('\x1b[?2004l\r{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); ctx.connectHttp2(); }); expect(mode).toBe("http2_v1"); }, 20000); // Case 3: the READY frame split across two chunk deliveries. it("PTY replay: accepts READY split across chunk boundaries", async () => { const { mode } = await runReadinessReplay((ctx) => { ctx.emitRaw("prompt$ wrapper-line\r\n"); const frame = '{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'; ctx.emitRaw(frame.slice(0, 12)); ctx.emitRaw(frame.slice(12)); ctx.connectHttp2(); }); expect(mode).toBe("http2_v1"); }, 20000); // Case 4: a multibyte character split across two chunks in the pre-READY noise. it("PTY replay: accepts READY when a multibyte char splits across chunks", async () => { const { mode } = await runReadinessReplay((ctx) => { const noise = Buffer.from("prompt ✓ done\r\n", "utf8"); ctx.emitRaw(noise.slice(0, 8).toString("utf8")); ctx.emitRaw(noise.slice(8).toString("utf8")); ctx.emitRaw('{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); ctx.connectHttp2(); }); expect(mode).toBe("http2_v1"); }, 20000); // Case 5: a version-2 line that once decoded as a `request` envelope frame // now decodes as `unknown_type`, because the codec no longer validates that // frame type. The gate treats it the same as any other non-READY line: skip // it and keep scanning. This holds for both a bare line (the frame starts at // offset 0, so the leading-prefix retry never runs) and a line where noise // comes before the frame on the same line (the retry runs, decodes the frame // part, and still does not find a READY type). Either way the valid READY // line that follows still authenticates. it("PTY replay: accepts READY after a bare and a prefixed former-frame line", async () => { const { mode } = await runReadinessReplay((ctx) => { const formerFrame = '{"version":2,"type":"request","id":"r-1","method":"GET","path":"/","query":"","headers":{},"bodyByteCount":0}'; ctx.emitRaw(`${formerFrame}\n`); // No newline between the prompt prefix and the former-frame line: same line. ctx.emitRaw(`prompt$ ${formerFrame}\n`); ctx.emitRaw('{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); ctx.connectHttp2(); }); expect(mode).toBe("http2_v1"); }, 20000); // Case 6: three lines the gate must reject without ending the handshake — a // wrong-version READY-shaped line, a READY line with a smuggled extra field, // and a same-line second-frame smuggling attempt (no newline between the two // JSON values, so the whole line fails to parse as one JSON value). Each one // fails the strict decode and the gate skips it, the same as any other // pre-READY noise; the valid READY line that follows still authenticates. it("PTY replay: accepts READY after a wrong-version, an extra-field, and a same-line smuggling attempt", async () => { const { mode } = await runReadinessReplay((ctx) => { ctx.emitRaw(`{"version":1,"type":"ready","nonce":"${ctx.nonce}"}\n`); ctx.emitRaw(`{"version":2,"type":"ready","nonce":"${ctx.nonce}","address":"http://127.0.0.1:1"}\n`); ctx.emitRaw( `{"version":2,"type":"ready","nonce":"${ctx.nonce}"}{"version":2,"type":"ready","nonce":"${ctx.nonce}"}\n`, ); ctx.emitRaw(`{"version":2,"type":"ready","nonce":"${ctx.nonce}"}\n`); ctx.connectHttp2(); }); expect(mode).toBe("http2_v1"); }, 20000); it("test_the_session_starts_at_the_client_preface_after_the_ready_line", async () => { // The gate retains every byte after the accepted READY line, and the host // starts the HTTP/2 session at the client preface offset inside that // retained suffix, inclusive — no byte before the preface reaches the // HTTP/2 server. A pre-preface byte would make the server report a // `PROTOCOL_ERROR`; this test proves the real session opens cleanly // instead, which only holds when the offset is exact. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-preface-offset-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; // The READY line and the client preface arrive in the same chunk, back // to back, with no gap — the exact production shape. ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-preface-offset", 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("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/agents/me", headers: { authorization: `Bearer ${bridgeToken}` }, }); expect(response.status).toBe(200); } finally { sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("test_a_preface_pattern_before_the_ready_line_does_not_start_a_session", async () => { // The scan window opens only after the gate accepts the READY line. A // banner byte can carry the same 24 octets before that line; a scan that // started earlier could match those bytes and start a session against // the shell. This test embeds the exact preface bytes in the pre-READY // noise, then proves a session still starts only at the REAL preface // that follows READY. const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-preface-lookalike-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; let bridgeToken = ""; const { runner, control } = makeHttp2SelectionRunner((ctx) => { bridgeToken = ctx.bridgeToken; // A pre-READY banner that happens to carry the literal preface bytes. ctx.emitRaw(Buffer.concat([Buffer.from("prompt$ "), HTTP2_TEST_CLIENT_PREFACE, Buffer.from("\r\n")])); ctx.emitReady(); sessionRef.current = ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-preface-lookalike", 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("http2_v1"); await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); const response = await http2TestRequest(sessionRef.current!, { method: "GET", path: "/api/agents/me", headers: { authorization: `Bearer ${bridgeToken}` }, }); expect(response.status).toBe(200); } finally { sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); // Case 5: a flood of short noise lines must fail closed at the cap rather than // scanning forever. This is the hole the cursor-based scan closes. it("PTY replay: fails closed on a pre-READY noise flood", async () => { const { mode } = await runReadinessReplay((ctx) => { const line = "x".repeat(64) + "\n"; for (let i = 0; i < 80_000; i += 1) ctx.emitRaw(line); ctx.emitRaw('{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); }); expect(mode).toBe("queue_v1"); }, 30000); }); // The names the embedded codec source declares. A test wraps the source and // reads these names back. The generated gateway never decodes, so the embedded // copy declares only the frame version and the encode function. interface EmbeddedCodec { encodeDuplexFrame: (frame: unknown) => string; DUPLEX_FRAME_VERSION: number; } // This describe block covers the zero-dependency codec every generated // gateway embeds (`DUPLEX_GATEWAY_CODEC_SOURCE`). The `http2_v1` gateway // embeds this same codec source to send its one READY line, so this coverage // stays live for the active transport. describe("embedded sandbox gateway codec", () => { it("encodes the READY frame to the exact byte-for-byte wire format", () => { // `JSON.stringify` writes object keys in insertion order, so this pins the // exact key order the gateway writes: version, then type, then nonce. A // reordered or reformatted call site would change the bytes on the wire // without failing a looser, parse-then-compare assertion. const codecFactory = new Function( `${getSandboxDuplexGatewayCodecSource()}\nreturn { encodeDuplexFrame, DUPLEX_FRAME_VERSION };`, ) as unknown as () => EmbeddedCodec; const codec = codecFactory(); expect(codec.DUPLEX_FRAME_VERSION).toBe(DUPLEX_FRAME_VERSION); const nonce = "fixed-test-nonce"; const encoded = codec.encodeDuplexFrame({ version: codec.DUPLEX_FRAME_VERSION, type: "ready", nonce }); expect(encoded).toBe(`{"version":2,"type":"ready","nonce":"${nonce}"}\n`); // The host encode side produces the same bytes for the same frame, so the // two copies stay wire compatible on the one frame the gateway still sends. expect(encoded).toBe(encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "ready", nonce })); }); }); /** Wait for the pending microtasks and macrotasks to settle. */ function flushMacrotasks(): Promise { return new Promise((resolve) => setImmediate(resolve)); } describe("sandbox target spec parse: enableSandboxDuplexBridge", () => { // The minimal serialized sandbox target the host stamps and the adapter parses. // A test overrides one field per case to prove the fail-closed parse. function serializedSandboxTarget(overrides: Record): Record { return { kind: "remote", transport: "sandbox", providerKey: "daytona", environmentId: "env-1", leaseId: "lease-1", remoteCwd: "/work", ...overrides, }; } it("reads the kill switch as a grant when the stamped field is true", () => { const parsed = parseAdapterExecutionTarget(serializedSandboxTarget({ enableSandboxDuplexBridge: true })); expect(parsed?.kind).toBe("remote"); if (parsed?.kind !== "remote" || parsed.transport !== "sandbox") { throw new Error("expected a sandbox execution target"); } expect(parsed.enableSandboxDuplexBridge).toBe(true); expect(adapterExecutionTargetEnablesSandboxDuplexBridge(parsed)).toBe(true); }); it("parses an absent field as no grant", () => { const parsed = parseAdapterExecutionTarget(serializedSandboxTarget({})); if (parsed?.kind !== "remote" || parsed.transport !== "sandbox") { throw new Error("expected a sandbox execution target"); } expect(parsed.enableSandboxDuplexBridge).toBe(false); expect(adapterExecutionTargetEnablesSandboxDuplexBridge(parsed)).toBe(false); }); it("parses a false field as no grant", () => { const parsed = parseAdapterExecutionTarget(serializedSandboxTarget({ enableSandboxDuplexBridge: false })); if (parsed?.kind !== "remote" || parsed.transport !== "sandbox") { throw new Error("expected a sandbox execution target"); } expect(parsed.enableSandboxDuplexBridge).toBe(false); expect(adapterExecutionTargetEnablesSandboxDuplexBridge(parsed)).toBe(false); }); it("fails closed on a non-boolean field", () => { // A string "true" is not the literal boolean true, so the parse never reads // it as a grant. This keeps a malformed round-trip on the file bridge. const parsed = parseAdapterExecutionTarget(serializedSandboxTarget({ enableSandboxDuplexBridge: "true" })); if (parsed?.kind !== "remote" || parsed.transport !== "sandbox") { throw new Error("expected a sandbox execution target"); } expect(parsed.enableSandboxDuplexBridge).toBe(false); expect(adapterExecutionTargetEnablesSandboxDuplexBridge(parsed)).toBe(false); }); it("returns false from the reader for a non-sandbox target", () => { const localTarget = parseAdapterExecutionTarget({ kind: "local", environmentId: "env-1", leaseId: "lease-1" }); expect(adapterExecutionTargetEnablesSandboxDuplexBridge(localTarget)).toBe(false); expect(adapterExecutionTargetEnablesSandboxDuplexBridge(null)).toBe(false); }); }); /** * A minimal run-disposition latch for the seam tests below. It reproduces the * same ordering rule the real bridge transport applies: the first ordered * loss or orderly completion latches the terminal disposition, and a later * call never overrides it. `emitExit` stands in for a transport-level channel * exit — the real transport treats every channel exit as a loss candidate, * mapped to the typed `provider_exit` reason. */ function createFakeBridgeTransport() { let lossOrdered = false; let lossReason: DuplexLossReason | null = null; let completionOrdered = false; const markOrderlyCompletion = (): void => { if (completionOrdered || lossOrdered) return; completionOrdered = true; }; return { get runDisposition() { return { failed: lossOrdered, lossReason }; }, settleRunDisposition() { markOrderlyCompletion(); return { failed: lossOrdered, lossReason }; }, markOrderlyCompletion, emitExit(): void { if (lossOrdered || completionOrdered) return; lossOrdered = true; lossReason = "provider_exit"; }, }; } describe("settleRunDisposition atomic read and mark", () => { it("marks the orderly completion and reports a success for a healthy channel", async () => { const broker = createFakeBridgeTransport(); // The one atomic step marks the orderly completion and reads the success. expect(broker.settleRunDisposition()).toEqual({ failed: false, lossReason: null }); // A later teardown loss orders after the mark, so it stays a normal teardown. broker.emitExit(); await flushMacrotasks(); expect(broker.runDisposition).toEqual({ failed: false, lossReason: null }); }); it("reports the failure and does not mark for a latched loss", async () => { const broker = createFakeBridgeTransport(); // A loss ordered before any orderly completion latches the failure. broker.emitExit(); await flushMacrotasks(); // The atomic step reads the failure and no-ops the mark, so a later // completion cannot clear the latch. expect(broker.settleRunDisposition()).toEqual({ failed: true, lossReason: "provider_exit" }); broker.markOrderlyCompletion(); expect(broker.runDisposition).toEqual({ failed: true, lossReason: "provider_exit" }); }); }); describe("CLI-lane run-disposition seam", () => { const CLEAN_RESULT = { exitCode: 0, signal: null, timedOut: false, stdout: "ok\n", stderr: "", pid: null, startedAt: "2026-08-22T00:00:00.000Z", } as const; function mockRunner(result: Record) { return { execute: vi.fn(async () => result) }; } function sandboxTarget(runner: unknown): AdapterSandboxExecutionTarget { return { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: "/workspace", timeoutMs: 30_000, runner, } as AdapterSandboxExecutionTarget; } it("fails a clean CLI completion closed when the bridge channel was lost mid-turn", async () => { const broker = createFakeBridgeTransport(); // The control channel dies mid-turn, before the CLI process exits. broker.emitExit(); await flushMacrotasks(); const runner = mockRunner({ ...CLEAN_RESULT }); const result = await runAdapterExecutionTargetProcess("run-cli-lost", sandboxTarget(runner), "agent-cli", [], { cwd: "/local", env: {}, timeoutSec: 5, graceSec: 1, onLog: async () => {}, settleRunDisposition: () => broker.settleRunDisposition(), }); // The lost channel overrides the clean exit to a failure with the typed code. expect(result.exitCode).toBe(1); expect(result.errorCode).toBe(DUPLEX_CHANNEL_LOST_ERROR_CODE); // The note names only the typed loss reason, not raw provider text. expect(result.stderr).toContain("provider_exit"); }); it("keeps a clean CLI completion a success when the channel stays healthy, and a teardown loss stays benign", async () => { const broker = createFakeBridgeTransport(); const runner = mockRunner({ ...CLEAN_RESULT }); const result = await runAdapterExecutionTargetProcess("run-cli-ok", sandboxTarget(runner), "agent-cli", [], { cwd: "/local", env: {}, timeoutSec: 5, graceSec: 1, onLog: async () => {}, settleRunDisposition: () => broker.settleRunDisposition(), }); expect(result.exitCode).toBe(0); expect(result.errorCode ?? null).toBeNull(); // The seam's atomic settle marked the orderly completion at agent // completion. A teardown loss ordered after it is a normal teardown, so the // run stays a success without any manual mark here. broker.emitExit(); await flushMacrotasks(); expect(broker.runDisposition.failed).toBe(false); }); it("keeps a clean CLI completion a success when the gateway exits during the run-log tail finish", async () => { const broker = createFakeBridgeTransport(); // A run-log tail whose finish emits a gateway exit. This reproduces the // race where the bridge gateway dies after the clean process completion but // before the host reads the disposition. The seam settles the disposition // synchronously before this finish await, so the mark orders first and the // teardown exit stays benign. const runLogTail: SandboxRunLogTailFactory = { create: () => ({ wrapCommand: (command, args) => ({ command, args }), start: () => {}, finish: async () => { broker.emitExit(); await flushMacrotasks(); }, abort: async () => {}, }), }; const runner = mockRunner({ ...CLEAN_RESULT }); const result = await runAdapterExecutionTargetProcess("run-cli-race", sandboxTarget(runner), "agent-cli", [], { cwd: "/local", env: {}, timeoutSec: 5, graceSec: 1, onLog: async () => {}, runLogTail, settleRunDisposition: () => broker.settleRunDisposition(), }); // The atomic settle at the completion boundary marked the orderly // completion before the finish await, so the gateway exit never latches a // false loss and the run stays a clean success. expect(result.exitCode).toBe(0); expect(result.errorCode ?? null).toBeNull(); expect(broker.runDisposition.failed).toBe(false); }); it("cannot clear the loss latch with a later completion", async () => { const broker = createFakeBridgeTransport(); // The loss latches before the CLI process exits. broker.emitExit(); await flushMacrotasks(); // A later orderly completion cannot clear the latch. broker.markOrderlyCompletion(); const runner = mockRunner({ ...CLEAN_RESULT }); const result = await runAdapterExecutionTargetProcess("run-cli-latch", sandboxTarget(runner), "agent-cli", [], { cwd: "/local", env: {}, timeoutSec: 5, graceSec: 1, onLog: async () => {}, settleRunDisposition: () => broker.settleRunDisposition(), }); expect(result.exitCode).toBe(1); expect(result.errorCode).toBe(DUPLEX_CHANNEL_LOST_ERROR_CODE); }); it("leaves an already-failed CLI result unchanged and never settles the disposition", async () => { const broker = createFakeBridgeTransport(); // The control channel is lost, but the process itself also exited non-zero. broker.emitExit(); await flushMacrotasks(); let settleCalls = 0; const runner = mockRunner({ ...CLEAN_RESULT, exitCode: 2, stderr: "boom\n" }); const result = await runAdapterExecutionTargetProcess("run-cli-failed", sandboxTarget(runner), "agent-cli", [], { cwd: "/local", env: {}, timeoutSec: 5, graceSec: 1, onLog: async () => {}, settleRunDisposition: () => { settleCalls += 1; return broker.settleRunDisposition(); }, }); // A non-zero exit is already a failure, so the seam leaves it unchanged and // reports no transport-level code. This is the same success-eligibility rule // the ACP lane applies. expect(result.exitCode).toBe(2); expect(result.errorCode ?? null).toBeNull(); expect(settleCalls).toBe(0); }); }); describe("duplex readiness gate replay-buffer reservation", () => { const READY_NONCE = "0123456789abcdef0123456789abcdef"; // A fake duplex channel the test drives directly. `control.emitData` re-enters // the data listener the gate bound at construction. `control.emitExit` re-enters // the exit listener. The fake records the stop and the close calls. function makeFakeReadinessChannel(): { channel: CommandManagedDuplexChannel; control: { stopCount: number; closeCount: number; written: string[]; emitData: (chunk: string) => void; emitExit: (exit: { exitCode: number | null }) => void; }; } { let dataListener: ((chunk: Uint8Array) => void) | null = null; let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; const control = { stopCount: 0, closeCount: 0, written: [] as string[], emitData: (chunk: string): void => dataListener?.(new TextEncoder().encode(chunk)), emitExit: (exit: { exitCode: number | null }): void => exitListener?.(exit), }; const channel: CommandManagedDuplexChannel = { write(data: Uint8Array): void { control.written.push(Buffer.from(data).toString("utf8")); }, onData(listener: (chunk: Uint8Array) => void): void { dataListener = listener; }, 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, control }; } function readyLine(): string { return `${JSON.stringify({ version: 2, type: "ready", nonce: READY_NONCE })}\n`; } it("drops the retained pre-READY buffer on READY acceptance", async () => { const { channel, control } = makeFakeReadinessChannel(); const gate = __duplexReadinessTesting.createReadinessGate(channel, { nonce: READY_NONCE, timeoutMs: 5_000, }); // A large pre-READY noise line and a large suffix arrive with the READY line // in one chunk. A sandbox controls every byte here. const noise = `${"n".repeat(4096)}\n`; const suffix = "s".repeat(2048); control.emitData(`${noise}${readyLine()}${suffix}`); expect((await gate.ready).ok).toBe(true); // The gate drops the pre-READY buffer, so the process no longer retains the // noise prefix. expect(gate.retainedReadinessBufferLength()).toBe(0); // The broker binds and replays the retained suffix. const replayed: string[] = []; gate.brokerChannel.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); expect(replayed).toEqual([suffix]); expect(gate.retainedReadinessBufferLength()).toBe(0); }); it("caps the post-READY replay buffer without the aggregate ledger", async () => { const { channel, control } = makeFakeReadinessChannel(); const readinessBufferCapBytes = DEFAULT_MAX_DUPLEX_FRAME_BYTES + 4_096; // No ledger: only the buffer's own direct byte cap can end the channel // here. This proves the cap holds even when no aggregate ledger is // present, unlike the ledger-only check this replaces. const gate = __duplexReadinessTesting.createReadinessGate(channel, { nonce: READY_NONCE, timeoutMs: 5_000, }); // The READY line arrives alone, so the pending suffix starts empty. control.emitData(readyLine()); expect((await gate.ready).ok).toBe(true); // A post-READY chunk one byte over the cap floods the replay buffer // before the broker binds. control.emitData("x".repeat(readinessBufferCapBytes + 1)); expect(gate.replayOverflowed()).toBe(true); expect(control.stopCount).toBe(1); // Binding the broker replays nothing, because the gate dropped the buffer. const replayed: string[] = []; gate.brokerChannel.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); expect(replayed).toEqual([]); }); }); describe("http2 preface scan post-preface replay buffer", () => { // The HTTP/2 client connection preface, 24 octets (RFC 9113, Section 3.4). // A test writes this literal, not the production constant, so the test // proves the real wire bytes match, not only that the two source files // agree on a name. const PREFACE = Buffer.from("505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", "hex"); // A fake duplex channel the test drives directly. `control.emitData` // re-enters the data listener the scan bound at construction. The fake // records the stop call, so a test proves an overflow fails closed. function makeFakeChannel(): { channel: CommandManagedDuplexChannel; control: { stopCount: number; emitData: (chunk: Buffer) => void }; } { let dataListener: ((chunk: Uint8Array) => void) | null = null; const control = { stopCount: 0, emitData: (chunk: Buffer): void => dataListener?.(chunk), }; const channel: CommandManagedDuplexChannel = { write(): void {}, onData(listener: (chunk: Uint8Array) => void): void { dataListener = listener; }, onExit(): void {}, stop(): void { control.stopCount += 1; }, close(): Promise { return Promise.resolve(); }, }; return { channel, control }; } it("rejects a single pre-preface chunk over the cap without reserving or concatenating it", async () => { const { channel, control } = makeFakeChannel(); const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { capBytes: 64, timeoutMs: 5_000, }); // One chunk, larger than the cap on its own, with no preface inside it. // The scan must reject it on its prospective length before the // `Buffer.concat` allocation. control.emitData(Buffer.from("x".repeat(128))); expect(await scan.settled).toBe("missing"); expect(scan.replayOverflowed()).toBe(false); }); it("rejects a pre-preface chunk that tips an already-buffered scan past the cap", async () => { const { channel, control } = makeFakeChannel(); const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { capBytes: 64, timeoutMs: 5_000, }); // The first chunk stays under the cap on its own, so the scan buffers it // while it keeps searching. const firstChunk = "n".repeat(40); control.emitData(Buffer.from(firstChunk)); // The second chunk, added to the first, passes the cap. The scan must // reject it before the concat that would grow the buffer past the cap, // and it must drop the already-buffered first chunk too. control.emitData(Buffer.from("n".repeat(40))); expect(await scan.settled).toBe("missing"); expect(scan.replayOverflowed()).toBe(false); }); it("fails closed and stops the channel when the post-preface buffer floods past the cap", async () => { const { channel, control } = makeFakeChannel(); const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { capBytes: 64, timeoutMs: 5_000, }); // The preface arrives alone, so the pending buffer starts empty. control.emitData(PREFACE); expect(await scan.settled).toBe("found"); expect(scan.replayOverflowed()).toBe(false); // A post-preface chunk larger than the cap floods the buffer before the // HTTP/2 server binds. The scan drops the buffer, stops the channel, and // fails closed — the same shape a missing preface fails closed. control.emitData(Buffer.from("x".repeat(128))); expect(scan.replayOverflowed()).toBe(true); expect(control.stopCount).toBe(1); // Binding the downstream listener replays nothing: the scan dropped the // buffer on the overflow. const replayed: string[] = []; scan.scanned.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); expect(replayed).toEqual([]); }); it("settles missing when the readiness timeout elapses with no preface found", async () => { const { channel, control } = makeFakeChannel(); const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { capBytes: 4_096, // A short bound, so the test does not wait out a production-sized one. timeoutMs: 20, }); // Partial, non-matching data arrives and the scan keeps searching. No // preface ever completes, so only the bound timeout settles the scan. const partial = "not-a-preface-and-never-will-be"; control.emitData(Buffer.from(partial)); expect(await scan.settled).toBe("missing"); }); it("finds a preface fragmented into many one-byte chunks", async () => { const { channel, control } = makeFakeChannel(); const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { capBytes: 4_096, timeoutMs: 5_000, }); // A slow sandbox socket can deliver the preface one byte at a time. The // scan must still find it and deliver the exact octets, the same as it // does for a preface that arrives in one chunk. for (const byte of PREFACE) { control.emitData(Buffer.from([byte])); } expect(await scan.settled).toBe("found"); expect(scan.replayOverflowed()).toBe(false); const replayed: string[] = []; scan.scanned.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); expect(replayed).toEqual([PREFACE.toString("utf8")]); }); it("bounds the pre-preface scan search and growth-copy work by the bytes received, across many one-byte fragments", async () => { const { channel, control } = makeFakeChannel(); __http2PrefaceScanTesting.resetScanSearchUnits(); __http2PrefaceScanTesting.resetScanBufferGrowthCopyUnits(); // An adversarial sandbox sends many one-byte fragments, none of them the // preface, right up to the cap boundary. A search that always restarts // from the beginning of the retained buffer re-examines the whole // buffer on every fragment; a one-copy-per-fragment append copies the // whole retained buffer on every fragment. Both are quadratic in the // fragment count. const capBytes = 2_048; const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { capBytes, timeoutMs: 5_000, }); for (let i = 0; i < capBytes; i += 1) { control.emitData(Buffer.from([0x2e])); // '.', never part of the preface } // One more one-byte fragment tips the retained buffer past the cap, so // the scan settles and this test can read the final work counts. control.emitData(Buffer.from([0x2e])); expect(await scan.settled).toBe("missing"); const searchUnits = __http2PrefaceScanTesting.readScanSearchUnits(); const growthCopyUnits = __http2PrefaceScanTesting.readScanBufferGrowthCopyUnits(); // Each one-byte fragment can re-examine at most the preface's 24 octets // of overlap (RFC 9113, Section 3.4), so the search work stays within a // small multiple of capBytes. Doubling growth copies a logarithmic // number of times, each at most the current buffer length, so the // growth-copy work stays within a small multiple of capBytes too. A // per-fragment full rescan or full copy is quadratic (about // capBytes^2 / 2), far above either bound. expect(searchUnits).toBeLessThanOrEqual(50 * capBytes); expect(growthCopyUnits).toBeLessThanOrEqual(4 * capBytes); }); it("bounds the post-preface replay buffer growth-copy work by the bytes received, across many one-byte fragments, and still enforces the cap", async () => { const { channel, control } = makeFakeChannel(); __http2PrefaceScanTesting.resetReplayBufferGrowthCopyUnits(); const capBytes = 2_048; const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { capBytes, timeoutMs: 5_000, }); control.emitData(PREFACE); expect(await scan.settled).toBe("found"); // Many one-byte post-preface fragments arrive before the HTTP/2 server // binds, right up to the cap boundary. A one-copy-per-fragment append // copies the whole retained buffer on every fragment; the fix must // instead grow by doubling. const postPrefaceBytes = capBytes - PREFACE.byteLength; for (let i = 0; i < postPrefaceBytes; i += 1) { control.emitData(Buffer.from([0x2e])); } const growthCopyUnits = __http2PrefaceScanTesting.readReplayBufferGrowthCopyUnits(); expect(growthCopyUnits).toBeLessThanOrEqual(4 * capBytes); // One more one-byte fragment tips the retained buffer past the cap. The // scan drops the buffer, stops the channel, and fails closed — the same // shape a missing preface fails closed. control.emitData(Buffer.from([0x2e])); expect(scan.replayOverflowed()).toBe(true); expect(control.stopCount).toBe(1); }); }); describe("EffectiveSandboxCapabilities deprecated alias", () => { it("still type-checks as EffectiveExecutionCapabilities", () => { // A type-level check, not a runtime one: this assignment fails to compile // if the alias drifts from the renamed interface. Keep it here so a later // removal of the alias is a deliberate act, not an accident. const snapshot: EffectiveExecutionCapabilities = { reusableLeases: false, nativeSyncIn: false, nativeSyncOut: false, persistentProcessSessions: false, independentControlCommands: false, incrementalSessionOutput: false, concurrentSyncOperations: false, duplexCommandStream: false, runnerWebSocketIngress: false, }; const aliased: EffectiveSandboxCapabilities = snapshot; expect(aliased).toEqual(snapshot); }); });