Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/exec-tool-review-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .changeset/exec-tool-single-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
2 changes: 1 addition & 1 deletion .changeset/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 })`.
2 changes: 1 addition & 1 deletion docs/09_tool_interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion docs/17_isolate_javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions examples/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

```text
Use container-shell to create a small Node.js project in /workspace, install its dependencies, and run its tests.
Expand Down Expand Up @@ -118,15 +118,15 @@ 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

`@cloudflare/codemode` runs Code Mode orchestration code in an isolated Dynamic Worker with outbound networking disabled. Tool calls return to the Durable Object and operate on its Computer workspace.

| 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.
Expand Down
4 changes: 2 additions & 2 deletions examples/think/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 16 additions & 3 deletions packages/computer/src/backends/container/cloudflare-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkspaceEgressPolicy["mode"], string> = {
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.
Expand Down Expand Up @@ -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<
Expand Down Expand Up @@ -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,
Expand Down
137 changes: 137 additions & 0 deletions packages/computer/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -71,6 +76,9 @@ function fakeRuntime(promisedProperties = false) {
calls.push({ command: `dispose:${id}`, options });
return Promise.resolve();
},
backends() {
return promisedProperties ? Promise.resolve([]) : [];
},
},
};
}
Expand Down Expand Up @@ -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<unknown>;
};
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<string, unknown> };

expect(exec.description).toContain("`ws:weather`: exports `forecast`.");
expect(Object.keys(schema.properties)).toContain("input");
});
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
});
25 changes: 23 additions & 2 deletions packages/computer/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
// over RPC.

import type { WorkspaceFilesystem } from "@cloudflare/dofs";

import type { WorkspaceBackendInfo } from "./runtime/runtime.js";
import type {
WorkspaceRuntimeEvent,
WorkspaceRuntimeExecHandle,
Expand Down Expand Up @@ -215,6 +215,8 @@ export interface WorkspaceRuntimeClient {
): Promise<WorkspaceRuntimeExecHandle<ExecEncoding>>;
killExec(id: string, options?: RuntimeKillOptions): Promise<void>;
disposeExec(id: string, options?: { backend?: string }): Promise<void>;
/** 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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -384,6 +403,7 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise<WorkspaceCl
(h) => h,
() => {},
local.useThink,
local.runtime.backends(),
);
}
// Remote path: fetch the stub over RPC and delegate to it. Handle
Expand All @@ -398,6 +418,7 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise<WorkspaceCl
(stub as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.();
},
await stub.useThink,
await stub.runtime.backends(),
);
} catch (error) {
(stub as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.();
Expand Down
1 change: 1 addition & 0 deletions packages/computer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading