Skip to content

computer: Polish the isolate JavaScript backend for ws:container agents - #213

Merged
aron-cf merged 7 commits into
mainfrom
computer/container-js-polish
Oct 8, 2026
Merged

aron-cf merged 7 commits into
mainfrom
computer/container-js-polish

Conversation

@scuffi

@scuffi scuffi commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Summary

I built an agent whose only execution backend is isolate JavaScript, reaching Linux through ws:container. Along the way I hit a handful of rough edges: each one either cost the model wasted tool calls or forced the host into a workaround. This PR fixes seven of them, one commit each, so they're easy to review (or drop) on their own.

Changes

1. Module-scope failures no longer pass silently

Why: The Workers runtime refuses I/O while a module is loading. A floating (async () => { await fs.writeFile(...) })() "completed" with exit code 0, no output, and no file. Top-level await failed with a message about request handlers, which isolate code doesn't have.

Change: Every node:fs and ws:* call goes through the same bridge, which now spots the runtime's refusal, records it, and throws a useful error. A run with a recorded refusal fails, even if the code caught the error. Runs also fail if a promise the default export left behind is still rejected after it settles.

node:fs writeFile can't run at module scope, where the Workers runtime refuses I/O. Call it from inside the default-exported function.

2. The model is told how to return a value

Why: The exec tool never said a module needs a default-exported function. Models tried a top-level return, then top-level await, then the floating function above. One agent run fixing a small Node.js project took 12 tool calls instead of 5.

Change: The JavaScript backend's description now spells out export default async function (input) { ... }, says to call its modules from inside it, and shows export { default } from "./main.js" for running a file that's already written. It lives on the backend rather than in the exec tool's hint, which every callable backend shares.

3. /workspace exists on first use

Why: A fresh Workspace had no /workspace, the backend's default root, so the first file write failed. Every host, including this package's own tests, ran mkdir before each run.

Change: A read-write backend creates its root when it's missing. It checks first, since mkdir records a change even on an existing directory, and doing that every run would keep sync busy. Read-only backends leave the Workspace alone.

4. Paths show up once in error messages

Why: Errors read no such path: /workspace: /workspace. About 80 callers put the path in the message, and createWorkspaceError appended it again.

Change: The path is only appended when the message doesn't already end with it. Fixing it in one place means new callers can't bring the duplicate back.

5. ctx.storage works without a cast

Why: Every example needed ctx.storage as unknown as DurableObjectStorageLike. dofs declared SQLStorageLike.exec as generic over any row type, and TypeScript wouldn't accept the real storage.

Change: exec isn't generic anymore; Database casts rows where it reads them. A type-only check in dofs fails the typecheck if the two drift apart again. The cast is gone from the examples, tutorial, test workers and benchmarks.

6. Node.js built-ins can be imported

Why: Only node:fs could be imported. node:path, usually the first thing a model reaches for, failed as "not configured", even though the Dynamic Worker runs with nodejs_compat.

Change: These are now allowed, with their subpaths:

node:path, node:url, node:util, node:events, node:buffer, node:assert, node:string_decoder, node:querystring, node:stream, node:crypto, node:zlib, node:timers, node:async_hooks, node:diagnostics_channel

Bare names like path work too, unless a configured module has that name. All of this needs nodejs_compat, which is on by default.

The rest stay off on purpose:

  • node:fs is already the Workspace-backed version.
  • node:process would bypass the backend's process shim.
  • node:net, node:http and friends reach the network, and I haven't checked them against the egress policy.
  • Others, like node:child_process, are stubs that throw.

7. A module needs a default export

Why: A module without a default export ran and returned null. A model that wrote export function main() got nothing back, and no error saying why.

Change: It's now rejected before anything runs, with an error that names its other exports and shows the two shapes that work.

The module exports `main` but no default, so there is nothing to run. Put the work in `export default async function (input) { ... }`, or re-export one with `export { default } from "./main.js"`.

