diff --git a/.changeset/container-sync-report.md b/.changeset/container-sync-report.md new file mode 100644 index 00000000..4b39440f --- /dev/null +++ b/.changeset/container-sync-report.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +`ws:container`'s `exec` also returns `sync: { status, skipped, skippedCount, error? }`. `status` is `pending` when the container's file changes have not reached the Workspace, and `skipped` lists up to 100 paths the Workspace refused, such as files in a read-only mount, with `skippedCount` giving the full count. The docs now state that the sync is last-writer-wins: the container's changes replace files written in the Workspace while the command runs. diff --git a/.changeset/exec-tool-options.md b/.changeset/exec-tool-options.md new file mode 100644 index 00000000..43992570 --- /dev/null +++ b/.changeset/exec-tool-options.md @@ -0,0 +1,11 @@ +--- +"@cloudflare/computer": minor +--- + +`createAITools` takes an `exec` option that lists the backends the model can use, keyed by backend id: `exec: { "worker-javascript": { description: "Use for data work." } }`. Leave it out to use every backend the Workspace has. `{}` exposes a backend with nothing beyond its own description, and `exec: {}` means no exec tool. `createExecTool` takes the same map as `backends`, and `defaultBackend` goes away: with more than one backend the model must name one on every call. + +`WorkerShellBackend` and `ContainerBackend` now describe themselves to the model, as `WorkerJavaScriptBackend` does, so the default needs no descriptions. A backend that says nothing gets a one-line default instead of an error. + +`shell` still works and is deprecated. `shell: { backends }` becomes `exec: backends`, and its `defaultBackend` is ignored. Output limits stay on `createExecTool`. + +`createAITools` moves to its own entry point, `@cloudflare/computer/tools/ai-sdk`. `@cloudflare/computer/tools` keeps the individual `create*Tool` functions and `WorkspaceFileStore`. Change `import { createAITools } from "@cloudflare/computer/tools"` to `from "@cloudflare/computer/tools/ai-sdk"`. diff --git a/.changeset/exec-tool-review-fixes.md b/.changeset/exec-tool-review-fixes.md new file mode 100644 index 00000000..94612fcb --- /dev/null +++ b/.changeset/exec-tool-review-fixes.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +A `WorkspaceClient` from `getWorkspace()` now answers `runtime.backends()`, locally and over RPC, from a snapshot taken when the client is created. `createAITools({ workspace: await getWorkspace(this) })` therefore offers `exec` over every backend, and a callable backend keeps its `input` argument and module list. `ContainerBackend` describes network access that matches its `egress` setting, and `exec` takes precedence over the deprecated `shell` option. diff --git a/.changeset/exec-tool-single-backend.md b/.changeset/exec-tool-single-backend.md index 701465bc..6594ddf7 100644 --- a/.changeset/exec-tool-single-backend.md +++ b/.changeset/exec-tool-single-backend.md @@ -2,6 +2,6 @@ "@cloudflare/computer": minor --- -The `exec` tool offers only the arguments that can work. With one backend there is no `backend` argument, the tool always runs there, `defaultBackend` becomes optional, and the description talks about what that backend does rather than how to choose one. `input` appears only when a configured backend accepts it. +The `exec` tool offers only the arguments that can work. With one backend there is no `backend` argument, the tool always runs there, and the description talks about what that backend does rather than how to choose one. `input` appears only when a configured backend accepts it. -Each backend's entry now adds what the backend says about itself, read through `workspace.runtime.describe(id)`. For `WorkerJavaScriptBackend` that is its source language and every module code can import, so `shell: { backends: { "worker-javascript": {} } }` is enough and the module list the model reads cannot drift from `modules`. A backend `description` is required only for a backend that does not describe itself. +Each backend's entry now adds what the backend says about itself, read through `workspace.runtime.backends()`. For `WorkerJavaScriptBackend` that is its source language and every module code can import, so the module list the model reads cannot drift from `modules`. diff --git a/.changeset/isolate-values.md b/.changeset/isolate-values.md new file mode 100644 index 00000000..bf76fc94 --- /dev/null +++ b/.changeset/isolate-values.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +A `WorkerJavaScriptBackend` run that returns an object with `undefined` fields now completes with those fields dropped, as `JSON.stringify` does, instead of failing with "must be JSON-compatible values". A cyclic argument to a `node:fs` or host module call now fails with a clear "must be acyclic" error instead of a stack overflow. diff --git a/.changeset/js-no-execution-cap.md b/.changeset/js-no-execution-cap.md new file mode 100644 index 00000000..2b9cdab9 --- /dev/null +++ b/.changeset/js-no-execution-cap.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +`WorkerJavaScriptBackend` no longer caps concurrent executions, and the `maxConcurrentExecutions` option is removed. Its default of 24 sat above the platform's own limit of 10 concurrent Dynamic Workers per request, so runs 11 through 24 were admitted and then failed with a platform error anyway. Executions now start until the platform says no, and that error is the execution's error. Remove `maxConcurrentExecutions` from backend options. diff --git a/.changeset/modules.md b/.changeset/modules.md index 52f178a3..603c03c3 100644 --- a/.changeset/modules.md +++ b/.changeset/modules.md @@ -6,6 +6,6 @@ `ws:git` and `ws:artifacts` are no longer installed automatically. Add `createGitModule()` from `@cloudflare/computer/modules/git` and `createArtifactsModule()` from `@cloudflare/computer/modules/artifacts`. `node:fs` and `node:fs/promises` stay built in. -The backend describes its source language and every importable module for a model in `backend.description`, which `workspace.runtime.describe(id)` returns. +The backend describes its source language and every importable module for a model in `backend.description`, which `workspace.runtime.backends()` returns along with each backend's id and whether it is callable. To migrate, move `trustedModules` entries into `modules`, replacing any `call(method, args)` handler with one function per method. Replace `allowGitNetwork: true` with `createGitModule({ allowNetwork: true })` and `allowArtifactNetwork: true` with `createArtifactsModule({ allowNetwork: true })`. diff --git a/.changeset/native-rpc-modules.md b/.changeset/native-rpc-modules.md new file mode 100644 index 00000000..c815a6cc --- /dev/null +++ b/.changeset/native-rpc-modules.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +`WorkerJavaScriptBackend` passes `node:fs` and host module calls between the isolate and the Durable Object as real Workers RPC values instead of JSON text with a custom byte encoding. Byte arrays now count at their real size against `maxCapabilityBytes`, so a 900-byte write fits under a 1024-byte limit where it used to be rejected. Every call still goes through one host bridge that enforces call counts, concurrency, deadlines, and byte budgets, and it now rejects functions, RPC stubs, and cycles in a request before the host acts on it. diff --git a/.changeset/remote-client-assets.md b/.changeset/remote-client-assets.md new file mode 100644 index 00000000..64ef299d --- /dev/null +++ b/.changeset/remote-client-assets.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +A remote `WorkspaceClient` from `getWorkspace(stub)` reports `assets` as `undefined` when the Workspace has no assets publisher, as a local client does. `createAITools` built from a remote client no longer offers a `publish` tool that fails when called. diff --git a/.changeset/ws-container-module.md b/.changeset/ws-container-module.md new file mode 100644 index 00000000..0b7d0985 --- /dev/null +++ b/.changeset/ws-container-module.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +Add `createContainerModule()` in `@cloudflare/computer/modules/container`. Install it as `modules: { "ws:container": createContainerModule() }` on a `WorkerJavaScriptBackend`, and JavaScript can run shell commands in the Workspace's `ContainerBackend` with `import { exec } from "ws:container"`. The JavaScript backend fails to connect if that backend is missing or runs module source rather than shell commands. The container shares the Workspace's files, a canceled execution kills the command, and `exec` refuses to run on a read-only backend. The module describes itself, so the `exec` tool tells the model about it without extra configuration. diff --git a/README.md b/README.md index f44e8a16..11727db5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ SQLite and exposes one pluggable execution surface through - **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 host modules such as - `ws:git` and `ws:artifacts`. + `ws:git`, `ws:artifacts`, and `ws:container`. A Workspace may register multiple backends under stable IDs. `workspace.runtime.exec(source, { backend })` is the single execution diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index 0ae826c2..ee1a0235 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -4,16 +4,16 @@ Computer ships a ready-made tool set for agents that use a `Workspace`, once for | Library | Entry point | Factory | | --- | --- | --- | -| [AI SDK](https://github.com/vercel/ai) (`ai`) | `@cloudflare/computer/tools` | `createAITools` | +| [AI SDK](https://github.com/vercel/ai) (`ai`) | `@cloudflare/computer/tools/ai-sdk` | `createAITools` | | [pi](https://github.com/earendil-works/pi) (`@earendil-works/pi-ai`) | `@cloudflare/computer/tools/pi-ai` | `createPiTools` | | [TanStack AI](https://tanstack.com/ai) (`@tanstack/ai`) | `@cloudflare/computer/tools/tanstack-ai` | `createTanStackTools` | -All three take the same options and build the same tools, with the same names, descriptions, schemas, and limits. Only the shape they return differs. Each entry point imports only `zod` and its own library's types, so a pi agent never loads `ai` and an AI SDK agent never loads pi. The individual AI SDK `create*Tool` functions and `WorkspaceFileStore` also come from `@cloudflare/computer/tools`. +All three take the same options and build the same tools, with the same names, descriptions, schemas, and limits. Only the shape they return differs. Each entry point imports only `zod` and its own library's types, so a pi agent never loads `ai` and an AI SDK agent never loads pi. The individual AI SDK `create*Tool` functions and `WorkspaceFileStore` come from `@cloudflare/computer/tools`. The tools wrap three Workspace surfaces: - `workspace.fs` for file reads, writes, edits, searches, listings, and deletion; -- `workspace.runtime.exec` for command execution when the caller opts in; +- `workspace.runtime.exec` for running commands and code on the Workspace's backends; - `workspace.assets` for publishing generated files when an assets publisher is configured. ## What ships @@ -34,13 +34,13 @@ The tools wrap three Workspace surfaces: | `createPublishTool` | Publish a workspace file through `workspace.assets`. | | `WorkspaceFileStore` | Adapt `workspace.fs` to the store used by file tools. | -Every tool set names its tools `read`, `ls`, `find`, `grep`, `write`, `edit`, and `delete`. `exec` appears when the caller supplies `shell` options. `publish` appears when assets are configured. In read-only mode the set is `read`, `ls`, `find`, and `grep`. +Every tool set names its tools `read`, `ls`, `find`, `grep`, `write`, `edit`, and `delete`. `exec` appears when the Workspace has a backend, unless you pass `exec: {}`. `publish` appears when assets are configured. In read-only mode the set is `read`, `ls`, `find`, and `grep`. ## Wiring up ```ts import { Workspace } from "@cloudflare/computer"; -import { createAITools } from "@cloudflare/computer/tools"; +import { createAITools } from "@cloudflare/computer/tools/ai-sdk"; export class Agent { workspace: Workspace; @@ -65,31 +65,20 @@ export class Agent { Pass the returned AI SDK `ToolSet` to `generateText`, `streamText`, or an agent framework hook such as `getTools()`. -Pass `shell` only when the Workspace has matching backend ids. With one backend, `exec` has no `backend` argument and always runs there: +`exec` lists the backends the model can use, keyed by backend id. Leave it out to use every backend. ```ts -const tools = createAITools({ +createAITools({ workspace }); // every backend +createAITools({ workspace, exec: { "worker-javascript": {} } }); // just this one +createAITools({ workspace, - shell: { backends: { "worker-javascript": {} } }, + exec: { "worker-javascript": { description: "Use for data work." } }, // with your own text }); ``` -With more than one, pass `defaultBackend` and the model picks a backend per call: - -```ts -const tools = createAITools({ - workspace, - shell: { - defaultBackend: "shell", - backends: { - shell: { description: "Fast Worker shell with built-in text commands." }, - container: { description: "Full Linux userland in a Cloudflare Container." }, - }, - }, -}); -``` +Each backend describes itself, and a `description` you pass comes first. `exec: {}` means no exec tool. With one backend, `exec` has no `backend` argument and always runs there. With several, the model must name a backend on every call; there is no default. -`createPiTools` and `createTanStackTools` take `shell` the same way. +`createPiTools` and `createTanStackTools` take `exec` the same way. ## pi @@ -166,7 +155,7 @@ createAITools({ read?, write?, edit?, - shell?, + exec?, }); ``` @@ -178,7 +167,8 @@ createAITools({ | `read` | default caps | Options passed to `createReadTool`. | | `write` | default caps | Options passed to `createWriteTool`. | | `edit` | default caps | Options passed to `createEditTool`. | -| `shell` | omitted | Options passed to `createExecTool`. | +| `exec` | every backend | Backend id to `{ description? }`. `{}` omits `exec`. | +| `shell` | omitted | Deprecated. `{ backends }` becomes `exec: backends`; `defaultBackend` is ignored. | `createPiTools` and `createTanStackTools` take the same options, plus their own listed above. @@ -332,9 +322,9 @@ The tool uses forced removal, so deleting a missing path succeeds. Set `recursiv ## `exec` -`exec` is opt-in. It calls `workspace.runtime.exec` with the configured backend and streams bounded output. +`exec` calls `workspace.runtime.exec` on the chosen backend and streams bounded output. `createExecTool({ workspace, backends?, maxBytes?, streamMaxBytes? })` takes the same `backends` as the `exec` option, plus output limits. -Each backend's entry in the tool description joins two parts: the `description` you pass, and what the backend says about itself (`backend.description`, read through `workspace.runtime.describe(id)`). `WorkerJavaScriptBackend` describes its source language and every module code can import, so `{ "worker-javascript": {} }` is enough and the list stays in step with `modules`. A backend that does not describe itself needs a `description`. Describe capabilities and startup cost in plain language. +Each backend's entry in the tool description joins two parts: your text, if any, and what the backend says about itself (`backend.description`, read through `workspace.runtime.backends()`). `WorkerJavaScriptBackend` describes its source language and every module code can import, so the list stays in step with `modules`. `WorkerShellBackend` and `ContainerBackend` describe their command sets, network access, and startup cost. A backend that says nothing gets a one-line default, so add text for a custom backend. The tool offers only the arguments that can work: @@ -342,7 +332,7 @@ The tool offers only the arguments that can work: | --- | --- | | One shell backend | `command`, `cwd`, `env` | | One callable backend | `command`, `cwd`, `env`, `input` | -| More than one | `command`, `cwd`, `backend`, `env`, plus `input` when any is callable. `defaultBackend` is required. | +| More than one | `command`, `cwd`, `backend` (required), `env`, plus `input` when any is callable | A `backend` value the model sends anyway is dropped when only one backend is configured. The output still names the backend that ran. @@ -357,7 +347,7 @@ line 3000 The model can open that file with `read` or search it with `grep`. `streamMaxBytes` is ignored; memory per stream stays within a few times `maxBytes`. -Wire this tool carefully: it executes arbitrary shell commands inside the configured backend. Treat its output as untrusted text when including it in later model input. Omit `shell` or use `readonly: true` when command execution is not part of the agent's job. +Wire this tool carefully: it executes arbitrary shell commands inside the configured backend. Treat its output as untrusted text when including it in later model input. Pass `exec: {}` or `readonly: true` when command execution is not part of the agent's job, and list backends explicitly when the Workspace has one the model should not use directly. ## `publish` diff --git a/docs/10_project_layout.md b/docs/10_project_layout.md index d4b870ed..3a122316 100644 --- a/docs/10_project_layout.md +++ b/docs/10_project_layout.md @@ -175,13 +175,13 @@ produces the Node SEA single-file binary at ## Tools -Agent tools (`read`, `write`, `edit`, `ls`, optional `exec`, and optional +Agent tools (`read`, `write`, `edit`, `ls`, `exec`, and optional `publish`) ship from the package rather than a separate one, with one entry point per agent library: `createAITools()` from -`@cloudflare/computer/tools`, `createPiTools()` from +`@cloudflare/computer/tools/ai-sdk`, `createPiTools()` from `@cloudflare/computer/tools/pi-ai`, and `createTanStackTools()` from `@cloudflare/computer/tools/tanstack-ai`. The individual AI SDK -`create*Tool` functions come from `@cloudflare/computer/tools` too. They live under +`create*Tool` functions come from `@cloudflare/computer/tools`. They live under [`packages/computer/src/tools/`](../packages/computer/src/tools/): `common/` holds each tool's schema, description, and executor with no agent library in it, and `ai-sdk/`, `pi-ai/`, and `tanstack-ai/` wrap diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 2fe49372..00481e6c 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -80,9 +80,9 @@ Workspace parses the graph before loading the Worker, confines every durable pat ## Execution limits and retention -The backend admits up to twenty-four executions at a time by default. A concurrent start past that ceiling fails with `EEXEC_BUSY` instead of creating an unbounded number of Dynamic Workers. Adjust `maxConcurrentExecutions` after measuring the Durable Object and Worker Loader limits for the deployment. +The backend does not cap concurrent executions itself. The platform limits how many Dynamic Workers run at once, and an execution started past that limit fails with the platform's error. -Each execution also bounds combined stdout and stderr output, active event subscribers, directory entries per read, concurrent and total capability calls, and cumulative capability request and response bytes. The corresponding `maxStdioBytes`, `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. +Each execution also bounds combined stdout and stderr output, active event subscribers, directory entries per read, concurrent and total capability calls, and cumulative capability request and response bytes. The corresponding `maxStdioBytes`, `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. Every capability call goes through one host bridge that enforces these limits. Values cross as real Workers RPC values, measured as UTF-8 bytes for strings and raw bytes for byte arrays, and anything that is not plain data, such as a function, an RPC stub, or a cycle, is rejected before the host acts on it. Completed execution records remain available for replay for sixty 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. @@ -127,10 +127,11 @@ Caller source can import three kinds of module, and all of them are fixed when t | --- | --- | --- | --- | | Built in | Always installed | The isolate, backed by the Workspace | `node:fs`, `node:fs/promises` | | Source | `modules: { name: "source" }` | The isolate | a bundled library | -| Host | `modules: { "ws:name": { fn } }`, or a factory | The Durable Object | `ws:git`, `ws:artifacts`, your own | +| Host | `modules: { "ws:name": { fn } }`, or a factory | The Durable Object | `ws:git`, `ws:container`, your own | ```ts import { createArtifactsModule } from "@cloudflare/computer/modules/artifacts"; +import { createContainerModule } from "@cloudflare/computer/modules/container"; import { createGitModule } from "@cloudflare/computer/modules/git"; new WorkerJavaScriptBackend({ @@ -139,6 +140,7 @@ new WorkerJavaScriptBackend({ "tar-stream": TAR_STREAM_BUNDLE, "ws:git": createGitModule(), "ws:artifacts": createArtifactsModule(), + "ws:container": createContainerModule(), "ws:weather": { forecast: ([city]) => lookUpForecast(String(city)), }, @@ -148,7 +150,7 @@ new WorkerJavaScriptBackend({ An import that is not built in, configured, or a relative Workspace path fails before the Worker is created. Caller source and durable files cannot shadow a configured or built-in module. -The backend describes its modules for a model in `backend.description`, which `workspace.runtime.describe(id)` returns and the `exec` tool shows. It is built from the same `modules` option the backend runs with, so it always matches what is installed: +The backend describes its modules for a model in `backend.description`, which `workspace.runtime.backends()` returns and the `exec` tool shows. It is built from the same `modules` option the backend runs with, so it always matches what is installed: ```text `command` is ECMAScript module source, run in an isolated JavaScript runtime. Relative imports resolve from `cwd` in the workspace. @@ -158,6 +160,7 @@ Modules code can import: - `node:fs/promises` (also `node:fs`): the workspace's files. ... - `tar-stream`: a bundled library. - `ws:git`: The workspace's Git repository tools: `status({ dir })`, ... +- `ws:container`: Runs shell commands in a full Linux container that shares this workspace's files. ... - `ws:weather`: exports `forecast`. ``` @@ -224,7 +227,7 @@ Each function receives the arguments the isolate passed, as an array of JSON-com | `access` | The backend's `"read"` or `"read-write"` access. Check it before any write. | | `resolvePath(path, { allowMissing })` | Confines a caller path to the backend root and rejects symlinks. | -The arguments come from caller code, so parse them before use. A function may return a value or a promise. The result must be JSON-compatible, and the bridge checks it at runtime: `undefined` becomes `null` and `undefined` object fields are dropped, as with `JSON.stringify`. It fits within the same capability byte limits as every other host call. A function that ignores `signal` and never settles keeps the execution in its finalizing state. +The arguments come from caller code, so parse them before use. A function may return a value or a promise. Arguments and results cross the isolate boundary as real values through Workers RPC, not as encoded text. The result must be JSON-compatible plain data, and the bridge checks it at runtime. As in JSON, an `undefined` result or array item becomes `null` and an `undefined` object field is left out, in arguments and results alike. Byte arrays work for `node:fs` calls but not for host modules. It fits within the same capability byte limits as every other host call. A function that ignores `signal` and never settles keeps the execution in its finalizing state. Specifiers and the export names of an object are checked at construction. A factory's export names are checked when the backend connects and the factory runs. A module must export at least one function, and every export name must be a JavaScript identifier name other than `default` or `then`. A reserved word such as `delete` is allowed, and caller code renames it on import: `import { delete as remove } from "ws:files"`. Importing a name the module does not export fails when the module graph links, before any code runs. @@ -244,6 +247,56 @@ import { create, get, list, importArtifact, deleteArtifact } from "ws:artifacts" `createArtifactsModule()` from `@cloudflare/computer/modules/artifacts` wraps the Workspace's Artifacts client. Calls that change Artifacts need a read-write backend. `importArtifact()` fetches from a caller-chosen URL on the host, so it is denied unless you pass `createArtifactsModule({ allowNetwork: true })`. Every call fails clearly when no Artifacts binding is configured. +### `ws:container` + +`createContainerModule()` from `@cloudflare/computer/modules/container` lets JavaScript run shell commands in the Workspace's container backend. With it, JavaScript is the only backend the model sees, and the container is something that JavaScript can call: + +```ts +import { ContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container"; + +class Agent extends withWorkspaceContainer(class extends DurableObject {}) { + workspace = new Workspace({ + storage: this.ctx.storage, + backends: [ + new WorkerJavaScriptBackend({ + loader: this.env.LOADER, + access: "read-write", + modules: { "ws:container": createContainerModule() }, + }), + new ContainerBackend({ + container: () => this, + workspace: { binding: "Agent", id: this.ctx.id.toString() }, + egress: { mode: "direct" }, + }), + ], + }); +} + +// Offer only the JavaScript backend; the container is reached through ws:container. +const tools = createAITools({ workspace: this.workspace, exec: { "worker-javascript": {} } }); +``` + +```js +import { exec } from "ws:container"; + +export default async function () { + const { exitCode, stdout, stderr } = await exec("npm test", { cwd: "/workspace/app" }); + return { passed: exitCode === 0, stdout, stderr }; +} +``` + +`exec(command, { cwd, env, stdin, timeoutMs })` runs through `workspace.runtime.exec` on the container backend: `ContainerBackend`, registered as `"container-shell"` unless you pass `backend`. If that backend is missing, or runs module source rather than shell commands, the JavaScript backend fails to connect. The container shares the Workspace's files: writes the module made before the call are pushed to the container, and the container's changes are pulled back before `exec` returns. A non-zero exit code comes back as a value, not as an error. + +The result also carries `sync`: `{ status, skipped, skippedCount, error? }`. `status` is `pending` when the container's file changes have not reached the Workspace yet, and `error` says why. `skipped` lists up to 100 paths the container wrote that the Workspace refused, such as files in a read-only mount, and `skippedCount` gives the full count. The sync is last-writer-wins: the container's changes replace files written in the Workspace while the command runs, without reporting them as skipped. Don't write files from the isolate that the running command also writes. + +A few limits follow from `exec` being a host call: + +- Output comes back when the command finishes, not while it runs. Each stream keeps its last 2000 lines or `maxOutputBytes` (64 KiB by default), which must stay well under the backend's `maxCapabilityBytes`. When a stream is cut, the result's `truncated.stdout.path` (or `stderr`) names a Workspace file holding all of it (see [Long output](./05_runtime_interface.md#long-output)), and the host never holds more than the end in memory. The default directory, `/.computer/output`, is outside the backend's default `root` (`/workspace`), so isolate code cannot open it with `node:fs`; the agent's `read` and `grep` tools can. Set the Workspace's `output.dir` under `root` if isolate code needs to read it. +- The command's timeout is capped at the time left before the host call deadline (`maxHostCallMs`, which defaults to `maxTimeoutMs`). Raise `defaultTimeoutMs`, `maxTimeoutMs`, and `maxHostCallMs` for slow installs and builds, and remember the container's first start. +- Cancelling the execution kills the running command. + +A container command can write to the Workspace, so `exec` refuses to run on a read-only backend. Whether it can reach the network follows `ContainerBackend`'s own `egress` setting, not the JavaScript backend's. + ## Isolation and lifecycle Each execution receives a fresh Dynamic Worker with: diff --git a/docs/README.md b/docs/README.md index 7003e7cf..e7199b6b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,9 +20,9 @@ It provides: - 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, 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`, host modules such as `ws:git` and `ws:artifacts`, and managed execution records. + - Isolated JavaScript with structured input/results, durable relative imports, configured libraries, durable `node:fs/promises`, host modules such as `ws:git` and `ws:container`, and managed execution records. - Workspace constructable without a backend, for filesystem-only use cases. - - Out-of-the-box agent tools for the AI SDK (`createAITools()` in `@cloudflare/computer/tools`), pi (`createPiTools()` in `@cloudflare/computer/tools/pi-ai`), and TanStack AI (`createTanStackTools()` in `@cloudflare/computer/tools/tanstack-ai`). + - Out-of-the-box agent tools for the AI SDK (`createAITools()` in `@cloudflare/computer/tools/ai-sdk`), pi (`createPiTools()` in `@cloudflare/computer/tools/pi-ai`), and TanStack AI (`createTanStackTools()` in `@cloudflare/computer/tools/tanstack-ai`). It comes with the following limitations: @@ -49,9 +49,11 @@ The package ships several entrypoints: | `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable relative imports, `node:fs/promises`, and host modules. | | `@cloudflare/computer/git` | Opt-in isomorphic-git glue for working with checkouts inside the workspace. Bundled lazily, with `pako` replaced by Workers `node:zlib`, and kept out of the default `@cloudflare/computer` graph. | | `@cloudflare/computer/artifacts` | `createArtifact`, an optionally session-scoped wrapper over the Cloudflare Artifacts Workers binding, plus its argv CLI. | +| `@cloudflare/computer/modules/container` | `createContainerModule()` for `ws:container`: run container commands from isolate JavaScript. | | `@cloudflare/computer/modules/git` | `createGitModule()` for `ws:git`: confined Git from isolate JavaScript. | | `@cloudflare/computer/modules/artifacts` | `createArtifactsModule()` for `ws:artifacts`: Artifacts from isolate JavaScript. | | `@cloudflare/computer/tools` | AI SDK tools for agents: read, write, edit, ls, optional exec, and optional publish. | +| `@cloudflare/computer/tools/ai-sdk` | `createAITools()`: the AI SDK tool set for a Workspace. | | `@cloudflare/computer/tools/pi-ai` | `createPiTools()`: the same tool set for pi, as declarations plus a function that runs a tool call. | | `@cloudflare/computer/tools/tanstack-ai` | `createTanStackTools()`: the same tool set for TanStack AI, as the list `chat({ tools })` takes. | diff --git a/examples/celld/README.md b/examples/celld/README.md index 7eec14a0..5d2d2220 100644 --- a/examples/celld/README.md +++ b/examples/celld/README.md @@ -128,7 +128,7 @@ the message or `CELLD_EXPECT` to use a different expected phrase. ## Workspace tools -The agent receives these tools from `@cloudflare/computer/tools`: +The agent receives these tools from `createAITools()` in `@cloudflare/computer/tools/ai-sdk`: | Tool | Purpose | | --- | --- | diff --git a/examples/celld/src/index.ts b/examples/celld/src/index.ts index fbcf24bd..ab9d23f1 100644 --- a/examples/celld/src/index.ts +++ b/examples/celld/src/index.ts @@ -5,7 +5,7 @@ import { type WorkspaceRuntimeLoader, withWorkspace, } from "@cloudflare/computer"; -import { createAITools } from "@cloudflare/computer/tools"; +import { createAITools } from "@cloudflare/computer/tools/ai-sdk"; import { routeAgentRequest } from "agents"; import { convertToModelMessages, isStepCount, streamText } from "ai"; import { createWorkersAI } from "workers-ai-provider"; @@ -49,24 +49,21 @@ export class CelldAgent extends withWorkspace(CelldAgentBase, (self) => { assets: false, ...(this.bindings.LOADER ? { - shell: { - defaultBackend: CELLD_JAVASCRIPT_BACKEND_ID, - backends: { - [CELLD_JAVASCRIPT_BACKEND_ID]: { - description: [ - "Runs a complete JavaScript module in a celld Dynamic Worker with structured input and output.", - "Pass module source, not a filename or bare script. The module must have a default export. Export a function to receive `(input, ctx)` and return structured output.", - "", - "```js", - "export default async function main(input, ctx) {", - ' console.log("cwd:", ctx.cwd);', - " return { received: input };", - "}", - "```", - "", - "The loaded worker cannot access the Workspace filesystem. Use read, write, edit, ls, find, grep, and delete outside exec.", - ].join("\n"), - }, + exec: { + [CELLD_JAVASCRIPT_BACKEND_ID]: { + description: [ + "Runs a complete JavaScript module in a celld Dynamic Worker with structured input and output.", + "Pass module source, not a filename or bare script. The module must have a default export. Export a function to receive `(input, ctx)` and return structured output.", + "", + "```js", + "export default async function main(input, ctx) {", + ' console.log("cwd:", ctx.cwd);', + " return { received: input };", + "}", + "```", + "", + "The loaded worker cannot access the Workspace filesystem. Use read, write, edit, ls, find, grep, and delete outside exec.", + ].join("\n"), }, }, } diff --git a/examples/mcp/README.md b/examples/mcp/README.md index 3be5d529..ffc2f1a5 100644 --- a/examples/mcp/README.md +++ b/examples/mcp/README.md @@ -83,7 +83,7 @@ Once connected, ask your MCP client to work in the Computer workspace. For examp Create /workspace/hello.txt, read it back, and list the workspace files. ``` -Commands use `worker-shell` by default. Select the container when the task needs a full Linux environment: +Every command names its backend: `worker-shell` for quick shell work, or `container-shell` when the task needs a full Linux environment: ```text Use container-shell to create a small Node.js project in /workspace, install its dependencies, and run its tests. @@ -118,7 +118,7 @@ You do not need to call the underlying Computer tools individually. The `code` t | `codemode.write({ path, content })` | Create or replace a file. | | `codemode.edit({ path, edits })` | Apply exact text replacements to a file. | | `codemode.delete_({ path, recursive? })` | Delete a file or directory. | -| `codemode.exec({ command, cwd?, backend?, env? })` | Run a command, using `worker-shell` unless another backend is selected. | +| `codemode.exec({ command, backend, cwd?, env? })` | Run a command on `worker-shell` or `container-shell`. | ## How it works @@ -126,7 +126,7 @@ You do not need to call the underlying Computer tools individually. The `code` t | Backend | Use it for | | --- | --- | -| `worker-shell` | The fast default for common commands. It has no ambient network access; its built-in Git command supports HTTPS remotes. | +| `worker-shell` | Fast, and the one to try first for common commands. It has no ambient network access; its built-in Git command supports HTTPS remotes. | | `container-shell` | Full Debian Linux with Node.js, npm, git, native binaries, and outbound networking. | The model can select a backend in `codemode.exec()`. The example does not retry automatically, so backend choice, cost, and failures remain visible. diff --git a/examples/mcp/src/index.test.ts b/examples/mcp/src/index.test.ts index 189e062e..57d4035c 100644 --- a/examples/mcp/src/index.test.ts +++ b/examples/mcp/src/index.test.ts @@ -85,8 +85,11 @@ describe("Computer Code Mode MCP", () => { }); const file = await codemode.read({ path: "/workspace/message.txt" }); const listing = await codemode.ls({ path: "/workspace" }); - const shell = await codemode.exec({ command: "pwd" }); - const git = await codemode.exec({ command: "git init && git status --short" }); + const shell = await codemode.exec({ command: "pwd", backend: "worker-shell" }); + const git = await codemode.exec({ + command: "git init && git status --short", + backend: "worker-shell", + }); return { content: file.content, listed: listing.entries.some((entry) => entry.name === "message.txt"), diff --git a/examples/mcp/src/index.ts b/examples/mcp/src/index.ts index e74b3c87..4394367d 100644 --- a/examples/mcp/src/index.ts +++ b/examples/mcp/src/index.ts @@ -7,10 +7,7 @@ import { WorkspaceServiceProxy, withWorkspace, } from "@cloudflare/computer"; -import { - LegacyContainerBackend, - withLegacyWorkspaceContainer, -} from "@cloudflare/computer/backends/container-legacy"; +import { ContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container"; import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; import { createGitClient } from "@cloudflare/computer/git"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; @@ -29,7 +26,7 @@ const TOKEN_ENCODER = new TextEncoder(); class ComputerMCPDurableObject extends DurableObject {} -class ComputerMCPBase extends withLegacyWorkspaceContainer(ComputerMCPDurableObject) { +class ComputerMCPBase extends withWorkspaceContainer(ComputerMCPDurableObject) { readonly workerShell = new WorkerShellBackend({ loader: this.env.LOADER, workspace: { binding: "COMPUTER_MCP", id: this.ctx.id.toString() }, @@ -37,10 +34,13 @@ class ComputerMCPBase extends withLegacyWorkspaceContainer(ComputerMCPDurableObj egress: { mode: "none" }, }); - readonly containerShell = new LegacyContainerBackend({ + readonly containerShell = new ContainerBackend({ container: () => this, workspace: { binding: "COMPUTER_MCP", id: this.ctx.id.toString() }, egress: { mode: "direct" }, + // The durable object schedules this container, so it asks for its + // size at launch; wrangler.jsonc names the image under `images.app`. + instance: "standard-2", }); } diff --git a/examples/mcp/src/server.ts b/examples/mcp/src/server.ts index 090275db..52774350 100644 --- a/examples/mcp/src/server.ts +++ b/examples/mcp/src/server.ts @@ -1,7 +1,7 @@ import { DynamicWorkerExecutor } from "@cloudflare/codemode"; import { codeMcpServer } from "@cloudflare/codemode/mcp"; import type { WorkspaceClient } from "@cloudflare/computer"; -import { createAITools } from "@cloudflare/computer/tools"; +import { createAITools } from "@cloudflare/computer/tools/ai-sdk"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ToolSet } from "ai"; @@ -11,29 +11,26 @@ export async function createComputerMCPServer(workspace: WorkspaceClient, loader const tools = createAITools({ workspace, assets: false, - shell: { - backends: { - "worker-shell": { - description: - "just-bash in an isolated Dynamic Worker. Starts quickly, " + - "does not boot a container, and has no ambient outbound network. " + - "Use it for common shell commands, quick file inspection, and " + - "text transformations. Its built-in git command supports clone, " + - "status, diff, and log; clone accepts HTTPS URLs through the " + - "durable workspace. Prefer the dedicated read, write, and edit " + - "tools for file operations. Cannot run npm, Node.js, Python, " + - "package managers, or arbitrary native binaries.", - }, - "container-shell": { - description: - "Full Debian Linux in a Cloudflare Container with Node.js, npm, " + - "git, package management, native binaries, and outbound network. " + - "Use it for dependency installation, builds, tests, or commands " + - "that worker-shell cannot run. Cold starts more slowly because " + - "the container must boot; prefer worker-shell for simple tasks.", - }, + exec: { + "worker-shell": { + description: + "just-bash in an isolated Dynamic Worker. Starts quickly, " + + "does not boot a container, and has no ambient outbound network. " + + "Use it for common shell commands, quick file inspection, and " + + "text transformations. Its built-in git command supports clone, " + + "status, diff, and log; clone accepts HTTPS URLs through the " + + "durable workspace. Prefer the dedicated read, write, and edit " + + "tools for file operations. Cannot run npm, Node.js, Python, " + + "package managers, or arbitrary native binaries.", + }, + "container-shell": { + description: + "Full Debian Linux in a Cloudflare Container with Node.js, npm, " + + "git, package management, native binaries, and outbound network. " + + "Use it for dependency installation, builds, tests, or commands " + + "that worker-shell cannot run. Cold starts more slowly because " + + "the container must boot; prefer worker-shell for simple tasks.", }, - defaultBackend: "worker-shell", }, }); diff --git a/examples/mcp/wrangler.jsonc b/examples/mcp/wrangler.jsonc index a627409b..52e3f1d5 100644 --- a/examples/mcp/wrangler.jsonc +++ b/examples/mcp/wrangler.jsonc @@ -9,11 +9,10 @@ "containers": [ { "class_name": "ComputerMCP", - "image": "./Dockerfile", - "instance_type": "standard-2", - "max_instances": 1, - "rollout_active_grace_period": 0, - "rollout_step_percentage": [100] + "scheduling_policy": "durable_object", + "images": { + "app": { "dockerfile": "./Dockerfile" } + } } ], "durable_objects": { diff --git a/examples/pi-ai/src/index.ts b/examples/pi-ai/src/index.ts index 3dbc7245..3980702e 100644 --- a/examples/pi-ai/src/index.ts +++ b/examples/pi-ai/src/index.ts @@ -25,12 +25,6 @@ export { WorkspaceServiceProxy }; const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"; -// The one worker shell `exec` runs on. -const SHELL = { - backends: { shell: { description: "A just-bash shell over the workspace files." } }, - defaultBackend: "shell", -}; - // Bound the spend if the model fails to converge. const MAX_TURNS = 10; @@ -54,7 +48,9 @@ export class PiAgent extends DurableObject { } async run(task: string): Promise { - const { tools, execute } = createPiTools({ workspace: this.workspace, shell: SHELL }); + // `exec` offers every backend the Workspace has; here that is the + // one worker shell, so the model never names a backend. + const { tools, execute } = createPiTools({ workspace: this.workspace }); const models = createModels(); models.setProvider(workersAI(this.env.AI, MODEL)); diff --git a/examples/rlm/worker/executor-agent.ts b/examples/rlm/worker/executor-agent.ts index 860f919d..7e245ea5 100644 --- a/examples/rlm/worker/executor-agent.ts +++ b/examples/rlm/worker/executor-agent.ts @@ -46,7 +46,6 @@ export class ExecutorAgent extends AIChatAgent { root: WORKSPACE_ROOT, access: "read", egress: { mode: "none" }, - maxConcurrentExecutions: 1, maxConcurrentCapabilityCalls: 4, maxCapabilityCalls: 24, maxCapabilityBytes: 4 * 1024 * 1024, diff --git a/examples/rlm/worker/executor-tool.ts b/examples/rlm/worker/executor-tool.ts index 07921ea9..ec5e53dd 100644 --- a/examples/rlm/worker/executor-tool.ts +++ b/examples/rlm/worker/executor-tool.ts @@ -19,7 +19,6 @@ export function createExecutorTool( "Callable isolated JavaScript. The command must be a complete ES module with a default async function.", }, }, - defaultBackend: backend, maxBytes: 16 * 1024, streamMaxBytes: 16 * 1024, }); diff --git a/examples/rlm/worker/rlm-agent.ts b/examples/rlm/worker/rlm-agent.ts index bd34039e..a3fd3d3f 100644 --- a/examples/rlm/worker/rlm-agent.ts +++ b/examples/rlm/worker/rlm-agent.ts @@ -108,7 +108,6 @@ export class RlmAgent extends AIChatAgent { access: "read", egress: { mode: "none" }, modules: { "ws:model": modelCapability }, - maxConcurrentExecutions: 1, maxConcurrentCapabilityCalls: 4, // One manifest read + 24 chunk reads + one bounded ws:model batch. maxCapabilityCalls: 26, diff --git a/examples/tanstack-ai/src/index.ts b/examples/tanstack-ai/src/index.ts index 8b167390..55d3e63f 100644 --- a/examples/tanstack-ai/src/index.ts +++ b/examples/tanstack-ai/src/index.ts @@ -45,10 +45,6 @@ export class TanStackAgent extends DurableObject { async run(task: string): Promise { const tools = createTanStackTools({ workspace: this.workspace, - shell: { - backends: { shell: { description: "A just-bash shell over the workspace files." } }, - defaultBackend: "shell", - }, }); const stream = chat({ diff --git a/examples/think/README.md b/examples/think/README.md index 9049e2bd..f6cd582d 100644 --- a/examples/think/README.md +++ b/examples/think/README.md @@ -24,7 +24,7 @@ would use, so no bespoke HTTP route or transport is involved. [think]: https://www.npmjs.com/package/@cloudflare/think [workspace]: ../../packages/computer -[tools]: ../../packages/computer/src/tools +[tools]: ../../packages/computer/src/tools/ai-sdk.ts [aisdk7]: https://vercel.com/blog/ai-sdk-7 ## Shape @@ -53,9 +53,10 @@ model, a Workspace, and the workspace tools. ## Tools The tools come from `createAITools()` in -[`@cloudflare/computer/tools`][tools]. This example enables the file -tools and opts into `exec` by passing a shell backend description; it -does not configure the assets publisher, so `publish` is not offered. +[`@cloudflare/computer/tools/ai-sdk`][tools]. This example offers the +file tools and an `exec` tool over both backends, each of which +describes itself to the model. It does not configure the assets +publisher, so `publish` is not offered. | Tool | What it does | | ------- | --------------------------------------------------------- | @@ -75,8 +76,9 @@ does not configure the assets publisher, so `publish` is not offered. and `git log` work from inside `exec` even though the shell isolate has no public network of its own. Only `https://` URLs are supported. -- `"container"` — a Cloudflare Container running `computerd` over capnweb, - modelled on [`examples/container-legacy`](../container-legacy). It has full Linux +- `"container"` — a `ContainerBackend` running `computerd` over capnweb + in a Cloudflare Container the durable object schedules, modelled on + [`examples/container`](../container). It has full Linux userland, public network, `npm`, `node`, `python`, package managers, test runners, and other real binaries on `$PATH`. It cold-starts more slowly, so use it when the shell backend cannot run the @@ -87,7 +89,7 @@ The system prompt tells the model to prefer `read`/`ls` over fast `shell` backend before falling through to `container`. See [`docs/05_runtime_interface.md`](../../docs/05_runtime_interface.md), [`docs/13_git_interface.md`](../../docs/13_git_interface.md), and -[`examples/container-legacy`](../container-legacy). +[`examples/container`](../container). ## Running it locally diff --git a/examples/think/src/agent.ts b/examples/think/src/agent.ts index 755cf4fc..1fc2ddc6 100644 --- a/examples/think/src/agent.ts +++ b/examples/think/src/agent.ts @@ -15,8 +15,8 @@ * store, agentic loop, and chat protocol. * - We own a `@cloudflare/computer.Workspace` with two backends: * a WorkerShellBackend (`"shell"`) for fast just-bash text tooling and - * a LegacyContainerBackend (`"container"`) for full Linux - * userland through computerd. This mirrors examples/container-legacy while + * a ContainerBackend (`"container"`) for full Linux + * userland through computerd. This mirrors examples/container while * keeping the chat surface unchanged. * - `useThink: true` adds the string-based compatibility surface * Think expects; the cast promotes it from optional to present. @@ -32,12 +32,9 @@ import { WorkspaceServiceProxy, type WorkspaceStub, } from "@cloudflare/computer"; -import { - LegacyContainerBackend, - withLegacyWorkspaceContainer, -} from "@cloudflare/computer/backends/container-legacy"; +import { ContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container"; import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; -import { createAITools } from "@cloudflare/computer/tools"; +import { createAITools } from "@cloudflare/computer/tools/ai-sdk"; import { Think } from "@cloudflare/think"; import type { ToolSet } from "ai"; import { createWorkersAI } from "workers-ai-provider"; @@ -58,11 +55,11 @@ function workspaceRef(ctx: DurableObjectState) { return { binding: "Assistant", id: ctx.id.toString() }; } -// Anchor Think's generic before the mixin so withLegacyWorkspaceContainer +// Anchor Think's generic before the mixin so withWorkspaceContainer // sees a concrete constructor. class AssistantBase extends Think {} -export class Assistant extends withLegacyWorkspaceContainer(AssistantBase) { +export class Assistant extends withWorkspaceContainer(AssistantBase) { /** We have a dedicated `exec` tool; skip Think's built-in bash. */ override workspaceBash = false; @@ -72,15 +69,18 @@ export class Assistant extends withLegacyWorkspaceContainer(AssistantBase) { /** * Container backend used when `exec` needs a real Linux userland. * The DO itself owns the container binding through the - * withLegacyWorkspaceContainer mixin; LegacyContainerBackend handles + * withWorkspaceContainer mixin; ContainerBackend handles * startup, outbound egress interception, the /api upgrade, and the * capnweb session. */ - readonly #containerBackend = new LegacyContainerBackend({ + readonly #containerBackend = new ContainerBackend({ id: "container", container: () => this, workspace: workspaceRef(this.ctx), egress: { mode: "direct" }, + // The durable object schedules this container, so it asks for its + // size at launch; wrangler.jsonc names the image under `images.app`. + instance: "standard-2", }); /** @@ -137,8 +137,8 @@ export class Assistant extends withLegacyWorkspaceContainer(AssistantBase) { " `exec cat` / `exec ls`.", " - write, edit: create and modify files. Prefer these over", " `exec sed` / shell heredocs.", - " - exec: run shell commands. Use the default `shell`", - " backend first: it is just-bash in a Dynamic", + " - exec: run shell commands. Name a backend on every call.", + " Try backend `shell` first: it is just-bash in a Dynamic", " Worker, cold-starts quickly, and includes `git`", " (clone / status / diff / log) via the host", " workspace. Only https:// git URLs are supported.", @@ -154,35 +154,8 @@ export class Assistant extends withLegacyWorkspaceContainer(AssistantBase) { } override getTools(): ToolSet { - return createAITools({ - workspace: this.workspace, - shell: { - defaultBackend: "shell", - backends: { - shell: { - description: - "just-bash in a Dynamic Worker. Cold-start fast, no " + - "container, no public network. Good for cat / grep / sed / " + - "awk / jq / head / tail / sort / find, quick file " + - "inspection, text transformations, and `git` (clone / " + - "status / diff / log) — the shell registers a built-in " + - "`git` command that forwards to the host workspace, so " + - "network-bound subcommands like `git clone` work even " + - "though the isolate itself has no public network. Only " + - "https:// URLs are supported. Cannot run npm, node, python, " + - "or any binary outside just-bash's built-in command set.", - }, - container: { - description: - "Cloudflare Container running computerd over capnweb. Full Linux " + - "userland: npm, node, python, package managers, test " + - "runners, real binaries on $PATH, and public network. Cold " + - "start is much slower because the container must boot; " + - "reach for it when the shell backend can't run the command. " + - "For git itself, prefer the shell backend.", - }, - }, - }, - }); + // Every backend the Workspace has, "shell" first. Both describe + // themselves to the model. + return createAITools({ workspace: this.workspace }); } } diff --git a/examples/think/wrangler.jsonc b/examples/think/wrangler.jsonc index 03cf2b88..5c988174 100644 --- a/examples/think/wrangler.jsonc +++ b/examples/think/wrangler.jsonc @@ -15,14 +15,15 @@ "containers": [ { "class_name": "Assistant", - // Built from the local Dockerfile, which copies computerd into a - // small Debian image with Node/npm/git for real build and test - // workflows. - "image": "./Dockerfile", - "instance_type": "standard-2", - "max_instances": 5, - "rollout_active_grace_period": 0, - "rollout_step_percentage": [100] + // The durable object schedules this container and asks for its + // size at launch, so the block names images instead of + // instance_type and max_instances. The image is built from the + // local Dockerfile, which copies computerd into a small Debian + // image with Node/npm/git for real build and test workflows. + "scheduling_policy": "durable_object", + "images": { + "app": { "dockerfile": "./Dockerfile" } + } } ], diff --git a/packages/computer/README.md b/packages/computer/README.md index b9aca255..4e52b6ee 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -51,7 +51,7 @@ own binding requirements — see [Choosing a backend](#choosing-a-backend). Optional peer dependencies, installed only if you use the matching feature: `zod` for every tools entry point, `ai` for -`@cloudflare/computer/tools`, and +`@cloudflare/computer/tools` and `@cloudflare/computer/tools/ai-sdk`, and `@platformatic/vfs` for the Node-side VFS provider. The pi and TanStack AI entry points need nothing beyond `zod`; your agent brings its own library. @@ -259,7 +259,7 @@ Alongside `exec`, the runtime exposes `getExec`, `killExec`, and - **Worker JavaScript** evaluates a module with structured input/results, durable relative imports, configured libraries, Workspace-backed `node:fs/promises`, and host modules such as - `ws:git` and `ws:artifacts`. It runs after `runtime.exec()` returns; the + `ws:git`, `ws:artifacts`, and `ws:container`. It runs after `runtime.exec()` returns; the run stays alive while its event stream is consumed. See [`docs/17_isolate_javascript.md`](../../docs/17_isolate_javascript.md) and [`examples/worker-javascript`](../../examples/worker-javascript). @@ -269,31 +269,27 @@ to a named one — see [Multiple backends](#multiple-backends). ## Tools for agents -`@cloudflare/computer/tools` ships AI SDK tools that wrap the Workspace +`@cloudflare/computer/tools/ai-sdk` ships `createAITools()`, AI SDK tools that wrap the Workspace surfaces, ready to hand to `generateText`, `streamText`, or an agent framework's `getTools()`. The default set is `read`, `ls`, `find`, -`grep`, `write`, `edit`, and `delete`; `exec` and `publish` are added -when you configure them. Read-only mode keeps `read`, `ls`, `find`, and +`grep`, `write`, `edit`, and `delete`, plus `exec` when the Workspace +has a backend and `publish` when assets are configured. Read-only mode keeps `read`, `ls`, `find`, and `grep`. ```ts -import { createAITools } from "@cloudflare/computer/tools"; +import { createAITools } from "@cloudflare/computer/tools/ai-sdk"; const tools = createAITools({ workspace, read: { maxBytes: 32 * 1024, maxLines: 800 }, - shell: { - defaultBackend: "shell", - backends: { - shell: { description: "Fast Worker shell with built-in text commands." }, - container: { description: "Full Linux userland in a Cloudflare Container." }, - }, - }, + // The backends the model can use. Omit for every backend. + exec: { shell: { description: "Try this first." }, container: {} }, }); ``` -The model reads each backend's `description` when deciding where a -command should run, so write them in plain language. Truncated text +Each backend describes itself to the model, and the text you give in +`exec` comes first. The model reads both when deciding where a command +should run, so write yours in plain language. Truncated text model output keeps both line and byte continuations; pass both to the next call to avoid transferring the same bytes again. Eligible image and PDF bytes are captured once during the bounded tool execution and returned @@ -437,9 +433,11 @@ on a computerd instance. | `@cloudflare/computer/backends/container-legacy` | `LegacyContainerBackend` and `withLegacyWorkspaceContainer`. Pulls in the computerd / capnweb sync plumbing. | | `@cloudflare/computer/backends/worker-shell` | `WorkerShellBackend` and the bundled just-bash runtime. | | `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable imports, `node:fs/promises`, and host modules. | +| `@cloudflare/computer/modules/container` | `createContainerModule()` for `ws:container`: run container commands from isolate JavaScript. | | `@cloudflare/computer/modules/git` | `createGitModule()` for `ws:git`: confined Git from isolate JavaScript. | | `@cloudflare/computer/modules/artifacts` | `createArtifactsModule()` for `ws:artifacts`: Artifacts from isolate JavaScript. | | `@cloudflare/computer/tools` | AI SDK tools for agents: `read`, `ls`, `find`, `grep`, `write`, `edit`, `delete`, and optional `exec` and `publish`. | +| `@cloudflare/computer/tools/ai-sdk` | `createAITools()`: the AI SDK tool set for a Workspace. | | `@cloudflare/computer/tools/pi-ai` | `createPiTools()`: the same tool set for pi (`@earendil-works/pi-ai`). | | `@cloudflare/computer/tools/tanstack-ai` | `createTanStackTools()`: the same tool set for TanStack AI (`@tanstack/ai`). | | `@cloudflare/computer/git` | Opt-in `isomorphic-git` glue for checkouts inside the workspace. | diff --git a/packages/computer/package.json b/packages/computer/package.json index bd2fbfe4..6ef5b251 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -31,6 +31,10 @@ "types": "./dist/artifacts/index.d.ts", "import": "./dist/artifacts/index.js" }, + "./modules/container": { + "types": "./dist/modules/container.d.ts", + "import": "./dist/modules/container.js" + }, "./modules/git": { "types": "./dist/modules/git.d.ts", "import": "./dist/modules/git.js" @@ -55,6 +59,10 @@ "types": "./dist/backends/container-legacy/index.d.ts", "import": "./dist/backends/container-legacy/index.js" }, + "./tools/ai-sdk": { + "types": "./dist/tools/ai-sdk.d.ts", + "import": "./dist/tools/ai-sdk.js" + }, "./backends/container": { "types": "./dist/backends/container/index.d.ts", "import": "./dist/backends/container/index.js" diff --git a/packages/computer/rolldown.config.ts b/packages/computer/rolldown.config.ts index 55a6958f..22b9a8a2 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -31,8 +31,10 @@ export default defineConfig({ "artifacts/index": "src/artifacts/index.ts", "assets/index": "src/assets/index.ts", "tools/index": "src/tools/index.ts", + "tools/ai-sdk": "src/tools/ai-sdk/index.ts", "tools/pi-ai": "src/tools/pi-ai/index.ts", "tools/tanstack-ai": "src/tools/tanstack-ai/index.ts", + "modules/container": "src/modules/container.ts", "modules/git": "src/modules/git.ts", "modules/artifacts": "src/modules/artifacts.ts", "backends/container-legacy/index": "src/backends/container-legacy/index.ts", diff --git a/packages/computer/src/backends/container/container-backend-launch.test.ts b/packages/computer/src/backends/container/container-backend-launch.test.ts index 8d198b87..4aa4a267 100644 --- a/packages/computer/src/backends/container/container-backend-launch.test.ts +++ b/packages/computer/src/backends/container/container-backend-launch.test.ts @@ -106,3 +106,20 @@ describe("both launch paths request the same container", () => { } }); }); + +describe("ContainerBackend description", () => { + test.each([ + [undefined, "It has no network access."], + [{ mode: "none" as const }, "It has no network access."], + [{ mode: "direct" as const }, "It has network access."], + ])("matches egress %o", (egress, expected) => { + const backend = new ContainerBackend({ + container: () => ({ getWorkspaceContainer: () => ({}) }) as never, + workspace: { binding: "SESSIONS", id: "session-1" }, + ...(egress === undefined ? {} : { egress }), + }); + + expect(backend.description).toContain("full Linux container"); + expect(backend.description).toContain(expected); + }); +}); diff --git a/packages/computer/src/backends/container/container-backend.ts b/packages/computer/src/backends/container/container-backend.ts index 003a3897..de42fcc1 100644 --- a/packages/computer/src/backends/container/container-backend.ts +++ b/packages/computer/src/backends/container/container-backend.ts @@ -182,6 +182,14 @@ export interface ContainerBackendOptions { } const DEFAULT_EGRESS_HOST = "computer.internal"; +// What the model is told about network access, by egress mode. The +// container only gets the internet with "direct"; "http-gateway" +// routes HTTP through the host, and "none" blocks it. +const NETWORK_DESCRIPTION: Record = { + direct: "It has network access.", + "http-gateway": "Outbound HTTP goes through a gateway the host controls.", + none: "It has no network access.", +}; // Image key assumed when a caller names none. Kept in step with the // same default in container-host.ts, which resolves it. const DEFAULT_IMAGE_NAME = "app"; @@ -227,6 +235,8 @@ function bearerMatches(header: string | null, expected: string | undefined): boo export class ContainerBackend implements WorkspaceBackend { readonly type = "cloudflare-container"; + /** What this backend tells a model: a full Linux shell, its network access, and its slow start. */ + readonly description: string; readonly id: string; // `ignore` sits with the un-defaulted options rather than under @@ -271,6 +281,11 @@ export class ContainerBackend implements WorkspaceBackend { this.#egress = options.egress ?? { mode: "none" }; this.#egressToken = this.#egress.mode === "http-gateway" ? crypto.randomUUID() : undefined; if (options.ignore !== undefined) checkIgnorePatterns(options.ignore); + this.description = [ + "A shell in a full Linux container: npm, node, python, package managers, test runners, and native binaries.", + NETWORK_DESCRIPTION[this.#egress.mode], + "Starts much more slowly than an in-Worker backend because the container must boot.", + ].join(" "); this.#options = { container: options.container, workspace: options.workspace, diff --git a/packages/computer/src/backends/worker-javascript/module-graph.ts b/packages/computer/src/backends/worker-javascript/module-graph.ts index 2e94ca65..34e9026c 100644 --- a/packages/computer/src/backends/worker-javascript/module-graph.ts +++ b/packages/computer/src/backends/worker-javascript/module-graph.ts @@ -342,44 +342,33 @@ function capabilitiesModule(maxCapabilityBytes: number) { } return call(namespace, method, args); } + // Arguments and results cross as real values through Workers RPC. + // The host bridge measures and limits them; this early check only + // spares an obviously oversized request the round trip. 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}) { + if (approximateBytes(args) > ${maxCapabilityBytes}) { throw new Error(${JSON.stringify(requestTooLargeMessage)}); } - const raw = await host.call(namespace + "." + method, request); - const payload = JSON.parse(String(raw)); + const payload = await host.call(namespace + "." + method, args); 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; + const error = new Error(payload.error.message); + if (payload.error.code !== undefined) error.code = payload.error.code; + if (payload.error.path !== undefined) error.path = payload.error.path; throw error; } - return decode(payload.result); + return 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 approximateBytes(value, seen = new Set()) { + if (typeof value === "string") return value.length; + if (value instanceof Uint8Array) return value.byteLength; + if (!value || typeof value !== "object") return 8; + if (seen.has(value)) throw new Error("Workspace capability request values must be acyclic."); + seen.add(value); + const entries = Array.isArray(value) ? value.map((item) => ["", item]) : Object.entries(value); + const total = entries.reduce((sum, [key, item]) => sum + key.length + approximateBytes(item, seen), 8); + seen.delete(value); + return total; } `; } diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts index e75b00fb..ab87bc61 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -486,49 +486,6 @@ describe("WorkerJavaScriptBackend", () => { await workspace.close(); }); - it("limits concurrent Dynamic Workers", async () => { - let resolveEvaluation!: (value: { result: number }) => void; - const evaluation = new Promise<{ result: number }>((resolve) => { - resolveEvaluation = resolve; - }); - const workspace = new Workspace({ - storage: new SQLiteTestStorage(), - backends: [ - new WorkerJavaScriptBackend({ - maxConcurrentExecutions: 1, - loader: { - load() { - return { - getEntrypoint() { - return { - evaluate: ( - _input: unknown, - host: { - assertResult(value: unknown): Promise; - attachOutput(readable: ReadableStream): Promise; - }, - ) => evaluation.then((outcome) => evaluateResult(host, outcome.result)), - }; - }, - }; - }, - }, - }), - ], - }); - await workspace.fs.mkdir("/workspace", { recursive: true }); - const first = await workspace.runtime.exec("export default 1", { id: "first" }); - 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); @@ -557,7 +514,7 @@ describe("WorkerJavaScriptBackend", () => { attachOutput(readable: ReadableStream): Promise; }, ) { - void host.call("fs.writeFile", JSON.stringify(["/workspace/output.txt", "done"])); + void host.call("fs.writeFile", ["/workspace/output.txt", "done"]); return evaluateResult(host, 1); }, }; @@ -792,9 +749,9 @@ describe("WorkerJavaScriptBackend", () => { return { async evaluate( _input: unknown, - host: { call(name: string, args: string): Promise }, + host: { call(name: string, args: unknown[]): Promise }, ) { - await host.call("host/ws:test.run", JSON.stringify([])); + await host.call("host/ws:test.run", []); }, }; }, @@ -843,9 +800,9 @@ describe("WorkerJavaScriptBackend", () => { return { evaluate( _input: unknown, - host: { call(name: string, args: string): Promise }, + host: { call(name: string, args: unknown[]): Promise }, ) { - void host.call("fs.writeFile", JSON.stringify(["/workspace/output.txt", "done"])); + void host.call("fs.writeFile", ["/workspace/output.txt", "done"]); return new Promise(() => undefined); }, }; @@ -1120,7 +1077,7 @@ describe("WorkerJavaScriptBackend", () => { initializeSchema(db, () => 0); const fs = new WorkspaceFilesystem(db); await fs.mkdir("/workspace", { recursive: true }); - let response = ""; + let response: unknown; const backend = new WorkerJavaScriptBackend({ modules: { "ws:test": { run: async () => null } }, loader: { @@ -1130,9 +1087,9 @@ describe("WorkerJavaScriptBackend", () => { return { async evaluate( _input: unknown, - host: { call(name: string, args: string): Promise }, + host: { call(name: string, args: unknown[]): Promise }, ) { - response = await host.call("host/ws:test.toString", JSON.stringify([])); + response = await host.call("host/ws:test.toString", []); }, }; }, @@ -1151,7 +1108,7 @@ describe("WorkerJavaScriptBackend", () => { for await (const _event of execution.events) { // Drain the run so the host call settles. } - expect(JSON.parse(response)).toMatchObject({ + expect(response).toMatchObject({ error: { message: expect.stringContaining("Unknown Workspace host module call") }, }); await handle.close?.(); @@ -1170,6 +1127,52 @@ describe("WorkerJavaScriptBackend", () => { } }); + it("runs concurrent executions without a cap of its own", async () => { + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + let started = 0; + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new WorkerJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { + evaluate: ( + _input: unknown, + host: { + assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; + }, + ) => { + started += 1; + return released.then(() => evaluateResult(host, 1)); + }, + }; + }, + }; + }, + }, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const handles = await Promise.all( + Array.from({ length: 30 }, (_, index) => + workspace.runtime.exec("export default 1", { id: `run-${index}` }), + ), + ); + await vi.waitFor(() => expect(started).toBe(30)); + release(); + const results = await Promise.all(handles.map((handle) => handle.result())); + expect(results.every((result) => result.status === "completed")).toBe(true); + await workspace.close(); + }); + it("rejects relative imports that collide with internal Loader modules", async () => { const load = vi.fn(); const workspace = new Workspace({ diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 280ba395..7285b773 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -65,8 +65,6 @@ export interface WorkerJavaScriptBackendOptions { 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. */ @@ -99,7 +97,6 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "maxCapabilityRequestBytes" | "maxCapabilityResponseBytes" | "maxDirectoryEntries" - | "maxConcurrentExecutions" | "maxExecutionSubscribers" | "retentionMs" | "maxRetainedExecutions" @@ -146,7 +143,6 @@ interface ExecutionRecord { control?: ActiveControl; bridge?: WorkspaceRuntimeBridge; finalization?: Promise; - admitted?: boolean; persistenceFailed?: boolean; result?: WorkspaceRuntimeValue; hasResult?: boolean; @@ -193,7 +189,6 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { "maxCapabilityResponseBytes", ); assertPositiveInteger(options.maxDirectoryEntries ?? 1024, "maxDirectoryEntries"); - assertPositiveInteger(options.maxConcurrentExecutions ?? 24, "maxConcurrentExecutions"); assertPositiveInteger(options.maxExecutionSubscribers ?? 8, "maxExecutionSubscribers"); assertPositiveFinite(options.retentionMs ?? 60 * 60_000, "retentionMs"); assertPositiveInteger(options.maxRetainedExecutions ?? 100, "maxRetainedExecutions"); @@ -236,7 +231,6 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { maxCapabilityRequestBytes: options.maxCapabilityRequestBytes ?? 8 * 1024 * 1024, maxCapabilityResponseBytes: options.maxCapabilityResponseBytes ?? 8 * 1024 * 1024, maxDirectoryEntries: options.maxDirectoryEntries ?? 1024, - maxConcurrentExecutions: options.maxConcurrentExecutions ?? 24, maxExecutionSubscribers: options.maxExecutionSubscribers ?? 8, retentionMs: options.retentionMs ?? 60 * 60_000, maxRetainedExecutions: options.maxRetainedExecutions ?? 100, @@ -265,7 +259,6 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { readonly #records = new Map(); readonly #pendingIds = new Set(); #closed = false; - #activeExecutions = 0; readonly #activeStreams = new Map(); #pendingStarts = 0; readonly #pendingStartWaiters = new Set<() => void>(); @@ -367,17 +360,11 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { 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; + // There is no cap on concurrent executions here: the platform limits + // concurrent Dynamic Workers itself and reports that limit as the + // execution's error. this.#pendingStarts += 1; this.#pendingIds.add(id); - let admittedRecord: ExecutionRecord | undefined; try { const capability = new WorkspaceRuntimeCapability( this.#host.fs, @@ -403,7 +390,6 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { events: [], subscribers: new Set(), status: "running", - admitted: true, }; try { this.#host.db.run( @@ -422,7 +408,6 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { 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, { @@ -476,7 +461,6 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { for (const resolve of this.#pendingStartWaiters) resolve(); this.#pendingStartWaiters.clear(); } - if (!admittedRecord) this.#activeExecutions -= 1; } } @@ -873,10 +857,6 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { 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); } diff --git a/packages/computer/src/backends/worker-shell/worker-shell.ts b/packages/computer/src/backends/worker-shell/worker-shell.ts index 981059d1..1fe4a1b2 100644 --- a/packages/computer/src/backends/worker-shell/worker-shell.ts +++ b/packages/computer/src/backends/worker-shell/worker-shell.ts @@ -139,6 +139,9 @@ const DEFAULT_COMPAT_FLAGS = ["nodejs_compat"]; export class WorkerShellBackend implements WorkspaceBackend { readonly type = "worker-shell"; + /** What this backend tells a model: a fast shell with a fixed command set. */ + readonly description = + "A just-bash shell in a Dynamic Worker. Starts fast, with no container and no direct network. Good for cat, grep, sed, awk, jq, head, tail, sort, find, text transformations, and a built-in `git` (clone, status, diff, log) that works through the workspace. Cannot run npm, node, python, or binaries outside its built-in command set."; readonly id: string; readonly #options: WorkerShellBackendOptions; readonly #egress: WorkspaceEgressPolicy; diff --git a/packages/computer/src/client.test.ts b/packages/computer/src/client.test.ts index 035b0e9b..28b2ad3b 100644 --- a/packages/computer/src/client.test.ts +++ b/packages/computer/src/client.test.ts @@ -9,8 +9,13 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { WorkerJavaScriptBackend } from "./backends/worker-javascript/worker-javascript.js"; import { getWorkspace, type WorkspaceClient } from "./client.js"; +import type { WorkspaceBackendInfo } from "./runtime/runtime.js"; +import type { WorkspaceModuleBackend } from "./runtime/types.js"; +import { createAITools } from "./tools/ai-sdk/index.js"; import { WORKSPACE, type WorkspaceStubHost } from "./with-workspace.js"; import { type ThinkWorkspaceCompatibility, Workspace } from "./workspace.js"; @@ -71,6 +76,9 @@ function fakeRuntime(promisedProperties = false) { calls.push({ command: `dispose:${id}`, options }); return Promise.resolve(); }, + backends() { + return promisedProperties ? Promise.resolve([]) : []; + }, }, }; } @@ -329,3 +337,152 @@ describe("client runtime.exec — remote handle rebuild", () => { expect(disposedHandles()).toBe(1); }); }); + +// A callable module backend that answers every execution with the +// structured input it was given, so a test can follow `input` from the +// exec tool through a client to the backend and back. +function echoBackend(): WorkspaceModuleBackend { + return { + protocol: "module", + id: "echo", + type: "echo", + callable: true, + description: "Echoes its input.", + async connect() { + return { + async exec(input) { + const id = input.id ?? "echo-1"; + return { + id, + events: new ReadableStream({ + start(controller) { + controller.enqueue({ + id, + seq: 1, + name: "exit", + code: 0, + result: { received: input.input ?? null }, + }); + controller.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.resolve(), + disposeExec: () => Promise.resolve(), + }; + }, + }; +} + +describe("getWorkspace — backend information", () => { + // A Workspace with one callable JavaScript backend that describes its + // modules. The loader is never reached; only construction runs. + function workspaceWithJavaScript() { + return new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new WorkerJavaScriptBackend({ + loader: { load: () => ({ getEntrypoint: () => ({}) }) }, + modules: { "ws:weather": { forecast: () => null } }, + }), + ], + }); + } + + for (const [path, connect] of [ + ["local", (ws: Workspace) => getWorkspace({ [WORKSPACE]: ws })], + [ + "remote", + (ws: Workspace) => getWorkspace({ __getWorkspaceStub: () => Promise.resolve(ws.stub()) }), + ], + ] as const) { + it(`sends structured input through a ${path} client to a callable backend`, async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [echoBackend()], + }); + const client = await connect(workspace); + const exec = createAITools({ workspace: client }).exec as { + execute?: (input: unknown, options: unknown) => AsyncIterable; + }; + if (!exec.execute) throw new Error("exec has no execute function"); + + let last: unknown; + for await (const snapshot of exec.execute( + { command: "export default (input) => input", input: { value: 42 } }, + { toolCallId: "call", messages: [] }, + )) { + last = snapshot; + } + + expect(last).toMatchObject({ + backend: "echo", + exitCode: 0, + result: { received: { value: 42 } }, + }); + await workspace.close(); + }); + } + + for (const [path, connect] of [ + ["local", (ws: Workspace) => getWorkspace({ [WORKSPACE]: ws })], + [ + "remote", + (ws: Workspace) => getWorkspace({ __getWorkspaceStub: () => Promise.resolve(ws.stub()) }), + ], + ] as const) { + it(`leaves publish out on a ${path} client without assets`, async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [echoBackend()], + }); + const client = await connect(workspace); + + expect(client.assets).toBeUndefined(); + expect(createAITools({ workspace: client }).publish).toBeUndefined(); + await workspace.close(); + }); + } + + it("keeps its backend snapshot from being edited", async () => { + const client = await getWorkspace({ + [WORKSPACE]: new Workspace({ storage: new SQLiteTestStorage(), backends: [echoBackend()] }), + }); + const list = client.runtime.backends(); + + expect(() => (list as WorkspaceBackendInfo[]).pop()).toThrow(); + expect(client.runtime.backends()).toHaveLength(1); + }); + + for (const [path, connect] of [ + ["local", (ws: Workspace) => getWorkspace({ [WORKSPACE]: ws })], + [ + "remote", + (ws: Workspace) => getWorkspace({ __getWorkspaceStub: () => Promise.resolve(ws.stub()) }), + ], + ] as const) { + it(`answers backend questions on a ${path} client`, async () => { + const client = await connect(workspaceWithJavaScript()); + + const [backend, ...others] = client.runtime.backends(); + + expect(others).toEqual([]); + expect(backend).toMatchObject({ id: "worker-javascript", callable: true }); + expect(backend?.description).toContain("`ws:weather`: exports"); + }); + + it(`builds a callable exec tool from a ${path} client`, async () => { + const client = await connect(workspaceWithJavaScript()); + const tools = createAITools({ workspace: client }); + const exec = tools.exec as { description?: string; inputSchema?: unknown } | undefined; + if (!(exec?.inputSchema instanceof z.ZodType)) + throw new Error("exec has no zod input schema"); + const schema = z.toJSONSchema(exec.inputSchema) as { properties: Record }; + + expect(exec.description).toContain("`ws:weather`: exports `forecast`."); + expect(Object.keys(schema.properties)).toContain("input"); + }); + } +}); diff --git a/packages/computer/src/client.ts b/packages/computer/src/client.ts index 959b16c0..db89f879 100644 --- a/packages/computer/src/client.ts +++ b/packages/computer/src/client.ts @@ -31,7 +31,7 @@ // over RPC. import type { WorkspaceFilesystem } from "@cloudflare/dofs"; - +import type { WorkspaceBackendInfo } from "./runtime/runtime.js"; import type { WorkspaceRuntimeEvent, WorkspaceRuntimeExecHandle, @@ -216,6 +216,8 @@ export interface WorkspaceRuntimeClient { ): Promise>; killExec(id: string, options?: RuntimeKillOptions): Promise; disposeExec(id: string, options?: { backend?: string }): Promise; + /** What each backend says about itself, as of when the client was created. */ + backends(): readonly WorkspaceBackendInfo[]; } // Options accepted by the plain `exec` form, common to both paths. @@ -275,6 +277,10 @@ function makeRuntimeClient( // Adapts the handle the underlying `exec` resolves to: identity on // the local path (already a host handle), rebuild on the remote path. rehydrate: RehydrateRuntimeHandle, + // Backends are fixed when the Workspace is constructed, so one + // snapshot serves the client's lifetime. It keeps backends() + // synchronous over RPC, where the tools need it at construction. + backends: readonly WorkspaceBackendInfo[], ): WorkspaceRuntimeClient { async function exec( commandOrStrings: string | TemplateStringsArray, @@ -307,7 +313,18 @@ function makeRuntimeClient( const killExec = (id: string, options?: RuntimeKillOptions) => runtime.killExec(id, options); const disposeExec = (id: string, options?: { backend?: string }) => runtime.disposeExec(id, options); - return { exec, getExec, killExec, disposeExec } as WorkspaceRuntimeClient; + // Frozen so a caller that edits the list cannot change what later + // tool sets see. + const snapshot: readonly WorkspaceBackendInfo[] = Object.freeze( + backends.map((info) => Object.freeze({ ...info })), + ); + return { + exec, + getExec, + killExec, + disposeExec, + backends: () => snapshot, + } as WorkspaceRuntimeClient; } function withExecutionId( @@ -338,10 +355,13 @@ function makeClient( rehydrate: (handle: unknown, metadata?: RuntimeHandleMetadata) => unknown, dispose: () => void, useThink: boolean, + backends: readonly WorkspaceBackendInfo[], + hasAssets: boolean, ): WorkspaceClient { const runtime = makeRuntimeClient( surface.runtime as UnderlyingRuntime, rehydrate as RehydrateRuntimeHandle, + backends, ); const client: WorkspaceClient = { get fs() { @@ -351,8 +371,10 @@ function makeClient( get git() { return surface.git; }, + // Undefined when the Workspace has no assets publisher, so tools + // built from the client leave `publish` out, as they do locally. get assets() { - return surface.assets; + return hasAssets ? surface.assets : undefined; }, get artifacts() { return surface.artifacts; @@ -389,6 +411,8 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise h, () => {}, local.useThink, + local.runtime.backends(), + local.assets !== undefined, ); } // Remote path: fetch the stub over RPC and delegate to it. Handle @@ -403,6 +427,8 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise void })[Symbol.dispose]?.(); }, await stub.useThink, + await stub.runtime.backends(), + await stub.hasAssets, ); } catch (error) { (stub as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); diff --git a/packages/computer/src/index.ts b/packages/computer/src/index.ts index 916be71c..ee722937 100644 --- a/packages/computer/src/index.ts +++ b/packages/computer/src/index.ts @@ -72,6 +72,7 @@ export { export type { WorkspaceEgressPolicy } from "./runtime/egress.js"; export type { WorkspaceOutputOptions } from "./runtime/output-files.js"; export type { TruncatedOutput } from "./runtime/output-spool.js"; +export type { WorkspaceBackendInfo } from "./runtime/runtime.js"; export type { ModuleExecutionEnvelope, ModuleExecutionInput, diff --git a/packages/computer/src/modules/container.test.ts b/packages/computer/src/modules/container.test.ts new file mode 100644 index 00000000..2001f67f --- /dev/null +++ b/packages/computer/src/modules/container.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from "vitest"; + +import type { + WorkspaceModuleCallContext, + WorkspaceModuleFunction, + WorkspaceModuleHost, +} from "../runtime/types.js"; +import type { ExecSyncResult } from "../shell.js"; +import { createContainerModule } from "./container.js"; + +interface ExecOptions { + readonly backend: string; + readonly encoding: "utf8"; + readonly cwd?: string; + readonly env?: Record; + readonly stdin?: string; + readonly timeoutMs: number; + readonly output?: { readonly maxBytes: number }; +} + +interface Run { + readonly command: string; + readonly options: ExecOptions; + killed: boolean; +} + +// An in-memory Workspace runtime that records each command and finishes +// it with the given output, or holds it open until it is killed. +function fakeRuntime(output: { + exitCode?: number; + stdout?: string; + stderr?: string; + hang?: boolean; + truncated?: Record; + sync?: ExecSyncResult; +}) { + const runs: Run[] = []; + const runtime = { + backends: () => [ + { id: "container-shell", protocol: "command" as const, callable: false }, + { id: "linux", protocol: "command" as const, callable: true }, + { id: "worker-javascript", protocol: "module" as const, callable: true }, + ], + async exec(command: string, options: ExecOptions) { + const run: Run = { command, options, killed: false }; + runs.push(run); + let stop: () => void = () => undefined; + const stopped = new Promise((resolve) => { + stop = resolve; + }); + return { + async result() { + if (output.hang) await stopped; + return { + exitCode: run.killed ? 130 : (output.exitCode ?? 0), + stdout: output.stdout ?? "", + stderr: output.stderr ?? "", + ...(output.truncated === undefined ? {} : { truncated: output.truncated }), + sync: output.sync ?? { status: "complete" as const, applied: 0, skipped: [] }, + }; + }, + async kill() { + run.killed = true; + stop(); + }, + }; + }, + }; + return { runtime, runs }; +} + +// Build the module's functions the way the backend does when it connects. +function build( + runtime: ReturnType["runtime"], + options?: Parameters[0], +): { readonly exec: WorkspaceModuleFunction } { + // SAFETY: The module only calls runtime.exec, and the fake implements the part of WorkspaceRuntime it uses. + const host = { runtime, git: undefined, artifacts: undefined } as unknown as WorkspaceModuleHost; + const functions = createContainerModule(options)(host); + const exec = functions.exec; + if (!exec) throw new Error("ws:container must export exec"); + return { exec }; +} + +function callContext( + overrides: Partial = {}, +): WorkspaceModuleCallContext { + return { + signal: new AbortController().signal, + deadline: Date.now() + 60_000, + access: "read-write", + resolvePath: async (path) => path, + ...overrides, + }; +} + +describe("createContainerModule", () => { + it("runs the command on the container backend and returns its output", async () => { + const { runtime, runs } = fakeRuntime({ exitCode: 3, stdout: "out", stderr: "err" }); + const container = build(runtime); + + await expect( + container.exec( + ["npm test", { cwd: "/workspace/app", env: { CI: "1" }, stdin: "y\n" }], + callContext(), + ), + ).resolves.toEqual({ + exitCode: 3, + stdout: "out", + stderr: "err", + sync: { status: "complete", skipped: [], skippedCount: 0 }, + }); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + command: "npm test", + options: { + backend: "container-shell", + encoding: "utf8", + cwd: "/workspace/app", + env: { CI: "1" }, + stdin: "y\n", + }, + }); + }); + + it("uses the configured backend id and omits unset options", async () => { + const { runtime, runs } = fakeRuntime({}); + const container = build(runtime, { backend: "linux" }); + + await container.exec(["ls"], callContext()); + expect(Object.keys(runs[0]?.options ?? {}).sort()).toEqual([ + "backend", + "encoding", + "output", + "timeoutMs", + ]); + expect(runs[0]?.options.backend).toBe("linux"); + }); + + it("refuses to run on a read-only backend", async () => { + const { runtime, runs } = fakeRuntime({}); + const container = build(runtime); + + await expect(container.exec(["ls"], callContext({ access: "read" }))).rejects.toThrow( + /write access/, + ); + expect(runs).toHaveLength(0); + }); + + it("caps the timeout at the time left before the host call deadline", async () => { + const { runtime, runs } = fakeRuntime({}); + const container = build(runtime); + + await container.exec( + ["sleep 1", { timeoutMs: 600_000 }], + callContext({ deadline: Date.now() + 5_000 }), + ); + await container.exec(["sleep 1", { timeoutMs: 1_000 }], callContext()); + expect(runs[0]?.options.timeoutMs).toBeLessThanOrEqual(5_000); + expect(runs[1]?.options.timeoutMs).toBe(1_000); + }); + + it("kills the command when the call is aborted", async () => { + const { runtime, runs } = fakeRuntime({ hang: true }); + const container = build(runtime); + const controller = new AbortController(); + + const pending = container.exec(["sleep 100"], callContext({ signal: controller.signal })); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(new Error("cancelled")); + + await expect(pending).resolves.toMatchObject({ exitCode: 130 }); + expect(runs[0]?.killed).toBe(true); + }); + + it("does not start a command once the call is aborted", async () => { + const { runtime, runs } = fakeRuntime({}); + const container = build(runtime); + const controller = new AbortController(); + controller.abort(new Error("cancelled")); + + await expect( + container.exec(["ls"], callContext({ signal: controller.signal })), + ).rejects.toThrow("cancelled"); + expect(runs).toHaveLength(0); + }); + + it("asks the runtime to cut output and passes on where it saved the rest", async () => { + const saved = { + status: "saved", + path: "/.computer/output/container-shell.run.stdout.log", + totalBytes: 900_000, + totalLines: 20_000, + firstLine: 18_001, + partialLine: false, + }; + const { runtime, runs } = fakeRuntime({ stdout: "tail\n", truncated: { stdout: saved } }); + const container = build(runtime, { maxOutputBytes: 1024 }); + + await expect(container.exec(["npm test"], callContext())).resolves.toEqual({ + exitCode: 0, + stdout: "tail\n", + stderr: "", + truncated: { stdout: saved }, + sync: { status: "complete", skipped: [], skippedCount: 0 }, + }); + expect(runs[0]?.options.output).toEqual({ maxBytes: 1024 }); + }); + + it("truncates each stream on UTF-8 boundaries when the runtime did not", async () => { + const { runtime } = fakeRuntime({ stdout: "a🙂b", stderr: "🙂🙂" }); + const container = build(runtime, { maxOutputBytes: 5 }); + + await expect(container.exec(["echo"], callContext())).resolves.toEqual({ + exitCode: 0, + stdout: "a🙂\n\n[truncated, 1 more bytes]", + stderr: "🙂\n\n[truncated, 4 more bytes]", + sync: { status: "complete", skipped: [], skippedCount: 0 }, + }); + }); + + it.each([ + ["no arguments", [], /takes a command/], + ["too many arguments", ["ls", {}, {}], /takes a command/], + ["an empty command", [" "], /non-empty string/], + ["a non-string command", [["ls"]], /non-empty string/], + ["non-object options", ["ls", "fast"], /options must be an object/], + ["an unknown option", ["ls", { shell: "zsh" }], /unknown option "shell"/], + ["a non-string cwd", ["ls", { cwd: 1 }], /cwd must be a string/], + ["a non-string env value", ["ls", { env: { A: 1 } }], /env "A" must be a string/], + ["a non-positive timeout", ["ls", { timeoutMs: 0 }], /timeoutMs must be a positive number/], + ])("rejects %s without running anything", async (_label, args, message) => { + const { runtime, runs } = fakeRuntime({}); + const container = build(runtime); + + // SAFETY: Each case hands exec arguments that isolate code could send; the cast only widens the test table's inferred type. + await expect(container.exec(args as never, callContext())).rejects.toThrow(message); + expect(runs).toHaveLength(0); + }); + + it("rejects a bad maxOutputBytes at construction", () => { + expect(() => createContainerModule({ maxOutputBytes: 0 })).toThrow(/maxOutputBytes/); + }); + + it("fails when it connects to a Workspace without the backend", () => { + const { runtime } = fakeRuntime({}); + expect(() => build(runtime, { backend: "missing" })).toThrow(/no backend "missing"/); + }); + + it("accepts a callable shell backend", () => { + const { runtime } = fakeRuntime({}); + expect(() => build(runtime, { backend: "linux" })).not.toThrow(); + }); + + it("refuses a backend that runs module source", () => { + const { runtime } = fakeRuntime({}); + expect(() => build(runtime, { backend: "worker-javascript" })).toThrow( + /runs module source, not shell commands/, + ); + }); + + it("reports a sync that has not reached the Workspace, and skipped paths", async () => { + const { runtime } = fakeRuntime({ + sync: { + status: "pending", + applied: 1, + error: "pull failed", + skipped: [ + { + path: "/workspace/ro/x.txt", + mountRoot: "/workspace/ro", + op: "write", + reason: "read-only", + }, + ], + }, + }); + const container = build(runtime); + + await expect(container.exec(["touch ro/x.txt"], callContext())).resolves.toMatchObject({ + sync: { + status: "pending", + error: "pull failed", + skipped: ["/workspace/ro/x.txt"], + skippedCount: 1, + }, + }); + }); + + it("caps a large skipped list so the result fits the bridge limits", async () => { + const skipped = Array.from({ length: 5000 }, (_, index) => ({ + path: `/workspace/ro/${index}.txt`, + mountRoot: "/workspace/ro", + op: "write" as const, + reason: "read-only" as const, + })); + const { runtime } = fakeRuntime({ + sync: { status: "pending", applied: 0, error: "e".repeat(10_000), skipped }, + }); + const container = build(runtime); + + const result = (await container.exec(["touch ro/*"], callContext())) as { + sync: { skipped: string[]; skippedCount: number; error: string }; + }; + expect(result.sync.skipped).toHaveLength(100); + expect(result.sync.skippedCount).toBe(5000); + expect(new TextEncoder().encode(result.sync.error).byteLength).toBeLessThan(1200); + }); + + it("describes itself for a model", () => { + expect(createContainerModule().description).toContain("full Linux container"); + }); +}); diff --git a/packages/computer/src/modules/container.ts b/packages/computer/src/modules/container.ts new file mode 100644 index 00000000..63040fd6 --- /dev/null +++ b/packages/computer/src/modules/container.ts @@ -0,0 +1,252 @@ +// `ws:container`: lets isolate JavaScript run shell commands in the +// Workspace's container backend. +// +// Installed on a WorkerJavaScriptBackend, it turns the container into a +// library the JavaScript backend calls, rather than a second backend +// the model has to choose between: +// +// import { exec } from "ws:container"; +// const { exitCode, stdout } = await exec("npm test", { cwd: "/workspace" }); +// +// Each call goes through `workspace.runtime.exec`, so the container +// sees the same files as the isolate: the usual sync bracket pushes +// pending Workspace writes before the command and pulls the +// container's changes after it. + +import type { + WorkspaceModuleCallContext, + WorkspaceModuleFactory, + WorkspaceModuleFunction, + WorkspaceModuleFunctions, + WorkspaceModuleHost, + WorkspaceRuntimeValue, +} from "../runtime/types.js"; +import type { ExecSyncResult } from "../shell.js"; +import { truncateText } from "../text-truncation.js"; + +const DEFAULT_BACKEND = "container-shell"; +const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024; +const EXEC_OPTION_KEYS = new Set(["cwd", "env", "stdin", "timeoutMs"]); +const MAX_SKIPPED_PATHS = 100; +const MAX_SYNC_ERROR_BYTES = 1024; + +/** Options for {@link createContainerModule}. */ +export interface ContainerModuleOptions { + /** Id of the container backend. Defaults to `"container-shell"`. */ + readonly backend?: string; + /** + * Largest standard output and standard error returned to the + * isolate, in bytes per stream. Longer output keeps its last lines + * (up to 2000), and the runtime saves all of it to a Workspace file + * that `truncated` names. Defaults to 64 KiB. Keep both streams well + * under the backend's `maxCapabilityBytes`. + */ + readonly maxOutputBytes?: number; +} + +/** + * Build the `ws:container` host module over the Workspace's container + * backend. + * + * It exports `exec(command, { cwd, env, stdin, timeoutMs })`, which + * returns `{ exitCode, stdout, stderr, sync }` once the command + * finishes. `sync` reports whether the container's file changes reached + * the Workspace, the first 100 paths it skipped, and `skippedCount`. A + * non-zero exit code is a normal result, not an error. Cancelling the + * execution kills the command. + * + * A container command can write to the Workspace, so `exec` refuses to + * run on a read-only backend. Network access follows the container + * backend's own egress setting; the JavaScript backend's does not apply. + * + * @param options - Which backend to use and how much output to return. + * @returns The module to pass as `modules["ws:container"]`. Its + * `description` tells the model how to use it. + * @throws When `maxOutputBytes` is not a positive integer. The module + * also throws when the backend connects if the Workspace has no + * such backend, or that backend runs module source rather than shell + * commands. A callable shell backend is fine. + */ +export function createContainerModule( + options: ContainerModuleOptions = {}, +): WorkspaceModuleFactory { + const backend = options.backend ?? DEFAULT_BACKEND; + const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + if (!Number.isInteger(maxOutputBytes) || maxOutputBytes <= 0) { + throw new Error("createContainerModule: maxOutputBytes must be a positive integer."); + } + + const create = (host: WorkspaceModuleHost): WorkspaceModuleFunctions => { + // The factory runs when the JavaScript backend connects, so a + // missing or wrong container backend fails there, before any code + // runs, rather than on the first exec. + const target = host.runtime.backends().find((info) => info.id === backend); + if (target === undefined) { + throw new Error( + `ws:container: the Workspace has no backend ${JSON.stringify(backend)}. Register a ContainerBackend, or pass createContainerModule({ backend }).`, + ); + } + // A module backend reads `exec` source as code, so a shell command + // sent there would run as JavaScript, or start a nested run. + if (target.protocol !== "command") { + throw new Error( + `ws:container: backend ${JSON.stringify(backend)} runs module source, not shell commands.`, + ); + } + return { exec: execOn(host) }; + }; + const execOn = + (host: WorkspaceModuleHost): WorkspaceModuleFunction => + async (args, context) => { + if (context.access !== "read-write") { + throw new Error("ws:container exec requires Workspace write access."); + } + const request = parseExecArgs(args); + const timeoutMs = remainingTime(request.timeoutMs, context); + context.signal.throwIfAborted(); + + const handle = await host.runtime.exec(request.command, { + backend, + encoding: "utf8", + timeoutMs, + // The runtime keeps only the end of long output in memory and + // saves the rest to a file, so a noisy command cannot exhaust + // the Durable Object. + output: { maxBytes: maxOutputBytes }, + ...(request.cwd === undefined ? {} : { cwd: request.cwd }), + ...(request.env === undefined ? {} : { env: request.env }), + ...(request.stdin === undefined ? {} : { stdin: request.stdin }), + }); + // Cancelling the isolate execution, or passing the host call + // deadline, stops the command instead of leaving it running. + const kill = () => void handle.kill().catch(() => undefined); + if (context.signal.aborted) kill(); + else context.signal.addEventListener("abort", kill, { once: true }); + try { + const result = await handle.result(); + // A Workspace with `output: false` returns everything, so cut + // here too; the runtime already cut a stream it reports. + return { + exitCode: result.exitCode, + stdout: + result.truncated?.stdout === undefined + ? truncateText(result.stdout, maxOutputBytes) + : result.stdout, + stderr: + result.truncated?.stderr === undefined + ? truncateText(result.stderr, maxOutputBytes) + : result.stderr, + ...(result.truncated === undefined ? {} : { truncated: { ...result.truncated } }), + sync: syncSummary(result.sync), + }; + } finally { + context.signal.removeEventListener("abort", kill); + } + }; + return Object.assign(create, { description: DESCRIPTION }); +} + +const DESCRIPTION = [ + "Runs shell commands in a full Linux container that shares this workspace's files.", + "Use it for npm, node, python, package managers, and native binaries. The container can take a while to start on first use.", + 'Call `const { exitCode, stdout, stderr } = await exec("npm test", { cwd: "/workspace" })`. Options are `cwd`, `env`, `stdin`, and `timeoutMs`.', + "Output comes back when the command finishes. Long output keeps its last lines; `truncated.stdout.path` (or `truncated.stderr.path`) then names a workspace file holding all of it. The file usually sits outside this code's `node:fs` root, so return the path and open it with the agent's read or grep tools. A non-zero `exitCode` is returned, not thrown.", + "`sync.status` is `pending` if the container's file changes have not reached the workspace yet. The container's changes win over files written meanwhile, so do not write files the command also writes while it runs.", +].join(" "); + +// How the container's file changes came back to the Workspace. A +// "pending" status means they did not, yet; `skipped` lists paths the +// container wrote that the Workspace refused, such as read-only mounts. +// +// The list is capped, and the error cut short, so a command that skips +// thousands of paths still fits within the bridge's response limits; +// otherwise the call would fail after the command had already run. +// `skippedCount` is the full count. +function syncSummary(sync: ExecSyncResult) { + return { + status: sync.status, + skipped: sync.skipped.slice(0, MAX_SKIPPED_PATHS).map((entry) => entry.path), + skippedCount: sync.skipped.length, + ...(sync.status === "pending" && sync.error !== undefined + ? { error: truncateText(sync.error, MAX_SYNC_ERROR_BYTES) } + : {}), + }; +} + +interface ExecRequest { + readonly command: string; + readonly cwd: string | undefined; + readonly env: Record | undefined; + readonly stdin: string | undefined; + readonly timeoutMs: number | undefined; +} + +// Arguments come from isolate code. A malformed call throws, and the +// bridge hands that error back to the isolate as a rejected promise. +function parseExecArgs(args: readonly WorkspaceRuntimeValue[]): ExecRequest { + if (args.length === 0 || args.length > 2) { + throw new TypeError("exec(command, options?) takes a command and an optional options object."); + } + const [command, options] = args; + if (typeof command !== "string" || command.trim().length === 0) { + throw new TypeError("exec: command must be a non-empty string."); + } + if (options === undefined || options === null) { + return { command, cwd: undefined, env: undefined, stdin: undefined, timeoutMs: undefined }; + } + if (typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("exec: options must be an object."); + } + for (const key of Object.keys(options)) { + if (!EXEC_OPTION_KEYS.has(key)) { + throw new TypeError( + `exec: unknown option ${JSON.stringify(key)}. Use cwd, env, stdin, or timeoutMs.`, + ); + } + } + return { + command, + cwd: optionalString(options.cwd, "cwd"), + env: optionalEnv(options.env), + stdin: optionalString(options.stdin, "stdin"), + timeoutMs: optionalTimeout(options.timeoutMs), + }; +} + +function optionalString(value: WorkspaceRuntimeValue | undefined, name: string) { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") throw new TypeError(`exec: ${name} must be a string.`); + return value; +} + +function optionalEnv(value: WorkspaceRuntimeValue | undefined) { + if (value === undefined || value === null) return undefined; + if (typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("exec: env must be an object of strings."); + } + const env: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry !== "string") { + throw new TypeError(`exec: env ${JSON.stringify(key)} must be a string.`); + } + env[key] = entry; + } + return env; +} + +function optionalTimeout(value: WorkspaceRuntimeValue | undefined) { + if (value === undefined || value === null) return undefined; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new TypeError("exec: timeoutMs must be a positive number."); + } + return value; +} + +// The command must finish before the host call deadline, or the +// isolate stops waiting while the container keeps working. Cap the +// requested timeout at the time left. +function remainingTime(requested: number | undefined, context: WorkspaceModuleCallContext) { + const remaining = context.deadline - Date.now(); + if (remaining <= 0) throw new Error("exec: the host call deadline has already passed."); + return requested === undefined ? remaining : Math.min(requested, remaining); +} diff --git a/packages/computer/src/modules/git.ts b/packages/computer/src/modules/git.ts index cce42eed..33b3380b 100644 --- a/packages/computer/src/modules/git.ts +++ b/packages/computer/src/modules/git.ts @@ -85,7 +85,7 @@ export function createGitModule(options: GitModuleOptions = {}): WorkspaceModule }, }); return Object.assign(create, { - description: `The workspace's Git repository tools: \`status({ dir })\`, \`diff({ dir })\`, \`log({ dir, depth })\`, \`clone({ url, dir })\`, and \`cli({ argv, cwd })\` for any other git subcommand.${allowNetwork ? "" : " Network commands such as clone, fetch, and push are not allowed."}`, + description: `The workspace's Git repository tools: \`status({ dir })\`, \`diff({ dir })\`, \`log({ dir, depth })\`, \`clone({ url, dir })\`, and \`cli({ argv, cwd })\` for any other git subcommand, including a leading \`-C \`.${allowNetwork ? "" : " Network commands such as clone, fetch, and push are not allowed."}`, }); } diff --git a/packages/computer/src/runtime/bridge.test.ts b/packages/computer/src/runtime/bridge.test.ts index 4c1a83b0..423f3145 100644 --- a/packages/computer/src/runtime/bridge.test.ts +++ b/packages/computer/src/runtime/bridge.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { WorkspaceRuntimeBridge } from "./bridge.js"; +import { type BridgeResponse, WorkspaceRuntimeBridge } from "./bridge.js"; import type { WorkspaceRuntimeCapability } from "./capability.js"; const encoder = new TextEncoder(); -const args = JSON.stringify(["value"]); +const args = ["value"]; function bridge(limits: { maxCalls?: number; @@ -17,8 +17,9 @@ function bridge(limits: { }); } -async function message(response: Promise) { - return (JSON.parse(await response) as { error?: { message?: string } }).error?.message; +async function message(response: Promise) { + const settled = await response; + return "error" in settled ? settled.error.message : undefined; } describe("WorkspaceRuntimeBridge cumulative limits", () => { @@ -32,7 +33,8 @@ describe("WorkspaceRuntimeBridge cumulative limits", () => { }); it("accepts requests at the cumulative byte boundary and rejects the next request", async () => { - const bytes = encoder.encode(args).byteLength; + // ["value"]: 8 for the array plus 5 UTF-8 bytes for the string. + const bytes = 8 + encoder.encode("value").byteLength; const target = bridge({ maxTotalRequestBytes: bytes * 2 }); await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); @@ -42,8 +44,8 @@ describe("WorkspaceRuntimeBridge cumulative limits", () => { }); it("accepts responses at the cumulative byte boundary and rejects the next response", async () => { - const sample = await bridge({}).call("host/ws:test.run", args); - const bytes = encoder.encode(sample).byteLength; + // The host function returns "ok": 2 UTF-8 bytes. + const bytes = encoder.encode("ok").byteLength; const target = bridge({ maxTotalResponseBytes: bytes * 2 }); await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); @@ -51,9 +53,52 @@ describe("WorkspaceRuntimeBridge cumulative limits", () => { `responses exceed ${bytes * 2} bytes`, ); }); + + it("counts error responses against the cumulative response budget", async () => { + const target = new WorkspaceRuntimeBridge({} as WorkspaceRuntimeCapability, { + maxTotalResponseBytes: 256, + hostModules: new Map([ + [ + "ws:test", + { + fail: async () => { + throw new Error("x".repeat(100)); + }, + }, + ], + ]), + }); + await expect(message(target.call("host/ws:test.fail", []))).resolves.toBe("x".repeat(100)); + await expect(message(target.call("host/ws:test.fail", []))).resolves.toContain( + "responses exceed 256 bytes", + ); + }); }); describe("WorkspaceRuntimeBridge host modules", () => { + function echo(values: unknown[]) { + return new WorkspaceRuntimeBridge({} as WorkspaceRuntimeCapability, { + hostModules: new Map([ + [ + "ws:test", + { + run: async () => values[0], + args: async (received) => received, + }, + ], + ]), + }); + } + + it("leaves undefined fields out and turns undefined array items into null", async () => { + await expect( + echo([{ value: 1, optional: undefined, list: [1, undefined] }]).call("host/ws:test.run", []), + ).resolves.toEqual({ result: { value: 1, list: [1, null] } }); + await expect( + echo([]).call("host/ws:test.args", [undefined, { a: undefined, b: 2 }]), + ).resolves.toEqual({ result: [null, { b: 2 }] }); + }); + it("calls a host function on its module", async () => { const functions = { async name() { @@ -66,10 +111,105 @@ describe("WorkspaceRuntimeBridge host modules", () => { const target = new WorkspaceRuntimeBridge({} as WorkspaceRuntimeCapability, { hostModules: new Map([["ws:test", functions]]), }); - await expect(target.call("host/ws:test.run", args)).resolves.toBe( - JSON.stringify({ result: "ok" }), + await expect(target.call("host/ws:test.run", [])).resolves.toEqual({ result: "ok" }); + }); +}); + +describe("WorkspaceRuntimeBridge values", () => { + function echoBridge(maxPayloadBytes?: number) { + return new WorkspaceRuntimeBridge({} as WorkspaceRuntimeCapability, { + ...(maxPayloadBytes === undefined ? {} : { maxPayloadBytes }), + hostModules: new Map([["ws:test", { run: async (values) => values[0] ?? null }]]), + }); + } + + it("passes plain values through without encoding them", async () => { + await expect( + echoBridge().call("host/ws:test.run", [{ nested: [1, "two", null, { three: true }] }]), + ).resolves.toEqual({ result: { nested: [1, "two", null, { three: true }] } }); + }); + + it.each([ + ["a function", () => 1], + ["a class instance", new Date(0)], + ])("rejects %s in a request before it reaches the host", async (_label, value) => { + await expect(message(echoBridge().call("host/ws:test.run", [value]))).resolves.toContain( + "plain data", ); }); + + it("rejects a cyclic request", async () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + await expect(message(echoBridge().call("host/ws:test.run", [cyclic]))).resolves.toContain( + "acyclic", + ); + }); + + it("rejects a request of many empty values by the payload limit", async () => { + await expect( + message(echoBridge(256).call("host/ws:test.run", [new Array(300).fill("")])), + ).resolves.toContain("request exceeds 256 bytes"); + await expect( + message(echoBridge(256).call("host/ws:test.run", [new Array(40).fill({})])), + ).resolves.toContain("request exceeds 256 bytes"); + }); + + it("keeps an own __proto__ field in a host module result", async () => { + const value = JSON.parse('{"__proto__": {"a": 1}, "b": 2}') as unknown; + const response = await echoBridge().call("host/ws:test.run", [value]); + expect(Object.keys((response as { result: object }).result)).toEqual(["__proto__", "b"]); + }); + + it("keeps a path that fits beside a short error message", async () => { + const path = `/${"p".repeat(600)}`; + const target = new WorkspaceRuntimeBridge({} as WorkspaceRuntimeCapability, { + maxPayloadBytes: 1024, + hostModules: new Map([ + [ + "ws:test", + { + run: async () => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT", path }); + }, + }, + ], + ]), + }); + await expect(target.call("host/ws:test.run", [])).resolves.toEqual({ + error: { message: "ENOENT", code: "ENOENT", path }, + }); + }); + + it("keeps an error with a long path within the payload limit", async () => { + const path = `/${"p".repeat(900)}`; + const target = new WorkspaceRuntimeBridge({} as WorkspaceRuntimeCapability, { + maxPayloadBytes: 1024, + hostModules: new Map([ + [ + "ws:test", + { + run: async () => { + throw Object.assign(new Error(`ENOENT: no such file, open '${path}'`), { + code: "ENOENT", + path, + }); + }, + }, + ], + ]), + }); + const response = await target.call("host/ws:test.run", []); + expect(response).toMatchObject({ error: { code: "ENOENT" } }); + expect(response).not.toHaveProperty("error.path"); + expect(encoder.encode(JSON.stringify(response)).byteLength).toBeLessThanOrEqual(1024); + }); + + it("rejects a request over the payload limit by its UTF-8 size", async () => { + await expect( + message(echoBridge(256).call("host/ws:test.run", ["é".repeat(200)])), + ).resolves.toContain("request exceeds 256 bytes"); + }); }); describe("WorkspaceRuntimeBridge assertResult", () => { diff --git a/packages/computer/src/runtime/bridge.ts b/packages/computer/src/runtime/bridge.ts index 31d4a27c..4f6468eb 100644 --- a/packages/computer/src/runtime/bridge.ts +++ b/packages/computer/src/runtime/bridge.ts @@ -1,7 +1,12 @@ import { RpcTarget } from "cloudflare:workers"; +import { utf8Prefix } from "../text-truncation.js"; import { assertRuntimeValue, type WorkspaceRuntimeCapability } from "./capability.js"; -import type { WorkspaceModuleCallContext, WorkspaceModuleFunctions } from "./types.js"; +import type { + WorkspaceModuleCallContext, + WorkspaceModuleFunctions, + WorkspaceRuntimeValue, +} from "./types.js"; export class WorkspaceRuntimeBridge extends RpcTarget { readonly #capability: WorkspaceRuntimeCapability; @@ -14,7 +19,7 @@ export class WorkspaceRuntimeBridge extends RpcTarget { readonly #maxTotalResponseBytes: number; readonly #maxResultBytes: number; readonly #onAttachOutput?: (readable: ReadableStream) => Promise; - readonly #inFlight = new Set>(); + readonly #inFlight = new Set>(); readonly #abortControllers = new Set(); #cancelled = false; #callTimedOut = false; @@ -71,13 +76,22 @@ export class WorkspaceRuntimeBridge extends RpcTarget { } } - call(name: string, argsJson: string): Promise { - const requestBytes = new TextEncoder().encode(argsJson).byteLength; + // The one entry point for isolate code. Arguments and results cross + // as real values through Workers RPC; this method is the proxy that + // keeps the limits on them, so untrusted code cannot overload the + // Durable Object with calls, concurrency, time, or bytes. + call(name: string, args: unknown[]): Promise { const reject = (message: string) => - Promise.resolve(encodeBoundedError(new Error(message), this.#maxPayloadBytes)); + Promise.resolve(boundedError(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 (typeof name !== "string" || !Array.isArray(args)) { + return reject("Workspace capability calls take a name and an argument list."); + } + let requestBytes: number; + try { + requestBytes = measureValue(args, this.#maxPayloadBytes, "request"); + } catch (error) { + return Promise.resolve(boundedError(error, this.#maxPayloadBytes)); } if (this.#inFlight.size >= this.#maxConcurrentCalls) { return reject( @@ -97,9 +111,7 @@ export class WorkspaceRuntimeBridge extends RpcTarget { 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); + const operation = respond(async () => { if (name.startsWith("host/")) { return this.#callHostModule(name, args, { signal: abort.signal, @@ -185,10 +197,13 @@ export class WorkspaceRuntimeBridge extends RpcTarget { this.#inFlight.delete(operation); this.#abortControllers.delete(abort); }); + // Errors count against the response budget too. The budget error + // itself is short and not counted, and `maxCalls` bounds how many + // the isolate can see. return call.then((response) => { - const bytes = new TextEncoder().encode(response).byteLength; + const bytes = "result" in response ? response.bytes : errorBytes(response); if (this.#responseBytes + bytes > this.#maxTotalResponseBytes) { - return encodeBoundedError( + return boundedError( new Error( `Workspace execution capability responses exceed ${this.#maxTotalResponseBytes} bytes.`, ), @@ -196,7 +211,7 @@ export class WorkspaceRuntimeBridge extends RpcTarget { ); } this.#responseBytes += bytes; - return response; + return "result" in response ? { result: response.result } : response; }); } @@ -228,113 +243,156 @@ export class WorkspaceRuntimeBridge extends RpcTarget { if (typeof fn !== "function") { throw new Error(`Unknown Workspace host module call ${JSON.stringify(name)}.`); } - assertBridgeValues(args); // Called on its module, so a method that uses `this` still works. - const result = (await fn.call(functions, args, context)) ?? null; - assertBridgeValues([result]); - return result; + const result = await fn.call(functions, hostArguments(args), context); + return hostValue(result ?? null); } } -function assertBridgeValues( - values: unknown[], -): asserts values is import("./types.js").WorkspaceRuntimeValue[] { +function hostArguments(args: unknown[]): WorkspaceRuntimeValue[] { + return args.map((arg) => hostValue(arg ?? null)); +} + +// Host module values follow JSON: an undefined object field is left +// out, and an undefined array item becomes null, as JSON.stringify +// does. Native RPC would otherwise carry undefined through, and the +// isolate's result check rejects it. +function hostValue(value: unknown): WorkspaceRuntimeValue { const seen = new Set(); - const visit = (value: unknown): void => { + const visit = (item: unknown): WorkspaceRuntimeValue => { if ( - value === null || - typeof value === "boolean" || - typeof value === "string" || - (typeof value === "number" && Number.isFinite(value)) - ) - return; - if (typeof value !== "object") throw new Error("Host module values must be JSON-compatible."); - if (seen.has(value)) throw new Error("Host 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); + item === null || + typeof item === "boolean" || + typeof item === "string" || + (typeof item === "number" && Number.isFinite(item)) + ) { + return item; + } + if (typeof item !== "object") throw new Error("Host module values must be JSON-compatible."); + if (seen.has(item)) throw new Error("Host module values must be acyclic."); + seen.add(item); + let copy: WorkspaceRuntimeValue; + if (Array.isArray(item)) { + copy = item.map((child: unknown) => (child === undefined ? null : visit(child))); + } else { + const prototype = Object.getPrototypeOf(item); if (prototype !== Object.prototype && prototype !== null) { throw new Error("Host module values must contain only plain objects."); } - // An undefined field is absent, as in JSON. encodeBridgeValue drops it. - for (const item of Object.values(value as Record)) { - if (item !== undefined) visit(item); + const fields: Record = {}; + for (const [key, child] of Object.entries(item)) { + // defineProperty keeps an own `__proto__` key a field. + if (child !== undefined) { + Object.defineProperty(fields, key, { + value: visit(child), + enumerable: true, + writable: true, + configurable: true, + }); + } } + copy = fields; } - seen.delete(value); + seen.delete(item); + return copy; }; - for (const value of values) visit(value); + return 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) - .filter(([, child]) => child !== undefined) - .map(([key, child]) => [key, encodeBridgeValue(child)]), - }); - } - return value; -} +/** What the bridge sends back for one call: a value, or a bounded error. */ +export type BridgeResponse = + | { readonly result: unknown } + | { + readonly error: { readonly message: string; readonly code?: string; readonly path?: string }; + }; -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."); -} +// A response before the per-execution budget check, carrying its size. +type MeasuredResponse = + | { result: unknown; bytes: number } + | Extract; -function isByteArray(value: unknown): value is number[] { - return ( - Array.isArray(value) && - value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255) - ); +const encoder = new TextEncoder(); +const MAX_RESPONSE_VALUES = 4096; + +// Measure plain data the way it costs the Durable Object: UTF-8 bytes +// of strings and keys, raw bytes of byte arrays, and a small fixed cost +// per scalar. Anything that is not plain data is rejected, including +// functions and RPC stubs, which Workers RPC would otherwise carry into +// the host as live callbacks, and cycles. +function measureValue(value: unknown, maxBytes: number, kind: "request" | "response"): number { + let bytes = 0; + let values = 0; + const seen = new Set(); + const add = (count: number) => { + bytes += count; + if (bytes > maxBytes) { + throw new Error(`Workspace capability ${kind} exceeds ${maxBytes} bytes.`); + } + }; + const visit = (item: unknown): void => { + values += 1; + if (kind === "response" && values > MAX_RESPONSE_VALUES) { + throw new Error("Workspace capability response has too many values."); + } + if ( + item === null || + item === undefined || + typeof item === "boolean" || + typeof item === "number" + ) { + add(8); + return; + } + // Every value costs at least one byte, so the byte limit also bounds + // how many values the host walks, even for empty strings and objects. + if (typeof item === "string") { + add(Math.max(1, encoder.encode(item).byteLength)); + return; + } + if (item instanceof Uint8Array) { + add(Math.max(1, item.byteLength)); + return; + } + if (typeof item !== "object") { + throw new Error(`Workspace capability ${kind} values must be plain data.`); + } + if (seen.has(item)) throw new Error(`Workspace capability ${kind} values must be acyclic.`); + seen.add(item); + if (Array.isArray(item)) { + add(8); + for (const child of item) visit(child); + } else { + const prototype = Object.getPrototypeOf(item); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`Workspace capability ${kind} values must be plain data.`); + } + add(8); + for (const [key, child] of Object.entries(item)) { + add(Math.max(1, encoder.encode(key).byteLength)); + visit(child); + } + } + seen.delete(item); + }; + visit(value); + return bytes; } function withDeadline( - call: Promise, + call: Promise, timeoutMs: number, maxPayloadBytes: number, onTimeout: () => void, -): Promise { +): Promise { let timer: ReturnType | undefined; - const timeout = new Promise((resolve) => { + const timeout = new Promise((resolve) => { timer = setTimeout(() => { onTimeout(); - resolve( - encodeBoundedError(new Error("Workspace capability call timed out."), maxPayloadBytes), - ); + resolve(boundedError(new Error("Workspace capability call timed out."), maxPayloadBytes)); }, timeoutMs); }); return Promise.race([call, timeout]).finally(() => { @@ -342,75 +400,52 @@ function withDeadline( }); } -async function encodeCall(run: () => Promise, maxPayloadBytes: number) { +async function respond( + run: () => Promise, + maxPayloadBytes: number, +): Promise { 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; + return { result, bytes: measureValue(result, maxPayloadBytes, "response") }; } catch (error) { - return encodeBoundedError(error, maxPayloadBytes); + return boundedError(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); -} +// Room for the envelope's keys and punctuation around the strings. +const ERROR_OVERHEAD_BYTES = 64; -function encodeBoundedError(error: unknown, maxPayloadBytes: number) { +// An error the isolate can rebuild, cut to fit the payload limit as a +// whole. `code` and `path` carry node:fs error details. Each is kept if +// it fits beside the message, or beside half the room when the message +// is long, and the message is cut to what is left. +function boundedError(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; + let room = Math.max(0, maxPayloadBytes - ERROR_OVERHEAD_BYTES); + const messageReserve = Math.min(encoder.encode(message).byteLength, room / 2); + const details: { code?: string; path?: string } = {}; + for (const key of ["code", "path"] as const) { + const detail = value?.[key]; + if (typeof detail !== "string") continue; + const bytes = encoder.encode(detail).byteLength; + if (bytes > room - messageReserve) continue; + details[key] = detail; + room -= bytes; } - return JSON.stringify({ error: { message: "Capability call failed" } }); + return { error: { message: truncateText(message, room), ...details } }; } -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 errorBytes(response: Extract): number { + const { message, code, path } = response.error; + return ( + ERROR_OVERHEAD_BYTES + + encoder.encode(message).byteLength + + encoder.encode(code ?? "").byteLength + + encoder.encode(path ?? "").byteLength + ); +} + +function truncateText(value: string, maxBytes: number) { + return utf8Prefix(value, maxBytes).text; } diff --git a/packages/computer/src/runtime/capability.ts b/packages/computer/src/runtime/capability.ts index 96e8a500..af78ec62 100644 --- a/packages/computer/src/runtime/capability.ts +++ b/packages/computer/src/runtime/capability.ts @@ -274,6 +274,10 @@ function assertValue(value: unknown, seen: WeakSet): void { 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); + // An undefined field is absent, as with JSON.stringify, which drops + // it when the value is framed. + for (const item of Object.values(value)) { + if (item !== undefined) assertValue(item, seen); + } seen.delete(value); } diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index 7bfac55e..fe47aa87 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -3,21 +3,23 @@ import type { SkippedEntry } from "@cloudflare/dofs"; import type { ExecEncoding } from "../shell.js"; import { type CommandOutputFiles, OutputSpool, type SpooledOutput } from "./output-spool.js"; import { makeOutputLimits, type OutputLimits } from "./output-tail.js"; -import type { - ModuleExecutionEnvelope, - WorkspaceModuleBackendHandle, - WorkspaceRuntimeDisposeOptions, - WorkspaceRuntimeEvent, - WorkspaceRuntimeExecHandle, - WorkspaceRuntimeExecOptions, - WorkspaceRuntimeGetOptions, - WorkspaceRuntimeKillOptions, - WorkspaceRuntimeResult, +import { + isModuleBackend, + type ModuleExecutionEnvelope, + type WorkspaceModuleBackendHandle, + type WorkspaceRegisteredBackend, + type WorkspaceRuntimeDisposeOptions, + type WorkspaceRuntimeEvent, + type WorkspaceRuntimeExecHandle, + type WorkspaceRuntimeExecOptions, + type WorkspaceRuntimeGetOptions, + type WorkspaceRuntimeKillOptions, + type WorkspaceRuntimeResult, } from "./types.js"; interface WorkspaceRuntimeRouterOptions { // What each registered backend says about itself. - backends: ReadonlyMap; + backends: ReadonlyMap; backendHandle: (id: string) => Promise; resolveBackendId: (id: string | undefined) => string; // Where output too long for a result is saved, and the default @@ -36,6 +38,21 @@ export function notCallableMessage(backend: string): string { return `Backend ${JSON.stringify(backend)} is not callable; it does not accept structured input.`; } +/** What a registered backend says about itself. */ +export interface WorkspaceBackendInfo { + /** The id the backend is registered under. */ + readonly id: string; + /** + * What `exec` source means on this backend: a shell command + * (`"command"`) or module source (`"module"`). + */ + readonly protocol: "command" | "module"; + /** Whether the backend takes structured `input` and returns a `result`. */ + readonly callable: boolean; + /** What the backend tells a model about itself. */ + readonly description?: string; +} + export class WorkspaceRuntime { readonly #options: WorkspaceRuntimeRouterOptions; @@ -51,12 +68,17 @@ export class WorkspaceRuntime { return this.#options.backends.get(id)?.callable === true; } - // What the named backend says about itself for a model: its source - // language and, for the JavaScript backend, the modules code can - // import. The exec tool adds it to the backend's entry so a caller - // does not have to repeat it. - describe(id: string): string | undefined { - return this.#options.backends.get(id)?.description; + // What each registered backend says about itself, in registration + // order. The exec tool builds itself from this, and a Workspace + // client snapshots it when it is created, so the answer is the same + // locally and over RPC. + backends(): WorkspaceBackendInfo[] { + return [...this.#options.backends].map(([id, backend]) => ({ + id, + protocol: isModuleBackend(backend) ? ("module" as const) : ("command" as const), + callable: backend.callable === true, + ...(backend.description === undefined ? {} : { description: backend.description }), + })); } exec(source: string): Promise>; diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index 0c0a2398..82968cee 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -29,10 +29,10 @@ export interface WorkspaceModuleCallContext { * * `args` holds the arguments the isolate passed, decoded from the wire. * They come from untrusted code, so parse them before use. The function - * may return a value or a promise of one. The result must be - * JSON-compatible: the bridge checks it at runtime, treats `undefined` - * as `null`, and drops `undefined` object fields, the way - * `JSON.stringify` does. + * may return a value or a promise of one. Arguments and results cross + * the isolate boundary as real values through Workers RPC, not as + * encoded text. The result must be JSON-compatible plain data: the + * bridge checks it at runtime and treats `undefined` as `null`. */ export type WorkspaceModuleFunction = ( args: readonly WorkspaceRuntimeValue[], diff --git a/packages/computer/src/stub.ts b/packages/computer/src/stub.ts index 682bc0fb..9e7d058f 100644 --- a/packages/computer/src/stub.ts +++ b/packages/computer/src/stub.ts @@ -68,6 +68,7 @@ import type { import type { ShareOptions } from "./assets/index.js"; import type { GitCliInput, GitCliResult } from "./git/index.js"; import { withSpan } from "./observe.js"; +import type { WorkspaceBackendInfo } from "./runtime/runtime.js"; import type { WorkspaceRuntimeEvent, WorkspaceRuntimeExecHandle, @@ -407,6 +408,11 @@ export class WorkspaceRuntimeStub extends RpcTarget { untrackStub(this); } + /** What each backend says about itself. A client snapshots this when it is created. */ + backends(): WorkspaceBackendInfo[] { + return this.#ws.runtime.backends(); + } + exec(source: string): Promise>; exec( source: string, @@ -661,6 +667,13 @@ export class WorkspaceStub extends RpcTarget { return this.#assets; } + // Whether the Workspace has an assets publisher. Reading `assets` + // over RPC always yields a placeholder, so a client asks this plain + // boolean once instead. + get hasAssets(): boolean { + return this.#assets !== undefined; + } + get artifacts(): WorkspaceArtifactsStub { return this.#artifacts; } diff --git a/packages/computer/src/tools/ai-sdk/index.test.ts b/packages/computer/src/tools/ai-sdk/index.test.ts index 8298751b..75ae16a4 100644 --- a/packages/computer/src/tools/ai-sdk/index.test.ts +++ b/packages/computer/src/tools/ai-sdk/index.test.ts @@ -6,7 +6,6 @@ import { createGitModule } from "../../modules/git.js"; import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../../runtime/types.js"; import { Workspace } from "../../workspace.js"; import { - createAITools, createDeleteTool, createEditTool, createFindTool, @@ -16,6 +15,7 @@ import { type FileStore, WorkspaceFileStore, } from "../index.js"; +import { createAITools } from "./index.js"; const toolOptions = { toolCallId: "test-call", messages: [] }; @@ -1330,19 +1330,56 @@ describe("createAITools filesystem tools", () => { }); describe("createAITools exec tool", () => { - it("adds exec only when shell options are provided", () => { - const workspace = makeWorkspace(); + it("offers exec by default only when the workspace has a backend", () => { + const withBackend = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [streamingCommandBackend([]) as never], + }); - expect(createAITools({ workspace }).exec).toBeUndefined(); - expect( - createAITools({ - workspace, - shell: { - defaultBackend: "shell", - backends: { shell: { description: "test shell" } }, - }, - }).exec, - ).toBeDefined(); + expect(createAITools({ workspace: makeWorkspace() }).exec).toBeUndefined(); + expect(createAITools({ workspace: withBackend }).exec).toBeDefined(); + expect(createAITools({ workspace: withBackend, exec: {} }).exec).toBeUndefined(); + expect(createAITools({ workspace: withBackend, readonly: true }).exec).toBeUndefined(); + }); + + it("offers every workspace backend by default", () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + streamingCommandBackend([]) as never, + new WorkerJavaScriptBackend({ loader: { load: () => ({ getEntrypoint: () => ({}) }) } }), + ], + }); + const tools = createAITools({ workspace }); + const schema = z.toJSONSchema(inputSchema(tools.exec)) as { + properties: { backend?: { enum?: string[] } }; + required?: string[]; + }; + + expect(schema.properties.backend?.enum).toEqual(["shell", "worker-javascript"]); + expect(schema.required).toContain("backend"); + expect(toolDescription(tools.exec)).not.toMatch(/default backend/i); + expect(toolDescription(tools.exec)).toContain('- "shell": Runs shell commands.'); + expect(toolDescription(tools.exec)).toContain("ECMAScript module source"); + }); + + it("takes the backends to offer, each with a note for the model", () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + streamingCommandBackend([]) as never, + new WorkerJavaScriptBackend({ loader: { load: () => ({ getEntrypoint: () => ({}) }) } }), + ], + }); + const listed = createAITools({ workspace, exec: { "worker-javascript": {} } }); + const mapped = createAITools({ + workspace, + exec: { "worker-javascript": { description: "Use for data work." }, shell: {} }, + }); + + expect(inputProperties(listed.exec)).not.toContain("backend"); + expect(toolDescription(listed.exec)).not.toContain('"shell"'); + expect(toolDescription(mapped.exec)).toContain("Use for data work.\n\n`command` is ECMAScript"); }); it("runs shell commands on the selected backend and keeps the end of long output", async () => { @@ -1528,15 +1565,28 @@ describe("createAITools exec tool", () => { }); }); - it("rejects invalid shell backend configuration", () => { - const workspace = makeWorkspace(); + it("lets exec win over the deprecated shell option", () => { + const workspace = { + runtime: { + async exec() { + throw new Error("not used"); + }, + }, + }; - expect(() => + expect( createAITools({ workspace, - shell: { defaultBackend: "missing", backends: { shell: { description: "test" } } }, - }), - ).toThrow(/defaultBackend/); + exec: {}, + shell: { backends: { shell: { description: "Commands." } } }, + }).exec, + ).toBeUndefined(); + }); + + it("rejects a backend the workspace does not have", () => { + expect(() => createAITools({ workspace: makeWorkspace(), exec: { missing: {} } })).toThrow( + /unknown backend "missing"/, + ); }); }); @@ -1575,7 +1625,7 @@ describe("createAITools callable exec", () => { }), }; }, - isCallable: (id: string) => id === "js", + backends: () => [{ id: "js", callable: true }], }, }; const tools = createAITools({ @@ -1621,7 +1671,7 @@ describe("createAITools callable exec", () => { result: async () => ({ exitCode: 0, stdout: "ok", stderr: "" }), }; }, - isCallable: (id: string) => id === "js", + backends: () => [{ id: "js", callable: true }], }, }; const tools = createAITools({ @@ -1645,7 +1695,10 @@ describe("createAITools callable exec", () => { called = true; return { result: async () => ({ exitCode: 0, stdout: "", stderr: "" }) }; }, - isCallable: (id: string) => id === "js", + backends: () => [ + { id: "shell", callable: false }, + { id: "js", callable: true }, + ], }, }; const tools = createAITools({ @@ -1700,7 +1753,7 @@ describe("createAITools callable exec", () => { async exec() { throw new Error("not used"); }, - isCallable: (id: string) => id === "js", + backends: () => [{ id: "js", callable: true }], }, }; const tools = createAITools({ @@ -1722,9 +1775,9 @@ describe("createAITools callable exec", () => { async exec() { throw new Error("not used"); }, - isCallable: () => true, - describe: (id: string) => - id === "js" ? "Modules: `ws:weather` exports `forecast`." : undefined, + backends: () => [ + { id: "js", callable: true, description: "Modules: `ws:weather` exports `forecast`." }, + ], }, }; const withBoth = createAITools({ @@ -1739,7 +1792,7 @@ describe("createAITools callable exec", () => { expect(toolDescription(withBackendOnly.exec)).toContain("`ws:weather` exports `forecast`"); }); - it("requires a description for a backend that does not describe itself", () => { + it("falls back to a short description for a backend that does not describe itself", () => { const workspace = { runtime: { async exec() { @@ -1747,10 +1800,9 @@ describe("createAITools callable exec", () => { }, }, }; + const tools = createAITools({ workspace, exec: { shell: {} } }); - expect(() => createAITools({ workspace, shell: { backends: { shell: {} } } })).toThrow( - /does not describe itself/, - ); + expect(toolDescription(tools.exec)).toContain("Runs shell commands."); }); }); @@ -1796,7 +1848,11 @@ describe("createAITools exec with one backend", () => { calls.push({ command, backend: options.backend, input: options.input }); return { result: async () => ({ exitCode: 0, stdout: "", stderr: "", value: 1 }) }; }, - isCallable: (id: string) => callable && id === "worker-javascript", + backends: () => [ + { id: "worker-javascript", callable }, + { id: "shell", callable: false }, + { id: "container", callable: false }, + ], }, }; return { calls, workspace }; @@ -1823,10 +1879,7 @@ describe("createAITools exec with one backend", () => { it("runs on the only backend when a direct caller names another", async () => { const { calls, workspace } = recordingWorkspace(false); - const tools = createAITools({ - workspace, - shell: { backends: { shell: { description: "Shell." } } }, - }); + const tools = createAITools({ workspace, exec: { shell: {} } }); await executeTool(tools.exec, { command: "echo hi", backend: "container" }); expect(calls).toEqual([{ command: "echo hi", backend: "shell", input: undefined }]); @@ -1870,17 +1923,19 @@ describe("createAITools exec with one backend", () => { expect(inputProperties(tools.exec)).toEqual(["backend", "command", "cwd", "env", "input"]); }); - it("requires defaultBackend when more than one backend is configured", () => { - const { workspace } = recordingWorkspace(false); + it("requires the model to name a backend when there is a choice", async () => { + const { calls, workspace } = recordingWorkspace(false); + const tools = createAITools({ + workspace, + exec: { container: { description: "Linux." }, shell: { description: "Fast." } }, + }); - expect(() => - createAITools({ - workspace, - shell: { - backends: { shell: { description: "Fast shell." }, container: { description: "Linux." } }, - }, - }), - ).toThrow(/defaultBackend/); + expect(() => inputSchema(tools.exec).parse({ command: "ls" })).toThrow(); + await expect(executeTool(tools.exec, { command: "ls" })).resolves.toMatchObject({ + error: "Name a backend to run on.", + }); + await executeTool(tools.exec, { command: "ls", backend: "shell" }); + expect(calls.map((call) => call.backend)).toEqual(["shell"]); }); }); @@ -1977,7 +2032,7 @@ describe("createAITools exec streaming", () => { { name: "exit", code: 0, result: { ok: true } }, ]); }, - isCallable: (id: string) => id === "js", + backends: () => [{ id: "js", callable: true }], }, }; const tools = createAITools({ diff --git a/packages/computer/src/tools/common/exec.ts b/packages/computer/src/tools/common/exec.ts index 5e4113ee..ba6e4b21 100644 --- a/packages/computer/src/tools/common/exec.ts +++ b/packages/computer/src/tools/common/exec.ts @@ -1,5 +1,4 @@ import { z } from "zod"; - import type { TruncatedOutput } from "../../runtime/output-spool.js"; import { DEFAULT_OUTPUT_MAX_BYTES, @@ -8,6 +7,7 @@ import { type OutputLimits, OutputWindow, } from "../../runtime/output-tail.js"; +import type { WorkspaceBackendInfo } from "../../runtime/runtime.js"; import { notCallableMessage } from "../../runtime/runtime.js"; import type { WorkspaceRuntimeTruncation, WorkspaceRuntimeValue } from "../../runtime/types.js"; @@ -68,34 +68,35 @@ export interface ExecWorkspaceLike { output?: { maxBytes: number; maxLines: number }; }, ): Promise; - // Whether a backend accepts a structured `input` value and returns - // a structured result. The tool asks this to know which backends - // are callable; the runtime derives it from each backend's - // `callable` flag. Omit when no backend is callable. - isCallable?(id: string): boolean; - // What a backend says about itself for a model, such as the - // language it runs and the modules that code can import. The tool - // shows it after the caller's own description. - describe?(id: string): string | undefined; + // What each registered backend says about itself: whether it takes + // structured `input`, and its description for the model. Used to + // build the tool, to offer every backend when the caller picks none, + // and to reject an unknown id up front. Without it, every backend + // must be named and is treated as a shell. + backends?(): readonly WorkspaceBackendInfo[]; }; } -export interface ExecBackendDescription { - // Guidance for the model about this backend, shown before whatever - // the backend says about itself. Required only when the backend does - // not describe itself. - description?: string; +/** Options for one backend the exec tool may run on. */ +export interface ExecBackendOptions { + /** Shown to the model before the backend's own description. */ + readonly description?: string; } +/** + * The backends the exec tool may run on, keyed by backend id: + * `{ "worker-javascript": { description: "Use for data work." } }`. + * Pass `{}` for a backend that needs nothing beyond its own + * description. + */ +export type ExecBackends = Readonly>; + export interface ExecToolOptions { workspace: ExecWorkspaceLike; - // Backends the model may run on. With exactly one, the tool has no - // `backend` argument and always runs there, so the model never has - // to reason about backends. - backends: Record; - // Backend used when the model omits `backend`. Required when more - // than one backend is configured; with one it defaults to that one. - defaultBackend?: string; + // Omit to offer every backend the Workspace has. With one backend + // the tool has no `backend` argument; with several the model must + // name one on every call. + backends?: ExecBackends; // The most bytes of each of stdout and stderr the model sees. // Longer output keeps its last lines, like pi's bash tool, and the // runtime saves the full output to a file the reply names. Defaults @@ -159,9 +160,9 @@ export interface ExecDefinition { } /** - * Check the backends once and build the exec tool's description, input - * schema, and executor. Throws when no backend is given or the default - * is not among them, so a misconfigured tool fails when it is built. + * Resolve the backends once and build the exec tool's description, + * input schema, and executor. Throws when no backend is left or an id + * is unknown, so a misconfigured tool fails when it is built. */ export function defineExec(options: ExecToolOptions): ExecDefinition { const limits = makeOutputLimits( @@ -172,32 +173,25 @@ export function defineExec(options: ExecToolOptions): ExecDefinition { "createExecTool", ); const now = options.now ?? Date.now; - const backendIds = Object.keys(options.backends); - if (backendIds.length === 0) { - throw new Error("createExecTool: pass at least one backend in `backends`"); - } - const single = backendIds.length === 1; - const defaultBackend = options.defaultBackend ?? (single ? backendIds[0] : undefined); - if (defaultBackend === undefined || !backendIds.includes(defaultBackend)) { - throw new Error( - `createExecTool: pass a defaultBackend that is one of ${backendIds.map((id) => JSON.stringify(id)).join(", ")}`, - ); - } - const runtime = options.workspace.runtime; - const backends = backendIds.map((id) => { - const text = [options.backends[id]?.description, runtime.describe?.(id)] - .filter((part) => part !== undefined && part !== "") - .join("\n\n"); - if (text === "") { - throw new Error( - `createExecTool: backend ${JSON.stringify(id)} does not describe itself; pass a description`, - ); - } - return { id, text, callable: runtime.isCallable?.(id) === true }; + // Read the backends once, so selection and descriptions agree. + const known = runtime.backends?.(); + const selected = selectBackends(options.backends, known); + const [first] = selected; + if (first === undefined) throw new Error("createExecTool: no backends to run on"); + const backendIds = selected.map((backend) => backend.id); + const single = backendIds.length === 1; + const backends = selected.map(({ id, guidance }) => { + const info = known?.find((backend) => backend.id === id); + const callable = info?.callable === true; + const own = info?.description; + const text = + [guidance, own].filter((part) => part !== undefined && part !== "").join("\n\n") || + (callable ? "Runs `command` as module source." : "Runs shell commands."); + return { id, text, callable }; }); const callableBackendIds = new Set(backends.filter((b) => b.callable).map((b) => b.id)); - const description = describeTool(backends, defaultBackend, limits); + const description = describeTool(backends, limits); // Offer only the fields that can work: `backend` when there is a // choice, `input` when some backend accepts it. const shape: Record = { @@ -212,11 +206,10 @@ export function defineExec(options: ExecToolOptions): ExecDefinition { }; if (!single) { shape.backend = z - // SAFETY: createExecTool checked that backendIds has at least one entry. + // SAFETY: defineExec checked that backendIds has at least one entry. .enum(backendIds as [string, ...string[]]) - .optional() .describe( - `Which backend to run on. Omit to use the default (${JSON.stringify(defaultBackend)}). If a command fails because the backend lacks that tool, retry on a backend whose description covers it.`, + "Which backend to run on. If a command fails because the backend lacks that tool, retry on a backend whose description covers it.", ); } if (callableBackendIds.size > 0) { @@ -235,9 +228,14 @@ export function defineExec(options: ExecToolOptions): ExecDefinition { description, inputSchema, execute: async function* ({ command, cwd, backend, env, input }, { abortSignal } = {}) { - // A single-backend tool runs there even when a direct caller, - // which skips the input schema, passes another backend. - const selectedBackend = single ? defaultBackend : (backend ?? defaultBackend); + // With one backend there is nothing to choose. With several the + // schema requires `backend`; a caller that skips the schema gets + // the same answer as an error. + const selectedBackend = single ? first.id : backend; + if (selectedBackend === undefined) { + yield { command, cwd: cwd ?? null, backend: "", error: "Name a backend to run on." }; + return; + } const base = { command, cwd: cwd ?? null, backend: selectedBackend }; if (input !== undefined && !callableBackendIds.has(selectedBackend)) { yield { ...base, error: notCallableMessage(selectedBackend) }; @@ -363,11 +361,7 @@ interface DescribedBackend { // With one backend the description is about what it does. With several // it lists them and explains how to choose. -function describeTool( - backends: readonly DescribedBackend[], - defaultBackend: string, - limits: OutputLimits, -): string { +function describeTool(backends: readonly DescribedBackend[], limits: OutputLimits): string { const output = outputHint(limits); const [only, ...others] = backends; if (only !== undefined && others.length === 0) { @@ -398,7 +392,7 @@ function describeTool( (b) => `- ${JSON.stringify(b.id)}${b.callable ? " (callable)" : ""}: ${b.text}`, ), "", - `Default backend: ${JSON.stringify(defaultBackend)}. Try this first for any command you're not sure about; if it fails with a "command not found" or a similar capability error, retry on a backend whose description covers the missing tool.`, + 'Name a backend on every call. If a command fails with a "command not found" or a similar capability error, retry on a backend whose description covers the missing tool.', `${SHELL_HINT} ${FILE_TOOLS_HINT} ${output}`, ...(callable.length === 0 ? [] @@ -409,6 +403,33 @@ function describeTool( ].join("\n"); } +// Resolve the caller's choice to a list of backends. +function selectBackends( + backends: ExecBackends | undefined, + registered: readonly WorkspaceBackendInfo[] | undefined, +): Array<{ id: string; guidance: string | undefined }> { + const known = registered?.map((backend) => backend.id); + let selected: Array<{ id: string; guidance: string | undefined }>; + if (backends === undefined) { + if (known === undefined) { + throw new Error("createExecTool: pass `backends`; this workspace cannot list its backends"); + } + selected = known.map((id) => ({ id, guidance: undefined })); + } else { + selected = Object.entries(backends).map(([id, backend]) => ({ + id, + guidance: backend.description, + })); + } + const unknown = known === undefined ? [] : selected.filter((b) => !known.includes(b.id)); + if (unknown.length > 0) { + throw new Error( + `createExecTool: unknown backend ${unknown.map((b) => JSON.stringify(b.id)).join(", ")}; the workspace has ${known?.map((id) => JSON.stringify(id)).join(", ") || "none"}`, + ); + } + return selected; +} + function commandHint(backends: readonly DescribedBackend[]): string { if (backends.every((backend) => backend.callable)) return "Module source to run."; if (backends.every((backend) => !backend.callable)) { diff --git a/packages/computer/src/tools/common/options.ts b/packages/computer/src/tools/common/options.ts index 518ef15c..27b4e510 100644 --- a/packages/computer/src/tools/common/options.ts +++ b/packages/computer/src/tools/common/options.ts @@ -1,4 +1,4 @@ -import type { ExecToolOptions, ExecWorkspaceLike } from "./exec.js"; +import type { ExecBackends, ExecToolOptions, ExecWorkspaceLike } from "./exec.js"; import type { EditToolOptions } from "./fs/edit.js"; import type { ReadToolOptions } from "./fs/read.js"; import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; @@ -15,8 +15,23 @@ export interface CreateToolsOptions { read?: Omit; write?: Omit; edit?: Omit; - /** The backends `exec` may run on and which one it uses by default. Omit for no exec tool. */ - shell?: Omit; + /** + * The backends `exec` may run on, keyed by id, each with an optional + * description for the model. Omit to offer every backend the + * Workspace has; `{}` means no exec tool. + */ + exec?: ExecBackends; + /** + * @deprecated Use `exec`. `{ backends }` becomes `exec: backends`; + * `defaultBackend` is ignored, because the model names a backend + * whenever there is a choice. Output limits move to `createExecTool`. + */ + shell?: LegacyShellOptions; +} + +interface LegacyShellOptions extends Omit { + backends: ExecBackends; + defaultBackend?: string; } export interface ResolvedToolOptions { @@ -24,7 +39,7 @@ export interface ResolvedToolOptions { write: WriteToolOptions; edit: EditToolOptions; delete: { store: WorkspaceFileStore }; - /** Absent when the set is read-only or `shell` is not given. */ + /** Absent when the set is read-only, the Workspace has no runtime, or no backend is selected. */ exec?: ExecToolOptions; publish: boolean; readonly: boolean; @@ -47,8 +62,25 @@ export function resolveToolOptions(options: CreateToolsOptions): ResolvedToolOpt }; } -// Pair `shell` with the Workspace's runtime. +// Turn `exec`, or the deprecated `shell`, into exec tool options. function execOptions(options: CreateToolsOptions): ExecToolOptions | undefined { - if (options.shell === undefined) return undefined; - return { workspace: options.workspace as ExecWorkspaceLike, ...options.shell }; + const runtime = options.workspace.runtime; + if (runtime === undefined) return undefined; + const exec = selectExec(options, runtime); + if (Object.keys(exec.backends).length === 0) return undefined; + return { workspace: { runtime }, ...exec }; +} + +function selectExec( + options: CreateToolsOptions, + runtime: ExecWorkspaceLike["runtime"], +): Omit & { backends: ExecBackends } { + // `exec` wins over the deprecated `shell`, so `exec: {}` always means + // no exec tool. + if (options.exec !== undefined) return { backends: options.exec }; + if (options.shell !== undefined) { + const { backends, defaultBackend: _ignored, ...limits } = options.shell; + return { ...limits, backends }; + } + return { backends: Object.fromEntries((runtime.backends?.() ?? []).map(({ id }) => [id, {}])) }; } diff --git a/packages/computer/src/tools/index.ts b/packages/computer/src/tools/index.ts index 19f5fd51..9ca3d5cf 100644 --- a/packages/computer/src/tools/index.ts +++ b/packages/computer/src/tools/index.ts @@ -1,9 +1,9 @@ -// The AI SDK tool set, its individual tools, and the file store under -// them. Tool sets for other agent libraries have their own entry points, -// so importing one never pulls in the AI SDK: +// The individual AI SDK tools and the file store under them. Tool sets +// for each agent library have their own entry points, so importing one +// never pulls in another library: +// @cloudflare/computer/tools/ai-sdk createAITools // @cloudflare/computer/tools/pi-ai createPiTools // @cloudflare/computer/tools/tanstack-ai createTanStackTools -export { type CreateAIToolsOptions, createAITools } from "./ai-sdk/index.js"; export { createDeleteTool, createEditTool, @@ -16,7 +16,8 @@ export { createWriteTool, } from "./ai-sdk/tools.js"; export type { - ExecBackendDescription, + ExecBackendOptions, + ExecBackends, ExecRuntimeHandle, ExecStreamEvent, ExecToolOptions, diff --git a/packages/computer/src/tools/pi-ai/index.test.ts b/packages/computer/src/tools/pi-ai/index.test.ts index d91cad05..83c0e063 100644 --- a/packages/computer/src/tools/pi-ai/index.test.ts +++ b/packages/computer/src/tools/pi-ai/index.test.ts @@ -2,6 +2,7 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { validateToolCall } from "@earendil-works/pi-ai"; import { makeStrictJsonSchema } from "@earendil-works/pi-ai/api/constrained-sampling"; import { describe, expect, it } from "vitest"; +import type { WorkspaceBackendInfo } from "../../runtime/runtime.js"; import { Workspace } from "../../workspace.js"; import { createPiTools, type PiJSONSchema } from "./index.js"; @@ -9,6 +10,12 @@ function makeWorkspace(): Workspace { return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); } +// Stands in for registered backends, so the tests can shape what the +// exec tool sees without running one. +function fakeBackends(workspace: Workspace, backends: WorkspaceBackendInfo[]): void { + (workspace.runtime as unknown as Record).backends = () => backends; +} + function declaration(tools: ReturnType, name: string) { const tool = tools.tools.find((candidate) => candidate.name === name); if (!tool) throw new Error(`no ${name} tool`); @@ -40,38 +47,48 @@ describe("createPiTools declarations", () => { expect(tools.tools.map((tool) => tool.name).sort()).toEqual(["find", "grep", "ls", "read"]); }); - it("offers the backends `shell` lists, with the default named", () => { - const tools = createPiTools({ - workspace: makeWorkspace(), - shell: { - backends: { - "worker-shell": { description: "Fast worker shell." }, - "container-shell": { description: "Full Linux container." }, - }, - defaultBackend: "worker-shell", - }, - }); + it("names a backend on every exec call when there are several", () => { + const workspace = makeWorkspace(); + fakeBackends(workspace, [ + { id: "worker-shell", callable: false, description: "Fast worker shell." }, + { id: "container-shell", callable: false, description: "Full Linux container." }, + ]); + const tools = createPiTools({ workspace }); const exec = declaration(tools, "exec"); expect(exec.description).toContain("Fast worker shell."); expect(exec.description).toContain("Full Linux container."); - expect(exec.description).toContain('Default backend: "worker-shell"'); const backend = exec.parameters.properties?.backend as { enum?: string[] }; expect(backend.enum).toEqual(["worker-shell", "container-shell"]); - expect(exec.parameters.required).not.toContain("backend"); + expect(exec.parameters.required).toContain("backend"); }); - it("leaves exec out without `shell` and for a read-only set", () => { + it("offers only the backends `exec` lists, with no backend argument for one", () => { const workspace = makeWorkspace(); - const shell = { - backends: { "worker-shell": { description: "Fast worker shell." } }, - defaultBackend: "worker-shell", - }; + fakeBackends(workspace, [ + { id: "worker-shell", callable: false, description: "Fast worker shell." }, + { id: "container-shell", callable: false, description: "Full Linux container." }, + ]); + const tools = createPiTools({ + workspace, + exec: { "worker-shell": { description: "Use for quick checks." } }, + }); + + const exec = declaration(tools, "exec"); + expect(exec.description).toContain("Use for quick checks."); + expect(exec.description).not.toContain("Full Linux container."); + expect(exec.parameters.properties).not.toHaveProperty("backend"); + expect(exec.parameters.properties).not.toHaveProperty("input"); + }); + + it("leaves exec out for `exec: {}` and for a read-only set", () => { + const workspace = makeWorkspace(); + fakeBackends(workspace, [{ id: "worker-shell", callable: false }]); - expect(createPiTools({ workspace }).tools.map((t) => t.name)).not.toContain("exec"); - expect( - createPiTools({ workspace, shell, readonly: true }).tools.map((t) => t.name), - ).not.toContain("exec"); + expect(createPiTools({ workspace, exec: {} }).tools.map((t) => t.name)).not.toContain("exec"); + expect(createPiTools({ workspace, readonly: true }).tools.map((t) => t.name)).not.toContain( + "exec", + ); }); it("emits required fields without a $schema key and keeps defaults optional", () => { @@ -236,11 +253,8 @@ describe("createPiTools execution", () => { seen.push({ input: options.input }); return { result: async () => ({ exitCode: 0, stdout: "", stderr: "" }) }; }; - (workspace.runtime as unknown as Record).isCallable = () => true; - const tools = createPiTools({ - workspace, - shell: { backends: { js: { description: "callable" } }, defaultBackend: "js" }, - }); + fakeBackends(workspace, [{ id: "js", callable: true, description: "callable" }]); + const tools = createPiTools({ workspace }); await tools.execute({ id: "1", name: "exec", arguments: { command: "a", input: null } }); await tools.execute({ id: "2", name: "exec", arguments: { command: "b" } }); diff --git a/packages/computer/src/tools/tanstack-ai/index.test.ts b/packages/computer/src/tools/tanstack-ai/index.test.ts index 7729da94..a11154f3 100644 --- a/packages/computer/src/tools/tanstack-ai/index.test.ts +++ b/packages/computer/src/tools/tanstack-ai/index.test.ts @@ -273,7 +273,7 @@ describe("createTanStackTools", () => { expect(isContentPartArray(result)).toBe(true); }); - it("offers the backends `shell` lists and defaults to one", () => { + it("offers every workspace backend by default and requires one per call", async () => { const workspace = new Workspace({ storage: new SQLiteTestStorage(), backends: [ @@ -281,23 +281,21 @@ describe("createTanStackTools", () => { new WorkerJavaScriptBackend({ loader: { load: () => ({ getEntrypoint: () => ({}) }) } }), ], }); - const shell = { - backends: { - shell: { description: "fast shell" }, - "worker-javascript": { description: "isolate JavaScript" }, - }, - defaultBackend: "shell", - }; - const tools = createTanStackTools({ workspace, shell, format: "object" }); + const tools = createTanStackTools({ workspace, format: "object" }); const schema = z.toJSONSchema(tools.exec.inputSchema) as { properties: Record; required?: string[]; }; expect(schema.properties.backend?.enum).toEqual(["shell", "worker-javascript"]); - expect(schema.required ?? []).not.toContain("backend"); + expect(schema.required).toContain("backend"); + // Only the callable backend takes structured input. expect(schema.properties).toHaveProperty("input"); - expect(createTanStackTools({ workspace }).map((t) => t.name)).not.toContain("exec"); + await expect(tools.exec.execute({ command: "ls" } as never)).resolves.toEqual({ + error: "Name a backend to run on.", + }); + expect(createTanStackTools({ workspace, exec: {} }).map((t) => t.name)).not.toContain("exec"); + await workspace.close(); }); it("settles a streaming exec tool on its terminal snapshot", async () => { @@ -314,7 +312,7 @@ describe("createTanStackTools", () => { }); const tools = createTanStackTools({ workspace, - shell: { backends: { shell: { description: "fast shell" } }, defaultBackend: "shell" }, + exec: { shell: { description: "fast shell" } }, format: "object", }); @@ -344,7 +342,7 @@ describe("createTanStackTools", () => { }); const tools = createTanStackTools({ workspace, - shell: { backends: { shell: { description: "fast shell" } }, defaultBackend: "shell" }, + exec: { shell: { description: "fast shell" } }, streamEventName: "exec-progress", format: "object", }); @@ -368,7 +366,7 @@ describe("createTanStackTools", () => { }); const tools = createTanStackTools({ workspace, - shell: { backends: { shell: { description: "fast shell" } }, defaultBackend: "shell" }, + exec: { shell: { description: "fast shell" } }, streamEventName: "exec-progress", format: "object", }); @@ -408,7 +406,7 @@ describe("createTanStackTools", () => { }); const tools = createTanStackTools({ workspace, - shell: { backends: { shell: { description: "fast shell" } }, defaultBackend: "shell" }, + exec: { shell: { description: "fast shell" } }, format: "object", }); const controller = new AbortController(); diff --git a/packages/computer/tests/script-runner-worker.ts b/packages/computer/tests/script-runner-worker.ts index 399cd780..86fac5fc 100644 --- a/packages/computer/tests/script-runner-worker.ts +++ b/packages/computer/tests/script-runner-worker.ts @@ -1,4 +1,6 @@ import { DurableObject, RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; +import type { ShellRPC, SyncRPC } from "@cloudflare/computer-rpc"; +import type { WorkspaceBackend } from "../src/backend.js"; import { WorkerJavaScriptBackend } from "../src/backends/worker-javascript/index.js"; import { createGitClient } from "../src/git/index.js"; import type { @@ -8,6 +10,7 @@ import type { } from "../src/index.js"; import { Workspace } from "../src/index.js"; import { createArtifactsModule } from "../src/modules/artifacts.js"; +import { createContainerModule } from "../src/modules/container.js"; import { createGitModule } from "../src/modules/git.js"; export interface Env { @@ -15,6 +18,46 @@ export interface Env { LOADER: WorkerLoader; } +// A command backend that stands in for the container. It echoes the +// command, working directory, one environment variable, and standard +// input, and exits with the length of the command. +function fakeContainerBackend(): WorkspaceBackend { + const encoder = new TextEncoder(); + const shell: ShellRPC = { + async exec(input) { + const id = input.id ?? crypto.randomUUID(); + const stdin = input.stdin ? new TextDecoder().decode(input.stdin) : ""; + const stdout = `ran ${input.source} in ${input.cwd ?? "?"} with ${input.env?.WHO ?? "-"} and ${stdin || "-"}\n`; + return { + id, + events: new ReadableStream({ + start(controller) { + controller.enqueue({ id, seq: 1, name: "stdout", value: encoder.encode(stdout) }); + controller.enqueue({ id, seq: 2, name: "stderr", value: encoder.encode("warn\n") }); + controller.enqueue({ id, seq: 3, name: "exit", code: input.source.length % 256 }); + controller.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.resolve(), + disposeExec: () => Promise.resolve(), + }; + // SAFETY: The fake backend declares sync "none", so the Workspace never calls these methods. + const sync = new Proxy( + {}, + { get: () => () => Promise.reject(new Error("sync: none")) }, + ) as SyncRPC; + return { + id: "container-shell", + type: "fake-container", + async connect() { + return { rpc: { sync, shell }, sync: "none", close: async () => {} }; + }, + }; +} + export class HostDO extends DurableObject { readonly #workspace: Workspace; @@ -34,6 +77,7 @@ export class HostDO extends DurableObject { "math-kit": "export const double = (value) => value * 2;", "ws:git": createGitModule(), "ws:artifacts": createArtifactsModule(), + "ws:container": createContainerModule(), "ws:test-host": { async echo(args) { return { args: [...args] }; @@ -67,6 +111,7 @@ export class HostDO extends DurableObject { }, }, }), + fakeContainerBackend(), ], }); } diff --git a/packages/computer/tests/script-runner.test.ts b/packages/computer/tests/script-runner.test.ts index 1e67d66d..f01debf8 100644 --- a/packages/computer/tests/script-runner.test.ts +++ b/packages/computer/tests/script-runner.test.ts @@ -100,7 +100,30 @@ describe("WorkspaceRuntime", () => { }); }); - it("round-trips bytes and marker-shaped plain objects without codec collisions", async () => { + it("moves bytes through node:fs without inflating them", async () => { + // 900 bytes fits under this fixture's 1024-byte capability limit as + // raw bytes. Encoded as JSON numbers it would be about four times + // larger and rejected. + const response = await runtime({ + source: ` + import fs from "node:fs/promises"; + export default async () => { + await fs.writeFile("/workspace/blob.bin", new Uint8Array(900).fill(255)); + const back = await fs.readFile("/workspace/blob.bin"); + return { isBytes: back instanceof Uint8Array, length: back.byteLength, last: back[899] }; + }; + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text).result, text).toMatchObject({ + status: "completed", + value: { isBytes: true, length: 900, last: 255 }, + }); + }); + + it("round-trips bytes, and plain objects shaped like the old codec, unchanged", async () => { const response = await runtime({ source: ` import fs from "node:fs/promises"; @@ -332,6 +355,85 @@ describe("WorkspaceRuntime", () => { }); }); + it("runs container commands from isolate code through ws:container", async () => { + const response = await runtime({ + source: ` + import { exec } from "ws:container"; + export default () => + exec("npm test", { cwd: "/workspace/app", env: { WHO: "isolate" }, stdin: "y" }); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "completed", + value: { + exitCode: 8, + stdout: "ran npm test in /workspace/app with isolate and y\n", + stderr: "warn\n", + }, + }, + }); + }); + + it("rejects a malformed ws:container call inside the isolate", async () => { + const response = await runtime({ + source: ` + import { exec } from "ws:container"; + export default async () => { + try { + await exec("ls", { shell: "zsh" }); + return "ran"; + } catch (error) { + return error.message; + } + }; + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text).result.value).toContain('unknown option "shell"'); + }); + + it("rejects a cyclic argument with a clear error", async () => { + const response = await runtime({ + source: ` + import { echo } from "ws:test-host"; + export default async () => { + const value = {}; + value.self = value; + try { + await echo(value); + return "sent"; + } catch (error) { + return error.message; + } + }; + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text).result.value, text).toContain("acyclic"); + }); + + it("drops undefined fields from a run result, as JSON does", async () => { + const response = await runtime({ + source: `export default () => ({ kept: 1, dropped: undefined, nested: { also: undefined } });`, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text).result, text).toMatchObject({ + status: "completed", + value: { kept: 1, nested: {} }, + }); + expect(JSON.parse(text).result.value).not.toHaveProperty("dropped"); + }); + it("does not expose unrestricted host operations through the node:fs dispatcher", async () => { const response = await runtime({ source: `