Skip to content

computer: Configure isolate modules through one modules option - #170

Open
mattzcarey wants to merge 2 commits into
mainfrom
feat/named-trusted-module-functions
Open

mattzcarey wants to merge 2 commits into
mainfrom
feat/named-trusted-module-functions

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

WorkerJavaScriptBackend had two ways to add an import, plus a set that was always on:

new WorkerJavaScriptBackend({
  modules: { "tar-stream": BUNDLE },                  // source only
  trustedModules: { "ws:model": { call(method, args) { if (method === "batch") ... } } },
  allowGitNetwork: true,                              // gates the always-on ws:git
  allowArtifactNetwork: true,                         // gates the always-on ws:artifacts
});
// isolate: import { call } from "ws:model"; await call("batch", requests);

There is now one modules option, and the value's type says what it is:

import { createGitModule } from "@cloudflare/computer/modules/git";

new WorkerJavaScriptBackend({
  loader: env.LOADER,
  modules: {
    "tar-stream": BUNDLE,                                  // string: bundled source
    "ws:weather": { forecast: ([city]) => lookUp(city) },  // object: host functions
    "ws:git": createGitModule({ allowNetwork: true }),     // factory: (host) => functions
  },
});
// isolate: import { forecast } from "ws:weather";
Value Meaning
string Source bundled into the isolate, with no host access
object of functions Host module under ws:*; each function is a named export
(host) => functions Host module built from host.git, host.artifacts, host.runtime when the backend connects
sequenceDiagram
  participant Code as Isolate code
  participant Shim as Generated ws:weather
  participant Bridge as Host bridge
  participant Fn as forecast(args, context)
  Code->>Shim: forecast("Lisbon")
  Shim->>Bridge: host/ws:weather.forecast ["Lisbon"]
  Bridge->>Fn: own-property lookup, then call
  Fn-->>Code: JSON result
Loading

node:fs and node:fs/promises stay built in, and nothing under ws: is installed unless configured. Git and Artifacts move out of the bridge into prebuilt factories, createGitModule() and createArtifactsModule() under @cloudflare/computer/modules/*. Their network access is now allowNetwork on the module instead of a backend flag.

Every host function gets (args, { signal, deadline, access, resolvePath }) and may return any JSON-compatible value. The bridge checks the result at runtime, turns undefined into null, and drops undefined fields, so an interface-typed result such as a GitClient value works without a wrapper. access and resolvePath give every module the write check and root confinement the bridge used to apply only to ws:git.

Generated shims export through export { fn0 as name }, so a reserved word such as delete works (callers rename it on import). Importing a name the module does not export fails when the graph links. The bridge only dispatches to functions the module owns, so inherited members like toString are unreachable. Specifiers, source module names, and object export names are checked at construction. A factory's export names are checked when the backend connects.

The backend also describes its source language and every importable module in backend.description, which workspace.runtime.describe(id) returns. It is built from the same modules option, and #171 puts it in front of the model.

ws:git's cli also accepts a leading -C <path>, which the git CLI added on main because agents reach for git -C. The path becomes the command's working directory and is confined to the backend root like cwd. A -C anywhere else, --git-dir, and --work-tree are still rejected.

This breaks three things. trustedModules moves into modules, with one function per method. ws:git and ws:artifacts must be configured. allowGitNetwork and allowArtifactNetwork become allowNetwork on the matching module. The rlm example moves to import { batch } from "ws:model". The script runner suite runs source modules, custom host modules, and both prebuilt modules in a real Dynamic Worker, including git confinement and network denial.

@changeset-bot

changeset-bot Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3ed0326

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

@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your interest in Cloudflare Computer.

This repository does not accept unsolicited pull requests. Please use one of the accepted contribution paths instead:

If a maintainer asked you to open this pull request, they can add the allow-pr label and reopen it.

@github-actions github-actions Bot closed this Sep 30, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 3ed0326

devin-ai-integration[bot]

This comment was marked as resolved.

import { call as hostCall } from ${JSON.stringify(capabilitiesImport)};
export const call = (method, ...args) => hostCall(${JSON.stringify(`trusted/${specifier}`)}, "call", [method, ...args]);
import { call } from ${JSON.stringify(capabilitiesImport)};
${names.map((name, index) => `const fn${index} = (...args) => call(${namespace}, ${JSON.stringify(name)}, args);`).join("\n")}
import { call as hostCall } from ${JSON.stringify(capabilitiesImport)};
export const call = (method, ...args) => hostCall(${JSON.stringify(`trusted/${specifier}`)}, "call", [method, ...args]);
import { call } from ${JSON.stringify(capabilitiesImport)};
${names.map((name, index) => `const fn${index} = (...args) => call(${namespace}, ${JSON.stringify(name)}, args);`).join("\n")}
@aron-cf

aron-cf commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

@mattzcarey I think this is a solid improvement. I think we can re-work these trusted modules further. They were added quite last minute during the container launch and I've not looked at them since. I think we can rip out most of the bridge code here and just rely on providing modules as RpcTarget implementations with some accounting wrapped around them.

import { RpcTarget } from "cloudflare:workers";

class ContainerModule extends RpcTarget {
  #env: Env;

  constructor(env: Env) {
    super();
    this.#env = env;       // invisible over RPC
  }

  async exec(cmd: string) { /* ... */ }   // callable
  async kill(id: string) { /* ... */ }    // callable

  #spawn() { /* ... */ }                  // invisible over RPC
}

new WorkerJavaScriptBackend({
  loader: env.LOADER,
  trustedModules: {
    "ws:container": new ContainerModule(env),
  },
});

