Skip to content

computer: Carry backend information through Workspace clients - #182

Merged
mattzcarey merged 3 commits into
feat/ws-container-modulefrom
fix/exec-tool-review
Oct 1, 2026
Merged

mattzcarey merged 3 commits into
feat/ws-container-modulefrom
fix/exec-tool-review

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Stacked on #172, after #181 merged into it.

This fixes the issues review found in #181.

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 left out, an agent got no exec tool, and a callable backend such as celld's lost its input argument and its module list:

A Durable Object that builds its own Workspace passes it straight to createAITools and was never affected. The bug hit the two cases that go through getWorkspace():

// 1. Inside a Durable Object using the withWorkspace mixin, which keeps
//    the Workspace private, as in examples/celld:
const workspace = await getWorkspace(this);
createAITools({ workspace }); // no exec tool, despite backends

// 2. From another Worker or Durable Object, over RPC, as in examples/egress:
const workspace = await getWorkspace(env.MyDO.get(id));
createAITools({ workspace }); // same

// Unaffected: a Durable Object that owns its Workspace, as in examples/think.
createAITools({ workspace: this.workspace });

The tool needs one list of facts per backend, so this PR also collapses those three methods into one:

runtime.backends(): { id: string; callable: boolean; description?: string }[]

The Workspace runtime, its RPC stub, the client, and the tool all speak that one method. backendIds and describe are gone. isCallable stays on the runtime only for its own check before running.

Backends are fixed when the Workspace is constructed, so a client takes one backends() snapshot when getWorkspace() creates it:

sequenceDiagram
  participant Agent
  participant Client as WorkspaceClient
  participant Stub as WorkspaceRuntimeStub (RPC)
  Agent->>Client: getWorkspace(handle)
  Client->>Stub: backends()
  Stub-->>Client: [{ id, callable, description }]
  Agent->>Client: createAITools({ workspace })
  Client-->>Agent: backends(), answered from the snapshot
Loading

The snapshot keeps createAITools synchronous over RPC, where the call would otherwise be a round trip.

Three smaller fixes:

  • CloudflareContainerBackend claimed network access even with the default egress: { mode: "none" }. Its description now follows the egress mode: direct, through a gateway, or none.
  • exec now takes precedence over the deprecated shell option, so exec: {} always means no exec tool.
  • The mcp README described backend as optional, and the think prompt named a default backend. Both now say to name a backend on every call.

client.test.ts builds tools from both a local and a remote client against a real WorkerJavaScriptBackend, checks the backend answers and the module list, and sends structured input through each client's exec tool to a callable backend that echoes it. The snapshot is frozen, so editing the list a client returns cannot change later answers. The other fixes each have a focused test.

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.
@mattzcarey mattzcarey added the allow-pr Allow a PR to remain open. label Oct 1, 2026
@changeset-bot

changeset-bot Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

馃 Changeset detected

