Skip to content

computer: updates to make workspace easier to extend - #214

Open
aron-cf wants to merge 23 commits into
mainfrom
computer-upstream
Open

aron-cf wants to merge 23 commits into
mainfrom
computer-upstream

Conversation

@aron-cf

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

Copy link
Copy Markdown
Collaborator

This PR adds functionality external harnesses need to either add themselves or work around in order to extend the defaults.

Isolate JavaScript modules (what code in the JavaScript backend can import)

  • ws:assets (new): publish a workspace file and get back a time-limited public URL.
  • ws:tools (new): call the agent's own tools, such as grep, from code, many times in one run.
  • ws:artifacts: can now mint git tokens (createToken) and credentialed clone URLs (share), so code can clone and push Artifacts repos.
  • ws:container:
    • a prelude option runs fixed shell setup before every command, such as set -o pipefail or a git identity;
    • a maxOutputLines option caps output by lines as well as bytes.
  • Module factories now receive the workspace's assets client (host.assets).
  • Module descriptions are read each time the backend is described, not once at startup. That's what lets ws:tools list tools that are created later.

The exec tool

  • input is now an object, not any JSON value. Models were sending it as a string of JSON, and this stops that. If one still arrives as a string, it's parsed back into an object.
  • Output limits can be set on the tool sets with execOutput, instead of only through the deprecated shell option.
  • defineExec is exported from a new @cloudflare/computer/tools/core entry point, so any agent library can build the exec tool without loading AI SDK or pi.

pi tools

  • createPiTools results now include each tool's raw output as details, such as exec's exit code and output, so callers don't have to parse the text sent to the model.

File tools

  • root option: keeps every file tool inside one directory. Relative paths resolve against it, and paths outside it or through symbolic links are refused.
  • Globs: grep's include and exclude, and find's exclude, accept one glob or a list.
  • Edit diffs are capped: skipped for very large replacements and cut to 128 KiB, with a diffTruncated flag. Large rewrites used to cost seconds of CPU and flood the transcript.

Glob matching (in dofs, which also changes what the find and grep tools match)

  • Braces now work: **/*.{ts,tsx} matches both. Before, braces were treated as literal text, so such patterns silently matched nothing.
  • node_modules/** now also skips the node_modules directory itself, so you no longer have to list both forms.

Devin Review

aron-cf added 23 commits October 8, 2026 16:22
A host module factory received the Workspace's Git client, Artifacts
client and runtime, but not its assets client, so a module that
publishes files had to close over a client it was given separately.
The Workspace now passes its assets client to a module backend when it
connects, and the JavaScript backend hands it to each factory. It is
absent when the Workspace has no assets configured.
Isolate code could reach Git, Artifacts and the container through host
modules, but had no way to publish a file it produced. ws:assets
exports publish(path, options), which uploads a workspace file through
the Workspace's assets client and returns a time-limited URL.

The expiry is milliseconds or a duration such as "30m", with a one hour
default that the module's options can change. The filename,
disposition and content type pass through to the client. The path
resolves against the backend root like any other host call, and the
module fails when the backend connects if the Workspace has no assets
client.
ws:artifacts could create, list and delete repositories but not mint
the git token that cloning or pushing one needs, so isolate code had
to fall back to the shell's artifacts command.

createToken(name, scope?, ttl?) mints a token and returns its id,
plaintext, scope and expiry. share(name, { scope?, ttl? }) returns the
repository's remote with a token embedded, the same URL that
`artifacts share` prints, and resolves the repository before minting
so a missing one fails without leaving a token behind. Both default to
a read token, take the ttl in seconds or as a duration, and refuse a
write token on a read-only backend.
A deployment often wants every container command to start from the
same shell state: pipefail, so a failing stage of a pipeline is not
hidden by the one after it, or exported variables such as a git
identity. ws:container passed the command through as written, so the
only way to get that was to wrap the module and rewrite each command.

createContainerModule({ prelude }) now runs the prelude before each
command, joined by a newline. A newline keeps a command that opens
with a comment or a shebang intact, which a semicolon would not, and
does not skip the command when the prelude's last status is non-zero,
which && would. The command is still checked for emptiness before the
prelude is added.
ws:container capped each stream by bytes, and the runtime then kept
up to its own 2000-line default. A deployment that shows the model a
few hundred lines elsewhere could not ask for the same here.
createContainerModule({ maxOutputLines }) now passes a line limit to
the runtime alongside the byte limit. Leaving it unset keeps the
runtime's default.
Reshaping JSON is one of the most common things agent code does, and
isolate code had no jq without starting the container. ws:jq exports
query(input, filter, options?), which runs a jq program on just-bash's
implementation in the Durable Object and returns its output text.

The input is JSON text or any value, which is encoded as JSON first.
The raw, compact, slurp, sort and nullInput options map to jq's flags.
The shell it runs in has only jq registered, so the filter cannot reach
another command even if quoting went wrong, and just-bash is loaded on
the first query, so a backend that never calls jq does not pay for it.
WorkerJavaScriptBackend built its description once, in its
constructor, from each module's description at that moment. A module
whose description depends on something that exists only later, such
as a module listing the agent's tools, which are built after the
Workspace the backend belongs to, could only describe itself in
general terms.

The backend now reads each host module factory's description whenever
its own description is read, so a factory can define description as a
getter and the exec tool, built afterwards, sees the current text.
The exec tool cuts each stream to a number of lines and bytes, and
passes the same limits to the runtime so that whatever the model does
not see is saved to a file. createAITools, createPiTools and
createTanStackTools could set those limits only through the deprecated
`shell` option, because `exec` names backends and nothing else, so a
caller had to choose between the current option and its own limits.

`execOutput: { maxBytes?, maxLines? }` now sets them alongside
`exec`, and wins over limits given through `shell`.
createPiTools turned a tool's structured output into JSON text for the
model and dropped the value itself. pi carries a tool result's
`details` beside its content for the caller and its interface, so a
caller that wanted the exit code or the stdout of an exec call had to
parse the model's text back.

The dispatcher now returns the tool's output as `details`. A read that
returns an image leaves it off, since the output holds the encoded
bytes already sent as content, and a call that fails validation or
throws has none.
Each tool set builds the exec tool from defineExec: a description, an
input schema, and an executor that yields snapshots. It was not
exported, so a caller registering tools with a library the package
has no adapter for had to go through createPiTools or the AI SDK's
createExecTool, and route calls through that library's shape.

@cloudflare/computer/tools/core exports defineExec and its types. It
depends on zod and nothing else, so importing it does not pull in an
agent library.
exec declared `input` as any JSON value. Offered a field that may be a
string, Anthropic models sent the object they meant as a string of
JSON, which the module then received as text. Checked live, Claude did
this on every call, whatever the field's description said. With the
field declared as an object and described as an "input object", Claude,
GPT and the Workers AI default each sent an object on every call.

`input` is now an object of JSON values, and the descriptions call it
an input object. A top-level array, string or number is rejected, and
a null under strict sampling still means the field was left out.
Declaring `input` as an object is what stops models sending it as a
string of JSON, but nothing makes every model follow the schema, and a
string now fails validation and costs the model a turn.

The exec definition gains prepareArguments, which replaces a string
`input` that parses to a JSON object with that object, and leaves
anything else as sent, so it fails validation with the schema's own
message. It never mutates its argument. createPiTools runs it before
validating an exec call. It does nothing when no backend is callable,
since `input` is not offered then.
An exclusion such as "node_modules/**" matched everything below
node_modules but not node_modules itself, so find returned the
directory and the walker still listed its children before dropping
each one. The tools describe exactly this pattern as skipping a
directory along with everything below it, and callers had to pass both
"node_modules" and "node_modules/**" to get that.

An exclusion ending in /** now also excludes the directory it names,
so the walk prunes it without listing it. A file of that name is kept,
since the pattern only ever named what is inside a directory. The same
walk serves recursive grep.
The glob matcher behind find, recursive grep and their exclusions
supported *, ** and ?, and treated a brace as literal text. A pattern
such as "**/*.{ts,tsx}", which models and people write by habit,
matched nothing, and the result was indistinguishable from no files of
that kind existing.

