Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sandbox-timeouts-file-ops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/sandbox": minor
---

feat(sandbox): runCommand timeout and AbortSignal cancellation, plus listFiles, deleteFile, rename, and exists file operations
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ This is a Bun workspace monorepo with six packages:
- **`@bunny.net/app-config`** (`packages/app-config/`) — Shared app configuration schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. Used by the CLI and potentially other tools.
- **`@bunny.net/database-shell`** (`packages/database-shell/`) — Standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`).
- **`@bunny.net/scriptable-dns-types`** (`packages/scriptable-dns-types/`): Ambient TypeScript declarations for the Scriptable DNS runtime globals (`ARecord`, `Monitoring`, `RoutingEngine`, etc.). Types-only, no runtime code: the DNS runtime can't `import`, so these power editor autocomplete and an optional typecheck step. Scaffolded into projects by `bunny dns scripts init`; intended to also feed the dashboard editor. Publishable to npm.
- **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. Zero CLI dependencies.
- **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`, `listFiles`/`deleteFile`/`rename`/`exists`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Blocking `runCommand` accepts `timeout` (rejects with `CommandTimeoutError` carrying partial output) and `signal` for cancellation. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. Zero CLI dependencies.
- **`@bunny.net/cli`** (`packages/cli/`) — The CLI. Depends on `@bunny.net/openapi-client`, `@bunny.net/app-config`, `@bunny.net/database-shell`, `@bunny.net/scriptable-dns-types`, and `@bunny.net/sandbox`.

```
Expand Down Expand Up @@ -157,12 +157,12 @@ bunny-cli/
│ │ ├── tsconfig.json
│ │ └── src/
│ │ ├── index.ts # Barrel export: Sandbox, Command, types
│ │ ├── sandbox.ts # Sandbox class: create/get/fromHandle, runCommand, writeFiles, readFile, mkDir, exposePort, domain, getEnv/setEnv/unsetEnv (persisted env), delete
│ │ ├── sandbox.ts # Sandbox class: create/get/fromHandle, runCommand (timeout/signal), writeFiles, readFile, listFiles, deleteFile, rename, exists, mkDir, exposePort, domain, getEnv/setEnv/unsetEnv (persisted env), delete
│ │ ├── provision.ts # Magic Containers app create/poll/endpoints + auth helpers + container env read/replace
│ │ ├── transport.ts # ssh2 SSH/SFTP transport (exec, file IO, reachability)
│ │ ├── transport.ts # ssh2 SSH/SFTP transport (exec with limits, file IO, reachability)
│ │ ├── command.ts # Command (detached, logs()) and CommandFinished
│ │ ├── types.ts # Option and handle types
│ │ ├── errors.ts # SandboxError
│ │ ├── errors.ts # SandboxError, CommandTimeoutError
│ │ └── sandbox.test.ts # Tests for pure logic (command building, app extraction)
│ │
│ └── cli/ # @bunny.net/cli package
Expand Down
53 changes: 39 additions & 14 deletions packages/sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,27 @@ const finished = await server.wait();
console.log(finished.exitCode);
```

## Timeouts and cancellation

Blocking commands accept a `timeout` in milliseconds and an `AbortSignal`. On timeout the remote process is killed and the call rejects with `CommandTimeoutError`, which carries the output collected so far:

```ts
import { CommandTimeoutError } from "@bunny.net/sandbox";

try {
await sandbox.runCommand({ cmd: "bun", args: ["run", "build"], timeout: 30_000 });
} catch (err) {
if (err instanceof CommandTimeoutError) console.log(err.stdout, err.stderr);
}

// Or cancel from an AbortSignal; the call rejects with the abort reason.
const controller = new AbortController();
const pending = sandbox.runCommand({ cmd: "sleep", args: ["600"], signal: controller.signal });
controller.abort();
```

Detached commands manage their own lifetime instead: use `command.kill()`.

## Reconnecting

`Sandbox.create` returns a handle you can persist and rebuild later, with no API round trip:
Expand All @@ -74,20 +95,24 @@ const sandbox = await Sandbox.get({ apiKey, appId });

## API

