From 6f9fb501938fbb20a115dde24ca6677cae699a73 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:01:32 +0000 Subject: [PATCH 1/2] computer, docs: Route execution through runtime Replace the public shell execution surface with workspace.runtime and route command execution through stable backend IDs. The existing container and worker command backends keep the push/pull bracket while sharing one client, stub, and observation path. Document the runtime surface and update the examples to call workspace.runtime instead of workspace.shell. --- README.md | 27 +- docs/01_vfs.md | 8 +- docs/02_sync_protocol.md | 23 +- docs/04_filesystem_interface.md | 6 +- docs/05_runtime_interface.md | 122 ++++ docs/05_shell_interface.md | 474 --------------- docs/07_injected_service.md | 6 +- docs/08_capnweb_interface.md | 4 +- docs/09_tool_interface.md | 6 +- docs/10_project_layout.md | 59 +- docs/11_lifecycle.md | 23 +- docs/12_worker_backend.md | 8 +- docs/13_git_interface.md | 5 +- docs/README.md | 71 +-- examples/artifacts/README.md | 2 +- examples/artifacts/src/index.ts | 2 +- examples/container/README.md | 4 +- examples/container/src/index.ts | 2 +- .../tsconfig.worker.json | 2 +- .../worker/runtime/adapter.test.ts | 2 +- .../worker/runtime/workspace.test.ts | 11 +- .../worker/runtime/workspace.ts | 81 ++- .../worker/think/agents.ts | 4 + examples/worker/src/index.ts | 2 +- packages/computer/README.md | 308 ++-------- packages/computer/src/backend.ts | 6 +- .../container/cloudflare-container.ts | 4 +- .../src/backends/worker/adapter.test.ts | 16 +- .../computer/src/backends/worker/adapter.ts | 24 +- .../src/backends/worker/entrypoint.test.ts | 36 ++ .../src/backends/worker/entrypoint.ts | 67 ++- .../src/backends/worker/worker.test.ts | 73 ++- .../computer/src/backends/worker/worker.ts | 88 ++- packages/computer/src/client.test.ts | 167 +++--- packages/computer/src/client.ts | 268 +++++---- packages/computer/src/index.ts | 44 +- .../computer/src/observe-integration.test.ts | 191 +------ packages/computer/src/observe.ts | 4 +- .../computer/src/observe/cloudflare.test.ts | 10 +- packages/computer/src/observe/cloudflare.ts | 2 +- packages/computer/src/proxy.ts | 14 +- packages/computer/src/retry.test.ts | 2 +- packages/computer/src/runtime/runtime.ts | 441 ++++++++++++++ packages/computer/src/runtime/types.ts | 114 ++++ packages/computer/src/runtime/wire.ts | 73 +++ packages/computer/src/sh.ts | 2 +- packages/computer/src/shell.test.ts | 127 ++++- packages/computer/src/shell.ts | 325 ++++++----- packages/computer/src/stub.test.ts | 256 ++------- packages/computer/src/stub.ts | 539 ++++++------------ .../computer/src/test-harness/shell.test.ts | 54 +- packages/computer/src/tools/ai.test.ts | 12 +- packages/computer/src/tools/exec.ts | 4 +- packages/computer/src/workspace.test.ts | 295 +++++++++- packages/computer/src/workspace.ts | 242 ++++++-- packages/computer/tests/proxy-worker.ts | 5 +- packages/computer/tests/proxy.test.ts | 12 + packages/computer/tests/stub-soak-worker.ts | 2 +- packages/computer/tests/stub-soak.test.ts | 8 +- .../computer/tests/worker-backend-worker.ts | 57 +- packages/dofs/src/fs/filesystem.ts | 6 +- packages/dofs/src/fs/readdir.test.ts | 7 + packages/dofs/src/fs/readdir.ts | 20 +- packages/dofs/src/fs/writeFile.test.ts | 30 + packages/dofs/src/fs/writeFile.ts | 23 +- packages/dofs/src/index.ts | 2 +- 66 files changed, 2661 insertions(+), 2273 deletions(-) create mode 100644 docs/05_runtime_interface.md delete mode 100644 docs/05_shell_interface.md create mode 100644 packages/computer/src/runtime/runtime.ts create mode 100644 packages/computer/src/runtime/types.ts create mode 100644 packages/computer/src/runtime/wire.ts diff --git a/README.md b/README.md index 323f6969..190043ff 100644 --- a/README.md +++ b/README.md @@ -2,28 +2,21 @@ Cloudflare Computer is a virtual filesystem that lives inside a Durable Object. The Durable Object holds the authoritative state in -SQLite and exposes the filesystem to a shell through a pluggable -backend. Two backends ship today: +SQLite and exposes one pluggable execution surface through +`workspace.runtime`. Two backends ship today: - **Container** projects the SQLite state into a sandbox container as a real FUSE mount. A sandbox-side daemon (`computerd`) mounts the state as a filesystem and syncs changes back over a capnweb RPC channel. Full Linux userland, real binaries, real network. -- **Worker** runs the shell as [just-bash](https://github.com/vercel-labs/just-bash) - inside a Dynamic Worker loaded through `env.LOADER`. The shell - reaches the host workspace over Workers RPC, so there is no second - store and no sync round trip. Broad textual tooling (`cat`, - `grep`, `awk`, `sed`, `jq`, ...), no container lifecycle. - -A single Workspace can host more than one backend at the same -time. Each backend registers under a stable `id`; `shell.exec` -defaults to the first one in the list and the caller routes a -specific call elsewhere with `{ backend: "sandbox" }`. Common -shape: a Worker backend for cheap textual tooling that runs every -command cold, plus a Container backend the agent reaches for when -it needs a real Linux environment for `npm`, `git`, or anything -else the worker isolate can't host. Each backend keeps its own -sync cursors and connects lazily on first use. +- **Isolate shell** runs [just-bash](https://github.com/vercel-labs/just-bash) + in a Dynamic Worker. It reaches the authoritative Workspace over + Workers RPC, so there is no second store or sync round trip. +A Workspace may register multiple backends under stable IDs. +`workspace.runtime.exec(source, { backend })` is the single execution +entry point; the selected backend defines how to interpret `source`. +The shipped backends treat it as a shell command. Backends connect lazily on +first use. Workspace can also be constructed without a backend at all, giving callers the filesystem on its own. diff --git a/docs/01_vfs.md b/docs/01_vfs.md index f62e5f1b..b348fe4b 100644 --- a/docs/01_vfs.md +++ b/docs/01_vfs.md @@ -64,10 +64,10 @@ via FUSE) create it like any other directory. ## Conventions -- **Absolute paths only.** Every fs and shell call takes an absolute +- **Absolute host paths.** Every `workspace.fs` path and command-backend `cwd` takes an absolute path starting with `/`. Relative paths are rejected with `EINVAL`. Resolve paths against `process.cwd()` (or the `cwd` option on - `shell.exec`) at the call site if you need relative semantics. + `runtime.exec`) at the call site if you need relative semantics. - **Forward slashes.** Paths are POSIX-style. Backslashes are not separators. - **No trailing slash.** `/workspace/foo` and `/workspace/foo/` are @@ -93,7 +93,7 @@ target shape: construction. - Read-only mounts (the default) reject all writes under their root with `EROFS`. Read-write mounts mirror writes back to the provider. -- Writes that originate from `shell.exec` under a read-only mount +- Writes that originate from `runtime.exec` under a read-only mount are silently dropped on the post-exec pull (see [02. Sync Protocol](./02_sync_protocol.md)). @@ -135,7 +135,7 @@ pinned `DISABLE_FUSE=1`, which produced a degraded mode where: directly; synchronization with the DO-side VFS happens over RPC through the post-exec pull bracket and explicit `workspace.push()` / `workspace.pull()` calls. -- Writes performed by `shell.exec` are picked up by the post-exec +- Writes performed by `runtime.exec` are picked up by the post-exec pull. `workspace.push()` flushes pending DO-side writes to the container without waiting for the next `exec()`. Use these when you need to synchronize the two sides outside of a command run. diff --git a/docs/02_sync_protocol.md b/docs/02_sync_protocol.md index aacec7e8..c75bc699 100644 --- a/docs/02_sync_protocol.md +++ b/docs/02_sync_protocol.md @@ -294,14 +294,14 @@ edited file) shows up exactly once on the wire. See - **Concurrent mutators.** `Workspace.push()` and `Workspace.pull()` go through a per-Workspace tail-promise FIFO. Two concurrent callers queue — the second can't enter `pushOnce` / `pullOnce` - until the first has resolved or rejected. The shell exec bracket - drives `push()` / `pull()` through the same facade, so - `shell.exec()` calls participate in the FIFO automatically. - Rejections aren't contagious: a failed mutation surfaces its - error to its own caller without poisoning the queue for the next. - Pure reads on `Workspace.fs` bypass the FIFO entirely — they hit - the local SQLite store, which the DO runtime already serialises - internally through its input gates. + until the first has resolved or rejected. A command's pre-exec push + and post-stream pull each use this facade, but the FIFO is not held + for the command's lifetime: overlapping commands and explicit sync + calls are not one transaction. Rejections aren't contagious: a + failed mutation surfaces its error to its own caller without + poisoning the queue for the next. Pure reads on `Workspace.fs` + bypass the FIFO entirely — they hit the local SQLite store, which + the DO runtime already serialises internally through its input gates. ## Conflict semantics @@ -322,8 +322,9 @@ the input gate wins and that is the authoritative state. The per-`Workspace` tail-promise FIFO adds a second layer of serialisation for `push()` and `pull()`: a concurrent pair of callers that both trigger sync operations will queue at the FIFO before either -enters `pushOnce` / `pullOnce`. `shell.exec()` participates in the -same FIFO automatically. +enters `pushOnce` / `pullOnce`. The push and pull phases of +`runtime.exec()` use that FIFO independently; the running command does +not hold it. ### Across two containers sharing one Workspace @@ -450,7 +451,7 @@ from the DO. Two options worth weighing later: - **Stub entries with an `ignored` flag** on `stat()`, surfaced via `readdir`. Easy to retrofit; surprising for tools that walk the tree and don't check the flag. -- **An explicit shell-only namespace** — e.g. `workspace.shell.readdir` +- **An explicit shell-only namespace** — e.g. `workspace.runtime.readdir` returns container-only entries, `workspace.fs.readdir` stays clean. Cleaner separation, larger API surface. diff --git a/docs/04_filesystem_interface.md b/docs/04_filesystem_interface.md index 33029554..1e40b11c 100644 --- a/docs/04_filesystem_interface.md +++ b/docs/04_filesystem_interface.md @@ -15,7 +15,7 @@ file could be large. ```ts interface Workspace { fs: WorkspaceFilesystem; - shell: WorkspaceShell; // see 05_shell_interface.md + runtime: WorkspaceRuntime; // see 05_runtime_interface.md } ``` @@ -232,7 +232,7 @@ const paths = await fs.ls("/workspace/.agents/skills"); ### `grep` Available on `Workspace.fs` for parity with the agent tools, and on -`Workspace.shell` when you want it to run inside the container (faster +`Workspace.runtime` when you want it to run inside the container (faster for large trees because it uses ripgrep). ```ts @@ -261,7 +261,7 @@ for (const hit of hits) { } ``` -See [05. Shell Interface](./05_shell_interface.md) for the container-side +See [05. Shell Interface](./05_runtime_interface.md) for the container-side variant. ## Error handling diff --git a/docs/05_runtime_interface.md b/docs/05_runtime_interface.md new file mode 100644 index 00000000..80c4e57f --- /dev/null +++ b/docs/05_runtime_interface.md @@ -0,0 +1,122 @@ +# 05. Runtime interface + +Workspace exposes one execution router: + +```ts +const handle = await workspace.runtime.exec(source, { + backend: "container-shell", + cwd: "/workspace", + encoding: "utf8", +}); + +const result = await handle.result(); +``` + +The backend ID defines how `source` is interpreted. The shipped +container and worker backends treat it as shell syntax. Module backends +can use the same surface for structured code execution. + +## API + +```ts +interface WorkspaceRuntime { + exec(source: string, options?: WorkspaceRuntimeExecOptions): Promise; + getExec(id: string, options?: WorkspaceRuntimeGetOptions): Promise; + killExec(id: string, options?: WorkspaceRuntimeKillOptions): Promise; + disposeExec(id: string, options?: WorkspaceRuntimeDisposeOptions): Promise; +} + +interface WorkspaceRuntimeExecOptions { + id?: string; + backend?: string; + cwd?: string; + encoding?: "utf8"; + input?: WorkspaceRuntimeValue; + timeoutMs?: number; +} + +interface WorkspaceRuntimeExecHandle extends ReadableStream { + readonly id: string; + readonly backend: string; + result(): Promise; + kill(signal?: KillSignal): Promise; + [Symbol.dispose](): void; +} +``` + +`input` is accepted by structured module backends and rejected by command +backends. `cwd` is the command working directory or, for module backends, +the base path for backend-specific resolution. A handle is single-consumer: +call `result()` or consume its event stream, not both. Repeated `result()` +calls return the same promise. `backend` records the resolved backend needed +for later reattachment. + +## Results + +```ts +interface WorkspaceRuntimeResult { + status: "completed" | "failed" | "cancelled"; + exitCode: number; + stdout: Uint8Array | string; + stderr: Uint8Array | string; + value?: WorkspaceRuntimeValue; + pushed: number; + pulled: number; + skipped: SkippedEntry[]; + sync: + | { status: "complete"; applied: number; skipped: SkippedEntry[] } + | { status: "pending"; applied: number; skipped: SkippedEntry[]; error: string }; +} +``` + +Command backends leave `value` unset. Module backends can use `value` for a +structured return value. A command can complete while its post-command pull +fails; in that case `sync.status` is `"pending"`, and a configured +`SyncRetryScheduler` can durably retry the pull without rerunning the +command. + +## Backend routing + +```ts +await workspace.runtime.exec("grep -R TODO .", { + backend: "isolate-shell", +}); + +await workspace.runtime.exec("npm test", { + backend: "container-shell", +}); +``` + +Omitting `backend` selects the first configured backend. Backend selection is +routing, not authorization; public gateways must validate it against +server-side policy. + +## Command synchronization + +Command backends continue to use the existing synchronization bracket: + +```text +push → spawn → events/result → pull +``` + +A backend with `sync: "none"`, such as `isolate-shell`, shares the host +store and reports zero push/pull counts. A container has its own VFS and +synchronizes changes before and after command execution. Fully draining +either `result()` or the event stream completes the post-command pull before +the stream closes. + +Module backends use host capability calls against the authoritative +Workspace and therefore require no push/pull round trip. + +## Lifecycle differences + +`container-shell` provides computerd's retained process log, replay, +signals, and disposal. + +`isolate-shell` intentionally preserves one-call, buffered-result behavior +in this release. It does not retain executions for later reattachment or +disposal. `timeoutMs` and a concurrent `killExec()` for a caller-supplied +execution ID cooperatively abort just-bash at statement boundaries; by the +time an ordinary `exec()` promise returns, the command has already settled. +Use the container backend when detached execution and retained lifecycle are +required. diff --git a/docs/05_shell_interface.md b/docs/05_shell_interface.md deleted file mode 100644 index e6741cd8..00000000 --- a/docs/05_shell_interface.md +++ /dev/null @@ -1,474 +0,0 @@ -# 05. Shell Interface - -> [!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](#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. - -## API - -```ts -interface WorkspaceShell { - exec( - 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>; - - get( - 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>; -} - -/** - * `T` is the payload type for stdout/stderr chunks: - * - `Uint8Array` for the default (binary) call signature. - * - `string` when `encoding: "utf8"` was passed. - */ -interface ExecHandle - extends ReadableStream> -{ - /** 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>; - - /** - * 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; - - /** - * 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; - resume?(): Promise; -} - -type ExecEvent = - | { 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 { - 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`, and `exec(cmd, { encoding: "utf8" })` returns -`ExecHandle`. There is no mixed mode — every chunk in a single -execution shares the same payload type. - -### `seq` and resume - -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 from `seq` 0. Useful for tooling that wants - the complete transcript. -- `resume: ` — deliver only events strictly after the given - `seq`. The usual pattern is "remember the last `seq` you saw, pass - it back after the reconnect". - -## Usage - -Run-and-wait: - -```ts -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: - -```ts -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: - -```ts -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: - -```ts -const run = await workspace.shell.exec("./long-running.sh"); -// ...elsewhere... -await run.kill(); // SIGTERM -// or, after a grace period: -await run.kill("SIGKILL"); -``` - -## Building commands safely - -`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: - -```ts -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: - -```ts -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: - -```ts -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. - -### Why escaping runs caller-side - -`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. - -## Working directory - -`cwd` is optional and defaults to the workspace root (see -[01. VFS](./01_vfs.md)). 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. - -## Sync semantics - -- **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](./02_sync_protocol.md). -- **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 to `result()`, not - to the stream closing, so callers that iterate the `ReadableStream` - directly without ever calling `result()` 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` / `pulled` counts on `ExecResult` are only - observable through `result()`. -- 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. Its `sync` field has `status: "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, has - `status: "complete"`. The existing `pushed`, `pulled`, and `skipped` fields - remain available for callers that only need counts. - -## Wire format and backpressure - -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: - -1. The consumer (host iterator, or capnweb's flow controller for a - remote consumer) stops pulling. -2. The runner's `ReadableStream` for the exec stops issuing `pull` - callbacks. -3. The runner pauses the child's stdout/stderr Node `Readable`s. -4. The kernel pipe fills. -5. 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-code mapping - -`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 - -`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. - -## Limits - -- One execution per `id` at a time. Reusing an id while a previous - run is still active throws `EEXEC_BUSY`. -- Commands run as a single non-interactive process. No TTY allocation. - Write inputs to a file with `fs.writeFile` first if a command needs - stdin. -- Live execs are bounded by `timeoutMs` (default ~320_000 ms, - per-call extensible). When the cap fires the runner sends `SIGKILL` - and emits a normal `exit` event with code `137`. **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: })` calls below the - retained window throw `ELOG_TRUNCATED`. After the TTL elapses the - exec record is reaped and lookups throw `ENOENT`. - -## Errors - -| Code | Thrown when | -| ----------------- | ------------------------------------------------------------------------------------------------------------ | -| `EEXEC_BUSY` | `exec(..., { id })` is called while another exec with the same `id` is still active. | -| `ELOG_TRUNCATED` | `get({ resume: })` 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](./04_filesystem_interface.md). - -## Backend selection - -`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`: - -```ts -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. - -### Cross-backend writes - -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: - -```ts -// 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](./06_mount_interface.md): the data layer -doesn't try to invent a synthetic global order; the caller's -await boundaries are what define one. - -## Unknowns - -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`, and - `tsc --watch` produce a continuous stream of writes inside the - container. The pull watermark advances on `exec()` boundaries, so a - watcher's intermediate writes don't reach the DO until the next - `exec()` or explicit `workspace.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 `exec` calls with different `id`s can be - in flight at the same time. The push/pull cycles around them are - not currently consolidated — each `exec` does 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 the `ExecHandle` is on the table. - -See [07. Injected Service](./07_injected_service.md) for how `exec()` is -served inside the container and -[08. Capnweb Interface](./08_capnweb_interface.md) for the RPC framing. diff --git a/docs/07_injected_service.md b/docs/07_injected_service.md index 246c373a..24b3548d 100644 --- a/docs/07_injected_service.md +++ b/docs/07_injected_service.md @@ -5,7 +5,7 @@ > `packages/computer/src/backends/`. Items marked **(planned)** are > deferred work. -The "injected service" is the computer daemon that runs *inside* the +The "injected service" is the workspace daemon that runs *inside* the sandbox container. It owns the FUSE mount, the in-container VFS, the exec runner, and the capnweb RPC endpoint the DO talks to. @@ -36,7 +36,7 @@ staging the binary into a container image. what surfaces those revisions back out. See doc 02 for the sync protocol. 3. **Exec.** Runs shell commands and streams stdout/stderr back over - capnweb. See [05. Shell Interface](./05_shell_interface.md). + capnweb. See [05. Shell Interface](./05_runtime_interface.md). 4. **Apply.** Accepts changes pushed by the DO and writes them into the VFS, suppressing its own dirty-tracking so deletes don't bounce back. @@ -115,7 +115,7 @@ Provider-agnostic shape — three steps, in order: ### Cloudflare Containers specifics -`CloudflareContainerBackend` (`packages/computer/src/backends/cloudflare-container.ts`) +`CloudflareContainerBackend` (`packages/computer/src/backends/container/cloudflare-container.ts`) wires it like this: 1. **Start.** `container.start({ enableInternet, env })` on the diff --git a/docs/08_capnweb_interface.md b/docs/08_capnweb_interface.md index ca654e05..8b9d4c83 100644 --- a/docs/08_capnweb_interface.md +++ b/docs/08_capnweb_interface.md @@ -198,7 +198,7 @@ type ExecEvent = | { id: string; seq: number; name: "exit"; value: number }; ``` -All payloads on the wire are binary. The host-side `Workspace.shell` +All payloads on the wire are binary. The host-side `Workspace.runtime` converts to `string` when the caller passes `encoding: "utf8"`. Every event carries a monotonic `seq` (per exec id) so callers can resume from a known point after a disconnect. @@ -264,7 +264,7 @@ backpressure rather than the originally-planned in-memory ring. **(planned)** the host-side exec handle will grow `pause()` / `resume()` for callers that want to throttle without relying on stream-pull semantics. See -[05. Shell Interface](./05_shell_interface.md). +[05. Shell Interface](./05_runtime_interface.md). ## Stream replay and durability diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index ae4cd063..125d2b45 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -5,10 +5,10 @@ The tools are thin wrappers over the existing `Workspace` surfaces: - `workspace.fs` for file reads, writes, edits, and directory listing. -- `workspace.shell.exec` for command execution when the caller opts in. +- `workspace.runtime.exec` for command execution when the caller opts in. - `workspace.assets` for publishing generated files when an assets publisher is configured. -Git access already ships through `workspace.git`, the third major surface on `Workspace` alongside `fs` and `shell`. AI SDK tool wrappers around that surface can land later against a stable target. See [`13_git_interface.md`](./13_git_interface.md). +Git access already ships through `workspace.git`, the third major surface on `Workspace` alongside `fs`, `runtime`, Assets, and Artifacts. AI SDK tool wrappers around that surface can land later against a stable target. See [`13_git_interface.md`](./13_git_interface.md). ## What ships @@ -223,7 +223,7 @@ Schema: } ``` -Calls `workspace.shell.exec(command, { cwd, encoding: "utf8", backend })`, waits for `result()`, and returns: +Calls `workspace.runtime.exec(command, { cwd, encoding: "utf8", backend })`, waits for `result()`, and returns: ```ts { diff --git a/docs/10_project_layout.md b/docs/10_project_layout.md index 19e4b5a7..6d7a0bf4 100644 --- a/docs/10_project_layout.md +++ b/docs/10_project_layout.md @@ -14,27 +14,27 @@ The workspace ships as a monorepo. Each published package lives under ``` ``` -computer/ +workspace/ ├── packages/ -│ ├── computer/ # @cloudflare/computer — DO-side facade, backends, proxy -│ ├── dofs/ # @cloudflare/dofs — SQLite-backed VFS + sync +│ ├── workspace/ # @cloudflare/computer — DO-side facade, backends, proxy +│ ├── vfs/ # @cloudflare/dofs — SQLite-backed VFS + sync │ ├── rpc/ # @cloudflare/computer-rpc — capnweb wire interface -│ ├── computerd/ # @cloudflare/computerd — in-container daemon (binary) +│ ├── computerd/ # @cloudflare/computerd — in-container daemon (binary) │ ├── fs-tools/ # (planned) AI SDK file tools + FileStore │ └── git-tools/ # (planned) AI SDK git tools ├── examples/ │ └── container/ # Reference container image for the computerd daemon ├── docs/ # This documentation set -└── package.json # Monorepo root (workspaces: packages/*, examples/*) +└── package.json # Workspace root (workspaces: packages/*, examples/*) ``` ### Folder rename history Two renames have landed: -- `packages/computer-rpc/` → `packages/rpc/` (folder only). The npm +- `packages/workspace-rpc/` → `packages/rpc/` (folder only). The npm package is still `@cloudflare/computer-rpc`. -- `packages/computer-fs/` → `packages/dofs/`, and the npm package +- `packages/workspace-fs/` → `packages/dofs/`, and the npm package was renamed `@cloudflare/computer-fs` → `@cloudflare/dofs`. If you grep older history or other docs and find the old folder paths, @@ -43,19 +43,19 @@ they refer to the same code under the new names. ## `packages/computer/` — `@cloudflare/computer` The DO-side facade. Owns the `Workspace` class, re-exports -`WorkspaceFilesystem` from `@cloudflare/dofs`, exposes the -`WorkspaceShell` surface, and selects between pluggable backends -(real Cloudflare container vs. test backend). Also ships the -`WorkspaceProxy` used by clients that want to talk to a workspace -through an RPC stub. +`WorkspaceFilesystem` from `@cloudflare/dofs`, exposes the public +`WorkspaceRuntime` surface, and routes execution across command and +module backends. It also ships the `WorkspaceProxy` used by clients +that talk to a workspace through an RPC stub. ``` packages/computer/ ├── src/ │ ├── index.ts # Public entrypoint │ ├── workspace.ts # Workspace facade -│ ├── shell.ts # WorkspaceShell -│ ├── backend.ts # Backend interface +│ ├── runtime/ # Public runtime router +│ ├── shell.ts # Internal command-backend adapter +│ ├── backend.ts # Command backend interface │ ├── backends/ │ │ ├── container/ # Cloudflare Container + computerd backend │ │ ├── worker/ # Dynamic Worker + just-bash backend @@ -65,18 +65,15 @@ packages/computer/ │ ├── stub.ts # DO stub helpers │ ├── test-harness-worker.ts # Worker entrypoint for the harness │ └── test-harness/ # Integration test wiring +├── tests/ # Workerd integration suites +├── test-harness/ # Container and load harness +├── rolldown.config.ts # ESM and declaration build ├── tsconfig.json -├── tsconfig.build.json # ESM build (extends ./tsconfig.json) -├── tsconfig.cjs.json # CJS build +├── tsconfig.build.json └── package.json ``` -Build: dual ESM + CJS via two `tsc` invocations -(`tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json`). The -`package.json` declares a single `.` export resolving to -`dist/cjs/index.js` (CJS) with ESM types alongside. There is no -`ws.js` or `shared.js` — the injected service is the separate `computerd` -package, and shared wire types live in `@cloudflare/computer-rpc`. +The package builds ESM and declarations with Rolldown. Public entrypoints include the root facade, Git, assets, artifacts, Container and Worker shell backends, and Cloudflare observability. The injected service is the separate `computerd` package, and shared wire types live in `@cloudflare/computer-rpc`. ## `packages/dofs/` — `@cloudflare/dofs` @@ -200,7 +197,10 @@ Runnable examples live at the repo root, not inside any package: ``` examples/ -└── container/ # Reference container image for computerd +├── container/ # Reference container image for computerd +├── worker/ # WorkerBackend example +├── code/ # workspace.runtime with Worker and Container shells +└── think/ # @cloudflare/think integration ``` The root `package.json` includes `examples/*` in its workspaces glob @@ -208,11 +208,9 @@ so each example can declare its own dependencies and scripts. ## Testing -- **Unit tests live next to source.** Every package follows the - `foo.ts` + `foo.test.ts` convention. There is no top-level - `tests/` directory anywhere in the repo. -- **Integration / harness tests** for the computer package live in - `packages/computer/test-harness/`: +- **Unit tests live next to source.** Packages follow the `foo.ts` + `foo.test.ts` convention. +- **Workerd integration tests** for WorkerBackend, Workspace RPC, and `workspace.runtime` live in `packages/computer/tests/` with dedicated Vitest and Wrangler configuration. +- **Container and load harness tests** live in `packages/computer/test-harness/`: - `end-to-end.test.ts` — DO ↔ container round-trip - `shell.test.ts` — shell surface against a real backend - `load.bench.ts` — load / soak benchmark @@ -227,9 +225,8 @@ so each example can declare its own dependencies and scripts. - **Biome.** Both linter and formatter are enabled in `biome.jsonc` at the repo root (`biome check` covers lint + format; `biome format` formats only). No ESLint, no Prettier. -- **esbuild.** Used by `packages/computerd/scripts/sea/bundle.mjs` to - produce the single-file `computerd` SEA bundle. Application bundling is - left to consumers. +- **Rolldown.** Builds the Workspace package's ESM entrypoints and declarations. +- **esbuild.** Used by `packages/computerd/scripts/sea/bundle.mjs` to produce the single-file `computerd` SEA bundle. - **vitest.** Drives unit tests in every package. `computerd` additionally uses `node --experimental-strip-types --test` for some scripts given its native-binary nature. diff --git a/docs/11_lifecycle.md b/docs/11_lifecycle.md index ff4a3c0e..cfa7bf37 100644 --- a/docs/11_lifecycle.md +++ b/docs/11_lifecycle.md @@ -122,8 +122,7 @@ lifetime policy. From the DO's perspective: `computerd` is a long-lived process. It outlives DO restarts — the `Container.monitor()` promise resolves only when the container itself exits, and the backend's `#monitoring` flag drops the cached handle at -that point so the next call rebuilds from scratch -(`cloudflare-container.ts:215-226`). +that point so the next call rebuilds from scratch (see the container host and backend implementations under `packages/computer/src/backends/container/`). The critical asymmetry: the **container's VFS is process-lifetime in-memory**, while the **DO's VFS is durable SQLite**. A container @@ -156,14 +155,12 @@ the `close` callback, the session is gone. ### Where capnweb attaches in our code On the DO side: `newWebSocketRpcSession(ws)` in -`CloudflareContainerBackend.connect()` (`cloudflare-container.ts:138-140`). +`CloudflareContainerBackend.connect()` in `packages/computer/src/backends/container/cloudflare-container.ts`. This installs `addEventListener("message", ...)` on the accepted WebSocket, which means **the DO must be alive in memory to receive frames**. There is no hibernation-aware variant today. -On the container side: `acceptWebSocketSession(ws, rpc)` in -`computerd.ts:181` for inbound `/ws` upgrades, or the same call in the -`/connect` outbound dial path at `computerd.ts:266`. Both attach to a `ws` +On the container side, `acceptWebSocketSession(ws, rpc)` is attached by the inbound upgrade and outbound `/connect` paths in `packages/computerd/src/cli/computerd.ts`. Both attach to a `ws` package WebSocket and require the `computerd` process to be live. ### Session lifecycle, today @@ -223,8 +220,8 @@ binds the envelope to a `using` variable so it disposes at the end of the scope. Drivers also drain the inner stream before disposal so the server side sees clean shutdown, not a cancelled stream. -For callers who use the driver helpers (`pullOnce`, `pushOnce`, -`WorkspaceShell.exec`), disposal is internal — nothing to do. +For callers who use the driver helpers (`pullOnce`, `pushOnce`, or +`workspace.runtime.exec`), transport disposal is internal. Callers who reach into `client.sync` / `client.shell` directly to invoke streaming methods inherit the disposal contract: bind the result to `using`, or call `result[Symbol.dispose]()` after @@ -236,15 +233,15 @@ draining. the boundary: - `WorkspaceStub`, returned from `getWorkspace()`. -- `WorkspaceExecHandleStub`, returned from `ws.shell.exec(...)`. -- `WorkspaceFilesystemStub` and `WorkspaceShellStub`, reached as - properties of `WorkspaceStub` (`ws.fs`, `ws.shell`). +- `WorkspaceRuntimeExecHandleStub`, returned from `ws.runtime.exec(...)`. +- `WorkspaceFilesystemStub` and `WorkspaceRuntimeStub`, reached as + properties of `WorkspaceStub` (`ws.fs`, `ws.runtime`). Worker-side callers dispose the two stubs they receive by direct return (the first two above). The sub-stubs reached as properties are not independently disposable in the Workers-RPC contract — their lifetime is bounded by the parent stub. Disposing -`WorkspaceStub` cascades to its `#fs` / `#shell` children on the +`WorkspaceStub` cascades to its `#fs` / `#runtime` children on the DO side. The minimal correct pattern from a Worker is: @@ -255,7 +252,7 @@ export default { const id = env.COMPUTERD.idFromName("user-123"); using ws = await env.COMPUTERD.get(id).getWorkspace(); - using handle = await ws.shell.exec("npm test"); + using handle = await ws.runtime.exec("npm test"); const result = await handle.result(); return Response.json({ exitCode: result.exitCode }); diff --git a/docs/12_worker_backend.md b/docs/12_worker_backend.md index a511d221..9a12c752 100644 --- a/docs/12_worker_backend.md +++ b/docs/12_worker_backend.md @@ -31,8 +31,8 @@ The worker backend trades the real environment for a Workers isolate that boots instantly, scales out cheaply, and has no container lifecycle. The shell is the just-bash interpreter; the supported command set is broad (`cat`, `grep`, `awk`, `sed`, `jq`, -`sort`, optional Python and JavaScript via WASM) but not the full -Linux userland. Filesystem operations forward into the same +`sort`) but not the full Linux userland. just-bash's Node-only language +commands are disabled on workerd. Filesystem operations forward into the same SQLite store as the container backend, so the storage shape, mount rules, and read-only enforcement are unchanged. @@ -46,9 +46,7 @@ Reach for the worker backend when: affecting the host DO. - You're sensitive to the per-session container cost. -Reach for the container backend when the agent runs `git`, `npm`, -a real language runtime, or anything else that wants a real -kernel under it. +Reach for the container backend when the agent runs `npm`, a real language runtime, native binaries, or anything else that needs a real kernel. Prefer the worker backend's host-forwarded `git` command for supported Git operations; use the container only for Git behavior that requires its native environment. ## Wire shape diff --git a/docs/13_git_interface.md b/docs/13_git_interface.md index b679febc..5ed59b6a 100644 --- a/docs/13_git_interface.md +++ b/docs/13_git_interface.md @@ -6,8 +6,7 @@ > `packages/computer/src/backends/worker/`. Everything below > works today. -`workspace.git` is the third major surface on `Workspace`, -alongside `fs` and `shell`. It runs every operation against the +`workspace.git` is a major typed surface on `Workspace`, alongside `fs`, `runtime`, Assets, and Artifacts. It runs every operation against the local SQLite-backed VFS through `isomorphic-git`, so a filesystem-only workspace (no backend) can drive a full clone/commit/diff cycle. @@ -132,7 +131,7 @@ console.log(stdout); ### Inside the shell (worker backend) ```ts -const handle = await ws.shell.exec( +const handle = await ws.runtime.exec( "git clone https://github.com/example/repo.git . && " + "echo 'hello world' > README.md && " + "git diff", diff --git a/docs/README.md b/docs/README.md index a2411363..8c9731f1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ It provides: - A fs API for working with files and directories compatible with Worker bindings. - R2-backed mounts for pre-filling read-only data into the workspace tree. - Durability over DO restarts for all file operations. - - A pluggable shell backend: a Cloudflare Container running the `computerd` FUSE daemon (full Linux userland) or a Dynamic Worker running [just-bash](https://github.com/vercel-labs/just-bash) (no container, broad textual tooling). + - Pluggable execution backends selected through `workspace.runtime`: a Cloudflare Container shell or a just-bash Dynamic Worker. - Workspace constructable without a backend, for filesystem-only use cases. - Out-of-the-box AI SDK tools for `@cloudflare/agents` through `@cloudflare/computer/tools`. @@ -41,9 +41,9 @@ The package ships several entrypoints: | Entrypoint | Purpose | | --- | --- | -| `@cloudflare/computer` | The Workspace facade, stub types, the R2 mount, and proxy classes. | +| `@cloudflare/computer` | The Workspace facade, first-class `workspace.runtime`, stub types, the R2 mount, and proxy classes. | | `@cloudflare/computer/backends/container` | `CloudflareContainerBackend` and `withWorkspaceContainer`. Pulls in the computerd / capnweb sync plumbing. | -| `@cloudflare/computer/backends/worker` | `WorkerBackend` and the bundled just-bash shell. The shell ships as a record of code-split modules the Dynamic Worker loads on demand: a ~290 KB entry parsed on cold start, plus ~2.5 MB of chunks that stay cold until a script reaches for them. | +| `@cloudflare/computer/backends/worker` | `WorkerBackend` and the bundled just-bash command runtime. | | `@cloudflare/computer/git` | Isomorphic-git glue for working with checkouts inside the workspace. | | `@cloudflare/computer/artifacts` | `createArtifact`, a session-scoped facade over the Cloudflare Artifacts Workers binding, plus its argv CLI. | | `@cloudflare/computer/tools` | AI SDK tools for agents: read, write, edit, ls, optional exec, and optional publish. | @@ -80,41 +80,32 @@ ENTRYPOINT ["/usr/local/bin/computerd"] ## Example ```ts -import { AIChatAgent } from "@cloudflare/ai-chat"; import { Workspace } from "@cloudflare/computer"; -import { CloudflareContainerBackend } from "@cloudflare/computer/backends/container"; - -export class Agent extends AIChatAgent { - readonly workspace: Workspace; - - constructor(...args: ConstructorParameters) { - super(...(args as [any, any])); - this.workspace = new Workspace({ - storage: this.ctx.storage, // DO storage → VFS lives here - backends: [ - new CloudflareContainerBackend({ - container: () => this, - workspace: { binding: "Agent", id: this.ctx.id.toString() }, - }), - ], - }); - } - - onStart() { - // Prime the backend connection in the background so the first - // exec is warm. `ready()` is idempotent and lazy-connects over - // the configured backends. - this.ctx.waitUntil( - (async () => { - await this.workspace.ready(); - await this.workspace.fs.mkdir("/workspace", { recursive: true }); - })().catch(() => {}), - ); - } +import { + CloudflareContainerBackend, + withWorkspaceContainer, +} from "@cloudflare/computer/backends/container"; +import { DurableObject } from "cloudflare:workers"; + +export class Agent extends withWorkspaceContainer(class extends DurableObject {}) { + readonly workspace = new Workspace({ + storage: this.ctx.storage, // DO storage → VFS lives here + backends: [ + new CloudflareContainerBackend({ + container: () => this, + workspace: { binding: "Agent", id: this.ctx.id.toString() }, + }), + ], + }); + + async initialize() { + await this.workspace.ready(); + await this.workspace.fs.mkdir("/workspace", { recursive: true }); + } } ``` -Once you have a `workspace` on your agent, the `fs` and `shell` surfaces feel a lot like Node's `fs/promises` and a shell session — everything is async, paths are absolute, and operations are durable across DO restarts. +Once you have a `workspace` on your Durable Object, the `fs` and `runtime` surfaces feel a lot like Node's `fs/promises` plus routed command execution — everything is async, paths are absolute, and operations are durable across DO restarts. Create and write files: @@ -169,7 +160,7 @@ for (const hit of hits) { Run a shell command in the sandbox — the same filesystem is mounted there, so writes from `fs` are immediately visible to `exec` and vice versa: ```ts -const run = await this.workspace.shell.exec("ls -la /workspace", { encoding: "utf8" }); +const run = await this.workspace.runtime.exec("ls -la /workspace", { encoding: "utf8" }); const { stdout, exitCode } = await run.result(); console.log(stdout, exitCode); ``` @@ -179,7 +170,7 @@ console.log(stdout, exitCode); ```ts // Inside a fetch handler on your Agent. async fetch(request: Request) { - const run = await this.workspace.shell.exec("npm test", { encoding: "utf8" }); + const run = await this.workspace.runtime.exec("npm test", { encoding: "utf8" }); const sse = run.pipeThrough( new TransformStream< @@ -225,7 +216,7 @@ above, then dive into the area you're working on. | [02. Sync Protocol](./02_sync_protocol.md) | How the DO-backed VFS synchronises with the sandbox container. | | [03. Filesystem Schema](./03_filesystem_schema.md) | SQLite schema backing the virtual filesystem. | | [04. Filesystem Interface](./04_filesystem_interface.md) | `Workspace.fs` API: `readFile`, `writeFile`, `mkdir`, `grep`, etc. | -| [05. Shell Interface](./05_shell_interface.md) | `Workspace.shell.exec` and streamed command execution. | +| [05. Runtime Interface](./05_runtime_interface.md) | `Workspace.runtime.exec/getExec/killExec/disposeExec` and backend routing. | | [06. Mount Interface](./06_mount_interface.md) | Pre-filling paths from R2, Artifacts, GitHub, and custom sources. **(not yet implemented)** | | [07. Injected Service](./07_injected_service.md) | The in-container `computerd` service that backs FUSE and shell. | | [08. Capnweb Interface](./08_capnweb_interface.md) | RPC wire protocol between the DO and the sandbox. | @@ -241,8 +232,8 @@ above, then dive into the area you're working on. ```ts interface Workspace { - fs: WorkspaceFilesystem; // 04_filesystem_interface.md - shell: WorkspaceShell; // 05_shell_interface.md, throws when no backend is configured + fs: WorkspaceFilesystem; + runtime: WorkspaceRuntime; /** Push pending DO-side writes to the configured backend. Resolves with the entry count. */ push(): Promise; @@ -258,4 +249,4 @@ interface Workspace { ``` See [04. Filesystem Interface](./04_filesystem_interface.md) and -[05. Shell Interface](./05_shell_interface.md) for the full surface, and [02. Sync Protocol](./02_sync_protocol.md) for `push`/`pull` semantics. +[05. Runtime Interface](./05_runtime_interface.md) for the full surface, and [02. Sync Protocol](./02_sync_protocol.md) for `push`/`pull` semantics. diff --git a/examples/artifacts/README.md b/examples/artifacts/README.md index be4dc3ee..6adfbf55 100644 --- a/examples/artifacts/README.md +++ b/examples/artifacts/README.md @@ -32,7 +32,7 @@ curl -X POST https://.workers.dev/create \ The Worker endpoint owns the orchestration. The durable object stays minimal: it owns the `Workspace`, exposes `getWorkspace()`, and bridges the host Artifacts binding into the worker-backend shell's `artifacts` command. -`POST /create` does the following through `ws.shell.exec(...)`: +`POST /create` does the following through `ws.runtime.exec(...)`: 1. clones `https://github.com/cloudflare/computer` into `/workspace/-source`; 2. copies `/workspace/-source/examples/worker` to `/workspace/`; diff --git a/examples/artifacts/src/index.ts b/examples/artifacts/src/index.ts index 17d1e747..95b5f549 100644 --- a/examples/artifacts/src/index.ts +++ b/examples/artifacts/src/index.ts @@ -196,7 +196,7 @@ async function exec( command: string, options: { cwd?: string; secretToRedact?: string } = {}, ): Promise { - const handle = await ws.shell.exec(command, { cwd: options.cwd, encoding: "utf8" }); + const handle = await ws.runtime.exec(command, { cwd: options.cwd, encoding: "utf8" }); try { const result = await handle.result(); if (result.exitCode === 0) return result.stdout; diff --git a/examples/container/README.md b/examples/container/README.md index 863b29c4..0e813bc3 100644 --- a/examples/container/README.md +++ b/examples/container/README.md @@ -53,7 +53,7 @@ client ─► Worker /c//{file,exec} `Workspace`). The Worker's fetch handler calls `await stub.getWorkspace()` once per request and then drives `ws.fs.writeFile(...)` / `ws.fs.readFile(...)` / - `ws.shell.exec(...)` directly. Promise pipelining keeps the + `ws.runtime.exec(...)` directly. Promise pipelining keeps the nested-property pattern (`ws.fs.writeFile`) at one round trip. The DO extends the plain `DurableObject` class from @@ -130,7 +130,7 @@ The Worker enables Cloudflare's built-in tracing in the workspace observer to `ctx.tracing` via the `@cloudflare/computer/observe/cloudflare` adapter. Every workspace operation (`workspace.connect`, `workspace.sync.push`, -`workspace.sync.pull`, `workspace.shell.exec`, and the +`workspace.sync.pull`, `workspace.runtime.exec`, and the `workspace.fs.*` family) opens a span on the runtime, so the dashboard under Workers → Observability → Traces shows them nested under the automatic fetch and Durable Object spans. diff --git a/examples/container/src/index.ts b/examples/container/src/index.ts index 7e503972..bbbadf1a 100644 --- a/examples/container/src/index.ts +++ b/examples/container/src/index.ts @@ -229,7 +229,7 @@ async function handleExec(request: Request, env: Env, name: string): Promise[0]); try { - const handle = await ws.shell.exec(command, { cwd: body.cwd, encoding: "utf8" }); + const handle = await ws.runtime.exec(command, { cwd: body.cwd, encoding: "utf8" }); const result = await handle.result(); return new Response(JSON.stringify(result), { status: 200, diff --git a/examples/think-compare-runtimes/tsconfig.worker.json b/examples/think-compare-runtimes/tsconfig.worker.json index e074965b..f738bef9 100644 --- a/examples/think-compare-runtimes/tsconfig.worker.json +++ b/examples/think-compare-runtimes/tsconfig.worker.json @@ -5,7 +5,7 @@ "lib": ["ES2022"], "allowImportingTsExtensions": true, "module": "ESNext", - "types": ["@cloudflare/workers-types/2023-07-01"], + "types": ["@cloudflare/workers-types"], "skipLibCheck": true, "moduleResolution": "bundler", "noEmit": true, diff --git a/examples/think-compare-runtimes/worker/runtime/adapter.test.ts b/examples/think-compare-runtimes/worker/runtime/adapter.test.ts index 6cb8c706..498df03c 100644 --- a/examples/think-compare-runtimes/worker/runtime/adapter.test.ts +++ b/examples/think-compare-runtimes/worker/runtime/adapter.test.ts @@ -19,7 +19,7 @@ describe("runtime adapters", () => { files.set(path, contents); }, }, - shell: { + runtime: { async exec(command: string) { return { async result() { diff --git a/examples/think-compare-runtimes/worker/runtime/workspace.test.ts b/examples/think-compare-runtimes/worker/runtime/workspace.test.ts index 262e83e3..1a287109 100644 --- a/examples/think-compare-runtimes/worker/runtime/workspace.test.ts +++ b/examples/think-compare-runtimes/worker/runtime/workspace.test.ts @@ -62,7 +62,7 @@ describe("createWorkspaceFixtureRuntime", () => { async ready(backend?: string) { calls.push(`ready ${backend ?? "default"}`); }, - shell: { + runtime: { async exec( command: string, options?: { backend?: string; cwd?: string; encoding?: "utf8"; timeoutMs?: number }, @@ -101,13 +101,14 @@ describe("createWorkspaceFixtureRuntime", () => { "find . -name package.json", "ls docs/workers", "pwd", + "echo '$(npm test)'", ])("exec keeps generic Workspace inspection on the worker shell backend: %s", async (command) => { const calls: string[] = []; const runner = createWorkspaceCommandRunner({ async ready(backend?: string) { calls.push(`ready ${backend ?? "default"}`); }, - shell: { + runtime: { async exec( actualCommand: string, options?: { backend?: string; cwd?: string; encoding?: "utf8" }, @@ -138,6 +139,10 @@ describe("createWorkspaceFixtureRuntime", () => { "npx vitest", "tsc --noEmit", "./scripts/check-docs.mjs", + 'echo "$(node --version)"', + 'echo "$(./scripts/check-docs.mjs)"', + `echo "$(printf ')'; npm test)"`, + 'echo "$(echo "$(node --version)")"', ])( "exec routes runtime and package commands to the Workspace container backend: %s", async (command) => { @@ -146,7 +151,7 @@ describe("createWorkspaceFixtureRuntime", () => { async ready(backend?: string) { calls.push(`ready ${backend ?? "default"}`); }, - shell: { + runtime: { async exec( actualCommand: string, options?: { backend?: string; cwd?: string; encoding?: "utf8" }, diff --git a/examples/think-compare-runtimes/worker/runtime/workspace.ts b/examples/think-compare-runtimes/worker/runtime/workspace.ts index 78f5ff28..93c3b5fd 100644 --- a/examples/think-compare-runtimes/worker/runtime/workspace.ts +++ b/examples/think-compare-runtimes/worker/runtime/workspace.ts @@ -18,7 +18,7 @@ interface WorkspaceFileStoreTarget { interface WorkspaceCommandTarget { ready(backend?: string): Promise; - shell: { + runtime: { exec( command: string, options: { backend?: string; cwd?: string; encoding: "utf8"; timeoutMs?: number }, @@ -57,7 +57,10 @@ export function createWorkspaceCommandRunner( async exec(command, options) { const backend = workspaceBackendForCommand(command); await workspace.ready(backend); - const handle = await workspace.shell.exec(command, toWorkspaceExecOptions(options, backend)); + const handle = await workspace.runtime.exec( + command, + toWorkspaceExecOptions(options, backend), + ); const { exitCode, stdout, stderr } = await handle.result(); return { exitCode, stdout, stderr, executionTarget: workspaceExecutionTarget(backend) }; }, @@ -100,13 +103,85 @@ const workerShellCommands = new Set([ const containerCommands = new Set(["node", "npm", "npx", "pnpm", "tsc", "vitest", "yarn"]); function workspaceBackendForCommand(command: string): "container" | "shell" { - const executables = shellExecutables(command); + const executables = shellCommands(command).flatMap(shellExecutables); if (executables.length === 0) return "shell"; return executables.every((executable) => workerShellCommands.has(executable)) ? "shell" : "container"; } +function shellCommands(command: string): string[] { + const substitutions = commandSubstitutions(command); + return [command, ...substitutions.flatMap(shellCommands)]; +} + +function commandSubstitutions(command: string): string[] { + const substitutions: string[] = []; + let singleQuoted = false; + let doubleQuoted = false; + let escaped = false; + for (let index = 0; index < command.length; index++) { + const char = command[index]; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === "'" && !doubleQuoted) { + singleQuoted = !singleQuoted; + continue; + } + if (char === '"' && !singleQuoted) { + doubleQuoted = !doubleQuoted; + continue; + } + if (singleQuoted) continue; + if (char === "`") { + const end = command.indexOf("`", index + 1); + if (end !== -1) { + substitutions.push(command.slice(index + 1, end)); + index = end; + } + continue; + } + if (char === "$" && command[index + 1] === "(") { + let depth = 1; + let end = index + 2; + let quote: "'" | '"' | null = null; + let innerEscaped = false; + for (; end < command.length && depth > 0; end++) { + const inner = command[end]; + if (innerEscaped) { + innerEscaped = false; + continue; + } + if (inner === "\\") { + innerEscaped = true; + continue; + } + if (quote) { + if (inner === quote) quote = null; + continue; + } + if (inner === "'" || inner === '"') { + quote = inner; + continue; + } + if (inner === "(") depth += 1; + else if (inner === ")") depth -= 1; + } + if (depth === 0) { + substitutions.push(command.slice(index + 2, end - 1)); + index = end - 1; + } + } + } + return substitutions; +} + function shellExecutables(command: string): string[] { return shellSegments(command).flatMap((segment) => { const executable = firstExecutable(segment); diff --git a/examples/think-compare-runtimes/worker/think/agents.ts b/examples/think-compare-runtimes/worker/think/agents.ts index 23e6387a..73e01e3a 100644 --- a/examples/think-compare-runtimes/worker/think/agents.ts +++ b/examples/think-compare-runtimes/worker/think/agents.ts @@ -264,6 +264,10 @@ export class WorkspaceThinkAgent extends RuntimeThinkAgent { } async getWorkspace(): Promise { + return this.__getWorkspaceStub(); + } + + async __getWorkspaceStub(): Promise { if (!this.#activeWorkspace) { throw new Error("WorkspaceThinkAgent has no active Workspace session."); } diff --git a/examples/worker/src/index.ts b/examples/worker/src/index.ts index 2f1c1590..7d6b0e65 100644 --- a/examples/worker/src/index.ts +++ b/examples/worker/src/index.ts @@ -189,7 +189,7 @@ async function handleExec(request: Request, env: Env, name: string): Promise[0]); try { - const handle = await ws.shell.exec(command, { cwd: body.cwd, encoding: "utf8" }); + const handle = await ws.runtime.exec(command, { cwd: body.cwd, encoding: "utf8" }); const result = await handle.result(); return new Response(JSON.stringify(result), { status: 200, diff --git a/packages/computer/README.md b/packages/computer/README.md index fd49151d..4fa1580c 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -11,11 +11,10 @@ > intent, not as description of the code today. Durable Object-side facade for Cloudflare Computer. Pairs a local -SQLite-backed VFS (via `@cloudflare/dofs`) with a pluggable backend -that decides where shell commands run. +SQLite-backed VFS (via `@cloudflare/dofs`) with pluggable execution +backends selected through `workspace.runtime.exec()`. -Two backends ship today, each on its own sub-path so the large -dependencies they carry can be tree-shaken when you only use one: +Two command backends ship today on tree-shakeable subpaths: - [`@cloudflare/computer/backends/container`](./src/backends/container/) — runs the shell inside a Cloudflare Container against a `computerd` @@ -29,11 +28,10 @@ dependencies they carry can be tree-shaken when you only use one: no second store, no sync round trip. See [`docs/12_worker_backend.md`](../../docs/12_worker_backend.md) and `examples/worker/`. - A backend can declare `sync: "none"` on the handle it returns to opt out of the push/pull bracket entirely — the worker backend does this because its shell shares the host store directly. The -bracket still runs around `shell.exec` so the surface stays +bracket still runs around `runtime.exec` so the surface stays uniform; the counts are just always zero. ## Public surface @@ -42,9 +40,10 @@ uniform; the counts are just always zero. backend handle, and the push/pull bracket. - `WorkspaceStub` — what `workspace.stub()` returns, designed to cross the Workers-RPC boundary into another Worker or DO. -- `WorkspaceShell` / `ExecHandle` — the command-execution half of - the API. Throws a clear error if the Workspace was constructed - without a backend. +- `workspace.runtime` / `WorkspaceRuntimeStub` — the single execution + surface: `exec`, `getExec`, `killExec`, and `disposeExec`. The selected + backend defines how to interpret the source. Module backends may include a + structured `value`; command backends return stdout/stderr and an exit code. - `workspace.git` — a typed git client backed by `isomorphic-git` against the local SQLite VFS. Surfaces both a TypeScript API (`workspace.git.clone({ url })`) and an @@ -68,80 +67,81 @@ uniform; the counts are just always zero. an Artifacts binding, the worker backend exposes the same CLI as an `artifacts` custom command. See [`docs/15_artifacts_interface.md`](../../docs/15_artifacts_interface.md). -- `createAITools` (from `@cloudflare/computer/tools`) — AI SDK - tools for agents: `read`, `write`, `edit`, `ls`, optional `exec`, - and optional `publish`. See - [`docs/09_tool_interface.md`](../../docs/09_tool_interface.md). ## Typical DO-side usage Container backend: ```ts -import { withWorkspace, WorkspaceProxy } from "@cloudflare/computer"; +import { Workspace, WorkspaceProxy } from "@cloudflare/computer"; import { CloudflareContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container"; import { DurableObject } from "cloudflare:workers"; export { WorkspaceProxy }; -// `withWorkspace` constructs the Workspace and installs the plumbing -// `getWorkspace` needs — no hand-written stub method. The options -// callback runs after `super(...)`, so it can read `self.ctx`. Compose -// it with `withWorkspaceContainer` when the durable object also owns -// the container binding. -export class ContainerExample extends withWorkspace( - withWorkspaceContainer(class extends DurableObject {}), - (self) => ({ - storage: self.ctx.storage, +export class ContainerExample extends withWorkspaceContainer(class extends DurableObject {}) { + #workspace = new Workspace({ + storage: this.ctx.storage, backends: [ new CloudflareContainerBackend({ - container: () => self, - workspace: { binding: "ContainerExample", id: self.ctx.id.toString() }, + container: () => this, + workspace: { binding: "ContainerExample", id: this.ctx.id.toString() }, }), ], - }), -) {} + }); + + async getWorkspace(): Promise { + await this.#workspace.ready(); + return this.#workspace.stub(); + } + + override fetch(req: Request) { return this.#workspace; /* see example */ } +} ``` Worker backend: ```ts -import { withWorkspace, WorkspaceServiceProxy } from "@cloudflare/computer"; +import { Workspace, WorkspaceServiceProxy } from "@cloudflare/computer"; import { WorkerBackend } from "@cloudflare/computer/backends/worker"; import { DurableObject } from "cloudflare:workers"; export { WorkspaceServiceProxy }; -export class ContainerExample extends withWorkspace( - class extends DurableObject {}, - (self) => ({ - storage: self.ctx.storage, +export class WorkerExample extends DurableObject { + #workspace = new Workspace({ + storage: this.ctx.storage, backends: [ new WorkerBackend({ - loader: self.env.LOADER, - workspace: { binding: "ContainerExample", id: self.ctx.id.toString() }, - ctx: self.ctx, + loader: this.env.LOADER, + workspace: { binding: "WorkerExample", id: this.ctx.id.toString() }, + ctx: this.ctx, }), ], - }), -) {} + }); + + async getWorkspace(): Promise { + await this.#workspace.ready(); + return this.#workspace.stub(); + } +} ``` -Filesystem only — no backend, no shell: +Filesystem only — no execution backend: ```ts const ws = new Workspace({ storage: ctx.storage }); await ws.ready(); await ws.fs.writeFile("/notes.md", "hello"); const body = await ws.fs.readFile("/notes.md", "utf8"); -// ws.shell throws — there's no backend wired up. +// ws.runtime throws — there's no backend wired up. ``` Pass `useThink: true` when assigning a workspace to `Think.workspace`. That adds Think's compatibility methods directly to the local instance -and to clients returned by `getWorkspace()`, while keeping the primary -file API on `workspace.fs`. +and to clients returned by `getWorkspace()`, while keeping `workspace.fs` +and `workspace.runtime` as the primary surfaces. Git, also without a backend: @@ -197,11 +197,10 @@ an in-shell `artifacts` command. See ## Multiple backends per workspace A Workspace can carry more than one backend. Each backend -registers under a stable `id` (defaulting to the backend's -diagnostic kind — `"worker"`, `"cloudflare-container"` — so -single-backend setups stay terse). `shell.exec` picks the -default (the first backend in the list) unless the caller -names a backend through `ExecOptions.backend`. Per-backend +registers under a stable selector `id` (defaulting to +`"isolate-shell"` or `"container-shell"`; this is intentionally separate from the diagnostic `type`). +`runtime.exec` picks the default (the first backend in the list) +unless the caller names one through `WorkspaceRuntimeExecOptions.backend`. Per-backend sync cursors live in dofs's `_vfs_watermark` table keyed by the same id; a push or pull against one backend never disturbs the other's cursors. @@ -225,10 +224,10 @@ const workspace = new Workspace({ }); // Default: the first backend in the list runs the command. -const grep = await ws.shell.exec("grep -r TODO /workspace"); +const grep = await ws.runtime.exec("grep -r TODO /workspace"); // Explicit: route a heavy build to the container. -const build = await ws.shell.exec("npm test", { backend: "sandbox" }); +const build = await ws.runtime.exec("npm test", { backend: "sandbox" }); ``` Backends connect lazily — the first `exec` (or `push` / @@ -240,216 +239,32 @@ the first one. A workspace with two backends that both write into `/workspace` has no global ordering between them; see -[`docs/05_shell_interface.md`](../../docs/05_shell_interface.md) +[`docs/05_runtime_interface.md`](../../docs/05_runtime_interface.md) for the caveat. ## Durable pending-sync retries -A command can finish after changing backend files while its post-command pull -fails. The command result reports `sync.status: "pending"`. If you configure a -`SyncRetryScheduler`, Workspace also writes one durable retry intent for that -backend. The library does not use an in-memory timer and cannot own the host -Durable Object's alarm. - -The scheduler contract contains only values that a host can persist: - -```ts -import { - type SyncRetryIntent, - type SyncRetryScheduler, - Workspace, -} from "@cloudflare/computer"; - -const RETRY_PREFIX = "workspace:sync-retry:"; +A command can finish after changing backend files while its post-command pull fails. The command result exposes `sync: { status: "pending", error, ... }`. Configure a `SyncRetryScheduler` on `Workspace` to persist one coalesced retry intent per backend, then call `workspace.retryPendingSync(backend)` from the owning Durable Object's alarm or another durable scheduler. Retries share the same per-backend mutation FIFO as `push`, `pull`, and command brackets, use bounded exponential backoff, clear their intent after success, and return `"exhausted"` after the configured maximum. The library intentionally does not own the host Durable Object's alarm. -class DurableObjectRetryScheduler implements SyncRetryScheduler { - constructor(private readonly state: DurableObjectState) {} - - async get(backend: string): Promise { - return this.state.storage.get(`${RETRY_PREFIX}${backend}`); - } - - async schedule(intent: SyncRetryIntent): Promise { - // schedule replaces the backend's existing intent, so repeated - // failures coalesce instead of creating an alarm queue. - await this.state.storage.put(`${RETRY_PREFIX}${intent.backend}`, intent); - - const intents = await this.state.storage.list({ - prefix: RETRY_PREFIX, - }); - const next = Math.min(...[...intents.values()].map((item) => item.notBefore)); - await this.state.storage.setAlarm(next); - } - - async clear(backend: string): Promise { - await this.state.storage.delete(`${RETRY_PREFIX}${backend}`); - } -} -``` - -Pass the scheduler to `Workspace` and invoke `retryPendingSync` from the host's -alarm or another durable scheduler: - -```ts -export class WorkspaceHost extends DurableObject { - readonly scheduler = new DurableObjectRetryScheduler(this.ctx); - readonly workspace = new Workspace({ - storage: this.ctx.storage, - backends: [/* ... */], - retryScheduler: this.scheduler, - retry: { - initialDelayMs: 1_000, - maxDelayMs: 60_000, - maxAttempts: 5, - }, - }); - - async alarm(): Promise { - const intents = await this.ctx.storage.list({ - prefix: RETRY_PREFIX, - }); - const now = Date.now(); - for (const intent of intents.values()) { - if (intent.notBefore <= now) { - const result = await this.workspace.retryPendingSync(intent.backend); - // An exhausted backend keeps its final intent in storage with a - // past-due notBefore. Clear it here so it stops driving the alarm; - // otherwise the wake-up fires immediately and forever. Inspect or - // alert before clearing if you need to surface the exhaustion. - if (result.status === "exhausted") { - await this.scheduler.clear(intent.backend); - } - } - } - - const remaining = await this.ctx.storage.list({ - prefix: RETRY_PREFIX, - }); - if (remaining.size > 0) { - await this.ctx.storage.setAlarm( - Math.min(...[...remaining.values()].map((item) => item.notBefore)), - ); - } - } -} -``` - -`retryPendingSync(backend?)` enters the same per-backend FIFO as commands, -`push`, and `pull`. It resumes `pullOnce` from the cursor already persisted in -SQLite. Success clears the intent. Failure replaces it with the next bounded -exponential-backoff attempt. Once `maxAttempts` fails, the final intent stays -in storage and the method returns `status: "exhausted"`; the host can inspect, -alert on, or explicitly clear it. Calling the method with no pending intent -returns `status: "idle"`. +See `SyncRetryScheduler`, `SyncRetryIntent`, and `SyncRetryOptions` in the root package exports for the persistence contract. ## Worker-side consumption ```ts -import { getWorkspace } from "@cloudflare/computer"; - export default { async fetch(request: Request, env: Env): Promise { const id = env.ContainerExample.idFromName("user-123"); - using ws = await getWorkspace(env.ContainerExample.get(id)); + using ws = await env.ContainerExample.get(id).getWorkspace(); await ws.fs.writeFile("/notes.md", "hello"); - using handle = await ws.shell.exec("ls /workspace", { encoding: "utf8" }); - const { exitCode, stdout, sync } = await handle.result(); - if (sync.status === "pending") { - console.warn("command completed before its filesystem changes synced", sync.error); - } + using handle = await ws.runtime.exec("ls /workspace"); + const { exitCode, stdout } = await handle.result(); return new Response(stdout, { status: exitCode === 0 ? 200 : 500 }); }, } satisfies ExportedHandler; ``` -`getWorkspace(stub)` calls the accessor the `withWorkspace` mixin -installed on the durable object, then wraps the returned stub in a -Worker-side client. Called with the durable object itself -(`getWorkspace(this)`), it returns the same client backed by the -in-isolate Workspace, so the surface is identical in both places. The -client mirrors the stub surface (`fs`, `git`, `shell`, `artifacts`, -`assets`); the only difference is that -`shell.exec` also accepts a tagged template, covered next. - -### Building commands safely - -`shell.exec` runs one command string through `/bin/sh -c` in the -container. Pasting a path or any other value straight into that string -is a shell-injection risk: a value like `x; rm -rf /` breaks out of its -argument. Call `exec` as a tagged template and interpolated values are -escaped for you: - -```ts -const file = "my notes.md"; -const out = await (await ws.shell.exec`cat ${file}`).result(); // cat 'my notes.md' -``` - -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. Use it when you -need `cwd` or `backend`, and wrap an interpolated command in the `sh` -tag to escape it: - -```ts -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` quotes strings and numbers, quotes arrays element-by-element, and -leaves the static template parts alone — they're the trusted command. -When you really do mean shell syntax, wrap the value in `{ raw: "..." }` -to opt out of escaping for that one value: - -```ts -await ws.shell.exec(sh`ls ${dir} ${{ raw: "| wc -l" }}`); -``` - -The escaping has to run in the caller, not on the durable-object side: -when the command crosses Workers RPC, a tagged template's `.raw` -property doesn't survive structured clone, so the wrapper (and `sh`) -collapse the template to a finished string before the call. The remote -stub's `exec` rejects a raw tagged-template call so the unescaped path -fails loudly. `shellQuote` is exported too, for the rare case where -you need to quote a single argument outside a template. - -### Reattaching to a run - -A command outlives the request that started it. Every handle carries -the run's `id`, and `shell.get(id)` reattaches to it from a later -request: - -```ts -using ws = await getWorkspace(env.ContainerExample.get(id)); - -// First request: start a long install and remember its id. -using started = await ws.shell.exec("npm install", { id: "install-1" }); - -// Later request, new handle, same run. `resume` picks where the -// replayed event stream starts: "tail" for live events only, "full" -// (the default) for everything the runner still holds, or a sequence -// number to resume after. -using again = await ws.shell.get("install-1", { encoding: "utf8", resume: "tail" }); -const { exitCode } = await again.result(); -``` - -The client mints an id when the caller doesn't pass one, so -`handle.id` is available either way. Reattach doesn't run the -push/pull bracket — it joins a run already in flight — so its sync -counts cover only what lands after the reattach. - -A run streams to one reader at a time. Dropping a handle — the `using` -above, or an explicit `cancel()` — hands the stream back, which is what -lets the next request reattach. Reattaching while an earlier handle is -still reading is refused. - ## Observability The package emits one span per documented operation through an optional @@ -486,14 +301,11 @@ The span names the package emits today: - `workspace.sync.push` / `workspace.sync.pull` — one per sync call. Tagged with the entry counts (`workspace.sync.pushed`, `workspace.sync.applied`, `workspace.sync.skipped`). -- `workspace.shell.exec` — the full exec bracket from the - `WorkspaceStub`. Contains `workspace.sync.push`, - `workspace.shell.exec.spawn`, and `workspace.sync.pull` as nested - children. Tagged with `workspace.shell.exit_code`, - `workspace.shell.pushed`, `workspace.shell.pulled`, - `workspace.shell.skipped`, and `workspace.shell.sync.status`. Pending - pulls also set `workspace.shell.sync.error` to the same bounded, - credential-redacted error returned in `ExecResult.sync`. +- `workspace.runtime.exec.spawn` — command-backend dispatch. Command + synchronization emits separate `workspace.sync.push` and + `workspace.sync.pull` spans. Module-runtime instrumentation currently + records backend connection and capability filesystem operations; a + unified parent execution span is planned. - `workspace.fs.` — one per filesystem call routed through the stub (`readFile`, `writeFile`, `stat`, `readdir`, `find`, `ls`, `grep`, `mkdir`, `rm`). Tagged with `workspace.fs.path` and, where @@ -521,8 +333,8 @@ discipline is the same. The minimum a caller needs to know: - `using` the value returned from `env.COMPUTERD.get(id).getWorkspace()`. -- `using` the handle returned from `ws.shell.exec(...)`. -- Don't worry about `ws.fs`, `ws.shell`, or `ws.git` — those are +- `using` the handle returned from `ws.runtime.exec(...)`. +- Don't worry about `ws.fs`, `ws.runtime`, or `ws.git` — those are property accessors that ride with the parent. - Pure-value returns (`readFile` as a string, `stat`, `readdir`, `git.cli({...})`, etc.) carry no stubs; nothing to dispose. diff --git a/packages/computer/src/backend.ts b/packages/computer/src/backend.ts index 8ca50a1d..721547c0 100644 --- a/packages/computer/src/backend.ts +++ b/packages/computer/src/backend.ts @@ -1,6 +1,6 @@ // A backend produces a connection to a shell runtime. The // Workspace constructor takes a set of backends keyed by `id`; -// `Workspace.shell.exec("...", { backend })` picks one by id. +// `Workspace.runtime.exec("...", { backend })` picks one by id. // `id` is user-supplied so the same shape can host multiple // instances of the same backend kind (two workers on different // loaders, a worker + a container side by side, ...). `type` @@ -27,8 +27,8 @@ export interface WorkspaceBackend { // // Each concrete backend constructor accepts an `id` option // and defaults it to a sensible string when omitted: "test" - // for TestBackend, "cloudflare-container" for the container - // backend, "worker" for the worker backend. Single-backend + // for TestBackend, "container-shell" for the container + // backend, and "isolate-shell" for the worker backend. Single-backend // setups can ride the default and never touch the field. readonly id: string; diff --git a/packages/computer/src/backends/container/cloudflare-container.ts b/packages/computer/src/backends/container/cloudflare-container.ts index d18b0bf1..aa43a62d 100644 --- a/packages/computer/src/backends/container/cloudflare-container.ts +++ b/packages/computer/src/backends/container/cloudflare-container.ts @@ -123,7 +123,7 @@ export interface CloudflareContainerBackendOptions { healthRetryMaxDelayMs?: number; // Selector this backend is registered under in Workspace. - // Defaults to "cloudflare-container"; override when the + // Defaults to "container-shell"; override when the // workspace hosts more than one instance of the same backend // kind (e.g. two containers pinned to different pool members). id?: string; @@ -158,7 +158,7 @@ export class CloudflareContainerBackend implements WorkspaceBackend { #handle: BackendHandle | undefined; constructor(options: CloudflareContainerBackendOptions) { - this.id = options.id ?? "cloudflare-container"; + this.id = options.id ?? "container-shell"; this.#options = { container: options.container, workspace: options.workspace, diff --git a/packages/computer/src/backends/worker/adapter.test.ts b/packages/computer/src/backends/worker/adapter.test.ts index b76f29f8..04ac7e56 100644 --- a/packages/computer/src/backends/worker/adapter.test.ts +++ b/packages/computer/src/backends/worker/adapter.test.ts @@ -10,12 +10,12 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { Bash } from "just-bash"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { BackendHandle, WorkspaceBackend } from "../../backend.js"; import { WorkspaceFilesystemStub } from "../../stub.js"; import { Workspace } from "../../workspace.js"; -import { WorkspaceFsAdapter } from "./adapter.js"; +import { type WorkspaceFs, WorkspaceFsAdapter } from "./adapter.js"; const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s); const fromUtf8 = (b: Uint8Array): string => new TextDecoder("utf-8").decode(b); @@ -94,6 +94,18 @@ describe("WorkspaceFsAdapter — reads", () => { expect(await adapter.exists("/missing")).toBe(false); }); + it("keeps expected PATH misses on the host side", async () => { + const exists = vi.fn(async () => false); + const stat = vi.fn(async () => { + throw Object.assign(new Error("should not cross RPC"), { code: "ENOENT" }); + }); + const pathAdapter = new WorkspaceFsAdapter({ exists, stat } as unknown as WorkspaceFs); + + await expect(pathAdapter.exists("/usr/bin/cat")).resolves.toBe(false); + expect(exists).toHaveBeenCalledWith("/usr/bin/cat"); + expect(stat).not.toHaveBeenCalled(); + }); + it("readdir returns immediate child names", async () => { await workspace.fs.mkdir("/d"); await workspace.fs.writeFile("/d/a", "a"); diff --git a/packages/computer/src/backends/worker/adapter.ts b/packages/computer/src/backends/worker/adapter.ts index ca968121..093e233b 100644 --- a/packages/computer/src/backends/worker/adapter.ts +++ b/packages/computer/src/backends/worker/adapter.ts @@ -35,6 +35,7 @@ export interface WorkspaceFs { readFile(path: string): Promise>; readFile(path: string, encoding: "utf8"): Promise; readFile(path: string, options: ReadFileOptions): Promise>; + exists(path: string): Promise; stat(path: string): Promise; lstat(path: string): Promise; readdir(path: string): Promise; @@ -95,24 +96,23 @@ export class WorkspaceFsAdapter { async exists(path: string): Promise { if (isVirtualDevPath(path)) return true; - try { - await this.#fs.stat(path); - return true; - } catch (err) { - if ((err as { code?: string }).code === "ENOENT") return false; - throw err; - } + // Resolve expected misses on the host side. Throwing ENOENT across Workers + // RPC makes workerd report an uncaught exception even when just-bash is + // deliberately probing PATH and catches the rejection. + return this.#fs.exists(path); } async stat(path: string): Promise { const virtual = virtualDevStat(path); if (virtual !== undefined) return virtual; + if (!(await this.#fs.exists(path))) throw missingPath(path); return mapStat(await this.#fs.stat(path)); } async lstat(path: string): Promise { const virtual = virtualDevStat(path); if (virtual !== undefined) return virtual; + if (!(await this.#fs.exists(path))) throw missingPath(path); return mapStat(await this.#fs.lstat(path)); } @@ -256,6 +256,16 @@ export class WorkspaceFsAdapter { } } +function missingPath(path: string): Error & { code: string; path: string } { + const error = new Error(`ENOENT: no such file or directory, stat '${path}'`) as Error & { + code: string; + path: string; + }; + error.code = "ENOENT"; + error.path = path; + return error; +} + function mapStat(s: WorkspaceStatResult): FsStat { return { isFile: s.isFile, diff --git a/packages/computer/src/backends/worker/entrypoint.test.ts b/packages/computer/src/backends/worker/entrypoint.test.ts index 63dc5b8a..0a988568 100644 --- a/packages/computer/src/backends/worker/entrypoint.test.ts +++ b/packages/computer/src/backends/worker/entrypoint.test.ts @@ -199,6 +199,42 @@ describe("ShellWorker", () => { await expect(worker.killExec({ id: "missing" })).resolves.toBeUndefined(); }); + it("enforces timeoutMs through just-bash's cooperative abort signal", async () => { + const worker = TestShellWorker.withFakeBash(fakeEnv(), async (_command, options) => { + if (options.signal?.aborted) throw options.signal.reason; + await new Promise((_, reject) => { + options.signal?.addEventListener("abort", () => reject(options.signal?.reason)); + }); + throw new Error("unreachable"); + }); + const events = await drain( + (await worker.exec({ id: "timeout", command: "while true; do :; done", timeoutMs: 5 })) + .events, + ); + expect(events).toMatchObject([ + { id: "timeout", name: "stderr", value: "Execution timed out\n" }, + { id: "timeout", name: "exit", value: 124 }, + ]); + }); + + it("kills an in-flight explicit execution id cooperatively", async () => { + const worker = TestShellWorker.withFakeBash(fakeEnv(), async (_command, options) => { + if (options.signal?.aborted) throw options.signal.reason; + await new Promise((_, reject) => { + options.signal?.addEventListener("abort", () => reject(options.signal?.reason)); + }); + throw new Error("unreachable"); + }); + const pending = worker.exec({ id: "cancel", command: "sleep 60" }); + await Promise.resolve(); + await worker.killExec({ id: "cancel", signal: "SIGINT" }); + const events = await drain((await pending).events); + expect(events).toMatchObject([ + { id: "cancel", name: "stderr", value: "Execution cancelled with SIGINT\n" }, + { id: "cancel", name: "exit", value: 130 }, + ]); + }); + it("fetch() rejects plain HTTP with a clear error", async () => { const worker = new TestShellWorker(undefined as never, fakeEnv() as never); const response = await worker.fetch(new Request("http://shell/", { method: "GET" })); diff --git a/packages/computer/src/backends/worker/entrypoint.ts b/packages/computer/src/backends/worker/entrypoint.ts index 531b8efe..1f69467b 100644 --- a/packages/computer/src/backends/worker/entrypoint.ts +++ b/packages/computer/src/backends/worker/entrypoint.ts @@ -6,7 +6,9 @@ // the Worker Loader callback the host DO supplied. No state // survives across exec calls: each builds its own Bash // instance, its own WorkspaceFsAdapter, and disposes the -// workspace stub when the run settles. +// workspace stub when the run settles. The only shared per-execution state is +// an AbortController map keyed by explicit execution id so timeout and kill +// requests can cooperatively stop just-bash at statement boundaries. // // That shape matters for two reasons. First, the workspace // reach happens on the user Worker's side, so every fs RPC the @@ -36,10 +38,6 @@ export interface ShellWorkerOptions { // container backend uses, so scripts that hard-code that path // keep working. cwd?: string; - // Forwarded to the Bash constructor verbatim. Off by default; - // each is a security knob and the consumer Worker opts in. - python?: boolean; - javascript?: boolean; } // Env shape the host Worker is expected to wire through the @@ -74,6 +72,12 @@ export interface HostWorkspaceStub extends GitCommandHost, AssetsCommandHost { } const DEFAULT_CWD = "/workspace"; +const MAX_OUTPUT_BYTES = 1024 * 1024; +const defenseDebug = console.debug.bind(console); +console.debug = (...args: unknown[]) => { + if (String(args[0]).startsWith("[DefenseInDepthBox] Could not register import() hooks:")) return; + defenseDebug(...args); +}; // Wire event shape. Matches @cloudflare/computer-rpc's ExecEvent // but with stdout/stderr values as utf8 strings (the host-side @@ -87,9 +91,11 @@ type WireEvent = export class ShellWorker< Env extends ShellWorkerEnv = ShellWorkerEnv, > extends WorkerEntrypoint { - // Subclasses override to opt into python / javascript or to - // change the default cwd. The base class leaves both off. + // Subclasses override to change the default cwd. + // just-bash's Python and JavaScript commands require + // node:worker_threads and cannot run in workerd. protected readonly shellOptions: ShellWorkerOptions = {}; + readonly #executions = new Map(); // Test seam: when set, exec uses this to spawn the command // instead of `new Bash({...}).exec(...)`. Real production @@ -134,10 +140,29 @@ export class ShellWorker< async exec(input: ExecInput): Promise<{ id: string; events: ReadableStream }> { const id = input.id ?? crypto.randomUUID(); const cwd = input.cwd ?? this.shellOptions.cwd ?? DEFAULT_CWD; + if (this.#executions.has(id)) + throw createShellError("EEXEC_BUSY", `execution ${id} is running`); + const controller = new AbortController(); + this.#executions.set(id, controller); + let timedOut = false; + const timer = + input.timeoutMs === undefined || input.timeoutMs === 0 + ? undefined + : setTimeout(() => { + timedOut = true; + controller.abort(new Error("Execution timed out")); + }, input.timeoutMs); - // Reach the host workspace on each call. Two concurrent - // execs get two stubs; no instance field, no race. - const ws = await this.env.HOST.getWorkspace(); + // Reach the host workspace on each call. Concurrent execs get separate + // stubs; only their cancellation controllers share this entrypoint. + let ws: HostWorkspaceStub; + try { + ws = await this.env.HOST.getWorkspace(); + } catch (error) { + if (timer !== undefined) clearTimeout(timer); + if (this.#executions.get(id) === controller) this.#executions.delete(id); + throw error; + } const customCommands: CustomCommand[] = [ defineGitCommand(ws), @@ -151,6 +176,7 @@ export class ShellWorker< if (this.bashFactoryOverride !== undefined) { result = await this.bashFactoryOverride(input.command, { cwd, + signal: controller.signal, customCommands, }); } else { @@ -159,8 +185,6 @@ export class ShellWorker< ConstructorParameters[0] >["fs"], cwd, - python: this.shellOptions.python, - javascript: this.shellOptions.javascript, customCommands, // just-bash's in-process DefenseInDepthBox activates by // registering ESM loader hooks through node:module's @@ -171,13 +195,20 @@ export class ShellWorker< // which is the real boundary. Opt out so the interpreter // runs on the workerd target. defenseInDepth: { enabled: false }, + executionLimits: { maxOutputSize: MAX_OUTPUT_BYTES }, }); - result = await bash.exec(input.command, { cwd }); + result = await bash.exec(input.command, { cwd, signal: controller.signal }); } } catch (error) { const message = error instanceof Error ? error.message : String(error); - result = { stdout: "", stderr: `${message}\n`, exitCode: 1 }; + result = { + stdout: "", + stderr: `${timedOut ? "Execution timed out" : message}\n`, + exitCode: timedOut ? 124 : controller.signal.aborted ? 130 : 1, + }; } finally { + if (timer !== undefined) clearTimeout(timer); + if (this.#executions.get(id) === controller) this.#executions.delete(id); // Dispose the workspace stub after Bash has fully settled. // Bash held a reference to the adapter for the duration of // the exec; once .exec resolves the adapter's underlying @@ -212,13 +243,13 @@ export class ShellWorker< throw createShellError("ENOENT", `no such exec: ${input.id}`); } - async killExec(_input: { + async killExec(input: { id: string; signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"; }): Promise { - // No state to kill. Bash runs synchronously to completion - // inside exec(); by the time a caller could observe an id - // through the returned envelope the run has already settled. + this.#executions + .get(input.id) + ?.abort(new Error(`Execution cancelled with ${input.signal ?? "SIGTERM"}`)); } } diff --git a/packages/computer/src/backends/worker/worker.test.ts b/packages/computer/src/backends/worker/worker.test.ts index f6789846..d199be57 100644 --- a/packages/computer/src/backends/worker/worker.test.ts +++ b/packages/computer/src/backends/worker/worker.test.ts @@ -148,6 +148,21 @@ describe("WorkerBackend", () => { expect(envelope.id).toBe("run-1"); }); + it("errors the stream on malformed execution frames", async () => { + const fetcher = fakeFetcher(() => ({ + id: "bad", + events: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"id":"bad","name":"exit"}\n')); + controller.close(); + }, + }), + })); + const handle = await new WorkerBackend({ fetcher: () => fetcher }).connect(); + const envelope = await handle.rpc.shell.exec({ command: "bad" }); + await expect(envelope.events.getReader().read()).rejects.toMatchObject({ code: "EPROTOCOL" }); + }); + it("forwards cwd and id options to the fetcher", async () => { let observed: { command: string; cwd?: string; id?: string } | undefined; const fetcher = fakeFetcher((input) => { @@ -185,7 +200,7 @@ describe("WorkerBackend", () => { backends: [new WorkerBackend({ fetcher: () => fetcher })], }); await ws.ready(); - const handle = await ws.shell.exec("echo world", { encoding: "utf8" }); + const handle = await ws.runtime.exec("echo world", { encoding: "utf8" }); const result = await handle.result(); expect(result.exitCode).toBe(0); expect(result.stdout).toBe("world\n"); @@ -230,6 +245,62 @@ describe("WorkerBackend", () => { expect(observedFlags).toEqual(["nodejs_compat"]); }); + it("disposes Loader entrypoint and worker handles exactly once", async () => { + let entrypointDisposals = 0; + let workerDisposals = 0; + const entrypoint = { + ...fakeFetcher(() => ({ + id: "x", + events: framedStream([{ id: "x", seq: 1, name: "exit", value: 0 }]), + })), + [Symbol.dispose]() { + entrypointDisposals += 1; + }, + }; + const loader = { + get() { + return { + getEntrypoint: () => entrypoint, + [Symbol.dispose]() { + workerDisposals += 1; + }, + }; + }, + }; + const backend = new WorkerBackend({ + loader, + workspace: { binding: "WorkspaceHost", id: "abc" }, + ctx: { exports: { WorkspaceServiceProxy: () => ({}) } }, + }); + const handle = await backend.connect(); + await handle.close(); + await handle.close(); + expect(entrypointDisposals).toBe(1); + expect(workerDisposals).toBe(1); + }); + + it("disposes the Loader worker when entrypoint creation fails", async () => { + let workerDisposals = 0; + const backend = new WorkerBackend({ + loader: { + get() { + return { + getEntrypoint() { + throw new Error("entrypoint failed"); + }, + [Symbol.dispose]() { + workerDisposals += 1; + }, + }; + }, + }, + workspace: { binding: "WorkspaceHost", id: "abc" }, + ctx: { exports: { WorkspaceServiceProxy: () => ({}) } }, + }); + await expect(backend.connect()).rejects.toThrow("entrypoint failed"); + expect(workerDisposals).toBe(1); + }); + it("resolves an async fetcher factory once per connect()", async () => { // A factory that fetches code from KV before minting the // Worker Loader stub will be async. The backend awaits it diff --git a/packages/computer/src/backends/worker/worker.ts b/packages/computer/src/backends/worker/worker.ts index c0ebf291..154c17fb 100644 --- a/packages/computer/src/backends/worker/worker.ts +++ b/packages/computer/src/backends/worker/worker.ts @@ -56,6 +56,7 @@ interface WorkerLoaderLike { getCode: () => WorkerLoaderCode | Promise, ): { getEntrypoint(name?: string): unknown; + [Symbol.dispose]?: () => void; }; } @@ -123,7 +124,7 @@ export interface WorkerBackendOptions { fetcher?: () => unknown | Promise; // Selector this backend is registered under in Workspace. - // Defaults to "worker"; override when the workspace hosts + // Defaults to "isolate-shell"; override when the workspace hosts // more than one instance of the same backend kind (e.g. two // workers on different loaders or with different shell // configurations). @@ -139,7 +140,7 @@ export class WorkerBackend implements WorkspaceBackend { readonly #options: WorkerBackendOptions; constructor(options: WorkerBackendOptions) { - this.id = options.id ?? "worker"; + this.id = options.id ?? "isolate-shell"; if (options.fetcher === undefined) { if ( options.loader === undefined || @@ -157,7 +158,8 @@ export class WorkerBackend implements WorkspaceBackend { } async connect(): Promise { - const fetcher = (await this.#resolveFetcher()) as WorkerShellFetcher; + const resolved = await this.#resolveFetcher(); + const fetcher = resolved.fetcher as WorkerShellFetcher; const shell: ShellRPC = { async exec(input) { @@ -190,15 +192,14 @@ export class WorkerBackend implements WorkspaceBackend { rpc, sync: "none", close: async () => { - // Nothing to tear down. The Fetcher is a value; dropping - // the reference is enough. + resolved.dispose(); }, }; } - async #resolveFetcher(): Promise { + async #resolveFetcher(): Promise<{ fetcher: unknown; dispose: () => void }> { if (this.#options.fetcher !== undefined) { - return this.#options.fetcher(); + return { fetcher: await this.#options.fetcher(), dispose: () => {} }; } // Convenience path: the backend builds the Loader callback // itself. The constructor checks the required options are @@ -212,7 +213,7 @@ export class WorkerBackend implements WorkspaceBackend { ? [...DEFAULT_COMPAT_FLAGS, ...this.#options.compatibilityFlags] : DEFAULT_COMPAT_FLAGS; - const stub = loader.get(loaderId, () => ({ + const worker = loader.get(loaderId, () => ({ compatibilityDate, compatibilityFlags, mainModule: "shell.js", @@ -231,7 +232,23 @@ export class WorkerBackend implements WorkspaceBackend { // on its own. Filesystem RPCs go through env.HOST. globalOutbound: null, })); - return stub.getEntrypoint("ShellWorker"); + let entrypoint: unknown; + try { + entrypoint = worker.getEntrypoint("ShellWorker"); + } catch (error) { + disposeQuietly(worker); + throw error; + } + let disposed = false; + return { + fetcher: entrypoint, + dispose: () => { + if (disposed) return; + disposed = true; + disposeQuietly(entrypoint as { [Symbol.dispose]?: () => void }); + disposeQuietly(worker); + }, + }; } } @@ -251,11 +268,10 @@ function decodeFramedEvents(source: ReadableStream): ReadableStream< buffer = buffer.slice(nl + 1); if (line.length > 0) { try { - controller.enqueue(reshape(JSON.parse(line))); - } catch { - // Drop malformed frames; the wire is strict NDJSON - // so a broken frame here is a bug on the shell - // side, not a recoverable condition. + controller.enqueue(parseFrame(line)); + } catch (error) { + controller.error(error); + return; } } nl = buffer.indexOf("\n"); @@ -265,17 +281,45 @@ function decodeFramedEvents(source: ReadableStream): ReadableStream< const tail = buffer + decoder.decode(); for (const line of tail.split("\n")) { if (line.length === 0) continue; - try { - controller.enqueue(reshape(JSON.parse(line))); - } catch { - // see transform() - } + controller.enqueue(parseFrame(line)); } }, }), ); } +function parseFrame(line: string): ExecEvent { + let event: Record; + try { + event = JSON.parse(line) as Record; + } catch { + throw protocolError("WorkerBackend received invalid execution JSON"); + } + if ( + typeof event.id !== "string" || + !Number.isSafeInteger(event.seq) || + (event.name !== "stdout" && event.name !== "stderr" && event.name !== "exit") || + ((event.name === "stdout" || event.name === "stderr") && typeof event.value !== "string") || + (event.name === "exit" && !Number.isSafeInteger(event.value)) + ) { + throw protocolError("WorkerBackend received a malformed execution frame"); + } + return reshape( + event as { + id: string; + seq: number; + name: "stdout" | "stderr" | "exit"; + value: string | number; + }, + ); +} + +function protocolError(message: string) { + const error = new Error(message) as Error & { code: string }; + error.code = "EPROTOCOL"; + return error; +} + function reshape(event: { id: string; seq: number; @@ -297,6 +341,12 @@ function reshape(event: { return { id: event.id, seq: event.seq, name: "exit", value: event.value as number }; } +function disposeQuietly(value: { [Symbol.dispose]?: () => void }) { + try { + value[Symbol.dispose]?.(); + } catch {} +} + function noopSync(): SyncRPC { const refuse = (name: string): never => { throw new Error( diff --git a/packages/computer/src/client.test.ts b/packages/computer/src/client.test.ts index 76c211d5..37942e8b 100644 --- a/packages/computer/src/client.test.ts +++ b/packages/computer/src/client.test.ts @@ -19,34 +19,22 @@ interface ExecCall { options: Record | undefined; } -interface GetCall { - id: string; - options: Record | undefined; -} - -// A fake shell that records exec and get calls. Stands in for both -// Workspace.shell (local) and the shell stub (remote) — the client -// only needs `exec(command, options?)` and `get(id, options?)`. -function fakeShell(): { - shell: { - exec: (c: string, o?: Record) => Promise; - get: (id: string, o?: Record) => Promise; - }; - calls: ExecCall[]; - gets: GetCall[]; - disposedHandles: () => number; -} { +// A fake shell that records exec calls. Stands in for both +// Workspace.runtime (local) and the runtime stub (remote) — the client +// only needs `exec(command, options?)`. +function fakeShell(promisedProperties = false) { const calls: ExecCall[] = []; - const gets: GetCall[] = []; let disposedHandles = 0; // A minimal handle satisfying both the local identity rehydrate // (which passes it through) and the remote rebuild (which calls // stream()/result()/kill()). stream() emits one JSONL exit frame so // the rebuilt handle can be iterated. const makeHandle = () => ({ + id: promisedProperties ? Promise.resolve("x") : "x", + backend: promisedProperties ? Promise.resolve("test") : "test", result: () => Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }), - stream: () => - new ReadableStream({ + stream: () => { + const stream = new ReadableStream({ start(c) { c.enqueue( new TextEncoder().encode( @@ -55,7 +43,9 @@ function fakeShell(): { ); c.close(); }, - }), + }); + return promisedProperties ? Promise.resolve(stream) : stream; + }, kill: () => Promise.resolve(), [Symbol.dispose]() { disposedHandles += 1; @@ -63,17 +53,24 @@ function fakeShell(): { }); return { calls, - gets, disposedHandles: () => disposedHandles, - shell: { + runtime: { exec(command: string, options?: Record) { calls.push({ command, options }); return Promise.resolve(makeHandle()); }, - get(id: string, options?: Record) { - gets.push({ id, options }); + getExec(id: string, options?: Record) { + calls.push({ command: `get:${id}`, options }); return Promise.resolve(makeHandle()); }, + killExec(id: string, options?: Record) { + calls.push({ command: `kill:${id}`, options }); + return Promise.resolve(); + }, + disposeExec(id: string, options?: Record) { + calls.push({ command: `dispose:${id}`, options }); + return Promise.resolve(); + }, }, }; } @@ -83,11 +80,10 @@ function fakeShell(): { function fakeRemote(): { host: WorkspaceStubHost; calls: ExecCall[]; - gets: GetCall[]; disposed: () => boolean; disposedHandles: () => number; } { - const { shell, calls, gets, disposedHandles } = fakeShell(); + const { runtime, calls, disposedHandles } = fakeShell(true); let disposed = false; const stub = { fs: { marker: "fs" }, @@ -95,14 +91,13 @@ function fakeRemote(): { git: { marker: "git" }, assets: undefined, artifacts: { marker: "artifacts" }, - shell, + runtime, [Symbol.dispose]() { disposed = true; }, }; return { calls, - gets, disposed: () => disposed, disposedHandles, host: { __getWorkspaceStub: () => Promise.resolve(stub as never) }, @@ -131,15 +126,11 @@ function fakeBrokenRemote(): { // Local path fake: an object carrying a real Workspace under the // stash symbol, but with its shell swapped for a recording fake so we // can assert on exec without a backend. -function fakeLocal(): { - host: { [WORKSPACE]: Workspace }; - calls: ExecCall[]; - gets: GetCall[]; -} { +function fakeLocal(): { host: { [WORKSPACE]: Workspace }; calls: ExecCall[] } { const ws = new Workspace({ storage: new SQLiteTestStorage() }); - const { shell, calls, gets } = fakeShell(); - Object.defineProperty(ws, "shell", { get: () => shell }); - return { calls, gets, host: { [WORKSPACE]: ws } }; + const { runtime, calls } = fakeShell(); + Object.defineProperty(ws, "runtime", { get: () => runtime }); + return { calls, host: { [WORKSPACE]: ws } }; } describe("getWorkspace — remote dispatch", () => { @@ -152,6 +143,33 @@ describe("getWorkspace — remote dispatch", () => { expect(ws).not.toHaveProperty("readFile"); }); + it("streams through an asynchronous remote stream result", async () => { + const { host } = fakeRemote(); + const ws = await getWorkspace(host); + const handle = await ws.runtime.exec("echo ok"); + const events = []; + for await (const event of handle) events.push(event); + expect(events).toEqual([{ id: "x", seq: 0, name: "exit", value: 0 }]); + }); + + it("exposes the complete runtime lifecycle and preserves handle ids", async () => { + const { host, calls } = fakeRemote(); + const ws = await getWorkspace(host); + const handle = (await ws.runtime.getExec("run-1", { resume: "tail" })) as { + id: string; + backend: string; + }; + expect(handle.id).toBe("x"); + expect(handle.backend).toBe("test"); + await ws.runtime.killExec("run-1", { signal: "SIGINT" }); + await ws.runtime.disposeExec("run-1", { backend: "container-shell" }); + expect(calls).toEqual([ + { command: "get:run-1", options: { resume: "tail" } }, + { command: "kill:run-1", options: { signal: "SIGINT" } }, + { command: "dispose:run-1", options: { backend: "container-shell" } }, + ]); + }); + it("disposing the client disposes the remote stub", async () => { const { host, disposed } = fakeRemote(); const ws = await getWorkspace(host); @@ -188,7 +206,7 @@ describe("getWorkspace — local dispatch", () => { it("delegates to the in-isolate Workspace via the symbol stash", async () => { const { host, calls } = fakeLocal(); const ws = await getWorkspace(host); - await ws.shell.exec`cat ${"my file.txt"}`; + await ws.runtime.exec`cat ${"my file.txt"}`; expect(calls[0].command).toBe("cat 'my file.txt'"); expect(ws).not.toHaveProperty("readFile"); }); @@ -218,45 +236,47 @@ describe("client shell.exec — tagged template form", () => { it("escapes interpolated values before they reach the shell", async () => { const { host, calls } = fakeRemote(); const ws = await getWorkspace(host); - await ws.shell.exec`echo ${"x; rm -rf /"}`; + await ws.runtime.exec`echo ${"x; rm -rf /"}`; expect(calls[0].command).toBe("echo 'x; rm -rf /'"); }); it("defaults to utf8 string output", async () => { const { host, calls } = fakeRemote(); const ws = await getWorkspace(host); - await ws.shell.exec`ls`; - expect(calls[0].options).toMatchObject({ encoding: "utf8" }); + await ws.runtime.exec`ls`; + expect(calls[0].options).toEqual({ encoding: "utf8" }); }); it("quotes each element of an interpolated array", async () => { const { host, calls } = fakeRemote(); const ws = await getWorkspace(host); - await ws.shell.exec`rm ${["a.txt", "b c.txt"]}`; + await ws.runtime.exec`rm ${["a.txt", "b c.txt"]}`; expect(calls[0].command).toBe("rm a.txt 'b c.txt'"); }); }); describe("client shell.exec — plain string form", () => { - it("forwards a bare command", async () => { + it("forwards a bare command with no options", async () => { const { host, calls } = fakeRemote(); const ws = await getWorkspace(host); - await ws.shell.exec("npm test"); - expect(calls[0].command).toBe("npm test"); + await ws.runtime.exec("npm test"); + expect(calls[0]).toEqual({ command: "npm test", options: undefined }); }); it("forwards options unchanged", async () => { const { host, calls } = fakeRemote(); const ws = await getWorkspace(host); - await ws.shell.exec("npm test", { cwd: "/workspace", backend: "sandbox" }); - expect(calls[0].command).toBe("npm test"); - expect(calls[0].options).toMatchObject({ cwd: "/workspace", backend: "sandbox" }); + await ws.runtime.exec("npm test", { cwd: "/workspace", backend: "sandbox" }); + expect(calls[0]).toEqual({ + command: "npm test", + options: { cwd: "/workspace", backend: "sandbox" }, + }); }); it("does not escape a plain string command", async () => { const { host, calls } = fakeRemote(); const ws = await getWorkspace(host); - await ws.shell.exec("echo 'already quoted'"); + await ws.runtime.exec("echo 'already quoted'"); expect(calls[0].command).toBe("echo 'already quoted'"); }); }); @@ -265,7 +285,7 @@ describe("client shell.exec — remote handle rebuild", () => { it("rebuilds a host-shaped handle: result() returns the run-and-wait result", async () => { const { host } = fakeRemote(); const ws = await getWorkspace(host); - const handle = await ws.shell.exec("echo hi"); + const handle = await ws.runtime.exec("echo hi"); const result = await handle.result(); expect(result).toEqual({ exitCode: 0, stdout: "", stderr: "" }); }); @@ -273,7 +293,7 @@ describe("client shell.exec — remote handle rebuild", () => { it("rebuilds an async-iterable handle that decodes the JSONL event stream", async () => { const { host } = fakeRemote(); const ws = await getWorkspace(host); - const handle = await ws.shell.exec("echo hi"); + const handle = await ws.runtime.exec("echo hi"); const events: Array<{ name: string; value: unknown }> = []; for await (const event of handle as AsyncIterable<{ name: string; value: unknown }>) { events.push({ name: event.name, value: event.value }); @@ -284,7 +304,7 @@ describe("client shell.exec — remote handle rebuild", () => { it("throws if result() is called after the stream has started", async () => { const { host } = fakeRemote(); const ws = await getWorkspace(host); - const handle = await ws.shell.exec("echo hi"); + const handle = await ws.runtime.exec("echo hi"); // Start streaming, then attempt result(): the underlying handle is // single-shot, so this is rejected rather than silently wrong. const reader = (handle as ReadableStream).getReader(); @@ -293,52 +313,11 @@ describe("client shell.exec — remote handle rebuild", () => { expect(() => (handle as { result(): unknown }).result()).toThrow(/already streaming/); }); - it("carries the id the caller asked for onto the rebuilt handle", async () => { - const { host, calls } = fakeRemote(); - const ws = await getWorkspace(host); - const handle = await ws.shell.exec("npm install", { id: "install-1" }); - expect(handle.id).toBe("install-1"); - expect(calls[0].options).toMatchObject({ id: "install-1" }); - }); - - it("mints an id when the caller omits one so the handle can be reattached", async () => { - const { host, calls } = fakeRemote(); - const ws = await getWorkspace(host); - const handle = await ws.shell.exec("npm install"); - // The minted id is what went over the wire, so a later - // shell.get(handle.id) addresses this run. - expect(typeof handle.id).toBe("string"); - expect(handle.id).not.toBe(""); - expect(calls[0].options?.id).toBe(handle.id); - }); - it("disposes the remote handle when the rebuilt handle is disposed", async () => { const { host, disposedHandles } = fakeRemote(); const ws = await getWorkspace(host); - const handle = await ws.shell.exec("echo hi"); + const handle = await ws.runtime.exec("echo hi"); handle[Symbol.dispose]?.(); expect(disposedHandles()).toBe(1); }); }); - -describe("client shell.get — reattach", () => { - it("reattaches over RPC and rebuilds a handle carrying the run's id", async () => { - const { host, gets } = fakeRemote(); - const ws = await getWorkspace(host); - const handle = await ws.shell.get("install-1", { encoding: "utf8", resume: "tail" }); - expect(gets[0]).toEqual({ - id: "install-1", - options: { encoding: "utf8", resume: "tail" }, - }); - expect(handle.id).toBe("install-1"); - const result = await handle.result(); - expect(result).toEqual({ exitCode: 0, stdout: "", stderr: "" }); - }); - - it("delegates to the in-isolate Workspace on the local path", async () => { - const { host, gets } = fakeLocal(); - const ws = await getWorkspace(host); - await ws.shell.get("install-1", { resume: 12 }); - expect(gets[0]).toEqual({ id: "install-1", options: { resume: 12 } }); - }); -}); diff --git a/packages/computer/src/client.ts b/packages/computer/src/client.ts index dec68e7e..844d203d 100644 --- a/packages/computer/src/client.ts +++ b/packages/computer/src/client.ts @@ -24,7 +24,7 @@ // the returned stub. // // Both return the same `WorkspaceClient`. The one member that needs -// adapting per path is `shell.exec`, which accepts a tagged template +// adapting per path is `runtime.exec`, which accepts a tagged template // (escaped caller-side through `sh`) as well as the plain // `(command, options?)` form. Escaping has to run caller-side because // a `TemplateStringsArray`'s `.raw` does not survive structured clone @@ -32,9 +32,15 @@ import type { WorkspaceFilesystem } from "@cloudflare/dofs"; -import { decodeExecEvents } from "./exec-wire.js"; +import type { + WorkspaceRuntimeEvent, + WorkspaceRuntimeExecHandle, + WorkspaceRuntimeResult, + WorkspaceRuntimeValue, +} from "./runtime/types.js"; +import { decodeRuntimeEvents } from "./runtime/wire.js"; import { type ShellValue, sh } from "./sh.js"; -import type { ExecEncoding, WorkspaceExecEvent } from "./shell.js"; +import type { ExecEncoding } from "./shell.js"; import { WORKSPACE, type WorkspaceStubHost } from "./with-workspace.js"; import { createThinkCompatibility, @@ -42,51 +48,83 @@ import { Workspace, } from "./workspace.js"; -// The remote shell handle stub: a result / stream / kill surface +// The remote runtime handle stub: a result / stream / kill surface // carried across Workers RPC. interface RemoteExecHandle { - result(): Promise<{ exitCode: number; stdout: unknown; stderr: unknown }>; - stream(): ReadableStream; + readonly id: string | PromiseLike; + readonly backend: string | PromiseLike; + result(): Promise>; + stream(): Promise> | ReadableStream; kill(signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"): Promise; [Symbol.dispose]?(): void; } // Rebuild a host-shaped ExecHandle from a remote handle stub. The -// result is a ReadableStream of decoded events with id, result() and +// result is a ReadableStream of decoded events with result() and // kill() tacked on, matching what the local path returns from -// Workspace.shell.exec. +// Workspace.runtime.exec. // -// result() and iterating the stream are mutually exclusive, mirroring -// the host ExecHandle: the underlying stub drains a single handle, so -// whichever is used first wins. result() goes through the stub's -// run-and-wait path (which runs the post-exit pull); iterating goes -// through the stub's byte stream (which doesn't). -function rebuildExecHandle(remote: RemoteExecHandle, id: string): unknown { - let started = false; - let reader: ReadableStreamDefaultReader> | undefined; - const stream = new ReadableStream>( +// result() and iterating the stream are mutually exclusive, matching the +// local handle. Both paths wait for command post-exit synchronization before +// completing; result() also returns its synchronization counts. +async function rebuildExecHandle( + remote: RemoteExecHandle, +): Promise> { + const [id, backend] = await Promise.all([remote.id, remote.backend]); + let claimed: "result" | "stream" | undefined; + let resultPromise: Promise> | undefined; + let reader: ReadableStreamDefaultReader> | undefined; + const stream = new ReadableStream>( { // Lazy: don't call remote.stream() until the consumer actually // pulls. A result()-only caller never starts the stream, so the // stub's single handle is free for its run-and-wait path. pull: async (controller) => { + if (claimed === "result") { + controller.error( + new Error( + "exec handle already consumed by result(): result() and streaming are exclusive", + ), + ); + return; + } if (reader === undefined) { - started = true; - reader = decodeExecEvents(remote.stream()).getReader(); + claimed = "stream"; + const encoded = await remote.stream(); + reader = decodeRuntimeEvents(encoded).getReader() as ReadableStreamDefaultReader< + WorkspaceRuntimeEvent + >; } try { - const { value, done } = await reader.read(); + const activeReader = reader; + if (!activeReader) throw new Error("runtime stream reader was not initialized"); + const { value, done } = await activeReader.read(); if (done) { + activeReader.releaseLock(); + reader = undefined; controller.close(); return; } controller.enqueue(value); } catch (error) { + reader?.releaseLock(); + reader = undefined; controller.error(error); } }, - cancel: (reason) => { - void reader?.cancel(reason); + cancel: async (reason) => { + const activeReader = reader; + reader = undefined; + if (!activeReader) { + dispose(); + return; + } + try { + await activeReader.cancel(reason); + } finally { + activeReader.releaseLock(); + dispose(); + } }, }, // highWaterMark 0 keeps pull() from firing until a real read, so a @@ -97,28 +135,30 @@ function rebuildExecHandle(remote: RemoteExecHandle, id: const dispose = () => { if (disposed) return; disposed = true; - void reader?.cancel(); + const activeReader = reader; + reader = undefined; + if (activeReader) { + void activeReader + .cancel() + .catch(() => undefined) + .finally(() => activeReader.releaseLock()); + } remote[Symbol.dispose]?.(); }; - const handle = stream as ReadableStream> & { - readonly id: string; - result(): Promise; - kill(signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"): Promise; - [Symbol.dispose](): void; - }; + const handle = stream as WorkspaceRuntimeExecHandle; Object.defineProperties(handle, { - id: { - value: id, - enumerable: false, - }, + id: { value: id, enumerable: false }, + backend: { value: backend, enumerable: false }, result: { value: () => { - if (started) { + if (claimed === "stream") { throw new Error( "exec handle already streaming: call result() or iterate the stream, not both", ); } - return remote.result(); + claimed = "result"; + resultPromise ??= remote.result() as Promise>; + return resultPromise; }, enumerable: false, }, @@ -134,7 +174,7 @@ function rebuildExecHandle(remote: RemoteExecHandle, id: return handle; } -// The shell half of the client. `exec` takes two forms: +// The runtime execution half of the client. `exec` takes two forms: // // - Tagged template: `exec`cat ${file}``. Interpolated values are // escaped before the command is built. Defaults to string @@ -145,44 +185,56 @@ function rebuildExecHandle(remote: RemoteExecHandle, id: // the underlying surface's default. Wrap an interpolated command // in `sh` to escape it: `exec(sh`cat ${file}`, { cwd })`. // -// `get` reattaches to a run by id, from any request and any handle: -// -// using ws = await getWorkspace(env.MyDO.get(id)); -// const handle = await ws.shell.get(runId, { resume: "tail" }); -// -// The id comes off a prior handle's `id` or from the caller's own -// `exec` options, so a long command outlives the request that started -// it. -// -// `R` is the handle type the underlying surface returns (the host -// `ExecHandle` locally, the handle stub remotely). -export interface WorkspaceShellClient { - exec(strings: TemplateStringsArray, ...values: ShellValue[]): Promise; - exec(command: string): Promise; - exec(command: string, options: ShellExecOptions & { encoding: "utf8" }): Promise; - exec(command: string, options: ShellExecOptions): Promise; - get(id: string): Promise; - get(id: string, options: ShellGetOptions & { encoding: "utf8" }): Promise; - get(id: string, options: ShellGetOptions): Promise; +export interface WorkspaceRuntimeClient { + exec( + strings: TemplateStringsArray, + ...values: ShellValue[] + ): Promise>; + exec(source: string): Promise>; + exec( + source: string, + options: RuntimeExecOptions & { encoding: "utf8" }, + ): Promise>; + exec( + source: string, + options: RuntimeExecOptions & { encoding?: undefined }, + ): Promise>; + exec( + source: string, + options: RuntimeExecOptions, + ): Promise>; + getExec(id: string): Promise>; + getExec( + id: string, + options: RuntimeGetOptions & { encoding: "utf8" }, + ): Promise>; + getExec( + id: string, + options?: RuntimeGetOptions, + ): Promise>; + killExec(id: string, options?: RuntimeKillOptions): Promise; + disposeExec(id: string, options?: { backend?: string }): Promise; } // Options accepted by the plain `exec` form, common to both paths. -export interface ShellExecOptions { +export interface RuntimeExecOptions { cwd?: string; encoding?: "utf8"; backend?: string; id?: string; timeoutMs?: number; + input?: WorkspaceRuntimeValue; } -// Options accepted by `get`, common to both paths. `resume` picks -// where the replayed event stream starts: "tail" for live events only, -// "full" (the default) for everything buffered, or a sequence number -// to resume after. -export interface ShellGetOptions { +export interface RuntimeGetOptions { encoding?: "utf8"; + backend?: string; resume?: "tail" | "full" | number; +} + +export interface RuntimeKillOptions { backend?: string; + signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"; } function isTemplateStringsArray(value: unknown): value is TemplateStringsArray { @@ -191,71 +243,62 @@ function isTemplateStringsArray(value: unknown): value is TemplateStringsArray { return Array.isArray(value); } -// The underlying shell surface both paths expose: an `exec` taking a -// command string and options, and a `get` taking an exec id. Locally -// this is `Workspace.shell`; remotely it's the shell stub. -interface UnderlyingShell { - // biome-ignore lint/suspicious/noExplicitAny: bridges two concrete exec overload sets - exec(command: string, options?: Record): Promise; - // biome-ignore lint/suspicious/noExplicitAny: bridges two concrete get overload sets - get(id: string, options?: Record): Promise; +// The underlying runtime surface both paths expose: an `exec` taking a +// command string and options. Locally this is `Workspace.runtime`; +// remotely it's the runtime stub. +interface UnderlyingRuntime { + // biome-ignore lint/suspicious/noExplicitAny: bridges local and RPC overload sets + exec(source: string, options?: Record): Promise; + // biome-ignore lint/suspicious/noExplicitAny: bridges local and RPC overload sets + getExec(id: string, options?: Record): Promise; + killExec(id: string, options?: RuntimeKillOptions): Promise; + disposeExec(id: string, options?: { backend?: string }): Promise; } -function makeShellClient( - shell: UnderlyingShell, +type RehydrateRuntimeHandle = ( + handle: unknown, +) => WorkspaceRuntimeExecHandle | Promise>; + +function makeRuntimeClient( + runtime: UnderlyingRuntime, // Adapts the handle the underlying `exec` resolves to: identity on - // the local path (already a host ExecHandle carrying its own id), - // rebuild on the remote path (a handle stub that needs inflating - // from its JSONL stream and carries no id of its own). - rehydrate: (handle: unknown, id: string) => unknown, - // biome-ignore lint/suspicious/noExplicitAny: handle types differ per path -): WorkspaceShellClient { + // the local path (already a host handle), rebuild on the remote path. + rehydrate: RehydrateRuntimeHandle, +): WorkspaceRuntimeClient { async function exec( commandOrStrings: string | TemplateStringsArray, - optionsOrValue?: ShellExecOptions | ShellValue, + optionsOrValue?: RuntimeExecOptions | ShellValue, ...rest: ShellValue[] - // biome-ignore lint/suspicious/noExplicitAny: handle types differ per path - ): Promise { + ): Promise> { if (isTemplateStringsArray(commandOrStrings)) { const values = optionsOrValue === undefined ? rest : [optionsOrValue as ShellValue, ...rest]; const command = sh(commandOrStrings, ...values); - const id = crypto.randomUUID(); - return rehydrate(await shell.exec(command, { id, encoding: "utf8" }), id); + return rehydrate<"utf8">(await runtime.exec(command, { encoding: "utf8" })); } - const options = (optionsOrValue as ShellExecOptions | undefined) ?? {}; - // Every run is addressed by an id the caller knows, so the handle - // exposes one on both paths and a later get() can reattach to a - // command that outlived the request that started it. A caller - // passing its own id keeps it; minting one here rather than - // reading the runner's back saves a round trip on the remote - // path, and the shell stub rejects a spawn that came back under a - // different id. - const id = options.id ?? crypto.randomUUID(); - return rehydrate(await shell.exec(commandOrStrings, { ...options, id }), id); - } - async function get( - id: string, - options?: ShellGetOptions, - // biome-ignore lint/suspicious/noExplicitAny: handle types differ per path - ): Promise { + const options = optionsOrValue as RuntimeExecOptions | undefined; const handle = options === undefined - ? await shell.get(id) - : await shell.get(id, options as Record); - return rehydrate(handle, id); + ? await runtime.exec(commandOrStrings) + : await runtime.exec(commandOrStrings, options as Record); + return options?.encoding === "utf8" ? rehydrate<"utf8">(handle) : rehydrate(handle); } - // biome-ignore lint/suspicious/noExplicitAny: handle types differ per path - return { exec, get } as WorkspaceShellClient; + const getExec = async (id: string, options?: RuntimeGetOptions) => { + const handle = await runtime.getExec(id, options as Record | undefined); + return options?.encoding === "utf8" ? rehydrate<"utf8">(handle) : rehydrate(handle); + }; + const killExec = (id: string, options?: RuntimeKillOptions) => runtime.killExec(id, options); + const disposeExec = (id: string, options?: { backend?: string }) => + runtime.disposeExec(id, options); + return { exec, getExec, killExec, disposeExec } as WorkspaceRuntimeClient; } -// The canonical client surface. `shell.exec` is the adapted member; +// The canonical client surface. `runtime.exec` is the adapted member; // `fs`, `git`, `artifacts`, and `assets` are the underlying surface's // members, passed through. The filesystem stub mirrors the local // filesystem, so it also serves as the common client type. export interface WorkspaceClient extends Partial { readonly fs: WorkspaceFilesystem; - // biome-ignore lint/suspicious/noExplicitAny: handle types differ per path - readonly shell: WorkspaceShellClient; + readonly runtime: WorkspaceRuntimeClient; // biome-ignore lint/suspicious/noExplicitAny: git type differs local vs remote readonly git: any; // biome-ignore lint/suspicious/noExplicitAny: assets type differs local vs remote @@ -268,16 +311,19 @@ export interface WorkspaceClient extends Partial { function makeClient( // biome-ignore lint/suspicious/noExplicitAny: underlying surface differs per path surface: any, - rehydrate: (handle: unknown, id: string) => unknown, + rehydrate: (handle: unknown) => unknown, dispose: () => void, useThink: boolean, ): WorkspaceClient { - const shell = makeShellClient(surface.shell as UnderlyingShell, rehydrate); + const runtime = makeRuntimeClient( + surface.runtime as UnderlyingRuntime, + rehydrate as RehydrateRuntimeHandle, + ); const client: WorkspaceClient = { get fs() { return surface.fs; }, - shell, + runtime, get git() { return surface.git; }, @@ -308,7 +354,7 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise rebuildExecHandle(h as RemoteExecHandle, id), + (h) => rebuildExecHandle(h as RemoteExecHandle), () => { (stub as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); }, diff --git a/packages/computer/src/index.ts b/packages/computer/src/index.ts index 9eeca774..c1aa32ff 100644 --- a/packages/computer/src/index.ts +++ b/packages/computer/src/index.ts @@ -26,11 +26,12 @@ export type { BackendHandle, WorkspaceBackend } from "./backend.js"; export { TestBackend, type TestBackendOptions } from "./backends/test.js"; export { getWorkspace, - type ShellExecOptions, - type ShellGetOptions, + type RuntimeExecOptions, + type RuntimeGetOptions, + type RuntimeKillOptions, type WorkspaceClient, type WorkspaceHandle, - type WorkspaceShellClient, + type WorkspaceRuntimeClient, } from "./client.js"; export { decodeExecEvents, encodeExecEvents } from "./exec-wire.js"; export { R2Bucket, type R2BucketBinding, type R2BucketOptions } from "./mounts/providers/r2.js"; @@ -56,27 +57,32 @@ export { WorkspaceServiceProxy, type WorkspaceServiceProxyProps, } from "./proxy.js"; -export { type RawShellValue, type ShellValue, sh, shellQuote } from "./sh.js"; export type { - ExecEncoding, - ExecHandle, - ExecOptions, - ExecResult, - ExecSyncResult, - GetExecOptions, - KillSignal, - WorkspaceExecEvent, -} from "./shell.js"; -export { WorkspaceShell } from "./shell.js"; + ModuleExecutionEnvelope, + ModuleExecutionInput, + WorkspaceModuleBackend, + WorkspaceModuleBackendHandle, + WorkspaceModuleBackendHost, + WorkspaceRegisteredBackend, + WorkspaceRuntimeDisposeOptions, + WorkspaceRuntimeEvent, + WorkspaceRuntimeExecHandle, + WorkspaceRuntimeExecOptions, + WorkspaceRuntimeGetOptions, + WorkspaceRuntimeKillOptions, + WorkspaceRuntimeResult, + WorkspaceRuntimeStatus, + WorkspaceRuntimeValue, +} from "./runtime/types.js"; +export { decodeRuntimeEvents, encodeRuntimeEvent } from "./runtime/wire.js"; +export { type RawShellValue, type ShellValue, sh, shellQuote } from "./sh.js"; +export type { ExecEncoding, ExecSyncResult, KillSignal } from "./shell.js"; export { WorkspaceAssetsStub, - WorkspaceExecHandleStub, - type WorkspaceExecOptions, - type WorkspaceExecResult, WorkspaceFilesystemStub, - type WorkspaceGetExecOptions, WorkspaceGitStub, - WorkspaceShellStub, + WorkspaceRuntimeExecHandleStub, + WorkspaceRuntimeStub, WorkspaceStub, } from "./stub.js"; export { diff --git a/packages/computer/src/observe-integration.test.ts b/packages/computer/src/observe-integration.test.ts index 564359e0..5e6a5ab0 100644 --- a/packages/computer/src/observe-integration.test.ts +++ b/packages/computer/src/observe-integration.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest"; import type { BackendHandle, WorkspaceBackend } from "./backend.js"; import { makeRecorder, type RecordedSpan } from "./observe-recorder.js"; -import { WorkspaceFilesystemStub, WorkspaceShellStub } from "./stub.js"; +import { WorkspaceFilesystemStub, WorkspaceRuntimeStub } from "./stub.js"; import { Workspace } from "./workspace.js"; function makeStorage(): SQLiteTestStorage { @@ -232,8 +232,8 @@ describe("Workspace observer — filesystem stub", () => { }); }); -describe("Workspace observer — shell stub", () => { - it("wraps the exec bracket in a workspace.shell.exec span with the sync nested underneath", async () => { +describe("Workspace observer — runtime stub", () => { + it("records command execution through workspace.runtime", async () => { const observer = makeRecorder(); // The stub kicks off exec on construction, so the shell RPC has to @@ -277,189 +277,18 @@ describe("Workspace observer — shell stub", () => { observer, }); await ws.ready(); - const shellStub = new WorkspaceShellStub(ws); + const runtimeStub = new WorkspaceRuntimeStub(ws); - using handle = await shellStub.exec("echo hi"); + using handle = await runtimeStub.exec("echo hi"); const result = await handle.result(); expect(result.exitCode).toBe(0); expect(execed).toEqual(["echo hi"]); - const execSpan = findSpan(observer.spans, "workspace.shell.exec"); - expect(execSpan.outcome).toBe("ok"); - expect(execSpan.attributes["workspace.shell.exit_code"]).toBe(0); - expect(execSpan.attributes["workspace.shell.pushed"]).toBe(0); - expect(execSpan.attributes["workspace.shell.pulled"]).toBe(0); - expect(execSpan.attributes["workspace.shell.skipped"]).toBe(0); - expect(execSpan.attributes["workspace.shell.sync.status"]).toBe("complete"); - expect(execSpan.attributes["workspace.shell.sync.error"]).toBeUndefined(); - - // Nesting: the bracket runs push → spawn → pull inside the exec - // span's callback, so all three appear as children on the recorder. - const childNames = execSpan.children.map((c) => c.name); - expect(childNames).toContain("workspace.sync.push"); - expect(childNames).toContain("workspace.shell.exec.spawn"); - expect(childNames).toContain("workspace.sync.pull"); - }); - - it("closes the exec span when the caller only kills the command", async () => { - // kill() is a terminal path for callers that never read the - // output: the signal goes out, the child exits, and nothing else - // touches the handle. The span has to close on that path, and the - // handle stays consumable so a caller can still collect the - // output of the killed run. - const observer = makeRecorder(); - let events!: ReadableStreamDefaultController; - const shellRpc: import("@cloudflare/computer-rpc").ShellRPC = { - async exec() { - return { - id: "exec-killed", - events: new ReadableStream({ - start(c) { - events = c; - }, - }), - }; - }, - async getExec() { - throw new Error("not used"); - }, - async killExec() { - events.enqueue({ id: "exec-killed", seq: 0, name: "exit", value: 137 }); - events.close(); - }, - async disposeExec() {}, - }; - const ws = new Workspace({ - storage: makeStorage(), - backends: [ - { - id: "shelled", - async connect() { - return { rpc: { sync: fakeSync(), shell: shellRpc }, close: async () => {} }; - }, - }, - ], - observer, - }); - await ws.ready(); - using handle = await new WorkspaceShellStub(ws).exec("sleep 100"); - await handle.kill("SIGKILL"); - - const execSpan = findSpan(observer.spans, "workspace.shell.exec"); - expect(execSpan.attributes["workspace.shell.sync.status"]).toBe("complete"); - expect(execSpan.attributes["workspace.shell.exit_code"]).toBe(-1); - - // The signal doesn't claim the handle: the output of the killed - // run is still there for the asking. - await expect(handle.result()).resolves.toMatchObject({ exitCode: 137 }); - }); - - it("leaves the span to a stream() the kill interrupts", async () => { - // Killing a command someone is streaming is not a terminal path - // for the span: the reader sees the exit event the signal - // produced, so it reports the real exit code and kill() keeps out - // of the way. - const observer = makeRecorder(); - let events!: ReadableStreamDefaultController; - const shellRpc: import("@cloudflare/computer-rpc").ShellRPC = { - async exec() { - return { - id: "exec-streamed", - events: new ReadableStream({ - start(c) { - events = c; - }, - }), - }; - }, - async getExec() { - throw new Error("not used"); - }, - async killExec() { - events.enqueue({ id: "exec-streamed", seq: 0, name: "exit", value: 137 }); - events.close(); - }, - async disposeExec() {}, - }; - const ws = new Workspace({ - storage: makeStorage(), - backends: [ - { - id: "shelled", - async connect() { - return { rpc: { sync: fakeSync(), shell: shellRpc }, close: async () => {} }; - }, - }, - ], - observer, - }); - await ws.ready(); - using handle = await new WorkspaceShellStub(ws).exec("sleep 100"); - const reader = handle.stream().getReader(); - const first = reader.read(); - - await handle.kill("SIGKILL"); - await first; - while (!(await reader.read()).done) { - // drain to close - } - reader.releaseLock(); - // The span closes once the reader reports the stream done; unlike - // result(), nothing hands the caller a promise to await for it. - await new Promise((resolve) => setTimeout(resolve, 0)); - - const execSpan = findSpan(observer.spans, "workspace.shell.exec"); - expect(execSpan.attributes["workspace.shell.exit_code"]).toBe(137); - }); - - it("reports pending sync on the exec span without exposing secrets", async () => { - const observer = makeRecorder(); - const secret = "observer-secret"; - const sync = fakeSync(); - sync.fetchChanges = async () => { - throw new Error(`WebSocket closed token=${secret}`); - }; - const shellRpc: import("@cloudflare/computer-rpc").ShellRPC = { - async exec() { - return { - id: "exec-pending", - events: new ReadableStream({ - start(c) { - c.enqueue({ id: "exec-pending", seq: 0, name: "exit", value: 0 }); - c.close(); - }, - }), - }; - }, - async getExec() { - throw new Error("not used"); - }, - async killExec() {}, - async disposeExec() {}, - }; - const ws = new Workspace({ - storage: makeStorage(), - backends: [ - { - id: "shelled", - async connect() { - return { rpc: { sync, shell: shellRpc }, close: async () => {} }; - }, - }, - ], - observer, - }); - await ws.ready(); - using handle = await new WorkspaceShellStub(ws).exec("noop"); - const result = await handle.result(); - expect(result.sync.status).toBe("pending"); - - const execSpan = findSpan(observer.spans, "workspace.shell.exec"); - expect(execSpan.attributes["workspace.shell.sync.status"]).toBe("pending"); - expect(execSpan.attributes["workspace.shell.sync.error"]).toBe( - "WebSocket closed token=[REDACTED]", - ); - expect(JSON.stringify(execSpan.attributes)).not.toContain(secret); + const spawnSpan = findSpan(observer.spans, "workspace.runtime.exec.spawn"); + expect(spawnSpan.outcome).toBe("ok"); + expect(spawnSpan.attributes["workspace.runtime.id"]).toBe("exec-1"); + expect(findSpan(observer.spans, "workspace.sync.push").outcome).toBe("ok"); + expect(findSpan(observer.spans, "workspace.sync.pull").outcome).toBe("ok"); }); }); diff --git a/packages/computer/src/observe.ts b/packages/computer/src/observe.ts index b957ddb7..c2e8d2ba 100644 --- a/packages/computer/src/observe.ts +++ b/packages/computer/src/observe.ts @@ -7,7 +7,7 @@ // connect() + watermark reconcile. // workspace.sync.push — one per `Workspace.push()` call. // workspace.sync.pull — one per `Workspace.pull()` call. -// workspace.shell.exec — one per `WorkspaceShell.exec()` call, +// workspace.runtime.exec — one per `WorkspaceShell.exec()` call, // covering pre-exec push, the spawn // request, and (when `result()` is // awaited) the post-drain pull. @@ -17,7 +17,7 @@ // find, ls, grep, mkdir, rm). // // Spans nest the way callers would expect. An `exec()` whose `result()` -// is awaited produces a `workspace.shell.exec` parent with +// is awaited produces a `workspace.runtime.exec` parent with // `workspace.sync.push` and `workspace.sync.pull` children. The nesting // is whatever the active context provides — for the built-in Cloudflare // `ctx.tracing` adapter that is the AsyncContextFrame; for an diff --git a/packages/computer/src/observe/cloudflare.test.ts b/packages/computer/src/observe/cloudflare.test.ts index 76c9979d..e3044d1f 100644 --- a/packages/computer/src/observe/cloudflare.test.ts +++ b/packages/computer/src/observe/cloudflare.test.ts @@ -89,14 +89,14 @@ describe("createCloudflareObserver", () => { it("hands the callback a span facade whose setAttribute reaches the runtime", async () => { const { tracing, spans } = makeFakeTracing(); const observer = createCloudflareObserver({ tracing }); - await observer.span("workspace.shell.exec", {}, async (span) => { - span.setAttribute("workspace.shell.exit_code", 0); - span.setAttribute("workspace.shell.cwd", "/workspace"); + await observer.span("workspace.runtime.exec", {}, async (span) => { + span.setAttribute("workspace.runtime.exit_code", 0); + span.setAttribute("workspace.runtime.cwd", "/workspace"); span.setAttribute("dropped", undefined); }); expect(spans[0].attributes).toEqual({ - "workspace.shell.exit_code": 0, - "workspace.shell.cwd": "/workspace", + "workspace.runtime.exit_code": 0, + "workspace.runtime.cwd": "/workspace", }); }); diff --git a/packages/computer/src/observe/cloudflare.ts b/packages/computer/src/observe/cloudflare.ts index 30ede19f..7b5f0474 100644 --- a/packages/computer/src/observe/cloudflare.ts +++ b/packages/computer/src/observe/cloudflare.ts @@ -70,7 +70,7 @@ export function createCloudflareObserver(options: CloudflareObserverOptions): Wo span(name, attributes, run) { // The runtime requires the operation name to fit in 64 bytes. // Names emitted by the workspace are well under that today - // (longest is `workspace.shell.exec.spawn` at 26 bytes), so no + // (longest is `workspace.runtime.exec.spawn` at 26 bytes), so no // truncation is needed here; an over-length name is a workspace // bug rather than a caller bug. return tracing.enterSpan(name, (runtimeSpan) => { diff --git a/packages/computer/src/proxy.ts b/packages/computer/src/proxy.ts index be8882a8..861d9231 100644 --- a/packages/computer/src/proxy.ts +++ b/packages/computer/src/proxy.ts @@ -65,13 +65,25 @@ export class WorkspaceProxy extends WorkerEntrypoint { const url = new URL(request.url); + const callback = url.pathname.match(/^\/__workspace_connect\/([0-9a-f-]{36})\/(health|ws)$/); + if (callback?.[2] === "health") { + return new Response("ok\n", { + headers: { "content-type": "text/plain; charset=utf-8" }, + }); + } + if (url.pathname === "/health") { return new Response("ok\n", { headers: { "content-type": "text/plain; charset=utf-8" }, }); } - if (url.pathname === "/ws") { + if (url.pathname === "/ws" || callback?.[2] === "ws") { + if (callback?.[1]) { + url.pathname = "/ws"; + url.searchParams.set("token", callback[1]); + request = new Request(url, request); + } const { binding, id } = this.ctx.props; const ns = (this.env as Record)[binding] as | DurableObjectNamespace diff --git a/packages/computer/src/retry.test.ts b/packages/computer/src/retry.test.ts index b751bd3b..1a7855e3 100644 --- a/packages/computer/src/retry.test.ts +++ b/packages/computer/src/retry.test.ts @@ -88,7 +88,7 @@ function retryBackend(options: { } async function runCommand(ws: Workspace): Promise { - const handle = await ws.shell.exec("build", { encoding: "utf8" }); + const handle = await ws.runtime.exec("build", { encoding: "utf8" }); const result = await handle.result(); expect(result.sync.status).toBe("pending"); return undefined; diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts new file mode 100644 index 00000000..93ba49dd --- /dev/null +++ b/packages/computer/src/runtime/runtime.ts @@ -0,0 +1,441 @@ +import type { SkippedEntry } from "@cloudflare/dofs"; + +import type { ExecEncoding, ExecHandle, WorkspaceShell } from "../shell.js"; +import type { + WorkspaceModuleBackendHandle, + WorkspaceRuntimeDisposeOptions, + WorkspaceRuntimeEvent, + WorkspaceRuntimeExecHandle, + WorkspaceRuntimeExecOptions, + WorkspaceRuntimeGetOptions, + WorkspaceRuntimeKillOptions, + WorkspaceRuntimeResult, +} from "./types.js"; + +interface WorkspaceRuntimeRouterOptions { + commandBackendIds: ReadonlySet; + shell: () => WorkspaceShell; + moduleHandle: (id: string) => Promise; + resolveBackendId: (id: string | undefined) => string; +} + +export class WorkspaceRuntime { + readonly #options: WorkspaceRuntimeRouterOptions; + + constructor(options: WorkspaceRuntimeRouterOptions) { + this.#options = options; + } + + exec(source: string): Promise>; + exec( + source: string, + options: WorkspaceRuntimeExecOptions<"utf8">, + ): Promise>; + exec( + source: string, + options: WorkspaceRuntimeExecOptions, + ): Promise>; + async exec( + source: string, + options: WorkspaceRuntimeExecOptions = {}, + ): Promise> { + if (options.id !== undefined) assertExecutionId(options.id); + const backend = this.#backend(options.backend); + if (this.#options.commandBackendIds.has(backend)) { + if (options.input !== undefined) { + throw new Error(`Backend ${JSON.stringify(backend)} does not accept structured input.`); + } + const shell = this.#options.shell(); + const handle = await ( + shell.exec as unknown as ( + command: string, + options: Record, + ) => Promise> + )(source, { + backend, + cwd: options.cwd, + encoding: options.encoding, + id: options.id, + timeoutMs: options.timeoutMs, + }); + return wrapCommandHandle(handle, backend); + } + + const runtime = await this.#options.moduleHandle(backend); + const envelope = await runtime.exec({ + id: options.id, + source, + cwd: options.cwd, + input: options.input, + timeoutMs: options.timeoutMs, + }); + return wrapModuleHandle(runtime, backend, envelope.id, envelope.events, options.encoding); + } + + getExec(id: string): Promise>; + getExec( + id: string, + options: WorkspaceRuntimeGetOptions<"utf8">, + ): Promise>; + getExec( + id: string, + options: WorkspaceRuntimeGetOptions, + ): Promise>; + async getExec( + id: string, + options: WorkspaceRuntimeGetOptions = {}, + ): Promise> { + assertExecutionId(id); + const backend = this.#backend(options.backend); + if (this.#options.commandBackendIds.has(backend)) { + const shell = this.#options.shell(); + const handle = await ( + shell.get as unknown as ( + id: string, + options: Record, + ) => Promise> + )(id, { + backend, + encoding: options.encoding, + resume: options.resume, + }); + const resultHandle = + options.resume === undefined || options.resume === "full" + ? undefined + : () => + ( + shell.get as unknown as ( + id: string, + options: Record, + ) => Promise> + )(id, { + backend, + encoding: options.encoding, + resume: "full", + }); + return wrapCommandHandle(handle, backend, resultHandle); + } + + const runtime = await this.#options.moduleHandle(backend); + const envelope = await runtime.getExec({ id, after: resumeToAfter(options.resume) }); + return wrapModuleHandle( + runtime, + backend, + envelope.id, + envelope.events, + options.encoding, + options.resume === undefined || options.resume === "full", + ); + } + + async killExec(id: string, options: WorkspaceRuntimeKillOptions = {}): Promise { + assertExecutionId(id); + const backend = this.#backend(options.backend); + if (this.#options.commandBackendIds.has(backend)) { + await this.#options.shell().kill(id, options.signal, { backend }); + return; + } + await (await this.#options.moduleHandle(backend)).killExec({ id, signal: options.signal }); + } + + async disposeExec(id: string, options: WorkspaceRuntimeDisposeOptions = {}): Promise { + assertExecutionId(id); + const backend = this.#backend(options.backend); + if (this.#options.commandBackendIds.has(backend)) { + await this.#options.shell().dispose(id, { backend }); + return; + } + await (await this.#options.moduleHandle(backend)).disposeExec({ id }); + } + + #backend(requested: string | undefined): string { + const backend = this.#options.resolveBackendId(requested); + if (!backend) { + throw new Error( + "Workspace has no execution backend configured. Pass `backends` to the Workspace constructor.", + ); + } + return backend; + } +} + +function wrapCommandHandle( + handle: ExecHandle, + backend: string, + lazyResultHandle?: () => Promise>, +): WorkspaceRuntimeExecHandle { + let claimed: "result" | "stream" | undefined; + let reader: ReadableStreamDefaultReader> | undefined; + let resultPromise: Promise> | undefined; + const stream = new ReadableStream>( + { + async pull(controller) { + if (claimed === "result") { + controller.error(new Error("runtime handle already consumed by result()")); + return; + } + claimed = "stream"; + reader ??= handle.getReader() as ReadableStreamDefaultReader>; + try { + const next = await reader.read(); + if (next.done) { + reader.releaseLock(); + reader = undefined; + controller.close(); + } else controller.enqueue(next.value); + } catch (error) { + reader?.releaseLock(); + reader = undefined; + controller.error(error); + } + }, + async cancel(reason) { + if (reader) { + try { + await reader.cancel(reason); + } finally { + reader.releaseLock(); + reader = undefined; + } + } else await handle.cancel(reason); + }, + }, + { highWaterMark: 0 }, + ) as WorkspaceRuntimeExecHandle; + Object.defineProperties(stream, { + id: { value: handle.id, enumerable: false }, + backend: { value: backend, enumerable: false }, + result: { + value: (): Promise> => { + if (claimed === "stream") { + throw new Error("runtime handle already streaming: result() and streaming are exclusive"); + } + claimed = "result"; + resultPromise ??= (async () => { + if (lazyResultHandle) await handle.cancel("result() requested a full replay"); + const result = lazyResultHandle + ? await (await lazyResultHandle()).result() + : await handle.result(); + return { + status: + result.exitCode === 0 + ? "completed" + : isCancellationExitCode(result.exitCode) + ? "cancelled" + : "failed", + ...result, + }; + })(); + return resultPromise; + }, + }, + kill: { + value: (signal?: WorkspaceRuntimeKillOptions["signal"]) => handle.kill(signal), + }, + [Symbol.dispose]: { value: () => handle[Symbol.dispose]() }, + }); + return stream; +} + +function wrapModuleHandle( + runtime: WorkspaceModuleBackendHandle, + backend: string, + id: string, + source: ReadableStream, + encoding: E | undefined, + resultMayUseSource = true, +): WorkspaceRuntimeExecHandle { + let claimed: "result" | "stream" | undefined; + let sourceCancelled = false; + let reader: ReadableStreamDefaultReader> | undefined; + let resultReader: ReadableStreamDefaultReader | undefined; + let resultPromise: Promise> | undefined; + const stream = new ReadableStream>( + { + async pull(controller) { + if (claimed === "result") { + controller.error(new Error("runtime handle already consumed by result()")); + return; + } + claimed = "stream"; + reader ??= transformModuleEvents(source, encoding).getReader(); + try { + const next = await reader.read(); + if (next.done) { + reader.releaseLock(); + reader = undefined; + controller.close(); + } else controller.enqueue(next.value); + } catch (error) { + reader?.releaseLock(); + reader = undefined; + controller.error(error); + } + }, + async cancel(reason) { + sourceCancelled = true; + if (reader) { + try { + await reader.cancel(reason); + } finally { + reader.releaseLock(); + reader = undefined; + } + } else await source.cancel(reason); + }, + }, + { highWaterMark: 0 }, + ) as WorkspaceRuntimeExecHandle; + Object.defineProperties(stream, { + id: { value: id, enumerable: false }, + backend: { value: backend, enumerable: false }, + result: { + value: (): Promise> => { + if (claimed === "stream") { + throw new Error("runtime handle already streaming: result() and streaming are exclusive"); + } + claimed = "result"; + resultPromise ??= (async () => { + const setReader = ( + active: ReadableStreamDefaultReader | undefined, + ) => { + resultReader = active; + }; + if (resultMayUseSource && !sourceCancelled) { + return drainModuleResult(source, encoding, setReader); + } + if (!sourceCancelled) await source.cancel("result() requested a full replay"); + return drainModuleResult((await runtime.getExec({ id })).events, encoding, setReader); + })(); + return resultPromise; + }, + }, + kill: { + value: (signal?: WorkspaceRuntimeKillOptions["signal"]) => runtime.killExec({ id, signal }), + }, + [Symbol.dispose]: { + value: () => { + if (resultReader) void resultReader.cancel().catch(() => undefined); + else void stream.cancel().catch(() => undefined); + }, + }, + }); + return stream; +} + +function transformModuleEvents( + source: ReadableStream, + encoding: E | undefined, +): ReadableStream> { + if (encoding !== "utf8") { + return source as ReadableStream>; + } + const stdoutDecoder = new TextDecoder(); + const stderrDecoder = new TextDecoder(); + let stdoutMeta: { id: string; seq: number } | undefined; + let stderrMeta: { id: string; seq: number } | undefined; + const flushPending = (controller: TransformStreamDefaultController>) => { + const stdout = stdoutDecoder.decode(); + const stderr = stderrDecoder.decode(); + if (stdout && stdoutMeta) { + controller.enqueue({ + ...stdoutMeta, + name: "stdout", + value: stdout, + } as WorkspaceRuntimeEvent); + } + if (stderr && stderrMeta) { + controller.enqueue({ + ...stderrMeta, + name: "stderr", + value: stderr, + } as WorkspaceRuntimeEvent); + } + }; + return source.pipeThrough( + new TransformStream>({ + transform(event, controller) { + if (event.name === "stdout" || event.name === "stderr") { + if (event.name === "stdout") stdoutMeta = { id: event.id, seq: event.seq }; + else stderrMeta = { id: event.id, seq: event.seq }; + controller.enqueue({ + ...event, + value: (event.name === "stdout" ? stdoutDecoder : stderrDecoder).decode(event.value, { + stream: true, + }), + } as WorkspaceRuntimeEvent); + } else { + if (event.name === "exit") flushPending(controller); + controller.enqueue(event as WorkspaceRuntimeEvent); + } + }, + flush: flushPending, + }), + ); +} + +async function drainModuleResult( + events: ReadableStream, + encoding: E | undefined, + setReader: (reader: ReadableStreamDefaultReader | undefined) => void, +): Promise> { + const stdout: Uint8Array[] = []; + const stderr: Uint8Array[] = []; + let value: WorkspaceRuntimeResult["value"]; + let exitCode = 1; + const reader = events.getReader(); + setReader(reader); + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + const event = next.value; + if (event.name === "stdout") stdout.push(event.value); + if (event.name === "stderr") stderr.push(event.value); + if (event.name === "result") value = event.value; + if (event.name === "exit") exitCode = event.value; + } + } finally { + reader.releaseLock(); + setReader(undefined); + } + return { + status: exitCode === 0 ? "completed" : exitCode === 130 ? "cancelled" : "failed", + exitCode, + stdout: join(stdout, encoding) as WorkspaceRuntimeResult["stdout"], + stderr: join(stderr, encoding) as WorkspaceRuntimeResult["stderr"], + ...(value === undefined ? {} : { value }), + pushed: 0, + pulled: 0, + skipped: [] as SkippedEntry[], + sync: { status: "complete", applied: 0, skipped: [] }, + }; +} + +function isCancellationExitCode(exitCode: number) { + return exitCode === 129 || exitCode === 130 || exitCode === 137 || exitCode === 143; +} + +function join(chunks: Uint8Array[], encoding: ExecEncoding): string | Uint8Array { + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return encoding === "utf8" ? new TextDecoder().decode(bytes) : bytes; +} + +function assertExecutionId(id: string) { + if (id.length === 0) throw new Error("Workspace runtime execution id must not be empty."); + if (new TextEncoder().encode(id).byteLength > 256) { + throw new Error("Workspace runtime execution id exceeds 256 bytes."); + } +} + +function resumeToAfter(resume: "tail" | "full" | number | undefined) { + if (resume === "tail") return "tail" as const; + if (typeof resume === "number") return resume; + return undefined; +} diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts new file mode 100644 index 00000000..00164173 --- /dev/null +++ b/packages/computer/src/runtime/types.ts @@ -0,0 +1,114 @@ +import type { SkippedEntry } from "@cloudflare/dofs"; + +import type { ExecEncoding, ExecSyncResult, KillSignal } from "../shell.js"; + +export type WorkspaceRuntimeValue = + | null + | boolean + | number + | string + | WorkspaceRuntimeValue[] + | { [key: string]: WorkspaceRuntimeValue }; + +export type WorkspaceRuntimeStatus = "completed" | "failed" | "cancelled"; + +type RuntimeChunk = E extends "utf8" ? string : Uint8Array; + +export type WorkspaceRuntimeEvent = + | { id: string; seq: number; name: "stdout"; value: RuntimeChunk } + | { id: string; seq: number; name: "stderr"; value: RuntimeChunk } + | { id: string; seq: number; name: "result"; value: WorkspaceRuntimeValue } + | { id: string; seq: number; name: "exit"; value: number }; + +export interface WorkspaceRuntimeResult { + status: WorkspaceRuntimeStatus; + exitCode: number; + stdout: E extends "utf8" ? string : Uint8Array; + stderr: E extends "utf8" ? string : Uint8Array; + value?: WorkspaceRuntimeValue; + pushed: number; + pulled: number; + skipped: SkippedEntry[]; + sync: ExecSyncResult; +} + +export interface WorkspaceRuntimeExecOptions { + id?: string; + backend?: string; + cwd?: string; + encoding?: E; + input?: WorkspaceRuntimeValue; + timeoutMs?: number; +} + +export interface WorkspaceRuntimeGetOptions { + backend?: string; + encoding?: E; + resume?: "tail" | "full" | number; +} + +export interface WorkspaceRuntimeKillOptions { + backend?: string; + signal?: KillSignal; +} + +export interface WorkspaceRuntimeDisposeOptions { + backend?: string; +} + +export interface WorkspaceRuntimeExecHandle + extends ReadableStream> { + readonly id: string; + readonly backend: string; + result(): Promise>; + kill(signal?: KillSignal): Promise; + [Symbol.dispose](): void; +} + +export interface ModuleExecutionInput { + id?: string; + source: string; + cwd?: string; + input?: WorkspaceRuntimeValue; + timeoutMs?: number; +} + +export interface ModuleExecutionEnvelope { + id: string; + events: ReadableStream; +} + +export interface WorkspaceModuleBackendHandle { + exec(input: ModuleExecutionInput): Promise; + getExec(input: { id: string; after?: number | "tail" }): Promise; + killExec(input: { id: string; signal?: KillSignal }): Promise; + disposeExec(input: { id: string }): Promise; + close(): Promise; +} + +export interface WorkspaceModuleBackendHost { + readonly db: import("@cloudflare/dofs").Database; + /** Attach detached backend work to the host event lifetime. */ + readonly waitUntil?: (promise: Promise) => void; + readonly fs: import("@cloudflare/dofs").WorkspaceFilesystem; + readonly git: import("../git/index.js").GitClient; + readonly artifacts: import("../artifacts/index.js").ArtifactClient; +} + +export interface WorkspaceModuleBackend { + readonly protocol: "module"; + readonly id: string; + readonly requiresWaitUntil?: boolean; + readonly type: string; + connect(host: WorkspaceModuleBackendHost): Promise; +} + +export type WorkspaceRegisteredBackend = + | import("../backend.js").WorkspaceBackend + | WorkspaceModuleBackend; + +export function isModuleBackend( + backend: WorkspaceRegisteredBackend, +): backend is WorkspaceModuleBackend { + return "protocol" in backend && backend.protocol === "module"; +} diff --git a/packages/computer/src/runtime/wire.ts b/packages/computer/src/runtime/wire.ts new file mode 100644 index 00000000..fac275bf --- /dev/null +++ b/packages/computer/src/runtime/wire.ts @@ -0,0 +1,73 @@ +import type { ExecEncoding } from "../shell.js"; +import type { WorkspaceRuntimeEvent, WorkspaceRuntimeValue } from "./types.js"; + +type RuntimeFrame = + | { id: string; seq: number; name: "stdout" | "stderr"; enc: "utf8"; value: string } + | { id: string; seq: number; name: "stdout" | "stderr"; enc: "b64"; value: string } + | { id: string; seq: number; name: "result"; value: WorkspaceRuntimeValue } + | { id: string; seq: number; name: "exit"; value: number }; + +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (let index = 0; index < bytes.length; index += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)); + } + return btoa(binary); +} + +function fromBase64(text: string): Uint8Array { + const binary = atob(text); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +export function encodeRuntimeEvent(event: WorkspaceRuntimeEvent): Uint8Array { + let frame: RuntimeFrame; + if (event.name === "exit" || event.name === "result") frame = event; + else if (typeof event.value === "string") { + frame = { id: event.id, seq: event.seq, name: event.name, enc: "utf8", value: event.value }; + } else { + frame = { + id: event.id, + seq: event.seq, + name: event.name, + enc: "b64", + value: toBase64(event.value), + }; + } + return new TextEncoder().encode(`${JSON.stringify(frame)}\n`); +} + +function decodeFrame(frame: RuntimeFrame): WorkspaceRuntimeEvent { + if (frame.name === "exit" || frame.name === "result") return frame; + return { + id: frame.id, + seq: frame.seq, + name: frame.name, + value: frame.enc === "utf8" ? frame.value : fromBase64(frame.value), + }; +} + +export function decodeRuntimeEvents( + source: ReadableStream, +): ReadableStream> { + const decoder = new TextDecoder(); + let buffered = ""; + return source.pipeThrough( + new TransformStream>({ + transform(chunk, controller) { + buffered += decoder.decode(chunk, { stream: true }); + const lines = buffered.split("\n"); + buffered = lines.pop() ?? ""; + for (const line of lines) { + if (line) controller.enqueue(decodeFrame(JSON.parse(line) as RuntimeFrame)); + } + }, + flush(controller) { + buffered += decoder.decode(); + if (buffered.trim()) controller.enqueue(decodeFrame(JSON.parse(buffered) as RuntimeFrame)); + }, + }), + ); +} diff --git a/packages/computer/src/sh.ts b/packages/computer/src/sh.ts index 7af57fa6..2e5676fb 100644 --- a/packages/computer/src/sh.ts +++ b/packages/computer/src/sh.ts @@ -1,6 +1,6 @@ // Shell command templating with automatic escaping. // -// `Workspace.shell.exec` takes a single command string that the +// `Workspace.runtime.exec` takes a single command string that the // container runs through `/bin/sh -c`. Building that string by hand // is a shell-injection footgun: any interpolated path, branch name, // or user-supplied value can break out of its argument and run diff --git a/packages/computer/src/shell.test.ts b/packages/computer/src/shell.test.ts index 7bce3966..4d95e887 100644 --- a/packages/computer/src/shell.test.ts +++ b/packages/computer/src/shell.test.ts @@ -272,6 +272,23 @@ describe("WorkspaceShell.exec — handle shape", () => { expect(seen).toEqual(["stdout", "stdout", "exit"]); }); + it("runs the post-command pull after stream-only consumption", async () => { + const f = fakeRpc({ events: [exit(1, 0)] }); + let pulls = 0; + const sync: Sync = { + push: async () => 0, + pull: async () => { + pulls += 1; + return applied(0); + }, + }; + const handle = await new WorkspaceShell(f.rpc.shell, sync).exec("noop"); + for await (const _event of handle) { + // Drain the stream. + } + expect(pulls).toBe(1); + }); + it("hides id / result / kill from Object.keys and JSON.stringify", async () => { const f = fakeRpc(); const shell = new WorkspaceShell(f.rpc.shell, makeSync()); @@ -300,47 +317,97 @@ describe("WorkspaceShell.exec — handle shape", () => { expect(f.calls.killExec).toEqual([{ id: "kid", signal: undefined }]); }); - it("kill() resolves only after the exit event is observed on the wire", async () => { - // Build a controllable stream where exit lands on demand. The - // kill() promise must not resolve before exit shows up, even - // though killExec returns immediately. - let pushExit: ((code: number) => void) | undefined; - const events: ReadableStream = new ReadableStream({ - start(c) { - pushExit = (code) => { - c.enqueue({ id: "kid", seq: 1, name: "exit", value: code }); - c.close(); + it("disposes the RPC envelope when the event stream errors", async () => { + let disposed = 0; + const shellRpc: ShellRPC = { + async exec() { + return { + id: "broken", + events: new ReadableStream({ + start(controller) { + controller.error(new Error("transport failed")); + }, + }), + [Symbol.dispose]() { + disposed += 1; + }, }; }, - }); - let killReturned = 0; - let killExecReturned = 0; - let order = 0; + async getExec() { + throw new Error("unused"); + }, + async killExec() {}, + async disposeExec() {}, + }; + const handle = await new WorkspaceShell(shellRpc, makeSync()).exec("noop"); + await expect(handle.getReader().read()).rejects.toThrow("transport failed"); + expect(disposed).toBe(1); + }); + + it("disposes the RPC envelope when the consumer cancels", async () => { + let disposed = 0; const shellRpc: ShellRPC = { - async exec(input) { - return { id: input.id ?? "kid", events }; + async exec() { + return { + id: "cancelled", + events: new ReadableStream(), + [Symbol.dispose]() { + disposed += 1; + }, + }; }, async getExec() { throw new Error("unused"); }, + async killExec() {}, + async disposeExec() {}, + }; + const handle = await new WorkspaceShell(shellRpc, makeSync()).exec("noop"); + await handle.cancel(); + expect(disposed).toBe(1); + }); + + it("kill() still reaches a non-retained backend when tail replay is absent", async () => { + let kills = 0; + const shellRpc: ShellRPC = { + async exec(input) { + return { id: input.id ?? "kid", events: new ReadableStream() }; + }, + async getExec() { + const error = new Error("no retained execution") as Error & { code: string }; + error.code = "ENOENT"; + throw error; + }, async killExec() { - killExecReturned = ++order; + kills += 1; }, async disposeExec() {}, }; - const shell = new WorkspaceShell(shellRpc, makeSync()); - const handle = await shell.exec("noop", { id: "kid" }); - const killPromise = handle.kill("SIGTERM").then(() => { - killReturned = ++order; - }); - // Give microtasks a chance to settle so killExec has returned. - await new Promise((r) => setTimeout(r, 0)); - expect(killExecReturned).toBe(1); - expect(killReturned).toBe(0); // not yet — exit hasn't arrived - // Now deliver exit. kill() should resolve. - pushExit?.(143); - await killPromise; - expect(killReturned).toBe(2); + const handle = await new WorkspaceShell(shellRpc, makeSync()).exec("noop", { id: "kid" }); + await handle.kill(); + expect(kills).toBe(1); + }); + + it("kill() remains a no-op when the execution already completed", async () => { + const shellRpc: ShellRPC = { + async exec(input) { + return { id: input.id ?? "kid", events: new ReadableStream() }; + }, + async getExec() { + return { + id: "kid", + events: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + }; + }, + async killExec() {}, + async disposeExec() {}, + }; + const handle = await new WorkspaceShell(shellRpc, makeSync()).exec("noop", { id: "kid" }); + await expect(handle.kill()).resolves.toBeUndefined(); }); }); diff --git a/packages/computer/src/shell.ts b/packages/computer/src/shell.ts index e717c2f5..22926d33 100644 --- a/packages/computer/src/shell.ts +++ b/packages/computer/src/shell.ts @@ -16,11 +16,8 @@ // visible to subsequent Workspace.fs reads. // The pushed / pulled counts land in ExecResult. // -// Pull only fires when the caller awaits handle.result(). A -// caller that consumes the stream directly gets the push but not -// the pull — docs/05 puts the pull after the exit event, which -// only result() observes. If you need the pull in that flow, -// drive the stream yourself then call Workspace.pull() explicitly. +// Pull fires after either result() or direct stream consumption drains +// the execution events. The stream does not close until that pull settles. // // get() (reattach) is intentionally not bracketed. Reattaching // to an already-running exec doesn't represent a new push frame. @@ -61,10 +58,8 @@ export interface ExecResult { // because they targeted a read-only mount root. // Empty when no read-only mounts are registered or // the container stayed clear of them. - // pulled / skipped are populated only when handle.result() is - // awaited. Consuming the stream directly leaves both at their - // empty values; pushed is observed before the stream is returned - // so it reflects the real push count either way. + // pushed is observed before the stream is returned. The remaining + // fields describe the post-command pull when result() is used. pushed: number; pulled: number; skipped: SkippedEntry[]; @@ -91,6 +86,7 @@ export interface ExecHandle readonly id: string; result(): Promise>; kill(signal?: KillSignal): Promise; + [Symbol.dispose](): void; } export interface ExecOptions { @@ -169,11 +165,11 @@ export class WorkspaceShell { } const envelope = await withSpan( this.#observer, - "workspace.shell.exec.spawn", + "workspace.runtime.exec.spawn", { - "workspace.shell.cwd": options.cwd, - "workspace.shell.timeout_ms": options.timeoutMs, - "workspace.shell.id": options.id, + "workspace.runtime.cwd": options.cwd, + "workspace.runtime.timeout_ms": options.timeoutMs, + "workspace.runtime.id": options.id, }, () => this.#shell.exec({ @@ -183,7 +179,7 @@ export class WorkspaceShell { timeoutMs: options.timeoutMs, }), (span, outcome) => { - if (outcome.ok) span.setAttribute("workspace.shell.id", outcome.value.id); + if (outcome.ok) span.setAttribute("workspace.runtime.id", outcome.value.id); }, ); // Dispose the result envelope when the event stream finishes @@ -210,6 +206,16 @@ export class WorkspaceShell { // between reattach and the next drain. return wrapHandle(this.#shell, this.#sync, id, events, options.encoding, 0); } + + kill(id: string, signal?: KillSignal, _options: { backend?: string } = {}): Promise { + return this.#shell.killExec({ id, signal }); + } + + dispose(id: string): Promise; + dispose(id: string, options: { backend?: string }): Promise; + dispose(id: string, _options: { backend?: string } = {}): Promise { + return this.#shell.disposeExec({ id }); + } } function resumeToAfter(resume: "tail" | "full" | number | undefined): number | "tail" | undefined { @@ -222,11 +228,8 @@ function resumeToAfter(resume: "tail" | "full" | number | undefined): number | " // ReadableStream that pipes from the wire stream and applies any // encoding conversion in flight. // -// The wire stream is tee'd so kill() can observe the exit event -// independently of whatever the caller does with the handle. kill() -// sends the signal then awaits the exit event so callers can rely on -// "resolved ⇒ child has exited" without having to drain the stream -// or await result() themselves. +// The user stream remains the only reader so backpressure reaches the backend. +// kill() requests a signal; result() or stream completion observes the exit. function wrapHandle( shell: ShellRPC, sync: Sync, @@ -235,10 +238,11 @@ function wrapHandle( encoding: E | undefined, pushed: number, ): ExecHandle { - const [forUser, forWatcher] = wireEvents.tee(); - const watcher = watchForExit(forWatcher); - const stream = pipeEvents(releaseWatcherOnCancel(forUser, watcher.release), encoding); + const postPull = withPostPull(pipeEvents(wireEvents, encoding), sync); + const stream = postPull.stream; const handle = stream as ExecHandle; + let resultPromise: Promise> | undefined; + let resultReader: ReadableStreamDefaultReader> | undefined; // configurable: true on result/kill lets the Workspace-level // router redefine them to add cross-cutting concerns (transport // failure invalidation on result(); future kill hooks). The id @@ -246,88 +250,30 @@ function wrapHandle( Object.defineProperties(handle, { id: { value: id, enumerable: false, writable: false, configurable: false }, result: { - value: () => drainToResult(stream, encoding, sync, pushed), + value: () => { + resultPromise ??= drainToResult(stream, encoding, pushed, postPull.outcome, (reader) => { + resultReader = reader; + }); + return resultPromise; + }, enumerable: false, writable: false, configurable: true, }, kill: { - value: async (signal?: KillSignal) => { - await shell.killExec({ id, signal }); - await watcher.exited; - }, + value: (signal?: KillSignal) => shell.killExec({ id, signal }), enumerable: false, writable: false, configurable: true, }, - }); - return handle; -} - -// Drain the watcher branch in the background, resolving once the -// first exit event is observed (or the stream closes / errors -// without one). Errors are swallowed so kill() doesn't reject on a -// torn-down wire — the caller's own branch will surface any real -// stream error. `release` abandons the watch, letting the branch -// cancel through to the wire stream. -function watchForExit(events: ReadableStream): { - exited: Promise; - release: () => void; -} { - const reader = events.getReader(); - const exited = (async () => { - try { - while (true) { - const { value, done } = await reader.read(); - if (done) return; - if (value.name === "exit") return; - } - } catch { - // Swallow — surfaced via the user-facing branch instead. - } finally { - reader.releaseLock(); - } - })(); - return { - exited, - release: () => { - reader.cancel().catch(() => {}); - }, - }; -} - -// A run allows one live subscriber in the container, so a caller -// that cancels its stream has to give the subscription up — until -// then a reattach through get() is refused for the rest of the run. -// Cancelling the caller's tee branch isn't enough: a tee holds its -// source until both branches cancel, and the watcher branch reads -// to the exit event. So the caller's cancel takes the watcher with -// it. -function releaseWatcherOnCancel( - source: ReadableStream, - release: () => void, -): ReadableStream { - const reader = source.getReader(); - return new ReadableStream( - { - pull: async (controller) => { - const { value, done } = await reader.read(); - if (done) { - controller.close(); - return; - } - controller.enqueue(value); - }, - cancel: async (reason) => { - // Order matters: the branch cancel doesn't settle until the - // watcher has cancelled too. - const cancelled = reader.cancel(reason); - release(); - await cancelled; + [Symbol.dispose]: { + value: () => { + if (resultReader) void resultReader.cancel().catch(() => undefined); + else void stream.cancel().catch(() => undefined); }, }, - { highWaterMark: 0 }, - ); + }); + return handle; } function pipeEvents( @@ -342,10 +288,23 @@ function pipeEvents( // across chunk splits. const stdoutDec = new TextDecoder("utf-8", { fatal: false }); const stderrDec = new TextDecoder("utf-8", { fatal: false }); + let stdoutMeta: { id: string; seq: number } | undefined; + let stderrMeta: { id: string; seq: number } | undefined; + const flushPending = (controller: TransformStreamDefaultController>) => { + const stdout = stdoutDec.decode(); + const stderr = stderrDec.decode(); + if (stdout && stdoutMeta) { + controller.enqueue({ ...stdoutMeta, name: "stdout", value: stdout as Chunk }); + } + if (stderr && stderrMeta) { + controller.enqueue({ ...stderrMeta, name: "stderr", value: stderr as Chunk }); + } + }; return source.pipeThrough( new TransformStream>({ transform(event, controller) { if (event.name === "stdout") { + stdoutMeta = { id: event.id, seq: event.seq }; controller.enqueue({ id: event.id, seq: event.seq, @@ -353,6 +312,7 @@ function pipeEvents( value: stdoutDec.decode(event.value, { stream: true }) as Chunk, }); } else if (event.name === "stderr") { + stderrMeta = { id: event.id, seq: event.seq }; controller.enqueue({ id: event.id, seq: event.seq, @@ -360,31 +320,102 @@ function pipeEvents( value: stderrDec.decode(event.value, { stream: true }) as Chunk, }); } else { + flushPending(controller); controller.enqueue(event as WorkspaceExecEvent); } }, - flush(_controller) { - // Flush any trailing bytes the streaming decoder - // held back. These are dropped on the floor today - // — they'd land in an event with no seq attached. - // In practice the child terminates its output with - // a newline; partial multi-byte sequences at EOF - // are rare. Note for follow-up if real callers see - // truncation. - stdoutDec.decode(); - stderrDec.decode(); - }, + flush: flushPending, }), ); } +interface PostPullOutcome { + applied: number; + skipped: SkippedEntry[]; + sync: ExecSyncResult; +} + +function withPostPull( + source: ReadableStream>, + sync: Sync, +): { stream: ReadableStream>; outcome: Promise } { + const reader = source.getReader(); + let resolveOutcome!: (outcome: PostPullOutcome) => void; + const outcome = new Promise((resolve) => { + resolveOutcome = resolve; + }); + const stream = new ReadableStream>( + { + async pull(controller) { + try { + const next = await reader.read(); + if (!next.done) { + controller.enqueue(next.value); + return; + } + reader.releaseLock(); + const pulled = await runPostPull(sync); + resolveOutcome(pulled); + controller.close(); + } catch (error) { + try { + reader.releaseLock(); + } catch {} + resolveOutcome({ + applied: 0, + skipped: [], + sync: { status: "pending", applied: 0, skipped: [], error: safeErrorMessage(error) }, + }); + controller.error(error); + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } finally { + reader.releaseLock(); + resolveOutcome({ + applied: 0, + skipped: [], + sync: { status: "pending", applied: 0, skipped: [], error: safeErrorMessage(reason) }, + }); + } + }, + }, + { highWaterMark: 0 }, + ); + return { stream, outcome }; +} + +async function runPostPull(sync: Sync): Promise { + try { + const result = await sync.pull(); + return { + applied: result.applied, + skipped: result.skipped, + sync: { status: "complete", applied: result.applied, skipped: result.skipped }, + }; + } catch (error) { + try { + await sync.onPullPending?.(error); + } catch {} + return { + applied: 0, + skipped: [], + sync: { status: "pending", applied: 0, skipped: [], error: safeErrorMessage(error) }, + }; + } +} + async function drainToResult( stream: ReadableStream>, encoding: E | undefined, - sync: Sync, pushed: number, + postPull: Promise, + setReader: (reader: ReadableStreamDefaultReader> | undefined) => void, ): Promise> { const reader = stream.getReader(); + setReader(reader); const stdoutParts: Array> = []; const stderrParts: Array> = []; let exitCode = -1; @@ -398,37 +429,17 @@ async function drainToResult( } } finally { reader.releaseLock(); + setReader(undefined); } - // Post-drain pull: apply anything computerd produced during the exec. - // Failures non-fatal per docs/05 ("failed pushes/pulls do not - // abort the command"); pulled / skipped stay at their empty - // values in that case. - let pulled = 0; - let skipped: SkippedEntry[] = []; - let syncResult: ExecSyncResult; - try { - const result = await sync.pull(); - pulled = result.applied; - skipped = result.skipped; - syncResult = { status: "complete", applied: pulled, skipped }; - } catch (error) { - syncResult = { status: "pending", applied: 0, skipped: [], error: safeErrorMessage(error) }; - try { - await sync.onPullPending?.(error); - } catch { - // The command result must remain available even when the host's - // durable scheduler is temporarily unavailable. The pending - // status keeps the missed pull visible to the caller. - } - } + const pulled = await postPull; return { exitCode, stdout: joinParts(stdoutParts, encoding), stderr: joinParts(stderrParts, encoding), pushed, - pulled, - skipped, - sync: syncResult, + pulled: pulled.applied, + skipped: pulled.skipped, + sync: pulled.sync, }; } @@ -453,34 +464,48 @@ function joinParts( return out as Chunk; } -// Pipe `stream` through an identity TransformStream that fires `onDone` -// exactly once when the stream finishes — clean end, cancel, or -// error. Used to release a capnweb result envelope as soon as the -// event stream it carried is drained, without having to keep the -// envelope reference alive across wrapHandle(). +// Wrap `stream` so its capnweb envelope is released exactly once on clean +// completion, source failure, or consumer cancellation. function disposeOnDone(stream: ReadableStream, onDone: () => void): ReadableStream { - let fired = false; - const fire = () => { - if (fired) return; - fired = true; + const reader = stream.getReader(); + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + try { + reader.releaseLock(); + } catch {} try { onDone(); } catch { - // ignore — disposer errors are not actionable here + // Disposer failures cannot be recovered at this boundary. } }; - return stream.pipeThrough( - new TransformStream({ - transform(chunk, controller) { - controller.enqueue(chunk); - }, - flush() { - fire(); + return new ReadableStream( + { + async pull(controller) { + try { + const { value, done } = await reader.read(); + if (done) { + finish(); + controller.close(); + } else { + controller.enqueue(value); + } + } catch (error) { + finish(); + controller.error(error); + } }, - cancel() { - fire(); + async cancel(reason) { + try { + await reader.cancel(reason); + } finally { + finish(); + } }, - }), + }, + { highWaterMark: 0 }, ); } diff --git a/packages/computer/src/stub.test.ts b/packages/computer/src/stub.test.ts index cf319c4e..b1511383 100644 --- a/packages/computer/src/stub.test.ts +++ b/packages/computer/src/stub.test.ts @@ -16,14 +16,14 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { beforeAll, describe, expect, it } from "vitest"; import type { BackendHandle, WorkspaceBackend } from "./backend.js"; -import { decodeExecEvents } from "./exec-wire.js"; -import type { ExecHandle, ExecResult } from "./shell.js"; +import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "./runtime/types.js"; +import { decodeRuntimeEvents } from "./runtime/wire.js"; import { WorkspaceAssetsStub, - WorkspaceExecHandleStub, WorkspaceFilesystemStub, WorkspaceGitStub, - WorkspaceShellStub, + WorkspaceRuntimeExecHandleStub, + WorkspaceRuntimeStub, } from "./stub.js"; import { Workspace } from "./workspace.js"; @@ -85,6 +85,7 @@ function fakeSync(): import("@cloudflare/computer-rpc").SyncRPC { function backend(rpc?: Partial): WorkspaceBackend { return { id: "test", + type: "test", async connect(): Promise { const sync = rpc?.sync ?? fakeSync(); const shell = rpc?.shell as Partial | undefined; @@ -102,7 +103,7 @@ function snapshotOf(names: string[]): Record { async function withStub( fn: (ws: Workspace) => T | Promise, - options?: Pick[0], "assets"> & { + options?: Pick[0], "assets" | "code"> & { backend?: WorkspaceBackend; }, ): Promise { @@ -110,6 +111,7 @@ async function withStub( storage: new SQLiteTestStorage(), backends: [options?.backend ?? backend()], assets: options?.assets, + code: options?.code, }); try { await ws.ready(); @@ -127,16 +129,17 @@ describe("WorkspaceStub", () => { await withStub(async (ws) => { const stub = ws.stub(); const fsDesc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(stub), "fs"); - const shellDesc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(stub), "shell"); const gitDesc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(stub), "git"); + const runtimeDesc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(stub), "runtime"); const assetsDesc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(stub), "assets"); expect(fsDesc?.get).toBeTypeOf("function"); - expect(shellDesc?.get).toBeTypeOf("function"); expect(gitDesc?.get).toBeTypeOf("function"); + expect(runtimeDesc?.get).toBeTypeOf("function"); expect(assetsDesc?.get).toBeTypeOf("function"); expect(stub.fs).toBeInstanceOf(WorkspaceFilesystemStub); - expect(stub.shell).toBeInstanceOf(WorkspaceShellStub); + expect(stub.runtime).toBeInstanceOf(WorkspaceRuntimeStub); expect(stub.git).toBeInstanceOf(WorkspaceGitStub); + expect(stub.runtime).toBeInstanceOf(WorkspaceRuntimeStub); expect(stub.assets).toBeUndefined(); }); }); @@ -190,6 +193,19 @@ describe("WorkspaceStub", () => { }); }); + it("fs.readdir forwards bounded-read options", async () => { + await withStub(async (ws) => { + const stub = ws.stub(); + await ws.fs.writeFile("/a", ""); + await ws.fs.writeFile("/b", ""); + await ws.fs.writeFile("/c", ""); + expect((await stub.fs.readdir("/", { limit: 2 })).map((entry) => entry.name)).toEqual([ + "a", + "b", + ]); + }); + }); + it("fs.stat propagates ENOENT for missing paths", async () => { await withStub(async (ws) => { const stub = ws.stub(); @@ -200,7 +216,7 @@ describe("WorkspaceStub", () => { it("shell.exec auto-reconnects after the backend signals closed", async () => { // When the underlying transport drops mid-session, the // Workspace clears its cached BackendHandle for that backend - // id. The next stub.shell.exec call must dial a fresh handle + // id. The next stub.runtime.exec call must dial a fresh handle // through the lazy-connect path rather than reuse the dead // one. Pin the heal so a transport drop is invisible to the // caller. @@ -261,9 +277,9 @@ describe("WorkspaceStub", () => { signalClosed(); await new Promise((r) => setTimeout(r, 0)); - // The next stub.shell.exec call enters the lazy connect + // The next stub.runtime.exec call enters the lazy connect // path against the now-empty handle cache and reconnects. - const handle = await stub.shell.exec("noop"); + const handle = await stub.runtime.exec("noop"); const res = await handle.result(); expect(res.exitCode).toBe(0); expect(connectCount).toBe(2); @@ -289,12 +305,12 @@ describe("WorkspaceStub", () => { enableStubTracking(); }); - it("disposing the parent stub releases fs, shell, and git sub-stubs", async () => { + it("disposing the parent stub releases fs, runtime, and git sub-stubs", async () => { await withStub(async (ws) => { const names = [ "WorkspaceStub", "WorkspaceFilesystemStub", - "WorkspaceShellStub", + "WorkspaceRuntimeStub", "WorkspaceGitStub", ]; const before = snapshotOf(names); @@ -302,12 +318,12 @@ describe("WorkspaceStub", () => { using stub = ws.stub(); // Touch every half so any lazy construction lands. expect(stub.fs).toBeInstanceOf(WorkspaceFilesystemStub); - expect(stub.shell).toBeInstanceOf(WorkspaceShellStub); + expect(stub.runtime).toBeInstanceOf(WorkspaceRuntimeStub); expect(stub.git).toBeInstanceOf(WorkspaceGitStub); const live = snapshotOf(names); expect(live.WorkspaceStub).toBe(before.WorkspaceStub + 1); expect(live.WorkspaceFilesystemStub).toBe(before.WorkspaceFilesystemStub + 1); - expect(live.WorkspaceShellStub).toBe(before.WorkspaceShellStub + 1); + expect(live.WorkspaceRuntimeStub).toBe(before.WorkspaceRuntimeStub + 1); expect(live.WorkspaceGitStub).toBe(before.WorkspaceGitStub + 1); } // Out of scope — `using` ran Symbol.dispose on the parent, @@ -336,7 +352,7 @@ describe("WorkspaceStub", () => { }); }); - it("shell result carries the structured sync outcome across Workers RPC", async () => { + it("runtime result carries the structured sync outcome across Workers RPC", async () => { const result = { exitCode: 0, stdout: "done", @@ -350,22 +366,13 @@ describe("WorkspaceStub", () => { skipped: [], error: "WebSocket closed before pull completed", }, - } satisfies ExecResult<"utf8">; - const hostHandle = { result: async () => result } as ExecHandle<"utf8">; - using handle = new WorkspaceExecHandleStub<"utf8">( - Promise.resolve(hostHandle), - { - promise: Promise.resolve({ - exitCode: 0, - pushed: 0, - pulled: 0, - skippedCount: 0, - sync: { status: "complete", applied: 0, skipped: [] }, - }), - resolve() {}, - }, - Promise.resolve(), - ); + } satisfies WorkspaceRuntimeResult<"utf8">; + const hostHandle = { + id: "result", + result: async () => result, + kill: async () => undefined, + } as unknown as WorkspaceRuntimeExecHandle<"utf8">; + using handle = new WorkspaceRuntimeExecHandleStub<"utf8">(hostHandle); await expect(handle.result()).resolves.toMatchObject({ exitCode: 0, sync: { @@ -377,8 +384,8 @@ describe("WorkspaceStub", () => { }); }); - it("shell.exec returns an eagerly-spawned handle", async () => { - // The stub's exec() kicks off the underlying workspace.shell.exec + it("runtime.exec returns an eagerly-spawned handle", async () => { + // The stub's exec() kicks off the underlying workspace.runtime.exec // before returning, so the caller's first round trip already has // the spawn in flight. We can't directly observe "eager" without // a clock, but we can pin that the returned handle is the right @@ -405,8 +412,8 @@ describe("WorkspaceStub", () => { await withStub( async (ws) => { const stub = ws.stub(); - const handle = await stub.shell.exec("noop"); - expect(handle).toBeInstanceOf(WorkspaceExecHandleStub); + const handle = await stub.runtime.exec("noop"); + expect(handle).toBeInstanceOf(WorkspaceRuntimeExecHandleStub); // exec ran by the time result() resolves. The stub kicks off // the underlying exec eagerly (via promise chaining) so the // caller doesn't pay an extra round trip before result(). @@ -418,88 +425,7 @@ describe("WorkspaceStub", () => { ); }); - it("shell.exec forwards id and timeoutMs to the runner", async () => { - // The remote surface accepts the same options as the host shell, - // so a stable id and a per-call timeout have to reach the runner - // rather than being dropped at the boundary. - const requests: Array<{ id?: string; timeoutMs?: number }> = []; - const shellRpc: import("@cloudflare/computer-rpc").ShellRPC = { - async exec(request) { - requests.push({ id: request.id, timeoutMs: request.timeoutMs }); - return { - id: request.id ?? "e-1", - events: new ReadableStream({ - start(c) { - c.enqueue({ id: request.id ?? "e-1", seq: 1, name: "exit", value: 0 }); - c.close(); - }, - }), - }; - }, - getExec: () => Promise.reject(new Error("not used")), - killExec: () => Promise.reject(new Error("not used")), - disposeExec: () => Promise.reject(new Error("not used")), - }; - await withStub( - async (ws) => { - const stub = ws.stub(); - using handle = await stub.shell.exec("noop", { - encoding: "utf8", - id: "install-1", - timeoutMs: 250, - }); - await handle.result(); - expect(requests).toEqual([{ id: "install-1", timeoutMs: 250 }]); - }, - { backend: backend({ shell: shellRpc }) }, - ); - }); - - it("shell.get reattaches to a run and resumes where the caller asked", async () => { - // A command outlives the request that spawned it, so a later - // request reattaches by id. `resume` reaches the runner as the - // `after` cursor of the replay. - const requests: Array<{ id: string; after: number | "tail" | undefined }> = []; - const shellRpc: import("@cloudflare/computer-rpc").ShellRPC = { - exec: () => Promise.reject(new Error("not used")), - async getExec(request) { - requests.push({ id: request.id, after: request.after }); - return { - id: request.id, - events: new ReadableStream({ - start(c) { - c.enqueue({ - id: request.id, - seq: 8, - name: "stdout", - value: new TextEncoder().encode("tail\n"), - }); - c.enqueue({ id: request.id, seq: 9, name: "exit", value: 0 }); - c.close(); - }, - }), - }; - }, - killExec: () => Promise.reject(new Error("not used")), - disposeExec: () => Promise.reject(new Error("not used")), - }; - await withStub( - async (ws) => { - const stub = ws.stub(); - using handle = await stub.shell.get("install-1", { encoding: "utf8", resume: 7 }); - await expect(handle.result()).resolves.toMatchObject({ exitCode: 0, stdout: "tail\n" }); - using tail = await stub.shell.get("install-1", { resume: "tail" }); - await tail.result(); - expect(requests).toEqual([ - { id: "install-1", after: 7 }, - { id: "install-1", after: "tail" }, - ]); - }, - { backend: backend({ shell: shellRpc }) }, - ); - }); - - it("shell.exec handle.stream() frames events as JSONL that decode back", async () => { + it("runtime.exec handle.stream() frames events as JSONL that decode back", async () => { // The handle's stream() projects the event stream as JSONL bytes // for the wire. Decoding it back yields the original events, // including a binary stdout chunk carried base64. @@ -524,9 +450,9 @@ describe("WorkspaceStub", () => { await withStub( async (ws) => { const stub = ws.stub(); - const handle = await stub.shell.exec("noop"); + const handle = await stub.runtime.exec("noop"); const events: Array<{ name: string; value: unknown }> = []; - const decoded = decodeExecEvents(handle.stream()); + const decoded = decodeRuntimeEvents(handle.stream()); const reader = decoded.getReader(); while (true) { const { value, done } = await reader.read(); @@ -543,7 +469,7 @@ describe("WorkspaceStub", () => { ); }); - it("shell.exec handle.stream() waits for the wire consumer to pull", async () => { + it("runtime.exec handle.stream() waits for the wire consumer to pull", async () => { let pulls = 0; const shellRpc: import("@cloudflare/computer-rpc").ShellRPC = { async exec() { @@ -574,10 +500,12 @@ describe("WorkspaceStub", () => { await withStub( async (ws) => { const stub = ws.stub(); - const handle = await stub.shell.exec("noop"); + const handle = await stub.runtime.exec("noop"); const wire = handle.stream(); await Promise.resolve(); - expect(pulls).toBe(0); + // WorkspaceShell's internal exit watcher may prefetch a small number of + // events, but the user-facing wire must not drain the whole execution. + expect(pulls).toBeLessThan(10); const reader = wire.getReader(); const first = await reader.read(); @@ -590,7 +518,7 @@ describe("WorkspaceStub", () => { ); }); - it("shell.exec handle rejects result() after stream() has claimed it", async () => { + it("runtime.exec handle rejects result() after stream() has claimed it", async () => { const shellRpc: import("@cloudflare/computer-rpc").ShellRPC = { async exec() { return { @@ -610,89 +538,11 @@ describe("WorkspaceStub", () => { await withStub( async (ws) => { const stub = ws.stub(); - const handle = await stub.shell.exec("noop"); + const handle = await stub.runtime.exec("noop"); handle.stream(); await expect(handle.result()).rejects.toThrow(/single-shot/); }, { backend: backend({ shell: shellRpc }) }, ); }); - - it("shell.exec rejects when the backend runs under a different id", async () => { - // The caller reattaches by the id it asked for, so a backend that - // substitutes its own has to fail loudly instead of handing back a - // handle shell.get can't find. - const shellRpc: import("@cloudflare/computer-rpc").ShellRPC = { - async exec() { - return { - id: "runner-picked", - events: new ReadableStream({ - start(c) { - c.enqueue({ id: "runner-picked", seq: 1, name: "exit", value: 0 }); - c.close(); - }, - }), - }; - }, - getExec: () => Promise.reject(new Error("not used")), - killExec: () => Promise.reject(new Error("not used")), - disposeExec: () => Promise.reject(new Error("not used")), - }; - await withStub( - async (ws) => { - const stub = ws.stub(); - using handle = await stub.shell.exec("noop", { id: "asked-for" }); - await expect(handle.result()).rejects.toThrow(/not the requested id asked-for/); - }, - { backend: backend({ shell: shellRpc }) }, - ); - }); - - it("disposing an exec handle frees the run for a later shell.get", async () => { - // The documented Worker flow: start a long command in one - // request, reattach in a later one. Only one live subscriber per - // run is allowed, so the handle stub's disposal has to hand the - // subscription back. - let subscriber: ReadableStreamDefaultController< - import("@cloudflare/computer-rpc").ExecEvent - > | null = null; - const subscribe = (id: string) => { - if (subscriber !== null) throw new Error(`exec ${id} already has a live subscriber`); - return new ReadableStream({ - start(c) { - subscriber = c; - }, - cancel() { - subscriber = null; - }, - }); - }; - const shellRpc: import("@cloudflare/computer-rpc").ShellRPC = { - async exec(request) { - const id = request.id ?? "e-1"; - return { id, events: subscribe(id) }; - }, - async getExec(request) { - return { id: request.id, events: subscribe(request.id) }; - }, - killExec: () => Promise.reject(new Error("not used")), - disposeExec: () => Promise.reject(new Error("not used")), - }; - - await withStub( - async (ws) => { - const stub = ws.stub(); - { - using handle = await stub.shell.exec("sleep 100", { id: "long-run" }); - expect(handle).toBeDefined(); - } - // Disposal cancels the handle's stream in the background. - await new Promise((resolve) => setTimeout(resolve, 0)); - - using again = await stub.shell.get("long-run", { resume: "tail" }); - expect(again).toBeDefined(); - }, - { backend: backend({ shell: shellRpc }) }, - ); - }); }); diff --git a/packages/computer/src/stub.ts b/packages/computer/src/stub.ts index 68c99efc..3e5d608b 100644 --- a/packages/computer/src/stub.ts +++ b/packages/computer/src/stub.ts @@ -6,8 +6,8 @@ // // Usage shape: // -// // Inside a DO that owns the live computerd connection: -// class ComputerdContainer extends DurableObject { +// // Inside a DO that owns the live wsd connection: +// class WsdContainer extends DurableObject { // #workspace = new Workspace({ backends: [...] }); // async getWorkspace(): Promise { // await this.#workspace.ready(); @@ -16,9 +16,9 @@ // } // // // From a Worker (or another DO): -// const ws = await env.COMPUTERD.get(id).getWorkspace(); +// const ws = await env.WSD.get(id).getWorkspace(); // await ws.fs.writeFile("/foo", bytes); -// const handle = await ws.shell.exec("ls /workspace"); +// const handle = await ws.runtime.exec("ls /workspace"); // const { exitCode, stdout, stderr } = await handle.result(); // // All the SyncRPC streaming (push / pushObjects / fetchObjects / @@ -28,12 +28,9 @@ // because Workers RPC doesn't carry non-byte ReadableStreams or // capnweb stubs. // -// Streaming exec crosses the boundary as bytes. Workers RPC only -// carries ReadableStream, so the exec handle's event -// stream is framed as JSONL bytes by handle.stream() and inflated -// back into events on the Worker side (see exec-wire.ts and the -// getWorkspace client). The handle also exposes result() (run-and- -// wait) and kill(). +// Runtime event streams cross Workers RPC as newline-delimited byte frames. +// The handle exposes the same single-consumer result, stream, kill, backend, +// and disposal surface as a local runtime handle. // // RpcTarget comes from capnweb rather than `cloudflare:workers`. // Per capnweb's docs, that import is an alias for the workerd @@ -46,6 +43,7 @@ import { trackStub, untrackStub } from "@cloudflare/computer-rpc/debug"; import type { GrepOptions, MkdirOptions, + ReaddirOptions, ReadFileOptions, RmOptions, WorkspaceDirentResult, @@ -67,52 +65,19 @@ import type { ArtifactsCLIResult, } from "./artifacts/index.js"; import type { ShareOptions } from "./assets/index.js"; -import { encodeExecEvent } from "./exec-wire.js"; import type { GitCliInput, GitCliResult } from "./git/index.js"; import { withSpan } from "./observe.js"; -import { assertNotTemplate } from "./sh.js"; -import type { ExecEncoding, ExecHandle, ExecSyncResult, WorkspaceExecEvent } from "./shell.js"; +import type { + WorkspaceRuntimeEvent, + WorkspaceRuntimeExecHandle, + WorkspaceRuntimeExecOptions, + WorkspaceRuntimeGetOptions, + WorkspaceRuntimeKillOptions, + WorkspaceRuntimeResult, +} from "./runtime/types.js"; +import { encodeRuntimeEvent } from "./runtime/wire.js"; import type { Workspace } from "./workspace.js"; -export interface WorkspaceExecOptions { - // Stable id for the run. Omit to let the runner mint a UUID. - // Reusing an id while a previous run is still active throws - // EEXEC_BUSY. - id?: string; - cwd?: string; - // "utf8" decodes stdout/stderr chunks through a streaming - // TextDecoder so multi-byte boundaries survive. Default leaves - // bytes as Uint8Array. - encoding?: "utf8"; - // Per-call timeout in milliseconds. Past this duration the - // container signals the child. Omit for the runner's default; - // pass 0 to disable the timeout for this call. - timeoutMs?: number; - // Backend selector. Omit to use the default backend (the first - // one configured on the Workspace); pass the id of another - // configured backend to route this call there. - backend?: string; -} - -export interface WorkspaceGetExecOptions { - // See WorkspaceExecOptions.encoding. - encoding?: "utf8"; - // Where the replayed event stream starts: "tail" for live events - // only, "full" (the default) for everything the runner still holds, - // or a sequence number to resume after. - resume?: "tail" | "full" | number; - // See WorkspaceExecOptions.backend. Reattach is per-backend: the id - // is only known to the backend that ran the command. - backend?: string; -} - -export interface WorkspaceExecResult { - exitCode: number; - stdout: E extends "utf8" ? string : Uint8Array; - stderr: E extends "utf8" ? string : Uint8Array; - sync: ExecSyncResult; -} - // Filesystem half. A direct proxy onto Workspace.fs — every // public WorkspaceFilesystem method is mirrored verbatim so the // remote surface matches the in-process surface one-for-one. @@ -149,6 +114,23 @@ export class WorkspaceFilesystemStub extends RpcTarget { ); } + exists(path: string): Promise { + return withSpan( + this.#ws.observer, + "workspace.fs.exists", + { "workspace.fs.path": path }, + async () => { + try { + await this.#ws.fs.stat(path); + return true; + } catch (error) { + if ((error as { code?: string }).code === "ENOENT") return false; + throw error; + } + }, + ); + } + stat(path: string): Promise { return withSpan(this.#ws.observer, "workspace.fs.stat", { "workspace.fs.path": path }, () => this.#ws.fs.stat(path), @@ -167,12 +149,12 @@ export class WorkspaceFilesystemStub extends RpcTarget { ); } - readdir(path: string): Promise { + readdir(path: string, options: ReaddirOptions = {}): Promise { return withSpan( this.#ws.observer, "workspace.fs.readdir", { "workspace.fs.path": path }, - () => this.#ws.fs.readdir(path), + () => this.#ws.fs.readdir(path, options), (span, outcome) => { if (outcome.ok) span.setAttribute("workspace.fs.entries", outcome.value.length); }, @@ -271,205 +253,167 @@ export class WorkspaceFilesystemStub extends RpcTarget { } } -// How the handle was consumed, fed back to the exec span so it can -// record the exit code. result() carries the full outcome; stream() -// carries the exit code observed on the wire and zeroes the sync -// counts (raw stream consumption skips the post-exit pull, matching -// the host ExecHandle contract). -interface ConsumeOutcome { - exitCode: number; - pushed: number; - pulled: number; - skippedCount: number; - sync: ExecSyncResult; -} - -// A deferred the exec span awaits. Resolving it (via result(), -// stream(), or dispose) lets the span close and record its outcome. -interface Consumer { - promise: Promise; - resolve(outcome: ConsumeOutcome): void; -} - -function makeConsumer(): Consumer { - let resolve!: (outcome: ConsumeOutcome) => void; - const promise = new Promise((r) => { - resolve = r; - }); - return { promise, resolve }; -} - -function emptyConsumeOutcome(exitCode = -1): ConsumeOutcome { - return { - exitCode, - pushed: 0, - pulled: 0, - skippedCount: 0, - sync: { status: "complete", applied: 0, skipped: [] }, - }; -} - -// Exec handle returned from WorkspaceShellStub.exec. Holds the -// underlying host ExecHandle and projects it across Workers RPC: -// -// - result() drains the handle and returns the run-and-wait result. -// - stream() returns the event stream framed as JSONL bytes — the -// one shape Workers RPC carries — for run-and-stream callers. The -// client side inflates it back into events. -// - kill() forwards a signal to the running command. -// -// result() and stream() are mutually exclusive: each drains the -// single underlying handle, so the first one called wins and the -// other throws (the host ExecHandle is a one-shot ReadableStream). -// This mirrors the host contract exactly. kill() is not one of the -// two: it signals the child without draining anything, so the output -// of a killed run remains available. -export class WorkspaceExecHandleStub extends RpcTarget { - readonly #handle: Promise>; - readonly #consumer: Consumer; - // Resolves when the exec span has closed (finalize has run). result() - // awaits it so the span's attributes are set before result() returns - // — observers see a complete span synchronously after the await. - readonly #span: Promise; +export class WorkspaceRuntimeExecHandleStub< + E extends "utf8" | undefined = undefined, +> extends RpcTarget { + readonly #handle: WorkspaceRuntimeExecHandle; #consumed = false; - #settled = false; + #disposed = false; + #reader: ReadableStreamDefaultReader> | undefined; - constructor(handle: Promise>, consumer: Consumer, span: Promise) { + constructor(handle: WorkspaceRuntimeExecHandle) { super(); this.#handle = handle; - this.#consumer = consumer; - this.#span = span; trackStub(this); } [Symbol.dispose](): void { - // If neither result() nor stream() ran, release the exec span so - // it doesn't hang open, and cancel the handle's stream so the run - // stops streaming to nobody — and stays reattachable through - // shell.get, which needs the subscription slot free. - if (!this.#consumed) { - this.#consumed = true; - this.#settle(emptyConsumeOutcome()); - this.#handle - .then((handle) => handle.cancel?.()) - .catch(() => { - // best effort — nothing to do if the handle never resolved - }); + if (this.#disposed) return; + this.#disposed = true; + const reader = this.#reader; + this.#reader = undefined; + if (reader) { + void reader + .cancel() + .catch(() => undefined) + .finally(() => reader.releaseLock()); + } else { + const cancel = (this.#handle as { cancel?: () => Promise }).cancel; + if (typeof cancel === "function") void cancel.call(this.#handle).catch(() => undefined); } untrackStub(this); } - #claim(): void { - if (this.#consumed) { - throw new Error("exec handle already consumed: result() and stream() are single-shot"); - } - this.#consumed = true; + get id(): string { + return this.#handle.id; } - // Close the exec span, once. Every terminal path settles: result(), - // stream(), kill(), and dispose. The first one to arrive describes - // the outcome; later ones are no-ops, so a result() after a kill() - // still returns the real result while the span keeps the outcome the - // kill recorded. - #settle(outcome: ConsumeOutcome): void { - if (this.#settled) return; - this.#settled = true; - this.#consumer.resolve(outcome); + get backend(): string { + return this.#handle.backend; } - async result(): Promise> { + async result(): Promise> { this.#claim(); - try { - const handle = await this.#handle; - const result = await handle.result(); - this.#settle({ - exitCode: result.exitCode, - pushed: result.pushed, - pulled: result.pulled, - skippedCount: result.skipped.length, - sync: result.sync, - }); - // Let the span close before returning so its attributes are set. - await this.#span.catch(() => {}); - return { - exitCode: result.exitCode, - // joinParts in shell.ts returns string for "utf8", - // Uint8Array otherwise — exactly the - // WorkspaceExecResult shape. - stdout: result.stdout as WorkspaceExecResult["stdout"], - stderr: result.stderr as WorkspaceExecResult["stderr"], - sync: result.sync, - }; - } catch (error) { - this.#settle(emptyConsumeOutcome()); - throw error; - } + return this.#handle.result(); } - // Event stream framed as JSONL bytes for the wire. The client - // decodes it back into WorkspaceExecEvents. Raw stream consumption - // skips the post-exit pull, so the exec span records zero sync - // counts; the exit code is captured off the wire as it passes. stream(): ReadableStream { this.#claim(); - const settle = (outcome: ConsumeOutcome) => this.#settle(outcome); - let reader: ReadableStreamDefaultReader> | undefined; - let exitCode = -1; + const reader = ( + this.#handle as ReadableStream> + ).getReader(); + this.#reader = reader; + const owner = this; return new ReadableStream( { - pull: async (controller) => { + async pull(controller) { try { - if (reader === undefined) { - const handle = await this.#handle; - reader = (handle as ReadableStream>).getReader(); - } const { value, done } = await reader.read(); if (done) { reader.releaseLock(); - reader = undefined; + owner.#reader = undefined; controller.close(); - settle(emptyConsumeOutcome(exitCode)); return; } - if (value.name === "exit") exitCode = value.value; - controller.enqueue(encodeExecEvent(value as WorkspaceExecEvent)); + controller.enqueue(encodeRuntimeEvent(value)); } catch (error) { - settle(emptyConsumeOutcome()); + reader.releaseLock(); + owner.#reader = undefined; controller.error(error); } }, - cancel: async (reason) => { - settle(emptyConsumeOutcome()); - await reader?.cancel(reason); - reader?.releaseLock(); - reader = undefined; + async cancel(reason) { + try { + await reader.cancel(reason); + } finally { + reader.releaseLock(); + owner.#reader = undefined; + } }, }, { highWaterMark: 0 }, ); } - async kill(signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"): Promise { - const handle = await this.#handle; - await handle.kill(signal); - // A result() or stream() already reading the handle describes the - // outcome itself — it sees the exit event this signal produced, - // exit code and all. Leave the span to it, and don't wait on it - // either: it closes when the caller finishes reading, which may be - // long after kill() returns. - if (this.#consumed) return; - // Otherwise this is a terminal path: the child has exited and - // nothing will read the output, so close the span rather than - // leave it open for the life of the session. With nothing drained - // the outcome carries no exit code or sync counts. - this.#settle(emptyConsumeOutcome()); - // Same reason result() waits: the span's attributes are set by the - // time kill() resolves. - await this.#span.catch(() => {}); + #claim() { + if (this.#consumed) { + throw new Error("runtime handle already consumed: result() and stream() are single-shot"); + } + this.#consumed = true; + } + + kill(signal?: WorkspaceRuntimeKillOptions["signal"]): Promise { + return this.#handle.kill(signal); } } -// Assets half. Pure value returns: `publish(path, { expiresAfter })` +export class WorkspaceRuntimeStub extends RpcTarget { + readonly #ws: Workspace; + + constructor(ws: Workspace) { + super(); + this.#ws = ws; + trackStub(this); + } + + [Symbol.dispose](): void { + untrackStub(this); + } + + exec(source: string): Promise>; + exec( + source: string, + options: WorkspaceRuntimeExecOptions<"utf8">, + ): Promise>; + exec( + source: string, + options: WorkspaceRuntimeExecOptions, + ): Promise>; + async exec( + source: string, + options: WorkspaceRuntimeExecOptions<"utf8" | undefined> = {}, + ): Promise> { + const handle = await ( + this.#ws.runtime.exec as unknown as ( + source: string, + options: WorkspaceRuntimeExecOptions<"utf8" | undefined>, + ) => Promise> + )(source, options); + return new WorkspaceRuntimeExecHandleStub(handle); + } + + getExec(id: string): Promise>; + getExec( + id: string, + options: WorkspaceRuntimeGetOptions<"utf8">, + ): Promise>; + getExec( + id: string, + options: WorkspaceRuntimeGetOptions, + ): Promise>; + async getExec( + id: string, + options: WorkspaceRuntimeGetOptions<"utf8" | undefined> = {}, + ): Promise> { + const handle = await ( + this.#ws.runtime.getExec as unknown as ( + id: string, + options: WorkspaceRuntimeGetOptions<"utf8" | undefined>, + ) => Promise> + )(id, options); + return new WorkspaceRuntimeExecHandleStub(handle); + } + + killExec(id: string, options: WorkspaceRuntimeKillOptions = {}): Promise { + return this.#ws.runtime.killExec(id, options); + } + + disposeExec(id: string, options: { backend?: string } = {}): Promise { + return this.#ws.runtime.disposeExec(id, options); + } +} + +// Assets half. Pure value returns: `publish(path, { expiresAfter }) // resolves to the share URL string. The configured assets client // lives on the durable-object side where the R2 binding and signing // secrets are available; the worker-backend shell only reaches this @@ -596,169 +540,6 @@ export class WorkspaceGitStub extends RpcTarget { } } -// Shell half. exec() and get() both return an RpcTarget handle the -// caller drains through result() or stream(); see the note at the top -// of this file. -export class WorkspaceShellStub extends RpcTarget { - readonly #ws: Workspace; - - constructor(ws: Workspace) { - super(); - this.#ws = ws; - trackStub(this); - } - - [Symbol.dispose](): void { - untrackStub(this); - } - - exec(command: string): Promise>; - exec( - command: string, - options: WorkspaceExecOptions & { encoding: "utf8" }, - ): Promise>; - exec(command: string, options: WorkspaceExecOptions): Promise>; - async exec( - command: string, - options: WorkspaceExecOptions = {}, - ): Promise> { - // A worker that calls this as a tagged template ships a - // TemplateStringsArray over Workers RPC, which loses its `.raw` - // property to structured clone before it lands here. Reject it - // so the unsafe path fails loudly; escaping belongs caller-side - // through sh`...`. - assertNotTemplate(command); - // Heal a torn-down session before reaching for the shell. The - // backend's `closed` listener (see workspace.ts) clears #handle, - // #shell, and #readyPromise on a mid-session transport drop, so - // a bare `this.#ws.shell` would throw "Workspace not connected" - // until a caller manually called ready() again. ready() is - // idempotent on a live handle and re-enters connect() on a dead - // one, so a stale container is detected and replaced - // transparently here. - await this.#ws.ready(); - - // Kick off the exec eagerly so the caller's first round trip - // (the one that built this stub) already has the spawn in - // flight. The handle is consumed later by the returned stub's - // result() or stream(). - // - // The bracket runs inside one `workspace.shell.exec` span. The - // span stays open until the handle is consumed: the spawn runs - // inside the callback (so `workspace.shell.exec.spawn` and the - // pre-exec push nest under it), then the callback awaits the - // consumer deferred, which the stub resolves from result() / - // stream() / dispose. Because the recorder keeps the span on its - // stack across that await, the post-drain pull triggered by - // result() also nests under this span. - const consumer = makeConsumer(); - let resolveHandle!: (handle: ExecHandle<"utf8" | undefined>) => void; - let rejectHandle!: (error: unknown) => void; - const handle = new Promise>((resolve, reject) => { - resolveHandle = resolve; - rejectHandle = reject; - }); - // Swallow rejections on the convenience promise; the real - // rejection is delivered to whoever awaits the handle. - handle.catch(() => {}); - - const span = withSpan( - this.#ws.observer, - "workspace.shell.exec", - { - "workspace.shell.cwd": options.cwd, - "workspace.shell.encoding": options.encoding, - "workspace.shell.backend": options.backend, - }, - async () => { - const spawned = - options.encoding === "utf8" - ? await this.#ws.shell.exec(command, { - id: options.id, - cwd: options.cwd, - encoding: "utf8", - timeoutMs: options.timeoutMs, - backend: options.backend, - }) - : await this.#ws.shell.exec(command, { - id: options.id, - cwd: options.cwd, - timeoutMs: options.timeoutMs, - backend: options.backend, - }); - // The caller addresses the run by the id it asked for, so a - // backend that mints its own instead has to say so rather - // than hand back a handle nothing can reattach to. - if (options.id !== undefined && spawned.id !== options.id) { - throw new Error(`backend ran exec as ${spawned.id}, not the requested id ${options.id}`); - } - resolveHandle(spawned as ExecHandle<"utf8" | undefined>); - return consumer.promise; - }, - (span, outcome) => { - if (!outcome.ok) return; - span.setAttribute("workspace.shell.exit_code", outcome.value.exitCode); - span.setAttribute("workspace.shell.pushed", outcome.value.pushed); - span.setAttribute("workspace.shell.pulled", outcome.value.pulled); - span.setAttribute("workspace.shell.skipped", outcome.value.skippedCount); - span.setAttribute("workspace.shell.sync.status", outcome.value.sync.status); - if (outcome.value.sync.status === "pending") { - span.setAttribute("workspace.shell.sync.error", outcome.value.sync.error); - } - }, - ); - // If the spawn throws, the span rejects: forward the failure to - // the handle so result() / stream() reject, and keep the span - // promise from surfacing as an unhandled rejection. - span.catch((error) => rejectHandle(error)); - return new WorkspaceExecHandleStub<"utf8" | undefined>(handle, consumer, span); - } - - get(id: string): Promise>; - get( - id: string, - options: WorkspaceGetExecOptions & { encoding: "utf8" }, - ): Promise>; - get(id: string, options: WorkspaceGetExecOptions): Promise>; - // Reattach to a run by id. The command outlives the request that - // started it, so a later request — or a caller that dropped its - // handle stub — picks the events back up here. - async get( - id: string, - options: WorkspaceGetExecOptions = {}, - ): Promise> { - // Same reason exec() calls ready(): heal a torn-down session - // before reaching for the shell. - await this.#ws.ready(); - const reattached = - options.encoding === "utf8" - ? await this.#ws.shell.get(id, { - encoding: "utf8", - resume: options.resume, - backend: options.backend, - }) - : await this.#ws.shell.get(id, { - resume: options.resume, - backend: options.backend, - }); - // Reattach runs no bracket: no pre-exec push, no post-exit pull, - // and so no span to hold open until the handle is consumed (see - // WorkspaceShell.get). The handle stub still reports how it was - // consumed, so give it a consumer nobody reads and a span that has - // already closed. - // - // Disposing this stub cancels the event stream, which gives the - // subscription back so the run can be reattached again. It doesn't - // stop the command — this handle didn't start it. Use kill() for - // that. - return new WorkspaceExecHandleStub<"utf8" | undefined>( - Promise.resolve(reattached as ExecHandle<"utf8" | undefined>), - makeConsumer(), - Promise.resolve(), - ); - } -} - // Top-level wrapper. Two sub-RpcTargets let callers use promise // pipelining: `stub.fs.writeFile(...)` is one round trip, not two. // @@ -767,7 +548,7 @@ export class WorkspaceShellStub extends RpcTarget { // // Note the name collision: the *type* `WorkspaceRPC` is also // exported by @cloudflare/computer-rpc as the wire contract -// between computerd and the DO. WorkspaceStub here is a different thing +// between wsd and the DO. WorkspaceStub here is a different thing // (the Workers-RPC value carried between the DO and a Worker), so // the name doesn't clash. export class WorkspaceStub extends RpcTarget { @@ -776,7 +557,7 @@ export class WorkspaceStub extends RpcTarget { // set in the constructor land as private isolate state and the // proxy reports "method not implemented". readonly #fs: WorkspaceFilesystemStub; - readonly #shell: WorkspaceShellStub; + readonly #runtime: WorkspaceRuntimeStub; readonly #git: WorkspaceGitStub; readonly #assets: WorkspaceAssetsStub | undefined; readonly #artifacts: WorkspaceArtifactsStub; @@ -785,7 +566,7 @@ export class WorkspaceStub extends RpcTarget { constructor(ws: Workspace) { super(); this.#fs = new WorkspaceFilesystemStub(ws); - this.#shell = new WorkspaceShellStub(ws); + this.#runtime = new WorkspaceRuntimeStub(ws); this.#git = new WorkspaceGitStub(ws); this.#assets = ws.assets === undefined ? undefined : new WorkspaceAssetsStub(ws); this.#artifacts = new WorkspaceArtifactsStub(ws.artifacts); @@ -801,7 +582,7 @@ export class WorkspaceStub extends RpcTarget { // getWorkspace() call) never collapses. [Symbol.dispose](): void { this.#fs[Symbol.dispose](); - this.#shell[Symbol.dispose](); + this.#runtime[Symbol.dispose](); this.#git[Symbol.dispose](); this.#assets?.[Symbol.dispose](); this.#artifacts[Symbol.dispose](); @@ -816,8 +597,8 @@ export class WorkspaceStub extends RpcTarget { return this.#useThink; } - get shell(): WorkspaceShellStub { - return this.#shell; + get runtime(): WorkspaceRuntimeStub { + return this.#runtime; } get git(): WorkspaceGitStub { diff --git a/packages/computer/src/test-harness/shell.test.ts b/packages/computer/src/test-harness/shell.test.ts index f47be0e6..402bc63f 100644 --- a/packages/computer/src/test-harness/shell.test.ts +++ b/packages/computer/src/test-harness/shell.test.ts @@ -18,7 +18,7 @@ describeIfDocker("Workspace.shell against a real computerd container", () => { it("exec captures stdout and exit code (utf8)", async () => { await withWorkspace(url, async (ws) => { await ws.ready(); - const handle = await ws.shell.exec("echo hello && exit 7", { + const handle = await ws.runtime.exec("echo hello && exit 7", { encoding: "utf8", }); const { exitCode, stdout, stderr } = await handle.result(); @@ -31,7 +31,7 @@ describeIfDocker("Workspace.shell against a real computerd container", () => { it("exec captures stdout as Uint8Array by default", async () => { await withWorkspace(url, async (ws) => { await ws.ready(); - const handle = await ws.shell.exec("printf bytes"); + const handle = await ws.runtime.exec("printf bytes"); const { stdout } = await handle.result(); expect(stdout).toBeInstanceOf(Uint8Array); expect(new TextDecoder().decode(stdout as Uint8Array)).toBe("bytes"); @@ -41,7 +41,7 @@ describeIfDocker("Workspace.shell against a real computerd container", () => { it("kill terminates a running command (SIGTERM → 143)", async () => { await withWorkspace(url, async (ws) => { await ws.ready(); - const handle = await ws.shell.exec("sleep 30", { + const handle = await ws.runtime.exec("sleep 30", { id: "killme", encoding: "utf8", }); @@ -54,14 +54,14 @@ describeIfDocker("Workspace.shell against a real computerd container", () => { it("get() replays a finished run by seq cursor", async () => { await withWorkspace(url, async (ws) => { await ws.ready(); - const first = await ws.shell.exec("printf 'a\\nb\\nc\\n'", { + const first = await ws.runtime.exec("printf 'a\\nb\\nc\\n'", { id: "replay", encoding: "utf8", }); const original = await first.result(); expect(original.exitCode).toBe(0); - const reattach = await ws.shell.get("replay", { + const reattach = await ws.runtime.getExec("replay", { encoding: "utf8", resume: "full", }); @@ -74,7 +74,7 @@ describeIfDocker("Workspace.shell against a real computerd container", () => { it("streaming iteration sees stdout chunks in order", async () => { await withWorkspace(url, async (ws) => { await ws.ready(); - const handle = await ws.shell.exec("printf one; printf two; printf three", { + const handle = await ws.runtime.exec("printf one; printf two; printf three", { encoding: "utf8", }); const chunks: string[] = []; @@ -101,7 +101,7 @@ describeIfDocker("Workspace.shell against a real computerd container", () => { // Touch computerd via the FUSE mount so currentRev advances // during the exec. Each `touch` is one applyChanges round // on the server, which bumps the rev exactly once. - const handle = await ws.shell.exec( + const handle = await ws.runtime.exec( "touch /workspace/bracket-a && touch /workspace/bracket-b && touch /workspace/bracket-c", { encoding: "utf8" }, ); @@ -115,47 +115,13 @@ describeIfDocker("Workspace.shell against a real computerd container", () => { }); }); - it("exec result.pulled is 0 for a command that does not touch the VFS", async () => { + it("exec completes synchronization for a command that does not touch the VFS", async () => { await withWorkspace(url, async (ws) => { await ws.ready(); - await ws.pull(); - const handle = await ws.shell.exec("echo cheap", { encoding: "utf8" }); + const handle = await ws.runtime.exec("echo cheap", { encoding: "utf8" }); const result = await handle.result(); expect(result.exitCode).toBe(0); - expect(result.pulled).toBe(0); + expect(result.sync.status).toBe("complete"); }); }); - - it("syncs repository outputs but ignores a generated dependency tree", async () => { - await withWorkspace(url, async (ws) => { - await ws.ready(); - await ws.pull(); - const root = `/workspace/repo-${crypto.randomUUID()}`; - await ws.fs.mkdir(`${root}/vendor/tiny`, { recursive: true }); - await ws.fs.writeFile( - `${root}/package.json`, - JSON.stringify({ - name: "sync-recovery-fixture", - private: true, - dependencies: { tiny: "file:vendor/tiny" }, - }), - ); - await ws.fs.writeFile(`${root}/vendor/tiny/index.js`, "installed\n"); - - const handle = await ws.shell.exec( - "mkdir -p node_modules/tiny dist && " + - "cp vendor/tiny/index.js node_modules/tiny/index.js && " + - "cat node_modules/tiny/index.js > dist/result.txt", - { cwd: root, encoding: "utf8" }, - ); - const result = await handle.result(); - - expect(result.exitCode, JSON.stringify(result)).toBe(0); - expect(result.pulled).toBeGreaterThan(0); - expect(await ws.fs.readFile(`${root}/dist/result.txt`, "utf8")).toBe("installed\n"); - await expect(ws.fs.stat(`${root}/node_modules`)).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - }, 60_000); }); diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 3e676ab2..9ea35cdb 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -290,7 +290,7 @@ describe("createAITools exec tool", () => { const calls: Array<{ command: string; cwd: string | undefined; backend: string | undefined }> = []; const workspace = { - shell: { + runtime: { async exec(command: string, options: { cwd?: string; encoding: "utf8"; backend?: string }) { calls.push({ command, cwd: options.cwd, backend: options.backend }); const result: ExecResult<"utf8"> = { @@ -332,7 +332,7 @@ describe("createAITools exec tool", () => { it("truncates exec output on UTF-8 byte boundaries", async () => { const workspace = { - shell: { + runtime: { async exec() { const result: ExecResult<"utf8"> = { exitCode: 0, @@ -364,7 +364,7 @@ describe("createAITools exec tool", () => { it("routes omitted backend to defaultBackend", async () => { const calls: Array<{ command: string; backend: string | undefined }> = []; const workspace = { - shell: { + runtime: { async exec(command: string, options: { encoding: "utf8"; backend?: string }) { calls.push({ command, backend: options.backend }); const result: ExecResult<"utf8"> = { @@ -396,7 +396,7 @@ describe("createAITools exec tool", () => { it("tells the model to retry on a capable backend after command-not-found errors", () => { const workspace = { - shell: { + runtime: { async exec() { throw new Error("not used"); }, @@ -419,7 +419,7 @@ describe("createAITools exec tool", () => { it("returns structured exec errors", async () => { const workspace = { - shell: { + runtime: { async exec() { throw new Error("backend unavailable"); }, @@ -443,7 +443,7 @@ describe("createAITools exec tool", () => { it("returns structured exec result errors", async () => { const workspace = { - shell: { + runtime: { async exec() { return { async result() { diff --git a/packages/computer/src/tools/exec.ts b/packages/computer/src/tools/exec.ts index 24f89dbb..efbb93c1 100644 --- a/packages/computer/src/tools/exec.ts +++ b/packages/computer/src/tools/exec.ts @@ -2,7 +2,7 @@ import { type Tool, tool } from "ai"; import { z } from "zod"; export interface ExecWorkspaceLike { - shell: { + runtime: { exec( command: string, options: { cwd?: string; encoding: "utf8"; backend?: string }, @@ -78,7 +78,7 @@ export function createExecTool( execute: async ({ command, cwd, backend }) => { const selectedBackend = backend ?? options.defaultBackend; try { - const handle = await options.workspace.shell.exec(command, { + const handle = await options.workspace.runtime.exec(command, { cwd, encoding: "utf8", backend: selectedBackend, diff --git a/packages/computer/src/workspace.test.ts b/packages/computer/src/workspace.test.ts index 352b2d30..e3ee0fc7 100644 --- a/packages/computer/src/workspace.test.ts +++ b/packages/computer/src/workspace.test.ts @@ -2,6 +2,7 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { describe, expect, it, vi } from "vitest"; import type { BackendHandle, WorkspaceBackend } from "./backend.js"; +import type { WorkspaceModuleBackend } from "./runtime/types.js"; import { WorkspaceTransportError } from "./transport-failure.js"; import { type ThinkWorkspaceCompatibility, Workspace } from "./workspace.js"; @@ -200,7 +201,7 @@ describe("Workspace backend selection", () => { }); const ws = new Workspace({ storage: makeStorage(), backends: [a, b] }); await ws.ready(); - const handle = await ws.shell.exec("true"); + const handle = await ws.runtime.exec("true"); await drainExec(handle); expect(aExecs).toBe(1); expect(bExecs).toBe(0); @@ -217,18 +218,173 @@ describe("Workspace backend selection", () => { }); const ws = new Workspace({ storage: makeStorage(), backends: [a, b] }); await ws.ready(); - await drainExec(await ws.shell.exec("true", { backend: "b" })); + await drainExec(await ws.runtime.exec("true", { backend: "b" })); expect(aExecs).toBe(0); expect(bExecs).toBe(1); }); + it("routes command backends through workspace.runtime.exec", async () => { + let calls = 0; + const ws = new Workspace({ + storage: makeStorage(), + backends: [ + execBackend("container-shell", () => { + calls += 1; + }), + ], + }); + const handle = await ws.runtime.exec("true", { + backend: "container-shell", + encoding: "utf8", + }); + const result = await handle.result(); + expect(calls).toBe(1); + expect(result).toMatchObject({ status: "completed", exitCode: 0 }); + }); + + it("flushes incomplete trailing UTF-8 from command execution", async () => { + const id = "utf8-command"; + const shell: import("@cloudflare/computer-rpc").ShellRPC = { + async exec() { + return { + id, + events: new ReadableStream({ + start(controller) { + controller.enqueue({ id, seq: 1, name: "stdout", value: new Uint8Array([0xe2]) }); + controller.enqueue({ id, seq: 2, name: "exit", value: 0 }); + controller.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.reject(new Error("not used")), + disposeExec: async () => undefined, + }; + const backend: WorkspaceBackend = { + id: "command", + type: "fake", + async connect() { + return { rpc: { sync: fakeRpc(), shell }, sync: "none", close: async () => undefined }; + }, + }; + const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); + const execution = await ws.runtime.exec("printf", { encoding: "utf8" }); + await expect(execution.result()).resolves.toMatchObject({ stdout: "�", exitCode: 0 }); + }); + + it("reports command executions killed through runtime as cancelled", async () => { + const controllers: Array< + ReadableStreamDefaultController + > = []; + const id = "cancel-command"; + let exit: import("@cloudflare/computer-rpc").ExecEvent | undefined; + const events = () => + new ReadableStream({ + start(controller) { + if (exit) { + controller.enqueue(exit); + controller.close(); + } else controllers.push(controller); + }, + }); + const shell: import("@cloudflare/computer-rpc").ShellRPC = { + async exec() { + return { id, events: events() }; + }, + async getExec() { + return { id, events: events() }; + }, + async killExec() { + exit = { id, seq: 1, name: "exit", value: 143 }; + for (const controller of controllers) { + controller.enqueue(exit); + controller.close(); + } + controllers.length = 0; + }, + disposeExec: async () => undefined, + }; + const backend: WorkspaceBackend = { + id: "command", + type: "fake", + async connect() { + return { rpc: { sync: fakeRpc(), shell }, sync: "none", close: async () => undefined }; + }, + }; + const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); + const execution = await ws.runtime.exec("sleep 60"); + await ws.runtime.killExec(id); + await expect(execution.result()).resolves.toMatchObject({ + status: "cancelled", + exitCode: 143, + }); + const replay = await ws.runtime.getExec(id); + await expect(replay.result()).resolves.toMatchObject({ status: "cancelled", exitCode: 143 }); + }); + + it("memoizes module results and enforces single-consumer handles", async () => { + const sourceEvents = () => + new ReadableStream({ + start(controller) { + controller.enqueue({ + id: "module-exec", + seq: 1, + name: "stdout", + value: new TextEncoder().encode("hello"), + }); + controller.enqueue({ + id: "module-exec", + seq: 2, + name: "stdout", + value: new Uint8Array([0xe2]), + }); + controller.enqueue({ id: "module-exec", seq: 3, name: "result", value: 42 }); + controller.enqueue({ id: "module-exec", seq: 4, name: "exit", value: 0 }); + controller.close(); + }, + }); + let replayCalls = 0; + const backend: WorkspaceModuleBackend = { + protocol: "module", + id: "module", + type: "test-module", + async connect() { + return { + async exec() { + return { id: "module-exec", events: sourceEvents() }; + }, + getExec: async () => { + replayCalls += 1; + return { id: "module-exec", events: sourceEvents() }; + }, + killExec: async () => undefined, + disposeExec: async () => undefined, + close: async () => undefined, + }; + }, + }; + const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); + const execution = await ws.runtime.exec("export default 42", { encoding: "utf8" }); + expect(execution.backend).toBe("module"); + const resultPromise = execution.result(); + expect(execution.result()).toBe(resultPromise); + await expect(execution.getReader().read()).rejects.toThrow(/consumed by result/); + await expect(resultPromise).resolves.toMatchObject({ + status: "completed", + stdout: "hello�", + value: 42, + }); + expect(replayCalls).toBe(0); + }); + it("throws on an unknown backend id", async () => { const ws = new Workspace({ storage: makeStorage(), backends: [execBackend("only", () => {})], }); await ws.ready(); - await expect(ws.shell.exec("true", { backend: "missing" })).rejects.toThrow( + await expect(ws.runtime.exec("true", { backend: "missing" })).rejects.toThrow( /no backend with id/, ); }); @@ -304,10 +460,10 @@ describe("Workspace backend selection", () => { const connectSpy = vi.spyOn(execBackendVar, "connect"); await ws2.ready(); expect(connectSpy).not.toHaveBeenCalled(); - await drainExec(await ws2.shell.exec("true")); + await drainExec(await ws2.runtime.exec("true")); expect(connectSpy).toHaveBeenCalledTimes(1); // Second exec reuses the cached handle. - await drainExec(await ws2.shell.exec("true")); + await drainExec(await ws2.runtime.exec("true")); expect(connectSpy).toHaveBeenCalledTimes(1); }); @@ -330,12 +486,96 @@ describe("Workspace backend selection", () => { expect(spyB).toHaveBeenCalledTimes(1); }); - it("shell accessor returns a router even before any connect", () => { + it("closes a module handle that resolves after Workspace.close", async () => { + let resolveConnect!: (handle: Awaited>) => void; + const closed = vi.fn(async () => undefined); + const backend: WorkspaceModuleBackend = { + protocol: "module", + id: "module", + type: "test-module", + connect: () => + new Promise((resolve) => { + resolveConnect = resolve; + }), + }; + const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); + const ready = ws.ready("module"); + await Promise.resolve(); + await ws.close(); + resolveConnect({ + exec: async () => { + throw new Error("not used"); + }, + getExec: async () => { + throw new Error("not used"); + }, + killExec: async () => undefined, + disposeExec: async () => undefined, + close: closed, + }); + await expect(ready).rejects.toThrow(/closed while backend/); + expect(closed).toHaveBeenCalledOnce(); + }); + + it("does not let a stale module connection clear a newer in-flight connection", async () => { + const resolvers: Array< + (handle: Awaited>) => void + > = []; + const backend: WorkspaceModuleBackend = { + protocol: "module", + id: "module", + type: "test-module", + connect: () => new Promise((resolve) => resolvers.push(resolve)), + }; + const handle = () => ({ + exec: async () => { + throw new Error("not used"); + }, + getExec: async () => { + throw new Error("not used"); + }, + killExec: async () => undefined, + disposeExec: async () => undefined, + close: async () => undefined, + }); + const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); + const stale = ws.ready("module"); + await Promise.resolve(); + await ws.close(); + const current = ws.ready("module"); + await Promise.resolve(); + resolvers[0]?.(handle()); + await expect(stale).rejects.toThrow(/closed while backend/); + const shared = ws.ready("module"); + expect(resolvers).toHaveLength(2); + resolvers[1]?.(handle()); + await Promise.all([current, shared]); + expect(resolvers).toHaveLength(2); + }); + + it("closes a command handle that resolves after Workspace.close", async () => { + let resolveConnect!: (handle: Awaited>) => void; + const closed = vi.fn(async () => undefined); + const backend: WorkspaceBackend = { + id: "command", + type: "test-command", + connect: () => new Promise((resolve) => (resolveConnect = resolve)), + }; + const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); + const ready = ws.ready("command"); + await Promise.resolve(); + await ws.close(); + resolveConnect({ rpc: composite(fakeRpc()), close: closed }); + await expect(ready).rejects.toThrow(/closed while backend/); + expect(closed).toHaveBeenCalledOnce(); + }); + + it("runtime accessor returns a router even before any connect", () => { const ws = new Workspace({ storage: makeStorage(), backends: [execBackend("only", () => {})], }); - expect(ws.shell).toBeDefined(); + expect(ws.runtime).toBeDefined(); }); it("fs accessor is available immediately — no ready() needed", () => { @@ -405,9 +645,9 @@ describe("Workspace backend selection", () => { expect(await ws.fs.readFile("/a.txt", "utf8")).toBe("hi"); }); - it("throws a clear error when shell is reached", () => { + it("throws a clear error when runtime execution is reached", async () => { const ws = new Workspace({ storage: makeStorage() }); - expect(() => ws.shell).toThrow(/no backend/); + await expect(ws.runtime.exec("true")).rejects.toThrow(/no execution backend/); }); it("push and pull short-circuit to zero / empty", async () => { @@ -419,6 +659,20 @@ describe("Workspace backend selection", () => { expect(pulled).toEqual({ applied: 0, skipped: [] }); }); + it("module-only push and pull are no-op synchronization", async () => { + const backend: WorkspaceModuleBackend = { + protocol: "module", + id: "module", + type: "test-module", + async connect() { + throw new Error("sync must not connect a module backend"); + }, + }; + const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); + expect(await ws.push()).toBe(0); + expect(await ws.pull()).toEqual({ applied: 0, skipped: [] }); + }); + it("stub() works and fs methods round-trip through it", async () => { const ws = new Workspace({ storage: makeStorage() }); await ws.ready(); @@ -427,16 +681,11 @@ describe("Workspace backend selection", () => { expect(await stub.fs.readFile("/b.txt", "utf8")).toBe("hello"); }); - it("stub().shell.exec throws the same no-backend error", async () => { + it("stub().runtime.exec throws the same no-backend error", async () => { const ws = new Workspace({ storage: makeStorage() }); await ws.ready(); const stub = ws.stub(); - // The stub's exec kicks off the underlying call eagerly - // and returns a handle whose result() awaits the pending - // promise. With no backend configured the inner promise - // rejects; await result() to surface it. - const handle = await stub.shell.exec("true"); - await expect(handle.result()).rejects.toThrow(/no backend/); + await expect(stub.runtime.exec("true")).rejects.toThrow(/no execution backend/); }); it("close() is a no-op with no backend configured", async () => { @@ -932,9 +1181,9 @@ describe("Workspace transport-failure invalidation", () => { }, }; const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); - await expect(ws.shell.exec("true")).rejects.toThrow(/RPC session was shut down/); + await expect(ws.runtime.exec("true")).rejects.toThrow(/RPC session was shut down/); expect(connects).toBe(1); - await ws.shell.exec("true").catch(() => undefined); + await ws.runtime.exec("true").catch(() => undefined); expect(connects).toBe(2); }); @@ -976,11 +1225,11 @@ describe("Workspace transport-failure invalidation", () => { }, }; const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); - const handle = await ws.shell.exec("sleep 60"); + const handle = await ws.runtime.exec("sleep 60"); await expect(handle.result()).rejects.toThrow(/WebSocket closed mid-stream/); expect(connects).toBe(1); // Next exec attempt must reconnect. - await ws.shell.exec("true").catch(() => undefined); + await ws.runtime.exec("true").catch(() => undefined); expect(connects).toBe(2); }); @@ -1038,7 +1287,7 @@ describe("Workspace transport-failure invalidation", () => { const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); // Dispatch against connection A; its stream stays pending. - const handleA = await ws.shell.exec("sleep 60"); + const handleA = await ws.runtime.exec("sleep 60"); expect(connects).toBe(1); const streamA = execStreams[0]; @@ -1048,7 +1297,7 @@ describe("Workspace transport-failure invalidation", () => { await new Promise((r) => setTimeout(r, 0)); // Next exec forces a fresh connection B. - const handleB = await ws.shell.exec("true"); + const handleB = await ws.runtime.exec("true"); expect(connects).toBe(2); const streamB = execStreams[1]; @@ -1064,7 +1313,7 @@ describe("Workspace transport-failure invalidation", () => { // reconnect again. Without the fix, A's late rejection would // have invalidated B's slot and this third exec would force a // third connect. - const handleC = await ws.shell.exec("true"); + const handleC = await ws.runtime.exec("true"); expect(connects).toBe(2); const streamC = execStreams[2]; diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index c2b50912..7d6b0b39 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -6,8 +6,8 @@ // store directly via the WorkspaceFilesystem class from // @cloudflare/dofs; sync between the host store and computerd // is driven explicitly via Workspace.push() / Workspace.pull(). -// The shell-side pre-exec push / post-exec pull bracket lives -// on Workspace.shell.exec. +// Command-backend pre-exec push / post-exec pull brackets are +// routed through Workspace.runtime.exec. import { pullOnce, pushOnce, reconcileWatermarks } from "@cloudflare/computer-rpc/driver"; import { @@ -32,6 +32,13 @@ import { MountIndex } from "./mounts/index.js"; import { buildMountRegistry, type MountValue } from "./mounts/registry.js"; import type { Mount } from "./mounts/types.js"; import { noopObserver, safeErrorMessage, type WorkspaceObserver, withSpan } from "./observe.js"; +import { WorkspaceRuntime } from "./runtime/runtime.js"; +import { + isModuleBackend, + type WorkspaceModuleBackend, + type WorkspaceModuleBackendHandle, + type WorkspaceRegisteredBackend, +} from "./runtime/types.js"; import { WorkspaceShell } from "./shell.js"; import { WorkspaceStub } from "./stub.js"; import { isWorkspaceTransportFailure } from "./transport-failure.js"; @@ -77,17 +84,20 @@ export interface WorkspaceOptions { // Database against it and runs initializeSchema (idempotent). storage: DurableObjectStorageLike; - // Backends are tried in declared order. The first one whose - // connect() resolves wins; the rest are not consulted. Optional: - // omit to construct a filesystem-only Workspace where the - // SQLite-backed fs surface is fully usable but the shell half - // throws a clear error on access. - backends?: WorkspaceBackend[]; + // Registered execution backends. The first is the default; + // callers can select another by stable id. + // Omit to construct a filesystem-only Workspace whose runtime + // reports that no execution backend is configured. + backends?: WorkspaceRegisteredBackend[]; // Clock used for mtime / last_seen on local FS writes. Defaults // to Date.now. Override for deterministic tests. now?: () => number; + // Attach detached module execution to the Durable Object event lifetime. + // Pass `ctx.waitUntil.bind(ctx)` when using module backends in production. + waitUntil?: (promise: Promise) => void; + // Identifier for this workspace / session. Forwarded to mount // factories via MountContext.sessionId. Optional; defaults to "". sessionId?: string; @@ -101,7 +111,7 @@ export interface WorkspaceOptions { // Observer that receives one span per workspace operation: a // `workspace.connect` per backend connect attempt, // `workspace.sync.push` / `workspace.sync.pull` per sync call, - // `workspace.shell.exec` per exec, and `workspace.fs.` per + // command runtime spans per exec, and `workspace.fs.` per // filesystem call routed through the stub. The default is a // no-op so the package has no observability cost when callers // do not opt in. See `./observe.ts` for the contract and the @@ -180,9 +190,13 @@ export class Workspace { #provider: SQLiteWorkspaceProvider | undefined; readonly #backends: WorkspaceBackend[]; readonly #backendsById: Map; + readonly #moduleBackendsById: Map; + readonly #registeredBackendIds: Set; readonly #defaultBackendId: string | undefined; + readonly #defaultCommandBackendId: string | undefined; readonly #observer: WorkspaceObserver; readonly #now: () => number; + readonly #waitUntil: ((promise: Promise) => void) | undefined; readonly #retryScheduler: SyncRetryScheduler | undefined; readonly #retryInitialDelayMs: number; readonly #retryMaxDelayMs: number; @@ -207,6 +221,10 @@ export class Workspace { // Per-backend WorkspaceShell facades. Constructed alongside each // handle; reused for the life of the handle. readonly #shells = new Map(); + readonly #moduleHandles = new Map(); + readonly #connectingModuleHandles = new Map>(); + #connectionGeneration = 0; + #runtime: WorkspaceRuntime | undefined; #readyPromise: Promise | undefined; // Per-backend FIFOs that serialize mutating entry points (push, // pull, and the shell exec bracket which goes through them) for @@ -228,6 +246,7 @@ export class Workspace { constructor(options: WorkspaceOptions) { this.#now = options.now ?? Date.now; + this.#waitUntil = options.waitUntil; this.#retryScheduler = options.retryScheduler; this.#retryInitialDelayMs = positiveRetryOption( options.retry?.initialDelayMs, @@ -256,24 +275,34 @@ export class Workspace { this.#db = new Database(options.storage); initializeSchema(this.#db, this.#now); this.#fs = new WorkspaceFilesystem(this.#db, { now: this.#now }); - this.#backends = (options.backends ?? []).slice(); - // Reject duplicate backend ids at construction. The Workspace - // selects backends by id at exec / push / pull time; two - // backends sharing a string would make the selector - // non-deterministic in a way that's almost certainly a - // configuration bug. - this.#backendsById = new Map(); - for (const backend of this.#backends) { - if (this.#backendsById.has(backend.id)) { + const registered = (options.backends ?? []).slice(); + if (registered.some((backend) => isModuleBackend(backend) && backend.requiresWaitUntil)) { + if (!options.waitUntil) { + throw new Error( + "Workspace module backend requires waitUntil; pass ctx.waitUntil.bind(ctx).", + ); + } + } + this.#backends = registered.filter( + (backend): backend is WorkspaceBackend => !isModuleBackend(backend), + ); + this.#backendsById = new Map(this.#backends.map((backend) => [backend.id, backend])); + this.#moduleBackendsById = new Map( + registered.filter(isModuleBackend).map((backend) => [backend.id, backend]), + ); + this.#registeredBackendIds = new Set(); + for (const backend of registered) { + if (this.#registeredBackendIds.has(backend.id)) { throw new Error( `Workspace: duplicate backend id ${JSON.stringify(backend.id)}. ` + "Pass an explicit `id` on each backend's constructor options to " + "distinguish them.", ); } - this.#backendsById.set(backend.id, backend); + this.#registeredBackendIds.add(backend.id); } - this.#defaultBackendId = this.#backends[0]?.id; + this.#defaultBackendId = registered[0]?.id; + this.#defaultCommandBackendId = this.#backends[0]?.id; this.#observer = options.observer ?? noopObserver; this.#mounts = buildMountRegistry(options.mounts, { sessionId: options.sessionId, @@ -375,6 +404,18 @@ export class Workspace { return this.#artifacts; } + get runtime(): WorkspaceRuntime { + if (!this.#runtime) { + this.#runtime = new WorkspaceRuntime({ + commandBackendIds: new Set(this.#backendsById.keys()), + shell: () => this.#routedShell(), + moduleHandle: (id) => this.#moduleHandleFor(id), + resolveBackendId: (id) => this.#resolveBackendId(id) ?? "", + }); + } + return this.#runtime; + } + /** * Underlying dofs `SQLiteWorkspaceProvider` over the local store. * @@ -409,23 +450,6 @@ export class Workspace { return this.#provider; } - // Shell facade. Throws if no backend was configured (the - // Workspace was constructed for filesystem-only use). - // - // The returned facade is a router: each method picks the right - // backend (the default, or one named through ExecOptions.backend) - // and forwards to that backend's ShellRPC. Backend connect is - // lazy — the first exec / get for a backend dials it. - get shell(): WorkspaceShell { - if (this.#backends.length === 0) { - throw new Error( - "Workspace has no backend configured — the shell is not available. " + - "Pass `backends` to the Workspace constructor to enable shell.exec.", - ); - } - return this.#routedShell(); - } - // ensureMountsIndexed() is the only thing ready() does today; // backends connect lazily on first use. The promise is still // cached so concurrent ready() calls share one index pass; a @@ -448,16 +472,21 @@ export class Workspace { const indexPromise = this.#readyPromise; if (options === undefined) return indexPromise; if (typeof options === "string") { - const id = options; + const id = this.#resolveBackendId(options); return (async () => { await indexPromise; - await this.#handleFor(id); + if (!id) return; + if (this.#moduleBackendsById.has(id)) await this.#moduleHandleFor(id); + else await this.#handleFor(id); })(); } if (options.all) { return (async () => { await indexPromise; - await Promise.all(this.#backends.map((b) => this.#handleFor(b.id))); + await Promise.all([ + ...this.#backends.map((backend) => this.#handleFor(backend.id)), + ...[...this.#moduleBackendsById.keys()].map((id) => this.#moduleHandleFor(id)), + ]); })(); } return indexPromise; @@ -500,7 +529,7 @@ export class Workspace { "workspace.sync.push", { "workspace.sync.backend": resolvedId }, async () => { - if (resolvedId === undefined) return 0; + if (resolvedId === undefined || this.#moduleBackendsById.has(resolvedId)) return 0; const handle = await this.#handleFor(resolvedId); // A backend that reuses the host store as its sole // source of truth has nothing to ship and no remote to @@ -582,7 +611,9 @@ export class Workspace { "workspace.sync.pull", { "workspace.sync.backend": resolvedId }, async () => { - if (resolvedId === undefined) return { applied: 0, skipped: [] }; + if (resolvedId === undefined || this.#moduleBackendsById.has(resolvedId)) { + return { applied: 0, skipped: [] }; + } const handle = await this.#handleFor(resolvedId); if (handle.sync === "none") return { applied: 0, skipped: [] }; return this.#runWithInvalidation(resolvedId, handle, () => @@ -644,11 +675,11 @@ export class Workspace { } } - // Per-backend mutation FIFO. The shell exec bracket - // (push → spawn → pull) and the public push() / pull() methods - // route through this; reads bypass it entirely. A push to - // backend A does not block exec on backend B because each id - // gets its own tail-promise. The undefined id (filesystem-only + // Per-backend mutation FIFO. Public push() / pull() calls and each + // command's pre-exec push and post-stream pull route through this; + // the FIFO is not held for the command's lifetime. Reads bypass it + // entirely. A push to backend A does not block sync on backend B + // because each id gets its own tail-promise. The undefined id (filesystem-only // path through push/pull) shares one slot. // // Rejections are not contagious: the catch arm here swallows @@ -681,13 +712,27 @@ export class Workspace { // workspace; throws on an unknown id. Omitted ids fall through // to the first backend in the list (the default). #resolveBackendId(id: string | undefined): string | undefined { - if (this.#backends.length === 0) return undefined; + if (this.#registeredBackendIds.size === 0) return undefined; const target = id ?? this.#defaultBackendId; if (target === undefined) return undefined; - if (!this.#backendsById.has(target)) { + if (!this.#registeredBackendIds.has(target)) { throw new Error( `Workspace: no backend with id ${JSON.stringify(target)}. ` + - `Configured backends: ${[...this.#backendsById.keys()].map((k) => JSON.stringify(k)).join(", ") || ""}.`, + `Configured backends: ${[...this.#registeredBackendIds].map((key) => JSON.stringify(key)).join(", ") || ""}.`, + ); + } + return target; + } + + #resolveCommandBackendId(id: string | undefined): string | undefined { + const target = id ?? this.#defaultCommandBackendId; + if (target === undefined) return undefined; + if (!this.#registeredBackendIds.has(target)) { + throw new Error(`Workspace: no backend with id ${JSON.stringify(target)}`); + } + if (!this.#backendsById.has(target)) { + throw new Error( + `Workspace backend ${JSON.stringify(target)} does not accept shell commands.`, ); } return target; @@ -697,13 +742,17 @@ export class Workspace { // Close every cached handle in parallel. Drop caches before // awaiting so a subsequent ready() / exec sees an empty slate // and rebuilds against fresh handles. + this.#connectionGeneration += 1; const handles = [...this.#handles.values()]; + const moduleHandles = [...this.#moduleHandles.values()]; this.#handles.clear(); this.#shells.clear(); this.#connecting.clear(); + this.#moduleHandles.clear(); + this.#connectingModuleHandles.clear(); this.#readyPromise = undefined; await Promise.all( - handles.map(async (h) => { + [...handles, ...moduleHandles].map(async (h) => { try { await h.close(); } catch { @@ -714,6 +763,49 @@ export class Workspace { ); } + #moduleHandleFor(id: string): Promise { + const cached = this.#moduleHandles.get(id); + if (cached) return Promise.resolve(cached); + const inflight = this.#connectingModuleHandles.get(id); + if (inflight) return inflight; + const backend = this.#moduleBackendsById.get(id); + if (!backend) { + return Promise.reject( + new Error(`Workspace backend ${JSON.stringify(id)} does not execute modules.`), + ); + } + const generation = this.#connectionGeneration; + let promise!: Promise; + promise = withSpan( + this.#observer, + "workspace.connect", + { "workspace.backend.id": id, "workspace.backend.type": backend.type }, + () => + backend.connect({ + db: this.#db, + waitUntil: this.#waitUntil, + fs: this.#fs, + git: this.git, + artifacts: this.#artifacts, + }), + ) + .then(async (handle) => { + if (generation !== this.#connectionGeneration) { + await handle.close().catch(() => undefined); + throw new Error(`Workspace closed while backend ${JSON.stringify(id)} was connecting.`); + } + this.#moduleHandles.set(id, handle); + return handle; + }) + .finally(() => { + if (this.#connectingModuleHandles.get(id) === promise) { + this.#connectingModuleHandles.delete(id); + } + }); + this.#connectingModuleHandles.set(id, promise); + return promise; + } + // Lazy backend connect. Concurrent callers for the same id // share one in-flight promise. The resolved handle is cached // until close() or the backend's `closed` promise fires. @@ -726,13 +818,19 @@ export class Workspace { if (backend === undefined) { return Promise.reject(new Error(`Workspace: no backend with id ${JSON.stringify(id)}`)); } - const promise = (async () => { + const generation = this.#connectionGeneration; + let promise!: Promise; + promise = (async () => { const handle = await withSpan( this.#observer, "workspace.connect", { "workspace.backend.id": id, "workspace.backend.type": backend.type }, () => backend.connect(), ); + if (generation !== this.#connectionGeneration) { + await handle.close().catch(() => undefined); + throw new Error(`Workspace closed while backend ${JSON.stringify(id)} was connecting.`); + } // Reconcile watermarks before publishing the handle. If the // remote restarted between our pushes / fetches it has lost // state we thought it had; reset the local cursors so the @@ -743,6 +841,10 @@ export class Workspace { if (handle.sync !== "none") { await reconcileWatermarks(this.#db, handle.rpc.sync, id); } + if (generation !== this.#connectionGeneration) { + await handle.close().catch(() => undefined); + throw new Error(`Workspace closed while backend ${JSON.stringify(id)} was connecting.`); + } this.#handles.set(id, handle); // Watch the transport for mid-session loss. Backends without // a `closed` promise (in-process fakes) opt out by omitting @@ -763,9 +865,9 @@ export class Workspace { } return handle; })().finally(() => { - // Always drop the in-flight entry so a failed connect can - // be retried by the next call. - this.#connecting.delete(id); + // Always drop this in-flight entry so a failed connect can be + // retried, without deleting a newer connection started after close(). + if (this.#connecting.get(id) === promise) this.#connecting.delete(id); }); this.#connecting.set(id, promise); return promise; @@ -800,9 +902,9 @@ export class Workspace { // and forwards to that backend's WorkspaceShell. #routedShell(): WorkspaceShell { const router = new WorkspaceShellRouter( - this.#defaultBackendId ?? "", + this.#defaultCommandBackendId ?? "", (id) => this.#shellFor(id), - (id) => this.#resolveBackendId(id) ?? "", + (id) => this.#resolveCommandBackendId(id) ?? "", (id, handle, error) => this.#onShellError(id, handle, error), ); return router as unknown as WorkspaceShell; @@ -915,6 +1017,32 @@ class WorkspaceShellRouter { return this.#wrapHandle(backendId, dispatchHandle, execHandle); } + async kill( + id: string, + signal?: import("./shell.js").KillSignal, + options: { backend?: string } = {}, + ): Promise { + const backendId = this.#resolveId(options.backend) || this.#defaultId; + const { shell, handle } = await this.#shellFor(backendId); + try { + await shell.kill(id, signal); + } catch (error) { + this.#onError(backendId, handle, error); + throw error; + } + } + + async dispose(id: string, options: { backend?: string } = {}): Promise { + const backendId = this.#resolveId(options.backend) || this.#defaultId; + const { shell, handle } = await this.#shellFor(backendId); + try { + await shell.dispose(id); + } catch (error) { + this.#onError(backendId, handle, error); + throw error; + } + } + // Wrap an ExecHandle so a transport-classified rejection from // result() invalidates the cached backend handle. The dispatch- // time catch above only fires when shell.exec()/get() rejects diff --git a/packages/computer/tests/proxy-worker.ts b/packages/computer/tests/proxy-worker.ts index 4b43f34b..5327ac46 100644 --- a/packages/computer/tests/proxy-worker.ts +++ b/packages/computer/tests/proxy-worker.ts @@ -26,7 +26,10 @@ export class TestStorageDO extends DurableObject { override async fetch(request: Request): Promise { const url = new URL(request.url); if (url.pathname === "/ws") { - return new Response("from-do", { status: 200 }); + return new Response( + url.searchParams.has("token") ? `from-do:${url.searchParams.get("token")}` : "from-do", + { status: 200 }, + ); } return new Response("DO unknown path", { status: 404 }); } diff --git a/packages/computer/tests/proxy.test.ts b/packages/computer/tests/proxy.test.ts index 59276552..c7a1c214 100644 --- a/packages/computer/tests/proxy.test.ts +++ b/packages/computer/tests/proxy.test.ts @@ -42,6 +42,18 @@ describe("WorkspaceProxy", () => { expect(await res.text()).toBe("from-do"); }); + it("accepts tokenized callback health and forwards a normalized tokenized /ws", async () => { + const token = "123e4567-e89b-12d3-a456-426614174000"; + const health = await SELF.fetch(`http://proxy.test/__workspace_connect/${token}/health`, { + headers: { "x-test-id": freshId() }, + }); + expect(health.status).toBe(200); + const websocket = await SELF.fetch(`http://proxy.test/__workspace_connect/${token}/ws`, { + headers: { "x-test-id": freshId(), "x-test-binding": "COMPUTERD" }, + }); + expect(await websocket.text()).toBe(`from-do:${token}`); + }); + it("/ws returns 500 when env[binding] is missing", async () => { const res = await SELF.fetch("http://proxy.test/ws", { headers: { "x-test-id": freshId(), "x-test-binding": "NOT_A_BINDING" }, diff --git a/packages/computer/tests/stub-soak-worker.ts b/packages/computer/tests/stub-soak-worker.ts index 9e1bca15..941c9dba 100644 --- a/packages/computer/tests/stub-soak-worker.ts +++ b/packages/computer/tests/stub-soak-worker.ts @@ -157,7 +157,7 @@ export default class TestDriver extends WorkerEntrypoint { // on the DO side. await ws.fs.writeFile(`/soak-${i}.txt`, `hello ${i}`); await ws.fs.readFile(`/soak-${i}.txt`, "utf8"); - const handle = await ws.shell.exec("noop"); + const handle = await ws.runtime.exec("noop"); await handle.result(); if (opts.disposeExecHandles) { (handle as unknown as Disposable)[Symbol.dispose]?.(); diff --git a/packages/computer/tests/stub-soak.test.ts b/packages/computer/tests/stub-soak.test.ts index 08dbe51f..17535c4b 100644 --- a/packages/computer/tests/stub-soak.test.ts +++ b/packages/computer/tests/stub-soak.test.ts @@ -36,7 +36,7 @@ async function runSoak( const ws = await stub.getWorkspace(); await ws.fs.writeFile(`/soak-${i}.txt`, `hello ${i}`); await ws.fs.readFile(`/soak-${i}.txt`, "utf8"); - const handle = await ws.shell.exec("noop"); + const handle = await ws.runtime.exec("noop"); await handle.result(); if (opts.disposeExecHandles) { (handle as unknown as Disposable)[Symbol.dispose]?.(); @@ -74,7 +74,7 @@ describe("WorkspaceStub disposal soak (DO ↔ Worker)", () => { // The expected contract: when the caller disposes every // returned stub, the DO-side counters return to baseline. // Disposing WorkspaceStub cascades to its #fs / #shell - // children; disposing WorkspaceExecHandleStub releases the + // children; disposing WorkspaceRuntimeExecHandleStub releases the // exec handle. Anything non-zero here is a leak. const result = await runSoak(20, { disposeStubs: true, disposeExecHandles: true }); const growth = diff(result.baseline, result.afterClose); @@ -95,8 +95,8 @@ describe("WorkspaceStub disposal soak (DO ↔ Worker)", () => { expect(growth, JSON.stringify(result, null, 2)).toMatchObject({ WorkspaceStub: 10, WorkspaceFilesystemStub: 10, - WorkspaceShellStub: 10, - WorkspaceExecHandleStub: 10, + WorkspaceRuntimeStub: 10, + WorkspaceRuntimeExecHandleStub: 10, }); }); diff --git a/packages/computer/tests/worker-backend-worker.ts b/packages/computer/tests/worker-backend-worker.ts index b4d9f9ab..c2032462 100644 --- a/packages/computer/tests/worker-backend-worker.ts +++ b/packages/computer/tests/worker-backend-worker.ts @@ -11,7 +11,7 @@ // only backend is a WorkerBackend dialing through env.LOADER. // Exposes writeFile / readFile / exec methods the test calls // directly through the DO stub; the exec method goes through -// workspace.shell.exec which actually drives just-bash in a +// workspace.runtime.exec which actually drives just-bash in a // real Dynamic Worker. // - default — a tiny WorkerEntrypoint that routes incoming // fetches into the DO. Lets the test drive the harness with @@ -19,7 +19,8 @@ import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; import { WorkerBackend } from "../src/backends/worker/index.js"; -import { type DurableObjectStorageLike, getWorkspace, withWorkspace } from "../src/index.js"; +import type { DurableObjectStorageLike, WorkspaceStub } from "../src/index.js"; +import { Workspace } from "../src/index.js"; export { WorkspaceServiceProxy } from "../src/proxy.js"; @@ -28,21 +29,24 @@ export interface Env { LOADER: WorkerLoader; } -export class HostDO extends withWorkspace(class extends DurableObject {}, (self) => { - const { ctx, env } = self as unknown as { ctx: DurableObjectState; env: Env }; - return { - storage: ctx.storage as unknown as DurableObjectStorageLike, - backends: [ - new WorkerBackend({ - loader: env.LOADER, - workspace: { binding: "HOST", id: ctx.id.toString() }, - ctx, - }), - ], - }; -}) { +export class HostDO extends DurableObject { + readonly #workspace: Workspace; #seeded: Promise | undefined; + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.#workspace = new Workspace({ + storage: ctx.storage as unknown as DurableObjectStorageLike, + backends: [ + new WorkerBackend({ + loader: env.LOADER, + workspace: { binding: "HOST", id: ctx.id.toString() }, + ctx, + }), + ], + }); + } + // The VFS is empty on a fresh DO — not even /workspace exists. // The computerd-container example seeds the mount root through computerd's // boot path; the worker example happens to seed it through an @@ -50,29 +54,32 @@ export class HostDO extends withWorkspace(class extends DurableObject {}, ( // /workspace directly. #seed(): Promise { if (this.#seeded === undefined) { - this.#seeded = (async () => { - using ws = await getWorkspace(this); - await ws.fs.mkdir("/workspace", { recursive: true }); - })(); + this.#seeded = this.#workspace.fs.mkdir("/workspace", { recursive: true }); } return this.#seeded; } + // Required by WorkspaceServiceProxy: the loopback proxy looks + // the host DO up by name and calls __getWorkspaceStub() to obtain the + // stub it returns to the Dynamic Worker. The shell's per-exec + // env.HOST.getWorkspace() proxy call lands here. + async __getWorkspaceStub(): Promise { + await this.#workspace.ready(); + return this.#workspace.stub(); + } + async writeFile(path: string, body: string): Promise { await this.#seed(); - using ws = await getWorkspace(this); - await ws.fs.writeFile(path, body); + await this.#workspace.fs.writeFile(path, body); } async readFile(path: string): Promise { - using ws = await getWorkspace(this); - return ws.fs.readFile(path, "utf8"); + return this.#workspace.fs.readFile(path, "utf8"); } async exec(command: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { await this.#seed(); - using ws = await getWorkspace(this); - const handle = await ws.shell.exec(command, { + const handle = await this.#workspace.runtime.exec(command, { encoding: "utf8", }); const result = await handle.result(); diff --git a/packages/dofs/src/fs/filesystem.ts b/packages/dofs/src/fs/filesystem.ts index c48caa50..82ea588c 100644 --- a/packages/dofs/src/fs/filesystem.ts +++ b/packages/dofs/src/fs/filesystem.ts @@ -19,7 +19,7 @@ import { find, type WorkspaceFoundEntry } from "./find.js"; import { type GrepOptions, grep, type WorkspaceGrepMatch } from "./grep.js"; import { ls } from "./ls.js"; import { type MkdirOptions, mkdir } from "./mkdir.js"; -import { readdir, type WorkspaceDirentResult } from "./readdir.js"; +import { type ReaddirOptions, readdir, type WorkspaceDirentResult } from "./readdir.js"; import { type ReadFileOptions, readFile } from "./readFile.js"; import { readlink } from "./readlink.js"; import { type RmOptions, rm } from "./rm.js"; @@ -78,8 +78,8 @@ export class WorkspaceFilesystem { return readlink(this.db, path); } - async readdir(path: string): Promise { - return readdir(this.db, path); + async readdir(path: string, options: ReaddirOptions = {}): Promise { + return readdir(this.db, path, options); } async find(directory: string, pattern?: string): Promise { diff --git a/packages/dofs/src/fs/readdir.test.ts b/packages/dofs/src/fs/readdir.test.ts index 33a10a9a..77b69ea6 100644 --- a/packages/dofs/src/fs/readdir.test.ts +++ b/packages/dofs/src/fs/readdir.test.ts @@ -45,6 +45,13 @@ describe("readdir", () => { }); }); + it("limits committed entries before materializing the result", async () => { + await withDB(async (db) => { + for (const name of ["a", "b", "c"]) await writeFile(db, `/${name}`, "", {}, () => 0); + expect(readdir(db, "/", { limit: 2 }).map((entry) => entry.name)).toEqual(["a", "b"]); + }); + }); + it("uses the canonical parent path for nested directories", async () => { await withDB(async (db) => { mkdir(db, "/a/b", { recursive: true }, () => 0); diff --git a/packages/dofs/src/fs/readdir.ts b/packages/dofs/src/fs/readdir.ts index eb133b2a..dddfa8d1 100644 --- a/packages/dofs/src/fs/readdir.ts +++ b/packages/dofs/src/fs/readdir.ts @@ -17,7 +17,16 @@ interface DirentRow { type: "file" | "dir" | "symlink"; } -export function readdir(db: Database, path: string): WorkspaceDirentResult[] { +export interface ReaddirOptions { + /** Maximum committed entries to materialize. Pending entries may extend the result. */ + limit?: number; +} + +export function readdir( + db: Database, + path: string, + options: ReaddirOptions = {}, +): WorkspaceDirentResult[] { const { path: canonical } = canonicalizePath(path); const node = resolveInode(db, canonical); if (node === null) { @@ -27,13 +36,18 @@ export function readdir(db: Database, path: string): WorkspaceDirentResult[] { throw createWorkspaceError("ENOTDIR", `not a directory: ${canonical}`, canonical); } + const limit = options.limit; + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0)) { + throw new TypeError("readdir limit must be a non-negative safe integer"); + } const rows = db.all( `SELECT d.name AS name, n.type AS type FROM vfs_dirents d JOIN vfs_nodes n ON n.inode = d.child_inode WHERE d.parent_inode = ? - ORDER BY d.name`, - node.inode, + ORDER BY d.name + ${limit === undefined ? "" : "LIMIT ?"}`, + ...(limit === undefined ? [node.inode] : [node.inode, limit]), ); const entries = rows.map((row) => ({ diff --git a/packages/dofs/src/fs/writeFile.test.ts b/packages/dofs/src/fs/writeFile.test.ts index 8bbd7062..d8df6b74 100644 --- a/packages/dofs/src/fs/writeFile.test.ts +++ b/packages/dofs/src/fs/writeFile.test.ts @@ -97,6 +97,36 @@ describe("writeFile", () => { }); }); + it("atomically rejects an exclusive write when the target exists", async () => { + await withDB(async (db) => { + await writeFile(db, "/exclusive.txt", "first", {}, () => 1); + await expect( + writeFile(db, "/exclusive.txt", "second", { exclusive: true }, () => 2), + ).rejects.toMatchObject({ code: "EEXIST" }); + expect(new TextDecoder().decode(readBack(db, "/exclusive.txt"))).toBe("first"); + }); + }); + + it("rejects an exclusive streaming write before reading its source", async () => { + await withDB(async (db) => { + await writeFile(db, "/exclusive-stream.txt", "first", {}, () => 1); + let pulls = 0; + const source = new ReadableStream( + { + pull(controller) { + pulls += 1; + controller.enqueue(new TextEncoder().encode("second")); + }, + }, + { highWaterMark: 0 }, + ); + await expect( + writeFile(db, "/exclusive-stream.txt", source, { exclusive: true }, () => 2), + ).rejects.toMatchObject({ code: "EEXIST" }); + expect(pulls).toBe(0); + }); + }); + it("accepts a Uint8Array", async () => { await withDB(async (db) => { const data = new Uint8Array([1, 2, 3, 4, 5]); diff --git a/packages/dofs/src/fs/writeFile.ts b/packages/dofs/src/fs/writeFile.ts index 1f045565..48f6fc53 100644 --- a/packages/dofs/src/fs/writeFile.ts +++ b/packages/dofs/src/fs/writeFile.ts @@ -28,6 +28,8 @@ export type WriteFileContent = string | Uint8Array | ReadableStream; export interface WriteFileOptions { mode?: number; + /** Fail with EEXIST when the target already exists. */ + exclusive?: boolean; } export interface WriteFileRange { @@ -146,9 +148,20 @@ async function writeFileStreaming( if (parts.length === 0) { throw createWorkspaceError("EISDIR", "cannot write to the root directory", canonical); } - // Reject before we stage any blob bytes so a read-only mount - // doesn't grow orphan vfs_blobs rows that gc() then has to reap. + // Reject before we stage any blob bytes so known failures do not grow + // orphan blob rows that gc() then has to reap. assertNotReadOnly(db, canonical); + if (options.exclusive) { + const parentInode = resolveParent(db, parts, canonical); + const existing = db.one( + "SELECT 1 FROM vfs_dirents WHERE parent_inode = ? AND name = ?", + parentInode, + parts[parts.length - 1], + ); + if (existing !== undefined) { + throw createWorkspaceError("EEXIST", `path exists: ${canonical}`, canonical); + } + } const mode = (options.mode ?? 0o644) & 0o7777; const mtime = now(); @@ -212,6 +225,9 @@ async function writeFileStreaming( ); let inode: number; if (existing !== undefined) { + if (options.exclusive) { + throw createWorkspaceError("EEXIST", `path exists: ${canonical}`, canonical); + } const node = db.one<{ type: "file" | "dir" }>( "SELECT type FROM vfs_nodes WHERE inode = ?", existing.child_inode, @@ -930,6 +946,9 @@ export function writeFileSync( let inode: number; if (existing !== undefined) { + if (options.exclusive) { + throw createWorkspaceError("EEXIST", `path exists: ${canonical}`, canonical); + } const node = db.one<{ type: "file" | "dir" }>( "SELECT type FROM vfs_nodes WHERE inode = ?", existing.child_inode, diff --git a/packages/dofs/src/index.ts b/packages/dofs/src/index.ts index 82c31682..76ef05a5 100644 --- a/packages/dofs/src/index.ts +++ b/packages/dofs/src/index.ts @@ -20,7 +20,7 @@ export { invalidateReadOnlyMountCache, readOnlyRootFor, } from "./fs/mount-guard.js"; -export type { WorkspaceDirentResult } from "./fs/readdir.js"; +export type { ReaddirOptions, WorkspaceDirentResult } from "./fs/readdir.js"; export type { ReadFileOptions } from "./fs/readFile.js"; export { readlink } from "./fs/readlink.js"; export type { RmOptions } from "./fs/rm.js"; From bcab35d4ab138b8a69e4862246986a79395534fa Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:01:44 +0000 Subject: [PATCH 2/2] computer, docs: Add isolate JavaScript backend Add a module backend that runs ECMAScript sources in Dynamic Workers with structured input, retained execution events, durable filesystem access, and host-owned git and artifact capabilities. Expose the backend on its own package subpath and cover it with unit and workerd integration tests. --- README.md | 12 +- docs/05_runtime_interface.md | 55 +- docs/10_project_layout.md | 3 +- docs/12_worker_backend.md | 5 +- docs/16_code_execution.md | 88 ++ docs/17_isolate_javascript.md | 168 +++ docs/18_runtime_migration.md | 31 + docs/README.md | 9 +- package-lock.json | 1 + packages/computer/README.md | 18 +- packages/computer/package.json | 8 +- packages/computer/rolldown.config.ts | 1 + .../computer/src/backends/javascript/index.ts | 4 + .../javascript/javascript-backend.test.ts | 722 +++++++++++ .../backends/javascript/javascript-backend.ts | 1113 +++++++++++++++++ .../src/backends/javascript/module-graph.ts | 385 ++++++ .../src/backends/worker/entrypoint.ts | 4 +- packages/computer/src/index.ts | 3 + packages/computer/src/runtime/bridge.test.ts | 60 + packages/computer/src/runtime/bridge.ts | 505 ++++++++ .../computer/src/runtime/capability.test.ts | 149 +++ packages/computer/src/runtime/capability.ts | 279 +++++ packages/computer/src/runtime/types.ts | 70 ++ .../computer/tests/script-runner-worker.ts | 269 ++++ packages/computer/tests/script-runner.test.ts | 428 +++++++ packages/computer/tests/tsconfig.json | 12 + .../tests/wrangler.script-runner.jsonc | 11 + .../computer/vitest.config.script-runner.ts | 15 + 28 files changed, 4379 insertions(+), 49 deletions(-) create mode 100644 docs/16_code_execution.md create mode 100644 docs/17_isolate_javascript.md create mode 100644 docs/18_runtime_migration.md create mode 100644 packages/computer/src/backends/javascript/index.ts create mode 100644 packages/computer/src/backends/javascript/javascript-backend.test.ts create mode 100644 packages/computer/src/backends/javascript/javascript-backend.ts create mode 100644 packages/computer/src/backends/javascript/module-graph.ts create mode 100644 packages/computer/src/runtime/bridge.test.ts create mode 100644 packages/computer/src/runtime/bridge.ts create mode 100644 packages/computer/src/runtime/capability.test.ts create mode 100644 packages/computer/src/runtime/capability.ts create mode 100644 packages/computer/tests/script-runner-worker.ts create mode 100644 packages/computer/tests/script-runner.test.ts create mode 100644 packages/computer/tests/tsconfig.json create mode 100644 packages/computer/tests/wrangler.script-runner.jsonc create mode 100644 packages/computer/vitest.config.script-runner.ts diff --git a/README.md b/README.md index 190043ff..2d299b0d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Cloudflare Computer is a virtual filesystem that lives inside a Durable Object. The Durable Object holds the authoritative state in SQLite and exposes one pluggable execution surface through -`workspace.runtime`. Two backends ship today: +`workspace.runtime`. Three backends ship today: - **Container** projects the SQLite state into a sandbox container as a real FUSE mount. A sandbox-side daemon (`computerd`) mounts the state @@ -12,11 +12,15 @@ SQLite and exposes one pluggable execution surface through - **Isolate shell** runs [just-bash](https://github.com/vercel-labs/just-bash) in a Dynamic Worker. It reaches the authoritative Workspace over Workers RPC, so there is no second store or sync round trip. +- **Isolate JavaScript** runs an ECMAScript module in a fresh Dynamic + Worker with structured input/results, durable relative imports, + configured libraries, Workspace-backed `node:fs/promises`, and trusted `ws:git` and + `ws:artifacts` modules. + A Workspace may register multiple backends under stable IDs. `workspace.runtime.exec(source, { backend })` is the single execution -entry point; the selected backend defines how to interpret `source`. -The shipped backends treat it as a shell command. Backends connect lazily on -first use. +entry point; the selected backend defines whether `source` is a shell +command or an ECMAScript module. Backends connect lazily on first use. Workspace can also be constructed without a backend at all, giving callers the filesystem on its own. diff --git a/docs/05_runtime_interface.md b/docs/05_runtime_interface.md index 80c4e57f..510e6640 100644 --- a/docs/05_runtime_interface.md +++ b/docs/05_runtime_interface.md @@ -1,4 +1,4 @@ -# 05. Runtime interface +# 05. Runtime Interface Workspace exposes one execution router: @@ -12,9 +12,7 @@ const handle = await workspace.runtime.exec(source, { const result = await handle.result(); ``` -The backend ID defines how `source` is interpreted. The shipped -container and worker backends treat it as shell syntax. Module backends -can use the same surface for structured code execution. +The backend ID defines how `source` is interpreted. Command runtimes accept shell syntax; module runtimes accept their documented programming language. ## API @@ -44,12 +42,7 @@ interface WorkspaceRuntimeExecHandle extends ReadableStream fs.readFile("/workspace/package.json", "utf8"); + `, + { backend: "isolate-javascript" }, +); ``` -Omitting `backend` selects the first configured backend. Backend selection is -routing, not authorization; public gateways must validate it against -server-side policy. +Omitting `backend` selects the first configured backend. Backend selection is routing, not authorization; public gateways must validate it against server-side policy. ## Command synchronization @@ -99,24 +94,16 @@ Command backends continue to use the existing synchronization bracket: push → spawn → events/result → pull ``` -A backend with `sync: "none"`, such as `isolate-shell`, shares the host -store and reports zero push/pull counts. A container has its own VFS and -synchronizes changes before and after command execution. Fully draining -either `result()` or the event stream completes the post-command pull before -the stream closes. +A backend with `sync: "none"`, such as `isolate-shell`, shares the host store and reports zero push/pull counts. A Container has its own VFS and synchronizes changes before and after command execution. Fully draining either `result()` or the event stream completes the post-command pull before the stream closes. -Module backends use host capability calls against the authoritative -Workspace and therefore require no push/pull round trip. +Module backends use host capability calls against the authoritative Workspace and therefore require no push/pull round trip. ## Lifecycle differences -`container-shell` provides computerd's retained process log, replay, -signals, and disposal. +`container-shell` provides computerd's retained process log, replay, signals, and disposal. + +`isolate-javascript` provides a Workspace-owned execution journal, retained result/events, host cancellation, and explicit disposal. Active Workers cannot be serialized across host restart; orphaned running records are reconciled to failed. + +`isolate-shell` intentionally preserves one-call, buffered-result behavior in this release. It does not retain executions for later reattachment or disposal. `timeoutMs` and a concurrent `killExec()` for a caller-supplied execution ID cooperatively abort just-bash at statement boundaries; by the time an ordinary `exec()` promise returns, the command has already settled. Use the Container or JavaScript isolate when detached execution and retained lifecycle are required. -`isolate-shell` intentionally preserves one-call, buffered-result behavior -in this release. It does not retain executions for later reattachment or -disposal. `timeoutMs` and a concurrent `killExec()` for a caller-supplied -execution ID cooperatively abort just-bash at statement boundaries; by the -time an ordinary `exec()` promise returns, the command has already settled. -Use the container backend when detached execution and retained lifecycle are -required. +See [16. Execution runtime architecture](./16_code_execution.md) and [17. Isolate JavaScript](./17_isolate_javascript.md). diff --git a/docs/10_project_layout.md b/docs/10_project_layout.md index 6d7a0bf4..13bd1204 100644 --- a/docs/10_project_layout.md +++ b/docs/10_project_layout.md @@ -53,12 +53,13 @@ packages/computer/ ├── src/ │ ├── index.ts # Public entrypoint │ ├── workspace.ts # Workspace facade -│ ├── runtime/ # Public runtime router +│ ├── runtime/ # Public runtime router and capabilities │ ├── shell.ts # Internal command-backend adapter │ ├── backend.ts # Command backend interface │ ├── backends/ │ │ ├── container/ # Cloudflare Container + computerd backend │ │ ├── worker/ # Dynamic Worker + just-bash backend +│ │ ├── javascript/ # Dynamic Worker ECMAScript backend │ │ └── test.ts # In-process test backend │ ├── proxy.ts # WorkspaceProxy │ ├── proxy-stub.ts # Client-side stub plumbing diff --git a/docs/12_worker_backend.md b/docs/12_worker_backend.md index 9a12c752..9ee30ed8 100644 --- a/docs/12_worker_backend.md +++ b/docs/12_worker_backend.md @@ -31,8 +31,9 @@ The worker backend trades the real environment for a Workers isolate that boots instantly, scales out cheaply, and has no container lifecycle. The shell is the just-bash interpreter; the supported command set is broad (`cat`, `grep`, `awk`, `sed`, `jq`, -`sort`) but not the full Linux userland. just-bash's Node-only language -commands are disabled on workerd. Filesystem operations forward into the same +`sort`) but not the full Linux userland. JavaScript modules run through the +[`isolate-javascript` backend](./17_isolate_javascript.md), not through just-bash's +Node-only language commands. Filesystem operations forward into the same SQLite store as the container backend, so the storage shape, mount rules, and read-only enforcement are unchanged. diff --git a/docs/16_code_execution.md b/docs/16_code_execution.md new file mode 100644 index 00000000..a10c42e8 --- /dev/null +++ b/docs/16_code_execution.md @@ -0,0 +1,88 @@ +# Workspace execution runtimes + +Workspace exposes one execution namespace: + +```ts +const handle = await workspace.runtime.exec(source, { + backend: "container-shell", + cwd: "/workspace", + encoding: "utf8", +}); +const result = await handle.result(); +``` + +The selected backend defines how it interprets `source`. + +| Backend | Source language | Intended use | +| --- | --- | --- | +| `container-shell` | shell command | Full Linux, native binaries, installed packages, processes | +| `isolate-shell` | just-bash command | Fast text tools and Workspace Git without a Container | +| `isolate-javascript` | ECMAScript module | Isolated structured JavaScript with trusted Workspace modules | + +Applications may register additional command or module backends under their own IDs. Backend IDs are part of the execution contract: changing the backend may change the source language. + +## Lifecycle + +```ts +const handle = await workspace.runtime.exec(source, { + id: "build-1", + backend: "isolate-javascript", +}); + +handle.id; +await handle.kill(); + +const resumed = await workspace.runtime.getExec("build-1", { + backend: "isolate-javascript", + resume: "full", +}); + +await workspace.runtime.disposeExec("build-1", { + backend: "isolate-javascript", +}); +``` + +The common result contains process-compatible output and an optional structured value: + +```ts +interface WorkspaceRuntimeResult { + status: "completed" | "failed" | "cancelled"; + exitCode: number; + stdout: Uint8Array | string; + stderr: Uint8Array | string; + value?: WorkspaceRuntimeValue; + pushed: number; + pulled: number; + skipped: SkippedEntry[]; +} +``` + +Command backends leave `value` unset. Module backends use it for their structured return value. + +`container-shell` retains the existing computerd process lifecycle. `isolate-javascript` keeps an execution journal in the Workspace database and retains events/results until `disposeExec`. Active isolate cancellation is host-driven by disposing the child Worker. An execution left running across a Workspace host restart is reconciled to failed because a live Worker capability cannot be serialized into SQLite. + +`isolate-shell` intentionally retains its existing behavior in this release: it buffers a just-bash call to completion, does not retain cross-request events, and cannot reattach by ID. Callers that require supervised process behavior should use `container-shell`; callers that require a managed isolate should use `isolate-javascript`. + +## Backend authority + +There is no general `workspace.scope()` abstraction. Backend construction fixes maximum authority and module availability. A public gateway must validate which backend a signed capability is allowed to select. + +For different authority levels, configure distinct backend instances: + +```ts +new IsolateJavaScriptBackend({ + id: "isolate-javascript-readonly", + loader: env.LOADER, + access: "read", +}); + +new IsolateJavaScriptBackend({ + id: "isolate-javascript", + loader: env.LOADER, + access: "read-write", +}); +``` + +The backend argument is never itself authorization. + +See [17. Isolate JavaScript](./17_isolate_javascript.md) for module and trusted-package behavior. diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md new file mode 100644 index 00000000..dec08478 --- /dev/null +++ b/docs/17_isolate_javascript.md @@ -0,0 +1,168 @@ +# Isolate JavaScript runtime + +`IsolateJavaScriptBackend` runs an ECMAScript module in a fresh Cloudflare Dynamic Worker: + +```ts +import { Workspace } from "@cloudflare/computer"; +import { IsolateJavaScriptBackend } from "@cloudflare/computer/backends/javascript"; + +const workspace = new Workspace({ + storage: ctx.storage, + waitUntil: ctx.waitUntil.bind(ctx), + backends: [ + new IsolateJavaScriptBackend({ + loader: env.LOADER, + root: "/workspace", + access: "read-write", + defaultTimeoutMs: 10_000, + maxTimeoutMs: 30_000, + globalOutbound: null, + modules: { + "math-kit": `export const double = value => value * 2;`, + }, + }), + ], +}); +``` + +Execute a module through the common runtime entry point: + +```ts +const handle = await workspace.runtime.exec( + ` + import { double } from "math-kit"; + import fs from "node:fs/promises"; + + export default async function main(input) { + const value = double(input.value); + await fs.writeFile("/workspace/result.txt", String(value)); + return { value, persisted: await fs.readFile("/workspace/result.txt", "utf8") }; + } + `, + { + backend: "isolate-javascript", + input: { value: 21 }, + encoding: "utf8", + }, +); + +const result = await handle.result(); +// result.value = { value: 42, persisted: "42" } +``` + +The source is a real ES module. Static imports, literal dynamic imports, and top-level await are supported. If the module default-exports a function, Workspace invokes it with `options.input`. Otherwise module evaluation completes with a `null` structured result. + +`waitUntil` is required for this backend. `runtime.exec()` returns before the Dynamic Worker finishes, so the host must attach completion to the Durable Object event lifetime. Construction fails when a module backend connects without this hook. + +## Durable relative imports + +Relative imports resolve from `cwd` through the durable Workspace filesystem: + +```ts +await workspace.fs.writeFile( + "/workspace/task.js", + ` + import fs from "node:fs/promises"; + export default input => fs.writeFile("/workspace/value.txt", String(input.value)); + `, +); + +await workspace.runtime.exec( + `import task from "./task.js"; export default task;`, + { + backend: "isolate-javascript", + cwd: "/workspace", + input: { value: 42 }, + }, +); +``` + +Workspace parses the graph before loading the Worker, confines every durable path, rejects symlink traversal, and enforces aggregate source, module-count, and import-depth limits. Dynamic imports must use string literals. + +## Execution limits and retention + +The backend admits one execution at a time by default. A concurrent start fails with `EEXEC_BUSY` instead of creating an unbounded number of Dynamic Workers. Set `maxConcurrentExecutions` only after measuring the Durable Object and Worker Loader limits for the deployment. + +Each execution also bounds log events, active event subscribers, directory entries per read, concurrent and total capability calls, and cumulative capability request and response bytes. The corresponding `maxLogEvents`, `maxExecutionSubscribers`, `maxDirectoryEntries`, and `max*Capability*` options may be lowered for public workloads. Directory reads apply their limit in SQLite before materializing rows. Requests are checked inside the isolate before Workers RPC and again by the host. + +Completed execution records remain available for replay for five minutes by default. The backend also keeps at most 100 completed records. Configure these bounds with `retentionMs` and `maxRetainedExecutions`. Completed records leave the in-memory active set immediately; replay reads them from SQLite. + +Cancellation stops new host capability calls, disposes the Dynamic Worker, and waits for host calls that were already accepted. Exit 130 is published only after those calls settle. Normal completion uses the same drain rule, so an unawaited capability call cannot mutate the workspace after exit 0. + +Host calls have a caller-visible deadline, controlled by `maxHostCallMs` and defaulting to `maxTimeoutMs`. Missing the deadline fails the capability call and marks the execution failed, even if caller code catches that error. Execution still waits for the accepted host operation itself before publishing a terminal event because many host APIs cannot roll back an external side effect after dispatch. Trusted modules receive an optional `{ signal, deadline }` context and must stop promptly when the signal aborts. A trusted module that ignores cancellation and never settles will keep execution in its finalizing state. `compatibilityDate` and `compatibilityFlags` control the Dynamic Worker runtime and default to the package-tested settings. + +## Configured modules + +Bare imports are installed at backend construction, not passed on individual executions: + +```ts +new IsolateJavaScriptBackend({ + loader: env.LOADER, + modules: { + "tar-stream": TAR_STREAM_BUNDLE, + }, +}); +``` + +Unknown bare imports fail before Worker creation. `node:fs` and `node:fs/promises` are host-installed exceptions backed by the durable Workspace. Configured modules are code, not host authority, and may not use the reserved `ws:` namespace or shadow either filesystem specifier. + +## Trusted Workspace modules + +Filesystem access uses the familiar asynchronous Node API, but is backed by the durable Workspace rather than an isolate-local filesystem. Both forms are installed automatically: + +```js +import fs from "node:fs/promises"; +// or: import { promises as fs } from "node:fs"; + +const text = await fs.readFile("/workspace/input.txt", "utf8"); +await fs.writeFile("/workspace/output.txt", text.toUpperCase()); +``` + +Supported promise APIs are `readFile`, `writeFile`, `mkdir`, `rm`, `chmod`, `symlink`, `readlink`, `readdir`, `stat`, `lstat`, and `access`. `readFile` returns bytes when encoding is omitted and supports `"utf8"` / `"utf-8"` for text; other encodings are rejected. `writeFile` supports the default `"w"` flag and exclusive `"wx"`; other Node flags are rejected, and—as in Node—the parent directory must already exist. Relative symlink targets are preserved by `readlink`, while reads and writes through symlinks are rejected by the Workspace confinement boundary. Synchronous and callback-style Node filesystem APIs are intentionally unavailable because every operation crosses the isolate-to-Workspace capability boundary. + +The entire `ws:` namespace remains reserved for other Workspace-maintained host capabilities. The built-in runtime installs `ws:git` and `ws:artifacts`. + +### `ws:git` + +```js +import { clone, diff, status, log, cli } from "ws:git"; +``` + +`ws:git` is explicit host authority rather than ambient isolate networking. Clone, fetch, pull, push, `ls-remote`, and submodule commands can perform host-side requests even when the Dynamic Worker has `globalOutbound: null`, so they are denied by default. Enable them only on a trusted backend construction with `allowGitNetwork: true`; local Git operations remain available without that authority. Remote `ws:artifacts.importArtifact()` is independently denied unless backend construction sets `allowArtifactNetwork: true`. + +### `ws:artifacts` + +```js +import { + create, + get, + list, + importArtifact, + deleteArtifact, +} from "ws:artifacts"; +``` + +These modules are sandbox-side shims over host RPC. Loader bindings, credentials, Durable Object storage, and unrestricted Workspace objects never enter user code. The host bridge checks the backend's fixed read/read-write authority on every mutation. Artifacts methods fail clearly when no Artifacts binding is configured. + +Caller modules and durable files cannot shadow `node:fs`, `node:fs/promises`, or `ws:*`. + +Path confinement rejects lexical escapes and every symlink component before an operation. These checks are not an atomic inode-style “resolve beneath root” primitive: do not treat one isolate capability as a security boundary against a separate, more privileged principal concurrently replacing paths in the same mutable Workspace. Deployments requiring that adversarial concurrency need a future transactional DOFS primitive or separate Workspace identities. + +## Isolation and lifecycle + +Each execution receives a fresh Dynamic Worker with: + +- explicit Worker Loader CPU limits; +- a host wall-clock deadline; +- `globalOutbound: null` by default; +- finite, acyclic JSON-compatible input and structured result validation; +- configurable source/module graph, input, result, captured-log, file/capability request, and response byte limits (`maxSourceBytes`, `maxInputBytes`, `maxResultBytes`, `maxLogBytes`, and `maxCapabilityBytes`); +- explicit entrypoint and Worker disposal; +- host-owned cancellation; +- retained events and result rows in the Workspace database. + +Console output is bounded but currently buffered in the Dynamic Worker and published when evaluation settles; the execution event stream provides replay/lifecycle semantics rather than live JavaScript console streaming. Completed writes are durable immediately. Failure or cancellation does not roll back filesystem effects already completed. + +## Trusted integrations + +A host can configure additional reserved capability modules through `IsolateJavaScriptBackend.trustedModules`; these modules are fixed when the backend is constructed and cannot be supplied or replaced by caller source. diff --git a/docs/18_runtime_migration.md b/docs/18_runtime_migration.md new file mode 100644 index 00000000..75aec455 --- /dev/null +++ b/docs/18_runtime_migration.md @@ -0,0 +1,31 @@ +# 18. Migrating to `workspace.runtime` + +This change is a breaking preview-API migration. Public execution now uses one router, while filesystem, Git, Assets, and Artifacts remain separate Workspace capabilities. + +## API mapping + +| Previous API | Runtime API | +|---|---| +| `workspace.shell.exec(command, options)` | `workspace.runtime.exec(command, options)` | +| `workspace.shell.get(id, options)` | `workspace.runtime.getExec(id, options)` | +| `workspace.shell.kill(id, options)` | `workspace.runtime.killExec(id, options)` | +| `workspace.shell.dispose(id, options)` | `workspace.runtime.disposeExec(id, options)` | +| `workspace.code` / script execution | `workspace.runtime.exec(source, { backend: "isolate-javascript", input })` | + +`WorkspaceShell` still exists internally to implement command backends. It is not a public Workspace property. + +## Default backend IDs + +- Cloudflare Container: `container-shell` +- just-bash Dynamic Worker: `isolate-shell` +- ECMAScript Dynamic Worker: `isolate-javascript` + +The first configured backend is the default for `runtime.exec()`. Pass `backend` explicitly at security boundaries. Routing is not authorization: trusted gateways must choose from a host-owned allowlist rather than accepting an arbitrary model-supplied backend ID. + +## Source semantics + +Command backends interpret the first argument as a shell command and reject structured `input`. `isolate-javascript` interprets it as an ECMAScript module and supports structured JSON-compatible input/results, durable relative modules, `node:fs/promises`, and host-owned trusted modules. + +## Lifecycle differences + +Container command executions use the remote process journal and push/pull synchronization bracket. `isolate-shell` uses the documented limited one-call Worker lifecycle. `isolate-javascript` stores execution status and events in the Workspace database and supports replay, cancellation, disposal, and restart recovery. Completed filesystem and provider side effects are not rolled back when execution fails or is cancelled. diff --git a/docs/README.md b/docs/README.md index 8c9731f1..1e856a04 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,8 @@ It provides: - A fs API for working with files and directories compatible with Worker bindings. - R2-backed mounts for pre-filling read-only data into the workspace tree. - Durability over DO restarts for all file operations. - - Pluggable execution backends selected through `workspace.runtime`: a Cloudflare Container shell or a just-bash Dynamic Worker. + - Pluggable execution backends selected through `workspace.runtime`: a Cloudflare Container shell, a just-bash Dynamic Worker, or an isolated ECMAScript-module Dynamic Worker. + - Isolated JavaScript with structured input/results, durable relative imports, configured libraries, durable `node:fs/promises`, trusted `ws:git` / `ws:artifacts`, and managed execution records. - Workspace constructable without a backend, for filesystem-only use cases. - Out-of-the-box AI SDK tools for `@cloudflare/agents` through `@cloudflare/computer/tools`. @@ -44,6 +45,7 @@ The package ships several entrypoints: | `@cloudflare/computer` | The Workspace facade, first-class `workspace.runtime`, stub types, the R2 mount, and proxy classes. | | `@cloudflare/computer/backends/container` | `CloudflareContainerBackend` and `withWorkspaceContainer`. Pulls in the computerd / capnweb sync plumbing. | | `@cloudflare/computer/backends/worker` | `WorkerBackend` and the bundled just-bash command runtime. | +| `@cloudflare/computer/backends/javascript` | `IsolateJavaScriptBackend`, configured libraries, durable relative imports, `node:fs/promises`, and trusted `ws:git` / `ws:artifacts`. | | `@cloudflare/computer/git` | Isomorphic-git glue for working with checkouts inside the workspace. | | `@cloudflare/computer/artifacts` | `createArtifact`, a session-scoped facade over the Cloudflare Artifacts Workers binding, plus its argv CLI. | | `@cloudflare/computer/tools` | AI SDK tools for agents: read, write, edit, ls, optional exec, and optional publish. | @@ -105,7 +107,7 @@ export class Agent extends withWorkspaceContainer(class extends DurableObject[0]) { + return super.connect({ ...host, waitUntil: host.waitUntil ?? (() => {}) }); + } +} + +function throwingLoader(message: string) { + return { + load() { + throw new Error(message); + }, + }; +} + +describe("IsolateJavaScriptBackend", () => { + it("requires a host event-lifetime hook", async () => { + const backend = new ProductionIsolateJavaScriptBackend({ loader: throwingLoader("unused") }); + await expect( + backend.connect({ + db: undefined as never, + fs: undefined as never, + git: undefined as never, + artifacts: undefined as never, + }), + ).rejects.toThrow(/requires WorkspaceOptions.waitUntil/); + }); + + it("validates timeout configuration", () => { + expect( + () => + new IsolateJavaScriptBackend({ + loader: throwingLoader("unused"), + maxTimeoutMs: Number.NaN, + }), + ).toThrow(/positive finite/); + expect( + () => + new IsolateJavaScriptBackend({ + loader: throwingLoader("unused"), + defaultTimeoutMs: -1, + }), + ).toThrow(/positive finite/); + }); + + it("cancels a started worker when waitUntil registration fails", async () => { + let entrypointDisposals = 0; + let workerDisposals = 0; + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + waitUntil() { + throw new Error("waitUntil unavailable"); + }, + backends: [ + new IsolateJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { + evaluate: () => new Promise(() => undefined), + [Symbol.dispose]() { + entrypointDisposals += 1; + }, + }; + }, + [Symbol.dispose]() { + workerDisposals += 1; + }, + }; + }, + }, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const execution = await workspace.runtime.exec("export default 1", { encoding: "utf8" }); + await expect(execution.result()).resolves.toMatchObject({ + status: "failed", + stderr: expect.stringContaining("waitUntil unavailable"), + }); + expect(entrypointDisposals).toBe(1); + expect(workerDisposals).toBe(1); + }); + + it("disposes Loader resources when evaluate throws synchronously", async () => { + let entrypointDisposals = 0; + let workerDisposals = 0; + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + waitUntil() {}, + backends: [ + new IsolateJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { + evaluate() { + throw new Error("evaluate failed"); + }, + [Symbol.dispose]() { + entrypointDisposals += 1; + }, + }; + }, + [Symbol.dispose]() { + workerDisposals += 1; + }, + }; + }, + }, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const execution = await workspace.runtime.exec("export default 1", { encoding: "utf8" }); + await expect(execution.result()).resolves.toMatchObject({ + status: "failed", + stderr: expect.stringContaining("evaluate failed"), + }); + expect(entrypointDisposals).toBe(1); + expect(workerDisposals).toBe(1); + }); + + it("migrates the legacy execution journal schema", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + db.run(`CREATE TABLE workspace_runtime_executions ( + backend TEXT NOT NULL, + id TEXT NOT NULL, + status TEXT NOT NULL, + PRIMARY KEY (backend, id) + )`); + db.run( + `INSERT INTO workspace_runtime_executions (backend, id, status) + VALUES ('isolate-javascript', 'legacy', 'completed')`, + ); + const fs = new WorkspaceFilesystem(db); + const backend = new IsolateJavaScriptBackend({ loader: throwingLoader("unused") }); + await backend.connect({ db, fs, git: undefined as never, artifacts: undefined as never }); + const columns = db.all<{ name: string }>("PRAGMA table_info(workspace_runtime_executions)"); + expect(columns.map((column) => column.name)).toEqual( + expect.arrayContaining(["created_at", "finished_at"]), + ); + expect( + db.scalar("SELECT finished_at FROM workspace_runtime_executions WHERE id = 'legacy'"), + ).toBeTypeOf("number"); + }); + + it("enforces finite input and result byte ceilings", async () => { + const load = vi.fn(() => ({ + getEntrypoint() { + return { evaluate: async () => ({ result: "result-too-large" }) }; + }, + })); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new IsolateJavaScriptBackend({ + loader: { load }, + maxInputBytes: 8, + maxResultBytes: 8, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await expect( + workspace.runtime.exec("export default 1", { input: "input-too-large" }), + ).rejects.toThrow("input exceeds 8 bytes"); + expect(load).not.toHaveBeenCalled(); + + const execution = await workspace.runtime.exec("export default 1", { encoding: "utf8" }); + await expect(execution.result()).resolves.toMatchObject({ + status: "failed", + stderr: expect.stringContaining("result exceeds 8 bytes"), + }); + }); + + it("rejects an execution whose module graph finishes after the handle closes", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const fs = new WorkspaceFilesystem(db); + await fs.mkdir("/workspace", { recursive: true }); + await fs.writeFile("/workspace/task.js", "export default 1"); + let release!: () => void; + const blocked = new Promise((resolve) => (release = resolve)); + const readFile = fs.readFile.bind(fs); + fs.readFile = (async (...args: Parameters) => { + await blocked; + return readFile(...args); + }) as typeof fs.readFile; + const backend = new IsolateJavaScriptBackend({ loader: throwingLoader("must not load") }); + const handle = await backend.connect({ + db, + fs, + git: undefined as never, + artifacts: undefined as never, + }); + const execution = handle.exec({ + source: `import task from "./task.js"; export default task;`, + cwd: "/workspace", + }); + await Promise.resolve(); + let closed = false; + const closing = handle.close().then(() => { + closed = true; + }); + await Promise.resolve(); + expect(closed).toBe(false); + release(); + await closing; + await expect(execution).rejects.toMatchObject({ code: "ECLOSED" }); + }); + + it("checks limits against the complete loader map including the runtime runner", async () => { + const load = vi.fn(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new IsolateJavaScriptBackend({ loader: { load }, maxSourceBytes: 128 })], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const execution = await workspace.runtime.exec("export default 1", { encoding: "utf8" }); + await expect(execution.result()).resolves.toMatchObject({ + status: "failed", + stderr: expect.stringContaining("loader graph exceeds 128 source bytes"), + }); + expect(load).not.toHaveBeenCalled(); + }); + + it("records synchronous loader startup failure as a completed failed execution", async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new IsolateJavaScriptBackend({ + loader: throwingLoader("loader failed"), + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const handle = await workspace.runtime.exec("export default () => 1", { + backend: "isolate-javascript", + id: "startup-failure", + encoding: "utf8", + }); + await expect(handle.result()).resolves.toMatchObject({ + status: "failed", + exitCode: 1, + stderr: expect.stringContaining("loader failed"), + }); + const replay = await workspace.runtime.getExec("startup-failure", { + backend: "isolate-javascript", + encoding: "utf8", + }); + await expect(replay.result()).resolves.toMatchObject({ status: "failed", exitCode: 1 }); + }); + + it("replays a coherent failure after backend recreation interrupts a run", async () => { + const storage = new SQLiteTestStorage(); + const dispose = vi.fn(); + const loader = { + load() { + return { + getEntrypoint() { + return { evaluate: () => new Promise(() => undefined) }; + }, + [Symbol.dispose]: dispose, + }; + }, + }; + const first = new Workspace({ + storage, + backends: [new IsolateJavaScriptBackend({ loader })], + }); + await first.fs.mkdir("/workspace", { recursive: true }); + await first.runtime.exec("export default async () => new Promise(() => {})", { + backend: "isolate-javascript", + id: "interrupted", + }); + + const recreated = new Workspace({ + storage, + backends: [new IsolateJavaScriptBackend({ loader })], + }); + const replay = await recreated.runtime.getExec("interrupted", { + backend: "isolate-javascript", + encoding: "utf8", + }); + await expect(replay.result()).resolves.toMatchObject({ + status: "failed", + exitCode: 1, + stderr: expect.stringContaining("runtime restarted"), + }); + await first.close(); + }); + + it("reserves an explicit execution id while module construction is in flight", async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new IsolateJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { evaluate: () => new Promise(() => undefined) }; + }, + }; + }, + }, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const [first, second] = await Promise.allSettled([ + workspace.runtime.exec("export default async () => new Promise(() => {})", { + id: "shared-id", + }), + workspace.runtime.exec("export default 2", { id: "shared-id" }), + ]); + expect([first.status, second.status].sort()).toEqual(["fulfilled", "rejected"]); + const rejected = first.status === "rejected" ? first.reason : second.reason; + expect(rejected).toMatchObject({ code: "EEXEC_BUSY" }); + await workspace.close(); + }); + + it("limits concurrent Dynamic Workers and attaches execution to waitUntil", async () => { + let resolveEvaluation!: (value: { result: number }) => void; + const evaluation = new Promise<{ result: number }>((resolve) => { + resolveEvaluation = resolve; + }); + const waitUntil = vi.fn<(promise: Promise) => void>(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + waitUntil, + backends: [ + new IsolateJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { evaluate: () => evaluation }; + }, + }; + }, + }, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const first = await workspace.runtime.exec("export default 1", { id: "first" }); + expect(waitUntil).toHaveBeenCalledOnce(); + await expect( + workspace.runtime.exec("export default 2", { id: "second" }), + ).rejects.toMatchObject({ code: "EEXEC_BUSY" }); + resolveEvaluation({ result: 1 }); + await expect(first.result()).resolves.toMatchObject({ status: "completed" }); + await expect( + workspace.runtime.exec("export default 2", { id: "second" }), + ).resolves.toBeDefined(); + await workspace.close(); + }); + + it("waits for accepted host calls before reporting successful completion", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const fs = new WorkspaceFilesystem(db); + await fs.mkdir("/workspace", { recursive: true }); + let releaseWrite!: () => void; + const writeReleased = new Promise((resolve) => { + releaseWrite = resolve; + }); + const originalWrite = fs.writeFile.bind(fs); + fs.writeFile = (async (...args: Parameters) => { + await writeReleased; + return originalWrite(...args); + }) as typeof fs.writeFile; + const backend = new IsolateJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { + evaluate( + _input: unknown, + host: { call(name: string, args: string): Promise }, + ) { + void host.call("fs.writeFile", JSON.stringify(["/workspace/output.txt", "done"])); + return Promise.resolve({ result: 1 }); + }, + }; + }, + }; + }, + }, + }); + const handle = await backend.connect({ + db, + fs, + git: undefined as never, + artifacts: undefined as never, + }); + const execution = await handle.exec({ id: "successful-host-call", source: "export default 1" }); + let settled = false; + const terminal = (async () => { + const events = []; + for await (const event of execution.events) events.push(event); + settled = true; + return events; + })(); + await Promise.resolve(); + expect(settled).toBe(false); + releaseWrite(); + const events = await terminal; + expect(await fs.readFile("/workspace/output.txt", "utf8")).toBe("done"); + expect(events.at(-1)).toMatchObject({ name: "exit", value: 0 }); + }); + + it("aborts cooperative trusted-module calls at their deadline", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const fs = new WorkspaceFilesystem(db); + await fs.mkdir("/workspace", { recursive: true }); + let aborted = false; + const backend = new IsolateJavaScriptBackend({ + maxHostCallMs: 5, + trustedModules: { + "ws:test": { + call(_method, _args, context) { + return new Promise((_resolve, reject) => { + context?.signal.addEventListener("abort", () => { + aborted = true; + reject(context.signal.reason); + }); + }); + }, + }, + }, + loader: { + load() { + return { + getEntrypoint() { + return { + async evaluate( + _input: unknown, + host: { call(name: string, args: string): Promise }, + ) { + await host.call("trusted/ws:test.call", JSON.stringify(["run"])); + return { result: 1 }; + }, + }; + }, + }; + }, + }, + }); + const handle = await backend.connect({ + db, + fs, + git: undefined as never, + artifacts: undefined as never, + }); + const execution = await handle.exec({ id: "trusted-timeout", source: "export default 1" }); + const events = []; + for await (const event of execution.events) events.push(event); + expect(aborted).toBe(true); + expect(events.at(-1)).toMatchObject({ name: "exit", value: 1 }); + }); + + it("waits for accepted host calls before reporting cancellation", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const fs = new WorkspaceFilesystem(db); + await fs.mkdir("/workspace", { recursive: true }); + let releaseWrite!: () => void; + const writeReleased = new Promise((resolve) => { + releaseWrite = resolve; + }); + let callStarted!: () => void; + const started = new Promise((resolve) => { + callStarted = resolve; + }); + const originalWrite = fs.writeFile.bind(fs); + fs.writeFile = (async (...args: Parameters) => { + callStarted(); + await writeReleased; + return originalWrite(...args); + }) as typeof fs.writeFile; + const backend = new IsolateJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { + evaluate( + _input: unknown, + host: { call(name: string, args: string): Promise }, + ) { + void host.call("fs.writeFile", JSON.stringify(["/workspace/output.txt", "done"])); + return new Promise(() => undefined); + }, + }; + }, + }; + }, + }, + }); + const handle = await backend.connect({ + db, + fs, + git: undefined as never, + artifacts: undefined as never, + }); + const execution = await handle.exec({ id: "cancel-host-call", source: "export default 1" }); + await started; + let killed = false; + const kill = handle.killExec({ id: execution.id }).then(() => { + killed = true; + }); + let secondKilled = false; + const secondKill = handle.killExec({ id: execution.id }).then(() => { + secondKilled = true; + }); + let closed = false; + const closing = handle.close().then(() => { + closed = true; + }); + await Promise.resolve(); + expect(killed).toBe(false); + expect(secondKilled).toBe(false); + expect(closed).toBe(false); + releaseWrite(); + await Promise.all([kill, secondKill, closing]); + expect(await fs.readFile("/workspace/output.txt", "utf8")).toBe("done"); + const events = []; + for await (const event of execution.events) events.push(event); + expect(events.at(-1)).toMatchObject({ name: "exit", value: 130 }); + }); + + it("settles subscribers when terminal persistence fails and repairs on reconnect", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const fs = new WorkspaceFilesystem(db); + await fs.mkdir("/workspace", { recursive: true }); + let finish!: (value: { result: number }) => void; + const evaluation = new Promise<{ result: number }>((resolve) => { + finish = resolve; + }); + const backend = new IsolateJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { evaluate: () => evaluation }; + }, + }; + }, + }, + }); + const host = { db, fs, git: undefined as never, artifacts: undefined as never }; + const handle = await backend.connect(host); + const execution = await handle.exec({ id: "storage-failure", source: "export default 1" }); + const originalRun = db.run.bind(db); + db.run = ((query: string, ...bindings: unknown[]) => { + if (query.includes("UPDATE workspace_runtime_executions")) { + throw new Error("storage unavailable"); + } + return originalRun(query, ...bindings); + }) as typeof db.run; + finish({ result: 1 }); + const events = []; + for await (const event of execution.events) events.push(event); + expect(events.at(-1)).toMatchObject({ name: "exit", value: 1 }); + const sameSessionReplay = await handle.getExec({ id: "storage-failure" }); + const sameSessionEvents = []; + for await (const event of sameSessionReplay.events) sameSessionEvents.push(event); + expect(sameSessionEvents.at(-1)).toMatchObject({ name: "exit", value: 1 }); + db.run = originalRun as typeof db.run; + + const reconnected = await backend.connect(host); + const replay = await reconnected.getExec({ id: "storage-failure" }); + const repaired = []; + for await (const event of replay.events) repaired.push(event); + expect(repaired.at(-1)).toMatchObject({ name: "exit", value: 1 }); + }); + + it("bounds durable completed-execution retention", async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new IsolateJavaScriptBackend({ + loader: throwingLoader("finished"), + maxRetainedExecutions: 1, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + for (const id of ["one", "two"]) { + const execution = await workspace.runtime.exec("export default 1", { id }); + await execution.result(); + } + const third = await workspace.runtime.exec("export default 1", { id: "three" }); + await third.result(); + await expect(workspace.runtime.getExec("one")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(workspace.runtime.getExec("two")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(workspace.runtime.getExec("three")).resolves.toBeDefined(); + }); + + it("rejects cwd and execution ids outside their configured bounds", async () => { + const load = vi.fn(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new IsolateJavaScriptBackend({ loader: { load } })], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await expect(workspace.runtime.exec("export default 1", { cwd: "/outside" })).rejects.toThrow( + /stay under \/workspace/, + ); + await expect( + workspace.runtime.exec("export default 1", { id: "x".repeat(257) }), + ).rejects.toThrow(/id exceeds 256 bytes/); + expect(load).not.toHaveBeenCalled(); + }); + + it("caps unconsumed event subscribers per execution", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const fs = new WorkspaceFilesystem(db); + await fs.mkdir("/workspace", { recursive: true }); + const backend = new IsolateJavaScriptBackend({ + maxExecutionSubscribers: 2, + loader: { + load() { + return { + getEntrypoint() { + return { evaluate: () => new Promise(() => undefined) }; + }, + }; + }, + }, + }); + const handle = await backend.connect({ + db, + fs, + git: undefined as never, + artifacts: undefined as never, + }); + await handle.exec({ id: "subscribers", source: "export default 1" }); + await handle.getExec({ id: "subscribers", after: "tail" }); + const rejected = await handle.getExec({ id: "subscribers", after: "tail" }); + await expect(rejected.events.getReader().read()).rejects.toMatchObject({ code: "EEXEC_BUSY" }); + await handle.close(); + }); + + it("rejects malformed host trusted-module names", async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new IsolateJavaScriptBackend({ + loader: throwingLoader("must not load"), + trustedModules: { + "ws:bad/path": { + async call() { + return null; + }, + }, + } as never, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await expect( + workspace.runtime.exec(`import { call } from "ws:bad/path"; export default call;`, { + backend: "isolate-javascript", + }), + ).rejects.toThrow(/simple reserved ws:\*/); + }); + + it("rejects relative imports that collide with internal Loader modules", async () => { + const load = vi.fn(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new IsolateJavaScriptBackend({ loader: { load }, root: "/" })], + }); + await workspace.fs.writeFile("/workspace-capabilities.js", "export const stolen = true"); + await expect( + workspace.runtime.exec(`import "./workspace-capabilities.js"; export default 1;`, { + cwd: "/", + }), + ).rejects.toThrow(/reserved for Workspace internals/); + expect(load).not.toHaveBeenCalled(); + }); + + it("rejects configured module names that collide with generated modules", async () => { + const load = vi.fn(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new IsolateJavaScriptBackend({ + loader: { load }, + modules: { + "__workspace_entry__.js": "export default 42", + "node:fs": "export default {};", + }, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await expect( + workspace.runtime.exec("export default 1", { backend: "isolate-javascript" }), + ).rejects.toThrow(/reserved module name/); + expect(load).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/computer/src/backends/javascript/javascript-backend.ts b/packages/computer/src/backends/javascript/javascript-backend.ts new file mode 100644 index 00000000..b17b5aed --- /dev/null +++ b/packages/computer/src/backends/javascript/javascript-backend.ts @@ -0,0 +1,1113 @@ +import { WorkspaceRuntimeBridge } from "../../runtime/bridge.js"; +import { assertRuntimeValue, WorkspaceRuntimeCapability } from "../../runtime/capability.js"; +import type { + ModuleExecutionEnvelope, + ModuleExecutionInput, + WorkspaceModuleBackend, + WorkspaceModuleBackendHandle, + WorkspaceModuleBackendHost, + WorkspaceRuntimeAccess, + WorkspaceRuntimeEvent, + WorkspaceRuntimeLoader, + WorkspaceRuntimeValue, + WorkspaceTrustedModule, +} from "../../runtime/types.js"; +import { buildModuleGraph } from "./module-graph.js"; + +export interface IsolateJavaScriptBackendOptions { + loader: WorkspaceRuntimeLoader; + id?: string; + root?: string; + access?: WorkspaceRuntimeAccess; + modules?: Record; + /** + * Host-owned capability modules installed under reserved ws:* specifiers. + * Caller source may import them, but cannot provide or replace them. + */ + trustedModules?: Record<`ws:${string}`, WorkspaceTrustedModule>; + defaultTimeoutMs?: number; + maxTimeoutMs?: number; + maxSourceBytes?: number; + maxInputBytes?: number; + maxResultBytes?: number; + maxLogBytes?: number; + maxLogEvents?: number; + maxCapabilityBytes?: number; + /** Caller-visible deadline for one host capability call. */ + maxHostCallMs?: number; + maxConcurrentCapabilityCalls?: number; + maxCapabilityCalls?: number; + maxCapabilityRequestBytes?: number; + maxCapabilityResponseBytes?: number; + /** Maximum entries returned by one isolated directory read. Defaults to 1024. */ + maxDirectoryEntries?: number; + /** Maximum graph loads and Dynamic Workers active at once. Defaults to 1. */ + maxConcurrentExecutions?: number; + /** Maximum live replay subscribers per execution. Defaults to 8. */ + maxExecutionSubscribers?: number; + /** Completed execution retention window. Defaults to five minutes. */ + retentionMs?: number; + /** Maximum completed executions retained per backend. Defaults to 100. */ + maxRetainedExecutions?: number; + compatibilityDate?: string; + compatibilityFlags?: string[]; + globalOutbound?: Fetcher | null; + /** Allow ws:git operations that can perform host-side network requests. */ + allowGitNetwork?: boolean; + /** Allow ws:artifacts imports from caller-selected remote URLs. */ + allowArtifactNetwork?: boolean; +} + +type ResolvedIsolateJavaScriptBackendOptions = Required< + Pick< + IsolateJavaScriptBackendOptions, + | "root" + | "access" + | "defaultTimeoutMs" + | "maxTimeoutMs" + | "maxSourceBytes" + | "maxInputBytes" + | "maxResultBytes" + | "maxLogBytes" + | "maxLogEvents" + | "maxCapabilityBytes" + | "maxHostCallMs" + | "maxConcurrentCapabilityCalls" + | "maxCapabilityCalls" + | "maxCapabilityRequestBytes" + | "maxCapabilityResponseBytes" + | "maxDirectoryEntries" + | "maxConcurrentExecutions" + | "maxExecutionSubscribers" + | "retentionMs" + | "maxRetainedExecutions" + | "compatibilityDate" + | "compatibilityFlags" + > +> & + IsolateJavaScriptBackendOptions; + +interface JavaScriptEntrypoint { + evaluate( + input: WorkspaceRuntimeValue, + host: WorkspaceRuntimeBridge, + ): Promise<{ result?: unknown; logs?: string[]; error?: string }>; + [Symbol.dispose]?: () => void; +} + +interface ActiveControl { + cancel(): void; + readonly completion: Promise; +} + +interface ExecutionSubscriber { + controller: ReadableStreamDefaultController; + index: number; + release(): void; +} + +interface ExecutionRecord { + id: string; + events: WorkspaceRuntimeEvent[]; + subscribers: Set; + status: "running" | "completed" | "failed" | "cancelled"; + control?: ActiveControl; + bridge?: WorkspaceRuntimeBridge; + finalization?: Promise; + admitted?: boolean; + persistenceFailed?: boolean; +} + +export class IsolateJavaScriptBackend implements WorkspaceModuleBackend { + readonly protocol = "module" as const; + readonly requiresWaitUntil = true; + readonly type = "isolate-javascript"; + readonly id: string; + readonly #options: ResolvedIsolateJavaScriptBackendOptions; + + constructor(options: IsolateJavaScriptBackendOptions) { + this.id = options.id ?? "isolate-javascript"; + const maxTimeoutMs = options.maxTimeoutMs ?? 30_000; + const defaultTimeoutMs = options.defaultTimeoutMs ?? Math.min(10_000, maxTimeoutMs); + assertPositiveFinite(maxTimeoutMs, "maxTimeoutMs"); + assertPositiveFinite(defaultTimeoutMs, "defaultTimeoutMs"); + assertPositiveFinite(options.maxSourceBytes ?? 256 * 1024, "maxSourceBytes"); + assertPositiveFinite(options.maxInputBytes ?? 256 * 1024, "maxInputBytes"); + assertPositiveFinite(options.maxResultBytes ?? 1024 * 1024, "maxResultBytes"); + assertPositiveFinite(options.maxLogBytes ?? 256 * 1024, "maxLogBytes"); + assertPositiveInteger(options.maxLogEvents ?? 1024, "maxLogEvents"); + assertPositiveFinite(options.maxCapabilityBytes ?? 1024 * 1024, "maxCapabilityBytes"); + assertPositiveFinite(options.maxHostCallMs ?? maxTimeoutMs, "maxHostCallMs"); + assertPositiveInteger( + options.maxConcurrentCapabilityCalls ?? 16, + "maxConcurrentCapabilityCalls", + ); + assertPositiveInteger(options.maxCapabilityCalls ?? 256, "maxCapabilityCalls"); + assertPositiveFinite( + options.maxCapabilityRequestBytes ?? 8 * 1024 * 1024, + "maxCapabilityRequestBytes", + ); + assertPositiveFinite( + options.maxCapabilityResponseBytes ?? 8 * 1024 * 1024, + "maxCapabilityResponseBytes", + ); + assertPositiveInteger(options.maxDirectoryEntries ?? 1024, "maxDirectoryEntries"); + assertPositiveInteger(options.maxConcurrentExecutions ?? 1, "maxConcurrentExecutions"); + assertPositiveInteger(options.maxExecutionSubscribers ?? 8, "maxExecutionSubscribers"); + assertPositiveFinite(options.retentionMs ?? 5 * 60_000, "retentionMs"); + assertPositiveInteger(options.maxRetainedExecutions ?? 100, "maxRetainedExecutions"); + if ((options.maxCapabilityBytes ?? 1024 * 1024) < 256) { + throw new Error("IsolateJavaScriptBackend maxCapabilityBytes must be at least 256 bytes."); + } + const compatibilityDate = options.compatibilityDate ?? "2026-05-23"; + if (!/^\d{4}-\d{2}-\d{2}$/.test(compatibilityDate)) { + throw new Error("IsolateJavaScriptBackend compatibilityDate must use YYYY-MM-DD."); + } + if (defaultTimeoutMs > maxTimeoutMs) { + throw new Error("IsolateJavaScriptBackend defaultTimeoutMs cannot exceed maxTimeoutMs."); + } + this.#options = { + ...options, + root: options.root ?? "/workspace", + access: options.access ?? "read-write", + defaultTimeoutMs, + maxTimeoutMs, + maxSourceBytes: options.maxSourceBytes ?? 256 * 1024, + maxInputBytes: options.maxInputBytes ?? 256 * 1024, + maxResultBytes: options.maxResultBytes ?? 1024 * 1024, + maxLogBytes: options.maxLogBytes ?? 256 * 1024, + maxLogEvents: options.maxLogEvents ?? 1024, + maxCapabilityBytes: options.maxCapabilityBytes ?? 1024 * 1024, + maxHostCallMs: options.maxHostCallMs ?? maxTimeoutMs, + maxConcurrentCapabilityCalls: options.maxConcurrentCapabilityCalls ?? 16, + maxCapabilityCalls: options.maxCapabilityCalls ?? 256, + maxCapabilityRequestBytes: options.maxCapabilityRequestBytes ?? 8 * 1024 * 1024, + maxCapabilityResponseBytes: options.maxCapabilityResponseBytes ?? 8 * 1024 * 1024, + maxDirectoryEntries: options.maxDirectoryEntries ?? 1024, + maxConcurrentExecutions: options.maxConcurrentExecutions ?? 1, + maxExecutionSubscribers: options.maxExecutionSubscribers ?? 8, + retentionMs: options.retentionMs ?? 5 * 60_000, + maxRetainedExecutions: options.maxRetainedExecutions ?? 100, + compatibilityDate, + compatibilityFlags: options.compatibilityFlags ?? ["nodejs_compat"], + globalOutbound: options.globalOutbound ?? null, + }; + } + + async connect(host: WorkspaceModuleBackendHost): Promise { + if (!host.waitUntil) { + throw new Error( + "IsolateJavaScriptBackend requires WorkspaceOptions.waitUntil; pass ctx.waitUntil.bind(ctx).", + ); + } + return new JavaScriptBackendHandle(this.#options, host); + } +} + +class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { + readonly #options: ResolvedIsolateJavaScriptBackendOptions; + readonly #host: WorkspaceModuleBackendHost; + readonly #records = new Map(); + readonly #pendingIds = new Set(); + #closed = false; + #activeExecutions = 0; + readonly #activeStreams = new Map(); + #pendingStarts = 0; + readonly #pendingStartWaiters = new Set<() => void>(); + + constructor(options: ResolvedIsolateJavaScriptBackendOptions, host: WorkspaceModuleBackendHost) { + this.#options = options; + this.#host = host; + host.db.run(` + CREATE TABLE IF NOT EXISTS workspace_runtime_executions ( + backend TEXT NOT NULL, + id TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + finished_at INTEGER, + PRIMARY KEY (backend, id) + ) + `); + addColumnIfMissing( + host, + "workspace_runtime_executions", + "created_at", + "INTEGER NOT NULL DEFAULT 0", + ); + addColumnIfMissing(host, "workspace_runtime_executions", "finished_at", "INTEGER"); + host.db.run( + `UPDATE workspace_runtime_executions + SET finished_at = ? + WHERE status != 'running' AND finished_at IS NULL`, + Date.now(), + ); + host.db.run(` + CREATE TABLE IF NOT EXISTS workspace_runtime_events ( + backend TEXT NOT NULL, + execution_id TEXT NOT NULL, + seq INTEGER NOT NULL, + name TEXT NOT NULL, + payload BLOB NOT NULL, + PRIMARY KEY (backend, execution_id, seq) + ) + `); + this.#prune(); + const interrupted = host.db.all<{ id: string }>( + `SELECT id FROM workspace_runtime_executions WHERE backend = ? AND status = 'running'`, + this.#backendId, + ); + for (const { id } of interrupted) { + const record = this.#recordFor(id); + if (record?.status !== "running") continue; + this.#finish(record, "failed", [ + { + id, + seq: record.events.length + 1, + name: "stderr", + value: new TextEncoder().encode( + "Execution was interrupted when its Workspace runtime restarted.\n", + ), + }, + { id, seq: record.events.length + 2, name: "exit", value: 1 }, + ]); + } + } + + async exec(input: ModuleExecutionInput): Promise { + if (this.#closed) throw runtimeError("ECLOSED", "Workspace JavaScript backend is closed"); + const id = input.id ?? crypto.randomUUID(); + assertExecutionId(id); + this.#prune(); + if (this.#pendingIds.has(id)) { + throw runtimeError("EEXEC_BUSY", `execution ${id} is already starting`); + } + const existing = this.#recordFor(id); + if (existing?.status === "running") { + throw runtimeError("EEXEC_BUSY", `execution ${id} is already running`); + } + if (existing) throw runtimeError("EEXEC_EXISTS", `execution ${id} already exists`); + + const timeoutMs = input.timeoutMs ?? this.#options.defaultTimeoutMs; + assertPositiveFinite(timeoutMs, "timeoutMs"); + if (timeoutMs > this.#options.maxTimeoutMs) { + throw new Error( + `Workspace runtime timeout cannot exceed ${this.#options.maxTimeoutMs} milliseconds.`, + ); + } + const inputValue = input.input ?? null; + assertRuntimeValue(inputValue); + assertEncodedSize(inputValue, this.#options.maxInputBytes, "input"); + if (new TextEncoder().encode(input.source).byteLength > this.#options.maxSourceBytes) { + throw new Error(`Workspace runtime source exceeds ${this.#options.maxSourceBytes} bytes.`); + } + if (this.#activeExecutions >= this.#options.maxConcurrentExecutions) { + throw runtimeError( + "EEXEC_BUSY", + `JavaScript backend already has ${this.#activeExecutions} active execution(s)`, + ); + } + + this.#activeExecutions += 1; + this.#pendingStarts += 1; + this.#pendingIds.add(id); + let admittedRecord: ExecutionRecord | undefined; + try { + const capability = new WorkspaceRuntimeCapability( + this.#host.fs, + this.#options.root, + this.#options.access, + this.#options.maxCapabilityBytes, + this.#options.maxDirectoryEntries, + ); + const graph = await buildModuleGraph({ + source: input.source, + cwd: input.cwd ?? this.#options.root, + capability, + configuredModules: this.#options.modules ?? {}, + trustedModuleNames: Object.keys(this.#options.trustedModules ?? {}), + maxSourceBytes: this.#options.maxSourceBytes, + maxCapabilityBytes: this.#options.maxCapabilityBytes, + }); + if (this.#closed) { + throw runtimeError("ECLOSED", "Workspace JavaScript backend closed during module loading"); + } + const record: ExecutionRecord = { + id, + events: [], + subscribers: new Set(), + status: "running", + admitted: true, + }; + try { + this.#host.db.run( + `INSERT INTO workspace_runtime_executions + (backend, id, status, created_at, finished_at) + VALUES (?, ?, 'running', ?, NULL)`, + this.#backendId, + id, + Date.now(), + ); + } catch (error) { + const durable = this.#recordFor(id); + if (durable?.status === "running") { + throw runtimeError("EEXEC_BUSY", `execution ${id} is already running`); + } + if (durable) throw runtimeError("EEXEC_EXISTS", `execution ${id} already exists`); + throw error; + } + admittedRecord = record; + this.#records.set(id, record); + try { + const bridge = new WorkspaceRuntimeBridge(capability, { + git: this.#host.git, + artifacts: this.#host.artifacts, + trustedModules: this.#options.trustedModules, + allowGitNetwork: this.#options.allowGitNetwork ?? false, + allowArtifactNetwork: this.#options.allowArtifactNetwork ?? false, + maxPayloadBytes: this.#options.maxCapabilityBytes, + maxCallDurationMs: this.#options.maxHostCallMs, + maxConcurrentCalls: this.#options.maxConcurrentCapabilityCalls, + maxCalls: this.#options.maxCapabilityCalls, + maxTotalRequestBytes: this.#options.maxCapabilityRequestBytes, + maxTotalResponseBytes: this.#options.maxCapabilityResponseBytes, + }); + record.bridge = bridge; + record.control = startJavaScriptExecution({ + loader: this.#options.loader, + modules: graph.modules, + entryName: graph.entryName, + input: inputValue, + bridge, + timeoutMs, + globalOutbound: this.#options.globalOutbound ?? null, + compatibilityDate: this.#options.compatibilityDate, + compatibilityFlags: this.#options.compatibilityFlags, + maxLogBytes: this.#options.maxLogBytes, + maxLogEvents: this.#options.maxLogEvents, + maxResultBytes: this.#options.maxResultBytes, + maxSourceBytes: this.#options.maxSourceBytes, + onComplete: (outcome) => this.#complete(record, outcome), + }); + this.#host.waitUntil?.(record.control.completion); + } catch (error) { + record.control?.cancel(); + await record.control?.completion.catch(() => undefined); + await this.#complete(record, { + error: error instanceof Error ? error.message : String(error), + }); + } + return { id, events: this.#stream(record) }; + } finally { + this.#pendingIds.delete(id); + this.#pendingStarts -= 1; + if (this.#pendingStarts === 0) { + for (const resolve of this.#pendingStartWaiters) resolve(); + this.#pendingStartWaiters.clear(); + } + if (!admittedRecord) this.#activeExecutions -= 1; + } + } + + async getExec(input: { id: string; after?: number | "tail" }): Promise { + assertExecutionId(input.id); + this.#prune(); + const record = this.#recordFor(input.id); + if (!record) throw runtimeError("ENOENT", `no such execution: ${input.id}`); + return { id: input.id, events: this.#stream(record, input.after) }; + } + + async killExec(input: { id: string }): Promise { + assertExecutionId(input.id); + const record = this.#recordFor(input.id); + if (!record) throw runtimeError("ENOENT", `no such execution: ${input.id}`); + await this.#cancel(record, "Execution cancelled.\n"); + } + + async disposeExec(input: { id: string }): Promise { + assertExecutionId(input.id); + const record = this.#recordFor(input.id); + if (!record) throw runtimeError("ENOENT", `no such execution: ${input.id}`); + if (record.status === "running") { + throw runtimeError("EEXEC_BUSY", `execution ${input.id} is still running`); + } + this.#host.db.transactionSync(() => { + this.#host.db.run( + `DELETE FROM workspace_runtime_events WHERE backend = ? AND execution_id = ?`, + this.#backendId, + input.id, + ); + this.#host.db.run( + `DELETE FROM workspace_runtime_executions WHERE backend = ? AND id = ?`, + this.#backendId, + input.id, + ); + }); + this.#records.delete(input.id); + } + + async close(): Promise { + this.#closed = true; + if (this.#pendingStarts > 0) { + await new Promise((resolve) => this.#pendingStartWaiters.add(resolve)); + } + const records = [...this.#records.values()]; + const finalizations = records.map((record) => + record.status === "running" + ? this.#cancel(record, "Execution cancelled because its Workspace closed.\n") + : record.finalization, + ); + await Promise.allSettled(finalizations); + for (const record of records) this.#close(record); + this.#records.clear(); + } + + get #backendId(): string { + return this.#options.id ?? "isolate-javascript"; + } + + async #cancel(record: ExecutionRecord, message: string): Promise { + if (record.finalization) return record.finalization; + if (record.status !== "running") return; + const finalization = (async () => { + record.control?.cancel(); + try { + await record.bridge?.cancelAndDrain(); + } catch (error) { + this.#finish(record, "failed", [ + { + id: record.id, + seq: record.events.length + 1, + name: "stderr", + value: new TextEncoder().encode( + `${error instanceof Error ? error.message : String(error)}\n`, + ), + }, + { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, + ]); + return; + } + this.#finish(record, "cancelled", [ + { + id: record.id, + seq: record.events.length + 1, + name: "stderr", + value: new TextEncoder().encode(message), + }, + { id: record.id, seq: record.events.length + 2, name: "exit", value: 130 }, + ]); + })(); + record.finalization = finalization; + return finalization; + } + + #recordFor(id: string): ExecutionRecord | undefined { + const cached = this.#records.get(id); + if (cached) return cached; + const row = this.#host.db.one<{ status: ExecutionRecord["status"] }>( + `SELECT status FROM workspace_runtime_executions WHERE backend = ? AND id = ?`, + this.#backendId, + id, + ); + if (!row) return undefined; + const events = this.#host.db + .all<{ seq: number; name: WorkspaceRuntimeEvent["name"]; payload: Uint8Array }>( + `SELECT seq, name, payload FROM workspace_runtime_events + WHERE backend = ? AND execution_id = ? ORDER BY seq`, + this.#backendId, + id, + ) + .map((event) => decodeEvent(id, event.seq, event.name, event.payload)); + const record: ExecutionRecord = { + id, + status: row.status, + events, + subscribers: new Set(), + }; + if (record.status === "running") { + // Every live execution is cached before its Dynamic Worker starts. A + // durable running row without a cached record is therefore an orphan + // left by restart or a failed terminal transaction. + this.#records.set(id, record); + this.#finish(record, "failed", [ + { + id, + seq: record.events.length + 1, + name: "stderr", + value: new TextEncoder().encode( + "Execution was interrupted when its Workspace runtime restarted or before its terminal state was persisted.\n", + ), + }, + { id, seq: record.events.length + 2, name: "exit", value: 1 }, + ]); + } + return record; + } + + #complete( + record: ExecutionRecord, + outcome: { result?: unknown; logs?: string[]; error?: string }, + ): Promise { + if (record.finalization) return record.finalization; + if (record.status !== "running") return Promise.resolve(); + const finalization = this.#completeOnce(record, outcome); + record.finalization = finalization; + return finalization; + } + + async #completeOnce( + record: ExecutionRecord, + outcome: { result?: unknown; logs?: string[]; error?: string }, + ) { + try { + await record.bridge?.cancelAndDrain(); + } catch (error) { + this.#finish(record, "failed", [ + { + id: record.id, + seq: record.events.length + 1, + name: "stderr", + value: new TextEncoder().encode( + `${error instanceof Error ? error.message : String(error)}\n`, + ), + }, + { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, + ]); + return; + } + try { + for (const log of outcome.logs ?? []) { + const stderr = log.startsWith("[warn] ") || log.startsWith("[error] "); + this.#append(record, { + id: record.id, + seq: record.events.length + 1, + name: stderr ? "stderr" : "stdout", + value: new TextEncoder().encode(`${log}\n`), + }); + } + if (outcome.error !== undefined) { + this.#finish(record, "failed", [ + { + id: record.id, + seq: record.events.length + 1, + name: "stderr", + value: new TextEncoder().encode( + `${truncateUtf8(outcome.error, Math.max(0, this.#options.maxLogBytes - 1))}\n`, + ), + }, + { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, + ]); + return; + } + const result = outcome.result ?? null; + try { + assertRuntimeValue(result); + assertEncodedSize(result, this.#options.maxResultBytes, "result"); + this.#finish(record, "completed", [ + { id: record.id, seq: record.events.length + 1, name: "result", value: result }, + { id: record.id, seq: record.events.length + 2, name: "exit", value: 0 }, + ]); + } catch (error) { + this.#finish(record, "failed", [ + { + id: record.id, + seq: record.events.length + 1, + name: "stderr", + value: new TextEncoder().encode( + `${error instanceof Error ? error.message : String(error)}\n`, + ), + }, + { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, + ]); + } + } catch (error) { + this.#finish(record, "failed", [ + { + id: record.id, + seq: record.events.length + 1, + name: "stderr", + value: new TextEncoder().encode( + `Execution finalization failed: ${error instanceof Error ? error.message : String(error)}\n`, + ), + }, + { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, + ]); + } + } + + #stream(record: ExecutionRecord, after?: number | "tail") { + const afterSeq = after === "tail" ? (record.events.at(-1)?.seq ?? 0) : (after ?? 0); + let released = false; + const subscriber: ExecutionSubscriber = { + controller: undefined as never, + index: record.events.findIndex((event) => event.seq > afterSeq), + release: () => { + if (released) return; + released = true; + const remaining = (this.#activeStreams.get(record.id) ?? 1) - 1; + if (remaining === 0) this.#activeStreams.delete(record.id); + else this.#activeStreams.set(record.id, remaining); + }, + }; + if (subscriber.index === -1) subscriber.index = record.events.length; + return new ReadableStream({ + start: (controller) => { + subscriber.controller = controller; + const activeStreams = this.#activeStreams.get(record.id) ?? 0; + if (activeStreams >= this.#options.maxExecutionSubscribers) { + controller.error( + runtimeError( + "EEXEC_BUSY", + `execution ${record.id} has too many active event subscribers`, + ), + ); + return; + } + this.#activeStreams.set(record.id, activeStreams + 1); + if (record.status === "running") record.subscribers.add(subscriber); + }, + pull: () => this.#pump(record, subscriber), + cancel: () => { + record.subscribers.delete(subscriber); + subscriber.release(); + }, + }); + } + + #append(record: ExecutionRecord, event: WorkspaceRuntimeEvent) { + this.#persistEvent(record.id, event); + this.#publishEvent(record, event); + } + + #finish( + record: ExecutionRecord, + status: Exclude, + events: WorkspaceRuntimeEvent[], + ) { + record.persistenceFailed = false; + let settledStatus = status; + let settledEvents = events; + try { + this.#host.db.transactionSync(() => { + this.#host.db.run( + `UPDATE workspace_runtime_executions + SET status = ?, finished_at = ? + WHERE backend = ? AND id = ?`, + status, + Date.now(), + this.#backendId, + record.id, + ); + for (const event of events) this.#persistEvent(record.id, event); + }); + } catch { + // The execution must settle even if durable storage is unavailable. + // Keep its terminal record for same-session replay; reconnect repairs + // the remaining durable `running` row. + record.persistenceFailed = true; + settledStatus = "failed"; + settledEvents = [ + { + id: record.id, + seq: record.events.length + 1, + name: "stderr", + value: new TextEncoder().encode( + "Execution failed because its terminal state could not be persisted.\n", + ), + }, + { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, + ]; + } + record.status = settledStatus; + record.events.push(...settledEvents); + for (const subscriber of [...record.subscribers]) this.#pump(record, subscriber); + this.#close(record); + if (!record.persistenceFailed) { + try { + this.#prune(); + } catch { + // Retention cleanup is retried on the next operation. + } + } + } + + #persistEvent(executionId: string, event: WorkspaceRuntimeEvent) { + this.#host.db.run( + `INSERT INTO workspace_runtime_events (backend, execution_id, seq, name, payload) + VALUES (?, ?, ?, ?, ?)`, + this.#backendId, + executionId, + event.seq, + event.name, + encodeEvent(event), + ); + } + + #publishEvent(record: ExecutionRecord, event: WorkspaceRuntimeEvent) { + record.events.push(event); + for (const subscriber of [...record.subscribers]) this.#pump(record, subscriber); + } + + #pump(record: ExecutionRecord, subscriber: ExecutionSubscriber) { + try { + while ( + subscriber.index < record.events.length && + (subscriber.controller.desiredSize ?? 0) > 0 + ) { + subscriber.controller.enqueue(record.events[subscriber.index++]); + } + if (subscriber.index >= record.events.length && record.status !== "running") { + record.subscribers.delete(subscriber); + subscriber.release(); + subscriber.controller.close(); + } + } catch { + record.subscribers.delete(subscriber); + subscriber.release(); + } + } + + #close(record: ExecutionRecord) { + for (const subscriber of [...record.subscribers]) this.#pump(record, subscriber); + record.control = undefined; + record.bridge = undefined; + if (record.admitted) { + record.admitted = false; + this.#activeExecutions = Math.max(0, this.#activeExecutions - 1); + } + if (record.status !== "running" && !record.persistenceFailed) { + this.#records.delete(record.id); + } + while (this.#records.size > this.#options.maxRetainedExecutions) { + const completed = [...this.#records.values()].find((item) => item.status !== "running"); + if (!completed) break; + this.#records.delete(completed.id); + } + } + + #prune() { + const cutoff = Date.now() - this.#options.retentionMs; + this.#host.db.transactionSync(() => { + this.#host.db.run( + `DELETE FROM workspace_runtime_events + WHERE backend = ? AND execution_id IN ( + SELECT id FROM workspace_runtime_executions + WHERE backend = ? AND status != 'running' AND finished_at < ? + )`, + this.#backendId, + this.#backendId, + cutoff, + ); + this.#host.db.run( + `DELETE FROM workspace_runtime_executions + WHERE backend = ? AND status != 'running' AND finished_at < ?`, + this.#backendId, + cutoff, + ); + const excess = this.#host.db.all<{ id: string }>( + `SELECT id FROM workspace_runtime_executions + WHERE backend = ? AND status != 'running' + ORDER BY finished_at DESC, created_at DESC, rowid DESC + LIMIT -1 OFFSET ?`, + this.#backendId, + this.#options.maxRetainedExecutions, + ); + for (const { id } of excess) { + this.#host.db.run( + `DELETE FROM workspace_runtime_events WHERE backend = ? AND execution_id = ?`, + this.#backendId, + id, + ); + this.#host.db.run( + `DELETE FROM workspace_runtime_executions WHERE backend = ? AND id = ?`, + this.#backendId, + id, + ); + } + }); + } +} + +function encodeEvent(event: WorkspaceRuntimeEvent): Uint8Array { + if (event.name === "stdout" || event.name === "stderr") return event.value; + return new TextEncoder().encode(JSON.stringify(event.value)); +} + +function decodeEvent( + id: string, + seq: number, + name: WorkspaceRuntimeEvent["name"], + payload: Uint8Array, +): WorkspaceRuntimeEvent { + if (name === "stdout" || name === "stderr") return { id, seq, name, value: payload }; + const value = JSON.parse(new TextDecoder().decode(payload)) as unknown; + if (name === "exit") return { id, seq, name, value: Number(value) }; + assertRuntimeValue(value); + return { id, seq, name: "result", value }; +} + +function startJavaScriptExecution(options: { + loader: WorkspaceRuntimeLoader; + modules: Record; + entryName: string; + input: WorkspaceRuntimeValue; + bridge: WorkspaceRuntimeBridge; + timeoutMs: number; + globalOutbound: Fetcher | null; + compatibilityDate: string; + compatibilityFlags: string[]; + maxLogBytes: number; + maxLogEvents: number; + maxResultBytes: number; + maxSourceBytes: number; + onComplete(outcome: { result?: unknown; logs?: string[]; error?: string }): void | Promise; +}): ActiveControl { + const modules = { + ...options.modules, + "workspace-runtime-runner.js": runtimeWorkerModule( + options.entryName, + options.maxLogBytes, + options.maxLogEvents, + options.maxResultBytes, + ), + }; + assertLoaderGraph(modules, options.maxSourceBytes); + const worker = options.loader.load({ + compatibilityDate: options.compatibilityDate, + compatibilityFlags: options.compatibilityFlags, + limits: { cpuMs: options.timeoutMs }, + mainModule: "workspace-runtime-runner.js", + modules, + globalOutbound: options.globalOutbound, + }); + let entrypoint: JavaScriptEntrypoint; + try { + entrypoint = worker.getEntrypoint(undefined, { + limits: { cpuMs: options.timeoutMs }, + }) as JavaScriptEntrypoint; + } catch (error) { + disposeQuietly(worker as { [Symbol.dispose]?: () => void }); + throw error; + } + let cancelled = false; + let disposed = false; + let timer: ReturnType | undefined; + const dispose = () => { + if (disposed) return; + disposed = true; + disposeQuietly(entrypoint); + disposeQuietly(worker as { [Symbol.dispose]?: () => void }); + }; + let cancelExecution!: (error: Error) => void; + const cancellation = new Promise((_, reject) => { + cancelExecution = reject; + }); + const execution = Promise.race([ + Promise.resolve().then(() => entrypoint.evaluate(options.input, options.bridge)), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("JavaScript execution timed out")), + options.timeoutMs, + ); + }), + cancellation, + ]); + const completion = execution + .then(async (outcome) => { + if (timer !== undefined) clearTimeout(timer); + dispose(); + await options.onComplete(outcome); + }) + .catch(async (error) => { + if (timer !== undefined) clearTimeout(timer); + dispose(); + if (!cancelled) { + await options.onComplete({ + error: String(error).includes("hung and would never generate a response") + ? "JavaScript execution timed out" + : error instanceof Error + ? error.message + : String(error), + }); + } + }) + .finally(() => { + if (timer !== undefined) clearTimeout(timer); + dispose(); + }); + return { + completion, + cancel() { + if (cancelled) return; + cancelled = true; + if (timer !== undefined) clearTimeout(timer); + cancelExecution(new Error("JavaScript execution cancelled")); + dispose(); + }, + }; +} + +function runtimeWorkerModule( + entryName: string, + maxLogBytes: number, + maxLogEvents: number, + maxResultBytes: number, +) { + return ` + import { WorkerEntrypoint } from "cloudflare:workers"; + import { install } from "workspace-capabilities.js"; + + export default class extends WorkerEntrypoint { + async evaluate(input, host) { + const logs = []; + const encoder = new TextEncoder(); + let logBytes = 0; + let logsTruncated = false; + const capture = (prefix, args) => { + if (logsTruncated) return; + if (logs.length >= ${maxLogEvents - 1}) { + logs.push("...[logs truncated]"); + logsTruncated = true; + return; + } + const line = prefix + args.map(String).join(" "); + const bytes = encoder.encode(line); + const remaining = ${maxLogBytes} - logBytes; + if (bytes.byteLength + 1 <= remaining) { + logs.push(line); + logBytes += bytes.byteLength + 1; + return; + } + const marker = encoder.encode("...[logs truncated]"); + const available = remaining - marker.byteLength - 1; + if (available >= 0) { + let prefix = bytes.slice(0, available); + let partial = ""; + while (prefix.byteLength > 0) { + try { + partial = new TextDecoder("utf-8", { fatal: true }).decode(prefix); + break; + } catch { + prefix = prefix.slice(0, -1); + } + } + logs.push(partial + "...[logs truncated]"); + logBytes += prefix.byteLength + marker.byteLength + 1; + } + logsTruncated = true; + }; + console.log = (...args) => capture("", args); + console.info = (...args) => capture("", args); + console.warn = (...args) => capture("[warn] ", args); + console.error = (...args) => capture("[error] ", args); + install(host); + try { + const module = await import(${JSON.stringify(entryName)}); + const result = typeof module.default === "function" + ? await module.default(input) + : module.default ?? null; + if (encoder.encode(JSON.stringify(result)).byteLength > ${maxResultBytes}) { + throw new Error("Workspace runtime result exceeds ${maxResultBytes} bytes."); + } + return { result, logs }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const bytes = encoder.encode(message); + let prefix = bytes.slice(0, ${maxLogBytes}); + while (prefix.byteLength > 0) { + try { + return { error: new TextDecoder("utf-8", { fatal: true }).decode(prefix), logs }; + } catch { + prefix = prefix.slice(0, -1); + } + } + return { error: "", logs }; + } + } + } + `; +} + +function assertLoaderGraph( + modules: Record, + maxSourceBytes: number, +) { + const entries = Object.values(modules); + if (entries.length > 256) { + throw new Error("Workspace JavaScript loader graph exceeds 256 modules."); + } + const bytes = entries.reduce( + (total, value) => + total + + new TextEncoder().encode(typeof value === "string" ? value : (value.js ?? "")).byteLength, + 0, + ); + if (bytes > maxSourceBytes) { + throw new Error(`Workspace JavaScript loader graph exceeds ${maxSourceBytes} source bytes.`); + } +} + +function truncateUtf8(value: string, maxBytes: number) { + const bytes = new TextEncoder().encode(value); + if (bytes.byteLength <= maxBytes) return value; + let prefix = bytes.slice(0, maxBytes); + while (prefix.byteLength > 0) { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(prefix); + } catch { + prefix = prefix.slice(0, -1); + } + } + return ""; +} + +function assertEncodedSize(value: WorkspaceRuntimeValue, maxBytes: number, name: string) { + const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength; + if (bytes > maxBytes) { + throw new Error(`Workspace runtime ${name} exceeds ${maxBytes} bytes.`); + } +} + +function assertPositiveFinite(value: number, name: string) { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`IsolateJavaScriptBackend ${name} must be a positive finite number.`); + } +} + +function assertPositiveInteger(value: number, name: string) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`IsolateJavaScriptBackend ${name} must be a positive integer.`); + } +} + +function assertExecutionId(id: string) { + if (typeof id !== "string" || id.length === 0) { + throw new Error("Workspace runtime execution id must be a non-empty string."); + } + if (new TextEncoder().encode(id).byteLength > 256) { + throw new Error("Workspace runtime execution id exceeds 256 bytes."); + } +} + +function addColumnIfMissing( + host: WorkspaceModuleBackendHost, + table: string, + column: string, + declaration: string, +) { + const columns = host.db.all<{ name: string }>(`PRAGMA table_info(${table})`); + if (!columns.some((item) => item.name === column)) { + host.db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${declaration}`); + } +} + +function runtimeError(code: string, message: string): Error & { code: string } { + const error = new Error(message) as Error & { code: string }; + error.code = code; + return error; +} + +function disposeQuietly(value: { [Symbol.dispose]?: () => void }) { + try { + value[Symbol.dispose]?.(); + } catch {} +} diff --git a/packages/computer/src/backends/javascript/module-graph.ts b/packages/computer/src/backends/javascript/module-graph.ts new file mode 100644 index 00000000..7c7605f4 --- /dev/null +++ b/packages/computer/src/backends/javascript/module-graph.ts @@ -0,0 +1,385 @@ +import { parse } from "acorn"; + +import type { WorkspaceRuntimeCapability } from "../../runtime/capability.js"; +import type { WorkspaceRuntimeLoader } from "../../runtime/types.js"; + +export type JavaScriptModuleMap = WorkspaceRuntimeLoader extends { + load(code: { modules: infer Modules }): unknown; +} + ? Modules + : never; + +const ENTRY_BASENAME = "__workspace_entry__.js"; +const RUNNER_MODULE = "workspace-runtime-runner.js"; +const CAPABILITIES_MODULE = "workspace-capabilities.js"; +const TRUSTED_MODULES = ["node:fs", "node:fs/promises", "ws:git", "ws:artifacts"] as const; + +export interface BuildModuleGraphOptions { + source: string; + cwd: string; + capability: WorkspaceRuntimeCapability; + configuredModules: Record; + trustedModuleNames?: string[]; + maxSourceBytes: number; + maxCapabilityBytes: number; + maxModules?: number; + maxDepth?: number; +} + +export async function buildModuleGraph(options: BuildModuleGraphOptions) { + const cwd = normalizeCwd(await options.capability.resolveConfined(options.cwd, true)); + const entryPath = `${cwd === "/" ? "" : cwd}/${ENTRY_BASENAME}`; + const entryName = moduleName(entryPath); + const modules: Record = Object.assign(Object.create(null), { + [entryName]: options.source, + [CAPABILITIES_MODULE]: capabilitiesModule(options.maxCapabilityBytes), + }); + const seen = new Set(); + const directories = new Set([directoryName(entryName)]); + let totalBytes = new TextEncoder().encode(options.source).byteLength; + const maxModules = options.maxModules ?? 128; + const maxDepth = options.maxDepth ?? 32; + const trustedModuleNames = new Set(TRUSTED_MODULES); + for (const name of options.trustedModuleNames ?? []) { + if ( + !/^ws:[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || + TRUSTED_MODULES.includes(name as (typeof TRUSTED_MODULES)[number]) + ) { + throw new Error( + `Trusted module ${JSON.stringify(name)} must use a unique simple reserved ws:* name.`, + ); + } + trustedModuleNames.add(name); + } + + async function visit(path: string, source: string, depth: number): Promise { + if (depth > maxDepth) throw new Error(`Workspace JavaScript import depth exceeds ${maxDepth}.`); + const name = moduleName(path); + if (seen.has(name)) return; + seen.add(name); + directories.add(directoryName(name)); + if (seen.size > maxModules) { + throw new Error(`Workspace JavaScript module graph exceeds ${maxModules} modules.`); + } + + for (const specifier of imports(source)) { + if (trustedModuleNames.has(specifier)) continue; + if (specifier === CAPABILITIES_MODULE) { + throw new Error(`Module ${JSON.stringify(specifier)} is reserved for Workspace internals.`); + } + if (specifier.startsWith("ws:")) { + throw new Error(`Unknown trusted Workspace module ${JSON.stringify(specifier)}.`); + } + if (specifier.startsWith(".")) { + const resolved = resolveRelative(path, specifier); + const childName = moduleName(resolved); + if (isInternalModuleName(childName)) { + throw new Error( + `Module ${JSON.stringify(childName)} is reserved for Workspace internals.`, + ); + } + if (seen.has(childName)) continue; + const stat = await options.capability.stat(resolved); + if (totalBytes + stat.size > options.maxSourceBytes) { + throw new Error( + `Workspace JavaScript module graph exceeds ${options.maxSourceBytes} source bytes.`, + ); + } + const child = await options.capability.readFile(resolved); + totalBytes += new TextEncoder().encode(child).byteLength; + if (totalBytes > options.maxSourceBytes) { + throw new Error( + `Workspace JavaScript module graph exceeds ${options.maxSourceBytes} source bytes.`, + ); + } + modules[childName] = child; + await visit(resolved, child, depth + 1); + continue; + } + if (specifier.startsWith("/")) { + throw new Error( + `Absolute JavaScript import ${JSON.stringify(specifier)} is not supported; use a relative Workspace import.`, + ); + } + if (!Object.hasOwn(options.configuredModules, specifier)) { + throw new Error( + `Module ${JSON.stringify(specifier)} is not configured for the isolate-javascript backend.`, + ); + } + } + } + + await visit(entryPath, options.source, 0); + + for (const specifier of Object.keys(options.configuredModules)) { + if ( + trustedModuleNames.has(specifier) || + specifier.startsWith("ws:") || + specifier === ENTRY_BASENAME || + specifier === RUNNER_MODULE || + specifier === CAPABILITIES_MODULE || + specifier.includes("/") + ) { + throw new Error( + `Configured module ${JSON.stringify(specifier)} uses a reserved module name.`, + ); + } + } + + // node:* specifiers use protocol-style resolution and therefore need exact + // module-map keys rather than the importer-directory aliases used by ws:*. + modules["node:fs/promises"] = { js: nodeFsPromisesModule() }; + modules["node:fs"] = { js: nodeFsModule() }; + + for (const directory of directories) { + const prefix = directory ? `${directory}/` : ""; + const toCapabilities = relativeModule(directory, CAPABILITIES_MODULE); + modules[`${prefix}ws:git`] = { js: gitModule(toCapabilities) }; + modules[`${prefix}ws:artifacts`] = { js: artifactsModule(toCapabilities) }; + for (const specifier of options.trustedModuleNames ?? []) { + modules[`${prefix}${specifier}`] = { + js: trustedModule(toCapabilities, specifier), + }; + } + for (const [specifier, source] of Object.entries(options.configuredModules)) { + const key = `${prefix}${specifier}`; + if (key in modules) { + throw new Error( + `Configured module ${JSON.stringify(specifier)} collides with ${JSON.stringify(key)}.`, + ); + } + modules[key] = { js: source }; + } + } + + return { entryName, modules }; +} + +function imports(source: string): string[] { + const ast = parse(source, { ecmaVersion: "latest", sourceType: "module" }) as unknown as { + body: unknown[]; + }; + const found: string[] = []; + walk(ast, (node) => { + const item = node as { type?: string; source?: { type?: string; value?: unknown } }; + if ( + item.type === "ImportDeclaration" || + item.type === "ExportNamedDeclaration" || + item.type === "ExportAllDeclaration" + ) { + if (typeof item.source?.value === "string") found.push(item.source.value); + } + if (item.type === "ImportExpression") { + if (item.source?.type !== "Literal" || typeof item.source.value !== "string") { + throw new Error("Workspace JavaScript dynamic imports must use a string literal."); + } + found.push(item.source.value); + } + }); + return found; +} + +function walk(value: unknown, visit: (node: unknown) => void): void { + if (value === null || typeof value !== "object") return; + visit(value); + for (const child of Object.values(value as Record)) { + if (Array.isArray(child)) for (const item of child) walk(item, visit); + else walk(child, visit); + } +} + +function normalizeCwd(cwd: string) { + if (!cwd.startsWith("/")) throw new Error("Workspace JavaScript cwd must be absolute."); + const parts: string[] = []; + for (const part of cwd.split("/")) { + if (!part || part === ".") continue; + if (part === "..") parts.pop(); + else parts.push(part); + } + return `/${parts.join("/")}`; +} + +function resolveRelative(importer: string, specifier: string) { + const base = importer.slice(0, importer.lastIndexOf("/")) || "/"; + const parts = `${base}/${specifier}`.split("/"); + const resolved: string[] = []; + for (const part of parts) { + if (!part || part === ".") continue; + if (part === "..") resolved.pop(); + else resolved.push(part); + } + return `/${resolved.join("/")}`; +} + +function relativeModule(fromDirectory: string, target: string) { + if (!fromDirectory) return `./${target}`; + return `${"../".repeat(fromDirectory.split("/").length)}${target}`; +} + +function moduleName(path: string) { + return path.replace(/^\/+/, ""); +} + +function directoryName(name: string) { + const slash = name.lastIndexOf("/"); + return slash === -1 ? "" : name.slice(0, slash); +} + +function isInternalModuleName(name: string) { + return ( + name === CAPABILITIES_MODULE || + name === RUNNER_MODULE || + name === ENTRY_BASENAME || + name.endsWith(`/${CAPABILITIES_MODULE}`) || + name.endsWith(`/${RUNNER_MODULE}`) || + name.split("/").at(-1)?.startsWith("ws:") === true + ); +} + +function capabilitiesModule(maxCapabilityBytes: number) { + return ` + let host; + const callKey = Symbol.for("cloudflare.workspace.runtime.call"); + const filesystemMethods = new Set([ + "readFile", "readFileBytes", "writeFile", "mkdir", "rm", "chmod", + "symlink", "readlink", "readdir", "readdirWithFileTypes", "stat", "lstat", "exists" + ]); + export function install(value) { + host = value; + globalThis[callKey] = filesystemCall; + } + async function filesystemCall(namespace, method, args) { + if (namespace !== "fs" || !filesystemMethods.has(method)) { + throw new Error("The internal Workspace filesystem dispatcher only accepts node:fs operations"); + } + return call(namespace, method, args); + } + export async function call(namespace, method, args) { + if (!host) throw new Error("Workspace capabilities are not installed"); + const request = JSON.stringify(args.map(encode)); + if (new TextEncoder().encode(request).byteLength > ${maxCapabilityBytes}) { + throw new Error("Workspace capability request exceeds ${maxCapabilityBytes} bytes."); + } + const raw = await host.call(namespace + "." + method, request); + const payload = JSON.parse(String(raw)); + if (payload.error !== undefined) { + const detail = typeof payload.error === "string" ? { message: payload.error } : payload.error; + const error = new Error(detail.message); + if (detail.code !== undefined) error.code = detail.code; + if (detail.path !== undefined) error.path = detail.path; + throw error; + } + return decode(payload.result); + } + function wrap(type, fields) { + return { __workspace_codec__: { version: 1, type, ...fields } }; + } + function encode(value) { + if (value instanceof Uint8Array) return wrap("bytes", { data: Array.from(value) }); + if (Array.isArray(value)) return wrap("array", { items: value.map(encode) }); + if (value && typeof value === "object") return wrap("object", { entries: Object.entries(value).map(([key, child]) => [key, encode(child)]) }); + return value; + } + function decode(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + if (Object.keys(value).length !== 1 || !("__workspace_codec__" in value)) throw new Error("Invalid Workspace codec envelope"); + const codec = value.__workspace_codec__; + if (!codec || codec.version !== 1) throw new Error("Invalid Workspace codec envelope"); + if (codec.type === "bytes") { + if (!Array.isArray(codec.data) || !codec.data.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) throw new Error("Invalid Workspace byte value"); + return new Uint8Array(codec.data); + } + if (codec.type === "array" && Array.isArray(codec.items)) return codec.items.map(decode); + if (codec.type === "object" && Array.isArray(codec.entries)) return Object.fromEntries(codec.entries.map(([key, child]) => [key, decode(child)])); + throw new Error("Invalid Workspace codec envelope"); + } + `; +} + +function proxyModule(capabilitiesImport: string, namespace: string, methods: string[]) { + return ` + import { call } from ${JSON.stringify(capabilitiesImport)}; + ${methods.map((method) => `export const ${method} = (...args) => call(${JSON.stringify(namespace)}, ${JSON.stringify(method)}, args);`).join("\n")} + `; +} + +function trustedModule(capabilitiesImport: string, specifier: string) { + return ` + import { call as hostCall } from ${JSON.stringify(capabilitiesImport)}; + export const call = (method, ...args) => hostCall(${JSON.stringify(`trusted/${specifier}`)}, "call", [method, ...args]); + `; +} + +function nodeFsPromisesModule() { + return ` + const callKey = Symbol.for("cloudflare.workspace.runtime.call"); + const invoke = (method, args) => { + const call = globalThis[callKey]; + if (!call) throw new Error("Workspace filesystem capability is not installed"); + return call("fs", method, args); + }; + const encoding = (options) => typeof options === "string" ? options : options?.encoding; + export const readFile = (path, options) => { + const requested = encoding(options); + if (requested === undefined || requested === null) return invoke("readFileBytes", [path]); + if (requested === "utf8" || requested === "utf-8") return invoke("readFile", [path]); + return Promise.reject(new TypeError("Workspace node:fs readFile supports only utf8 encoding")); + }; + export const writeFile = (path, data, options) => invoke("writeFile", [path, data, options]); + export const mkdir = (path, options) => invoke("mkdir", [path, options]); + export const rm = (path, options) => invoke("rm", [path, options]); + export const chmod = (path, mode) => invoke("chmod", [path, mode]); + export const symlink = (target, path) => invoke("symlink", [target, path]); + export const readlink = (path) => invoke("readlink", [path]); + export const readdir = async (path = ".", options) => { + if (!options?.withFileTypes) return invoke("readdir", [path]); + const entries = await invoke("readdirWithFileTypes", [path]); + return entries.map((entry) => dirent(entry.name, entry)); + }; + export const stat = async (path) => stats(await invoke("stat", [path])); + export const lstat = async (path) => stats(await invoke("lstat", [path])); + export const access = async (path) => { + if (!await invoke("exists", [path])) { + const error = new Error("ENOENT: no such file or directory, access '" + path + "'"); + error.code = "ENOENT"; + error.path = path; + throw error; + } + }; + function stats(value) { + return Object.assign({}, value, { + isFile: () => value.isFile, + isDirectory: () => value.isDirectory, + isSymbolicLink: () => value.isSymbolicLink, + }); + } + function dirent(name, value) { + return { + name, + isFile: () => value.isFile, + isDirectory: () => value.isDirectory, + isSymbolicLink: () => value.isSymbolicLink, + }; + } + const promises = { readFile, writeFile, mkdir, rm, chmod, symlink, readlink, readdir, stat, lstat, access }; + export default promises; + `; +} + +function nodeFsModule() { + return `${nodeFsPromisesModule()}\nexport { default as promises } from "node:fs/promises";`; +} + +function gitModule(capabilitiesImport: string) { + return proxyModule(capabilitiesImport, "git", ["clone", "diff", "status", "log", "cli"]); +} + +function artifactsModule(capabilitiesImport: string) { + return proxyModule(capabilitiesImport, "artifacts", [ + "create", + "get", + "list", + "importArtifact", + "deleteArtifact", + ]); +} diff --git a/packages/computer/src/backends/worker/entrypoint.ts b/packages/computer/src/backends/worker/entrypoint.ts index 1f69467b..b40779e2 100644 --- a/packages/computer/src/backends/worker/entrypoint.ts +++ b/packages/computer/src/backends/worker/entrypoint.ts @@ -92,8 +92,8 @@ export class ShellWorker< Env extends ShellWorkerEnv = ShellWorkerEnv, > extends WorkerEntrypoint { // Subclasses override to change the default cwd. - // just-bash's Python and JavaScript commands require - // node:worker_threads and cannot run in workerd. + // ECMAScript module execution uses workspace.runtime; just-bash's Python and JavaScript + // commands require node:worker_threads and cannot run in workerd. protected readonly shellOptions: ShellWorkerOptions = {}; readonly #executions = new Map(); diff --git a/packages/computer/src/index.ts b/packages/computer/src/index.ts index c1aa32ff..da33c58e 100644 --- a/packages/computer/src/index.ts +++ b/packages/computer/src/index.ts @@ -64,15 +64,18 @@ export type { WorkspaceModuleBackendHandle, WorkspaceModuleBackendHost, WorkspaceRegisteredBackend, + WorkspaceRuntimeAccess, WorkspaceRuntimeDisposeOptions, WorkspaceRuntimeEvent, WorkspaceRuntimeExecHandle, WorkspaceRuntimeExecOptions, WorkspaceRuntimeGetOptions, WorkspaceRuntimeKillOptions, + WorkspaceRuntimeLoader, WorkspaceRuntimeResult, WorkspaceRuntimeStatus, WorkspaceRuntimeValue, + WorkspaceTrustedModule, } from "./runtime/types.js"; export { decodeRuntimeEvents, encodeRuntimeEvent } from "./runtime/wire.js"; export { type RawShellValue, type ShellValue, sh, shellQuote } from "./sh.js"; diff --git a/packages/computer/src/runtime/bridge.test.ts b/packages/computer/src/runtime/bridge.test.ts new file mode 100644 index 00000000..202aa793 --- /dev/null +++ b/packages/computer/src/runtime/bridge.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { WorkspaceRuntimeBridge } from "./bridge.js"; +import type { WorkspaceRuntimeCapability } from "./capability.js"; + +const encoder = new TextEncoder(); +const args = JSON.stringify(["run", "value"]); + +function bridge(limits: { + maxCalls?: number; + maxTotalRequestBytes?: number; + maxTotalResponseBytes?: number; +}) { + return new WorkspaceRuntimeBridge({} as WorkspaceRuntimeCapability, { + ...limits, + trustedModules: { + "ws:test": { + async call() { + return "ok"; + }, + }, + }, + }); +} + +async function message(response: Promise) { + return (JSON.parse(await response) as { error?: { message?: string } }).error?.message; +} + +describe("WorkspaceRuntimeBridge cumulative limits", () => { + it("accepts the configured call count and rejects the next call", async () => { + const target = bridge({ maxCalls: 2 }); + await expect(message(target.call("trusted/ws:test.call", args))).resolves.toBeUndefined(); + await expect(message(target.call("trusted/ws:test.call", args))).resolves.toBeUndefined(); + await expect(message(target.call("trusted/ws:test.call", args))).resolves.toContain( + "exceeds 2 capability calls", + ); + }); + + it("accepts requests at the cumulative byte boundary and rejects the next request", async () => { + const bytes = encoder.encode(args).byteLength; + const target = bridge({ maxTotalRequestBytes: bytes * 2 }); + await expect(message(target.call("trusted/ws:test.call", args))).resolves.toBeUndefined(); + await expect(message(target.call("trusted/ws:test.call", args))).resolves.toBeUndefined(); + await expect(message(target.call("trusted/ws:test.call", args))).resolves.toContain( + `requests exceed ${bytes * 2} bytes`, + ); + }); + + it("accepts responses at the cumulative byte boundary and rejects the next response", async () => { + const sample = await bridge({}).call("trusted/ws:test.call", args); + const bytes = encoder.encode(sample).byteLength; + const target = bridge({ maxTotalResponseBytes: bytes * 2 }); + await expect(message(target.call("trusted/ws:test.call", args))).resolves.toBeUndefined(); + await expect(message(target.call("trusted/ws:test.call", args))).resolves.toBeUndefined(); + await expect(message(target.call("trusted/ws:test.call", args))).resolves.toContain( + `responses exceed ${bytes * 2} bytes`, + ); + }); +}); diff --git a/packages/computer/src/runtime/bridge.ts b/packages/computer/src/runtime/bridge.ts new file mode 100644 index 00000000..3ca127dc --- /dev/null +++ b/packages/computer/src/runtime/bridge.ts @@ -0,0 +1,505 @@ +import { RpcTarget } from "cloudflare:workers"; + +import type { ArtifactClient } from "../artifacts/index.js"; +import type { GitClient } from "../git/index.js"; +import type { WorkspaceRuntimeCapability } from "./capability.js"; +import type { WorkspaceTrustedModule } from "./types.js"; + +export class WorkspaceRuntimeBridge extends RpcTarget { + readonly #capability: WorkspaceRuntimeCapability; + readonly #git: GitClient | undefined; + readonly #artifacts: ArtifactClient | undefined; + readonly #trustedModules: Record; + readonly #allowGitNetwork: boolean; + readonly #allowArtifactNetwork: boolean; + readonly #maxPayloadBytes: number; + readonly #maxCallDurationMs: number; + readonly #maxConcurrentCalls: number; + readonly #maxCalls: number; + readonly #maxTotalRequestBytes: number; + readonly #maxTotalResponseBytes: number; + readonly #inFlight = new Set>(); + readonly #abortControllers = new Set(); + #cancelled = false; + #callTimedOut = false; + #calls = 0; + #requestBytes = 0; + #responseBytes = 0; + + constructor( + capability: WorkspaceRuntimeCapability, + integrations: { + git?: GitClient; + artifacts?: ArtifactClient; + trustedModules?: Record; + allowGitNetwork?: boolean; + allowArtifactNetwork?: boolean; + maxPayloadBytes?: number; + maxCallDurationMs?: number; + maxConcurrentCalls?: number; + maxCalls?: number; + maxTotalRequestBytes?: number; + maxTotalResponseBytes?: number; + } = {}, + ) { + super(); + this.#capability = capability; + this.#git = integrations.git; + this.#artifacts = integrations.artifacts; + this.#trustedModules = integrations.trustedModules ?? {}; + this.#allowGitNetwork = integrations.allowGitNetwork ?? false; + this.#allowArtifactNetwork = integrations.allowArtifactNetwork ?? false; + this.#maxPayloadBytes = integrations.maxPayloadBytes ?? 1024 * 1024; + this.#maxCallDurationMs = integrations.maxCallDurationMs ?? 30_000; + this.#maxConcurrentCalls = integrations.maxConcurrentCalls ?? 16; + this.#maxCalls = integrations.maxCalls ?? 256; + this.#maxTotalRequestBytes = integrations.maxTotalRequestBytes ?? 8 * 1024 * 1024; + this.#maxTotalResponseBytes = integrations.maxTotalResponseBytes ?? 8 * 1024 * 1024; + } + + call(name: string, argsJson: string): Promise { + const requestBytes = new TextEncoder().encode(argsJson).byteLength; + const reject = (message: string) => + Promise.resolve(encodeBoundedError(new Error(message), this.#maxPayloadBytes)); + if (this.#cancelled) return reject("Workspace execution is being cancelled."); + if (requestBytes > this.#maxPayloadBytes) { + return reject(`Workspace capability request exceeds ${this.#maxPayloadBytes} bytes.`); + } + if (this.#inFlight.size >= this.#maxConcurrentCalls) { + return reject( + `Workspace execution exceeds ${this.#maxConcurrentCalls} concurrent capability calls.`, + ); + } + if (this.#calls >= this.#maxCalls) { + return reject(`Workspace execution exceeds ${this.#maxCalls} capability calls.`); + } + if (this.#requestBytes + requestBytes > this.#maxTotalRequestBytes) { + return reject( + `Workspace execution capability requests exceed ${this.#maxTotalRequestBytes} bytes.`, + ); + } + this.#calls += 1; + this.#requestBytes += requestBytes; + const abort = new AbortController(); + this.#abortControllers.add(abort); + const deadline = Date.now() + this.#maxCallDurationMs; + const operation = encodeCall(async () => { + const encodedArgs = JSON.parse(argsJson) as unknown[]; + const args = encodedArgs.map(decodeBridgeValue); + if (name.startsWith("git.")) return this.#callGit(name.slice(4), args); + if (name.startsWith("artifacts.")) return this.#callArtifacts(name.slice(10), args); + if (name.startsWith("trusted/")) { + return this.#callTrusted(name, args, { signal: abort.signal, deadline }); + } + const operation = name.startsWith("fs.") ? name.slice(3) : name; + switch (operation) { + case "readFile": + return this.#capability.readFile(String(args[0])); + case "readFileBytes": + return this.#capability.readFileBytes(String(args[0])); + case "stat": + return this.#capability.stat(String(args[0])); + case "lstat": + return this.#capability.lstat(String(args[0])); + case "exists": + return this.#capability.exists(String(args[0])); + case "readlink": + return this.#capability.readlink(String(args[0])); + case "readdir": + return this.#capability.readdir(args[0] === undefined ? "." : String(args[0])); + case "readdirWithFileTypes": + return this.#capability.readdirWithFileTypes( + args[0] === undefined ? "." : String(args[0]), + ); + case "find": + return this.#capability.find( + args[0] === undefined ? "." : String(args[0]), + args[1] === undefined ? undefined : String(args[1]), + ); + case "glob": + return this.#capability.glob(String(args[0])); + case "ls": + return this.#capability.ls(args[0] === undefined ? "." : String(args[0])); + case "grep": + return this.#capability.grep( + String(args[0]), + args[1] === undefined ? "." : String(args[1]), + args[2] as { ignoreCase?: boolean } | undefined, + ); + case "writeFile": + await this.#capability.writeFileNode( + String(args[0]), + decodeBytes(args[1]), + args[2] as { flag?: string } | undefined, + ); + return null; + case "mkdir": + await this.#capability.mkdir( + String(args[0]), + args[1] as { recursive?: boolean } | undefined, + ); + return null; + case "rm": + await this.#capability.rm( + String(args[0]), + args[1] as { recursive?: boolean; force?: boolean } | undefined, + ); + return null; + case "chmod": + await this.#capability.chmod(String(args[0]), Number(args[1])); + return null; + case "symlink": + await this.#capability.symlink(String(args[0]), String(args[1])); + return null; + default: + throw new Error(`Unknown Workspace code operation ${JSON.stringify(name)}.`); + } + }, this.#maxPayloadBytes); + const call = withDeadline(operation, this.#maxCallDurationMs, this.#maxPayloadBytes, () => { + this.#callTimedOut = true; + abort.abort(new Error("Workspace capability call timed out.")); + }); + // The caller gets a bounded response, but terminal execution waits for + // the accepted host operation itself. This prevents a late mutation + // after an exit event when the underlying API cannot be aborted. + this.#inFlight.add(operation); + void operation.finally(() => { + this.#inFlight.delete(operation); + this.#abortControllers.delete(abort); + }); + return call.then((response) => { + const bytes = new TextEncoder().encode(response).byteLength; + if (this.#responseBytes + bytes > this.#maxTotalResponseBytes) { + return encodeBoundedError( + new Error( + `Workspace execution capability responses exceed ${this.#maxTotalResponseBytes} bytes.`, + ), + this.#maxPayloadBytes, + ); + } + this.#responseBytes += bytes; + return response; + }); + } + + async cancelAndDrain(): Promise { + this.#cancelled = true; + for (const controller of this.#abortControllers) { + controller.abort(new Error("Workspace execution is being cancelled.")); + } + await Promise.allSettled([...this.#inFlight]); + if (this.#callTimedOut) { + throw new Error("A Workspace capability call did not settle before its deadline."); + } + } + + async #callTrusted( + name: string, + args: unknown[], + context: { signal: AbortSignal; deadline: number }, + ) { + const suffix = ".call"; + const specifier = name.endsWith(suffix) ? name.slice("trusted/".length, -suffix.length) : ""; + const trusted = this.#trustedModules[specifier]; + if (!trusted) { + throw new Error(`Unknown trusted Workspace module call ${JSON.stringify(name)}.`); + } + const method = String(args[0]); + const callArgs = args.slice(1); + assertBridgeValues(callArgs); + const result = await trusted.call(method, callArgs, context); + assertBridgeValues([result]); + return result; + } + + async #callGit(name: string, args: unknown[]) { + if (!this.#git) throw new Error("Workspace Git is not configured for this execution."); + switch (name) { + case "clone": + this.#requireWrite("Git clone"); + this.#requireGitNetwork("Git clone"); + return this.#git + .clone( + (await this.#gitOptions(args[0], true)) as unknown as Parameters[0], + ) + .then(() => null); + case "diff": + return this.#git.diff( + (await this.#gitOptions(args[0])) as Parameters[0], + ); + case "status": + return this.#git.status( + (await this.#gitOptions(args[0])) as Parameters[0], + ); + case "log": + return this.#git.log((await this.#gitOptions(args[0])) as Parameters[0]); + case "cli": { + this.#requireWrite("Git CLI"); + const input = (args[0] ?? {}) as Parameters[0]; + assertSafeGitCliArguments(input.argv); + if (isGitNetworkCommand(input.argv)) this.#requireGitNetwork("Git CLI network command"); + return this.#git.cli({ + ...input, + cwd: await this.#capability.resolveConfined(input.cwd ?? ".", true), + }); + } + default: + throw new Error(`Unknown Workspace Git operation ${JSON.stringify(name)}.`); + } + } + + #callArtifacts(name: string, args: unknown[]) { + if (!this.#artifacts) + throw new Error("Workspace Artifacts are not configured for this execution."); + switch (name) { + case "create": + this.#requireWrite("Artifacts create"); + return this.#artifacts.create( + String(args[0]), + args[1] as Parameters[1], + ); + case "get": + return this.#artifacts.get(String(args[0])); + case "list": + return this.#artifacts.list(); + case "importArtifact": + this.#requireWrite("Artifacts import"); + if (!this.#allowArtifactNetwork) { + throw new Error( + "Artifacts import requires IsolateJavaScriptBackend allowArtifactNetwork: true.", + ); + } + return this.#artifacts.import( + String(args[0]), + args[1] as Parameters[1], + args[2] as Parameters[2], + ); + case "deleteArtifact": + this.#requireWrite("Artifacts delete"); + return this.#artifacts.delete(String(args[0])); + default: + throw new Error(`Unknown Workspace Artifacts operation ${JSON.stringify(name)}.`); + } + } + + async #gitOptions(value: unknown, allowMissing = false): Promise> { + const options = (value ?? {}) as Record; + return { + ...options, + dir: await this.#capability.resolveConfined( + typeof options.dir === "string" ? options.dir : ".", + allowMissing, + ), + }; + } + + #requireGitNetwork(operation: string) { + if (!this.#allowGitNetwork) { + throw new Error(`${operation} requires IsolateJavaScriptBackend allowGitNetwork: true.`); + } + } + + #requireWrite(operation: string) { + if (this.#capability.access !== "read-write") { + throw new Error(`${operation} requires Workspace write access.`); + } + } +} + +function assertSafeGitCliArguments(argv: string[] | undefined) { + if ( + argv?.some( + (argument) => + argument === "-C" || + argument.startsWith("-C") || + argument === "--git-dir" || + argument.startsWith("--git-dir=") || + argument === "--work-tree" || + argument.startsWith("--work-tree="), + ) + ) { + throw new Error( + "Git CLI path overrides are not available inside a confined Workspace runtime.", + ); + } +} + +function isGitNetworkCommand(argv: string[] | undefined) { + const networkCommands = new Set(["clone", "fetch", "pull", "push", "ls-remote", "submodule"]); + return argv?.some((argument) => networkCommands.has(argument.toLowerCase())) ?? false; +} + +function assertBridgeValues( + values: unknown[], +): asserts values is import("./types.js").WorkspaceRuntimeValue[] { + const seen = new Set(); + const visit = (value: unknown): void => { + if ( + value === null || + typeof value === "boolean" || + typeof value === "string" || + (typeof value === "number" && Number.isFinite(value)) + ) + return; + if (typeof value !== "object") + throw new Error("Trusted module values must be JSON-compatible."); + if (seen.has(value)) throw new Error("Trusted module values must be acyclic."); + seen.add(value); + if (Array.isArray(value)) for (const item of value) visit(item); + else { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error("Trusted module values must contain only plain objects."); + } + for (const item of Object.values(value as Record)) visit(item); + } + seen.delete(value); + }; + for (const value of values) visit(value); +} + +function decodeBytes(value: unknown): string | Uint8Array { + return value instanceof Uint8Array ? value : String(value); +} + +function encodeBridgeValue(value: unknown): unknown { + const wrap = (type: string, fields: Record) => ({ + __workspace_codec__: { version: 1, type, ...fields }, + }); + if (value instanceof Uint8Array) return wrap("bytes", { data: Array.from(value) }); + if (Array.isArray(value)) return wrap("array", { items: value.map(encodeBridgeValue) }); + if (value && typeof value === "object") { + return wrap("object", { + entries: Object.entries(value).map(([key, child]) => [key, encodeBridgeValue(child)]), + }); + } + return value; +} + +function decodeBridgeValue(value: unknown): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const record = value as Record; + if (Object.keys(record).length !== 1 || !("__workspace_codec__" in record)) { + throw new Error("Invalid Workspace codec envelope."); + } + const codec = record.__workspace_codec__ as Record | null; + if (codec?.version !== 1) throw new Error("Invalid Workspace codec envelope."); + if (codec.type === "bytes") { + if (!isByteArray(codec.data)) throw new Error("Invalid Workspace byte value."); + return new Uint8Array(codec.data); + } + if (codec.type === "array" && Array.isArray(codec.items)) { + return codec.items.map(decodeBridgeValue); + } + if (codec.type === "object" && Array.isArray(codec.entries)) { + return Object.fromEntries( + codec.entries.map((entry) => { + if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") { + throw new Error("Invalid Workspace object entry."); + } + return [entry[0], decodeBridgeValue(entry[1])]; + }), + ); + } + throw new Error("Invalid Workspace codec envelope."); +} + +function isByteArray(value: unknown): value is number[] { + return ( + Array.isArray(value) && + value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255) + ); +} + +function withDeadline( + call: Promise, + timeoutMs: number, + maxPayloadBytes: number, + onTimeout: () => void, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { + onTimeout(); + resolve( + encodeBoundedError(new Error("Workspace capability call timed out."), maxPayloadBytes), + ); + }, timeoutMs); + }); + return Promise.race([call, timeout]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +} + +async function encodeCall(run: () => Promise, maxPayloadBytes: number) { + try { + const result = await run(); + assertResponseWithin(result, maxPayloadBytes); + const encoded = JSON.stringify({ result: encodeBridgeValue(result) }); + if (new TextEncoder().encode(encoded).byteLength > maxPayloadBytes) { + throw new Error(`Workspace capability response exceeds ${maxPayloadBytes} bytes.`); + } + return encoded; + } catch (error) { + return encodeBoundedError(error, maxPayloadBytes); + } +} + +function assertResponseWithin(value: unknown, maxBytes: number) { + let bytes = 0; + let nodes = 0; + const visit = (item: unknown): void => { + nodes += 1; + if (nodes > 4096) throw new Error("Workspace capability response has too many values."); + if (typeof item === "string") bytes += item.length * 3; + else if (item instanceof Uint8Array) bytes += item.byteLength * 4; + else if (typeof item === "number" || typeof item === "boolean" || item === null) bytes += 16; + else if (Array.isArray(item)) for (const child of item) visit(child); + else if (item && typeof item === "object") { + for (const [key, child] of Object.entries(item)) { + bytes += key.length * 3; + visit(child); + } + } + if (bytes > maxBytes) { + throw new Error(`Workspace capability response exceeds ${maxBytes} bytes.`); + } + }; + visit(value); +} + +function encodeBoundedError(error: unknown, maxPayloadBytes: number) { + const value = error as { code?: unknown; path?: unknown }; + const message = error instanceof Error ? error.message : String(error); + const detailed = JSON.stringify({ + error: { + message, + ...(typeof value?.code === "string" ? { code: value.code } : {}), + ...(typeof value?.path === "string" ? { path: value.path } : {}), + }, + }); + const encoder = new TextEncoder(); + if (encoder.encode(detailed).byteLength <= maxPayloadBytes) return detailed; + + let budget = Math.max(0, maxPayloadBytes - 40); + while (budget >= 0) { + const bounded = JSON.stringify({ error: { message: truncateUtf8(message, budget) } }); + if (encoder.encode(bounded).byteLength <= maxPayloadBytes) return bounded; + budget -= 1; + } + return JSON.stringify({ error: { message: "Capability call failed" } }); +} + +function truncateUtf8(value: string, maxBytes: number) { + const bytes = new TextEncoder().encode(value); + if (bytes.byteLength <= maxBytes) return value; + let prefix = bytes.slice(0, maxBytes); + while (prefix.byteLength > 0) { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(prefix); + } catch { + prefix = prefix.slice(0, -1); + } + } + return ""; +} diff --git a/packages/computer/src/runtime/capability.test.ts b/packages/computer/src/runtime/capability.test.ts new file mode 100644 index 00000000..6d1c348c --- /dev/null +++ b/packages/computer/src/runtime/capability.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; +import { assertRuntimeValue, WorkspaceRuntimeCapability } from "./capability.js"; +import type { WorkspaceRuntimeFilesystem } from "./types.js"; + +function fakeFs(): WorkspaceRuntimeFilesystem { + return { + readFile: vi.fn() as unknown as WorkspaceRuntimeFilesystem["readFile"], + stat: vi.fn().mockResolvedValue({ size: 0 }), + lstat: vi.fn().mockResolvedValue({ isSymbolicLink: false }), + readlink: vi.fn(), + readdir: vi.fn().mockResolvedValue([]), + find: vi.fn().mockResolvedValue([]), + ls: vi.fn().mockResolvedValue([]), + grep: vi.fn().mockResolvedValue([]), + writeFile: vi.fn(), + mkdir: vi.fn(), + rm: vi.fn(), + chmod: vi.fn(), + symlink: vi.fn(), + }; +} + +describe("WorkspaceRuntimeCapability", () => { + it("resolves relative and absolute paths under its root", () => { + const workspace = new WorkspaceRuntimeCapability(fakeFs(), "/workspace", "read"); + expect(workspace.resolve("notes/a.txt")).toBe("/workspace/notes/a.txt"); + expect(workspace.resolve("/workspace/notes/./a.txt")).toBe("/workspace/notes/a.txt"); + }); + + it("rejects traversal outside its root", () => { + const workspace = new WorkspaceRuntimeCapability(fakeFs(), "/workspace", "read"); + expect(() => workspace.resolve("../secret.txt")).toThrow("stay under /workspace"); + expect(() => workspace.resolve("/etc/passwd")).toThrow("stay under /workspace"); + }); + + it("blocks every mutation for a read-only capability", async () => { + const fs = fakeFs(); + const workspace = new WorkspaceRuntimeCapability(fs, "/workspace", "read"); + await expect(workspace.writeFile("result.txt", "nope")).rejects.toThrow( + "write access is not available", + ); + await expect(workspace.mkdir("directory")).rejects.toThrow("write access is not available"); + await expect(workspace.rm("result.txt")).rejects.toThrow("write access is not available"); + await expect(workspace.chmod("result.txt", 0o600)).rejects.toThrow( + "write access is not available", + ); + await expect(workspace.symlink("target", "link")).rejects.toThrow( + "write access is not available", + ); + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(fs.mkdir).not.toHaveBeenCalled(); + expect(fs.rm).not.toHaveBeenCalled(); + expect(fs.chmod).not.toHaveBeenCalled(); + expect(fs.symlink).not.toHaveBeenCalled(); + }); + + it("creates parents and writes through a read-write capability", async () => { + const fs = fakeFs(); + const workspace = new WorkspaceRuntimeCapability(fs, "/workspace", "read-write"); + await workspace.writeFile("nested/result.txt", "ok"); + expect(fs.mkdir).toHaveBeenCalledWith("/workspace/nested", { recursive: true }); + expect(fs.writeFile).toHaveBeenCalledWith("/workspace/nested/result.txt", "ok"); + }); + + it("rejects files larger than the capability read ceiling before materializing them", async () => { + const fs = fakeFs(); + vi.mocked(fs.stat).mockResolvedValue({ size: 5 } as never); + const workspace = new WorkspaceRuntimeCapability(fs, "/workspace", "read", 4); + await expect(workspace.readFile("large.txt")).rejects.toThrow("exceeds 4 bytes"); + expect(fs.readFile).not.toHaveBeenCalled(); + }); + + it("bounds directory materialization and returns dirent metadata without follow-up stats", async () => { + const fs = fakeFs(); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: "a", isFile: true, isDirectory: false, isSymbolicLink: false }, + { name: "b", isFile: false, isDirectory: true, isSymbolicLink: false }, + { name: "c", isFile: true, isDirectory: false, isSymbolicLink: false }, + ]); + const workspace = new WorkspaceRuntimeCapability(fs, "/workspace", "read", 1024, 2); + + await expect(workspace.readdir(".")).rejects.toThrow("exceeds 2 entries"); + expect(fs.readdir).toHaveBeenCalledWith("/workspace", { limit: 3 }); + + vi.mocked(fs.readdir).mockResolvedValueOnce([ + { name: "a", isFile: true, isDirectory: false, isSymbolicLink: false }, + ]); + await expect(workspace.readdirWithFileTypes(".")).resolves.toEqual([ + { name: "a", isFile: true, isDirectory: false, isSymbolicLink: false }, + ]); + }); + + it("applies node writeFile parent and exclusive-create semantics", async () => { + const fs = fakeFs(); + const workspace = new WorkspaceRuntimeCapability(fs, "/workspace", "read-write"); + const exists = Object.assign(new Error("exists"), { code: "EEXIST" }); + vi.mocked(fs.writeFile).mockRejectedValueOnce(exists); + await expect(workspace.writeFileNode("existing.txt", "nope", { flag: "wx" })).rejects.toBe( + exists, + ); + expect(fs.writeFile).toHaveBeenCalledWith("/workspace/existing.txt", "nope", { + exclusive: true, + }); + + const missing = Object.assign(new Error("missing"), { code: "ENOENT" }); + vi.mocked(fs.lstat).mockImplementation(async (path) => { + if (path === "/workspace/missing") throw missing; + return { isSymbolicLink: false } as never; + }); + await expect(workspace.writeFileNode("missing/file.txt", "nope")).rejects.toBe(missing); + }); + + it("preserves a relative symlink target after validating confinement", async () => { + const fs = fakeFs(); + const workspace = new WorkspaceRuntimeCapability(fs, "/workspace", "read-write"); + await workspace.symlink("../target", "dir/link"); + expect(fs.symlink).toHaveBeenCalledWith("../target", "/workspace/dir/link"); + }); + + it("rejects existing symlinks in the configured root and path", async () => { + const fs = fakeFs(); + vi.mocked(fs.lstat).mockImplementation(async (path) => + path === "/workspace/link" + ? ({ isSymbolicLink: true } as Awaited>) + : ({ isSymbolicLink: false } as Awaited>), + ); + const workspace = new WorkspaceRuntimeCapability(fs, "/workspace", "read-write"); + await expect(workspace.readFile("link/secret.txt")).rejects.toThrow( + "cannot traverse symbolic link /workspace/link", + ); + await expect(workspace.writeFile("link/result.txt", "nope")).rejects.toThrow( + "cannot traverse symbolic link /workspace/link", + ); + }); +}); + +describe("assertRuntimeValue", () => { + it("accepts JSON-compatible values", () => { + expect(() => assertRuntimeValue({ ok: true, values: [1, "two", null] })).not.toThrow(); + }); + + it("rejects non-finite numbers, class instances, and cycles", () => { + expect(() => assertRuntimeValue(Number.NaN)).toThrow("JSON-compatible"); + expect(() => assertRuntimeValue(new Date())).toThrow("plain objects"); + const cycle: Record = {}; + cycle.self = cycle; + expect(() => assertRuntimeValue(cycle)).toThrow("cannot contain cycles"); + }); +}); diff --git a/packages/computer/src/runtime/capability.ts b/packages/computer/src/runtime/capability.ts new file mode 100644 index 00000000..96e8a500 --- /dev/null +++ b/packages/computer/src/runtime/capability.ts @@ -0,0 +1,279 @@ +import type { + WorkspaceRuntimeAccess, + WorkspaceRuntimeFilesystem, + WorkspaceRuntimeValue, +} from "./types.js"; + +export class WorkspaceRuntimeCapability { + readonly #fs: WorkspaceRuntimeFilesystem; + readonly #root: string; + readonly #access: WorkspaceRuntimeAccess; + readonly #maxReadBytes: number; + readonly #maxDirectoryEntries: number; + + constructor( + fs: WorkspaceRuntimeFilesystem, + root: string, + access: WorkspaceRuntimeAccess, + maxReadBytes = 1024 * 1024, + maxDirectoryEntries = 1024, + ) { + this.#fs = fs; + this.#root = normalizeRoot(root); + this.#access = access; + this.#maxReadBytes = maxReadBytes; + this.#maxDirectoryEntries = maxDirectoryEntries; + } + + get access(): WorkspaceRuntimeAccess { + return this.#access; + } + + async readFile(path: string) { + const resolved = await this.#resolveSafe(path); + await this.#assertReadableSize(resolved); + return this.#fs.readFile(resolved, "utf8"); + } + + async readFileBytes(path: string) { + const resolved = await this.#resolveSafe(path); + await this.#assertReadableSize(resolved); + const stream = await this.#fs.readFile(resolved); + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > this.#maxReadBytes) { + await reader.cancel("Workspace runtime file read limit exceeded"); + throw new Error(`Workspace runtime file read exceeds ${this.#maxReadBytes} bytes.`); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; + } + + async stat(path: string) { + return this.#fs.stat(await this.#resolveSafe(path)); + } + + async lstat(path: string) { + const resolved = this.resolve(path); + await this.#assertSafeComponents(resolved, false, true); + return this.#fs.lstat(resolved); + } + + async exists(path: string) { + try { + await this.stat(path); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + throw error; + } + } + + async readlink(path: string) { + const resolved = this.resolve(path); + await this.#assertSafeComponents(resolved, false, true); + return this.#fs.readlink(resolved); + } + + async readdir(path = ".") { + return (await this.#readdirEntries(path)).map((entry) => entry.name); + } + + async readdirWithFileTypes(path = ".") { + return (await this.#readdirEntries(path)).map((entry) => ({ + name: entry.name, + isFile: entry.isFile, + isDirectory: entry.isDirectory, + isSymbolicLink: entry.isSymbolicLink, + })); + } + + async #readdirEntries(path: string) { + const entries = await this.#fs.readdir(await this.#resolveSafe(path), { + limit: this.#maxDirectoryEntries + 1, + }); + if (entries.length > this.#maxDirectoryEntries) { + throw new Error(`Workspace runtime directory exceeds ${this.#maxDirectoryEntries} entries.`); + } + return entries; + } + + async find(directory = ".", pattern?: string) { + return this.#fs.find(await this.#resolveSafe(directory), pattern); + } + + async glob(pattern: string) { + await this.#assertSafeComponents(this.#root, false); + return this.#fs.find(this.#root, pattern); + } + + async ls(prefix = ".") { + return this.#fs.ls(await this.#resolveSafe(prefix, true)); + } + + async grep(pattern: string, path = ".", options?: { ignoreCase?: boolean }) { + return this.#fs.grep(pattern, await this.#resolveSafe(path), options); + } + + async writeFile(path: string, content: string | Uint8Array) { + this.#requireWrite(); + const resolved = await this.#resolveSafe(path, true); + const parent = resolved.slice(0, resolved.lastIndexOf("/")) || this.#root; + await this.#fs.mkdir(parent, { recursive: true }); + await this.#assertSafeComponents(parent, false); + await this.#fs.writeFile(resolved, content); + } + + async writeFileNode(path: string, content: string | Uint8Array, options?: { flag?: string }) { + this.#requireWrite(); + const flag = options?.flag ?? "w"; + if (flag !== "w" && flag !== "wx") { + throw new Error(`Workspace node:fs writeFile does not support flag ${JSON.stringify(flag)}.`); + } + const resolved = await this.#resolveSafe(path, true); + const parent = resolved.slice(0, resolved.lastIndexOf("/")) || this.#root; + await this.#assertSafeComponents(parent, false); + await this.#fs.writeFile(resolved, content, { exclusive: flag === "wx" }); + } + + async mkdir(path: string, options?: { recursive?: boolean }) { + this.#requireWrite(); + const resolved = await this.#resolveSafe(path, true); + await this.#fs.mkdir(resolved, options); + await this.#assertSafeComponents(resolved, false); + } + + async rm(path: string, options?: { recursive?: boolean; force?: boolean }) { + this.#requireWrite(); + return this.#fs.rm(await this.#resolveSafe(path, options?.force === true), options); + } + + async chmod(path: string, mode: number) { + this.#requireWrite(); + return this.#fs.chmod(await this.#resolveSafe(path), mode); + } + + async symlink(target: string, path: string) { + this.#requireWrite(); + const resolvedPath = await this.#resolveSafe(path, true); + const parent = resolvedPath.slice(0, resolvedPath.lastIndexOf("/")) || this.#root; + const candidateTarget = target.startsWith("/") ? target : `${parent}/${target}`; + await this.#resolveSafe(candidateTarget, true); + return this.#fs.symlink(target, resolvedPath); + } + + resolveConfined(path: string, allowMissing = false) { + return this.#resolveSafe(path, allowMissing); + } + + resolve(path: string) { + if (typeof path !== "string" || path.includes("\0")) { + throw new Error("Workspace code paths must be strings without NUL bytes."); + } + const candidate = path.startsWith("/") ? path : `${this.#root}/${path}`; + const parts: string[] = []; + for (const part of candidate.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") parts.pop(); + else parts.push(part); + } + const resolved = `/${parts.join("/")}`; + if (this.#root !== "/" && resolved !== this.#root && !resolved.startsWith(`${this.#root}/`)) { + throw new Error(`Workspace code paths must stay under ${this.#root}.`); + } + return resolved; + } + + async #resolveSafe(path: string, allowMissing = false) { + const resolved = this.resolve(path); + await this.#assertSafeComponents(resolved, allowMissing); + return resolved; + } + + async #assertSafeComponents(path: string, allowMissing: boolean, allowFinalSymlink = false) { + const parts = path.split("/").filter(Boolean); + let current = ""; + for (const [index, part] of parts.entries()) { + current += `/${part}`; + try { + const stat = await this.#fs.lstat(current); + const isFinal = index === parts.length - 1; + if (stat.isSymbolicLink && !(allowFinalSymlink && isFinal)) { + throw new Error(`Workspace code cannot traverse symbolic link ${current}.`); + } + } catch (error) { + if (allowMissing && errorCode(error) === "ENOENT") return; + throw error; + } + } + } + + async #assertReadableSize(path: string) { + const stat = await this.#fs.stat(path); + if (stat.size > this.#maxReadBytes) { + throw new Error(`Workspace runtime file read exceeds ${this.#maxReadBytes} bytes.`); + } + } + + #requireWrite() { + if (this.#access !== "read-write") { + throw new Error("Workspace code write access is not available."); + } + } +} + +function normalizeRoot(root: string) { + if (!root.startsWith("/")) throw new Error("Workspace code root must be absolute."); + const normalized = root.replace(/\/+$/, "") || "/"; + if (normalized.includes("\0") || normalized.split("/").includes("..")) { + throw new Error("Workspace code root must be normalized."); + } + return normalized; +} + +function errorCode(error: unknown) { + return typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : undefined; +} + +export function assertRuntimeValue(value: unknown): asserts value is WorkspaceRuntimeValue { + assertValue(value, new WeakSet()); +} + +function assertValue(value: unknown, seen: WeakSet): void { + if (value === null || typeof value === "boolean" || typeof value === "string") return; + if (typeof value === "number" && Number.isFinite(value)) return; + if (typeof value !== "object") { + throw new Error("Workspace code inputs and results must be JSON-compatible values."); + } + if (seen.has(value)) throw new Error("Workspace code inputs and results cannot contain cycles."); + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) assertValue(item, seen); + seen.delete(value); + return; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error("Workspace code inputs and results must use plain objects."); + } + for (const item of Object.values(value)) assertValue(item, seen); + seen.delete(value); +} diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index 00164173..7964de26 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -2,6 +2,17 @@ import type { SkippedEntry } from "@cloudflare/dofs"; import type { ExecEncoding, ExecSyncResult, KillSignal } from "../shell.js"; +export type WorkspaceRuntimeAccess = "read" | "read-write"; + +export interface WorkspaceTrustedModule { + /** Dispatch a call made through a host-installed reserved ws:* module. */ + call( + method: string, + args: WorkspaceRuntimeValue[], + context?: { signal: AbortSignal; deadline: number }, + ): Promise; +} + export type WorkspaceRuntimeValue = | null | boolean @@ -10,6 +21,65 @@ export type WorkspaceRuntimeValue = | WorkspaceRuntimeValue[] | { [key: string]: WorkspaceRuntimeValue }; +export interface WorkspaceRuntimeStat { + name: string; + inode: number; + mode: number; + mtime: number; + size: number; + isFile: boolean; + isDirectory: boolean; + isSymbolicLink: boolean; +} + +export interface WorkspaceRuntimeFilesystem { + readFile(path: string): Promise>; + readFile(path: string, encoding: "utf8"): Promise; + stat(path: string): Promise; + lstat(path: string): Promise; + readlink(path: string): Promise; + readdir( + path: string, + options?: { limit?: number }, + ): Promise< + Array<{ + name: string; + isFile: boolean; + isDirectory: boolean; + isSymbolicLink: boolean; + }> + >; + find(directory: string, pattern?: string): Promise>; + ls(prefix: string): Promise; + grep( + pattern: string, + path: string, + options?: { ignoreCase?: boolean }, + ): Promise>; + mkdir(path: string, options?: { recursive?: boolean }): Promise; + writeFile( + path: string, + content: string | Uint8Array, + options?: { exclusive?: boolean }, + ): Promise; + rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise; + chmod(path: string, mode: number): Promise; + symlink(target: string, path: string): Promise; +} + +export interface WorkspaceRuntimeLoader { + load(code: { + compatibilityDate: string; + compatibilityFlags?: string[]; + limits?: { cpuMs?: number }; + mainModule: string; + modules: Record; + globalOutbound?: Fetcher | null; + }): { + getEntrypoint(name?: string, options?: { limits?: { cpuMs?: number } }): unknown; + }; +} + export type WorkspaceRuntimeStatus = "completed" | "failed" | "cancelled"; type RuntimeChunk = E extends "utf8" ? string : Uint8Array; diff --git a/packages/computer/tests/script-runner-worker.ts b/packages/computer/tests/script-runner-worker.ts new file mode 100644 index 00000000..4a2f9216 --- /dev/null +++ b/packages/computer/tests/script-runner-worker.ts @@ -0,0 +1,269 @@ +import { DurableObject, RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; +import { IsolateJavaScriptBackend } from "../src/backends/javascript/index.js"; +import type { + DurableObjectStorageLike, + WorkspaceRuntimeValue, + WorkspaceStub, +} from "../src/index.js"; +import { Workspace } from "../src/index.js"; + +export interface Env { + HOST: DurableObjectNamespace; + LOADER: WorkerLoader; +} + +export class HostDO extends DurableObject { + readonly #workspace: Workspace; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.#workspace = new Workspace({ + storage: ctx.storage as unknown as DurableObjectStorageLike, + waitUntil: ctx.waitUntil.bind(ctx), + backends: [ + new IsolateJavaScriptBackend({ + loader: env.LOADER, + maxLogBytes: 64, + maxLogEvents: 4, + maxCapabilityBytes: 1024, + maxConcurrentCapabilityCalls: 2, + modules: { + "math-kit": "export const double = (value) => value * 2;", + }, + trustedModules: { + "ws:test-host": { + async call(method, args) { + if (method === "invalid-result") return new Date() as never; + if (method === "large-error") throw new Error("x".repeat(5000)); + if (method === "slow") { + await new Promise((resolve) => setTimeout(resolve, 20)); + return null; + } + if (method === "marker") { + return { + __workspace_codec__: { version: 1, type: "bytes", data: [1] }, + keep: true, + }; + } + return { method, args }; + }, + }, + }, + }), + ], + }); + } + + async writeFile(path: string, source: string) { + await this.#workspace.fs.mkdir(path.slice(0, path.lastIndexOf("/")) || "/workspace", { + recursive: true, + }); + await this.#workspace.fs.writeFile(path, source); + } + + async symlink(target: string, path: string) { + await this.#workspace.fs.mkdir(path.slice(0, path.lastIndexOf("/")) || "/", { + recursive: true, + }); + await this.#workspace.fs.symlink(target, path); + } + + getWorkspace(): WorkspaceStub { + return this.#workspace.stub(); + } + + readFile(path: string) { + return this.#workspace.fs.readFile(path, "utf8"); + } + + async runRuntime(input: { + source: string; + cwd?: string; + value?: WorkspaceRuntimeValue; + id?: string; + }) { + await this.#workspace.fs.mkdir("/workspace", { recursive: true }); + const handle = await this.#workspace.runtime.exec(input.source, { + backend: "isolate-javascript", + cwd: input.cwd, + input: input.value, + id: input.id, + encoding: "utf8", + }); + return { id: handle.id, result: await handle.result() }; + } + + async startRuntime(input: { source: string; id: string }) { + await this.#workspace.fs.mkdir("/workspace", { recursive: true }); + const handle = await this.#workspace.runtime.exec(input.source, { + backend: "isolate-javascript", + id: input.id, + }); + void handle.result().catch(() => undefined); + return handle.id; + } + + async getRuntime(id: string, resume?: "tail") { + try { + const handle = await this.#workspace.runtime.getExec(id, { + backend: "isolate-javascript", + encoding: "utf8", + resume, + }); + return { ok: true as const, result: await handle.result() }; + } catch (error) { + return { + ok: false as const, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + killRuntime(id: string) { + return this.#workspace.runtime.killExec(id, { backend: "isolate-javascript" }); + } + + disposeRuntime(id: string) { + return this.#workspace.runtime.disposeExec(id, { backend: "isolate-javascript" }); + } +} + +class ModuleProbeBridge extends RpcTarget { + read(path: string): string { + return `host:${path}`; + } +} + +export default class extends WorkerEntrypoint { + override async fetch(request: Request) { + const url = new URL(request.url); + const stub = this.env.HOST.get(this.env.HOST.idFromName("script-runner")); + + try { + if (url.pathname === "/module-probe") { + const worker = this.env.LOADER.load({ + compatibilityDate: "2026-06-17", + compatibilityFlags: ["nodejs_compat"], + mainModule: "runner.js", + modules: { + "runner.js": ` + import { WorkerEntrypoint } from "cloudflare:workers"; + import { install } from "workspace:capabilities"; + export default class extends WorkerEntrypoint { + async evaluate(bridge) { + install(bridge); + globalThis.__probeBridge = bridge; + const user = await import("./user.js"); + return user.default(); + } + } + `, + "workspace:capabilities": { + js: ` + let bridge; + export function install(value) { bridge = value; } + export function call(name, ...args) { + if (!bridge) throw new Error("Workspace capabilities are not installed"); + return bridge[name](...args); + } + `, + }, + "node:fs/promises": { + js: ` + export const tag = "trusted"; + export function readFile(path) { return globalThis.__probeBridge.read(path); } + `, + }, + "helper.js": `export const suffix = "relative";`, + "user.js": ` + import { readFile } from "node:fs/promises"; + import { suffix } from "./helper.js"; + export default async function run() { + const dynamic = await import("node:fs/promises"); + return [await readFile("/workspace/probe.txt"), suffix, dynamic.tag].join("|"); + } + `, + }, + globalOutbound: null, + }); + const entrypoint = worker.getEntrypoint() as unknown as { + evaluate(bridge: ModuleProbeBridge): Promise; + [Symbol.dispose]?: () => void; + }; + try { + return new Response(await entrypoint.evaluate(new ModuleProbeBridge())); + } finally { + entrypoint[Symbol.dispose]?.(); + (worker as unknown as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); + } + } + if (url.pathname === "/runtime") { + return Response.json( + await stub.runRuntime( + (await request.json()) as { + source: string; + cwd?: string; + value?: WorkspaceRuntimeValue; + id?: string; + }, + ), + ); + } + + if (url.pathname === "/runtime-start") { + return Response.json({ + id: await stub.startRuntime((await request.json()) as { source: string; id: string }), + }); + } + + if (url.pathname === "/runtime-get") { + const response = await stub.getRuntime( + url.searchParams.get("id") ?? "missing", + url.searchParams.get("resume") === "tail" ? "tail" : undefined, + ); + return response.ok + ? Response.json(response.result) + : Response.json({ error: response.error }, { status: 400 }); + } + + if (url.pathname === "/runtime-kill") { + await stub.killRuntime(url.searchParams.get("id") ?? "missing"); + return new Response(null, { status: 204 }); + } + + if (url.pathname === "/runtime-dispose") { + await stub.disposeRuntime(url.searchParams.get("id") ?? "missing"); + return new Response(null, { status: 204 }); + } + + if (url.pathname === "/write") { + await stub.writeFile( + url.searchParams.get("path") ?? "/workspace/script.js", + await request.text(), + ); + return new Response(null, { status: 204 }); + } + + if (url.pathname === "/symlink") { + await stub.symlink( + url.searchParams.get("target") ?? "/outside-secret.txt", + url.searchParams.get("path") ?? "/workspace/outside-link", + ); + return new Response(null, { status: 204 }); + } + + if (url.pathname === "/read") { + return new Response( + await stub.readFile(url.searchParams.get("path") ?? "/workspace/result.txt"), + ); + } + + return new Response("not found", { status: 404 }); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 400 }, + ); + } + } +} diff --git a/packages/computer/tests/script-runner.test.ts b/packages/computer/tests/script-runner.test.ts new file mode 100644 index 00000000..03323c01 --- /dev/null +++ b/packages/computer/tests/script-runner.test.ts @@ -0,0 +1,428 @@ +import { SELF } from "cloudflare:test"; + +async function write(path: string, source: string) { + const response = await SELF.fetch(`https://example.test/write?path=${encodeURIComponent(path)}`, { + method: "POST", + body: source, + }); + expect(response.status).toBe(204); +} + +async function symlink(target: string, path: string) { + const response = await SELF.fetch( + `https://example.test/symlink?target=${encodeURIComponent(target)}&path=${encodeURIComponent(path)}`, + { method: "POST" }, + ); + expect(response.status).toBe(204); +} + +async function read(path: string) { + const response = await SELF.fetch(`https://example.test/read?path=${encodeURIComponent(path)}`); + expect(response.status).toBe(200); + return response.text(); +} + +async function runtime(body: Record) { + return SELF.fetch("https://example.test/runtime", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("WorkspaceRuntime", () => { + it("resolves reserved, relative, and literal dynamic Worker Loader modules", async () => { + const response = await SELF.fetch("https://example.test/module-probe"); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(text).toBe("host:/workspace/probe.txt|relative|trusted"); + }); + + it("executes an ES module with configured and trusted modules", async () => { + const response = await runtime({ + source: ` + import { double } from "math-kit"; + import fs from "node:fs/promises"; + import { promises as nodeFs } from "node:fs"; + import * as git from "ws:git"; + import { call } from "ws:test-host"; + export default async function main(input) { + const value = double(input.value); + await fs.writeFile("/workspace/runtime-result.txt", String(value)); + const initialized = await git.cli({ argv: ["init"], cwd: "/workspace/repository" }); + return { + value, + persisted: await fs.readFile("/workspace/runtime-result.txt", "utf8"), + gitExitCode: initialized.exitCode, + trusted: await call("echo", input.value), + nodeFs: { + isFile: (await nodeFs.stat("/workspace/runtime-result.txt")).isFile(), + entries: await nodeFs.readdir("/workspace"), + }, + }; + } + `, + value: { value: 21 }, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "completed", + exitCode: 0, + value: { + value: 42, + persisted: "42", + gitExitCode: 0, + trusted: { method: "echo", args: [21] }, + nodeFs: { + isFile: true, + entries: expect.arrayContaining(["runtime-result.txt"]), + }, + }, + }, + }); + }); + + it("round-trips bytes and marker-shaped plain objects without codec collisions", async () => { + const response = await runtime({ + source: ` + import fs from "node:fs/promises"; + import { call } from "ws:test-host"; + export default async () => { + await fs.writeFile("/workspace/bytes.bin", new Uint8Array([0, 127, 255])); + const value = await fs.readFile("/workspace/bytes.bin"); + return { + isBytes: value instanceof Uint8Array, + bytes: Array.from(value), + marker: await call("marker"), + }; + }; + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "completed", + value: { + isBytes: true, + bytes: [0, 127, 255], + marker: { + __workspace_codec__: { version: 1, type: "bytes", data: [1] }, + keep: true, + }, + }, + }, + }); + }); + + it("bounds persisted console output including truncation markers and newlines", async () => { + const response = await runtime({ + source: `export default () => { console.log("🙂".repeat(256)); return true; };`, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + const payload = JSON.parse(text); + expect(payload.result.status).toBe("completed"); + expect(new TextEncoder().encode(payload.result.stdout).byteLength).toBeLessThanOrEqual(64); + expect(payload.result.stdout).toContain("logs truncated"); + }); + + it("bounds oversized trusted-module error responses", async () => { + const response = await runtime({ + source: ` + import { call } from "ws:test-host"; + export default () => call("large-error"); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + const payload = JSON.parse(text); + expect(payload.result.status).toBe("failed"); + expect(new TextEncoder().encode(payload.result.stderr).byteLength).toBeLessThanOrEqual(64); + }); + + it("bounds log event amplification independently of log bytes", async () => { + const response = await runtime({ + source: `export default () => { for (let i = 0; i < 20; i++) console.log(""); return true; };`, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + const payload = JSON.parse(text); + expect(payload.result.stdout.split("\n").filter(Boolean)).toEqual(["...[logs truncated]"]); + expect(payload.result.stdout.split("\n").length - 1).toBe(4); + }); + + it("bounds concurrent host capability calls", async () => { + const response = await runtime({ + source: ` + import { call } from "ws:test-host"; + export default async () => { + const settled = await Promise.allSettled([call("slow"), call("slow"), call("slow")]); + return settled.map((item) => item.status); + }; + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text).result.value).toEqual(["fulfilled", "fulfilled", "rejected"]); + }); + + it("rejects non-plain results from host trusted modules", async () => { + const response = await runtime({ + source: ` + import { call } from "ws:test-host"; + export default () => call("invalid-result"); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "failed", + stderr: expect.stringContaining("plain objects"), + }, + }); + }); + + it("does not expose unrestricted host operations through the node:fs dispatcher", async () => { + const response = await runtime({ + source: ` + export default async function () { + const call = globalThis[Symbol.for("cloudflare.workspace.runtime.call")]; + return call("fs", "find", ["/workspace"]); + } + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "failed", + stderr: expect.stringContaining("internal Workspace filesystem dispatcher"), + }, + }); + }); + + it("preserves supported node:fs write and relative-symlink semantics", async () => { + const response = await runtime({ + source: ` + import fs from "node:fs/promises"; + export default async function () { + await fs.mkdir("/workspace/links", { recursive: true }); + await fs.writeFile("/workspace/target.txt", "target"); + await fs.symlink("../target.txt", "/workspace/links/target"); + let exclusive; + try { await fs.writeFile("/workspace/target.txt", "overwrite", { flag: "wx" }); } + catch (error) { exclusive = error.code; } + let missingParent; + try { await fs.writeFile("/workspace/missing/file.txt", "nope"); } + catch (error) { missingParent = error.code; } + let unsupportedEncoding; + try { await fs.readFile("/workspace/target.txt", "base64"); } + catch (error) { unsupportedEncoding = error.message; } + return { + exclusive, + missingParent, + unsupportedEncoding, + link: await fs.readlink("/workspace/links/target"), + isLink: (await fs.lstat("/workspace/links/target")).isSymbolicLink(), + }; + } + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "completed", + value: { + exclusive: "EEXIST", + missingParent: "ENOENT", + unsupportedEncoding: expect.stringContaining("supports only utf8"), + link: "../target.txt", + isLink: true, + }, + }, + }); + }); + + it("confines trusted Git operations to the backend root", async () => { + const response = await runtime({ + source: ` + import { status } from "ws:git"; + export default () => status({ dir: "/" }); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text)).toMatchObject({ + result: { + status: "failed", + stderr: expect.stringContaining("must stay under /workspace"), + }, + }); + }); + + it("rejects Git CLI path overrides that bypass the runtime root", async () => { + const response = await runtime({ + source: ` + import { cli } from "ws:git"; + export default () => cli({ cwd: "/workspace", argv: ["-C", "/outside", "status"] }); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "failed", + stderr: expect.stringContaining("path overrides are not available"), + }, + }); + }); + + it("denies host-side Artifact import authority by default", async () => { + const response = await runtime({ + source: ` + import { importArtifact } from "ws:artifacts"; + export default () => importArtifact("repo", { url: "https://example.com/repo.git" }); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "failed", + stderr: expect.stringContaining("allowArtifac"), + }, + }); + }); + + it("denies host-side Git network authority by default", async () => { + const response = await runtime({ + source: ` + import { clone } from "ws:git"; + export default () => clone({ url: "https://example.com/repository.git", dir: "/workspace/repository" }); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "failed", + stderr: expect.stringContaining("allowGitNetwork"), + }, + }); + }); + + it("rejects trusted Git paths that traverse a symlink", async () => { + await write("/outside/repository/README.md", "outside"); + await symlink("/outside/repository", "/workspace/linked-repository"); + const response = await runtime({ + source: ` + import { status } from "ws:git"; + export default () => status({ dir: "/workspace/linked-repository" }); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text)).toMatchObject({ + result: { + status: "failed", + stderr: expect.stringContaining("cannot traverse symbolic link"), + }, + }); + }); + + it("loads transitive durable relative modules", async () => { + await write("/workspace/lib/math.js", "export const add = (a, b) => a + b;"); + await write( + "/workspace/task.js", + ` + import { add } from "./lib/math.js"; + import { writeFile } from "node:fs/promises"; + export default async function task(input) { + const value = add(input.a, input.b); + await writeFile("/workspace/module-result.txt", String(value)); + return value; + } + `, + ); + const response = await runtime({ + source: `import task from "./task.js"; export default task;`, + value: { a: 2, b: 5 }, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text)).toMatchObject({ result: { value: 7 } }); + expect(await read("/workspace/module-result.txt")).toBe("7"); + }); + + it("bounds thrown errors before transport and persistence", async () => { + const response = await runtime({ + source: `export default () => { throw new Error("🙂".repeat(1024)); };`, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + const payload = JSON.parse(text); + expect(payload.result.status).toBe("failed"); + expect(new TextEncoder().encode(payload.result.stderr).byteLength).toBeLessThanOrEqual(64); + }); + + it("supports start, kill, get, tail result, and dispose for isolate execution", async () => { + const id = `managed-${crypto.randomUUID()}`; + const start = await SELF.fetch("https://example.test/runtime-start", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id, + source: `export default async function () { await new Promise((resolve) => setTimeout(resolve, 10000)); }`, + }), + }); + expect(start.status).toBe(200); + const killed = await SELF.fetch( + `https://example.test/runtime-kill?id=${encodeURIComponent(id)}`, + { method: "POST" }, + ); + expect(killed.status).toBe(204); + const result = await SELF.fetch( + `https://example.test/runtime-get?id=${encodeURIComponent(id)}`, + ); + expect(result.status).toBe(200); + expect(await result.json()).toMatchObject({ + status: "cancelled", + exitCode: 130, + }); + const tail = await SELF.fetch( + `https://example.test/runtime-get?id=${encodeURIComponent(id)}&resume=tail`, + ); + expect(tail.status).toBe(200); + expect(await tail.json()).toMatchObject({ status: "cancelled", exitCode: 130 }); + const disposed = await SELF.fetch( + `https://example.test/runtime-dispose?id=${encodeURIComponent(id)}`, + { method: "POST" }, + ); + expect(disposed.status).toBe(204); + const missing = await SELF.fetch( + `https://example.test/runtime-get?id=${encodeURIComponent(id)}`, + ); + expect(missing.status).toBe(400); + }); +}); diff --git a/packages/computer/tests/tsconfig.json b/packages/computer/tests/tsconfig.json new file mode 100644 index 00000000..529a7f67 --- /dev/null +++ b/packages/computer/tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": [ + "@cloudflare/workers-types", + "@cloudflare/vitest-pool-workers", + "vitest/globals", + "node" + ] + }, + "include": ["./**/*.ts"] +} diff --git a/packages/computer/tests/wrangler.script-runner.jsonc b/packages/computer/tests/wrangler.script-runner.jsonc new file mode 100644 index 00000000..c74db27d --- /dev/null +++ b/packages/computer/tests/wrangler.script-runner.jsonc @@ -0,0 +1,11 @@ +{ + "name": "workspace-script-runner-tests", + "main": "./script-runner-worker.ts", + "compatibility_date": "2026-06-23", + "compatibility_flags": ["nodejs_compat", "experimental"], + "worker_loaders": [{ "binding": "LOADER" }], + "durable_objects": { + "bindings": [{ "name": "HOST", "class_name": "HostDO" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["HostDO"] }] +} diff --git a/packages/computer/vitest.config.script-runner.ts b/packages/computer/vitest.config.script-runner.ts new file mode 100644 index 00000000..6096c222 --- /dev/null +++ b/packages/computer/vitest.config.script-runner.ts @@ -0,0 +1,15 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./tests/wrangler.script-runner.jsonc" }, + }), + ], + test: { + globals: true, + include: ["tests/script-runner.test.ts"], + testTimeout: 60_000, + }, +});