A brace group with at least one top-level comma now matches any of its
alternatives, and groups nest. A brace with no comma, or with no
closing brace, is still literal, as it is in a shell.
The grep tool took `include` as one glob and `exclude` as a list, and
the find tool took `exclude` as a list, so a model had to remember
which field was which shape and lost a turn to validation when it
guessed wrong.

Each of these fields now accepts a string or a list of strings.
Several include globs are joined into one brace group, which the glob
matcher reads as alternatives, and blank globs are dropped.
The edit tool diffed the whole file after every edit and returned the
diff and patch whole. Diffing costs roughly the square of the number of
differing lines: one changed line in a large file is cheap, but
rewriting a few thousand lines takes seconds of CPU, inside a Durable
Object holding the session, for a diff nobody reads. The output had no
bound either, so a run of large edits could fill the transcript.

The edit still applies in full. When the replaced text, counted in
lines on either side of each edit, exceeds maxDiffLines (2000 by
default), the diff is skipped. Otherwise the diff and patch are each
cut to maxDiffBytes (128 KiB by default) on a character boundary.
Either way the result sets diffTruncated.
The file tools passed whatever path the model sent straight to the
Workspace, so a tool set meant for /workspace could read, write or
delete anywhere in the filesystem, including through a symbolic link
under /workspace that points elsewhere. Callers fenced paths by
wrapping every tool.

`root` on the tool set options now confines read, write, edit,
delete, ls, find, grep and publish to one directory. A relative path
resolves against the root, `.` and `..` are resolved before the
check, and a path that leaves the root is refused. When the filesystem
has lstat, a path with a symbolic link anywhere below the root is
refused too. Each refusal comes back as the tool's ordinary error
result, and nothing is touched.
Isolate code often wants to do what the agent's own tools do, such as
a search or a fetch, many times in one run. Without a module for it,
each call cost a model turn, or the code reimplemented the tool.