| Method | Description |
| --------------------------------------------- | ---------------------------------------------------- |
| `Sandbox.create(options)` | Provision a sandbox and wait until SSH is reachable. |
| `Sandbox.get({ appId })` | Retrieve an existing sandbox by app ID. |
| `Sandbox.fromHandle(handle)` | Rebuild a sandbox from a serialized handle. |
| `sandbox.runCommand(cmd, args)` | Run a command, blocking for the result. |
| `sandbox.runCommand({ ..., detached: true })` | Start a command and stream `logs()`. |
| `sandbox.writeFiles(files)` | Upload files, creating parent directories. |
| `sandbox.readFile(path)` | Read a file into a Buffer, or `null` if missing. |
| `sandbox.mkDir(path)` | Create a directory. |
| `sandbox.exposePort(port, label?)` | Expose a port as a public CDN URL. |
| `sandbox.domain(port)` | Return the URL of an already exposed port. |
| `sandbox.delete()` | Delete the sandbox and its backing app. |
| `sandbox.toHandle()` | Serialize the sandbox for reconnection. |
| Method | Description |
| --------------------------------------------- | --------------------------------------------------------- |
| `Sandbox.create(options)` | Provision a sandbox and wait until SSH is reachable. |
| `Sandbox.get({ appId })` | Retrieve an existing sandbox by app ID. |
| `Sandbox.fromHandle(handle)` | Rebuild a sandbox from a serialized handle. |
| `sandbox.runCommand(cmd, args)` | Run a command, blocking for the result. |
| `sandbox.runCommand({ ..., detached: true })` | Start a command and stream `logs()`. |
| `sandbox.writeFiles(files)` | Upload files, creating parent directories. |
| `sandbox.readFile(path)` | Read a file into a Buffer, or `null` if missing. |
| `sandbox.listFiles(path?)` | List directory entries; `[]` if the directory is missing. |
| `sandbox.deleteFile(path)` | Delete a file; `false` if it did not exist. |
| `sandbox.rename(from, to)` | Rename or move; fails if the destination exists. |
| `sandbox.exists(path)` | Whether a file or directory exists. |
| `sandbox.mkDir(path)` | Create a directory. |
| `sandbox.exposePort(port, label?)` | Expose a port as a public CDN URL. |
| `sandbox.domain(port)` | Return the URL of an already exposed port. |
| `sandbox.delete()` | Delete the sandbox and its backing app. |
| `sandbox.toHandle()` | Serialize the sandbox for reconnection. |

## Not yet supported

Expand Down
12 changes: 12 additions & 0 deletions packages/sandbox/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,15 @@ export class SandboxError extends Error {
this.name = "SandboxError";
}
}

