Repository navigation
Conversation
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 detectedLatest commit: 02345f1 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 |
| 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(",")}}`; |
There was a problem hiding this comment.
馃煛 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.
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, |
There was a problem hiding this comment.
馃煛 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.
Was this helpful? React with 馃憤 or 馃憥 to provide feedback.
| context.signal.throwIfAborted(); | ||
| return tool.execute(input ?? {}, { signal: context.signal }); |
There was a problem hiding this comment.
|
|
||
| export function normalizeRootedPath(root: string, input: string): string { | ||
| const base = normalizeAbsolute(root); | ||
| const absolute = normalizeAbsolute(input.startsWith("/") ? input : `${base}/${input}`); |
There was a problem hiding this comment.
馃煡 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.
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; |
| const share = parsePublishOptions(publishOptions, defaultExpiresAfter); | ||
| const resolved = await context.resolvePath(path); | ||
| context.signal.throwIfAborted(); | ||
| return assets.share(resolved, share); |
There was a problem hiding this comment.
commit: |
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 asgrep, 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:preludeoption runs fixed shell setup before every command, such asset -o pipefailor a git identity;maxOutputLinesoption caps output by lines as well as bytes.host.assets).ws:toolslist tools that are created later.The
exectoolinputis 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.execOutput, instead of only through the deprecatedshelloption.defineExecis exported from a new@cloudflare/computer/tools/coreentry point, so any agent library can build the exec tool without loading AI SDK or pi.pi tools
createPiToolsresults now include each tool's raw output asdetails, such as exec's exit code and output, so callers don't have to parse the text sent to the model.File tools
rootoption: keeps every file tool inside one directory. Relative paths resolve against it, and paths outside it or through symbolic links are refused.includeandexclude, and find'sexclude, accept one glob or a list.diffTruncatedflag. 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)**/*.{ts,tsx}matches both. Before, braces were treated as literal text, so such patterns silently matched nothing.node_modules/**now also skips thenode_modulesdirectory itself, so you no longer have to list both forms.