Things to look at

  • New failures: runs that caught a refused call, left a rejected promise behind, or had no default export used to succeed and now fail. That's intentional, but it's a behavior change.
  • Refusal detection: it matches the runtime's error message. If workerd rewords it, we fall back to today's behavior.
  • Type change: SQLStorageLike.exec no longer accepts a row type argument, so exec<Row>() calls through the interface stop compiling. The changeset says patch; happy to make it minor.
  • Still silent: a plain Promise.reject() at module scope, since the runtime never reports it anywhere we can see.

Trying it

import fs from "node:fs/promises";
import { join } from "node:path";

(async () => { await fs.writeFile("/workspace/a.txt", "x"); })(); // now fails the run, naming the call

export default async () => join("/workspace", "b.txt");

Testing

Every fix has a test that failed before it. The module-scope cases, the fresh-Workspace root, and the Node.js imports run in a real Dynamic Worker; the Node.js imports run on both module registries. Unit tests cover the module graph rewrites, the default-export check, the model-facing descriptions, read-only roots, and error messages. docs/17_isolate_javascript.md is updated to match.

@changeset-bot

changeset-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

馃 Changeset detected

Latest commit: 50e8b10

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 Patch
@cloudflare/dofs Patch
@cloudflare/computer-rpc Patch
@cloudflare/computerd Patch

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

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

commit: 50e8b10

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@scuffi can you take a human pass at these changelog entries, they're structured right, but can be super short.

Comment thread .changeset/exec-tool-module-shape.md Outdated
"@cloudflare/computer": patch
---

The `exec` tool now tells the model to put a callable backend's work in `export default async function (input)`, and the isolate JavaScript backend's description says its modules can't be called at the module's top level; see [`exec` tool documentation](https://github.com/cloudflare/computer/blob/main/docs/09_tool_interface.md#exec).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We parse the code to get the dependency, I was wondering if we can do some simple validation on this stuff. To avoid the tool even executing.

  1. Error if no top level export.
  2. Error if forbidden node package imported.

Also the export { default } from "./main.ts" pattern should be included too as it's super clean token wise.

Comment thread docs/17_isolate_javascript.md Outdated
The source is a real ES module. Static imports, literal dynamic imports, and top-level await are supported. If the module default-exports a function, Workspace invokes it with `options.input`. Otherwise module evaluation completes with a `null` structured result.
The source is a real ES module, with static imports and literal dynamic imports. If the module default-exports a function, Workspace invokes it with `options.input`. Otherwise module evaluation completes with a `null` structured result.

Do the module's work in that function. The Workers runtime evaluates modules outside any request and refuses I/O there, so a `node:fs` or host module call at module scope fails with an error that names the call. Top-level `await` works only for work that does no I/O. A refused call fails the run even if the code catches the error or never awaits the call, since the work it asked for never happened. So does any other rejected promise that nothing has handled once the default export settles.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This needs to be clearer.

| Kind | Configured with | Runs in | Example |
| --- | --- | --- | --- |
| Built in | Always installed | The isolate, backed by the Workspace | `node:fs`, `node:fs/promises` |
| Node.js | `nodejs_compat` in `compatibilityFlags`, the default | The isolate, provided by the runtime | `node:path`, `node:crypto` |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When we gonna add the node:sqlite shim to allow agents to cross communicate via DO storage?!

@scuffi
scuffi force-pushed the computer/container-js-polish branch from fe9c25d to 2d55455 Compare October 8, 2026 13:24
@aron-cf

aron-cf commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator

Couple more available modules timers/promises and async_hooks

return `Each of stdout and stderr is cut to its last ${limits.maxLines} lines or ${formatSize(limits.maxBytes)}, whichever is hit first; when it is, the full output is saved to a file the reply names, which the read and grep tools can open.`;
}
const SHELL_HINT = "Use for builds, test runs, typechecks, formatters, and git plumbing.";
const CALLABLE_HINT =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is used for all backends. Instead we want this on the JavaScriptWorkerBackend.description which will be included.

