diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index ce1d9f6faf2..779596ca6c3 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -211,7 +211,7 @@ describe("sandbox adapter execution targets", () => { elapsedMs: number; }; - async function runProxyWithInput(command: string, input: string): Promise { + async function runProxyWithInput(command: string, input: string, keepStdinOpen = false): Promise { const startedAt = performance.now(); const child = spawn(command, [], { stdio: ["pipe", "pipe", "pipe"] }); let stdout = ""; @@ -224,7 +224,8 @@ describe("sandbox adapter execution targets", () => { child.stderr.on("data", (chunk) => { stderr += chunk; }); - child.stdin.end(input); + if (keepStdinOpen) child.stdin.write(input); + else child.stdin.end(input); const code = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { child.kill("SIGKILL"); @@ -234,7 +235,7 @@ describe("sandbox adapter execution targets", () => { clearTimeout(timeout); reject(error); }); - child.on("exit", (exitCode) => { + child.on("close", (exitCode) => { clearTimeout(timeout); resolve(exitCode); }); @@ -929,6 +930,56 @@ describe("sandbox adapter execution targets", () => { } }); + it.each([ + { streamOutputViaSession: false, exitCode: 0 }, + { streamOutputViaSession: false, exitCode: 7 }, + { streamOutputViaSession: true, exitCode: 0 }, + { streamOutputViaSession: true, exitCode: 7 }, + { streamOutputViaSession: false, exitCode: null }, + { streamOutputViaSession: true, exitCode: null }, + ])("exits with stdin open after remote exit (stream=$streamOutputViaSession, code=$exitCode)", async ({ + streamOutputViaSession, + exitCode, + }) => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-open-stdin-")); + cleanupDirs.push(rootDir); + // ACP keeps stdin open while it waits for a handshake. A remote child can + // exit before replying; that must close the proxy and fail the handshake. + const output = "final output\n".repeat(16_384); + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "run-open-stdin", + target: { + kind: "remote", + transport: "sandbox", + providerKey: "local-test", + remoteCwd: rootDir, + runner: createLocalSandboxRunner(), + }, + runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + adapterKey: "acpx", + command: exitCode === null ? path.join(rootDir, "missing-agent") : process.execPath, + args: ["-e", `process.stdout.write("final output\\n".repeat(16_384)); process.stderr.write("final diagnostic\\n"); process.exitCode = ${exitCode};`], + cwd: rootDir, + env: {}, + timeoutSec: 10, + streamOutputViaSession, + }); + expect(bridge).not.toBeNull(); + try { + const result = await runProxyWithInput(bridge!.agentCommand, "initialize\n", true); + expect(result.code).toBe(exitCode ?? 1); + if (exitCode === null) { + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("ENOENT"); + } else { + expect(result.stdout).toBe(output); + expect(result.stderr).toBe("final diagnostic\n"); + } + } finally { + await bridge?.stop(); + } + }, 15_000); + 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); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 592de096379..14effb96412 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -2533,9 +2533,20 @@ let buffer = ""; let exiting = false; function send(message) { + if (exiting) return; socket.write(JSON.stringify({ token, ...message }) + "\\n"); } +function finish(code) { + if (exiting) return; + exiting = true; + process.exitCode = code; + // ACP keeps stdin open while waiting for a reply. Release that handle when + // the remote process stops, but let pending stdout/stderr writes drain. + process.stdin.destroy(); + socket.end(); +} + socket.on("connect", () => send({ type: "hello" })); process.stdin.on("data", (chunk) => send({ type: "stdin", data: Buffer.from(chunk).toString("base64") })); process.stdin.on("end", () => send({ type: "stdinEnd" })); @@ -2554,13 +2565,9 @@ socket.on("data", (chunk) => { (message.stream === "stderr" ? process.stderr : process.stdout).write(out); } else if (message.type === "error") { process.stderr.write(String(message.message || "Process session bridge failed.") + "\\n"); - exiting = true; - process.exitCode = 1; - socket.end(); + finish(1); } else if (message.type === "exit") { - exiting = true; - process.exitCode = typeof message.code === "number" ? message.code : 1; - socket.end(); + finish(typeof message.code === "number" ? message.code : 1); } } });