/** Thrown when a command exceeds its timeout. Carries the output collected so far. */
export class CommandTimeoutError extends SandboxError {
constructor(
readonly timeoutMs: number,
readonly stdout: string,
readonly stderr: string,
) {
super(`Command timed out after ${timeoutMs}ms.`);
this.name = "CommandTimeoutError";
}
}
3 changes: 2 additions & 1 deletion packages/sandbox/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
export { Command, CommandFinished, type LogChunk } from "./command.ts";
export { SandboxError } from "./errors.ts";
export { CommandTimeoutError, SandboxError } from "./errors.ts";
export { Sandbox } from "./sandbox.ts";
export type {
CreateOptions,
FileToWrite,
GetOptions,
RunCommandOptions,
SandboxAuth,
SandboxFileEntry,
SandboxHandle,
SandboxImage,
} from "./types.ts";
21 changes: 21 additions & 0 deletions packages/sandbox/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ suite("sandbox integration", () => {
// Missing files resolve to null.
expect(await sandbox.readFile("does-not-exist.txt")).toBeNull();

// List, rename, and delete round trip.
await sandbox.writeFiles([{ path: "dir/a.txt", content: "a" }]);
const entries = await sandbox.listFiles("dir");
expect(entries.map((e) => e.name)).toContain("a.txt");
expect(await sandbox.listFiles("no-such-dir")).toEqual([]);
expect(await sandbox.exists("dir/a.txt")).toBe(true);
// Renaming onto an existing file is refused rather than overwriting.
await sandbox.writeFiles([{ path: "dir/taken.txt", content: "t" }]);
await expect(
sandbox.rename("dir/a.txt", "dir/taken.txt"),
).rejects.toThrow("already exists");
await sandbox.rename("dir/a.txt", "dir/b.txt");
expect(await sandbox.exists("dir/a.txt")).toBe(false);
expect(await sandbox.deleteFile("dir/b.txt")).toBe(true);
expect(await sandbox.deleteFile("dir/b.txt")).toBe(false);

// A hanging command rejects once its timeout elapses.
await expect(
sandbox.runCommand({ cmd: "sleep", args: ["30"], timeout: 2000 }),
).rejects.toThrow("timed out");

// Stream output from a detached command.
const cmd = await sandbox.runCommand({
cmd: "sh",
Expand Down
142 changes: 142 additions & 0 deletions packages/sandbox/src/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { CommandTimeoutError } from "./errors.ts";
import {
extractAgentToken,
extractAnycastHost,
Expand All @@ -12,6 +13,7 @@ import {
Sandbox,
shellQuote,
} from "./sandbox.ts";
import { collectExec, fileEntryFromAttrs } from "./transport.ts";

describe("Sandbox.create", () => {
test("rejects reserved env key AGENT_TOKEN before any network call", async () => {
Expand All @@ -27,6 +29,146 @@ describe("Sandbox.create", () => {
});
});

describe("Sandbox.runCommand validation", () => {
// fromHandle connects lazily, so validation errors surface with no network.
const sandbox = Sandbox.fromHandle({
appId: "app-1",
name: "test",
agentToken: "token",
sshHost: "192.0.2.1",
});

test("rejects a non-positive timeout before any network call", async () => {
await expect(sandbox.runCommand({ cmd: "ls", timeout: 0 })).rejects.toThrow(
"timeout must be a positive number",
);
});

test("rejects timeout combined with detached", async () => {
await expect(
sandbox.runCommand({ cmd: "ls", detached: true, timeout: 5000 }),
).rejects.toThrow("not supported with detached");
});

test("rejects signal combined with detached", async () => {
await expect(
sandbox.runCommand({
cmd: "ls",
detached: true,
signal: new AbortController().signal,
}),
).rejects.toThrow("not supported with detached");
});
});

function fakeStream() {
const listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
const stderrListeners: Array<(data: Buffer) => void> = [];
return {
signals: [] as string[],
closed: false,
on(event: string, cb: (...args: never[]) => void) {
listeners[event] ??= [];
listeners[event].push(cb as (...args: unknown[]) => void);
return this;
},
stderr: {
on(_event: string, cb: (data: Buffer) => void) {
stderrListeners.push(cb);
return this;
},
},
signal(name: string) {
this.signals.push(name);
},
close() {
this.closed = true;
},
emit(event: string, ...args: unknown[]) {
for (const cb of listeners[event] ?? []) cb(...args);
},
emitStderr(data: Buffer) {
for (const cb of stderrListeners) cb(data);
},
};
}

describe("collectExec", () => {
test("resolves with collected output on close", async () => {
const stream = fakeStream();
const pending = collectExec(stream);
stream.emit("data", Buffer.from("out"));
stream.emitStderr(Buffer.from("err"));
stream.emit("close", 0);
expect(await pending).toEqual({
stdout: "out",
stderr: "err",
exitCode: 0,
});
});

test("times out, kills the remote process, and keeps partial output", async () => {
const stream = fakeStream();
const pending = collectExec(stream, { timeoutMs: 10 });
stream.emit("data", Buffer.from("partial"));
const err = await pending.catch((e: unknown) => e);
expect(err).toBeInstanceOf(CommandTimeoutError);
expect((err as CommandTimeoutError).stdout).toBe("partial");
expect(stream.signals).toContain("KILL");
expect(stream.closed).toBe(true);
});

test("rejects immediately when the signal is already aborted", async () => {
const stream = fakeStream();
const err = await collectExec(stream, {
signal: AbortSignal.abort("stop"),
}).catch((e: unknown) => e);
expect(err).toBe("stop");
expect(stream.closed).toBe(true);
});

test("rejects with the abort reason on mid-flight abort", async () => {
const stream = fakeStream();
const controller = new AbortController();
const pending = collectExec(stream, { signal: controller.signal });
controller.abort(new Error("cancelled"));
const err = await pending.catch((e: unknown) => e);
expect((err as Error).message).toBe("cancelled");
expect(stream.signals).toContain("KILL");
});
});

describe("fileEntryFromAttrs", () => {
const attrs = (mode: number, kind: "dir" | "link" | "file" | "other") => ({
size: 42,
mode,
isDirectory: () => kind === "dir",
isSymbolicLink: () => kind === "link",
isFile: () => kind === "file",
});

test("classifies entries and masks the mode to permission bits", () => {
expect(fileEntryFromAttrs("src", attrs(0o40755, "dir"))).toEqual({
name: "src",
type: "directory",
size: 42,
mode: 0o755,
});
expect(fileEntryFromAttrs("a.txt", attrs(0o100644, "file"))).toEqual({
name: "a.txt",
type: "file",
size: 42,
mode: 0o644,
});
expect(fileEntryFromAttrs("ln", attrs(0o120777, "link")).type).toBe(
"symlink",
);
expect(fileEntryFromAttrs("sock", attrs(0o140644, "other")).type).toBe(
"other",
);
});
});

describe("buildRemoteCommand", () => {
test("defaults to the workplace and quotes the command", () => {
expect(buildRemoteCommand({ cmd: "ls", args: ["-la"] })).toBe(
Expand Down
44 changes: 42 additions & 2 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
GetOptions,
RunCommandOptions,
SandboxAuth,
SandboxFileEntry,
SandboxHandle,
} from "./types.ts";

Expand Down Expand Up @@ -170,19 +171,38 @@ export class Sandbox {

/** Run a command, blocking for the result unless detached is set. */
async runCommand(command: string, args?: string[]): Promise<CommandFinished>;
async runCommand(command: RunCommandOptions): Promise<Command>;
async runCommand(
command: RunCommandOptions & { detached: true },
): Promise<Command>;
async runCommand(command: RunCommandOptions): Promise<CommandFinished>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve union return for dynamic detached options

When callers build an options object in a variable typed as RunCommandOptions (for example from a helper or config) and set detached: true, overload resolution falls through to this catch-all and types the result as CommandFinished, even though the runtime branch still returns a live Command. That lets consumer code type-check while treating a still-running command as finished (or prevents access to logs()/kill() without casts); narrow this overload to non-detached options or return the union for dynamic inputs.

Useful? React with 👍 / 👎.

async runCommand(
command: string | RunCommandOptions,
args: string[] = [],
): Promise<CommandFinished | Command> {
const opts: RunCommandOptions =
typeof command === "string" ? { cmd: command, args } : command;
if (
opts.timeout !== undefined &&
(!Number.isFinite(opts.timeout) || opts.timeout <= 0)
) {
throw new SandboxError(
"timeout must be a positive number of milliseconds.",
);
}
if (opts.detached && (opts.timeout !== undefined || opts.signal)) {
throw new SandboxError(
"timeout and signal are not supported with detached; use command.kill().",
);
}
const remote = buildRemoteCommand(opts);

if (opts.detached) {
return new Command(await this.transport.execStream(remote));
}
const { stdout, stderr, exitCode } = await this.transport.exec(remote);
const { stdout, stderr, exitCode } = await this.transport.exec(remote, {
timeoutMs: opts.timeout,
signal: opts.signal,
});
return new CommandFinished(exitCode, stdout, stderr);
}

Expand All @@ -205,6 +225,26 @@ export class Sandbox {
return this.transport.readFile(resolvePath(path));
}

/** List directory entries, sorted by name; [] when the directory does not exist. Defaults to the workplace. */
async listFiles(path = "."): Promise<SandboxFileEntry[]> {
return this.transport.readDir(resolvePath(path));
}

/** Delete a file. Returns false when it does not exist. */
async deleteFile(path: string): Promise<boolean> {
return this.transport.unlink(resolvePath(path));
}

/** Rename or move a file or directory. Fails when the destination exists. */
async rename(from: string, to: string): Promise<void> {
return this.transport.rename(resolvePath(from), resolvePath(to));
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/** Whether a file or directory exists at the path. */
async exists(path: string): Promise<boolean> {
return (await this.transport.stat(resolvePath(path))) !== null;
}

async mkDir(path: string): Promise<void> {
const { exitCode, stderr } = await this.transport.exec(
`mkdir -p ${shellQuote(resolvePath(path))}`,
Expand Down
Loading