Repository navigation
computer: Polish the isolate JavaScript backend for ws:container agents - #213
Conversation
馃 Changeset detectedLatest commit: 50e8b10 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 |
commit: |
There was a problem hiding this comment.
@scuffi can you take a human pass at these changelog entries, they're structured right, but can be super short.
| "@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). |
There was a problem hiding this comment.
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.
- Error if no top level export.
- 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.
| 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. |
| | 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` | |
There was a problem hiding this comment.
When we gonna add the node:sqlite shim to allow agents to cross communicate via DO storage?!
fe9c25d to
2d55455
Compare
|
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 = |
There was a problem hiding this comment.
This is used for all backends. Instead we want this on the JavaScriptWorkerBackend.description which will be included.
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.
2d55455 to
50e8b10
Compare
| const node = options.nodeModules ? nodeModule(specifier) : undefined; | ||
| if (node !== undefined) { | ||
| if (node !== specifier) edits.push({ ...site, specifier: node }); | ||
| continue; |
There was a problem hiding this comment.
馃煛 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.
Was this helpful? React with 馃憤 or 馃憥 to provide feedback.
| if (this.#options.access === "read-write") | ||
| await ensureDirectory(this.#host.fs, this.#options.root); |
There was a problem hiding this comment.
馃攳 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.
Was this helpful? React with 馃憤 or 馃憥 to provide feedback.
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-levelawaitfailed with a message about request handlers, which isolate code doesn't have.Change: Every
node:fsandws:*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.2. The model is told how to return a value
Why: The
exectool never said a module needs a default-exported function. Models tried a top-levelreturn, then top-levelawait, 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 showsexport { default } from "./main.js"for running a file that's already written. It lives on the backend rather than in theexectool's hint, which every callable backend shares.3.
/workspaceexists on first useWhy: 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, ranmkdirbefore each run.Change: A read-write backend creates its root when it's missing. It checks first, since
mkdirrecords 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, andcreateWorkspaceErrorappended 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.storageworks without a castWhy: Every example needed
ctx.storage as unknown as DurableObjectStorageLike. dofs declaredSQLStorageLike.execas generic over any row type, and TypeScript wouldn't accept the real storage.Change:
execisn't generic anymore;Databasecasts 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:fscould be imported.node:path, usually the first thing a model reaches for, failed as "not configured", even though the Dynamic Worker runs withnodejs_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_channelBare names like
pathwork too, unless a configured module has that name. All of this needsnodejs_compat, which is on by default.The rest stay off on purpose:
node:fsis already the Workspace-backed version.node:processwould bypass the backend'sprocessshim.node:net,node:httpand friends reach the network, and I haven't checked them against the egress policy.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 wroteexport 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.
Things to look at
SQLStorageLike.execno longer accepts a row type argument, soexec<Row>()calls through the interface stop compiling. The changeset sayspatch; happy to make itminor.Promise.reject()at module scope, since the runtime never reports it anywhere we can see.Trying it
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.mdis updated to match.