scuffi added 7 commits October 8, 2026 15:17
A module that starts async work without awaiting it reported success
even when that work failed. The common case is a floating async
function at module scope, such as `(async () => { await
fs.writeFile(...) })()`. The Workers runtime evaluates modules outside
any request and refuses I/O there, so the write never happened, yet the
run completed with exit code 0 and no output. Agents reach for this
pattern right after top-level `await` fails, and it turns a loud error
into a silently wrong answer.

The runtime doesn't dispatch `unhandledrejection` for promises rejected
during module evaluation, so the runner cannot catch this case
generically. Instead the capability bridge recognizes the runtime's
refusal, records it, and throws an error that names the call and says
to move it into the default-exported function. The runtime's own
message talks about request handlers, which isolate code doesn't have.
A run with a recorded refusal fails even if the module caught the
error, because the work it asked for never happened.

The runner also listens for `unhandledrejection` and
`rejectionhandled`, and fails the run if a rejection is still unhandled
one timer turn after the default export settles. That covers promises
the default export left behind.

The isolate JavaScript documentation claimed top-level `await` was
supported without qualification. It now says that it works only for
work that does no I/O, and that the module's work belongs in its
default-exported function.
The `exec` tool told the model that a callable backend's return value
comes back in the `result` field, but the isolate JavaScript backend
never said that a module only has a return value if it default-exports
a function, or that its top level can't do I/O. Models wrote a
top-level `return`, which is a syntax error, then top-level `await`
with I/O, which the Workers runtime refuses, then a floating async
function whose failure went unnoticed. Each attempt cost a tool call,
and an agent fixing a small Node.js project took 12 calls instead of
5.