Latest commit: 6511ce7

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[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 3de7511

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.
devin-ai-integration[bot]

This comment was marked as resolved.

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.
@mattzcarey
mattzcarey merged commit 1adc1e6 into feat/ws-container-module Oct 1, 2026
13 of 14 checks passed
@mattzcarey
mattzcarey deleted the fix/exec-tool-review branch October 1, 2026 11:02
mattzcarey added a commit that referenced this pull request Oct 1, 2026
* 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.
mattzcarey added a commit that referenced this pull request Oct 1, 2026
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.

The exec core is the one #181 and #182 settled on: `exec` takes a
backend map, every Workspace backend is offered by default, `backend`
appears only when there is a choice, and `input` only when a backend
is callable. 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>
mattzcarey added a commit that referenced this pull request Oct 6, 2026
* 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.
mattzcarey added a commit that referenced this pull request Oct 6, 2026
* 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.
aron-cf pushed a commit that referenced this pull request Oct 7, 2026
* computer: Add ws:container for isolate JavaScript

An agent that wants both isolated JavaScript and a full Linux
container has so far needed two exec backends, and the model had to
pick one per command. This lets JavaScript be the only backend the
model sees, with the container as a library it can call.

createContainerModule() in @cloudflare/computer/modules/container is a
host module factory. Installed as ws:container, its exec(command,
options) runs through workspace.runtime.exec on the container backend,
so the container shares the Workspace's files through the usual sync
bracket. It returns the exit code and bounded output once the command
finishes, kills the command when the execution is cancelled, caps the
command's timeout at the host call deadline, and refuses to run on a
read-only backend, since a container command can write to the
Workspace whatever the isolate's access is. Its description tells the
model how to call it, and reaches the exec tool through the
JavaScript backend's own description.

* computer: Pick exec backends with one exec option (#181)

* computer: Carry backend information through Workspace clients (#182)

* computer: Carry backend information through Workspace clients

A WorkspaceClient from getWorkspace() wrapped the runtime with exec,
getExec, killExec, and disposeExec only. The exec tool also asks the
runtime for backendIds, isCallable, and describe, so a client lost all
three: with exec omitted it offered no exec tool, and a callable
backend lost its input argument and its module list. Over RPC those
calls would be asynchronous, while the tool builds its schema
synchronously.

Backends are fixed when the Workspace is constructed, so the runtime
and its RPC stub now expose one backends() call, and a client takes
that snapshot when it is created and answers the three questions from
it, locally and remotely alike.

CloudflareContainerBackend now describes network access from its
egress setting instead of always claiming it, exec takes precedence
over the deprecated shell option so exec: {} always means no tool, and
the mcp README and think prompt stop implying a default backend.

* computer: Answer backend questions with one backends() call

The exec tool learned about backends through three runtime methods,
backendIds, isCallable, and describe, and every way of reaching a
Workspace had to forward all three. It now reads one list from
runtime.backends(), each entry carrying the id, whether the backend is
callable, and its description. backendIds and describe are gone, and
a Workspace client exposes the same backends() from its snapshot.
isCallable stays on the runtime for its own check before running.

* computer: Freeze the client backend snapshot and test input through it

A client returned its backend snapshot array itself, so a caller that
edited it changed what later tool sets saw. The snapshot is now frozen
once when the client is created.

The client tests only checked that the exec schema offered input. They
now send structured input through the exec tool on a local and a
remote client to a callable backend that echoes it, and check the
value comes back as the result. The mcp README no longer calls
worker-shell the default.

* computer: Describe the new ContainerBackend instead of the legacy one

The exec tool's self-description for the container landed on the
platform-scheduled backend, which is now LegacyContainerBackend. It
moves to ContainerBackend, the backend containers should use, with its
network line still following the egress mode. The legacy backend goes
back to describing nothing and gets the exec tool's one-line default.

* computer: Check ws:container's backend when it connects

ws:container found a missing or wrong container backend only on its
first exec. Its factory runs when the JavaScript backend connects and
can read the Workspace's backends, so it now fails there: with no such
backend, or with one that runs modules instead of shell commands.

Its description also stopped promising network access. The model only
sees the module's text in this setup, and whether the container can
reach the network depends on the container backend's egress setting,
which the module cannot know when it is built.

* docs: Use ContainerBackend for ws:container

The ws:container docs, the exec tool docs, and the changesets named
the old CloudflareContainerBackend. They now show ContainerBackend,
the durable-object-scheduled backend containers should use.

* examples/think, examples/mcp: Move to ContainerBackend

Both examples ran their container through LegacyContainerBackend, the
platform-scheduled backend, which no longer describes itself to the
model. They now use ContainerBackend: the durable object schedules the
container, the containers block names an image under images.app with
scheduling_policy "durable_object", and the backend asks for
standard-2 at launch, the size the old block requested.

* computer: Check only that ws:container's backend exists

ws:container also rejected a backend marked callable, taking that as a
sign it runs modules. callable means a backend takes structured input,
and a shell backend may do both, so the check refused valid backends
and let through a module backend that was not callable. It now checks
only that the backend exists.

* computer: Tell ws:container's backend kind by protocol

ws:container needs a backend that runs shell commands. It first judged
that by callable, which describes structured input instead: a shell
backend may be callable, and a module backend need not be. Without any
check, pointing it at the JavaScript backend would run a shell command
as JavaScript, or start a nested run.

runtime.backends() now reports each backend's protocol, "command" or
"module", and ws:container refuses a module backend when it connects.
A callable shell backend is accepted.

* computer: Run a single-backend exec tool on its backend for direct calls

Import createAITools from its new home in the client test, and cover
a direct execute call that names another backend.

* computer: Format the exec tool after the rebase onto main

* computer: Let the runtime cut ws:container output and say where the rest is

ws:container now asks the runtime for at most maxOutputBytes per stream,
so a noisy command keeps only its end in memory and its full output in
a Workspace file. The result passes on `truncated` with that file's
path. A Workspace with output saving off still gets the old cut.

* computer: Say where ws:container's saved output can be read

The default output directory is outside the JavaScript backend's root,
so point isolate code at the agent's read and grep tools, and document
setting output.dir under root when code needs the file itself.

* computer: Pass isolate capability calls as native RPC values

The Dynamic Worker already received a real RPC object, the runtime
bridge, but every node:fs and host module call was JSON-encoded on top
of it: arguments became a string with a custom codec for bytes,
arrays, and objects, and results came back the same way. Bytes
travelled as arrays of numbers, roughly four times their size against
the capability byte limit, and both sides carried an encoder and a
decoder.

Arguments and results now cross as Workers RPC values. The isolate
passes its argument list straight to host.call, and the bridge answers
with { result } or a bounded { error } carrying code and path for
node:fs. The bridge stays the single proxy for every call, so its
controls are unchanged: cancellation, concurrent and total call
counts, per-call deadlines with abort, and draining accepted calls
before an execution ends.

Byte budgets now measure the values themselves: UTF-8 bytes of strings
and keys, raw bytes of byte arrays, and a fixed cost per scalar. The
same walk rejects anything that is not plain data, including functions
and RPC stubs that Workers RPC would otherwise carry into the host as
live callbacks, and cycles.

* computer: Keep host bridge responses within their limits

- Error responses now count against the cumulative response budget.
- An error's path and code are kept only if the whole error still fits
  the payload limit; the message is cut to what is left.
- Every value costs at least one byte, so many empty strings or objects
  can no longer reach the host past the request limit.
- Host module arguments and results follow JSON again: undefined object
  fields are left out and undefined array items become null, so a host
  value with an optional field no longer fails the execution.

* computer: Keep fitting error paths and own __proto__ fields in host values

* computer: Leave concurrent JavaScript executions to the platform

WorkerJavaScriptBackend admitted up to 24 executions at once by
default. The platform allows 10 concurrent Dynamic Workers per
request, so runs 11 through 24 were admitted and then failed with the
platform's error anyway, while the backend's own ceiling only added an
option and a counter to keep in step.

The cap and maxConcurrentExecutions are gone. Executions start until
the platform refuses one, and that refusal is the execution's error.
The rlm examples drop the option. Starts already in progress still
hold close() until they finish, and reusing a running execution id
still fails with EEXEC_BUSY.

* computer: Mention -C in ws:git's description

ws:git accepts a leading -C <path> in cli, but its description for the
model did not say so, so a model reading it had no reason to use it.

* computer: Leave publish out of tools built from a remote client without assets

A remote client's assets getter returned the stub's assets property,
which over RPC is always a placeholder, never undefined. createAITools
therefore offered publish on a remote client even when the Workspace
had no assets publisher, and calling it failed because the receiver
did not implement assets.

The Workspace stub now exposes a plain hasAssets boolean, which the
client reads once when it is created, as it already does for useThink.
A client without assets reports undefined, locally and remotely.

* computer: Drop undefined result fields and reject cyclic arguments cleanly

A run that returned { kept: 1, dropped: undefined } failed with "must
be JSON-compatible values", although the result is framed as JSON,
which drops undefined fields. Values like that are common in ordinary
code, so the check now treats an undefined field as absent.

The isolate's early size check walked arguments without tracking what
it had seen, so a cyclic argument overflowed the stack. It now stops
at a repeated object and reports that values must be acyclic, the same
wording the host uses.

* computer: Report ws:container's file sync to the caller

ws:container returned the exit code and output but dropped the sync
result, so code that called exec could not tell whether the
container's file changes had reached the Workspace, or which ones the
Workspace had refused.

exec now also returns sync: its status, the skipped paths, and the
error when the pull is still pending. The module's description and
the docs also say the sync is last-writer-wins, because a file the
isolate writes while the command runs is replaced by the container's
version without being reported as skipped.

* computer: Cap ws:container's skipped-path list

A command that wrote thousands of files into a read-only mount made
the sync summary larger than the bridge's response limits allow. exec
then failed after the command had already run and its changes had
been pulled, losing the exit code and inviting a retry that repeats
the side effects.

The summary now lists at most 100 skipped paths, adds skippedCount
with the full count, and cuts a pending sync's error to 1 KiB.

* computer: Expect skippedCount in the ws:container truncation test
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

allow-pr Allow a PR to remain open.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant