From 37ae86a8cb5b2dd7df603bce09d538feb44b6387 Mon Sep 17 00:00:00 2001 From: Devin Date: Mon, 6 Apr 2026 21:37:10 -0700 Subject: [PATCH 1/3] runAs update --- README.md | 8 +- src/sandbox/process.ts | 33 +++++++- src/services/sandboxes.ts | 19 +++-- src/types/index.ts | 1 + src/types/sandbox.ts | 3 + tests/sandbox/e2e/process-api.test.ts | 117 ++++++++++++++++++++++++++ 6 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 tests/sandbox/e2e/process-api.test.ts diff --git a/README.md b/README.md index a3ce818..3967445 100644 --- a/README.md +++ b/README.md @@ -192,9 +192,11 @@ const main = async () => { await sandbox.files.writeText("/tmp/watch-demo.txt", "watch me"); await watch.stop(); - const proc = await sandbox.processes.start({ - command: "bash", - args: ["-lc", "echo process-started && sleep 1 && echo process-finished"], + const proc = await sandbox.processes.start( + "echo process-started && sleep 1 && echo process-finished", + { + runAs: "root", + } }); for await (const event of proc.stream()) { diff --git a/src/sandbox/process.ts b/src/sandbox/process.ts index 45de3d0..f4f1a9d 100644 --- a/src/sandbox/process.ts +++ b/src/sandbox/process.ts @@ -1,6 +1,7 @@ import { RuntimeSSEEvent, RuntimeTransport } from "./base"; import { SandboxExecParams, + SandboxExecOptions, SandboxProcessListParams, SandboxProcessListResponse, SandboxProcessResult, @@ -124,9 +125,21 @@ const buildProcessPayload = (input: SandboxExecParams) => ({ env: input.env, timeoutMs: input.timeoutMs, timeout_sec: input.timeoutSec, + runAs: input.runAs, useShell: input.useShell, }); +const normalizeExecParams = ( + input: string | SandboxExecParams, + options?: SandboxExecOptions +): SandboxExecParams => + typeof input === "string" + ? { + command: input, + ...options, + } + : input; + const encodeStdinPayload = (input: SandboxProcessStdinParams) => { if (input.data === undefined) { return { @@ -283,10 +296,16 @@ export class SandboxProcessHandle { export class SandboxProcessesApi { constructor(private readonly transport: RuntimeTransport) {} - async exec(input: SandboxExecParams): Promise { + async exec(command: string, options?: SandboxExecOptions): Promise; + async exec(input: SandboxExecParams): Promise; + async exec( + input: string | SandboxExecParams, + options?: SandboxExecOptions + ): Promise { + const params = normalizeExecParams(input, options); const response = await this.transport.requestJSON("/sandbox/exec", { method: "POST", - body: JSON.stringify(buildProcessPayload(input)), + body: JSON.stringify(buildProcessPayload(params)), headers: { "content-type": "application/json", }, @@ -295,10 +314,16 @@ export class SandboxProcessesApi { return normalizeProcessResult(response.result); } - async start(input: SandboxExecParams): Promise { + async start(command: string, options?: SandboxExecOptions): Promise; + async start(input: SandboxExecParams): Promise; + async start( + input: string | SandboxExecParams, + options?: SandboxExecOptions + ): Promise { + const params = normalizeExecParams(input, options); const response = await this.transport.requestJSON("/sandbox/processes", { method: "POST", - body: JSON.stringify(buildProcessPayload(input)), + body: JSON.stringify(buildProcessPayload(params)), headers: { "content-type": "application/json", }, diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 15990c0..2a563b2 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -11,6 +11,7 @@ import { SandboxExposeParams, SandboxExposeResult, SandboxExecParams, + SandboxExecOptions, SandboxImageListResponse, SandboxListParams, SandboxListResponse, @@ -276,15 +277,17 @@ export class SandboxHandle { return buildSandboxExposedUrl(this.runtime, port); } - async exec(input: string | SandboxExecParams): Promise { - const params = - typeof input === "string" - ? { - command: input, - } - : input; + async exec(input: string, options?: SandboxExecOptions): Promise; + async exec(input: SandboxExecParams): Promise; + async exec( + input: string | SandboxExecParams, + options?: SandboxExecOptions + ): Promise { + if (typeof input === "string") { + return this.processes.exec(input, options); + } - return this.processes.exec(params); + return this.processes.exec(input); } async getProcess(processId: string): Promise { diff --git a/src/types/index.ts b/src/types/index.ts index 930a478..90c9efd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -139,6 +139,7 @@ export { SandboxUnexposeResult, SandboxProcessStatus, SandboxExecParams, + SandboxExecOptions, SandboxProcessSummary, SandboxProcessResult, SandboxProcessListParams, diff --git a/src/types/sandbox.ts b/src/types/sandbox.ts index 30ead58..4307552 100644 --- a/src/types/sandbox.ts +++ b/src/types/sandbox.ts @@ -180,9 +180,12 @@ export interface SandboxExecParams { env?: Record; timeoutMs?: number; timeoutSec?: number; + runAs?: string; useShell?: boolean; } +export type SandboxExecOptions = Omit; + export interface SandboxProcessSummary { id: string; status: SandboxProcessStatus; diff --git a/tests/sandbox/e2e/process-api.test.ts b/tests/sandbox/e2e/process-api.test.ts new file mode 100644 index 0000000..ebf38a3 --- /dev/null +++ b/tests/sandbox/e2e/process-api.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test, vi } from "vitest"; +import type { RuntimeTransport } from "../../../src/sandbox/base"; +import { SandboxProcessesApi } from "../../../src/sandbox/process"; +import { SandboxHandle } from "../../../src/services/sandboxes"; + +const execResponse = { + result: { + id: "proc_exec", + status: "exited" as const, + exit_code: 0, + stdout: "ok\n", + stderr: "", + started_at: 1, + completed_at: 2, + }, +}; + +const startResponse = { + process: { + id: "proc_start", + status: "running" as const, + command: "sleep 30", + cwd: "/tmp", + started_at: 1, + }, +}; + +describe("sandbox process api", () => { + test("exec string overload forwards runAs in the runtime payload", async () => { + const requestJSON = vi + .fn() + .mockResolvedValueOnce(execResponse) + .mockResolvedValueOnce(startResponse); + const transport = { + requestJSON, + } as unknown as RuntimeTransport; + const api = new SandboxProcessesApi(transport); + + await api.exec("whoami", { + cwd: "/tmp", + env: { FOO: "bar" }, + timeoutMs: 5_000, + runAs: "root", + }); + await api.start("sleep 30", { + cwd: "/tmp", + runAs: "root", + }); + + expect(requestJSON).toHaveBeenNthCalledWith( + 1, + "/sandbox/exec", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + command: "whoami", + cwd: "/tmp", + env: { FOO: "bar" }, + timeoutMs: 5_000, + runAs: "root", + }), + }) + ); + expect(requestJSON).toHaveBeenNthCalledWith( + 2, + "/sandbox/processes", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + command: "sleep 30", + cwd: "/tmp", + runAs: "root", + }), + }) + ); + }); + + test("exec object form preserves runAs in the runtime payload", async () => { + const requestJSON = vi.fn().mockResolvedValue(execResponse); + const transport = { + requestJSON, + } as unknown as RuntimeTransport; + const api = new SandboxProcessesApi(transport); + + await api.exec({ + command: "whoami", + runAs: "root", + timeoutSec: 5, + }); + + expect(requestJSON).toHaveBeenCalledWith( + "/sandbox/exec", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + command: "whoami", + timeout_sec: 5, + runAs: "root", + }), + }) + ); + }); + + test("sandbox handle exec forwards string options to processes.exec", async () => { + const exec = vi.fn().mockResolvedValue(execResponse.result); + + await SandboxHandle.prototype.exec.call( + { + processes: { exec }, + }, + "whoami", + { runAs: "root" } + ); + + expect(exec).toHaveBeenCalledWith("whoami", { runAs: "root" }); + }); +}); From c64bd47a8d585570057ec9504430b1e837cff372 Mon Sep 17 00:00:00 2001 From: Devin Deng Date: Tue, 7 Apr 2026 18:11:15 +0000 Subject: [PATCH 2/3] update param passing --- src/sandbox/files.ts | 69 +++++-- src/sandbox/process.ts | 34 +++- src/types/sandbox.ts | 2 + tests/load-env.ts | 5 + tests/sandbox/e2e/files.test.ts | 283 ++++++++++++++------------ tests/sandbox/e2e/lifecycle.test.ts | 59 +++++- tests/sandbox/e2e/process-api.test.ts | 40 ++++ tests/sandbox/e2e/process.test.ts | 10 + 8 files changed, 336 insertions(+), 166 deletions(-) diff --git a/src/sandbox/files.ts b/src/sandbox/files.ts index 9ea927c..81858b1 100644 --- a/src/sandbox/files.ts +++ b/src/sandbox/files.ts @@ -406,9 +406,20 @@ export class SandboxFilesApi { constructor( private readonly transport: RuntimeTransport, private readonly getConnectionInfo: () => Promise, - private readonly runtimeProxyOverride?: string + private readonly runtimeProxyOverride?: string, + private readonly defaultRunAs?: string ) {} + withRunAs(runAs?: string): SandboxFilesApi { + const normalized = runAs?.trim(); + return new SandboxFilesApi( + this.transport, + this.getConnectionInfo, + this.runtimeProxyOverride, + normalized ? normalized : undefined + ); + } + async exists(path: string): Promise { try { await this.getInfo(path); @@ -428,7 +439,7 @@ export class SandboxFilesApi { const response = await this.transport.requestJSON( "/sandbox/files/stat", undefined, - { path } + this.withRunAsQuery({ path }) ); return normalizeFileInfo(response.file); } @@ -441,10 +452,10 @@ export class SandboxFilesApi { const response = await this.transport.requestJSON( "/sandbox/files", undefined, - { + this.withRunAsQuery({ path, depth: options.depth ?? 1, - } + }) ); return response.entries.map(normalizeFileInfo); @@ -535,7 +546,7 @@ export class SandboxFilesApi { "/sandbox/files/write", { method: "POST", - body: JSON.stringify({ files: encodedFiles }), + body: this.withRunAsBody({ files: encodedFiles }), headers: { "content-type": "application/json", }, @@ -577,7 +588,7 @@ export class SandboxFilesApi { method: "PUT", body, }, - { path } + this.withRunAsQuery({ path }) ); return { @@ -588,7 +599,7 @@ export class SandboxFilesApi { async download(path: string): Promise { return this.transport.requestBuffer("/sandbox/files/download", undefined, { - path, + ...this.withRunAsQuery({ path }), }); } @@ -597,7 +608,7 @@ export class SandboxFilesApi { "/sandbox/files/mkdir", { method: "POST", - body: JSON.stringify({ + body: this.withRunAsBody({ path, parents: options.parents, mode: options.mode, @@ -616,7 +627,7 @@ export class SandboxFilesApi { "/sandbox/files/move", { method: "POST", - body: JSON.stringify({ + body: this.withRunAsBody({ from: oldPath, to: newPath, }), @@ -632,7 +643,7 @@ export class SandboxFilesApi { async remove(path: string, options: { recursive?: boolean } = {}): Promise { await this.transport.requestJSON("/sandbox/files/delete", { method: "POST", - body: JSON.stringify({ + body: this.withRunAsBody({ path, recursive: options.recursive, }), @@ -647,7 +658,7 @@ export class SandboxFilesApi { "/sandbox/files/copy", { method: "POST", - body: JSON.stringify({ + body: this.withRunAsBody({ from: params.source, to: params.destination, recursive: params.recursive, @@ -665,7 +676,7 @@ export class SandboxFilesApi { async chmod(params: SandboxFileChmodParams): Promise { await this.transport.requestJSON<{ success: boolean }>("/sandbox/files/chmod", { method: "POST", - body: JSON.stringify(params), + body: this.withRunAsBody(params), headers: { "content-type": "application/json", }, @@ -675,7 +686,7 @@ export class SandboxFilesApi { async chown(params: SandboxFileChownParams): Promise { await this.transport.requestJSON<{ success: boolean }>("/sandbox/files/chown", { method: "POST", - body: JSON.stringify(params), + body: this.withRunAsBody(params), headers: { "content-type": "application/json", }, @@ -691,7 +702,7 @@ export class SandboxFilesApi { "/sandbox/files/watch", { method: "POST", - body: JSON.stringify({ + body: this.withRunAsBody({ path, recursive: options.recursive, }), @@ -717,7 +728,7 @@ export class SandboxFilesApi { ): Promise { return this.transport.requestJSON("/sandbox/files/presign-upload", { method: "POST", - body: JSON.stringify({ + body: this.withRunAsBody({ path, expiresInSeconds: options.expiresInSeconds, oneTime: options.oneTime, @@ -734,7 +745,7 @@ export class SandboxFilesApi { ): Promise { return this.transport.requestJSON("/sandbox/files/presign-download", { method: "POST", - body: JSON.stringify({ + body: this.withRunAsBody({ path, expiresInSeconds: options.expiresInSeconds, oneTime: options.oneTime, @@ -752,7 +763,7 @@ export class SandboxFilesApi { ): Promise { return this.transport.requestJSON("/sandbox/files/read", { method: "POST", - body: JSON.stringify({ + body: this.withRunAsBody({ path, offset: options.offset, length: options.length, @@ -774,7 +785,7 @@ export class SandboxFilesApi { "/sandbox/files/write", { method: "POST", - body: JSON.stringify({ + body: this.withRunAsBody({ path, data, encoding, @@ -789,4 +800,26 @@ export class SandboxFilesApi { return normalizeWriteInfo(response.files[0]!); } + + private withRunAsQuery>( + params: T + ): T & { runAs?: string } { + if (!this.defaultRunAs) { + return params; + } + return { + ...params, + runAs: this.defaultRunAs, + }; + } + + private withRunAsBody(body: T): string { + if (!this.defaultRunAs) { + return JSON.stringify(body); + } + return JSON.stringify({ + ...body, + runAs: this.defaultRunAs, + }); + } } diff --git a/src/sandbox/process.ts b/src/sandbox/process.ts index f4f1a9d..d52c9ee 100644 --- a/src/sandbox/process.ts +++ b/src/sandbox/process.ts @@ -57,6 +57,7 @@ interface StartProcessResponse { } const DEFAULT_PROCESS_KILL_WAIT_MS = 5_000; +const SHELL_SAFE_TOKEN_PATTERN = /^[A-Za-z0-9_@%+=:,./-]+$/; const normalizeProcessSummary = (process: RawProcessSummary): SandboxProcessSummary => ({ id: process.id, @@ -118,15 +119,38 @@ const normalizeStreamEvent = (event: RuntimeSSEEvent): SandboxProcessStreamEvent return null; }; +const quoteShellToken = (token: string): string => { + if (token.length === 0) { + return "''"; + } + + return SHELL_SAFE_TOKEN_PATTERN.test(token) + ? token + : `'${token.replace(/'/g, `'\"'\"'`)}'`; +}; + +const buildShellCommand = (command: string, args?: string[]): string => { + if (!args || args.length === 0) { + return command; + } + + return [command, ...args].map((token) => quoteShellToken(token)).join(" "); +}; + +const normalizeLegacyProcessParams = (input: SandboxExecParams): SandboxExecParams => ({ + ...input, + command: buildShellCommand(input.command, input.args), + args: undefined, + useShell: undefined, +}); + const buildProcessPayload = (input: SandboxExecParams) => ({ command: input.command, - args: input.args, cwd: input.cwd, env: input.env, timeoutMs: input.timeoutMs, timeout_sec: input.timeoutSec, runAs: input.runAs, - useShell: input.useShell, }); const normalizeExecParams = ( @@ -134,11 +158,11 @@ const normalizeExecParams = ( options?: SandboxExecOptions ): SandboxExecParams => typeof input === "string" - ? { + ? normalizeLegacyProcessParams({ command: input, ...options, - } - : input; + }) + : normalizeLegacyProcessParams(input); const encodeStdinPayload = (input: SandboxProcessStdinParams) => { if (input.data === undefined) { diff --git a/src/types/sandbox.ts b/src/types/sandbox.ts index 4307552..7ace3b9 100644 --- a/src/types/sandbox.ts +++ b/src/types/sandbox.ts @@ -175,12 +175,14 @@ export type SandboxProcessStatus = export interface SandboxExecParams { command: string; + /** @deprecated Legacy compatibility only. Converted into a single shell command string. */ args?: string[]; cwd?: string; env?: Record; timeoutMs?: number; timeoutSec?: number; runAs?: string; + /** @deprecated Ignored for process APIs. Commands always execute via `/bin/sh -lc` server-side. */ useShell?: boolean; } diff --git a/tests/load-env.ts b/tests/load-env.ts index e0d8c95..395f184 100644 --- a/tests/load-env.ts +++ b/tests/load-env.ts @@ -5,3 +5,8 @@ dotenv.config({ path: path.resolve(__dirname, ".env"), quiet: true, }); + +dotenv.config({ + path: path.resolve(__dirname, "..", ".env"), + quiet: true, +}); diff --git a/tests/sandbox/e2e/files.test.ts b/tests/sandbox/e2e/files.test.ts index eaefe6f..a033b9f 100644 --- a/tests/sandbox/e2e/files.test.ts +++ b/tests/sandbox/e2e/files.test.ts @@ -78,6 +78,7 @@ const createParentSymlinkEscapeFixture = async ( const linkDir = `${allowedDir}/evil`; const escapedFile = `${linkDir}/secret.txt`; const setup = await sandbox.exec({ + runAs: "root", command: "bash", args: [ "-lc", @@ -102,11 +103,13 @@ const createParentSymlinkEscapeFixture = async ( describe.sequential("sandbox filesystem e2e", () => { let sandbox: SandboxHandle | null = null; + let files: SandboxHandle["files"]; const baseDir = `/tmp/${testName("sdk-files")}`; beforeAll(async () => { sandbox = await client.sandboxes.create(defaultSandboxParams("sdk-files")); await waitForRuntimeReady(sandbox); + files = sandbox.files.withRunAs("root"); }); afterAll(async () => { @@ -114,21 +117,21 @@ describe.sequential("sandbox filesystem e2e", () => { }); test("exists returns false for a missing path", async () => { - const exists = await sandbox!.files.exists(`${baseDir}/missing.txt`); + const exists = await files.exists(`${baseDir}/missing.txt`); expect(exists).toBe(false); }); test("makeDir reports whether it created the directory", async () => { const path = `${baseDir}/dirs/root`; - expect(await sandbox!.files.makeDir(path)).toBe(true); - expect(await sandbox!.files.makeDir(path)).toBe(false); + expect(await files.makeDir(path)).toBe(true); + expect(await files.makeDir(path)).toBe(false); }); test("getInfo returns rich metadata for files", async () => { const path = `${baseDir}/info/hello.txt`; - await sandbox!.files.writeText(path, "hello from sdk files"); + await files.writeText(path, "hello from sdk files"); - const info = await sandbox!.files.getInfo(path); + const info = await files.getInfo(path); expect(info.name).toBe("hello.txt"); expect(info.path).toBe(path); expect(info.type).toBe("file"); @@ -142,16 +145,16 @@ describe.sequential("sandbox filesystem e2e", () => { test("list honors depth and returns rich metadata", async () => { const dir = `${baseDir}/list`; - await sandbox!.files.makeDir(`${dir}/nested/inner`); - await sandbox!.files.writeText(`${dir}/root.txt`, "root"); - await sandbox!.files.writeText(`${dir}/nested/child.txt`, "child"); - await sandbox!.files.writeText(`${dir}/nested/inner/grandchild.txt`, "grandchild"); + await files.makeDir(`${dir}/nested/inner`); + await files.writeText(`${dir}/root.txt`, "root"); + await files.writeText(`${dir}/nested/child.txt`, "child"); + await files.writeText(`${dir}/nested/inner/grandchild.txt`, "grandchild"); - const depthOne = await sandbox!.files.list(dir, { depth: 1 }); + const depthOne = await files.list(dir, { depth: 1 }); expect(depthOne.map((entry) => entry.name)).toEqual(["nested", "root.txt"]); expect(depthOne.map((entry) => entry.type)).toEqual(["dir", "file"]); - const depthTwo = await sandbox!.files.list(dir, { depth: 2 }); + const depthTwo = await files.list(dir, { depth: 2 }); expect(depthTwo.map((entry) => entry.path)).toEqual([ `${dir}/nested`, `${dir}/nested/child.txt`, @@ -164,15 +167,16 @@ describe.sequential("sandbox filesystem e2e", () => { const dir = `${baseDir}/list-symlink`; const target = `${dir}/target.txt`; const link = `${dir}/link.txt`; - await sandbox!.files.makeDir(dir); - await sandbox!.files.writeText(target, "payload"); + await files.makeDir(dir); + await files.writeText(target, "payload"); const result = await sandbox!.exec({ + runAs: "root", command: "bash", args: ["-lc", `ln -sfn \"${target}\" \"${link}\"`], }); expect(result.exitCode).toBe(0); - const entries = await sandbox!.files.list(dir, { depth: 1 }); + const entries = await files.list(dir, { depth: 1 }); const linkEntry = entries.find((entry) => entry.path === link); expect(linkEntry).toBeDefined(); expect(linkEntry!.symlinkTarget).toBe(target); @@ -181,14 +185,15 @@ describe.sequential("sandbox filesystem e2e", () => { test("getInfo surfaces symlink metadata", async () => { const target = `${baseDir}/symlink/target.txt`; const link = `${baseDir}/symlink/link.txt`; - await sandbox!.files.writeText(target, "target"); + await files.writeText(target, "target"); const result = await sandbox!.exec({ + runAs: "root", command: "bash", args: ["-lc", `mkdir -p \"${baseDir}/symlink\" && ln -sfn \"${target}\" \"${link}\"`], }); expect(result.exitCode).toBe(0); - const info = await sandbox!.files.getInfo(link); + const info = await files.getInfo(link); expect(info.path).toBe(link); expect(info.symlinkTarget).toBe(target); }); @@ -197,6 +202,7 @@ describe.sequential("sandbox filesystem e2e", () => { const brokenTarget = `${baseDir}/symlink-broken/missing-target.txt`; const brokenLink = `${baseDir}/symlink-broken/link.txt`; const result = await sandbox!.exec({ + runAs: "root", command: "bash", args: [ "-lc", @@ -205,55 +211,55 @@ describe.sequential("sandbox filesystem e2e", () => { }); expect(result.exitCode).toBe(0); - expect(await sandbox!.files.exists(brokenLink)).toBe(true); - const info = await sandbox!.files.getInfo(brokenLink); + expect(await files.exists(brokenLink)).toBe(true); + const info = await files.getInfo(brokenLink); expect(info.path).toBe(brokenLink); expect(info.symlinkTarget).toBe(brokenTarget); }); test("read supports text, bytes, blob, stream, offset, and length", async () => { const path = `${baseDir}/read/readme.txt`; - await sandbox!.files.writeText(path, "hello from sdk files"); + await files.writeText(path, "hello from sdk files"); - const text = await sandbox!.files.read(path); + const text = await files.read(path); expect(text).toBe("hello from sdk files"); - const chunk = await sandbox!.files.read(path, { + const chunk = await files.read(path, { format: "text", offset: 6, length: 4, }); expect(chunk).toBe("from"); - const bytes = await sandbox!.files.read(path, { format: "bytes" }); + const bytes = await files.read(path, { format: "bytes" }); expect(bytes.equals(Buffer.from("hello from sdk files"))).toBe(true); - const blob = await sandbox!.files.read(path, { format: "blob" }); + const blob = await files.read(path, { format: "blob" }); expect(blob).toBeInstanceOf(Blob); expect(await blob.text()).toBe("hello from sdk files"); - const stream = await sandbox!.files.read(path, { format: "stream" }); + const stream = await files.read(path, { format: "stream" }); expect(await readStreamText(stream)).toBe("hello from sdk files"); }); test("write supports single files and batches", async () => { - const single = await sandbox!.files.write( + const single = await files.write( `${baseDir}/write/single.txt`, "single file" ); expect(single.name).toBe("single.txt"); expect(single.path).toBe(`${baseDir}/write/single.txt`); - expect(await sandbox!.files.readText(single.path)).toBe("single file"); + expect(await files.readText(single.path)).toBe("single file"); - const batch = await sandbox!.files.write([ + const batch = await files.write([ { path: `${baseDir}/write/batch-a.txt`, data: "batch-a" }, { path: `${baseDir}/write/batch-b.bin`, data: Buffer.from([1, 2, 3, 4]) }, ]); expect(batch).toHaveLength(2); expect(batch.map((entry) => entry.name)).toEqual(["batch-a.txt", "batch-b.bin"]); - expect(await sandbox!.files.readText(`${baseDir}/write/batch-a.txt`)).toBe("batch-a"); + expect(await files.readText(`${baseDir}/write/batch-a.txt`)).toBe("batch-a"); expect( - (await sandbox!.files.readBytes(`${baseDir}/write/batch-b.bin`)).equals( + (await files.readBytes(`${baseDir}/write/batch-b.bin`)).equals( Buffer.from([1, 2, 3, 4]) ) ).toBe(true); @@ -261,77 +267,79 @@ describe.sequential("sandbox filesystem e2e", () => { test("writeText and writeBytes preserve append and mode options", async () => { const textPath = `${baseDir}/write-options/text.txt`; - await sandbox!.files.writeText(textPath, "hello", { mode: "0640" }); - await sandbox!.files.writeText(textPath, " world", { append: true }); - expect(await sandbox!.files.readText(textPath)).toBe("hello world"); - expect((await sandbox!.files.getInfo(textPath)).mode).toBe(0o640); + await files.writeText(textPath, "hello", { mode: "0640" }); + await files.writeText(textPath, " world", { append: true }); + expect(await files.readText(textPath)).toBe("hello world"); + expect((await files.getInfo(textPath)).mode).toBe(0o640); const bytesPath = `${baseDir}/write-options/bytes.bin`; - await sandbox!.files.writeBytes(bytesPath, Buffer.from([1, 2]), { mode: "0600" }); - await sandbox!.files.writeBytes(bytesPath, Buffer.from([3]), { append: true }); + await files.writeBytes(bytesPath, Buffer.from([1, 2]), { mode: "0600" }); + await files.writeBytes(bytesPath, Buffer.from([3]), { append: true }); expect( - (await sandbox!.files.readBytes(bytesPath)).equals(Buffer.from([1, 2, 3])) + (await files.readBytes(bytesPath)).equals(Buffer.from([1, 2, 3])) ).toBe(true); }); test("upload and download transfer raw bytes", async () => { const path = `${baseDir}/transfer/upload.txt`; - const uploaded = await sandbox!.files.upload(path, "uploaded from sdk"); + const uploaded = await files.upload(path, "uploaded from sdk"); expect(uploaded.bytesWritten).toBeGreaterThan(0); - const downloaded = await sandbox!.files.download(path); + const downloaded = await files.download(path); expect(downloaded.toString("utf8")).toBe("uploaded from sdk"); }); test("rename and copy preserve file and symlink semantics", async () => { const filePath = `${baseDir}/rename/hello.txt`; const renamedPath = `${baseDir}/rename/hello-renamed.txt`; - await sandbox!.files.writeText(filePath, "rename me"); + await files.writeText(filePath, "rename me"); - const renamed = await sandbox!.files.rename(filePath, renamedPath); + const renamed = await files.rename(filePath, renamedPath); expect(renamed.path).toBe(renamedPath); - expect(await sandbox!.files.exists(filePath)).toBe(false); - expect(await sandbox!.files.readText(renamedPath)).toBe("rename me"); + expect(await files.exists(filePath)).toBe(false); + expect(await files.readText(renamedPath)).toBe("rename me"); const linkPath = `${baseDir}/rename/hello-link.txt`; const copiedLinkPath = `${baseDir}/rename/hello-link-copy.txt`; const renamedLinkPath = `${baseDir}/rename/hello-link-renamed.txt`; const linkResult = await sandbox!.exec({ + runAs: "root", command: "bash", args: ["-lc", `ln -sfn \"${renamedPath}\" \"${linkPath}\"`], }); expect(linkResult.exitCode).toBe(0); - const copiedLink = await sandbox!.files.copy({ + const copiedLink = await files.copy({ source: linkPath, destination: copiedLinkPath, }); expect(copiedLink.path).toBe(copiedLinkPath); - expect((await sandbox!.files.getInfo(copiedLinkPath)).symlinkTarget).toBe(renamedPath); + expect((await files.getInfo(copiedLinkPath)).symlinkTarget).toBe(renamedPath); - const renamedLink = await sandbox!.files.rename(copiedLinkPath, renamedLinkPath); + const renamedLink = await files.rename(copiedLinkPath, renamedLinkPath); expect(renamedLink.path).toBe(renamedLinkPath); - expect((await sandbox!.files.getInfo(renamedLinkPath)).symlinkTarget).toBe(renamedPath); + expect((await files.getInfo(renamedLinkPath)).symlinkTarget).toBe(renamedPath); }); test("rename preserves symlinked directories and list follows the renamed link", async () => { const targetDir = `${baseDir}/rename-dir/target-dir`; const linkDir = `${baseDir}/rename-dir/link-dir`; const renamedLinkDir = `${baseDir}/rename-dir/link-dir-renamed`; - await sandbox!.files.makeDir(targetDir); - await sandbox!.files.writeText(`${targetDir}/child.txt`, "child"); + await files.makeDir(targetDir); + await files.writeText(`${targetDir}/child.txt`, "child"); const result = await sandbox!.exec({ + runAs: "root", command: "bash", args: ["-lc", `ln -sfn \"${targetDir}\" \"${linkDir}\"`], }); expect(result.exitCode).toBe(0); - const renamed = await sandbox!.files.rename(linkDir, renamedLinkDir); + const renamed = await files.rename(linkDir, renamedLinkDir); expect(renamed.path).toBe(renamedLinkDir); - const info = await sandbox!.files.getInfo(renamedLinkDir); + const info = await files.getInfo(renamedLinkDir); expect(info.symlinkTarget).toBe(targetDir); - const entries = await sandbox!.files.list(renamedLinkDir, { depth: 1 }); + const entries = await files.list(renamedLinkDir, { depth: 1 }); expect(entries.map((entry) => entry.path)).toEqual([`${targetDir}/child.txt`]); }); @@ -341,15 +349,16 @@ describe.sequential("sandbox filesystem e2e", () => { const target = `${nestedDir}/target.txt`; const link = `${nestedDir}/link.txt`; const destinationDir = `${baseDir}/copy-tree/destination`; - await sandbox!.files.makeDir(nestedDir); - await sandbox!.files.writeText(target, "payload"); + await files.makeDir(nestedDir); + await files.writeText(target, "payload"); const result = await sandbox!.exec({ + runAs: "root", command: "bash", args: ["-lc", `cd \"${nestedDir}\" && ln -sfn \"target.txt\" \"link.txt\"`], }); expect(result.exitCode).toBe(0); - await sandbox!.files.copy({ + await files.copy({ source: sourceDir, destination: destinationDir, recursive: true, @@ -357,53 +366,55 @@ describe.sequential("sandbox filesystem e2e", () => { const copiedLink = `${destinationDir}/nested/link.txt`; const copiedTarget = `${destinationDir}/nested/target.txt`; - expect(await sandbox!.files.readText(copiedTarget)).toBe("payload"); - expect((await sandbox!.files.getInfo(copiedLink)).symlinkTarget).toBe(copiedTarget); + expect(await files.readText(copiedTarget)).toBe("payload"); + expect((await files.getInfo(copiedLink)).symlinkTarget).toBe(copiedTarget); }); test("list depth does not recurse through symlink loops", async () => { const dir = `${baseDir}/loop-list`; const nestedDir = `${dir}/nested`; const filePath = `${nestedDir}/child.txt`; - await sandbox!.files.makeDir(nestedDir); - await sandbox!.files.writeText(filePath, "payload"); + await files.makeDir(nestedDir); + await files.writeText(filePath, "payload"); const result = await sandbox!.exec({ + runAs: "root", command: "bash", args: ["-lc", `cd \"${nestedDir}\" && ln -sfn .. loop`], }); expect(result.exitCode).toBe(0); - const entries = await sandbox!.files.list(dir, { depth: 4 }); + const entries = await files.list(dir, { depth: 4 }); const paths = entries.map((entry) => entry.path); expect(paths).toContain(`${nestedDir}/loop`); expect(paths.some((path) => path.includes("/loop/"))).toBe(false); - expect((await sandbox!.files.getInfo(`${nestedDir}/loop`)).symlinkTarget).toBe(dir); + expect((await files.getInfo(`${nestedDir}/loop`)).symlinkTarget).toBe(dir); }); test("copy preserves symlink loops without expanding them", async () => { const sourceDir = `${baseDir}/loop-copy/source`; const nestedDir = `${sourceDir}/nested`; - await sandbox!.files.makeDir(nestedDir); - await sandbox!.files.writeText(`${nestedDir}/child.txt`, "payload"); + await files.makeDir(nestedDir); + await files.writeText(`${nestedDir}/child.txt`, "payload"); const result = await sandbox!.exec({ + runAs: "root", command: "bash", args: ["-lc", `cd \"${nestedDir}\" && ln -sfn .. loop`], }); expect(result.exitCode).toBe(0); const destinationDir = `${baseDir}/loop-copy/destination`; - await sandbox!.files.copy({ + await files.copy({ source: sourceDir, destination: destinationDir, recursive: true, }); const copiedLoop = `${destinationDir}/nested/loop`; - expect((await sandbox!.files.getInfo(copiedLoop)).symlinkTarget).toBe( + expect((await files.getInfo(copiedLoop)).symlinkTarget).toBe( `${destinationDir}` ); - const entries = await sandbox!.files.list(destinationDir, { depth: 4 }); + const entries = await files.list(destinationDir, { depth: 4 }); expect(entries.some((entry) => entry.path.includes("/loop/"))).toBe(false); }); @@ -411,9 +422,10 @@ describe.sequential("sandbox filesystem e2e", () => { const source = `${baseDir}/copy-overwrite/source.txt`; const existingTarget = `${baseDir}/copy-overwrite/existing-target.txt`; const destinationLink = `${baseDir}/copy-overwrite/destination-link.txt`; - await sandbox!.files.writeText(source, "source payload"); - await sandbox!.files.writeText(existingTarget, "existing target"); + await files.writeText(source, "source payload"); + await files.writeText(existingTarget, "existing target"); const result = await sandbox!.exec({ + runAs: "root", command: "bash", args: [ "-lc", @@ -422,31 +434,31 @@ describe.sequential("sandbox filesystem e2e", () => { }); expect(result.exitCode).toBe(0); - await sandbox!.files.copy({ + await files.copy({ source, destination: destinationLink, overwrite: true, }); - expect(await sandbox!.files.readText(destinationLink)).toBe("source payload"); - expect(await sandbox!.files.readText(existingTarget)).toBe("existing target"); - expect((await sandbox!.files.getInfo(destinationLink)).symlinkTarget).toBeUndefined(); + expect(await files.readText(destinationLink)).toBe("source payload"); + expect(await files.readText(existingTarget)).toBe("existing target"); + expect((await files.getInfo(destinationLink)).symlinkTarget).toBeUndefined(); }); test("chmod updates metadata and chown failures stay structured", async () => { const path = `${baseDir}/chmod/file.txt`; - await sandbox!.files.writeText(path, "chmod me"); + await files.writeText(path, "chmod me"); - await sandbox!.files.chmod({ + await files.chmod({ path, mode: "0640", }); - expect((await sandbox!.files.getInfo(path)).mode).toBe(0o640); + expect((await files.getInfo(path)).mode).toBe(0o640); await expectHyperbrowserError( "file chown", () => - sandbox!.files.chown({ + files.chown({ path, uid: 0, gid: 0, @@ -462,7 +474,7 @@ describe.sequential("sandbox filesystem e2e", () => { error instanceof Error && /expected HyperbrowserError, but call succeeded/.test(error.message) ) { - expect((await sandbox!.files.getInfo(path)).name).toBe("file.txt"); + expect((await files.getInfo(path)).name).toBe("file.txt"); return; } throw error; @@ -471,38 +483,40 @@ describe.sequential("sandbox filesystem e2e", () => { test("remove deletes paths and is idempotent for missing targets", async () => { const path = `${baseDir}/remove/file.txt`; - await sandbox!.files.writeText(path, "remove me"); + await files.writeText(path, "remove me"); - await sandbox!.files.remove(path); - expect(await sandbox!.files.exists(path)).toBe(false); + await files.remove(path); + expect(await files.exists(path)).toBe(false); - await sandbox!.files.remove(path); - await sandbox!.files.remove(`${baseDir}/remove`, { recursive: true }); - expect(await sandbox!.files.exists(`${baseDir}/remove`)).toBe(false); + await files.remove(path); + await files.remove(`${baseDir}/remove`, { recursive: true }); + expect(await files.exists(`${baseDir}/remove`)).toBe(false); }); test("remove unlinks symlinks without deleting their targets", async () => { const target = `${baseDir}/remove-link/target.txt`; const link = `${baseDir}/remove-link/link.txt`; - await sandbox!.files.writeText(target, "keep me"); + await files.writeText(target, "keep me"); const result = await sandbox!.exec({ + runAs: "root", command: "bash", args: ["-lc", `mkdir -p \"${baseDir}/remove-link\" && ln -sfn \"${target}\" \"${link}\"`], }); expect(result.exitCode).toBe(0); - await sandbox!.files.remove(link); - expect(await sandbox!.files.exists(link)).toBe(false); - expect(await sandbox!.files.readText(target)).toBe("keep me"); + await files.remove(link); + expect(await files.exists(link)).toBe(false); + expect(await files.readText(target)).toBe("keep me"); }); test("remove with recursive unlinks symlinked directories without deleting the target tree", async () => { const targetDir = `${baseDir}/remove-recursive/target-dir`; const targetFile = `${targetDir}/child.txt`; const linkDir = `${baseDir}/remove-recursive/link-dir`; - await sandbox!.files.makeDir(targetDir); - await sandbox!.files.writeText(targetFile, "keep tree"); + await files.makeDir(targetDir); + await files.writeText(targetFile, "keep tree"); const result = await sandbox!.exec({ + runAs: "root", command: "bash", args: [ "-lc", @@ -511,14 +525,15 @@ describe.sequential("sandbox filesystem e2e", () => { }); expect(result.exitCode).toBe(0); - await sandbox!.files.remove(linkDir, { recursive: true }); - expect(await sandbox!.files.exists(linkDir)).toBe(false); - expect(await sandbox!.files.readText(targetFile)).toBe("keep tree"); + await files.remove(linkDir, { recursive: true }); + expect(await files.exists(linkDir)).toBe(false); + expect(await files.readText(targetFile)).toBe("keep tree"); }); test("read and download follow symlinks whose targets resolve outside the old roots", async () => { const link = `${baseDir}/escape/file-link`; const result = await sandbox!.exec({ + runAs: "root", command: "bash", args: [ "-lc", @@ -527,11 +542,11 @@ describe.sequential("sandbox filesystem e2e", () => { }); expect(result.exitCode).toBe(0); - const text = await sandbox!.files.readText(link); + const text = await files.readText(link); expect(text.length).toBeGreaterThan(0); expect(text).toContain("localhost"); - const downloaded = await sandbox!.files.download(link); + const downloaded = await files.download(link); expect(downloaded.toString("utf8")).toContain("localhost"); }); @@ -542,16 +557,16 @@ describe.sequential("sandbox filesystem e2e", () => { "parent-escape-read" ); - expect(await sandbox!.files.readText(escapedFile)).toBe("outside secret"); - expect((await sandbox!.files.download(escapedFile)).toString("utf8")).toBe( + expect(await files.readText(escapedFile)).toBe("outside secret"); + expect((await files.download(escapedFile)).toString("utf8")).toBe( "outside secret" ); - const entries = await sandbox!.files.list(linkDir, { depth: 1 }); + const entries = await files.list(linkDir, { depth: 1 }); expect(entries.map((entry) => entry.path)).toEqual([`${outsideDir}/secret.txt`]); const seen = waitForEvent(); - const handle = await sandbox!.files.watchDir( + const handle = await files.watchDir( linkDir, async (event) => { if (isCreateOrWriteEvent(event.type) && event.name === "fresh.txt") { @@ -565,7 +580,7 @@ describe.sequential("sandbox filesystem e2e", () => { ); try { - await sandbox!.files.writeText(`${outsideDir}/fresh.txt`, "watch parent link"); + await files.writeText(`${outsideDir}/fresh.txt`, "watch parent link"); await expect(seen.promise).resolves.toBe("fresh.txt"); } finally { await handle.stop(); @@ -581,26 +596,27 @@ describe.sequential("sandbox filesystem e2e", () => { const copyDestination = `${baseDir}/parent-escape-mutate/copied.txt`; const renameDestination = `${baseDir}/parent-escape-mutate/renamed.txt`; - const info = await sandbox!.files.getInfo(escapedFile); + const info = await files.getInfo(escapedFile); expect(info.type).toBe("file"); expect(info.size).toBe("outside secret".length); - const copied = await sandbox!.files.copy({ + const copied = await files.copy({ source: escapedFile, destination: copyDestination, }); expect(copied.path).toBe(copyDestination); - expect(await sandbox!.files.readText(copyDestination)).toBe("outside secret"); + expect(await files.readText(copyDestination)).toBe("outside secret"); - const renamed = await sandbox!.files.rename(escapedFile, renameDestination); + const renamed = await files.rename(escapedFile, renameDestination); expect(renamed.path).toBe(renameDestination); - expect(await sandbox!.files.exists(outsideFile)).toBe(false); - expect(await sandbox!.files.readText(renameDestination)).toBe("outside secret"); + expect(await files.exists(outsideFile)).toBe(false); + expect(await files.readText(renameDestination)).toBe("outside secret"); - await sandbox!.files.writeText(escapedFile, "remove me"); - await sandbox!.files.remove(escapedFile); + await files.writeText(escapedFile, "remove me"); + await files.remove(escapedFile); const outsideRead = await sandbox!.exec({ + runAs: "root", command: "bash", args: [ "-lc", @@ -609,8 +625,8 @@ describe.sequential("sandbox filesystem e2e", () => { }); expect(outsideRead.exitCode).toBe(0); expect(outsideRead.stdout.trim()).toBe("__MISSING__"); - expect(await sandbox!.files.exists(copyDestination)).toBe(true); - expect(await sandbox!.files.exists(renameDestination)).toBe(true); + expect(await files.exists(copyDestination)).toBe(true); + expect(await files.exists(renameDestination)).toBe(true); }); test("list and watchDir follow symlinked directories outside the old roots", async () => { @@ -618,6 +634,7 @@ describe.sequential("sandbox filesystem e2e", () => { const targetFile = `${targetDir}/child.txt`; const link = `${baseDir}/escape/dir-link`; const result = await sandbox!.exec({ + runAs: "root", command: "bash", args: [ "-lc", @@ -626,11 +643,11 @@ describe.sequential("sandbox filesystem e2e", () => { }); expect(result.exitCode).toBe(0); - const entries = await sandbox!.files.list(link, { depth: 1 }); + const entries = await files.list(link, { depth: 1 }); expect(entries.map((entry) => entry.path)).toEqual([targetFile]); const seen = waitForEvent(); - const handle = await sandbox!.files.watchDir( + const handle = await files.watchDir( link, async (event) => { if (isCreateOrWriteEvent(event.type) && event.name === "file.txt") { @@ -644,7 +661,7 @@ describe.sequential("sandbox filesystem e2e", () => { ); try { - await sandbox!.files.writeText(`${targetDir}/file.txt`, "watch through link"); + await files.writeText(`${targetDir}/file.txt`, "watch through link"); await expect(seen.promise).resolves.toBe("file.txt"); } finally { await handle.stop(); @@ -653,12 +670,12 @@ describe.sequential("sandbox filesystem e2e", () => { test("watchDir reports relative file events and recursive nested changes", async () => { const dir = `${baseDir}/watch`; - await sandbox!.files.makeDir(`${dir}/nested`); + await files.makeDir(`${dir}/nested`); const directEvent = waitForEvent(); const recursiveEvent = waitForEvent(); - const directHandle = await sandbox!.files.watchDir( + const directHandle = await files.watchDir( dir, async (event) => { if (isCreateOrWriteEvent(event.type) && event.name === "direct.txt") { @@ -671,7 +688,7 @@ describe.sequential("sandbox filesystem e2e", () => { } ); - const recursiveHandle = await sandbox!.files.watchDir( + const recursiveHandle = await files.watchDir( dir, async (event) => { if (isCreateOrWriteEvent(event.type) && event.name === "nested/recursive.txt") { @@ -686,8 +703,8 @@ describe.sequential("sandbox filesystem e2e", () => { ); try { - await sandbox!.files.writeText(`${dir}/direct.txt`, "watch me"); - await sandbox!.files.writeText(`${dir}/nested/recursive.txt`, "watch me too"); + await files.writeText(`${dir}/direct.txt`, "watch me"); + await files.writeText(`${dir}/nested/recursive.txt`, "watch me too"); await expect(directEvent.promise).resolves.toBe("direct.txt"); await expect(recursiveEvent.promise).resolves.toBe("nested/recursive.txt"); } finally { @@ -699,7 +716,7 @@ describe.sequential("sandbox filesystem e2e", () => { test("watchDir returns structured errors for missing directories and file paths", async () => { await expectHyperbrowserError( "watch missing directory", - () => sandbox!.files.watchDir(`${baseDir}/watch-missing`, () => undefined), + () => files.watchDir(`${baseDir}/watch-missing`, () => undefined), { statusCode: 404, service: "runtime", @@ -709,10 +726,10 @@ describe.sequential("sandbox filesystem e2e", () => { ); const filePath = `${baseDir}/watch-invalid/file.txt`; - await sandbox!.files.writeText(filePath, "not a directory"); + await files.writeText(filePath, "not a directory"); await expectHyperbrowserError( "watch file path", - () => sandbox!.files.watchDir(filePath, () => undefined), + () => files.watchDir(filePath, () => undefined), { statusCode: 400, service: "runtime", @@ -724,7 +741,7 @@ describe.sequential("sandbox filesystem e2e", () => { test("presigned upload and download URLs work end to end", async () => { const path = `${baseDir}/presign/file.txt`; - const upload = await sandbox!.files.uploadUrl(path, { + const upload = await files.uploadUrl(path, { oneTime: true, }); expect(upload.path).toBe(path); @@ -735,9 +752,9 @@ describe.sequential("sandbox filesystem e2e", () => { body: "presigned upload body", }); expect(uploadResponse.status).toBe(200); - expect(await sandbox!.files.readText(path)).toBe("presigned upload body"); + expect(await files.readText(path)).toBe("presigned upload body"); - const download = await sandbox!.files.downloadUrl(path, { + const download = await files.downloadUrl(path, { oneTime: true, }); expect(download.path).toBe(path); @@ -752,7 +769,7 @@ describe.sequential("sandbox filesystem e2e", () => { test("one-time presigned upload URLs allow only one concurrent use", async () => { const path = `${baseDir}/presign-race/upload.txt`; - const upload = await sandbox!.files.uploadUrl(path, { oneTime: true }); + const upload = await files.uploadUrl(path, { oneTime: true }); const [first, second] = await Promise.all([ fetchSignedUrl(upload.url, { @@ -768,14 +785,14 @@ describe.sequential("sandbox filesystem e2e", () => { const statuses = [first.status, second.status].sort((a, b) => a - b); expect(statuses).toEqual([200, 401]); - const finalContent = await sandbox!.files.readText(path); + const finalContent = await files.readText(path); expect(["first body", "second body"]).toContain(finalContent); }); test("one-time presigned download URLs allow only one concurrent use", async () => { const path = `${baseDir}/presign-race/download.txt`; - await sandbox!.files.writeText(path, "download once"); - const download = await sandbox!.files.downloadUrl(path, { oneTime: true }); + await files.writeText(path, "download once"); + const download = await files.downloadUrl(path, { oneTime: true }); const [first, second] = await Promise.all([ fetchSignedUrl(download.url, { @@ -796,11 +813,11 @@ describe.sequential("sandbox filesystem e2e", () => { const source = `${baseDir}/rename-race/source.txt`; const left = `${baseDir}/rename-race/left.txt`; const right = `${baseDir}/rename-race/right.txt`; - await sandbox!.files.writeText(source, "race"); + await files.writeText(source, "race"); const results = await Promise.allSettled([ - sandbox!.files.rename(source, left), - sandbox!.files.rename(source, right), + files.rename(source, left), + files.rename(source, right), ]); const fulfilled = results.filter((result) => result.status === "fulfilled"); @@ -822,14 +839,14 @@ describe.sequential("sandbox filesystem e2e", () => { ); expect(error).toBeDefined(); - const winnerPath = await sandbox!.files.exists(left) ? left : right; - expect(await sandbox!.files.readText(winnerPath)).toBe("race"); + const winnerPath = await files.exists(left) ? left : right; + expect(await files.readText(winnerPath)).toBe("race"); }); test("missing file reads still return structured errors", async () => { await expectHyperbrowserError( "missing file read", - () => sandbox!.files.readText(`${baseDir}/still-missing.txt`), + () => files.readText(`${baseDir}/still-missing.txt`), { statusCode: 404, service: "runtime", @@ -840,7 +857,7 @@ describe.sequential("sandbox filesystem e2e", () => { }); test("list rejects invalid depth locally", async () => { - await expect(sandbox!.files.list(baseDir, { depth: 0 })).rejects.toThrow( + await expect(files.list(baseDir, { depth: 0 })).rejects.toThrow( "depth should be at least one" ); }); diff --git a/tests/sandbox/e2e/lifecycle.test.ts b/tests/sandbox/e2e/lifecycle.test.ts index c46bf62..302c30e 100644 --- a/tests/sandbox/e2e/lifecycle.test.ts +++ b/tests/sandbox/e2e/lifecycle.test.ts @@ -6,6 +6,7 @@ import { randomUUID } from "crypto"; import fetch from "node-fetch"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { HyperbrowserError } from "../../../src/client"; import type { SandboxHandle } from "../../../src/services/sandboxes"; import { API_KEY, @@ -23,7 +24,43 @@ import { const client = createClient(); const CUSTOM_IMAGE_NAME = "node"; -const SNAPSHOT_CREATE_TEST_TIMEOUT_MS = 90_000; +const SNAPSHOT_CREATE_TEST_TIMEOUT_MS = 300_000; +const SNAPSHOT_SETTLE_DELAY_MS = 30_000; +const SNAPSHOT_CREATE_RETRY_TIMEOUT_MS = 120_000; +const SNAPSHOT_CREATE_RETRY_DELAY_MS = 5_000; + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +const isSnapshotNotReadyError = (error: unknown): boolean => { + if (!(error instanceof HyperbrowserError)) { + return false; + } + if (error.statusCode !== 404) { + return false; + } + return /snapshot not found/i.test(error.message); +}; + +const createFromSnapshotEventually = async ( + snapshotName: string, + snapshotId: string +): Promise => { + const deadline = Date.now() + SNAPSHOT_CREATE_RETRY_TIMEOUT_MS; + while (true) { + try { + return await client.sandboxes.create({ + snapshotName, + snapshotId, + }); + } catch (error) { + if (!isSnapshotNotReadyError(error) || Date.now() >= deadline) { + throw error; + } + await sleep(SNAPSHOT_CREATE_RETRY_DELAY_MS); + } + } +}; type ListedFirecrackerImage = { id: string; @@ -236,13 +273,14 @@ describe.sequential("sandbox lifecycle e2e", () => { test( "create from an image-backed memory snapshot succeeds", async () => { - expect(customImageMemorySnapshot).toBeTruthy(); + expect(customImageMemorySnapshot).toBeTruthy(); await waitForCreatedSnapshot(client, customImageMemorySnapshot!.snapshotId); - customSnapshotSandbox = await client.sandboxes.create({ - snapshotName: customImageMemorySnapshot!.snapshotName, - snapshotId: customImageMemorySnapshot!.snapshotId, - }); + await sleep(SNAPSHOT_SETTLE_DELAY_MS); + customSnapshotSandbox = await createFromSnapshotEventually( + customImageMemorySnapshot!.snapshotName, + customImageMemorySnapshot!.snapshotId + ); expect(customSnapshotSandbox.id).toBeTruthy(); expect(customSnapshotSandbox.status).toBe("active"); @@ -382,10 +420,11 @@ describe.sequential("sandbox lifecycle e2e", () => { expect(memorySnapshot).toBeTruthy(); await waitForCreatedSnapshot(client, memorySnapshot!.snapshotId); - secondary = await client.sandboxes.create({ - snapshotName: memorySnapshot!.snapshotName, - snapshotId: memorySnapshot!.snapshotId, - }); + await sleep(SNAPSHOT_SETTLE_DELAY_MS); + secondary = await createFromSnapshotEventually( + memorySnapshot!.snapshotName, + memorySnapshot!.snapshotId + ); const response = await secondary.stop(); expect(response.success).toBe(true); diff --git a/tests/sandbox/e2e/process-api.test.ts b/tests/sandbox/e2e/process-api.test.ts index ebf38a3..a4ede9f 100644 --- a/tests/sandbox/e2e/process-api.test.ts +++ b/tests/sandbox/e2e/process-api.test.ts @@ -101,6 +101,46 @@ describe("sandbox process api", () => { ); }); + test("legacy args and useShell are normalized out of process payloads", async () => { + const requestJSON = vi + .fn() + .mockResolvedValueOnce(execResponse) + .mockResolvedValueOnce(startResponse); + const transport = { + requestJSON, + } as unknown as RuntimeTransport; + const api = new SandboxProcessesApi(transport); + + await api.exec({ + command: "/bin/echo", + args: ["legacy args value"], + useShell: false, + runAs: "root", + }); + await api.start({ + command: "bash", + args: ["-lc", "echo process-started"], + useShell: true, + cwd: "/tmp", + }); + + const execPayload = JSON.parse(requestJSON.mock.calls[0][1].body); + expect(execPayload).toEqual({ + command: "/bin/echo 'legacy args value'", + runAs: "root", + }); + expect(execPayload).not.toHaveProperty("args"); + expect(execPayload).not.toHaveProperty("useShell"); + + const startPayload = JSON.parse(requestJSON.mock.calls[1][1].body); + expect(startPayload).toEqual({ + command: "bash -lc 'echo process-started'", + cwd: "/tmp", + }); + expect(startPayload).not.toHaveProperty("args"); + expect(startPayload).not.toHaveProperty("useShell"); + }); + test("sandbox handle exec forwards string options to processes.exec", async () => { const exec = vi.fn().mockResolvedValue(execResponse.result); diff --git a/tests/sandbox/e2e/process.test.ts b/tests/sandbox/e2e/process.test.ts index fb6e479..aedef7d 100644 --- a/tests/sandbox/e2e/process.test.ts +++ b/tests/sandbox/e2e/process.test.ts @@ -47,6 +47,16 @@ describe.sequential("sandbox process e2e", () => { expect(result.stdout).toContain("process-exec-ok"); }); + test("legacy useShell process input is ignored and still executes", async () => { + const result = await sandbox!.exec({ + command: "echo process-use-shell-ignored", + useShell: false, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("process-use-shell-ignored"); + }); + test("exec returns non-zero results without throwing", async () => { const result = await sandbox!.exec({ command: "bash", From cecef7276848f9092d731f279835bd5f0e13ae03 Mon Sep 17 00:00:00 2001 From: Devin Deng Date: Tue, 7 Apr 2026 18:13:55 +0000 Subject: [PATCH 3/3] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0aa393d..b6df2b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbrowser/sdk", - "version": "0.89.0", + "version": "0.89.1", "description": "Node SDK for Hyperbrowser API", "author": "", "repository": {