From 3789c7fec4f036e15ce3e1f4323e25200fa542a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Salvador=20Giron=C3=A8s?= Date: Wed, 3 Jun 2026 10:38:15 +0200 Subject: [PATCH] fix(ts): surface incomplete sandbox run instead of silent success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Sandbox.run()` streams `POST /api/v1/processes/run` and only sets `exitCode` when it sees an `exit_code`/`signal` event. If the stream ends without one — e.g. the sandbox is unreachable and the proxy closes the stream after a timeout — it returned `{ exitCode: -1, stdout: "", stderr: "" }` and never threw. Callers saw a fake success for a command that never ran. Track whether a terminal event arrived and throw `SandboxConnectionError` when it didn't. This matches the canonical behavior in the Rust CLI (`crates/cli/src/commands/sbx/exec.rs`), which treats a missing exit event as failure (`exit_code.unwrap_or(1)`). Co-Authored-By: Claude Opus 4.8 --- typescript/src/sandbox.ts | 14 +++++++++++++- typescript/tests/sandbox.test.ts | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/typescript/src/sandbox.ts b/typescript/src/sandbox.ts index 4eee955b..ac8a3252 100644 --- a/typescript/src/sandbox.ts +++ b/typescript/src/sandbox.ts @@ -4,7 +4,7 @@ import { Desktop, } from "./desktop.js"; import * as defaults from "./defaults.js"; -import { SandboxError } from "./errors.js"; +import { SandboxError, SandboxConnectionError } from "./errors.js"; import { type HttpRequestOptions, type Traced, HttpClient } from "./http.js"; import { type CheckpointOptions, @@ -719,6 +719,7 @@ export class Sandbox { const stdoutLines: string[] = []; const stderrLines: string[] = []; let exitCode = -1; + let sawExit = false; for await (const raw of parseSSEStream>(sseStream)) { if (typeof raw.line === "string") { @@ -728,6 +729,7 @@ export class Sandbox { stdoutLines.push(raw.line); } } else if ("exit_code" in raw || "signal" in raw) { + sawExit = true; if (typeof raw.exit_code === "number") { exitCode = raw.exit_code; } else if (typeof raw.signal === "number") { @@ -736,6 +738,16 @@ export class Sandbox { } } + // A completed run always terminates with an exit_code/signal event. If the + // stream ends without one, the command never finished — e.g. the sandbox was + // unreachable and the proxy closed the stream after a timeout. Surface that + // as an error instead of returning a misleading success with empty output. + if (!sawExit) { + throw new SandboxConnectionError( + `Command "${command}" did not complete: the process stream ended without an exit status.`, + ); + } + return Object.assign( { exitCode, stdout: stdoutLines.join("\n"), stderr: stderrLines.join("\n") }, { traceId }, diff --git a/typescript/tests/sandbox.test.ts b/typescript/tests/sandbox.test.ts index ead29048..7fd02264 100644 --- a/typescript/tests/sandbox.test.ts +++ b/typescript/tests/sandbox.test.ts @@ -87,6 +87,25 @@ describe("Sandbox", () => { await sbx.run("echo"); sbx.close(); }); + + it("throws when the stream ends without an exit event", async () => { + // Reproduces the unreachable-sandbox case: the proxy closes the run + // stream after a timeout without ever delivering an exit_code, so the + // command never actually executed. This must surface as an error rather + // than a silent success with empty output. + mockFetch((url) => { + if (url.includes("/api/v1/processes/run")) { + return sseResponse([{ pid: 42, started_at: 1700000000 }]); + } + return new Response("", { status: 404 }); + }); + + const sbx = makeSandbox(); + await expect(sbx.run("node", { args: ["-v"] })).rejects.toThrow( + /did not complete/, + ); + sbx.close(); + }); }); describe("listProcesses", () => {