Skip to content

computer: Serve an agent's own tools to isolate JavaScript as ws:tools - #216

Open
aron-cf wants to merge 1 commit into
mainfrom
tool-module
Open

aron-cf wants to merge 1 commit into
mainfrom
tool-module

Conversation

@aron-cf

@aron-cf aron-cf commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Code running in exec can now import and call it's toolset.

createToolBindings(), from @cloudflare/computer/modules/tools, is generic. It turns a list of bindings into the ws:tools module:

import { createToolBindings } from "@cloudflare/computer/modules/tools";

new WorkerJavaScriptBackend({
  loader: env.LOADER,
  modules: {
    "ws:tools": createToolBindings([
      {
        name: "weather",
        call: async ({ city }) => ({ text: await forecast(String(city)), isError: false }),
      },
    ]),
  },
});

forPiTools(), from @cloudflare/computer/tools/pi-ai, builds those bindings from pi tools. It runs each call the way pi's agent loop does: prepareArguments, then validation, then execute.

import { validateToolArguments } from "@earendil-works/pi-ai";
import { createToolBindings } from "@cloudflare/computer/modules/tools";
import { forPiTools } from "@cloudflare/computer/tools/pi-ai";

// pi-agent-core AgentTools, read each time the backend connects
"ws:tools": createToolBindings(forPiTools(() => agent.tools, { validate: validateToolArguments }))

// or the Workspace's own tool set
"ws:tools": createToolBindings(forPiTools(createPiTools({ workspace })))

The exec then can import the tools as any other module:

import { grep, read } from "ws:tools";

export default async function ({ files }) {
  const todos = {};
  for (const path of files) {
    const { text, isError } = await grep({ query: "TODO", path });
    if (!isError && text.trim()) todos[path] = text;
  }
  return todos;
}

Each tool takes one object of arguments, the same object the model would pass. It returns { text, details, isError }, plus structuredContent when the tool provides one.


Devin Review

Code running in exec often wants the tools the model already has: a
loop that greps a hundred files is one exec call instead of a hundred
model turns. Each host that wanted this had to write its own host
module, repeating the same argument checks, export-name rules, and
result shape.

createToolBindings() in @cloudflare/computer/modules/tools builds the
ws:tools module from bindings, each a name and a function to call. It
takes a list, or a function the backend calls each time it connects,
for tools that need a Workspace that does not exist yet when the
backend is constructed. Every export takes one object of arguments and
resolves with { text, details, isError }. exec is left out by default,
since the caller is already inside exec and a run that can start runs
has no bound on how deep it goes. A tool whose name cannot be an
export fails the connection with a message naming it, rather than the
backend's generic one.

forPiTools() in @cloudflare/computer/tools/pi-ai makes those bindings
from pi. A pi-ai Tool is only a declaration, so it takes executable
tools in the shape pi-agent-core defines, or the result of
createPiTools(). A call runs the way pi's agent loop runs a model's
call: prepareArguments, then an optional validator, then execute with
a fresh call id and the call's abort signal. Passing pi-ai's
validateToolArguments holds code to the same check as the model. The
pi types stay structural, so pi is still not a build-time dependency.
@changeset-bot

changeset-bot Bot commented Oct 8, 2026

Copy link
Copy Markdown

馃 Changeset detected

Latest commit: 07e3316

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@cloudflare/computer Minor
@cloudflare/dofs Minor
@cloudflare/computer-rpc Minor
@cloudflare/computerd Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

Devin Review

functions[binding.name] = async (args, context) =>
binding.call(singleObject(binding.name, args), context);
}
return functions;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

馃敶 Empty tool sets block execution

When no tools remain after filtering, createToolBindings returns an empty module. assertHostModuleExports rejects it during connection, so even executions without ws:tools fail.

Learn more

The JavaScript backend checks every host module's exports when it connects, before running any source. A tool list can be empty, or the default exec exclusion can remove its only tool. In either case, this factory returns no functions, and assertHostModuleExports rejects the entire connection. No execution can start, even one that never imports ws:tools.

Example: With createToolBindings(forPiTools([execTool])), exec is excluded. A script importing only node:path fails to start because the ws:tools factory exports nothing.

Recommended fix: Define how an empty ws:tools module is represented and ensure the backend accepts that representation. Alternatively, fail at configuration with a targeted message if empty tool sets are unsupported; test both an empty list and an all-excluded list.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

): WorkspaceModuleFactory {
const exclude = new Set(options.exclude ?? ["exec"]);
const create = (_host: WorkspaceModuleHost): WorkspaceModuleFunctions => {
const functions: Record<string, WorkspaceModuleFunction> = {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

馃煛 Tool named proto disappears

A tool named __proto__ passes validation, but assigning its function to functions invokes the inherited setter. The export disappears, and a sole __proto__ tool blocks backend connection.

Suggested change
const functions: Record<string, WorkspaceModuleFunction> = {};
const functions: Record<string, WorkspaceModuleFunction> = Object.create(null);

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

Comment on lines +92 to +93
functions[binding.name] = async (args, context) =>
binding.call(singleObject(binding.name, args), context);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

馃煡 Read-only executions can modify Workspace files

On a read-only backend, createToolBindings still exposes mutating agent tools and calls them without checking context.access. Those tools use the host Workspace, so write or delete can change files despite read-only execution.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

Comment on lines +92 to +93
functions[binding.name] = async (args, context) =>
binding.call(singleObject(binding.name, args), context);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

馃煡 Tool calls escape the backend root

With a backend rooted below Workspace root, createToolBindings passes tool paths unchanged instead of using context.resolvePath. Bound filesystem tools then access files outside the backend root.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

@pkg-pr-new

pkg-pr-new Bot commented Oct 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@216

commit: 07e3316

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant