Repository navigation
computer: Update tool sets to work with new exec model - #186
mattzcarey wants to merge 17 commits into
Conversation
WorkerJavaScriptBackend had two ways to add imports: modules for
bundled source, and trustedModules for a single call(method, args)
host handler reached through one generic call export. On top of those,
node:fs, ws:git, and ws:artifacts were always installed, with git and
artifacts special-cased in the bridge and gated by allowGitNetwork and
allowArtifactNetwork.
There is now one modules option, and the value's type says what it
is. A string is bundled source, as before. An object of functions is a
host module under a ws:* specifier, and each function becomes a named
export, so { "ws:weather": { forecast } } lets code write
import { forecast } from "ws:weather". A function is a factory the
backend calls when it connects, with the Workspace's Git client,
Artifacts client, and runtime. Each call gets its access level and a
path resolver confined to the backend root alongside the signal and
deadline, and may return any JSON-compatible value; the bridge checks
it at runtime.
Git and Artifacts become prebuilt factories under
@cloudflare/computer/modules/*, and nothing under ws: is installed
unless configured. Network access moves to allowNetwork on each
module. node:fs and node:fs/promises stay built in. Specifiers, source
module names, and object export names are checked at construction.
The backend also describes its source language and every importable
module in a description property, which workspace.runtime.describe(id)
returns, built from the same modules option it runs with. The rlm
example moves ws:model to a named batch function.
The Git CLI now takes a leading -C <path>, because agents reach for git -C instead of changing directory. ws:git rejected every -C as a path override, so those commands failed inside an isolate. A leading -C now becomes the command's working directory and goes through the same root confinement as cwd. A -C anywhere else, and --git-dir and --work-tree, are still rejected.
The exec tool always offered a backend argument, even with one backend configured, and always offered input, even when no backend accepted it. With one backend the model saw an enum of one value and a description written for choosing between backends. Callers also had to describe each backend by hand, so the modules a JavaScript backend installs had to be listed twice and kept in step. With one backend the tool now has no backend argument and always runs there, defaultBackend becomes optional, and the description talks about what that backend does. input appears only when some backend accepts it. A backend value sent anyway is removed by the schema. Each backend's entry adds what the backend says about itself, read through workspace.runtime.describe(id), after the caller's own description, which becomes optional for a backend that describes itself. The tool builds one input schema instead of a single-backend and a multi-backend variant, and its output truncation moves to a shared UTF-8 helper.
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.
* 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.
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.
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.
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.
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.
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 detectedLatest commit: 67b065c The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
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 |
| const { task } = (await request.json()) as { task?: string }; | ||
| if (!task) return new Response("body needs a task\n", { status: 400 }); | ||
|
|
||
| const agent = env.PiAgent.get(env.PiAgent.idFromName("demo")); | ||
| return new Response(`${await agent.run(task)}\n`); |
There was a problem hiding this comment.
There was a problem hiding this comment.
Intentional. These are local wrangler dev demos, unauthenticated like the other examples in this repo (worker-shell, worker-javascript, tutorial). The README covers running it locally.
| const { task } = (await request.json()) as { task?: string }; | ||
| if (!task) return new Response("body needs a task\n", { status: 400 }); | ||
|
|
||
| const agent = env.TanStackAgent.get(env.TanStackAgent.idFromName("demo")); | ||
| return new Response(`${await agent.run(task)}\n`); |
There was a problem hiding this comment.
🟥 Anonymous callers control the TanStack agent
Any caller can POST a task to TanStackAgent.run without authentication. The agent exposes workspace writes and shell execution, allowing anonymous callers to consume Workers AI quota and alter shared files.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Same as the pi example: a local wrangler dev demo, unauthenticated like the repo's other examples.
commit: |
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.
| for (const [name, field] of Object.entries(schema.shape as Record<string, z.ZodType>)) { | ||
| if (field.safeParse(undefined).success && !field.safeParse(null).success) names.add(name); | ||
| } |
There was a problem hiding this comment.
🟡 Invalid optional arguments execute tools
When a caller passes path: null to find, absentWhenNull drops it even though the declared schema rejects null. The tool searches /workspace instead of returning an invalid-arguments error.
Learn more
The pi dispatcher removes null placeholders before validating arguments. Strict sampling can produce those placeholders for optional fields in read, write, or edit; ordinary calls to other tools do not require this conversion. Here, absentWhenNull also includes optional fields on tools such as findInputSchema. Its path schema accepts omission but not null. Removing an explicit null lets its default apply, so an invalid call runs against the workspace root.
Example: A pi caller invokes execute({ id: "1", name: "find", arguments: { pattern: "**/*.ts", path: null } }). Instead of returning isError: true for invalid arguments, the dispatcher omits path and searches /workspace.
Recommended fix: Restrict placeholder-null removal to tools using constrained sampling, or validate ordinary calls before removing nulls. Keep the intentional strict-mode behavior for read's optional fields and preserve valid null values such as callable exec.input.
Was this helpful? React with 👍 or 👎 to provide feedback.
Split each tool into a framework-neutral core under tools/common (schema, description, executor; zod only) and an adapter per agent library. tools/ai-sdk wraps the core with `tool()` from `ai`; tools/pi-ai and tools/tanstack-ai build their own shapes from the same core without importing their libraries, so each entry point pulls in only what it uses. createAITools stays exported from @cloudflare/computer/tools. The exec core keeps the current options: a `shell` with a backend map and a default backend. All three tool sets resolve options through the same resolveToolOptions, so they offer the same tools. The pi and TanStack adapters come from #149. Co-authored-by: aron <263346377+aron-cf@users.noreply.github.com>
Two one-shot agents on a Worker-shell Workspace: pi-ai, where `run` is the whole loop, and tanstack-ai, where chat() owns it. Both pass the one worker shell to `exec` through `shell`. `npm run local` drives each loop in Node with a scripted model, through a small shim for `cloudflare:workers`. Bumps the libraries to current releases (pi-ai 0.99, @tanstack/ai 0.63, @tanstack/ai-cloudflare 0.2) and adds both to the CI examples matrix. Docs, READMEs, and a changeset cover the two entry points. The examples come from #149. Co-authored-by: aron <263346377+aron-cf@users.noreply.github.com>
chat() passes a tool result to the adapter as multimodal content only when it is a ContentPart array. The read tool returned an image or PDF as a plain object, which TanStack JSON-stringified, so the model got the base64 as text. It now returns a text part plus an image or document part, and the test checks the shape with TanStack's own isContentPartArray. Also covers a failed pi publish, which already comes back as an error result.
Renames createPiAITools to createPiTools and createTanStackAITools to createTanStackTools, with their option and result types. The entry points stay tools/pi-ai and tools/tanstack-ai. pi checks tool arguments with TypeBox, which also compiles plain JSON Schema, so the pi tools need only zod for their own schemas. pi 0.99 also closes a constrainedSampling schema itself when the provider runs it strict. The adapter used to close read, write, and edit up front, which made every optional field required in the schema pi validates against. A provider that fell back to ordinary tool calling and left `offset` out of a read failed pi's own validateToolCall. The declarations now stay open, and execute drops a null only on an optional field that cannot take one. Tests check the declarations against pi's validateToolCall and makeStrictJsonSchema.
The tool sets landed on main with the exec options main had: a `shell` with a backend map and a default backend, and createAITools exported from @cloudflare/computer/tools. This branch has since moved exec to one `exec` option that offers every Workspace backend by default and takes its backend list from runtime.backends(), and moved createAITools to @cloudflare/computer/tools/ai-sdk. The resolution carries that exec core into tools/common/exec.ts and common/options.ts, so createAITools, createPiTools, and createTanStackTools all take `exec` (with `shell` kept as a deprecated alias). The tools/ai-sdk entry point, the examples, and the docs follow the same options.
e670853 to
67b065c
Compare
| const declaredRelative = declared.map((path) => stripMount(path, resolved.mountPoint)); | ||
| const actualRelative = resolved.paths.map((path) => stripMount(path, resolved.mountPoint)); | ||
| const difference = diffIgnore(declaredRelative, actualRelative); |
There was a problem hiding this comment.
🔴 Redundant ignored paths block connection
When ignore includes a parent and its child, assertIgnoreMatches rejects the healthy container. resolveMountIgnore drops the child as redundant, so its reported set differs from the declaration.
Learn more
The daemon resolves MOUNT_IGNORE to a minimal set. A declared parent covers every child, so resolveMountIgnore removes nested entries and reports only the parent. The host comparison does not minimize the declaration. It therefore treats an equivalent, correctly applied configuration as a mismatch and refuses every connection.
Example: With ignore: ['/node_modules', '/node_modules/.cache'], the daemon reports ['node_modules']. The host reports .cache missing, although every .cache file stays local.
Recommended fix: Normalize and collapse covered paths in the declaration before comparing it to the daemon's resolved set. Preserve the original declaration in error details.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const info: ComputerdInfo = { | ||
| backend, | ||
| mountPoint, | ||
| port, | ||
| store, | ||
| ignore: describeMountIgnore(ignoreConfig), |
There was a problem hiding this comment.
🟡 Local-only paths reported without a mount
With FUSE_MOUNT=none, describeMountIgnore reports active local-only paths although no passthrough mounts. A client can accept the report while those paths still enter the VFS.
Learn more
The daemon reports its configured ignore set in /__computerd/info. It creates the passthrough layer only inside the mount branch, which is skipped when the backend is none. The host accepts the reported set as proof that paths are kept out of sync, despite there being no local-only layer.
Example: Start computerd with FUSE_MOUNT=none MOUNT_IGNORE=/node_modules. The info endpoint reports enabled: true and paths: ['node_modules'], but no FUSE operations route /workspace/node_modules to local disk.
Recommended fix: Reject a nonempty MOUNT_IGNORE when the backend is none, or explicitly report it as inactive and ensure the host check refuses an inactive declaration.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const wrapped: FuseOps = { | ||
| ...ops, |
There was a problem hiding this comment.
🟡 Extended attributes fail on local-only files
For a file under MOUNT_IGNORE, getxattr and listxattr still reach the VFS. The VFS lacks local-only files, so applications receive ENOENT for existing files.
Learn more
The wrapper copies every VFS operation before overriding selected methods. The VFS xattr handlers check whether a path exists in the VFS. Local-only files are excluded from the VFS, so these handlers return ENOENT for paths the mount itself can read.
Example: With MOUNT_IGNORE=/node_modules, create /workspace/node_modules/pkg/index.js through the mount and request its extended attributes. Its FUSE getattr succeeds, but getxattr returns ENOENT.
Recommended fix: Override setxattr, getxattr, listxattr, and removexattr for ignored paths, using local filesystem behavior or matching the VFS's supported xattr semantics while checking existence on local disk.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const target = localPath(path); | ||
| // O_CREAT is not implied by open(2) here; the kernel sends | ||
| // create() for that. But a flag set including O_TRUNC still has | ||
| // to reach the real file, so the flags are passed through as-is. | ||
| const fd = fs.openSync(target, flags); | ||
| cb(0, allocateHandle(fd, path)); |
0e2e978 to
1a90515
Compare
Stacked on #172. Replaces #149: same pi and TanStack AI tool sets from @aron-cf, rebuilt on the
execAPI that #181 and #182 gavecreateAITools.Two new entry points, named after the library each one serves:
ai@cloudflare/computer/tools/ai-sdkcreateAITools(unchanged)@earendil-works/pi-ai@cloudflare/computer/tools/pi-aicreatePiTools@tanstack/ai@cloudflare/computer/tools/tanstack-aicreateTanStackToolsAll three take the same options,
execincluded, and build the same tools from the same core:Layout
flowchart LR subgraph common["tools/common (zod only)"] fs["fs/*: schema, description, executor"] exec["exec: defineExec() → description, inputSchema, execute"] opts["options: resolveToolOptions()"] end common --> aisdk["tools/ai-sdk<br/>tool() from ai"] common --> pi["tools/pi-ai<br/>JSON Schema + dispatcher"] common --> ts["tools/tanstack-ai<br/>Standard Schema tool list"] aisdk --> idx["tools (index)<br/>create*Tool, WorkspaceFileStore"]tool()fromai, so nothing could share them without loadingai. Each tool is now split:common/holds the schema, description, and executor, andai-sdk/tools.tswraps them.@cloudflare/computer/toolsexports exactly what it did before.defineExec()is the stack'screateExecToolminustool(): one backend means nobackendargument, several meanbackendis required, andinputappears only when a backend is callable. The pi and TanStack adapters get all of that for free.resolveToolOptions()holds theexec/ deprecatedshellhandling that lived increateAITools, so all three sets agree on which tools exist.Tree-shaking
pi and TanStack are declared as local structural types, not imports, so they are not peer dependencies at all. I bundled each entry with esbuild, keeping packages external:
tools/ai-sdkai,zodtools/pi-aizodtools/tanstack-aizod(
node:zlibalso shows up in all three, the same as on the base branch. It comes from a shared rolldown chunk, not the tools.)Changes from #149
tools/pi→tools/pi-ai,tools/tanstack→tools/tanstack-ai. The functions keep Add tools for Pi and Tanstack harnesses #149's names,createPiToolsandcreateTanStackTools.zod. The declarations now stay open: pi 0.99 closes aconstrainedSamplingschema itself when the provider runs it strict. Add tools for Pi and Tanstack harnesses #149 closedread,write, andeditup front, so a non-strict provider that left outoffsetfailed pi's ownvalidateToolCall. Tests now check the declarations againstvalidateToolCallandmakeStrictJsonSchema.tools/indexno longer re-exports the pi and TanStack sets, which would have tied them back toai.execinstead ofshell+defaultBackend. The tests cover several backends,execnarrowing to one,exec: {}, and callableinput.excludein the common grep.readreturns an image or PDF as[text part, image/document part]. TanStack only passesContentPart[]results to the adapter as multimodal content; Add tools for Pi and Tanstack harnesses #149's plain object got JSON-stringified, so the model saw base64 as text.examples/pi-aiandexamples/tanstack-ai, bump to pi-ai 0.99 /@tanstack/ai0.63 /@tanstack/ai-cloudflare0.2, and join the CI examples matrix.npm run localruns each agent loop in Node against a scripted model (a small shim coverscloudflare:workers).docs/09_tool_interface.mdhas pi and TanStack sections. READMEs and a changeset are updated.The lockfile only adds the examples' dependencies. No existing version changes.
Co-authored-by: aron 263346377+aron-cf@users.noreply.github.com