From b9b20045d240075df15341d73edcf69361ace382 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:15:41 +0000 Subject: [PATCH] computer: Make the codemode module scheme configurable CodemodeBackend serves host modules, and the libraries a host provides, only under codemode:*. A host that presents these modules to a model as its own, such as agent:git and agent:web, has no way to name them so. The backend now takes scheme, which replaces codemode everywhere the scheme was fixed: the specifiers host modules and host libraries must use, the imports a source module may make by name, the error for code that imports an unconfigured module under the scheme, and the module names reserved for Workspace internals. Under another scheme, codemode:* is an ordinary bare name. The scheme is checked when the backend is constructed: a lowercase letter followed by lowercase letters, digits, or dashes, and not one the Workers runtime or URLs already give a meaning, such as node or https. It defaults to codemode, and a backend without the option behaves as before. createBrowserModule() names its own specifier and codemode:tools in the description it gives the model, so it takes specifier and toolsSpecifier for a host that serves the browser or the agent's tools under other names. Its errors name its specifier too. --- .changeset/codemode-module-scheme.md | 5 + docs/17_isolate_javascript.md | 28 ++- .../src/backends/codemode/codemode.test.ts | 200 +++++++++++++++++- .../src/backends/codemode/codemode.ts | 28 ++- .../src/backends/codemode/module-graph.ts | 91 ++++++-- packages/computer/src/modules/browser.test.ts | 15 ++ packages/computer/src/modules/browser.ts | 44 ++-- packages/computer/src/runtime/types.ts | 3 +- 8 files changed, 380 insertions(+), 34 deletions(-) create mode 100644 .changeset/codemode-module-scheme.md diff --git a/.changeset/codemode-module-scheme.md b/.changeset/codemode-module-scheme.md new file mode 100644 index 00000000..b8a98f78 --- /dev/null +++ b/.changeset/codemode-module-scheme.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +`CodemodeBackend` takes a `scheme`, such as `"agent"`, to serve host modules and the host's libraries as `agent:*` instead of `codemode:*`, and `createBrowserModule()` takes `specifier` and `toolsSpecifier` for the names its description gives. See [Module scheme](https://github.com/cloudflare/computer/blob/main/docs/17_isolate_javascript.md#module-scheme). diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index daa360da..6a3d2a53 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -362,7 +362,7 @@ Source modules live together in the module directory, not beside the caller's fi ### Host modules -A host module runs in the Durable Object, and each of its functions becomes a named export in the isolate. Host modules must use a simple `codemode:*` specifier. Nothing under `codemode:` is installed unless you configure it. +A host module runs in the Durable Object, and each of its functions becomes a named export in the isolate. Host modules must use a simple `codemode:*` specifier, or one under the backend's [module scheme](#module-scheme). Nothing under `codemode:` is installed unless you configure it. Pass an object of functions: @@ -429,6 +429,30 @@ The run's code never sees metadata: it isn't in `process.env` or the input, so c Specifiers and the export names of an object are checked at construction. A factory's export names are checked when the backend connects and the factory runs. An object must export at least one function. A factory may build none, as `codemode:tools` does for an agent with no tools: the backend then leaves that module out of the connection, so only code that imports it fails, with a message saying the module exports nothing, and every other run still starts. Every export name must be a JavaScript identifier name other than `default` or `then`. A reserved word such as `delete` is allowed, and caller code renames it on import: `import { delete as remove } from "codemode:files"`. Importing a name the module does not export fails when the module graph links, before any code runs. +#### Module scheme + +A host that wants its modules under a name of its own sets `scheme`, which replaces `codemode` in every specifier above. With `scheme: "agent"`, host modules and the libraries the host provides are `agent:git`, `agent:weather` and so on: code imports them by those names, the backend's description lists them so, and an import of an `agent:*` module that isn't configured fails before the Worker is created, as an unconfigured `codemode:*` one does by default. + +```ts +new CodemodeBackend({ + loader: env.LOADER, + scheme: "agent", + modules: { + "agent:git": createGitModule(), + "agent:web": createBrowserModule({ + browser: this.browser, + specifier: "agent:web", + toolsSpecifier: "agent:tools", + }), + "agent:tools": createToolBindings(forPiTools(() => agent.tools)), + }, +}); +``` + +`codemode:*` is then an ordinary bare name: a host module can't use it, and code that imports one it doesn't have fails as it would for any other unknown module. A backend has one scheme. It must be a lowercase letter followed by lowercase letters, digits, or `-`, and can't be one the Workers runtime or URLs already give a meaning, such as `node`, `cloudflare`, `http`, or `file`. The default, `codemode`, behaves exactly as before. + +The prebuilt modules work under any specifier. `createBrowserModule()` tells the model which specifier to import it from, and not to reach the browser tool through `codemode:tools`, so pass it `specifier` and `toolsSpecifier` when it's served under other names. + #### Codemode modules `createCodemodeModule()` builds a host module that can return objects with methods, and give its functions shared context. Define the kinds of object the module returns, add context, then make the module: @@ -759,7 +783,7 @@ The function may throw instead, when a run without that metadata shouldn't brows Runs in one browser scope take turns, like `browserTool` calls, because they would otherwise see each other's navigation: pi and the AI SDK run a message's tool calls in parallel. A run takes its turn at its first browser call and keeps it until it ends, and runs that never call the module never wait. A run gives up after `lockTimeoutMs` (45 seconds by default) with an error saying why; keep it under the run's timeout and the backend's `maxHostCallMs`. A run that browsed and is waiting for a person keeps its browser scope while it waits: its session is closed, and other runs' first calls in that browser scope fail at once, saying the browser is held by a run that's waiting for a person. A run that took its turn while one ahead of it paused fails the same way. The module remembers which runs browsed in memory, so after the Durable Object restarts, a waiting run holds nothing until it resumes. Once that run resumes, it takes its turn again only at its first new browser call, since replay answers its earlier calls from the journal. -Don't expose `browserTool` to code through `codemode:tools` alongside this module. A run calling it would wait for its own turn until the host call timed out. The module's description says so, and also tells the model to save screenshots to a file with `node:fs` and open them with `read`, to take about eight per run (each host call returns at most 1 MiB, and a run's calls share 8 MiB each way), and to work on several pages inside one run rather than across runs started together. The backend adds that description to its own, which the `exec` tool shows. An app that writes `exec`'s description itself must include it, from `backend.description`. +Don't expose `browserTool` to code through `codemode:tools` alongside this module. A run calling it would wait for its own turn until the host call timed out. The module's description says so, naming `toolsSpecifier` (`codemode:tools` by default) as it names `specifier` (`codemode:browser` by default) in its import line, and also tells the model to save screenshots to a file with `node:fs` and open them with `read`, to take about eight per run (each host call returns at most 1 MiB, and a run's calls share 8 MiB each way), and to work on several pages inside one run rather than across runs started together. The backend adds that description to its own, which the `exec` tool shows. An app that writes `exec`'s description itself must include it, from `backend.description`. The module is experimental and may change in any release. diff --git a/packages/computer/src/backends/codemode/codemode.test.ts b/packages/computer/src/backends/codemode/codemode.test.ts index 34a622e9..ba634f63 100644 --- a/packages/computer/src/backends/codemode/codemode.test.ts +++ b/packages/computer/src/backends/codemode/codemode.test.ts @@ -2,8 +2,9 @@ import { Database, initializeSchema, WorkspaceFilesystem } from "@cloudflare/dof import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { describe, expect, it, vi } from "vitest"; +import { createGitModule } from "../../modules/git"; import { createCodemodeModule, type WorkspaceModuleScopeEnd } from "../../runtime/codemode-module"; -import type { WorkspaceRuntimeLoader } from "../../runtime/types"; +import type { WorkspaceModule, WorkspaceRuntimeLoader } from "../../runtime/types"; import { Workspace } from "../../workspace"; import { runtimeCode, CodemodeBackend } from "./codemode"; import { ExecutionJournal, fingerprint, loadResume, type ResumeStart, saveResume } from "./journal"; @@ -2050,6 +2051,203 @@ describe("CodemodeBackend", () => { `import { value } from "base"; import { run } from "./codemode:echo"; export { value, run };`, ); }); + + describe("with a scheme", () => { + async function workspaceWith( + load: WorkspaceRuntimeLoader["load"], + modules: Record, + ) { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new CodemodeBackend({ loader: { load }, scheme: "agent", modules })], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + return workspace; + } + + // A loader whose run calls each host function in turn, as code that + // imported them would, then completes. + function callingLoader(names: readonly string[]) { + return vi.fn( + (_code: { modules: Record }) => ({ + getEntrypoint() { + return { + async evaluate( + _input: unknown, + host: { + call(name: string, args: unknown[]): Promise; + assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; + }, + ) { + for (const name of names) await host.call(name, []); + await evaluateResult(host, null); + }, + }; + }, + }), + ); + } + + it("serves objects of functions, factories and codemode modules under its scheme", async () => { + const called: string[] = []; + const load = callingLoader([ + "host/agent:echo.run", + "host/agent:repo.recent", + "host/agent:jobs.start", + ]); + const workspace = await workspaceWith(load, { + "agent:echo": { run: async () => void called.push("echo") }, + "agent:repo": () => ({ recent: async () => void called.push("repo") }), + "agent:jobs": createCodemodeModule().module({ + start: async () => void called.push("jobs"), + }), + }); + + const execution = await workspace.runtime.exec( + `import { run } from "agent:echo"; import { recent } from "agent:repo"; import { start } from "agent:jobs"; export default [run, recent, start].length;`, + ); + await expect(execution.result()).resolves.toMatchObject({ status: "completed" }); + + expect(called).toEqual(["echo", "repo", "jobs"]); + const modules = load.mock.calls[0]?.[0].modules ?? {}; + expect(Object.keys(modules).filter((name) => name.startsWith("__modules__/"))).toEqual([ + "__modules__/agent:echo", + "__modules__/agent:repo", + "__modules__/agent:jobs", + ]); + expect(source(modules["workspace/__workspace_entry__.js"])).toBe( + `import { run } from "../__modules__/agent:echo"; import { recent } from "../__modules__/agent:repo"; import { start } from "../__modules__/agent:jobs"; export default [run, recent, start].length;`, + ); + }); + + it("describes a prebuilt factory and the host's libraries under its scheme", () => { + const backend = new CodemodeBackend({ + loader: throwingLoader("must not load"), + scheme: "agent", + modules: { + "agent:git": createGitModule(), + "agent:lib": "export const x = 1;", + // Under another scheme, codemode:* is an ordinary bare name. + "codemode:lib": "export const x = 1;", + }, + }); + + expect(backend.description).toContain( + "- `agent:git`: The workspace's Git repository tools: `status({ dir })`", + ); + expect(backend.description).toContain("- `agent:lib`: a library the host provides."); + expect(backend.description).toContain("- `codemode:lib`: a bundled library."); + }); + + it("serves source under its scheme, which can import the host's modules by name", async () => { + const load = completingLoader(); + const workspace = await workspaceWith(load, { + "agent:facade": `import { run } from "agent:echo"; import { value } from "agent:base"; export const echo = { run, value };`, + "agent:base": "export const value = 1;", + "agent:echo": { run: async () => null }, + }); + + const execution = await workspace.runtime.exec( + `import { echo } from "agent:facade"; export default typeof echo.run;`, + ); + await expect(execution.result()).resolves.toMatchObject({ status: "completed" }); + + const modules = load.mock.calls[0]?.[0].modules ?? {}; + expect(source(modules["__modules__/agent:facade"])).toBe( + `import { run } from "./agent:echo"; import { value } from "./agent:base"; export const echo = { run, value };`, + ); + }); + + it.each([ + [ + "code that imports an unconfigured module under its scheme", + {}, + `import "agent:missing"; export default 1;`, + `Module "agent:missing" is not configured. Add it to the backend's modules option.`, + ], + [ + "code that imports a codemode:* module", + {}, + `import { status } from "codemode:git"; export default status;`, + `Module "codemode:git" is not configured for the codemode backend.`, + ], + [ + "source that imports an unconfigured module under its scheme", + { lib: `import "agent:missing";` }, + `import "lib"; export default 1;`, + `Configured module "lib" imports "agent:missing", which is not configured.`, + ], + ])("rejects %s", async (_label, modules, code, message) => { + const load = vi.fn(); + const workspace = await workspaceWith(load, modules); + + await expect(workspace.runtime.exec(code)).rejects.toThrow(message); + expect(load).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "a host module under codemode:*", + { "codemode:git": { run: async () => null } }, + 'Host module "codemode:git" must use a simple agent:* name, such as "agent:git".', + ], + [ + "source under its scheme with a path", + { "agent:lib/x": "export const x = 1;" }, + 'Module "agent:lib/x" must use a simple agent:* name, such as "agent:git".', + ], + ])("rejects %s at construction", (_label, modules, message) => { + expect( + () => + new CodemodeBackend({ + loader: throwingLoader("must not load"), + scheme: "agent", + modules, + }), + ).toThrow(message); + }); + + it("keeps codemode:* by default, and refuses another scheme's host modules", () => { + expect( + () => + new CodemodeBackend({ + loader: throwingLoader("must not load"), + modules: { "agent:git": { run: async () => null } }, + }), + ).toThrow( + 'Host module "agent:git" must use a simple codemode:* name, such as "codemode:git".', + ); + const backend = new CodemodeBackend({ + loader: throwingLoader("must not load"), + scheme: "codemode", + modules: { "codemode:weather": { forecast: () => null } }, + }); + expect(backend.description).toBe( + new CodemodeBackend({ + loader: throwingLoader("must not load"), + modules: { "codemode:weather": { forecast: () => null } }, + }).description, + ); + }); + + it.each(["", "Agent", "1agent", "agent:", "my_agent", "node", "cloudflare", "https", "file"])( + "refuses the scheme %j", + (scheme) => { + expect( + () => new CodemodeBackend({ loader: throwingLoader("must not load"), scheme }), + ).toThrow(/CodemodeBackend scheme/); + }, + ); + + it("accepts lowercase schemes with digits and dashes", () => { + for (const scheme of ["agent", "my-agent", "agent2"]) { + expect( + () => new CodemodeBackend({ loader: throwingLoader("must not load"), scheme }), + ).not.toThrow(); + } + }); + }); }); }); diff --git a/packages/computer/src/backends/codemode/codemode.ts b/packages/computer/src/backends/codemode/codemode.ts index 3343b018..2ce2c173 100644 --- a/packages/computer/src/backends/codemode/codemode.ts +++ b/packages/computer/src/backends/codemode/codemode.ts @@ -55,7 +55,9 @@ import { } from "./journal.js"; import { assertHostModuleExports, + assertScheme, buildModuleGraph, + DEFAULT_SCHEME, hasNodeModules, internalModuleSources, NODE_MODULES_DESCRIPTION, @@ -77,7 +79,8 @@ export interface CodemodeBackendOptions { * one, is a host module: it runs in the Durable Object under a `codemode:*` * specifier, and each function becomes a named export. Source may use a * simple `codemode:*` specifier too, for a library the host provides, such as - * one that wraps host modules in objects. + * one that wraps host modules in objects. `scheme` replaces `codemode` + * in these specifiers. * * ```ts * modules: { @@ -93,6 +96,21 @@ export interface CodemodeBackendOptions { * connecting throws when a host module's export names are not allowed. */ modules?: Record; + /** + * The scheme of host module specifiers, and of the libraries the host + * provides, without its colon. Defaults to `"codemode"`, for + * `codemode:git`. With `"agent"`, host modules are `agent:git` and + * `agent:weather`, code imports them by those names, and the backend's + * description lists them so. + * + * A specifier under any other scheme, `codemode:` included, is then an + * ordinary bare name: a host module can't use it, and an import of one + * that isn't configured fails like any other unknown module. The scheme + * must be a lowercase letter followed by lowercase letters, digits, or + * `-`, and not one the Workers runtime or URLs already use, such as + * `node`, `cloudflare`, `http` or `file`. + */ + scheme?: string; defaultTimeoutMs?: number; maxTimeoutMs?: number; /** @@ -214,6 +232,7 @@ type ResolvedCodemodeBackendOptions = Required< CodemodeBackendOptions, | "root" | "access" + | "scheme" | "defaultTimeoutMs" | "maxTimeoutMs" | "maxCpuMs" @@ -374,6 +393,8 @@ export class CodemodeBackend implements WorkspaceModuleBackend { if ((options.maxCapabilityBytes ?? 1024 * 1024) < 256) { throw new Error("CodemodeBackend maxCapabilityBytes must be at least 256 bytes."); } + const scheme = options.scheme ?? DEFAULT_SCHEME; + assertScheme(scheme); const compatibilityDate = options.compatibilityDate ?? "2026-05-23"; if (!/^\d{4}-\d{2}-\d{2}$/.test(compatibilityDate)) { throw new Error("CodemodeBackend compatibilityDate must use YYYY-MM-DD."); @@ -392,7 +413,8 @@ export class CodemodeBackend implements WorkspaceModuleBackend { this.#options = { ...backendOptions, egress: resolvedEgress, - modules: parseModules(options.modules ?? {}), + modules: parseModules(options.modules ?? {}, scheme), + scheme, root: options.root ?? WORKSPACE_ROOT, access: options.access ?? "read-write", defaultTimeoutMs, @@ -976,6 +998,7 @@ class CodemodeBackendHandle implements WorkspaceModuleBackendHandle { this.#sourceModules ??= prepareSourceModules( this.#options.modules.source, new Set([...this.#options.modules.host.keys(), ...this.#options.modules.codemode.keys()]), + this.#options.scheme, ); return this.#sourceModules; } @@ -1034,6 +1057,7 @@ class CodemodeBackendHandle implements WorkspaceModuleBackendHandle { nodeModules: hasNodeModules(this.#options.compatibilityFlags), maxSourceBytes: this.#options.maxSourceBytes, maxCapabilityBytes: this.#options.maxCapabilityBytes, + scheme: this.#options.scheme, }); if (this.#closed) { throw runtimeError("ECLOSED", "CodemodeBackend closed during module loading"); diff --git a/packages/computer/src/backends/codemode/module-graph.ts b/packages/computer/src/backends/codemode/module-graph.ts index d7f4dfd8..1e56ad05 100644 --- a/packages/computer/src/backends/codemode/module-graph.ts +++ b/packages/computer/src/backends/codemode/module-graph.ts @@ -75,7 +75,53 @@ function nodeModule(specifier: string): string | undefined { const name = specifier.startsWith("node:") ? specifier.slice("node:".length) : specifier; return NODE_MODULES.some((allowed) => allowed === name) ? `node:${name}` : undefined; } -const HOST_SPECIFIER = /^codemode:[A-Za-z0-9][A-Za-z0-9._-]*$/; +/** The scheme of host module specifiers unless the backend's `scheme` says otherwise. */ +export const DEFAULT_SCHEME = "codemode"; + +const SCHEME = /^[a-z][a-z0-9-]*$/; +// Schemes the Workers runtime or URLs already give a meaning, such as +// `node:path` or `https://...`. Modules under one would shadow those, or +// be mistaken for them by the code that imports them. +const RESERVED_SCHEMES = new Set([ + "node", + "cloudflare", + "workerd", + "http", + "https", + "ws", + "wss", + "ftp", + "file", + "data", + "blob", + "javascript", +]); + +/** + * Check a backend's `scheme`. + * + * @param scheme - The scheme, without its colon. + * @throws When it is not a lowercase letter followed by lowercase letters, + * digits, or `-`, or is a scheme the runtime or URLs already use. + */ +export function assertScheme(scheme: string): void { + if (typeof scheme !== "string" || !SCHEME.test(scheme)) { + throw new Error( + `CodemodeBackend scheme must be a lowercase letter followed by lowercase letters, digits, or "-", such as "codemode", not ${JSON.stringify(scheme)}.`, + ); + } + if (RESERVED_SCHEMES.has(scheme)) { + throw new Error( + `CodemodeBackend scheme ${JSON.stringify(scheme)} is reserved: the Workers runtime or URL parsing already gives it a meaning.`, + ); + } +} + +// A simple specifier under the scheme, such as `codemode:git`. The scheme +// is checked first, so it has no characters a pattern treats specially. +function hostSpecifier(scheme: string): RegExp { + return new RegExp(`^${scheme}:[A-Za-z0-9][A-Za-z0-9._-]*$`); +} /** The `modules` option, parsed once when the backend is constructed. */ export interface ParsedModules { @@ -97,12 +143,19 @@ const FILESYSTEM_DESCRIPTION = * checked when the backend connects and it runs. * * @param modules - The modules passed to the backend. + * @param scheme - The scheme host modules use, such as `codemode`. * @returns Source modules, host module factories, codemode modules, and * their description. * @throws When a specifier or an object's export names are not allowed. * The host configured the backend wrongly and no execution can use it. */ -export function parseModules(modules: Readonly>): ParsedModules { +export function parseModules( + modules: Readonly>, + scheme: string = DEFAULT_SCHEME, +): ParsedModules { + const prefix = `${scheme}:`; + const simple = hostSpecifier(scheme); + const example = JSON.stringify(`${scheme}:git`); const source: Record = Object.create(null); const host = new Map(); const codemode = new Map(); @@ -113,28 +166,28 @@ export function parseModules(modules: Readonly>) throw new Error(`Module ${JSON.stringify(specifier)} is built in and cannot be replaced.`); } if (typeof module === "string") { - if (specifier.startsWith("codemode:")) { + if (specifier.startsWith(prefix)) { // Source the host provides under its own namespace, such as a facade over its host // modules that exports objects, which host modules can't. Same naming rule as theirs. - if (!HOST_SPECIFIER.test(specifier)) { + if (!simple.test(specifier)) { throw new Error( - `Module ${JSON.stringify(specifier)} must use a simple codemode:* name, such as "codemode:git".`, + `Module ${JSON.stringify(specifier)} must use a simple ${prefix}* name, such as ${example}.`, ); } source[specifier] = module; lines.push(`- ${name}: a library the host provides.`); continue; } - if (specifier.includes("/") || isInternalModuleName(specifier)) { + if (specifier.includes("/") || isInternalModuleName(specifier, scheme)) { throw new Error(`Module ${JSON.stringify(specifier)} uses a reserved module name.`); } source[specifier] = module; lines.push(`- ${name}: a bundled library.`); continue; } - if (!HOST_SPECIFIER.test(specifier)) { + if (!simple.test(specifier)) { throw new Error( - `Host module ${JSON.stringify(specifier)} must use a simple codemode:* name, such as "codemode:git".`, + `Host module ${JSON.stringify(specifier)} must use a simple ${prefix}* name, such as ${example}.`, ); } if (isCodemodeModule(module)) { @@ -186,20 +239,24 @@ export interface BuildModuleGraphOptions { maxCapabilityBytes: number; maxModules?: number; maxDepth?: number; + /** The scheme host modules use. Defaults to `codemode`. */ + scheme?: string; } /** * Prepare the backend's source modules for the module directory, once * per backend handle: check what each one imports, and point its - * `codemode:*` imports at the module directory. + * imports under the module scheme, such as `codemode:*`, at the module + * directory. * * A source module is stored beside the other configured modules rather * than beside the caller's files, so it may import them (those under - * `codemode:*` by name too), host modules, and built-in modules, but not - * Workspace files. + * the module scheme by name too), host modules, and built-in modules, + * but not Workspace files. * * @param sources - Source modules by specifier. * @param hostModules - The specifiers of the backend's host modules. + * @param scheme - The scheme host modules use, such as `codemode`. * @returns Each source module, ready to store in the module directory. * @throws When a source module is not valid JavaScript, or imports a * path that is not another configured module or a host module that is @@ -208,6 +265,7 @@ export interface BuildModuleGraphOptions { export function prepareSourceModules( sources: Readonly>, hostModules: ReadonlySet, + scheme: string = DEFAULT_SCHEME, ): Readonly> { const prepared: Record = Object.create(null); for (const [specifier, source] of Object.entries(sources)) { @@ -222,7 +280,7 @@ export function prepareSourceModules( const edits: ImportSite[] = []; for (const site of sites) { const imported = site.specifier; - if (imported.startsWith("codemode:")) { + if (imported.startsWith(`${scheme}:`)) { if (!hostModules.has(imported) && !Object.hasOwn(sources, imported)) { throw new Error( `Configured module ${JSON.stringify(specifier)} imports ${JSON.stringify(imported)}, which is not configured.`, @@ -259,6 +317,7 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { let totalBytes = new TextEncoder().encode(options.source).byteLength; const maxModules = options.maxModules ?? 128; const maxDepth = options.maxDepth ?? 32; + const scheme = options.scheme ?? DEFAULT_SCHEME; const storedName = (specifier: string) => `${MODULES_DIRECTORY}/${specifier}`; async function visit(path: string, source: string, depth: number): Promise { @@ -296,7 +355,7 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { `Module ${JSON.stringify(specifier)} exports nothing on this backend: its factory built no functions when the backend connected.`, ); } - if (specifier.startsWith("codemode:")) { + if (specifier.startsWith(`${scheme}:`)) { throw new Error( `Module ${JSON.stringify(specifier)} is not configured. Add it to the backend's modules option.`, ); @@ -305,7 +364,7 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { const absolute = specifier.startsWith("/"); const resolved = absolute ? normalizePath(specifier) : resolveRelative(path, specifier); const childName = moduleName(resolved); - if (isInternalModuleName(childName)) { + if (isInternalModuleName(childName, scheme)) { throw new Error( `Module ${JSON.stringify(childName)} is reserved for Workspace internals.`, ); @@ -498,7 +557,7 @@ function directoryName(name: string) { return slash === -1 ? "" : name.slice(0, slash); } -function isInternalModuleName(name: string) { +function isInternalModuleName(name: string, scheme: string) { return ( name === CAPABILITIES_MODULE || name === DURABLE_MODULE || @@ -509,7 +568,7 @@ function isInternalModuleName(name: string) { name.endsWith(`/${CAPABILITIES_MODULE}`) || name.endsWith(`/${DURABLE_MODULE}`) || name.endsWith(`/${RUNNER_MODULE}`) || - name.split("/").at(-1)?.startsWith("codemode:") === true + name.split("/").at(-1)?.startsWith(`${scheme}:`) === true ); } diff --git a/packages/computer/src/modules/browser.test.ts b/packages/computer/src/modules/browser.test.ts index 9589f2d3..e3d2ca66 100644 --- a/packages/computer/src/modules/browser.test.ts +++ b/packages/computer/src/modules/browser.test.ts @@ -212,6 +212,21 @@ describe("createBrowserModule", () => { ); }); + it("names the specifiers it's given in its description", () => { + const module = createBrowserModule({ + browser: new FakeBrowser(["cdp"]), + specifier: "agent:browser", + toolsSpecifier: "agent:tools", + }); + + expect(module.description).toContain('import * as browser from "agent:browser"'); + expect(module.description).toContain("through `agent:tools`"); + expect(module.description).not.toContain("codemode:"); + expect(parseModules({ "agent:browser": module }, "agent").description).toContain( + "- `agent:browser`: A persistent Chrome browser", + ); + }); + it('drives the "shared" browser scope by default, a string, or the one a function picks', async () => { const browser = new FakeBrowser(); await startScope(createBrowserModule({ browser }), runScope(), hostWith()); diff --git a/packages/computer/src/modules/browser.ts b/packages/computer/src/modules/browser.ts index 3792930e..70243182 100644 --- a/packages/computer/src/modules/browser.ts +++ b/packages/computer/src/modules/browser.ts @@ -68,6 +68,19 @@ export interface BrowserModuleOptions { * gets this module's error rather than timing out. */ readonly lockTimeoutMs?: number; + /** + * The specifier the backend serves this module under, which its + * description tells code to import and its errors name. Defaults to + * `"codemode:browser"`. Set it when the module is served under another + * name, or under a backend's `scheme`, such as `"agent:browser"`. + */ + readonly specifier?: string; + /** + * The specifier of the module that serves the agent's tools, which the + * description tells code not to call the browser tool through. Defaults + * to `"codemode:tools"`. + */ + readonly toolsSpecifier?: string; } const DEFAULT_BROWSER_SCOPE = "shared"; @@ -75,13 +88,16 @@ const DEFAULT_LOCK_TIMEOUT_MS = 45_000; // A run that waited less than this for its turn isn't told about it. const NOTICEABLE_WAIT_MS = 1_000; -const WORKSPACE_RULES = [ - 'Import the module as a namespace, `import * as browser from "codemode:browser"`, and call `browser.({ ... })` as above.', +const DEFAULT_SPECIFIER = "codemode:browser"; +const DEFAULT_TOOLS_SPECIFIER = "codemode:tools"; + +const workspaceRules = (specifier: string, toolsSpecifier: string) => [ + `Import the module as a namespace, \`import * as browser from ${JSON.stringify(specifier)}\`, and call \`browser.({ ... })\` as above.`, "Notices about the browser (it restarted, your tab was closed, the page opened tabs) are written to stderr after the run ends.", 'To look at a page, save a screenshot and open the file with `read`: `const { data } = await browser.cdp({ method: "Page.captureScreenshot", sessionId: "active" })`, then `await fs.writeFile(path, Uint8Array.from(atob(data), (c) => c.charCodeAt(0)))` with `node:fs/promises`.', "One call returns at most 1 MiB, and a run's calls share 8 MiB each way, so take about eight screenshots per run at most, and print PDFs in a run of their own.", "Runs that use the browser take turns, even when started together: a run waits until the runs ahead of it end. To work on several pages, do it in one run: open a tab per page with Target.createTarget and drive them with Promise.all.", - "Don't call the browser tool from code through `codemode:tools`: it waits for this run's turn, which only ends when this run does.", + `Don't call the browser tool from code through \`${toolsSpecifier}\`: it waits for this run's turn, which only ends when this run does.`, ]; /** @@ -95,7 +111,8 @@ const WORKSPACE_RULES = [ * needs Workspace write access, since browsing can change things on the * sites the browser is logged in to. * - * @param options - The browser, the browser scope, and how long to wait for a turn. + * @param options - The browser, the browser scope, how long to wait for a + * turn, and the specifiers its description names. * @returns The module to pass as `modules["codemode:browser"]`. * @throws When `scope` is an empty string. */ @@ -103,12 +120,14 @@ export function createBrowserModule(options: BrowserModuleOptions): CodemodeModu const { browser } = options; const scope = options.scope ?? DEFAULT_BROWSER_SCOPE; const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS; - if (scope === "") throw new Error("codemode:browser: scope must not be empty."); + const specifier = options.specifier ?? DEFAULT_SPECIFIER; + const toolsSpecifier = options.toolsSpecifier ?? DEFAULT_TOOLS_SPECIFIER; + if (scope === "") throw new Error(`${specifier}: scope must not be empty.`); const browserScopeOf = ({ id, metadata }: WorkspaceModuleExecution): string => { const value = typeof scope === "function" ? scope({ id, metadata }) : scope; if (typeof value !== "string" || value === "") { throw new Error( - `codemode:browser: the scope function returned ${JSON.stringify(value)} for run ${id}; a browser scope must be a non-empty string.`, + `${specifier}: the scope function returned ${JSON.stringify(value)} for run ${id}; a browser scope must be a non-empty string.`, ); } return value; @@ -128,7 +147,7 @@ export function createBrowserModule(options: BrowserModuleOptions): CodemodeModu docs.functions, "", "Rules:", - ...[...docs.rules, ...WORKSPACE_RULES].map((rule) => `- ${rule}`), + ...[...docs.rules, ...workspaceRules(specifier, toolsSpecifier)].map((rule) => `- ${rule}`), ] .join("\n") .split("\n") @@ -151,7 +170,7 @@ export function createBrowserModule(options: BrowserModuleOptions): CodemodeModu "session", async (run: WorkspaceModuleScope, host: WorkspaceModuleHost) => { const browserScope = browserScopeOf(run); - await assertNotHeldByPausedRun(host, run, browserScope, pausedWithSession); + await assertNotHeldByPausedRun(specifier, host, run, browserScope, pausedWithSession); const session = browser.session({ scope: browserScope, lock: true }); const timeout = AbortSignal.timeout(lockTimeoutMs); @@ -161,7 +180,7 @@ export function createBrowserModule(options: BrowserModuleOptions): CodemodeModu await session.close(); if (timeout.aborted && !run.signal.aborted) { throw new Error( - `codemode:browser: gave up waiting for the browser after ${Math.round(lockTimeoutMs / 1000)}s: other runs in browser scope ${JSON.stringify(browserScope)} were using it. Runs that use the browser take turns, so try again once they finish.`, + `${specifier}: gave up waiting for the browser after ${Math.round(lockTimeoutMs / 1000)}s: other runs in browser scope ${JSON.stringify(browserScope)} were using it. Runs that use the browser take turns, so try again once they finish.`, ); } throw error; @@ -170,7 +189,7 @@ export function createBrowserModule(options: BrowserModuleOptions): CodemodeModu // this one waited. A run marks itself waiting before it releases // the lock, so checking again now can't miss it. try { - await assertNotHeldByPausedRun(host, run, browserScope, pausedWithSession); + await assertNotHeldByPausedRun(specifier, host, run, browserScope, pausedWithSession); } catch (error) { await session.close(); throw error; @@ -211,7 +230,7 @@ export function createBrowserModule(options: BrowserModuleOptions): CodemodeModu for (const name of exportNames) { functions[name] = async (args, ctx) => { if (ctx.access !== "read-write") { - throw new Error(`codemode:browser ${name} requires Workspace write access.`); + throw new Error(`${specifier} ${name} requires Workspace write access.`); } if (args.length > 1) { throw new TypeError(`browser.${name}(input) takes one input object.`); @@ -246,6 +265,7 @@ interface PausedClaim { // that reused the id, holds nothing. Claims for runs no longer waiting // are dropped here, since only a waiting run can hold a scope. async function assertNotHeldByPausedRun( + specifier: string, host: WorkspaceModuleHost, run: WorkspaceModuleScope, browserScope: string, @@ -261,7 +281,7 @@ async function assertNotHeldByPausedRun( const claim = pausedWithSession.get(call.id); if (claim?.browserScope === browserScope && call.since <= claim.closedAt) { throw new Error( - `codemode:browser: browser scope ${JSON.stringify(browserScope)} is held by a run that's waiting for a person (run ${call.id}). Try again once it has its answer.`, + `${specifier}: browser scope ${JSON.stringify(browserScope)} is held by a run that's waiting for a person (run ${call.id}). Try again once it has its answer.`, ); } } diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index 56dbfd6e..2c52a4f5 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -137,7 +137,8 @@ export interface WorkspaceModuleFactory { * with methods, and give its functions shared context, including state * for one scope of a run, such as a session the run opens and must close. * - * Host modules must use a `codemode:*` specifier. Source may use a simple one + * Host modules must use a `codemode:*` specifier, or one under the scheme + * the backend's `scheme` names instead. Source may use a simple one * too, for a library the host provides. */ export type WorkspaceModule =