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, + }, +});