Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/browser-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

Adds the experimental `ws:browser` host module (`@cloudflare/computer/modules/browser`), which lets isolate JavaScript drive a persistent browser over the Chrome DevTools Protocol. It takes anything with a `name` and a `session()`, such as `Browser` from `agents/browser`, and opens one session per run.
41 changes: 41 additions & 0 deletions docs/17_isolate_javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,47 @@ On a backend with `access: "read"`, only tools marked `readOnly` run. Every othe

The module's description lists the names code can import. With a function of tools, the function is called again each time the backend describes itself, so the `exec` tool, built after the Workspace, sees the current list. A list is read as given; any other iterable may be single-use, so it is not read for the description, and neither is a function that throws.

### `ws:browser`

> [!NOTE]
> **Experimental.**

```js
import { send, attachToTarget, spec, getDebugLog, clearDebugLog } from "ws:browser";
```

`createBrowserModule({ browser, scope?, maxResultBytes? })` from `@cloudflare/computer/modules/browser` lets JavaScript drive a persistent Chrome browser over the Chrome DevTools Protocol. The browser belongs to the host: the module takes anything with the `BrowserSource` shape, a `name` and a `session({ scope, maxResultBytes })` that starts a session for one run. `Browser` from `agents/browser` has that shape, so an agent passes its own browser, and neither package depends on the other:

```ts
import { Browser, browserRun } from "agents/browser";
import { createBrowserModule } from "@cloudflare/computer/modules/browser";

browser = new Browser({ provider: browserRun(this.env.BROWSER) });

new WorkerJavaScriptBackend({
loader: this.env.LOADER,
modules: { "ws:browser": createBrowserModule({ browser: this.browser }) },
});
```

```js
import { writeFile } from "node:fs/promises";
import { Buffer } from "node:buffer";
import { send } from "ws:browser";

export default async function () {
await send({ method: "Page.navigate", params: { url: "https://example.com" }, sessionId: "active" });
const { data } = await send({ method: "Page.captureScreenshot", sessionId: "active" });
await writeFile("/workspace/page.png", Buffer.from(data, "base64"));
}
```

`send({ method, params?, sessionId?, timeoutMs? })` returns a command's result without the JSON-RPC envelope. `sessionId: "active"` is the tab the scope is working in. The session remembers it between runs, and opens a new blank tab when the scope has none. `attachToTarget({ targetId })` makes another open tab active and returns a `sessionId` handle that keeps working in later runs. A session id from a raw `Target.attachToTarget` only lasts for one run. `Browser.close` and `Browser.crash` are refused, because the host decides when the browser ends.

A run's first call starts a session, and every call in the run goes to it. When the run ends, the module closes the session, which saves the scope's active tab and closes its connection. The browser stays open. If the session reports that the browser was replaced since the scope last used it, that another scope closed or shares this one's tab, or that the page opened tabs, the module says so on the run's standard error. The CDP rules above (the active tab, attach handles, refused commands, error hints) belong to the session, so `agents/browser`'s direct `createBrowserTool` behaves the same way. The module's description, which the `exec` tool shows, covers the calls and the CDP mistakes models commonly make.

`scope` keeps conversations that share one browser in their own tabs, while cookies and logins stay shared. A `Page.captureScreenshot` result over `maxResultBytes` (1,000,000 by default in `agents/browser`) fails with a note to capture the viewport or use JPEG. Keep it under the backend's `maxCapabilityBytes`.

### `ws:container`

