From bb8f76c8fccdf141097c48fcc9b2073281ee1e60 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 10:27:19 +0100 Subject: [PATCH 1/9] computer: Add ws:container for isolate JavaScript An agent that wants both isolated JavaScript and a full Linux container has so far needed two exec backends, and the model had to pick one per command. This lets JavaScript be the only backend the model sees, with the container as a library it can call. createContainerModule() in @cloudflare/computer/modules/container is a host module factory. Installed as ws:container, its exec(command, options) runs through workspace.runtime.exec on the container backend, so the container shares the Workspace's files through the usual sync bracket. It returns the exit code and bounded output once the command finishes, kills the command when the execution is cancelled, caps the command's timeout at the host call deadline, and refuses to run on a read-only backend, since a container command can write to the Workspace whatever the isolate's access is. Its description tells the model how to call it, and reaches the exec tool through the JavaScript backend's own description. --- .changeset/ws-container-module.md | 5 + README.md | 2 +- docs/17_isolate_javascript.md | 47 +++- docs/README.md | 3 +- packages/computer/README.md | 3 +- packages/computer/package.json | 4 + packages/computer/rolldown.config.ts | 1 + .../computer/src/modules/container.test.ts | 208 ++++++++++++++++++ packages/computer/src/modules/container.ts | 190 ++++++++++++++++ .../computer/tests/script-runner-worker.ts | 45 ++++ packages/computer/tests/script-runner.test.ts | 43 ++++ 11 files changed, 547 insertions(+), 4 deletions(-) create mode 100644 .changeset/ws-container-module.md create mode 100644 packages/computer/src/modules/container.test.ts create mode 100644 packages/computer/src/modules/container.ts diff --git a/.changeset/ws-container-module.md b/.changeset/ws-container-module.md new file mode 100644 index 00000000..9b227b4b --- /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 container backend with `import { exec } from "ws:container"`. 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/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 2fe49372..696bb826 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)), }, @@ -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,48 @@ 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 +this.workspace = new Workspace({ + storage: ctx.storage, + backends: [ + new WorkerJavaScriptBackend({ + loader: env.LOADER, + access: "read-write", + modules: { "ws:container": createContainerModule() }, + }), + new CloudflareContainerBackend({ /* ... */ }), + ], +}); + +const tools = createAITools({ + workspace: this.workspace, + shell: { backends: { "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 (`"container-shell"` unless you pass `backend`). 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 and reach the network, whatever the JavaScript backend's egress settings say. `exec` refuses to run on a read-only backend. + ## Isolation and lifecycle Each execution receives a fresh Dynamic Worker with: diff --git a/docs/README.md b/docs/README.md index f9cfb30f..58516013 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ 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`. @@ -49,6 +49,7 @@ 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. | diff --git a/packages/computer/README.md b/packages/computer/README.md index 0412c6fb..54a7f8a6 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). @@ -419,6 +419,7 @@ 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`. | diff --git a/packages/computer/package.json b/packages/computer/package.json index 333f145a..6cd037c6 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" diff --git a/packages/computer/rolldown.config.ts b/packages/computer/rolldown.config.ts index 70b3ec7a..2e74d76b 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ "artifacts/index": "src/artifacts/index.ts", "assets/index": "src/assets/index.ts", "tools/index": "src/tools/index.ts", + "modules/container": "src/modules/container.ts", "modules/git": "src/modules/git.ts", "modules/artifacts": "src/modules/artifacts.ts", "backends/container-legacy/index": "src/backends/container-legacy/index.ts", diff --git a/packages/computer/src/modules/container.test.ts b/packages/computer/src/modules/container.test.ts new file mode 100644 index 00000000..c6fdd2a4 --- /dev/null +++ b/packages/computer/src/modules/container.test.ts @@ -0,0 +1,208 @@ +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 = { + 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("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..06720b7a --- /dev/null +++ b/packages/computer/src/modules/container.ts @@ -0,0 +1,190 @@ +// `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, + 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 and reach the network, + * so `exec` refuses to run on a read-only backend. Egress settings on + * the JavaScript backend do not apply to the container. + * + * @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 host + * configured the module wrongly. + */ +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 => ({ + async exec(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, native binaries, and network access. 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/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: ` From 6d3a211f27060a69824c79b4cec33585c78a3ad3 Mon Sep 17 00:00:00 2001 From: Matt <77928207+mattzcarey@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:23:35 +0100 Subject: [PATCH 2/9] computer: Pick exec backends with one exec option (#181) --- .changeset/exec-tool-options.md | 11 ++ .changeset/exec-tool-single-backend.md | 4 +- docs/09_tool_interface.md | 44 +++---- docs/10_project_layout.md | 7 +- docs/17_isolate_javascript.md | 6 +- docs/README.md | 3 +- examples/celld/README.md | 2 +- examples/celld/src/index.ts | 35 +++--- examples/mcp/src/index.test.ts | 7 +- examples/mcp/src/server.ts | 43 ++++--- examples/rlm/worker/executor-tool.ts | 1 - examples/think/README.md | 9 +- examples/think/src/agent.ts | 35 +----- packages/computer/README.md | 23 ++-- packages/computer/package.json | 4 + packages/computer/rolldown.config.ts | 1 + .../container-legacy/cloudflare-container.ts | 3 + .../src/backends/worker-shell/worker-shell.ts | 3 + packages/computer/src/runtime/runtime.ts | 7 ++ .../src/tools/{ai.test.ts => ai-sdk.test.ts} | 105 ++++++++++------ packages/computer/src/tools/ai-sdk.ts | 93 +++++++++++++++ packages/computer/src/tools/ai.ts | 50 -------- packages/computer/src/tools/exec.ts | 112 +++++++++++------- packages/computer/src/tools/index.ts | 4 +- 24 files changed, 352 insertions(+), 260 deletions(-) create mode 100644 .changeset/exec-tool-options.md rename packages/computer/src/tools/{ai.test.ts => ai-sdk.test.ts} (95%) create mode 100644 packages/computer/src/tools/ai-sdk.ts delete mode 100644 packages/computer/src/tools/ai.ts diff --git a/.changeset/exec-tool-options.md b/.changeset/exec-tool-options.md new file mode 100644 index 00000000..6dd792bd --- /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 `CloudflareContainerBackend` 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-single-backend.md b/.changeset/exec-tool-single-backend.md index 701465bc..a524ec8d 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.describe(id)`. 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/docs/09_tool_interface.md b/docs/09_tool_interface.md index 181a2ba7..6a9543a7 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.describe(id)`). `WorkerJavaScriptBackend` describes its source language and every module code can import, so the list stays in step with `modules`. `WorkerShellBackend` and `CloudflareContainerBackend` describe their command sets 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 696bb826..665282d6 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -264,10 +264,8 @@ this.workspace = new Workspace({ ], }); -const tools = createAITools({ - workspace: this.workspace, - shell: { backends: { "worker-javascript": {} } }, -}); +// Offer only the JavaScript backend; the container is reached through ws:container. +const tools = createAITools({ workspace: this.workspace, exec: { "worker-javascript": {} } }); ``` ```js diff --git a/docs/README.md b/docs/README.md index 58516013..bd7af671 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,7 @@ It provides: - 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: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: @@ -53,6 +53,7 @@ The package ships several entrypoints: | `@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/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/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/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..ebc17274 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 | | ------- | --------------------------------------------------------- | diff --git a/examples/think/src/agent.ts b/examples/think/src/agent.ts index 755cf4fc..36a31109 100644 --- a/examples/think/src/agent.ts +++ b/examples/think/src/agent.ts @@ -37,7 +37,7 @@ import { withLegacyWorkspaceContainer, } from "@cloudflare/computer/backends/container-legacy"; 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"; @@ -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/packages/computer/README.md b/packages/computer/README.md index 54a7f8a6..d9786886 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -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 @@ -423,6 +419,7 @@ on a computerd instance. | `@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 6cd037c6..1cdc9b96 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -51,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 2e74d76b..1a941889 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -31,6 +31,7 @@ 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", diff --git a/packages/computer/src/backends/container-legacy/cloudflare-container.ts b/packages/computer/src/backends/container-legacy/cloudflare-container.ts index a6089f0a..8508e8f0 100644 --- a/packages/computer/src/backends/container-legacy/cloudflare-container.ts +++ b/packages/computer/src/backends/container-legacy/cloudflare-container.ts @@ -180,6 +180,9 @@ function bearerMatches(header: string | null, expected: string | undefined): boo export class LegacyContainerBackend implements WorkspaceBackend { readonly type = "cloudflare-container"; + /** What this backend tells a model: a full Linux shell that is slower to start. */ + readonly description = + "A shell in a full Linux container: npm, node, python, package managers, test runners, native binaries, and network access. Starts much more slowly than an in-Worker backend because the container must boot."; readonly id: string; readonly #options: Required< 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/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index f74965f8..64842597 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -43,6 +43,13 @@ export class WorkspaceRuntime { return this.#options.backends.get(id)?.callable === true; } + // Every registered backend id, in registration order. The first is + // the default. The exec tool uses this when the caller does not pick + // backends itself. + backendIds(): string[] { + return [...this.#options.backends.keys()]; + } + // 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 diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai-sdk.test.ts similarity index 95% rename from packages/computer/src/tools/ai.test.ts rename to packages/computer/src/tools/ai-sdk.test.ts index 146c7c83..66bed393 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,10 @@ describe("createAITools exec tool", () => { }); }); - it("rejects invalid shell backend configuration", () => { - const workspace = makeWorkspace(); - - expect(() => - createAITools({ - workspace, - shell: { defaultBackend: "missing", backends: { shell: { description: "test" } } }, - }), - ).toThrow(/defaultBackend/); + it("rejects a backend the workspace does not have", () => { + expect(() => createAITools({ workspace: makeWorkspace(), exec: { missing: {} } })).toThrow( + /unknown backend "missing"/, + ); }); }); @@ -1739,7 +1771,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 +1779,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."); }); }); @@ -1859,17 +1890,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"]); }); }); diff --git a/packages/computer/src/tools/ai-sdk.ts b/packages/computer/src/tools/ai-sdk.ts new file mode 100644 index 00000000..f0593dac --- /dev/null +++ b/packages/computer/src/tools/ai-sdk.ts @@ -0,0 +1,93 @@ +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 } { + if (options.shell !== undefined) { + const { backends, defaultBackend: _ignored, ...limits } = options.shell; + return { ...limits, backends }; + } + if (options.exec !== undefined) return { backends: options.exec }; + return { backends: Object.fromEntries((runtime.backendIds?.() ?? []).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..08eb864a 100644 --- a/packages/computer/src/tools/exec.ts +++ b/packages/computer/src/tools/exec.ts @@ -67,27 +67,34 @@ export interface ExecWorkspaceLike { 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. + // shows it after the caller's own text. describe?(id: string): string | undefined; + // Every registered backend id. Used when the caller does not pick + // backends, and to reject an unknown id up front. + backendIds?(): string[]; }; } -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 +142,22 @@ 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); + const [first] = selected; + if (first === undefined) throw new Error("createExecTool: no backends to run on"); + const backendIds = selected.map((backend) => backend.id); + const single = backendIds.length === 1; + const backends = selected.map(({ id, guidance }) => { + const callable = runtime.isCallable?.(id) === true; + const own = runtime.describe?.(id); + 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 +174,8 @@ export function createExecTool(options: ExecToolOptions): Tool 0) { @@ -198,7 +194,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 +352,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, + runtime: ExecWorkspaceLike["runtime"], +): Array<{ id: string; guidance: string | undefined }> { + const known = runtime.backendIds?.(); + 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, From bfb6147a3584c037c7ee82418d1c811d8524123a Mon Sep 17 00:00:00 2001 From: Matt <77928207+mattzcarey@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:02:44 +0100 Subject: [PATCH 3/9] computer: Carry backend information through Workspace clients (#182) * computer: Carry backend information through Workspace clients A WorkspaceClient from getWorkspace() wrapped the runtime with exec, getExec, killExec, and disposeExec only. The exec tool also asks the runtime for backendIds, isCallable, and describe, so a client lost all three: with exec omitted it offered no exec tool, and a callable backend lost its input argument and its module list. Over RPC those calls would be asynchronous, while the tool builds its schema synchronously. Backends are fixed when the Workspace is constructed, so the runtime and its RPC stub now expose one backends() call, and a client takes that snapshot when it is created and answers the three questions from it, locally and remotely alike. CloudflareContainerBackend now describes network access from its egress setting instead of always claiming it, exec takes precedence over the deprecated shell option so exec: {} always means no tool, and the mcp README and think prompt stop implying a default backend. * computer: Answer backend questions with one backends() call The exec tool learned about backends through three runtime methods, backendIds, isCallable, and describe, and every way of reaching a Workspace had to forward all three. It now reads one list from runtime.backends(), each entry carrying the id, whether the backend is callable, and its description. backendIds and describe are gone, and a Workspace client exposes the same backends() from its snapshot. isCallable stays on the runtime for its own check before running. * computer: Freeze the client backend snapshot and test input through it A client returned its backend snapshot array itself, so a caller that edited it changed what later tool sets saw. The snapshot is now frozen once when the client is created. The client tests only checked that the exec schema offered input. They now send structured input through the exec tool on a local and a remote client to a callable backend that echoes it, and check the value comes back as the result. The mcp README no longer calls worker-shell the default. --- .changeset/exec-tool-review-fixes.md | 5 + .changeset/exec-tool-single-backend.md | 2 +- .changeset/modules.md | 2 +- docs/09_tool_interface.md | 2 +- docs/17_isolate_javascript.md | 2 +- examples/mcp/README.md | 6 +- examples/think/src/agent.ts | 4 +- .../container-legacy/cloudflare-container.ts | 19 ++- packages/computer/src/client.test.ts | 137 ++++++++++++++++++ packages/computer/src/client.ts | 25 +++- packages/computer/src/index.ts | 1 + packages/computer/src/runtime/runtime.ts | 33 +++-- packages/computer/src/stub.ts | 6 + packages/computer/src/tools/ai-sdk.test.ts | 43 ++++-- packages/computer/src/tools/ai-sdk.ts | 6 +- packages/computer/src/tools/exec.ts | 32 ++-- 16 files changed, 269 insertions(+), 56 deletions(-) create mode 100644 .changeset/exec-tool-review-fixes.md diff --git a/.changeset/exec-tool-review-fixes.md b/.changeset/exec-tool-review-fixes.md new file mode 100644 index 00000000..220bb2ed --- /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. `CloudflareContainerBackend` 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 a524ec8d..6594ddf7 100644 --- a/.changeset/exec-tool-single-backend.md +++ b/.changeset/exec-tool-single-backend.md @@ -4,4 +4,4 @@ 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 the module list the model reads cannot drift from `modules`. +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/docs/09_tool_interface.md b/docs/09_tool_interface.md index 6a9543a7..50d8b277 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -245,7 +245,7 @@ The tool uses forced removal, so deleting a missing path succeeds. Set `recursiv `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: your text, if any, 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 the list stays in step with `modules`. `WorkerShellBackend` and `CloudflareContainerBackend` describe their command sets and startup cost. A backend that says nothing gets a one-line default, so add text for a custom backend. +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 `CloudflareContainerBackend` describe their command sets 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: diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 665282d6..ccc6533f 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -150,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. 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/think/src/agent.ts b/examples/think/src/agent.ts index 36a31109..56b405e3 100644 --- a/examples/think/src/agent.ts +++ b/examples/think/src/agent.ts @@ -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.", diff --git a/packages/computer/src/backends/container-legacy/cloudflare-container.ts b/packages/computer/src/backends/container-legacy/cloudflare-container.ts index 8508e8f0..1249802b 100644 --- a/packages/computer/src/backends/container-legacy/cloudflare-container.ts +++ b/packages/computer/src/backends/container-legacy/cloudflare-container.ts @@ -138,6 +138,15 @@ export interface LegacyContainerBackendOptions { } 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.", +}; + // Paths the egress proxy serves. The container assembles no paths of // its own, so these travel in the /connect request and both ends stay // in step from one place. @@ -180,9 +189,8 @@ function bearerMatches(header: string | null, expected: string | undefined): boo export class LegacyContainerBackend implements WorkspaceBackend { readonly type = "cloudflare-container"; - /** What this backend tells a model: a full Linux shell that is slower to start. */ - readonly description = - "A shell in a full Linux container: npm, node, python, package managers, test runners, native binaries, and network access. Starts much more slowly than an in-Worker backend because the container must boot."; + /** 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< @@ -212,6 +220,11 @@ export class LegacyContainerBackend 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/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/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index 64842597..bd43f8b9 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -28,6 +28,16 @@ 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; + /** 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,19 +53,16 @@ export class WorkspaceRuntime { return this.#options.backends.get(id)?.callable === true; } - // Every registered backend id, in registration order. The first is - // the default. The exec tool uses this when the caller does not pick - // backends itself. - backendIds(): string[] { - return [...this.#options.backends.keys()]; - } - - // 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, + 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-sdk.test.ts b/packages/computer/src/tools/ai-sdk.test.ts index 66bed393..d7ff742f 100644 --- a/packages/computer/src/tools/ai-sdk.test.ts +++ b/packages/computer/src/tools/ai-sdk.test.ts @@ -1565,6 +1565,24 @@ describe("createAITools exec tool", () => { }); }); + it("lets exec win over the deprecated shell option", () => { + const workspace = { + runtime: { + async exec() { + throw new Error("not used"); + }, + }, + }; + + expect( + createAITools({ + workspace, + 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"/, @@ -1607,7 +1625,7 @@ describe("createAITools callable exec", () => { }), }; }, - isCallable: (id: string) => id === "js", + backends: () => [{ id: "js", callable: true }], }, }; const tools = createAITools({ @@ -1653,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({ @@ -1677,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({ @@ -1732,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({ @@ -1754,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({ @@ -1827,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 }; @@ -1999,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 index f0593dac..b0157cb6 100644 --- a/packages/computer/src/tools/ai-sdk.ts +++ b/packages/computer/src/tools/ai-sdk.ts @@ -84,10 +84,12 @@ 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 }; } - if (options.exec !== undefined) return { backends: options.exec }; - return { backends: Object.fromEntries((runtime.backendIds?.() ?? []).map((id) => [id, {}])) }; + return { backends: Object.fromEntries((runtime.backends?.() ?? []).map(({ id }) => [id, {}])) }; } diff --git a/packages/computer/src/tools/exec.ts b/packages/computer/src/tools/exec.ts index 08eb864a..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,18 +60,12 @@ 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 text. - describe?(id: string): string | undefined; - // Every registered backend id. Used when the caller does not pick - // backends, and to reject an unknown id up front. - backendIds?(): string[]; + // 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[]; }; } @@ -143,14 +137,16 @@ export function createExecTool(options: ExecToolOptions): Tool backend.id); const single = backendIds.length === 1; + const known = runtime.backends?.(); const backends = selected.map(({ id, guidance }) => { - const callable = runtime.isCallable?.(id) === true; - const own = runtime.describe?.(id); + 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."); @@ -355,9 +351,9 @@ function describeTool(backends: readonly DescribedBackend[]): string { // Resolve the caller's choice to a list of backends. function selectBackends( backends: ExecBackends | undefined, - runtime: ExecWorkspaceLike["runtime"], + registered: readonly WorkspaceBackendInfo[] | undefined, ): Array<{ id: string; guidance: string | undefined }> { - const known = runtime.backendIds?.(); + const known = registered?.map((backend) => backend.id); let selected: Array<{ id: string; guidance: string | undefined }>; if (backends === undefined) { if (known === undefined) { From 86206a18c97d31d21f9cd4ea575f8150363c5efd Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 12:15:19 +0100 Subject: [PATCH 4/9] computer: Describe the new ContainerBackend instead of the legacy one The exec tool's self-description for the container landed on the platform-scheduled backend, which is now LegacyContainerBackend. It moves to ContainerBackend, the backend containers should use, with its network line still following the egress mode. The legacy backend goes back to describing nothing and gets the exec tool's one-line default. --- .../container-legacy/cloudflare-container.ts | 16 ---------------- .../container/container-backend-launch.test.ts | 17 +++++++++++++++++ .../src/backends/container/container-backend.ts | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/computer/src/backends/container-legacy/cloudflare-container.ts b/packages/computer/src/backends/container-legacy/cloudflare-container.ts index 1249802b..a6089f0a 100644 --- a/packages/computer/src/backends/container-legacy/cloudflare-container.ts +++ b/packages/computer/src/backends/container-legacy/cloudflare-container.ts @@ -138,15 +138,6 @@ export interface LegacyContainerBackendOptions { } 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.", -}; - // Paths the egress proxy serves. The container assembles no paths of // its own, so these travel in the /connect request and both ends stay // in step from one place. @@ -189,8 +180,6 @@ function bearerMatches(header: string | null, expected: string | undefined): boo export class LegacyContainerBackend 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< @@ -220,11 +209,6 @@ export class LegacyContainerBackend 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/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, From 4296adfc214ae2ea09a9109a74437ef8f60d6f98 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 12:15:20 +0100 Subject: [PATCH 5/9] computer: Check ws:container's backend when it connects ws:container found a missing or wrong container backend only on its first exec. Its factory runs when the JavaScript backend connects and can read the Workspace's backends, so it now fails there: with no such backend, or with one that runs modules instead of shell commands. Its description also stopped promising network access. The model only sees the module's text in this setup, and whether the container can reach the network depends on the container backend's egress setting, which the module cannot know when it is built. --- .../computer/src/modules/container.test.ts | 17 ++++++++ packages/computer/src/modules/container.ts | 39 ++++++++++++++----- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/packages/computer/src/modules/container.test.ts b/packages/computer/src/modules/container.test.ts index c6fdd2a4..10e7d5ac 100644 --- a/packages/computer/src/modules/container.test.ts +++ b/packages/computer/src/modules/container.test.ts @@ -32,6 +32,11 @@ function fakeRuntime(output: { }) { const runs: Run[] = []; const runtime = { + backends: () => [ + { id: "container-shell", callable: false }, + { id: "linux", callable: false }, + { id: "worker-javascript", callable: true }, + ], async exec(command: string, options: ExecOptions) { const run: Run = { command, options, killed: false }; runs.push(run); @@ -202,6 +207,18 @@ describe("createContainerModule", () => { 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("refuses a backend that runs modules instead of shell commands", () => { + const { runtime } = fakeRuntime({}); + expect(() => build(runtime, { backend: "worker-javascript" })).toThrow( + /runs modules, 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 index 06720b7a..61a10ff8 100644 --- a/packages/computer/src/modules/container.ts +++ b/packages/computer/src/modules/container.ts @@ -16,6 +16,7 @@ import type { WorkspaceModuleCallContext, WorkspaceModuleFactory, + WorkspaceModuleFunction, WorkspaceModuleFunctions, WorkspaceModuleHost, WorkspaceRuntimeValue, @@ -48,15 +49,16 @@ export interface ContainerModuleOptions { * 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 and reach the network, - * so `exec` refuses to run on a read-only backend. Egress settings on - * the JavaScript backend do not apply to the container. + * 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 host - * configured the module wrongly. + * @throws When `maxOutputBytes` is not a positive integer. The module + * also throws when the backend connects if the Workspace has no + * such backend, or it runs modules rather than shell commands. */ export function createContainerModule( options: ContainerModuleOptions = {}, @@ -67,8 +69,26 @@ export function createContainerModule( throw new Error("createContainerModule: maxOutputBytes must be a positive integer."); } - const create = (host: WorkspaceModuleHost): WorkspaceModuleFunctions => ({ - async exec(args, context) { + const create = (host: WorkspaceModuleHost): WorkspaceModuleFunctions => { + // The factory runs when the JavaScript backend connects, so a + // missing or mismatched 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 }).`, + ); + } + if (target.callable) { + throw new Error( + `ws:container: backend ${JSON.stringify(backend)} runs modules, 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."); } @@ -99,14 +119,13 @@ export function createContainerModule( } 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, native binaries, and network access. The container can take a while to start on first use.", + "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(" "); From ef893771b8432701dba8cbc903070d8e48d8a1c9 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 12:15:54 +0100 Subject: [PATCH 6/9] docs: Use ContainerBackend for ws:container The ws:container docs, the exec tool docs, and the changesets named the old CloudflareContainerBackend. They now show ContainerBackend, the durable-object-scheduled backend containers should use. --- .changeset/exec-tool-options.md | 2 +- .changeset/exec-tool-review-fixes.md | 2 +- .changeset/ws-container-module.md | 2 +- docs/09_tool_interface.md | 2 +- docs/17_isolate_javascript.md | 34 +++++++++++++++++----------- 5 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.changeset/exec-tool-options.md b/.changeset/exec-tool-options.md index 6dd792bd..43992570 100644 --- a/.changeset/exec-tool-options.md +++ b/.changeset/exec-tool-options.md @@ -4,7 +4,7 @@ `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 `CloudflareContainerBackend` 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. +`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`. diff --git a/.changeset/exec-tool-review-fixes.md b/.changeset/exec-tool-review-fixes.md index 220bb2ed..94612fcb 100644 --- a/.changeset/exec-tool-review-fixes.md +++ b/.changeset/exec-tool-review-fixes.md @@ -2,4 +2,4 @@ "@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. `CloudflareContainerBackend` describes network access that matches its `egress` setting, and `exec` takes precedence over the deprecated `shell` option. +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/ws-container-module.md b/.changeset/ws-container-module.md index 9b227b4b..4553323e 100644 --- a/.changeset/ws-container-module.md +++ b/.changeset/ws-container-module.md @@ -2,4 +2,4 @@ "@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 container backend with `import { exec } from "ws:container"`. 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. +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 does not run 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/docs/09_tool_interface.md b/docs/09_tool_interface.md index 50d8b277..52e24963 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -245,7 +245,7 @@ The tool uses forced removal, so deleting a missing path succeeds. Set `recursiv `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: 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 `CloudflareContainerBackend` describe their command sets and startup cost. A backend that says nothing gets a one-line default, so add text for a custom backend. +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: diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index ccc6533f..2f098116 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -252,17 +252,25 @@ import { create, get, list, importArtifact, deleteArtifact } from "ws:artifacts" `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 -this.workspace = new Workspace({ - storage: ctx.storage, - backends: [ - new WorkerJavaScriptBackend({ - loader: env.LOADER, - access: "read-write", - modules: { "ws:container": createContainerModule() }, - }), - new CloudflareContainerBackend({ /* ... */ }), - ], -}); +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": {} } }); @@ -277,7 +285,7 @@ export default async function () { } ``` -`exec(command, { cwd, env, stdin, timeoutMs })` runs through `workspace.runtime.exec` on the container backend (`"container-shell"` unless you pass `backend`). 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. +`exec(command, { cwd, env, stdin, timeoutMs })` runs through `workspace.runtime.exec` on the container backend: `ContainerBackend`, registered as `"container-shell"` unless you pass `backend`. The backend must exist and run shell commands, or 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: @@ -285,7 +293,7 @@ A few limits follow from `exec` being a host call: - 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 and reach the network, whatever the JavaScript backend's egress settings say. `exec` refuses to run on a read-only backend. +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 From 0970232ea9cb59d44c5cda3173d202c8c35bf21d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 12:17:18 +0100 Subject: [PATCH 7/9] examples/think, examples/mcp: Move to ContainerBackend Both examples ran their container through LegacyContainerBackend, the platform-scheduled backend, which no longer describes itself to the model. They now use ContainerBackend: the durable object schedules the container, the containers block names an image under images.app with scheduling_policy "durable_object", and the backend asks for standard-2 at launch, the size the old block requested. --- examples/mcp/src/index.ts | 12 ++++++------ examples/mcp/wrangler.jsonc | 9 ++++----- examples/think/README.md | 7 ++++--- examples/think/src/agent.ts | 20 ++++++++++---------- examples/think/wrangler.jsonc | 17 +++++++++-------- 5 files changed, 33 insertions(+), 32 deletions(-) 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/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/think/README.md b/examples/think/README.md index ebc17274..f6cd582d 100644 --- a/examples/think/README.md +++ b/examples/think/README.md @@ -76,8 +76,9 @@ 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 @@ -88,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 56b405e3..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,10 +32,7 @@ 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/ai-sdk"; import { Think } from "@cloudflare/think"; @@ -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", }); /** 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" } + } } ], From da2f7980a3801175b3438d23898aa75af982383b Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 12:31:35 +0100 Subject: [PATCH 8/9] computer: Check only that ws:container's backend exists ws:container also rejected a backend marked callable, taking that as a sign it runs modules. callable means a backend takes structured input, and a shell backend may do both, so the check refused valid backends and let through a module backend that was not callable. It now checks only that the backend exists. --- .changeset/ws-container-module.md | 2 +- docs/17_isolate_javascript.md | 2 +- packages/computer/src/modules/container.test.ts | 7 ------- packages/computer/src/modules/container.ts | 14 ++++---------- 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/.changeset/ws-container-module.md b/.changeset/ws-container-module.md index 4553323e..70278091 100644 --- a/.changeset/ws-container-module.md +++ b/.changeset/ws-container-module.md @@ -2,4 +2,4 @@ "@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 does not run 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. +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. 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/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 2f098116..7f2b5558 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -285,7 +285,7 @@ export default async function () { } ``` -`exec(command, { cwd, env, stdin, timeoutMs })` runs through `workspace.runtime.exec` on the container backend: `ContainerBackend`, registered as `"container-shell"` unless you pass `backend`. The backend must exist and run shell commands, or 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. +`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, 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: diff --git a/packages/computer/src/modules/container.test.ts b/packages/computer/src/modules/container.test.ts index 10e7d5ac..23531abd 100644 --- a/packages/computer/src/modules/container.test.ts +++ b/packages/computer/src/modules/container.test.ts @@ -212,13 +212,6 @@ describe("createContainerModule", () => { expect(() => build(runtime, { backend: "missing" })).toThrow(/no backend "missing"/); }); - it("refuses a backend that runs modules instead of shell commands", () => { - const { runtime } = fakeRuntime({}); - expect(() => build(runtime, { backend: "worker-javascript" })).toThrow( - /runs modules, 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 index 61a10ff8..59d38520 100644 --- a/packages/computer/src/modules/container.ts +++ b/packages/computer/src/modules/container.ts @@ -58,7 +58,7 @@ export interface ContainerModuleOptions { * `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 it runs modules rather than shell commands. + * such backend. */ export function createContainerModule( options: ContainerModuleOptions = {}, @@ -71,19 +71,13 @@ export function createContainerModule( const create = (host: WorkspaceModuleHost): WorkspaceModuleFunctions => { // The factory runs when the JavaScript backend connects, so a - // missing or mismatched 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) { + // missing container backend fails there, before any code runs, + // rather than on the first exec. + if (!host.runtime.backends().some((info) => info.id === backend)) { throw new Error( `ws:container: the Workspace has no backend ${JSON.stringify(backend)}. Register a ContainerBackend, or pass createContainerModule({ backend }).`, ); } - if (target.callable) { - throw new Error( - `ws:container: backend ${JSON.stringify(backend)} runs modules, not shell commands.`, - ); - } return { exec: execOn(host) }; }; const execOn = From 0e2e9789413cda77d2e400a8ac98c7326965db93 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 14:27:35 +0100 Subject: [PATCH 9/9] computer: Tell ws:container's backend kind by protocol ws:container needs a backend that runs shell commands. It first judged that by callable, which describes structured input instead: a shell backend may be callable, and a module backend need not be. Without any check, pointing it at the JavaScript backend would run a shell command as JavaScript, or start a nested run. runtime.backends() now reports each backend's protocol, "command" or "module", and ws:container refuses a module backend when it connects. A callable shell backend is accepted. --- .changeset/ws-container-module.md | 2 +- docs/17_isolate_javascript.md | 2 +- .../computer/src/modules/container.test.ts | 18 +++++++++-- packages/computer/src/modules/container.ts | 17 ++++++++--- packages/computer/src/runtime/runtime.ts | 30 ++++++++++++------- 5 files changed, 49 insertions(+), 20 deletions(-) diff --git a/.changeset/ws-container-module.md b/.changeset/ws-container-module.md index 70278091..0b7d0985 100644 --- a/.changeset/ws-container-module.md +++ b/.changeset/ws-container-module.md @@ -2,4 +2,4 @@ "@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. 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. +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/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 7f2b5558..b37af753 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -285,7 +285,7 @@ export default async function () { } ``` -`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, 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. +`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: diff --git a/packages/computer/src/modules/container.test.ts b/packages/computer/src/modules/container.test.ts index 23531abd..6a1ff849 100644 --- a/packages/computer/src/modules/container.test.ts +++ b/packages/computer/src/modules/container.test.ts @@ -33,9 +33,9 @@ function fakeRuntime(output: { const runs: Run[] = []; const runtime = { backends: () => [ - { id: "container-shell", callable: false }, - { id: "linux", callable: false }, - { id: "worker-javascript", callable: true }, + { 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 }; @@ -212,6 +212,18 @@ describe("createContainerModule", () => { 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 index 59d38520..0002a082 100644 --- a/packages/computer/src/modules/container.ts +++ b/packages/computer/src/modules/container.ts @@ -58,7 +58,8 @@ export interface ContainerModuleOptions { * `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. + * such backend, or that backend runs module source rather than shell + * commands. A callable shell backend is fine. */ export function createContainerModule( options: ContainerModuleOptions = {}, @@ -71,13 +72,21 @@ export function createContainerModule( const create = (host: WorkspaceModuleHost): WorkspaceModuleFunctions => { // The factory runs when the JavaScript backend connects, so a - // missing container backend fails there, before any code runs, - // rather than on the first exec. - if (!host.runtime.backends().some((info) => info.id === backend)) { + // 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 = diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index bd43f8b9..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; } @@ -32,6 +34,11 @@ export function notCallableMessage(backend: string): string { 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. */ @@ -60,6 +67,7 @@ export class WorkspaceRuntime { 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 }), }));