The backend's description now gives the shape to write, `export
default async function (input) { ... }`, and says to call `node:fs`
and the other modules inside it. It also shows how to run a file
already in the Workspace, `export { default } from "./main.js"`, which
saves sending its source again. These rules are specific to Workers
isolates, so they live with the backend rather than in the `exec`
tool's hint, which every callable backend shares.
WorkerJavaScriptBackend confines isolate code to `root`, `/workspace`
by default, but a new Workspace has no directories at all. The first
`node:fs` call in a fresh Workspace failed with "no such path:
/workspace", while a module that touched no files ran fine, so the
failure showed up only once code did real work. Every host had to
create the directory before running anything, and the package's own
script runner tests did so before each run.

A read-write backend now creates its root before a run when the
directory is missing. It checks first rather than always calling a
recursive mkdir, because mkdir records a change even when the
directory already exists, and doing that on every run would give sync
something to do each time. A read-only backend leaves the Workspace
untouched, as its access promises.

The script runner tests no longer create the root themselves, and can
now target a fresh Workspace to check this directly.
`createWorkspaceError` appends `: <path>` to every message that comes
with a path, but most callers already end the message with it, so
errors read "no such path: /workspace: /workspace". About 80 call
sites do this.

The path is now appended only when the message doesn't already end
with it. Fixing it once here, rather than at every call site, keeps
new callers from bringing the duplicate back. Errors still carry the
path in their `path` field either way.
Every Workspace built in a Durable Object needed
`storage: ctx.storage as unknown as DurableObjectStorageLike`, and all
the examples carried that cast, several with a comment explaining it.
dofs declared `SQLStorageLike.exec` as generic over any object row
type, while the Workers runtime constrains its row type to records of
SQLite values. A generic method can't be assigned to one with a
broader type parameter, so TypeScript rejected the real storage even
though it works at run time.

`SQLStorageLike.exec` is no longer generic. It returns plain records,
and `Database`, the one place that reads rows, casts them to the shape
each query selects. `types.ts` gains a type-only assertion that a
Durable Object's storage fits the interface, so the dofs typecheck
fails if the two drift apart again.

The casts, their imports, and their comments are gone from the
examples, the tutorial README, the computer test workers, and the
dofs benchmarks.
The isolate JavaScript backend rejected every `node:*` import except
`node:fs` and `node:fs/promises`, which it replaces with modules backed
by the Workspace. The module graph knew only built-in, configured, host,
and path imports, so `import path from "node:path"` failed with "not
configured", even though the Dynamic Worker runs with `nodejs_compat`
and provides the module. Models reach for `node:path` first, and source
modules could already import it, since their bare imports go unchecked.

Imports of `node:path`, `node:url`, `node:util`, `node:events`,
`node:buffer`, `node:assert`, `node:string_decoder`, `node:querystring`,
`node:stream`, `node:crypto`, `node:zlib`, `node:timers`,
`node:async_hooks`, and `node:diagnostics_channel`, and their subpaths,
are now left for the runtime to resolve. A bare name such as `path`
becomes `node:path`, which both module registries resolve, unless a
configured module has that name. These modules work entirely inside the
isolate. The rest of the runtime's Node.js modules either duplicate the
Workspace, reach outside the isolate, or are stubs that throw, so they
stay unavailable.

The modules are allowed only when `compatibilityFlags` includes
`nodejs_compat`, the default. The backend's model-facing description
lists them under the same condition.
An isolate JavaScript module without a default export ran and
completed with a `null` result. A model that wrote `export function
main() { ... }`, or did its work at the module's top level, got nothing
back and no error saying why. That top-level work could only compute,
too, since module scope can't do I/O.

The backend now rejects such a module before loading it. The error
names the module's other exports and shows the two shapes that work: a
default-exported function, or a re-export such as `export { default }
from "./main.js"`, which runs a file already in the Workspace. The
check parses the source before the module graph is built, so a
rejected module never starts a Dynamic Worker.
@scuffi
scuffi force-pushed the computer/container-js-polish branch from 2d55455 to 50e8b10 Compare October 8, 2026 14:38
@scuffi
scuffi marked this pull request as ready for review October 8, 2026 15:32
@aron-cf aron-cf changed the title computer, dofs: Polish the isolate JavaScript backend for ws:container agents computer: Polish the isolate JavaScript backend for ws:container agents Oct 8, 2026
@aron-cf
aron-cf merged commit 3249dce into main Oct 8, 2026
21 of 22 checks passed
@aron-cf
aron-cf deleted the computer/container-js-polish branch October 8, 2026 15:33

@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 2 potential issues.

Devin Review

Comment on lines +287 to +290
const node = options.nodeModules ? nodeModule(specifier) : undefined;
if (node !== undefined) {
if (node !== specifier) edits.push({ ...site, specifier: node });
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃煛 Bare Node imports fail in libraries

When a configured library imports path, prepareSourceModules leaves the bare name unchanged. The Loader looks beside the library for path, so the run fails instead of loading node:path.

Learn more

The entry module rewrites allowed bare Node imports to their node: form, but configured source modules pass through a separate prepareSourceModules path. That path rewrites host imports but leaves bare Node imports untouched. The Loader resolves a bare import beside the configured module instead of treating it as a built-in, so a library using import path from "path" cannot load even with Node compatibility enabled.

Example: Configure modules: { helper: 'import path from "path"; export const name = path.basename("/a.txt");' }. Running import { name } from "helper"; export default name; cannot resolve the library's path import, while the same import in the entry module works.

Recommended fix: Pass the resolved nodeModules option into prepareSourceModules and rewrite allowed bare imports there as well, after checking for configured module names so configured modules retain precedence. Add a test that loads a configured library importing a bare Node built-in under both module registries.

Devin Review


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

Comment on lines +386 to +387
if (this.#options.access === "read-write")
await ensureDirectory(this.#host.fs, this.#options.root);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃攳 Check root creation against mounted trees

When root contains a read-only mount, ensureDirectory can encounter the mount guard while creating a missing ancestor. Check whether mount indexing guarantees that ancestor already exists before execution.

Devin Review


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

This was referenced Oct 8, 2026
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.

2 participants