Skip to content

v0.8.0

Choose a tag to compare

@ggoodman ggoodman released this 11 Jun 16:46
793a811

This release redesigns process spawning around two explicit use cases: ordinary long-lived commands with streams, and interactive terminal sessions with a PTY. This is a breaking API change because the old spawn shape blurred those two modes and made it hard to build SSH-like clients without special cases.

Separate APIs for streamed processes and PTYs

Use sandbox.spawn() for non-interactive commands that may still run for a long time. It returns a process handle immediately with Web streams for stdin, stdout, and stderr, plus lifecycle promises for readiness and exit.

const process = sandbox.spawn("node", ["server.js"], {
  cwd: "/workspace",
});

await process.ready;

const logs = process.stdout
  .pipeThrough(new TextDecoderStream())
  .getReader();

await process.stdin.getWriter().write(new TextEncoder().encode("status\n"));

const exit = await process.exit;
console.log(exit);

Use sandbox.pty() when the guest process should believe it is attached to a terminal. This is the right shape for shells, REPLs, TUIs, and SSH-like clients that need terminal behavior instead of plain pipes.

const shell = sandbox.pty("/bin/bash", [], {
  cwd: "/workspace",
  size: { rows: 30, cols: 120 },
});

await shell.ready;

shell.output
  .pipeThrough(new TextDecoderStream())
  .pipeTo(hostTerminalWritable);

hostTerminalReadable.pipeTo(shell.input);

shell.resize({ rows: 40, cols: 140 });

Undefined args default to an empty argv

Callers can still omit the argument list for commands that do not need one. undefined args are treated as [] at the public boundary for exec, spawn, and pty.

await sandbox.exec("pwd");

const process = sandbox.spawn("sleep");
const shell = sandbox.pty("/bin/sh", {
  size: { rows: 30, cols: 120 },
});

Process lifecycle is now explicit

Spawned handles expose readiness separately from process completion. This lets callers wire up streams immediately while still detecting launch failures correctly. If a process exits before it reaches the ready state, ready rejects and exit still resolves with the process termination details.

const process = sandbox.spawn("false");

try {
  await process.ready;
} catch (error) {
  // The command failed before it became ready.
}

const exit = await process.exit;

Signal exits are reported as signal exits instead of being collapsed into only numeric exit codes when the guest can report the signal.

const process = sandbox.spawn("sleep", ["60"]);
await process.kill("SIGTERM");

const exit = await process.exit;
// { exitCode: null, signal: "SIGTERM" }

Abort and stream closure behavior is stricter

Already-aborted signals now prevent spawn() and pty() from launching guest commands. Once a spawned process or PTY has closed its input stream, later writes fail instead of being accepted and ignored.

const controller = new AbortController();
controller.abort();

try {
  sandbox.spawn("touch", ["/tmp/should-not-exist"], {
    signal: controller.signal,
  });
} catch (error) {
  // No guest command was launched.
}

Choosing the right process API

Use pty() for programs that draw a terminal UI, check whether stdin is a TTY, use line editing, or expect job-control behavior. Shells, language REPLs, editors, pagers, and SSH-like clients should generally use this API.

const shell = sandbox.pty("/bin/bash", [], {
  size: { rows: 30, cols: 120 },
});

Use spawn() for services, build tools, test runners, package managers, and other commands where stdin, stdout, and stderr should remain separate streams.

const process = sandbox.spawn("npm", ["test"]);

process.stdout
  .pipeThrough(new TextDecoderStream())
  .pipeTo(logWritable);

const exit = await process.exit;

When piping host input into the guest, treat writable failures as the normal signal that the guest no longer accepts input. For long-lived clients, stop the host-side pipe when exit resolves or when the writable rejects.