`createContainerModule()` from `@cloudflare/computer/modules/container` lets JavaScript run shell commands in the Workspace's container backend. With it, JavaScript is the only backend the model sees, and the container is something that JavaScript can call:
Expand Down
2 changes: 2 additions & 0 deletions packages/computer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ removed in a future release; each one names its replacement.
| `LegacyContainerBackend` and `withLegacyWorkspaceContainer` | `@cloudflare/computer/backends/container-legacy` | Deprecated: use `ContainerBackend` |
| `WorkerShellBackend` and the shell command groups | `@cloudflare/computer/backends/worker-shell`, `@cloudflare/computer/shell/*` | Experimental |
| The `ws:container` host module | `@cloudflare/computer/modules/container` | Experimental |
| The `ws:browser` host module | `@cloudflare/computer/modules/browser` | Experimental |
| `workspace.push()`, `workspace.pull()`, and the sync protocol between the durable object and `computerd` | `@cloudflare/computer` | Experimental |
| The `computerd` daemon and its container image | `ghcr.io/cloudflare/computer-computerd-linux-x64` | Experimental |
| The RPC wire protocol between the durable object and `computerd` (`@cloudflare/computer-rpc`) | Internal | Experimental |
Expand Down Expand Up @@ -514,6 +515,7 @@ on a computerd instance.
| `@cloudflare/computer/modules/git` | `createGitModule()` for `ws:git`: confined Git from isolate JavaScript. |
| `@cloudflare/computer/modules/artifacts` | `createArtifactsModule()` for `ws:artifacts`: Artifacts and git tokens from isolate JavaScript. |
| `@cloudflare/computer/modules/assets` | `createAssetsModule()` for `ws:assets`: publish a Workspace file from isolate JavaScript. |
| `@cloudflare/computer/modules/browser` | `createBrowserModule()` for `ws:browser`: drive a persistent browser over the Chrome DevTools Protocol from isolate JavaScript. |
| `@cloudflare/computer/modules/tools` | `createToolBindings()` for `ws:tools`: an agent's own tools, callable from isolate JavaScript. |
| `@cloudflare/computer/tools` | AI SDK tools for agents: `read`, `ls`, `find`, `grep`, `write`, `edit`, `delete`, and optional `exec` and `publish`. |
| `@cloudflare/computer/tools/core` | `defineExec()`: the `exec` tool's description, schema, and executor, with no agent library. |
Expand Down
4 changes: 4 additions & 0 deletions packages/computer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
"types": "./dist/modules/assets.d.ts",
"import": "./dist/modules/assets.js"
},
"./modules/browser": {
"types": "./dist/modules/browser.d.ts",
"import": "./dist/modules/browser.js"
},
"./tools": {
"types": "./dist/tools/index.d.ts",
"import": "./dist/tools/index.js"
Expand Down
1 change: 1 addition & 0 deletions packages/computer/rolldown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default defineConfig({
"modules/artifacts": "src/modules/artifacts.ts",
"modules/tools": "src/modules/tools.ts",
"modules/assets": "src/modules/assets.ts",
"modules/browser": "src/modules/browser.ts",
"backends/container-legacy/index": "src/backends/container-legacy/index.ts",
"backends/container/index": "src/backends/container/index.ts",
"backends/worker-javascript/index": "src/backends/worker-javascript/index.ts",
Expand Down
216 changes: 216 additions & 0 deletions packages/computer/src/modules/browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, expect, it } from "vitest";

import type {
WorkspaceModuleCallContext,
WorkspaceModuleHost,
WorkspaceRuntimeValue,
} from "../runtime/types.js";
import {
createBrowserModule,
type BrowserModuleOptions,
type BrowserSession,
type BrowserSessionOptions,
type BrowserSessionReport,
type BrowserSource,
} from "./browser.js";

// SAFETY: The browser module never touches the host's Git, Artifacts, or runtime.
const host = {} as WorkspaceModuleHost;

interface FakeSession extends BrowserSession {
readonly options: BrowserSessionOptions | undefined;
readonly calls: Array<[string, unknown]>;
closed: number;
}

// Sessions that record their calls. The CDP behaviour behind a session is
// `agents/browser`'s, tested there; this module only routes calls to the
// run's session and closes it.
class FakeBrowser implements BrowserSource {
readonly name = "work";
readonly sessions: FakeSession[] = [];
report: BrowserSessionReport | undefined = {
restarted: false,
newTabs: [],
tabClosed: false,
tabShared: false,
};

session(options?: BrowserSessionOptions): BrowserSession {
const calls: Array<[string, unknown]> = [];
const record = (name: string) => async (input?: unknown) => {
calls.push([name, input]);
if (name === "send" && (input as { method?: string })?.method === "Browser.close") {
throw new Error("Browser.close is not available");
}
return { called: name, input: input ?? null };
};
const session: FakeSession = {
options,
calls,
closed: 0,
send: record("send"),
attachToTarget: record("attachToTarget"),
spec: record("spec"),
getDebugLog: record("getDebugLog"),
clearDebugLog: record("clearDebugLog"),
close: async () => {
session.closed += 1;
return this.report;
},
};
this.sessions.push(session);
return session;
}
}

function setup(options: Partial<BrowserModuleOptions> = {}) {
const browser = new FakeBrowser();
const module = createBrowserModule({ browser, ...options });
const functions = module(host);
let runs = 0;

function context(executionId: string, logs: string[], deferred: Array<() => unknown>) {
return {
executionId,
signal: new AbortController().signal,
deadline: Date.now() + 60_000,
access: "read-write",
resolvePath: async (path: string) => path,
log: (text: string) => logs.push(text),
defer: (fn: () => unknown) => deferred.push(fn),
} satisfies WorkspaceModuleCallContext;
}

/** Make `calls` as one run, then run what it deferred, as the backend does. */
async function run(calls: Array<[string, ...WorkspaceRuntimeValue[]]>) {
const logs: string[] = [];
const deferred: Array<() => unknown> = [];
const ctx = context(`run-${++runs}`, logs, deferred);
const results: unknown[] = [];
let error: unknown;
try {
for (const [name, ...args] of calls) {
results.push(await functions[name](args, ctx));
}
} catch (caught) {
error = caught;
}
while (deferred.length > 0) await deferred.pop()?.();
return { results, error, logs };
}

return { browser, module, functions, run, context };
}

describe("ws:browser", () => {
it("exports the browser calls and describes them for a model", () => {
const { functions, module } = setup();
expect(Object.keys(functions).sort()).toEqual([
"attachToTarget",
"clearDebugLog",
"getDebugLog",
"send",
"spec",
]);
expect(module.description).toContain("send({ method");
expect(module.description).toContain("standard error");
expect(module.description).not.toContain("cdp.");
});

it("starts one session per run and closes it when the run ends", async () => {
const t = setup();
const first = await t.run([
["send", { method: "Page.navigate", sessionId: "active" }],
["attachToTarget", { targetId: "target-1" }],
["spec"],
["getDebugLog", { limit: 5 }],
["clearDebugLog"],
]);
expect(first.error).toBeUndefined();
expect(first.results[0]).toEqual({
called: "send",
input: { method: "Page.navigate", sessionId: "active" },
});
expect(t.browser.sessions).toHaveLength(1);
expect(t.browser.sessions[0].calls.map(([name]) => name)).toEqual([
"send",
"attachToTarget",
"spec",
"getDebugLog",
"clearDebugLog",
]);
expect(t.browser.sessions[0].closed).toBe(1);

await t.run([["send", { method: "Target.getTargets" }]]);
expect(t.browser.sessions).toHaveLength(2);
expect(t.browser.sessions[1].closed).toBe(1);
});

it("shares one session between a run's concurrent calls", async () => {
const t = setup();
const logs: string[] = [];
const deferred: Array<() => unknown> = [];
const ctx = t.context("parallel", logs, deferred);
await Promise.all([
t.functions.send([{ method: "Target.getTargets" }], ctx),
t.functions.send([{ method: "Browser.getVersion" }], ctx),
]);
expect(t.browser.sessions).toHaveLength(1);
expect(deferred).toHaveLength(1);
});

it("passes the scope and screenshot limit to the session", async () => {
const t = setup({ scope: "thread-1", maxResultBytes: 500 });
await t.run([["spec"]]);
expect(t.browser.sessions[0].options).toEqual({ scope: "thread-1", maxResultBytes: 500 });

const plain = setup();
await plain.run([["spec"]]);
expect(plain.browser.sessions[0].options).toEqual({});
});

it("writes what happened to the browser to standard error", async () => {
const t = setup();
t.browser.report = {
restarted: true,
newTabs: [{ targetId: "target-3", url: "https://popup.example/" }],
tabClosed: true,
tabShared: true,
};
const { logs } = await t.run([["send", { method: "Target.getTargets" }]]);
expect(logs).toEqual([
expect.stringContaining("The browser was restarted before this run"),
expect.stringContaining(
"Another conversation using this browser is also working in your tab",
),
'New tabs opened by the page: [{"targetId":"target-3","url":"https://popup.example/"}]',
]);

t.browser.report = { restarted: false, newTabs: [], tabClosed: true, tabShared: false };
const closed = await t.run([["send", { method: "Target.getTargets" }]]);
expect(closed.logs).toEqual([
expect.stringContaining("The tab you were working in was closed"),
]);
});

it("passes the session's errors to the code and still closes it", async () => {
const t = setup();
const { error } = await t.run([["send", { method: "Browser.close" }]]);
expect(String(error)).toContain("Browser.close is not available");
expect(t.browser.sessions[0].closed).toBe(1);
});

it("refuses more than one argument", async () => {
const t = setup();
const { error } = await t.run([["send", { method: "Page.navigate" }, "extra"]]);
expect(String(error)).toContain("takes one options object");
});

it("leaves the browser alone for a run that never called it", async () => {
const t = setup();
const { logs } = await t.run([]);
expect(logs).toEqual([]);
expect(t.browser.sessions).toEqual([]);
});
});
Loading
Loading