From 6c420b16d2c79e770f7512ff9f15f4b3595d91e3 Mon Sep 17 00:00:00 2001 From: John Lindquist Date: Tue, 31 Mar 2026 23:46:12 -0600 Subject: [PATCH] fix: handle empty response body from background commands in runCommand --- .../src/api-client/api-client.test.ts | 66 +++++++++++-- .../src/api-client/api-client.ts | 95 +++++++++++++------ 2 files changed, 126 insertions(+), 35 deletions(-) diff --git a/packages/vercel-sandbox/src/api-client/api-client.test.ts b/packages/vercel-sandbox/src/api-client/api-client.test.ts index f4246df2..074f6872 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.test.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.test.ts @@ -95,9 +95,7 @@ describe("APIClient", () => { expect.fail("Expected APIError to be thrown"); } catch (err) { expect(err).toBeInstanceOf(APIError); - expect(err.message).toBe( - "Status code 410 is not ok", - ); + expect(err.message).toBe("Status code 410 is not ok"); expect(err.json).toEqual({ error: "gone", }); @@ -270,14 +268,69 @@ describe("APIClient", () => { expect.fail("Expected APIError to be thrown"); } catch (err) { expect(err).toBeInstanceOf(APIError); - expect(err.message).toBe( - "Status code 410 is not ok", - ); + expect(err.message).toBe("Status code 410 is not ok"); expect(err.json).toEqual({ error: "gone", }); } }); + + it("returns synthetic command when non-wait response body is empty (background process)", async () => { + mockFetch.mockResolvedValue( + new Response("", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.runCommand({ + sessionId: "sbx_123", + command: "sleep", + args: ["10"], + env: {}, + sudo: false, + cwd: "/home", + }); + + expect(result.json.command.name).toBe("sleep"); + expect(result.json.command.args).toEqual(["10"]); + expect(result.json.command.sessionId).toBe("sbx_123"); + expect(result.json.command.cwd).toBe("/home"); + expect(result.json.command.exitCode).toBeNull(); + expect(result.text).toBe(""); + }); + + it("parses normal non-wait response correctly", async () => { + const commandData = { + command: { + id: "cmd_456", + name: "ls", + args: ["-la"], + cwd: "/", + sessionId: "sbx_123", + exitCode: null, + startedAt: 1, + }, + }; + + mockFetch.mockResolvedValue( + new Response(JSON.stringify(commandData), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const result = await client.runCommand({ + sessionId: "sbx_123", + command: "ls", + args: ["-la"], + env: {}, + sudo: false, + }); + + expect(result.json.command.id).toBe("cmd_456"); + expect(result.json.command.name).toBe("ls"); + }); }); describe("stopSession", () => { @@ -891,6 +944,5 @@ describe("APIClient", () => { JSON.stringify({ expiration: 0 }), ); }); - }); }); diff --git a/packages/vercel-sandbox/src/api-client/api-client.ts b/packages/vercel-sandbox/src/api-client/api-client.ts index d18ca9a4..5bc280f4 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.ts @@ -5,7 +5,7 @@ import { type RequestParams, } from "./base-client"; import { -type CommandFinishedData, + type CommandFinishedData, SessionAndRoutesResponse, SessionResponse, SessionsResponse, @@ -143,9 +143,12 @@ export class APIClient extends BaseClient { querystring = querystring ? `?${querystring}` : ""; return parseOrThrow( SessionAndRoutesResponse, - await this.request(`/v2/sandboxes/sessions/${params.sessionId}${querystring}`, { - signal: params.signal, - }), + await this.request( + `/v2/sandboxes/sessions/${params.sessionId}${querystring}`, + { + signal: params.signal, + }, + ), ); } @@ -284,9 +287,9 @@ export class APIClient extends BaseClient { return { command, finished }; } - return parseOrThrow( - CommandResponse, - await this.request(`/v2/sandboxes/sessions/${params.sessionId}/cmd`, { + const response = await this.request( + `/v2/sandboxes/sessions/${params.sessionId}/cmd`, + { method: "POST", body: JSON.stringify({ command: params.command, @@ -296,8 +299,32 @@ export class APIClient extends BaseClient { sudo: params.sudo, }), signal: params.signal, - }), + }, ); + + // Background commands (e.g. `cmd &`) may return 200 with an empty body. + // Clone the response so we can inspect the text without consuming it. + const cloned = response.clone(); + const text = await cloned.text(); + if (response.ok && text.trim() === "") { + return { + json: { + command: { + id: "", + name: params.command, + args: params.args, + cwd: params.cwd ?? "", + sessionId: params.sessionId, + exitCode: null, + startedAt: Date.now(), + }, + }, + response, + text: "", + }; + } + + return parseOrThrow(CommandResponse, response); } async getCommand(params: { @@ -343,11 +370,14 @@ export class APIClient extends BaseClient { }) { return parseOrThrow( EmptyResponse, - await this.request(`/v2/sandboxes/sessions/${params.sessionId}/fs/mkdir`, { - method: "POST", - body: JSON.stringify({ path: params.path, cwd: params.cwd }), - signal: params.signal, - }), + await this.request( + `/v2/sandboxes/sessions/${params.sessionId}/fs/mkdir`, + { + method: "POST", + body: JSON.stringify({ path: params.path, cwd: params.cwd }), + signal: params.signal, + }, + ), ); } @@ -359,15 +389,18 @@ export class APIClient extends BaseClient { const writer = new FileWriter(); return { response: (async () => { - return this.request(`/v2/sandboxes/sessions/${params.sessionId}/fs/write`, { - method: "POST", - headers: { - "content-type": "application/gzip", - "x-cwd": params.extractDir, + return this.request( + `/v2/sandboxes/sessions/${params.sessionId}/fs/write`, + { + method: "POST", + headers: { + "content-type": "application/gzip", + "x-cwd": params.extractDir, + }, + body: await consumeReadable(writer.readable), + signal: params.signal, }, - body: await consumeReadable(writer.readable), - signal: params.signal, - }); + ); })(), writer, }; @@ -613,7 +646,11 @@ export class APIClient extends BaseClient { if (params.blocking) { let session = response.json.session; - while (session.status !== "stopped" && session.status !== "failed" && session.status !== "aborted") { + while ( + session.status !== "stopped" && + session.status !== "failed" && + session.status !== "aborted" + ) { await setTimeout(500, undefined, { signal: params.signal }); const poll = await this.getSession({ sessionId: params.sessionId, @@ -701,12 +738,14 @@ export class APIClient extends BaseClient { ); } - async getSandbox(params: WithPrivate<{ - name: string; - projectId: string; - resume?: boolean; - signal?: AbortSignal; - }>) { + async getSandbox( + params: WithPrivate<{ + name: string; + projectId: string; + resume?: boolean; + signal?: AbortSignal; + }>, + ) { const privateParams = getPrivateParams(params); const query: Record = { projectId: params.projectId,