createToolsModule(tools, { exclude }) exports each tool under its own
name. Each export takes one object of the tool's arguments and returns
what the tool returns. The list is read through a function, so tools
built after the Workspace can be offered, and the module describes
itself with the current tool names when the backend is described.
Excluded tools, such as exec itself, and names that cannot be a module
export are left out.
ws:jq ran jq on just-bash in the Durable Object. just-bash belongs to
the worker shell backend, and a module that loads it puts it in the
bundle of a deployment that never uses that backend. Isolate code is
JavaScript and can reshape JSON itself, and the container has real jq
when a program needs it, so the module is removed rather than given
another jq implementation.
The isolate JavaScript guide gains sections for ws:assets and
ws:tools, ws:artifacts' createToken and share, ws:container's prelude
and maxOutputLines, the assets client on a module factory's host, and
the lazily read module description. Both entry point tables list the
new modules and @cloudflare/computer/tools/core.
The filesystem interface now describes brace alternatives in find and
grep globs, and that an exclusion ending in /** also prunes the
directory it names. The examples drop the paired "node_modules" and
"node_modules/**" exclusions that are no longer needed, and the
comment above the glob compiler lists brace groups.
The tool interface now covers the root option and how it refuses
paths, execOutput, the details pi results carry, exec's input object
and the repair of one sent as text, defineExec on
@cloudflare/computer/tools/core, one or several globs for grep and
find, brace globs, and the edit tool's diff bounds.
One changeset per change, each a line with a link to its
documentation. Declaring exec's input as an object and pruning the
directory a trailing /** exclusion names are minor, since each changes
an existing behavior; the rest are additions and are patches.
@changeset-bot

changeset-bot Bot commented Oct 8, 2026

Copy link
Copy Markdown

馃 Changeset detected

Latest commit: 02345f1

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 6 potential issues.

Devin Review

export function globOne(value: string | readonly string[] | undefined): string | undefined {
const list = globList(value);
if (list === undefined) return undefined;
return list.length === 1 ? list[0] : `{${list.join(",")}}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃煛 Comma-bearing include globs lose matches

When grep receives multiple includes with a literal comma, globOne splits that filename into separate brace alternatives. Matching files disappear from search results.

Learn more

The grep tool converts multiple include globs to one pattern for the filesystem search. The brace parser treats every top-level comma inside that pattern as an alternative separator, including commas originally inside a filename. The resulting pattern no longer matches the filename, and grep silently omits its matches.

Example: With include: ["src/a,b.ts", "src/c.ts"], the generated pattern is {src/a,b.ts,src/c.ts}. The src/a,b.ts file does not match any of those alternatives.

Recommended fix: Preserve each input pattern as an independent alternative without interpreting its literal commas as separators. This may require adding a list-of-includes path to the find/grep walker or implementing an escaping convention in the glob compiler and encoder together.

Devin Review


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

// saves the rest to a file, so a noisy command cannot exhaust
// the Durable Object.
output: { maxBytes: maxOutputBytes },
output,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃煛 Container line cap fails without spooling

With runtime output saving disabled, maxOutputLines does not limit container results. result() returns full streams, and the fallback truncates bytes only.

Learn more

The Workspace runtime can disable its output spools, in which case drainModuleResult collects every output chunk without applying output limits. ws:container already truncates unbounded output on the fallback path, but that fallback only enforces maxOutputBytes. Adding maxOutputLines to the runtime request therefore does not enforce the new option in this configuration.

Example: With output saving disabled, maxOutputLines: 2, and a command printing 100 short lines, ws:container returns all 100 lines if they fit under 64 KiB.

Recommended fix: Apply both byte and line limits on the fallback result when the runtime did not truncate a stream, using the same tail semantics as the runtime's output window.

Devin Review


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

Comment on lines +47 to +48
context.signal.throwIfAborted();
return tool.execute(input ?? {}, { signal: context.signal });

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 isolate can invoke write tools

On a read-only backend, createToolsModule forwards calls to mutating agent tools without checking context.access. Isolate code can alter workspace files despite its read-only setting.

Devin Review


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


export function normalizeRootedPath(root: string, input: string): string {
const base = normalizeAbsolute(root);
const absolute = normalizeAbsolute(input.startsWith("/") ? input : `${base}/${input}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃煡 Root restriction bypassed through symlink and dot segments

For paths containing link/.., normalizeRootedPath removes the link before the symlink check. The underlying filesystem can follow that link, allowing file tools to access paths outside the configured root.

Devin Review


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

const resolve = async (input: unknown): Promise<string> => {
if (typeof input !== "string") throw new TypeError("path must be a string");
const path = normalizeRootedPath(base, input);
if (typeof fs.lstat !== "function") return path;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃煥 Missing lstat silently disables link confinement

If a structural workspace.fs lacks lstat, resolve accepts links without checking them. Rooted file tools can follow a link outside the root.

Devin Review


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

Comment on lines +41 to +44
const share = parsePublishOptions(publishOptions, defaultExpiresAfter);
const resolved = await context.resolvePath(path);
context.signal.throwIfAborted();
return assets.share(resolved, share);

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 isolate can publish workspace files

On read-only backends, publish still calls assets.share without checking context.access. Isolate code can upload files and issue public links despite read-only access.

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@214

commit: 02345f1

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