From 996c8c7d702eede887d3554e7bde1e0db64472ba Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 11:27:36 +0100 Subject: [PATCH 1/3] 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. --- .changeset/exec-tool-review-fixes.md | 5 ++ examples/mcp/README.md | 4 +- examples/think/src/agent.ts | 4 +- .../container/cloudflare-container.test.ts | 14 +++++ .../container/cloudflare-container.ts | 19 +++++-- packages/computer/src/client.test.ts | 51 +++++++++++++++++++ packages/computer/src/client.ts | 27 +++++++++- packages/computer/src/index.ts | 1 + packages/computer/src/runtime/runtime.ts | 26 ++++++++-- packages/computer/src/stub.ts | 6 +++ packages/computer/src/tools/ai-sdk.test.ts | 18 +++++++ packages/computer/src/tools/ai-sdk.ts | 4 +- 12 files changed, 166 insertions(+), 13 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..ef1cbb64 --- /dev/null +++ b/.changeset/exec-tool-review-fixes.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +A `WorkspaceClient` from `getWorkspace()` now answers `runtime.backendIds()`, `runtime.isCallable(id)`, and `runtime.describe(id)`, 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/examples/mcp/README.md b/examples/mcp/README.md index 3be5d529..1c183188 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 diff --git a/examples/think/src/agent.ts b/examples/think/src/agent.ts index c706d3f4..2401300c 100644 --- a/examples/think/src/agent.ts +++ b/examples/think/src/agent.ts @@ -137,8 +137,8 @@ export class Assistant extends withWorkspaceContainer(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/cloudflare-container.test.ts b/packages/computer/src/backends/container/cloudflare-container.test.ts index f34a0135..4fcce561 100644 --- a/packages/computer/src/backends/container/cloudflare-container.test.ts +++ b/packages/computer/src/backends/container/cloudflare-container.test.ts @@ -177,6 +177,20 @@ function makeFakeHost(opts: FakeHostOptions = {}): FakeHost { const fakeWorkspace: WorkspaceRef = { binding: "TestDO", id: "abc123" }; describe("CloudflareContainerBackend", () => { + it.each([ + [undefined, "It has no network access."], + [{ mode: "none" as const }, "It has no network access."], + [{ mode: "direct" as const }, "It has network access."], + ])("describes network access that matches egress %o", (egress, expected) => { + const backend = new CloudflareContainerBackend({ + container: () => ({ getWorkspaceContainer: () => ({}) }) as never, + workspace: fakeWorkspace, + ...(egress === undefined ? {} : { egress }), + }); + + expect(backend.description).toContain(expected); + }); + test("connect() classifies a container start failure as transport", async () => { const platformError = new Error( "There is no container instance that can be provided to this Durable Object, try again later", diff --git a/packages/computer/src/backends/container/cloudflare-container.ts b/packages/computer/src/backends/container/cloudflare-container.ts index 29862b23..2438b533 100644 --- a/packages/computer/src/backends/container/cloudflare-container.ts +++ b/packages/computer/src/backends/container/cloudflare-container.ts @@ -138,6 +138,15 @@ export interface CloudflareContainerBackendOptions { } 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 CloudflareContainerBackend 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 CloudflareContainerBackend 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..b785a7a8 100644 --- a/packages/computer/src/client.test.ts +++ b/packages/computer/src/client.test.ts @@ -9,8 +9,11 @@ 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 { createAITools } from "./tools/ai-sdk.js"; import { WORKSPACE, type WorkspaceStubHost } from "./with-workspace.js"; import { type ThinkWorkspaceCompatibility, Workspace } from "./workspace.js"; @@ -71,6 +74,9 @@ function fakeRuntime(promisedProperties = false) { calls.push({ command: `dispose:${id}`, options }); return Promise.resolve(); }, + backends() { + return promisedProperties ? Promise.resolve([]) : []; + }, }, }; } @@ -329,3 +335,48 @@ describe("client runtime.exec — remote handle rebuild", () => { expect(disposedHandles()).toBe(1); }); }); + +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(`answers backend questions on a ${path} client`, async () => { + const client = await connect(workspaceWithJavaScript()); + + expect(client.runtime.backendIds()).toEqual(["worker-javascript"]); + expect(client.runtime.isCallable("worker-javascript")).toBe(true); + expect(client.runtime.isCallable("missing")).toBe(false); + expect(client.runtime.describe("worker-javascript")).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..3019ee23 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,12 @@ export interface WorkspaceRuntimeClient { ): Promise>; killExec(id: string, options?: RuntimeKillOptions): Promise; disposeExec(id: string, options?: { backend?: string }): Promise; + /** Every registered backend id. */ + backendIds(): string[]; + /** Whether the backend takes structured `input` and returns a `result`. */ + isCallable(id: string): boolean; + /** What the backend tells a model about itself. */ + describe(id: string): string | undefined; } // Options accepted by the plain `exec` form, common to both paths. @@ -270,6 +276,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 answers these questions for the client's lifetime, locally + // and over RPC alike. + backends: readonly WorkspaceBackendInfo[], ): WorkspaceRuntimeClient { async function exec( commandOrStrings: string | TemplateStringsArray, @@ -302,7 +312,16 @@ 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; + const backend = (id: string) => backends.find((info) => info.id === id); + return { + exec, + getExec, + killExec, + disposeExec, + backendIds: () => backends.map((info) => info.id), + isCallable: (id: string) => backend(id)?.callable === true, + describe: (id: string) => backend(id)?.description, + } as WorkspaceRuntimeClient; } function withExecutionId( @@ -333,10 +352,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 +405,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 +420,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 b98ab49a..c8764af6 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..4f9dcf7f 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,13 +53,23 @@ 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. + // Every registered backend id, in registration order. The exec tool + // uses this when the caller does not pick backends itself. backendIds(): string[] { return [...this.#options.backends.keys()]; } + // What each backend says about itself, in one plain value. A + // Workspace client takes this snapshot when it is created, so it can + // answer backendIds, isCallable, and describe without a round trip. + backends(): WorkspaceBackendInfo[] { + return [...this.#options.backends].map(([id, backend]) => ({ + id, + callable: backend.callable === true, + ...(backend.description === undefined ? {} : { description: backend.description }), + })); + } + // 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/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..2ecaf4c2 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"/, diff --git a/packages/computer/src/tools/ai-sdk.ts b/packages/computer/src/tools/ai-sdk.ts index f0593dac..3af720a9 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, {}])) }; } From 3de7511875bf054bdb431280b80f944c00b21498 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 11:37:57 +0100 Subject: [PATCH 2/3] 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. --- .changeset/exec-tool-review-fixes.md | 2 +- .changeset/exec-tool-single-backend.md | 2 +- .changeset/modules.md | 2 +- docs/09_tool_interface.md | 2 +- docs/17_isolate_javascript.md | 2 +- packages/computer/src/client.test.ts | 9 +++--- packages/computer/src/client.ts | 17 ++++-------- packages/computer/src/runtime/runtime.ts | 21 +++----------- packages/computer/src/tools/ai-sdk.test.ts | 25 +++++++++++------ packages/computer/src/tools/ai-sdk.ts | 2 +- packages/computer/src/tools/exec.ts | 32 ++++++++++------------ 11 files changed, 50 insertions(+), 66 deletions(-) diff --git a/.changeset/exec-tool-review-fixes.md b/.changeset/exec-tool-review-fixes.md index ef1cbb64..220bb2ed 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.backendIds()`, `runtime.isCallable(id)`, and `runtime.describe(id)`, 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. `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 6e3f351d..33c31f9f 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/packages/computer/src/client.test.ts b/packages/computer/src/client.test.ts index b785a7a8..63aedde9 100644 --- a/packages/computer/src/client.test.ts +++ b/packages/computer/src/client.test.ts @@ -361,10 +361,11 @@ describe("getWorkspace — backend information", () => { it(`answers backend questions on a ${path} client`, async () => { const client = await connect(workspaceWithJavaScript()); - expect(client.runtime.backendIds()).toEqual(["worker-javascript"]); - expect(client.runtime.isCallable("worker-javascript")).toBe(true); - expect(client.runtime.isCallable("missing")).toBe(false); - expect(client.runtime.describe("worker-javascript")).toContain("`ws:weather`: exports"); + 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 () => { diff --git a/packages/computer/src/client.ts b/packages/computer/src/client.ts index 3019ee23..7760af96 100644 --- a/packages/computer/src/client.ts +++ b/packages/computer/src/client.ts @@ -215,12 +215,8 @@ export interface WorkspaceRuntimeClient { ): Promise>; killExec(id: string, options?: RuntimeKillOptions): Promise; disposeExec(id: string, options?: { backend?: string }): Promise; - /** Every registered backend id. */ - backendIds(): string[]; - /** Whether the backend takes structured `input` and returns a `result`. */ - isCallable(id: string): boolean; - /** What the backend tells a model about itself. */ - describe(id: string): string | undefined; + /** 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. @@ -277,8 +273,8 @@ function makeRuntimeClient( // 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 answers these questions for the client's lifetime, locally - // and over RPC alike. + // 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( @@ -312,15 +308,12 @@ function makeRuntimeClient( const killExec = (id: string, options?: RuntimeKillOptions) => runtime.killExec(id, options); const disposeExec = (id: string, options?: { backend?: string }) => runtime.disposeExec(id, options); - const backend = (id: string) => backends.find((info) => info.id === id); return { exec, getExec, killExec, disposeExec, - backendIds: () => backends.map((info) => info.id), - isCallable: (id: string) => backend(id)?.callable === true, - describe: (id: string) => backend(id)?.description, + backends: () => backends, } as WorkspaceRuntimeClient; } diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index 4f9dcf7f..bd43f8b9 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -53,15 +53,10 @@ export class WorkspaceRuntime { return this.#options.backends.get(id)?.callable === true; } - // Every registered backend id, in registration order. The exec tool - // uses this when the caller does not pick backends itself. - backendIds(): string[] { - return [...this.#options.backends.keys()]; - } - - // What each backend says about itself, in one plain value. A - // Workspace client takes this snapshot when it is created, so it can - // answer backendIds, isCallable, and describe without a round trip. + // 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, @@ -70,14 +65,6 @@ export class WorkspaceRuntime { })); } - // 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; - } - 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 2ecaf4c2..d7ff742f 100644 --- a/packages/computer/src/tools/ai-sdk.test.ts +++ b/packages/computer/src/tools/ai-sdk.test.ts @@ -1625,7 +1625,7 @@ describe("createAITools callable exec", () => { }), }; }, - isCallable: (id: string) => id === "js", + backends: () => [{ id: "js", callable: true }], }, }; const tools = createAITools({ @@ -1671,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({ @@ -1695,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({ @@ -1750,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({ @@ -1772,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({ @@ -1845,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 }; @@ -2017,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 3af720a9..b0157cb6 100644 --- a/packages/computer/src/tools/ai-sdk.ts +++ b/packages/computer/src/tools/ai-sdk.ts @@ -91,5 +91,5 @@ function execOptions( const { backends, defaultBackend: _ignored, ...limits } = options.shell; return { ...limits, backends }; } - 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 6511ce705452534842b835626616f47d8b7428e5 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 12:00:45 +0100 Subject: [PATCH 3/3] 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. --- examples/mcp/README.md | 2 +- packages/computer/src/client.test.ts | 85 ++++++++++++++++++++++++++++ packages/computer/src/client.ts | 7 ++- 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/examples/mcp/README.md b/examples/mcp/README.md index 1c183188..ffc2f1a5 100644 --- a/examples/mcp/README.md +++ b/examples/mcp/README.md @@ -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/packages/computer/src/client.test.ts b/packages/computer/src/client.test.ts index 63aedde9..7111edab 100644 --- a/packages/computer/src/client.test.ts +++ b/packages/computer/src/client.test.ts @@ -13,6 +13,8 @@ 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"; @@ -336,6 +338,44 @@ describe("client runtime.exec — remote handle rebuild", () => { }); }); +// 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. @@ -351,6 +391,51 @@ describe("getWorkspace — backend information", () => { }); } + 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 })], [ diff --git a/packages/computer/src/client.ts b/packages/computer/src/client.ts index 7760af96..5247b105 100644 --- a/packages/computer/src/client.ts +++ b/packages/computer/src/client.ts @@ -308,12 +308,17 @@ function makeRuntimeClient( const killExec = (id: string, options?: RuntimeKillOptions) => runtime.killExec(id, options); const disposeExec = (id: string, options?: { backend?: string }) => runtime.disposeExec(id, options); + // 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: () => backends, + backends: () => snapshot, } as WorkspaceRuntimeClient; }