Note
Parts of this document describe target behaviour that the code
doesn't fully ship yet β primarily kill() waiting for reap, the
timeoutMs cap on live execs, cwd validation, and pause() /
resume() on the host handle. Each one is called out inline as
"planned" so callers know which guarantees to lean on today vs.
wait for. Everything else has been reconciled with what main
actually ships.
Workspace.shell runs commands inside the sandbox container against the
same filesystem tree the DO writes to. Every exec is wrapped by an
incremental push (DO β container) before the command runs and an
incremental pull (container β DO) after it exits, so the VFS is the
authoritative copy after the call returns. The "after" half of that
bracket is qualified β see Sync semantics below.
Note
The package intentionally exposes one entry point β exec() β and
not a separate spawn / childProcess surface. Every exec is
detached: it returns immediately with an ExecHandle, and you await
result() (or consume the event stream) to observe completion. If you
want fire-and-forget, throw the handle away. If you want
run-and-wait, await handle.result(). There is no third mode.
interface WorkspaceShell {
exec<E extends "utf8" | undefined = undefined>(
command: string,
options?: {
id?: string;
cwd?: string;
encoding?: E;
/**
* Hard cap on how long the child may live before the runner
* sends `SIGKILL`. Defaults to ~320_000 ms. Pass a larger value
* for known long-running work; pass a smaller value to fail
* fast. Planned β not yet enforced (see "Limits").
*/
timeoutMs?: number;
},
): Promise<ExecHandle<E extends "utf8" ? string : Uint8Array>>;
get<E extends "utf8" | undefined = undefined>(
id: string,
options?: {
encoding?: E;
/**
* `"tail"` β resume from the live tail (default).
* `"full"` β replay every recorded event from `seq` 0.
* `number` β resume strictly after the given `seq`. Used by
* clients that recorded the last `seq` they saw and
* want exactly-once delivery across a reconnect.
*/
resume?: "tail" | "full" | number;
},
): Promise<ExecHandle<E extends "utf8" ? string : Uint8Array>>;
}
/**
* `T` is the payload type for stdout/stderr chunks:
* - `Uint8Array` for the default (binary) call signature.
* - `string` when `encoding: "utf8"` was passed.
*/
interface ExecHandle<T extends string | Uint8Array = Uint8Array>
extends ReadableStream<ExecEvent<T>>
{
/** Stable id for this execution. Pass to `shell.get(id)` to reattach. */
readonly id: string;
/** Resolves when the command exits. Drains the stream internally. */
result(): Promise<ExecResult<T>>;
/**
* Terminate the running command. Defaults to SIGTERM; pass `"SIGKILL"`
* for an unconditional kill. Resolves once the child has exited β
* equivalent to awaiting the `exit` event. Safe to call after exit
* (no-op).
*
* Planned: today `kill()` is fire-and-forget after signal delivery
* and resolves as soon as the signal is queued. The reap-await
* semantics described above are the target.
*/
kill(signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"): Promise<void>;
/**
* Planned. Pause/resume the underlying child's stdout/stderr without
* consuming the event stream. Useful for callers that want to gate a
* command (e.g., wait for an external go-ahead) without holding the
* stream open in a `for await`. Not yet shipped.
*/
pause?(): Promise<void>;
resume?(): Promise<void>;
}
type ExecEvent<T extends string | Uint8Array = Uint8Array> =
| { id: string; seq: number; name: "stdout"; value: T }
| { id: string; seq: number; name: "stderr"; value: T }
| { id: string; seq: number; name: "exit"; value: number };
type ExecSyncResult =
| { status: "complete"; applied: number; skipped: SkippedEntry[] }
| {
status: "pending";
applied: number;
skipped: SkippedEntry[];
error: string;
};
interface ExecResult<T extends string | Uint8Array = Uint8Array> {
exitCode: number;
stdout: T;
stderr: T;
pushed: number; // VFS changes uploaded before the command
pulled: number; // VFS changes downloaded after the command
skipped: SkippedEntry[];
sync: ExecSyncResult;
}The generic T on ExecHandle, ExecEvent, and ExecResult is
inferred from the call signature: exec(cmd) returns
ExecHandle<Uint8Array>, and exec(cmd, { encoding: "utf8" }) returns
ExecHandle<string>. There is no mixed mode β every chunk in a single
execution shares the same payload type.
Every event carries a monotonically increasing seq. The runner
records events in its per-exec log, and seq is the cursor get()
uses to position a resume:
resume: "tail"β start from the live tail, dropping anything that happened before reattach.resume: "full"β replay fromseq0. Useful for tooling that wants the complete transcript.resume: <number>β deliver only events strictly after the givenseq. The usual pattern is "remember the lastseqyou saw, pass it back after the reconnect".
Run-and-wait:
const run = await workspace.shell.exec("zig build", {
cwd: "/workspace",
encoding: "utf8",
});
const { exitCode, stdout, stderr, sync } = await run.result();
if (exitCode !== 0) throw new Error(stderr);
if (sync.status === "pending") {
console.warn(`command completed, but workspace sync is pending: ${sync.error}`);
}Stream stdout as the command runs:
const run = await workspace.shell.exec("npm test", { encoding: "utf8" });
let lastSeq = -1;
for await (const event of run) {
lastSeq = event.seq;
if (event.name === "stdout") process.stdout.write(event.value);
if (event.name === "stderr") process.stderr.write(event.value);
if (event.name === "exit") console.log(`exit ${event.value}`);
}Reattach to a long-running execution after a reconnect:
const run = await workspace.shell.exec("npm ci", {
id: "install-1",
encoding: "utf8",
});
// ... DO restart ...
const same = await workspace.shell.get("install-1", { resume: "tail" });
const { exitCode } = await same.result();Reattach via get() skips the original push frame: pushed reports
0 on the resulting ExecResult, and the post-exit pull is
best-effort β the reattached handle didn't own the bracket, so it
doesn't promise it.
Cancel a running command:
const run = await workspace.shell.exec("./long-running.sh");
// ...elsewhere...
await run.kill(); // SIGTERM
// or, after a grace period:
await run.kill("SIGKILL");exec takes one command string, and the container runs it through
/bin/sh -c. Interpolating a value straight into that string is a
shell-injection risk: a path or branch name like x; rm -rf / breaks
out of its argument.
Reach the Workspace through getWorkspace and call shell.exec as a
tagged template. Interpolated values are escaped before the command
runs. getWorkspace takes either the durable object stub from a
Worker (getWorkspace(env.MyDO.get(id))) or the durable object itself
from inside it (getWorkspace(this)); the surface is identical both
ways:
import { getWorkspace } from "@cloudflare/computer";
using ws = await getWorkspace(env.MyDO.get(id));
const file = "my notes.md";
const out = await (await ws.shell.exec`cat ${file}`).result(); // cat 'my notes.md'Strings and numbers are quoted, arrays are quoted element-by-element
and joined with spaces, and the literal parts of the template (the
trusted command) are emitted verbatim. The tagged-template form
defaults to string (utf8) output, since a caller reaching for it
almost always wants text back.
The plain exec(command, options) form is unchanged and still
available. Use it when you need cwd or backend, and wrap an
interpolated command in the sh tag to escape it:
import { sh } from "@cloudflare/computer";
await ws.shell.exec(sh`cat ${file}`, { cwd: "/workspace" });
await ws.shell.exec("npm test", { cwd: "/workspace", encoding: "utf8" });The plain form defaults to Uint8Array output; pass
{ encoding: "utf8" } for a string.
sh is exported on its own for composing a command string β the
building block both the tagged-template exec and the
exec(sh..., options) form use. Its escaping rules: strings and
numbers are quoted, arrays are quoted element-by-element, and the
static parts come from strings.raw so a backslash you write in the
template reaches the shell as written. To splice in deliberate shell
syntax β a pipe, a redirect, a pre-quoted sub-command β wrap the value
in { raw: "..." } to opt out of escaping for that one value:
await ws.shell.exec(sh`ls ${dir} ${{ raw: "| wc -l" }}`);The single-argument quoter shellQuote is exported for cases that
don't fit a template.
sh collapses a template to a finished string before the call
because of the RPC boundary. When a Worker calls exec through the
Workspace stub, the command crosses Workers RPC as a value and is run
on the durable-object side. A TemplateStringsArray does not survive
that trip intact β structured clone keeps the indexed string parts but
drops the .raw property the escaping relies on. So the escaping has
to happen in the caller, which is what getWorkspace's client does
for the tagged-template form and what sh does explicitly. The
remote stub's exec rejects a raw tagged-template call with a
TypeError rather than run an unescaped command, so the unsafe path
fails loudly.
cwd is optional and defaults to the workspace root (see
01. VFS). The target validation rejects any value that
isn't an absolute path under the workspace root β container-local
paths like /tmp will fail, as will relative paths that would
otherwise resolve against the runner's own cwd. Planned: the
runner currently passes cwd straight to child_process.spawn with
no validation. Don't rely on the guard until it lands.
- Before the command runs, every DO-side change the container hasn't seen is pushed, and lazy-mount stubs the command might touch are hydrated. See 02. Sync Protocol.
- After the command exits (any exit code, including non-zero), the
DO pulls every dirty change the command produced β but only when
the caller awaits
result(). The pull is wired toresult(), not to the stream closing, so callers that iterate theReadableStreamdirectly without ever callingresult()get the push frame but skip the post-exit pull. This is intentional:result()is the contract for "I want the VFS reconciled before I move on". Equivalently,pushed/pulledcounts onExecResultare only observable throughresult(). - For read-write mounts, container-side writes under the mount root are mirrored back to the provider after the pull (provider first, then VFS).
- Failed pushes/pulls do not change the command's exit code. If the
post-command pull fails,
result()still returns the command's exit code, stdout, and stderr. Itssyncfield hasstatus: "pending", zero applied entries, an empty skipped list, and a bounded error string with common credential forms redacted. A successful pull, including a clean no-op, hasstatus: "complete". The existingpushed,pulled, andskippedfields remain available for callers that only need counts.
Stdout and stderr are emitted as chunked bytes β each Node
Buffer the kernel hands us becomes one ExecEvent whose value is
a Uint8Array (or the decoded string slice under
encoding: "utf8"). There is no line splitting and no in-process
buffer larger than the kernel pipe.
Backpressure is pull-based, end to end, via the WHATWG
ReadableStream contract:
- The consumer (host iterator, or capnweb's flow controller for a remote consumer) stops pulling.
- The runner's
ReadableStreamfor the exec stops issuingpullcallbacks. - The runner pauses the child's stdout/stderr Node
Readables. - The kernel pipe fills.
- The child blocks on
write(2).
No ring buffer, no spill threshold, no in-process queue past what the stream contract permits. (Earlier drafts of this doc described a 1 MiB spill-to-log behaviour; that was never implemented and the design has landed on pull-based flow control instead.)
exit.value is the child's own exit code when it exits normally. When
the child is terminated by a signal the runner maps the signal to a
conventional code so the wire stays a plain number:
| Signal | exitCode |
|---|---|
| SIGTERM | 143 |
| SIGKILL | 137 |
| SIGINT | 130 |
| SIGHUP | 129 |
| (unknown) | -1 |
So exitCode === 137 means "killed by SIGKILL", not "the command's
own 137". Callers that need to distinguish "killed by us" from "killed
itself with that code" should track whether they called kill().
encoding: "utf8" decodes each chunk through a stateful TextDecoder
so multi-byte boundaries are preserved across chunk edges.
Known loss: any tail bytes returned by the decoder's final flush at end-of-stream are dropped today. In practice this only bites when the child exits mid-codepoint, which well-behaved programs don't do, but the silent-loss behaviour is on the revisit list.
- One execution per
idat a time. Reusing an id while a previous run is still active throwsEEXEC_BUSY. - Commands run as a single non-interactive process. No TTY allocation.
Write inputs to a file with
fs.writeFilefirst if a command needs stdin. - Live execs are bounded by
timeoutMs(default ~320_000 ms, per-call extensible). When the cap fires the runner sendsSIGKILLand emits a normalexitevent with code137. Planned: the current code has no cap; live execs run until the container is reaped. Don't rely on the cap until it lands. - Per-exec log retention defaults: up to 16 MiB of recorded events
per exec, kept for 5 minutes after exit. Both limits are
extensible. When the byte cap is exceeded, the oldest events are
evicted and subsequent
get({ resume: <number> })calls below the retained window throwELOG_TRUNCATED. After the TTL elapses the exec record is reaped and lookups throwENOENT.
| Code | Thrown when |
|---|---|
EEXEC_BUSY |
exec(..., { id }) is called while another exec with the same id is still active. |
ELOG_TRUNCATED |
get({ resume: <seq> }) requests a seq that's been evicted past the per-exec retention cap (default 16 MiB). |
ENOENT |
get(id) is called for an id that was never recorded, or whose retention TTL (default 5 min) has elapsed. |
These errors propagate as thrown rejections from the host call. They parallel the filesystem error surface in 04. Filesystem Interface.
Workspace.shell.exec resolves the backend to dispatch into on
every call. A workspace with one backend uses it as the default;
a workspace with more than one uses the first backend in the
list as the default and lets the caller name another through
ExecOptions.backend:
const run = await ws.shell.exec("npm test", { backend: "sandbox" });The id is the same selector Workspace.push(id?) and
Workspace.pull(id?) take. An unknown id rejects with a clear
error naming the configured backends.
Backends connect lazily β the first exec (or push / pull /
ready(id)) for an id dials it. The connect runs once per
workspace lifetime; the resolved handle is cached until the
backend's closed promise fires or Workspace.close() runs.
Each backend keeps its own pushRev / fetchRev watermarks in
the dofs _vfs_watermark table, keyed by the backend's id; a
pull from backend A never touches backend B's cursors.
A workspace with two backends that both touch /workspace has
no global ordering between them. The container backend's
writes land in the in-container VFS first and only become visible
to the DO after the next pull; the worker backend's writes land
in the DO directly through the host RPC. Two exec calls fired
close together against different backends can finish in either
order, and the file state the host store sees afterwards depends
on which side's pull ran last.
Callers that need a happens-before between cross-backend execs have to drive the sequencing themselves:
// Bad: races. The build's container-side writes may not be
// visible to the grep before grep runs.
const build = ws.shell.exec("npm test", { backend: "sandbox" });
const grep = ws.shell.exec("grep -r FAIL /workspace");
await Promise.all([build, grep]);
// Good: await the build's result() so its post-exec pull
// completes, then start the worker-backend exec.
await (await ws.shell.exec("npm test", { backend: "sandbox" })).result();
await (await ws.shell.exec("grep -r FAIL /workspace")).result();This is the same shape as the read-only mount enforcement note in 06. Mount Interface: the data layer doesn't try to invent a synthetic global order; the caller's await boundaries are what define one.
The following behaviours are not fully specified yet and may change before the API is stable. File an issue if your use case depends on a particular resolution.
- File watchers. Tools like
vitest --watch,next dev, andtsc --watchproduce a continuous stream of writes inside the container. The pull watermark advances onexec()boundaries, so a watcher's intermediate writes don't reach the DO until the nextexec()or explicitworkspace.pull(). Whether the workspace should grow a "live sync" mode that streams container revisions to the DO as they happen is an open design question. - Overlapping execs. Two
execcalls with differentids can be in flight at the same time. The push/pull cycles around them are not currently consolidated β eachexecdoes its own push before starting and its own pull after exiting, which can mean redundant work and surprising interleavings of dirty state. We plan to add batch consolidation (one push covering every pending exec, one pull draining everything in flight) but the exact semantics aren't decided. - Stdin. No streaming stdin today. The current workaround is to
write the input to a file and
<it in the command, but a proper stdin stream on theExecHandleis on the table.
See 07. Injected Service for how exec() is
served inside the container and
08. Capnweb Interface for the RPC framing.