Isolate code is unchanged:

import { exec } from "ws:container";
export default () => exec("npm test");

We can then delete,

bridge.ts        #callTrusted                (name parsing + own-property lookups)
bridge.ts        encodeBridgeValue           }
bridge.ts        decodeBridgeValue           } one copy of the codec
module-graph.ts  encode / decode / wrap      } the other copy, in a template string
module-graph.ts  __workspace_codec__         the envelope format
module-graph.ts  trustedModule()             shrinks to a plain forward

We can potentially use a proxy wrapper to keep the existing quotas & checks.

class MeteredModule extends RpcTarget {
  // NOTE: instance properties aren't visible over RPC, so this needs
  // prototype-level definition or a Proxy. Worth prototyping first.
  constructor(target, budget) {
    super();
    for (const name of methodNames(target)) {
      this[name] = async (...args) => {
        budget.charge();                 // count, concurrency, deadline
        assertRuntimeValue(args);        // keep the type gate
        return budget.track(target[name](...args));   // for cancelAndDrain
      };
    }
  }
}

@mattzcarey
mattzcarey force-pushed the feat/named-trusted-module-functions branch from 3bc4799 to 3cb2999 Compare October 1, 2026 09:27
@mattzcarey mattzcarey changed the title computer: Export named functions from trusted modules computer: Configure isolate modules through one modules option Oct 1, 2026

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

Devin Review

assertBridgeValues(callArgs);
const result = await trusted.call(method, callArgs, context);
assertBridgeValues(args);
const result = (await fn(args, context)) ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Host module methods lose their receiver

When an exported host method uses this, fn(args, context) invokes it without its module object. The call fails instead of returning the method's result.

Suggested change
const result = (await fn(args, context)) ?? null;
const result = (await fn.call(functions, args, context)) ?? null;

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +50 to +51
describe(id: string): string | undefined {
return this.#options.backends.get(id)?.description;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Configured modules stay hidden from models

When a model uses the exec tool, describe(id) never reaches its backend guidance. createExecTool reads only caller-supplied descriptions, so configured imports remain invisible to the model.

Learn more

The JavaScript backend now builds a description listing importable modules and exposes it through WorkspaceRuntime.describe. The exec tool constructs model-visible guidance only from its separate backends option in createExecTool; createAITools passes that option through without adding runtime descriptions. Consequently configuring modules on a backend does not inform a model using the exec tool about their names or exports.

Example: Configure modules: { "ws:weather": { forecast } } and create an exec tool with backends: { js: { description: "Run JavaScript" } }. The model sees “Run JavaScript” but never sees ws:weather or forecast.

Recommended fix: Extend the exec tool's runtime interface with optional describe(id) and append its result to each backend's caller-supplied description in createExecTool, preserving support for lightweight runtime mocks.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +31 to +50
* Modules caller source can import by specifier.
*
* A string value is JavaScript source bundled into the isolate, such as
* a library build. An object of functions, or a factory that builds
* one, is a host module: it runs in the Durable Object under a `ws:*`
* specifier, and each function becomes a named export.
*
* ```ts
* modules: {
* "tar-stream": TAR_STREAM_BUNDLE,
* "ws:git": createGitModule(),
* "ws:weather": { forecast: ([city]) => lookUpForecast(String(city)) },
* }
* ```
*
* `node:fs` and `node:fs/promises` are always installed and cannot be
* replaced. The constructor throws when a specifier is not allowed, and
* connecting throws when a host module's export names are not allowed.
*/
trustedModules?: Record<`ws:${string}`, WorkspaceTrustedModule>;
modules?: Record<string, WorkspaceModule>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 PR example differs from module configuration

The PR example still uses trustedModules, while this interface accepts host functions through modules. Its sequence diagram also names the old trusted/ bridge route. Align the PR description with the shipped API.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

WorkerJavaScriptBackend had two ways to add imports: modules for
bundled source, and trustedModules for a single call(method, args)
host handler reached through one generic call export. On top of those,
node:fs, ws:git, and ws:artifacts were always installed, with git and
artifacts special-cased in the bridge and gated by allowGitNetwork and
allowArtifactNetwork.

There is now one modules option, and the value's type says what it
is. A string is bundled source, as before. An object of functions is a
host module under a ws:* specifier, and each function becomes a named
export, so { "ws:weather": { forecast } } lets code write
import { forecast } from "ws:weather". A function is a factory the
backend calls when it connects, with the Workspace's Git client,
Artifacts client, and runtime. Each call gets its access level and a
path resolver confined to the backend root alongside the signal and
deadline, and may return any JSON-compatible value; the bridge checks
it at runtime.

Git and Artifacts become prebuilt factories under
@cloudflare/computer/modules/*, and nothing under ws: is installed
unless configured. Network access moves to allowNetwork on each
module. node:fs and node:fs/promises stay built in. Specifiers, source
module names, and object export names are checked at construction.

The backend also describes its source language and every importable
module in a description property, which workspace.runtime.describe(id)
returns, built from the same modules option it runs with. The rlm
example moves ws:model to a named batch function.
The Git CLI now takes a leading -C <path>, because agents reach for
git -C instead of changing directory. ws:git rejected every -C as a
path override, so those commands failed inside an isolate.

A leading -C now becomes the command's working directory and goes
through the same root confinement as cwd. A -C anywhere else, and
--git-dir and --work-tree, are still rejected.
@mattzcarey
mattzcarey force-pushed the feat/named-trusted-module-functions branch from 3cb2999 to 3ed0326 Compare October 1, 2026 11:23
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.

3 participants