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/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/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 c8b8adfa..52050294 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 181a2ba7..52e24963 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -1,11 +1,11 @@ # 09. Tool interface (agents) -`@cloudflare/computer/tools` ships ready-made [AI SDK](https://github.com/vercel/ai) tools for agents that use a `Workspace`. +`@cloudflare/computer/tools/ai-sdk` ships `createAITools()`, a ready-made [AI SDK](https://github.com/vercel/ai) tool set for agents that use a `Workspace`. The individual `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 @@ -24,13 +24,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. | -`createAITools()` always 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`. +`createAITools()` always 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; @@ -55,29 +55,18 @@ 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. ## `createAITools` @@ -89,7 +78,7 @@ createAITools({ read?, write?, edit?, - shell?, + exec?, }); ``` @@ -101,7 +90,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. | ## `read` @@ -253,9 +243,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: @@ -263,11 +253,11 @@ 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. -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 8718132f..eb558b43 100644 --- a/docs/10_project_layout.md +++ b/docs/10_project_layout.md @@ -175,9 +175,10 @@ produces the Node SEA single-file binary at ## Tools -AI SDK tools (`read`, `write`, `edit`, `ls`, optional `exec`, and -optional `publish`) ship from the `@cloudflare/computer/tools` subpath -rather than a separate package, under +AI SDK tools (`read`, `write`, `edit`, `ls`, `exec`, and optional +`publish`) ship from the package rather than a separate one: +`createAITools()` from `@cloudflare/computer/tools/ai-sdk`, and the +individual `create*Tool` functions from `@cloudflare/computer/tools`. They live under [`packages/computer/src/tools/`](../packages/computer/src/tools/). See [09. Tool Interface (Agents)](./09_tool_interface.md). diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 2fe49372..b37af753 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -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`. ``` @@ -244,6 +247,54 @@ 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. + +A few limits follow from `exec` being a host call: + +- Output comes back when the command finishes, not while it runs. Each stream is cut at `maxOutputBytes` (64 KiB by default), which must stay well under the backend's `maxCapabilityBytes`. +- 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 f9cfb30f..bd7af671 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 AI SDK tools for `@cloudflare/agents` through `@cloudflare/computer/tools`. + - Out-of-the-box AI SDK tools for `@cloudflare/agents` through `createAITools()` in `@cloudflare/computer/tools/ai-sdk`. 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. | A consumer that only uses the container backend never imports the worker subpath, so the just-bash payload tree-shakes away. 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/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/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 0412c6fb..d9786886 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -256,7 +256,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). @@ -266,31 +266,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 @@ -419,9 +415,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/git` | Opt-in `isomorphic-git` glue for checkouts inside the workspace. | | `@cloudflare/computer/assets` | `createAssets` — share a workspace file to R2 as a presigned URL. | | `@cloudflare/computer/artifacts` | `createArtifact` and its CLI, an optionally session-scoped wrapper over the Cloudflare Artifacts binding. | diff --git a/packages/computer/package.json b/packages/computer/package.json index 333f145a..1cdc9b96 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" @@ -47,6 +51,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 70b3ec7a..1a941889 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -31,6 +31,8 @@ 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.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 2994f79b..075cd996 100644 --- a/packages/computer/src/backends/container/container-backend.ts +++ b/packages/computer/src/backends/container/container-backend.ts @@ -162,6 +162,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"; @@ -207,6 +215,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; readonly #options: Required< @@ -239,6 +249,11 @@ export class ContainerBackend implements WorkspaceBackend { this.id = options.id ?? "container-shell"; this.#egress = options.egress ?? { mode: "none" }; this.#egressToken = this.#egress.mode === "http-gateway" ? crypto.randomUUID() : undefined; + 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-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..7111edab 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.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,132 @@ 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(); + }); + } + + 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 6e3bf4b2..5247b105 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, @@ -215,6 +215,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. @@ -270,6 +272,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, @@ -302,7 +308,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( @@ -333,10 +350,12 @@ function makeClient( rehydrate: (handle: unknown, metadata?: RuntimeHandleMetadata) => unknown, dispose: () => void, useThink: boolean, + backends: readonly WorkspaceBackendInfo[], ): WorkspaceClient { const runtime = makeRuntimeClient( surface.runtime as UnderlyingRuntime, rehydrate as RehydrateRuntimeHandle, + backends, ); const client: WorkspaceClient = { get fs() { @@ -384,6 +403,7 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise h, () => {}, local.useThink, + local.runtime.backends(), ); } // Remote path: fetch the stub over RPC and delegate to it. Handle @@ -398,6 +418,7 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise void })[Symbol.dispose]?.(); }, await stub.useThink, + await stub.runtime.backends(), ); } catch (error) { (stub as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); diff --git a/packages/computer/src/index.ts b/packages/computer/src/index.ts index fc863c0a..dd18a93a 100644 --- a/packages/computer/src/index.ts +++ b/packages/computer/src/index.ts @@ -70,6 +70,7 @@ export { type WorkspaceServiceProxyProps, } from "./proxy.js"; export type { WorkspaceEgressPolicy } from "./runtime/egress.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..6a1ff849 --- /dev/null +++ b/packages/computer/src/modules/container.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; + +import type { + WorkspaceModuleCallContext, + WorkspaceModuleFunction, + WorkspaceModuleHost, +} from "../runtime/types.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; +} + +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; +}) { + 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 ?? "", + }; + }, + 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" }); + 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", + "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("truncates each stream on UTF-8 boundaries", 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]", + }); + }); + + 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("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..0002a082 --- /dev/null +++ b/packages/computer/src/modules/container.ts @@ -0,0 +1,212 @@ +// `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 { 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"]); + +/** 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. Output past it is cut and ends with + * a truncation marker. 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 }` once the command finishes. 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, + ...(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(); + return { + exitCode: result.exitCode, + stdout: truncateText(result.stdout, maxOutputBytes), + stderr: truncateText(result.stderr, maxOutputBytes), + }; + } 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, and long output is truncated. A non-zero `exitCode` is returned, not thrown.", +].join(" "); + +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/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index f74965f8..f51cec6f 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -1,21 +1,23 @@ import type { SkippedEntry } from "@cloudflare/dofs"; import type { ExecEncoding } from "../shell.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; } @@ -28,6 +30,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; @@ -43,12 +60,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/stub.ts b/packages/computer/src/stub.ts index 682bc0fb..2759b141 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, diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai-sdk.test.ts similarity index 94% rename from packages/computer/src/tools/ai.test.ts rename to packages/computer/src/tools/ai-sdk.test.ts index 146c7c83..d7ff742f 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai-sdk.test.ts @@ -5,8 +5,8 @@ import { WorkerJavaScriptBackend } from "../backends/worker-javascript/worker-ja import { createGitModule } from "../modules/git.js"; import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../runtime/types.js"; import { Workspace } from "../workspace.js"; +import { createAITools } from "./ai-sdk.js"; import { - createAITools, createDeleteTool, createEditTool, createFindTool, @@ -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 truncates 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 }; @@ -1859,17 +1915,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"]); }); }); @@ -1966,7 +2024,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/ai-sdk.ts b/packages/computer/src/tools/ai-sdk.ts new file mode 100644 index 00000000..b0157cb6 --- /dev/null +++ b/packages/computer/src/tools/ai-sdk.ts @@ -0,0 +1,95 @@ +import type { ToolSet } from "ai"; +import { + createExecTool, + type ExecBackends, + type ExecToolOptions, + type ExecWorkspaceLike, +} from "./exec.js"; +import { createDeleteTool } from "./fs/delete.js"; +import { createEditTool, type EditToolOptions } from "./fs/edit.js"; +import { createFindTool } from "./fs/find.js"; +import { createGrepTool } from "./fs/grep.js"; +import { createListTool } from "./fs/list.js"; +import { createReadTool, type ReadToolOptions } from "./fs/read.js"; +import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; +import { createWriteTool, type WriteToolOptions } from "./fs/write.js"; +import { createPublishTool, type PublishWorkspaceLike } from "./publish.js"; + +/** Options for {@link createAITools}. */ +export interface CreateAIToolsOptions { + workspace: FileWorkspaceLike & Partial & Partial; + readonly?: boolean; + assets?: boolean; + read?: Omit; + write?: Omit; + edit?: 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; +} + +/** + * Build the AI SDK tool set for a Workspace: `read`, `ls`, `find`, and + * `grep`, plus `write`, `edit`, `delete`, `exec`, and `publish` unless + * the set is read-only. `exec` offers every backend the Workspace has + * unless `exec` picks them. + * + * @param options - The Workspace and per-tool options. + * @returns An AI SDK `ToolSet` for `generateText`, `streamText`, or an agent's `getTools()`. + */ +export function createAITools(options: CreateAIToolsOptions): ToolSet { + const store = new WorkspaceFileStore(options.workspace); + const tools: ToolSet = { + read: createReadTool({ store, ...options.read }), + ls: createListTool({ workspace: options.workspace }), + find: createFindTool({ workspace: options.workspace }), + grep: createGrepTool({ workspace: options.workspace }), + }; + + if (options.readonly === true) return tools; + + tools.write = createWriteTool({ store, ...options.write }); + tools.edit = createEditTool({ store, ...options.edit }); + tools.delete = createDeleteTool({ store }); + + const runtime = options.workspace.runtime; + if (runtime !== undefined) { + const exec = execOptions(options, runtime); + if (Object.keys(exec.backends).length > 0) { + tools.exec = createExecTool({ workspace: { runtime }, ...exec }); + } + } + + if (options.assets !== false && options.workspace.assets !== undefined) { + tools.publish = createPublishTool({ workspace: options.workspace as PublishWorkspaceLike }); + } + + return tools; +} + +// Turn `exec`, or the deprecated `shell`, into createExecTool options. +function execOptions( + options: CreateAIToolsOptions, + 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/ai.ts b/packages/computer/src/tools/ai.ts deleted file mode 100644 index 78cc8358..00000000 --- a/packages/computer/src/tools/ai.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { ToolSet } from "ai"; -import { createExecTool, type ExecToolOptions, type ExecWorkspaceLike } from "./exec.js"; -import { createDeleteTool } from "./fs/delete.js"; -import { createEditTool, type EditToolOptions } from "./fs/edit.js"; -import { createFindTool } from "./fs/find.js"; -import { createGrepTool } from "./fs/grep.js"; -import { createListTool } from "./fs/list.js"; -import { createReadTool, type ReadToolOptions } from "./fs/read.js"; -import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; -import { createWriteTool, type WriteToolOptions } from "./fs/write.js"; -import { createPublishTool, type PublishWorkspaceLike } from "./publish.js"; - -export interface CreateAIToolsOptions { - workspace: FileWorkspaceLike & Partial & Partial; - readonly?: boolean; - assets?: boolean; - read?: Omit; - write?: Omit; - edit?: Omit; - shell?: Omit; -} - -export function createAITools(options: CreateAIToolsOptions): ToolSet { - const store = new WorkspaceFileStore(options.workspace); - const tools: ToolSet = { - read: createReadTool({ store, ...options.read }), - ls: createListTool({ workspace: options.workspace }), - find: createFindTool({ workspace: options.workspace }), - grep: createGrepTool({ workspace: options.workspace }), - }; - - if (options.readonly === true) return tools; - - tools.write = createWriteTool({ store, ...options.write }); - tools.edit = createEditTool({ store, ...options.edit }); - tools.delete = createDeleteTool({ store }); - - if (options.shell !== undefined) { - tools.exec = createExecTool({ - workspace: options.workspace as ExecWorkspaceLike, - ...options.shell, - }); - } - - if (options.assets !== false && options.workspace.assets !== undefined) { - tools.publish = createPublishTool({ workspace: options.workspace as PublishWorkspaceLike }); - } - - return tools; -} diff --git a/packages/computer/src/tools/exec.ts b/packages/computer/src/tools/exec.ts index 620d8e56..fd24385d 100644 --- a/packages/computer/src/tools/exec.ts +++ b/packages/computer/src/tools/exec.ts @@ -1,6 +1,6 @@ import { type Tool, tool } from "ai"; import { z } from "zod"; - +import type { WorkspaceBackendInfo } from "../runtime/runtime.js"; import { notCallableMessage } from "../runtime/runtime.js"; import type { WorkspaceRuntimeValue } from "../runtime/types.js"; import { truncateText, utf8Prefix } from "../text-truncation.js"; @@ -60,34 +60,35 @@ export interface ExecWorkspaceLike { input?: WorkspaceRuntimeValue; }, ): 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; // Per-snapshot display cap for each of stdout and stderr, in bytes. // Output past it is shown as a truncation marker. Defaults to 64 KiB. maxBytes?: number; @@ -135,32 +136,24 @@ export function createExecTool(options: ExecToolOptions): Tool 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 }; + const selected = selectBackends(options.backends, runtime.backends?.()); + 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 known = runtime.backends?.(); + 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); + const description = describeTool(backends); // Offer only the fields that can work: `backend` when there is a // choice, `input` when some backend accepts it. const shape: Record = { @@ -177,9 +170,8 @@ export function createExecTool(options: ExecToolOptions): Tool 0) { @@ -198,7 +190,14 @@ export function createExecTool(options: ExecToolOptions): Tool `- ${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}`, ...(callable.length === 0 ? [] @@ -349,6 +348,33 @@ function describeTool(backends: readonly DescribedBackend[], defaultBackend: str ].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/index.ts b/packages/computer/src/tools/index.ts index 9bad4745..8bd6f739 100644 --- a/packages/computer/src/tools/index.ts +++ b/packages/computer/src/tools/index.ts @@ -1,7 +1,7 @@ -export { type CreateAIToolsOptions, createAITools } from "./ai.js"; export { createExecTool, - type ExecBackendDescription, + type ExecBackendOptions, + type ExecBackends, type ExecRuntimeHandle, type ExecStreamEvent, type ExecToolOptions, 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..b017a34f 100644 --- a/packages/computer/tests/script-runner.test.ts +++ b/packages/computer/tests/script-runner.test.ts @@ -332,6 +332,49 @@ 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("does not expose unrestricted host operations through the node:fs dispatcher", async () => { const response = await runtime({ source: `