@boardwalk-labs/runner 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,6 +38,24 @@ finishes, nothing new is claimed. Useful flags: `--once` (execute one run, then
38
38
  and `HTTPS_PROXY` set; the daemon and the run processes both honor it. Runs inherit this
39
39
  machine's network — a model or service reachable from the box is reachable from the run.
40
40
 
41
+ ### Browser tier (computer use)
42
+
43
+ `computer.openBrowser()` and `agent({ session })` drive an in-VM browser. On the hosted
44
+ runners the environment ships it; on a self-hosted machine you provide the pieces and point the
45
+ runner at them with a small env contract — the same contract the hosted image sets, so the code
46
+ path is identical:
47
+
48
+ | Variable | Meaning |
49
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
50
+ | `BOARDWALK_BROWSER_TIER=1` | Enable the per-run browser-session manager. Unset (the default) means `computer.openBrowser()` fails with a clear "not available on this runner". |
51
+ | `BOARDWALK_BROWSER_CHROME_PATH` | Path to a Chromium/Chrome binary the run launches with a CDP endpoint (headful, so it renders on a display). |
52
+ | `BOARDWALK_BROWSER_MCP_COMMAND` | Command to launch [Playwright MCP](https://github.com/microsoft/playwright-mcp) (e.g. a `playwright-mcp` bin, or `npx`). The runner attaches it to the browser over CDP. |
53
+ | `DISPLAY` | The X display the browser renders on (e.g. a headless `Xvfb :0`). |
54
+
55
+ So a self-hosted machine that wants the browser tier installs Chromium + Playwright MCP, runs a
56
+ display (e.g. `Xvfb`), and sets those variables. Nothing else changes; a machine without them
57
+ runs every non-browser workflow exactly as before.
58
+
41
59
  ## Security model
42
60
 
43
61
  This part of the contract is settled, even though the client isn't built yet:
@@ -0,0 +1,59 @@
1
+ import type { ArtifactRef, BrowserSession, BrowserSessionOptions, McpServerRef } from "@boardwalk-labs/workflow/runtime";
2
+ /** One MCP tool result content block (the subset we consume). */
3
+ export interface McpContentBlock {
4
+ type: string;
5
+ text?: string;
6
+ /** base64 for `type: "image"`. */
7
+ data?: string;
8
+ mimeType?: string;
9
+ }
10
+ export interface McpToolResult {
11
+ content: readonly McpContentBlock[];
12
+ isError: boolean;
13
+ }
14
+ /** A live MCP client bound to one session's Playwright MCP server (the PROGRAM's channel). */
15
+ export interface SessionMcpCaller {
16
+ callTool: (name: string, args: Record<string, unknown>) => Promise<McpToolResult>;
17
+ close: () => Promise<void>;
18
+ }
19
+ /** A launched browser process: the CDP endpoint + the Playwright MCP HTTP URL the agent binds to. */
20
+ export interface BrowserProcess {
21
+ /** e.g. `http://127.0.0.1:9222` (program-internal, never exposed). */
22
+ readonly cdpUrl: string;
23
+ /** e.g. `http://127.0.0.1:9333/mcp` — the agent's MCP endpoint. */
24
+ readonly mcpUrl: string;
25
+ /** Terminate Chromium + the Playwright MCP server. Idempotent. */
26
+ kill: () => Promise<void>;
27
+ }
28
+ /** Spawns Chromium (headful on the guest display) + a per-session Playwright MCP attached to it,
29
+ * with the arbitrary-JS tools (`browser_evaluate`, `browser_run_code_unsafe`) DISABLED for the
30
+ * agent — page-eval stays program-only. Production impl in browser_session_backend.ts. */
31
+ export interface BrowserBackend {
32
+ launch: (opts: BrowserSessionOptions | undefined) => Promise<BrowserProcess>;
33
+ }
34
+ /** Store a captured screenshot as a run artifact; returns its ref (the same store `artifacts.write` uses). */
35
+ export type ScreenshotArtifactWriter = (name: string, contentType: string, base64: string, metadata: Record<string, unknown>) => Promise<ArtifactRef>;
36
+ export interface BrowserSessionManagerDeps {
37
+ backend: BrowserBackend;
38
+ /** Open the PROGRAM's MCP client to a session's Playwright MCP HTTP URL. */
39
+ connect: (mcpUrl: string) => Promise<SessionMcpCaller>;
40
+ writeArtifact: ScreenshotArtifactWriter;
41
+ /** Monotonic session-id source (injected for determinism in tests). */
42
+ nextId: () => string;
43
+ }
44
+ /**
45
+ * Per-run manager of browser sessions. Opens them (spawn + connect + handle), resolves a session to
46
+ * its agent-facing MCP ref for `agent({ session })` binding, and reaps every open session at run end.
47
+ */
48
+ export declare class BrowserSessionManager {
49
+ private readonly deps;
50
+ private readonly sessions;
51
+ constructor(deps: BrowserSessionManagerDeps);
52
+ open(opts?: BrowserSessionOptions): Promise<BrowserSession>;
53
+ /** The agent-facing MCP ref for a session handle, or null if it isn't a live session of this run
54
+ * (e.g. already closed, or a foreign object). The host appends this to the leaf's `mcp`. */
55
+ mcpRefFor(session: BrowserSession): McpServerRef | null;
56
+ /** Tear down every still-open session. Called once when the run ends (best-effort per session). */
57
+ closeAll(): Promise<void>;
58
+ private reap;
59
+ }
@@ -0,0 +1,188 @@
1
+ // Browser-tier computer use — the runtime backing for `computer.openBrowser()` (the SDK surface)
2
+ // and `agent({ session })`. See docs/COMPUTER_USE_SESSION.md.
3
+ //
4
+ // A session is a program-owned, in-VM Chromium with a CDP endpoint, plus a per-session Playwright
5
+ // MCP HTTP server attached to that endpoint. The agent drives the browser through that MCP server
6
+ // (the ENGINE connects to it — binding a session is just adding its http MCP ref to the leaf's
7
+ // `mcp`, which passes assertHostedMcpAllowed unchanged since it's http + a valid localhost URL).
8
+ // The trusted PROGRAM drives/inspects the same browser through this module's own MCP client (the
9
+ // `BrowserSession` handle methods).
10
+ //
11
+ // The process spawn (Chromium + Playwright MCP, on the guest display) and the MCP client are behind
12
+ // injected seams (`BrowserBackend`, `SessionMcpCaller`) so the manager + handle logic is unit-tested
13
+ // without a guest; the production seams (browser_session_backend.ts) run against the guest image.
14
+ import { AppError, ErrorCode } from "./support/index.js";
15
+ /** Browser tools hidden from the AGENT: arbitrary page JS (`browser_evaluate`) and arbitrary
16
+ * Playwright driver code (`browser_run_code_unsafe`). The engine drops them from the agent's tool
17
+ * set (McpServerRef.excludeTools), while the trusted program keeps them via its own MCP client (the
18
+ * SessionMcpCaller connects to the same server directly, unfiltered) — so `session.eval()` still
19
+ * works and the model can't run arbitrary code. Least privilege; see docs/COMPUTER_USE_SESSION.md. */
20
+ const AGENT_EXCLUDED_BROWSER_TOOLS = ["browser_evaluate", "browser_run_code_unsafe"];
21
+ /**
22
+ * Per-run manager of browser sessions. Opens them (spawn + connect + handle), resolves a session to
23
+ * its agent-facing MCP ref for `agent({ session })` binding, and reaps every open session at run end.
24
+ */
25
+ export class BrowserSessionManager {
26
+ deps;
27
+ sessions = new Map();
28
+ constructor(deps) {
29
+ this.deps = deps;
30
+ }
31
+ async open(opts) {
32
+ const id = this.deps.nextId();
33
+ const proc = await this.deps.backend.launch(opts);
34
+ let caller;
35
+ try {
36
+ caller = await this.deps.connect(proc.mcpUrl);
37
+ }
38
+ catch (err) {
39
+ await proc.kill();
40
+ throw err;
41
+ }
42
+ const mcpRef = {
43
+ name: `browser-${id}`,
44
+ transport: "http",
45
+ url: proc.mcpUrl,
46
+ excludeTools: AGENT_EXCLUDED_BROWSER_TOOLS,
47
+ };
48
+ const handle = makeBrowserSessionHandle(id, caller, this.deps.writeArtifact, () => this.reap(id));
49
+ this.sessions.set(id, { handle, mcpRef, proc, caller });
50
+ return handle;
51
+ }
52
+ /** The agent-facing MCP ref for a session handle, or null if it isn't a live session of this run
53
+ * (e.g. already closed, or a foreign object). The host appends this to the leaf's `mcp`. */
54
+ mcpRefFor(session) {
55
+ return this.sessions.get(session.id)?.mcpRef ?? null;
56
+ }
57
+ /** Tear down every still-open session. Called once when the run ends (best-effort per session). */
58
+ async closeAll() {
59
+ const open = [...this.sessions.keys()];
60
+ await Promise.allSettled(open.map((id) => this.reap(id)));
61
+ }
62
+ async reap(id) {
63
+ const s = this.sessions.get(id);
64
+ if (s === undefined)
65
+ return;
66
+ this.sessions.delete(id);
67
+ // Close the client first (unblocks the server), then kill the processes. Both best-effort.
68
+ await Promise.allSettled([s.caller.close(), s.proc.kill()]);
69
+ }
70
+ }
71
+ /** Text of a tool result (all text blocks joined). */
72
+ function textOf(result) {
73
+ return result.content
74
+ .map((c) => (typeof c.text === "string" ? c.text : ""))
75
+ .filter((t) => t.length > 0)
76
+ .join("\n");
77
+ }
78
+ function assertOk(result, tool) {
79
+ if (result.isError) {
80
+ throw new AppError(ErrorCode.INTERNAL_ERROR, `browser ${tool} failed: ${textOf(result)}`, {
81
+ kind: "browser_tool_error",
82
+ tool,
83
+ });
84
+ }
85
+ return result;
86
+ }
87
+ /** Parse a single `Field: value` line out of a Playwright MCP `browser_snapshot` header. */
88
+ function fieldFromSnapshot(snapshot, field) {
89
+ const re = new RegExp(`^\\s*-?\\s*${field}:\\s*(.+)$`, "m");
90
+ return re.exec(snapshot)?.[1]?.trim() ?? "";
91
+ }
92
+ function makeBrowserSessionHandle(id, caller, writeArtifact, onClose) {
93
+ const call = (name, args = {}) => caller.callTool(name, args);
94
+ let closed = false;
95
+ const live = () => {
96
+ if (closed) {
97
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `browser session ${id} is closed`, {
98
+ kind: "browser_session_closed",
99
+ });
100
+ }
101
+ };
102
+ return {
103
+ id,
104
+ async navigate(url) {
105
+ live();
106
+ assertOk(await call("browser_navigate", { url }), "navigate");
107
+ },
108
+ async url() {
109
+ live();
110
+ return fieldFromSnapshot(textOf(assertOk(await call("browser_snapshot"), "snapshot")), "Page URL");
111
+ },
112
+ async title() {
113
+ live();
114
+ return fieldFromSnapshot(textOf(assertOk(await call("browser_snapshot"), "snapshot")), "Page Title");
115
+ },
116
+ async screenshot(opts) {
117
+ live();
118
+ const res = assertOk(await call("browser_take_screenshot", { fullPage: opts?.fullPage ?? false }), "screenshot");
119
+ const image = res.content.find((c) => c.type === "image" && typeof c.data === "string");
120
+ if (image?.data === undefined) {
121
+ throw new AppError(ErrorCode.INTERNAL_ERROR, "browser screenshot returned no image", {
122
+ kind: "browser_screenshot_no_image",
123
+ });
124
+ }
125
+ return await writeArtifact(`screenshot-${id}-${Date.now().toString(36)}.png`, image.mimeType ?? "image/png", image.data, { kind: "screenshot", session_id: id });
126
+ },
127
+ async console() {
128
+ live();
129
+ return parseConsole(textOf(assertOk(await call("browser_console_messages"), "console")));
130
+ },
131
+ async network() {
132
+ live();
133
+ return parseNetwork(textOf(assertOk(await call("browser_network_requests"), "network")));
134
+ },
135
+ async eval(expression) {
136
+ live();
137
+ // Playwright MCP's browser_evaluate takes a `function` (a JS arrow) — wrap the expression.
138
+ const res = assertOk(await call("browser_evaluate", { function: `() => (${expression})` }), "eval");
139
+ const text = textOf(res).trim();
140
+ try {
141
+ return JSON.parse(text);
142
+ }
143
+ catch {
144
+ return text;
145
+ }
146
+ },
147
+ async close() {
148
+ if (closed)
149
+ return;
150
+ closed = true;
151
+ await onClose();
152
+ },
153
+ };
154
+ }
155
+ const CONSOLE_LINE = /^\s*\[?(LOG|INFO|WARN(?:ING)?|ERROR|DEBUG)\]?\s*[:-]?\s*(.*)$/i;
156
+ function parseConsole(text) {
157
+ const out = [];
158
+ for (const line of text.split("\n")) {
159
+ if (line.trim().length === 0)
160
+ continue;
161
+ const m = CONSOLE_LINE.exec(line);
162
+ const level = (m?.[1] ?? "log").toLowerCase();
163
+ out.push({
164
+ level: level.startsWith("warn")
165
+ ? "warn"
166
+ : (["log", "info", "error", "debug"].includes(level)
167
+ ? level
168
+ : "log"),
169
+ text: m?.[2] ?? line.trim(),
170
+ timestamp: 0,
171
+ });
172
+ }
173
+ return out;
174
+ }
175
+ const NETWORK_LINE = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b\s+(\S+)(?:.*?\b(\d{3})\b)?/;
176
+ function parseNetwork(text) {
177
+ const out = [];
178
+ for (const line of text.split("\n")) {
179
+ const m = NETWORK_LINE.exec(line);
180
+ if (m === null)
181
+ continue;
182
+ const entry = { method: m[1], url: m[2], timestamp: 0 };
183
+ if (m[3] !== undefined)
184
+ entry.status = Number(m[3]);
185
+ out.push(entry);
186
+ }
187
+ return out;
188
+ }
@@ -0,0 +1,44 @@
1
+ import type { BrowserBackend, McpContentBlock, SessionMcpCaller } from "./browser_session.js";
2
+ /**
3
+ * The guest-image contract for the browser tier — the values the runner-images rootfs sets so this
4
+ * backend can launch Chromium + Playwright MCP without any network fetch (the run's egress is proxied,
5
+ * so a runtime `npx` download would fail). All but `chromePath` have safe defaults.
6
+ */
7
+ export interface GuestBrowserConfig {
8
+ /** Absolute path to the Chromium/Chrome binary the image ships (BOARDWALK_BROWSER_CHROME_PATH). */
9
+ chromePath: string;
10
+ /** X display Chromium renders on (headful, so the desktop tier can mirror it). DISPLAY, default ":0". */
11
+ display: string;
12
+ /** How to launch the PRE-INSTALLED Playwright MCP. The per-session flags (--port/--cdp-endpoint/
13
+ * --config) are appended. Default runs the pinned package via npx with fetching disabled. */
14
+ mcpCommand: string;
15
+ mcpBaseArgs: readonly string[];
16
+ /** Milliseconds to wait for Chromium's CDP endpoint / the MCP server to answer before failing. */
17
+ readyTimeoutMs: number;
18
+ }
19
+ /** True when the runner image declares the browser stack present (BOARDWALK_BROWSER_TIER=1). */
20
+ export declare function browserTierEnabled(env: NodeJS.ProcessEnv): boolean;
21
+ /** Read the guest browser config from env, or null when the tier is disabled / no Chrome path is set. */
22
+ export declare function loadGuestBrowserConfig(env: NodeJS.ProcessEnv): GuestBrowserConfig | null;
23
+ /** Bind an ephemeral port, read it, release it — then hand it to the child. A tiny TOCTOU window, but
24
+ * each run owns its VM/container so nothing else competes for the loopback port. */
25
+ export declare function freePort(): Promise<number>;
26
+ /** Poll an HTTP URL until it answers (any status < 500 counts as "up" — Playwright MCP's localhost
27
+ * guard returns 403 to a bare GET, which still proves the server is listening). */
28
+ export declare function waitForHttp(url: string, timeoutMs: number): Promise<void>;
29
+ /**
30
+ * Build the production BrowserBackend. Each `launch` allocates two loopback ports, spawns a
31
+ * program-owned Chromium (CDP endpoint, headful on the guest display, isolated profile) and a
32
+ * per-session Playwright MCP HTTP server attached to it, waits for both to answer, and returns the
33
+ * handle the manager wraps. On any failure it tears down everything it started (no leaked processes /
34
+ * temp profiles).
35
+ */
36
+ export declare function makeGuestBrowserBackend(cfg: GuestBrowserConfig): BrowserBackend;
37
+ /** Adapt one MCP tool-result content block (the SDK's loose shape) to the subset browser_session.ts reads. */
38
+ export declare function toContentBlock(block: unknown): McpContentBlock;
39
+ /**
40
+ * The program's MCP client factory: open a StreamableHTTP client to a session's Playwright MCP server
41
+ * (the trusted PROGRAM's channel, distinct from the engine's separate connection the AGENT uses). Wraps
42
+ * the SDK Client behind the manager's `SessionMcpCaller` seam. `mcpUrl` must be the `localhost` form.
43
+ */
44
+ export declare function connectSessionMcp(mcpUrl: string): Promise<SessionMcpCaller>;
@@ -0,0 +1,221 @@
1
+ // Production seams for the browser tier — the guest-coupled halves of browser_session.ts's injected
2
+ // contracts: the `BrowserBackend` (spawn a program-owned CDP Chromium on the guest display + a
3
+ // per-session Playwright MCP HTTP server attached to it) and the program's MCP client factory
4
+ // (`connectSessionMcp`). See docs/COMPUTER_USE_SESSION.md.
5
+ //
6
+ // These only run where the runner IMAGE ships the browser stack (Chromium + a pre-installed
7
+ // Playwright MCP + an X display). The composition root gates on `browserTierEnabled(env)` and only
8
+ // constructs a BrowserSessionManager with this backend when the image opts in — elsewhere
9
+ // `computer.openBrowser()` fails with a clear "not available on this runner image".
10
+ //
11
+ // Config proven against Playwright MCP 0.0.77 (spike, 2026-07-09):
12
+ // - HTTP mode (`--port`) + `--cdp-endpoint` attach + a StreamableHTTP client all work.
13
+ // - The agent's + the program's MCP URL MUST use `localhost` (not 127.0.0.1): the `/mcp` endpoint
14
+ // enforces a literal `localhost` Host as DNS-rebinding protection, and `--allowed-hosts '*'`
15
+ // does NOT lift it. So `mcpUrl` is built with `localhost`; the engine binds the same ref.
16
+ // - `capabilities:['core']` does NOT drop `browser_evaluate` / `browser_run_code_unsafe` — they
17
+ // are core tools. Excluding them from the AGENT (they stay available to the trusted program's
18
+ // `eval()`) is an engine-level `excludeTools` follow-up, tracked in COMPUTER_USE_SESSION.md.
19
+ import { spawn } from "node:child_process";
20
+ import { createServer } from "node:net";
21
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { setTimeout as delay } from "node:timers/promises";
25
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
26
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
27
+ import { AppError, ErrorCode, createLogger } from "./support/index.js";
28
+ const log = createLogger("browser_backend");
29
+ const DEFAULT_MCP_PACKAGE = "@playwright/mcp@0.0.77";
30
+ /** True when the runner image declares the browser stack present (BOARDWALK_BROWSER_TIER=1). */
31
+ export function browserTierEnabled(env) {
32
+ return env.BOARDWALK_BROWSER_TIER === "1";
33
+ }
34
+ /** Read the guest browser config from env, or null when the tier is disabled / no Chrome path is set. */
35
+ export function loadGuestBrowserConfig(env) {
36
+ if (!browserTierEnabled(env))
37
+ return null;
38
+ const chromePath = env.BOARDWALK_BROWSER_CHROME_PATH?.trim();
39
+ if (chromePath === undefined || chromePath.length === 0) {
40
+ log.warn("browser_tier_missing_chrome_path");
41
+ return null;
42
+ }
43
+ // A custom command (a direct bin) takes no npx prefix; the default is `npx --no-install <pkg>`.
44
+ const command = env.BOARDWALK_BROWSER_MCP_COMMAND?.trim();
45
+ const baseArgs = command === undefined || command.length === 0
46
+ ? ["--yes", "--no-install", env.BOARDWALK_BROWSER_MCP_PACKAGE?.trim() || DEFAULT_MCP_PACKAGE]
47
+ : (env.BOARDWALK_BROWSER_MCP_ARGS ?? "").split(/\s+/).filter((a) => a.length > 0);
48
+ const timeout = Number(env.BOARDWALK_BROWSER_READY_TIMEOUT_MS);
49
+ return {
50
+ chromePath,
51
+ display: env.DISPLAY?.trim() || ":0",
52
+ mcpCommand: command !== undefined && command.length > 0 ? command : "npx",
53
+ mcpBaseArgs: baseArgs,
54
+ readyTimeoutMs: Number.isFinite(timeout) && timeout > 0 ? timeout : 30_000,
55
+ };
56
+ }
57
+ /** Bind an ephemeral port, read it, release it — then hand it to the child. A tiny TOCTOU window, but
58
+ * each run owns its VM/container so nothing else competes for the loopback port. */
59
+ export async function freePort() {
60
+ return await new Promise((resolve, reject) => {
61
+ const srv = createServer();
62
+ srv.once("error", reject);
63
+ srv.listen(0, "127.0.0.1", () => {
64
+ const addr = srv.address();
65
+ if (addr === null || typeof addr === "string") {
66
+ srv.close(() => reject(new Error("could not allocate a port")));
67
+ return;
68
+ }
69
+ const { port } = addr;
70
+ srv.close(() => resolve(port));
71
+ });
72
+ });
73
+ }
74
+ /** Poll an HTTP URL until it answers (any status < 500 counts as "up" — Playwright MCP's localhost
75
+ * guard returns 403 to a bare GET, which still proves the server is listening). */
76
+ export async function waitForHttp(url, timeoutMs) {
77
+ const deadline = Date.now() + timeoutMs;
78
+ let lastErr = "no response";
79
+ while (Date.now() < deadline) {
80
+ try {
81
+ const res = await fetch(url);
82
+ if (res.status < 500)
83
+ return;
84
+ lastErr = `status ${String(res.status)}`;
85
+ }
86
+ catch (err) {
87
+ lastErr = err instanceof Error ? err.message : String(err);
88
+ }
89
+ await delay(200);
90
+ }
91
+ throw new AppError(ErrorCode.INTERNAL_ERROR, `browser backend: ${url} never came up (${lastErr})`, {
92
+ kind: "browser_backend_timeout",
93
+ });
94
+ }
95
+ function killProc(proc) {
96
+ try {
97
+ if (proc.exitCode === null && proc.signalCode === null)
98
+ proc.kill("SIGKILL");
99
+ }
100
+ catch {
101
+ // best-effort: a dead child must not throw out of teardown.
102
+ }
103
+ }
104
+ /**
105
+ * Build the production BrowserBackend. Each `launch` allocates two loopback ports, spawns a
106
+ * program-owned Chromium (CDP endpoint, headful on the guest display, isolated profile) and a
107
+ * per-session Playwright MCP HTTP server attached to it, waits for both to answer, and returns the
108
+ * handle the manager wraps. On any failure it tears down everything it started (no leaked processes /
109
+ * temp profiles).
110
+ */
111
+ export function makeGuestBrowserBackend(cfg) {
112
+ return {
113
+ async launch(opts) {
114
+ const cdpPort = await freePort();
115
+ const mcpPort = await freePort();
116
+ const profileDir = await mkdtemp(join(tmpdir(), "bw-browser-"));
117
+ const cdpUrl = `http://127.0.0.1:${String(cdpPort)}`;
118
+ // localhost (NOT 127.0.0.1) — the Playwright MCP /mcp endpoint rejects any other Host (spike).
119
+ const mcpUrl = `http://localhost:${String(mcpPort)}/mcp`;
120
+ const started = [];
121
+ const cleanup = async () => {
122
+ for (const p of started)
123
+ killProc(p);
124
+ await rm(profileDir, { recursive: true, force: true }).catch(() => undefined);
125
+ };
126
+ try {
127
+ // 1) Program-owned Chromium with a CDP endpoint, headful on the guest display so the desktop
128
+ // tier + recording can mirror it. The isolated profile dir is this session's alone.
129
+ const chrome = spawn(cfg.chromePath, [
130
+ `--remote-debugging-port=${String(cdpPort)}`,
131
+ `--user-data-dir=${profileDir}`,
132
+ "--no-first-run",
133
+ "--no-default-browser-check",
134
+ "--disable-features=Translate",
135
+ ...(opts?.startUrl !== undefined ? [opts.startUrl] : ["about:blank"]),
136
+ ], { stdio: "ignore", env: { ...process.env, DISPLAY: cfg.display } });
137
+ started.push(chrome);
138
+ chrome.once("error", (err) => {
139
+ log.error("browser_chrome_spawn_error", { error: err.message });
140
+ });
141
+ await waitForHttp(`${cdpUrl}/json/version`, cfg.readyTimeoutMs);
142
+ // 2) Per-session Playwright MCP in HTTP mode, attached to the CDP browser. `sharedBrowserContext`
143
+ // so the program's client and the agent's engine connection drive the SAME browser context.
144
+ const configPath = join(profileDir, "pw-mcp.json");
145
+ await writeFile(configPath, JSON.stringify({ capabilities: ["core"], sharedBrowserContext: true }));
146
+ const mcp = spawn(cfg.mcpCommand, [
147
+ ...cfg.mcpBaseArgs,
148
+ "--port",
149
+ String(mcpPort),
150
+ "--host",
151
+ "127.0.0.1",
152
+ // Only loopback clients exist inside the VM; disable the DNS-rebinding host list (the
153
+ // literal-localhost guard on /mcp still applies, which is why mcpUrl uses `localhost`).
154
+ "--allowed-hosts",
155
+ "*",
156
+ "--cdp-endpoint",
157
+ cdpUrl,
158
+ "--config",
159
+ configPath,
160
+ ], { stdio: ["ignore", "ignore", "inherit"], env: { ...process.env, DISPLAY: cfg.display } });
161
+ started.push(mcp);
162
+ mcp.once("error", (err) => {
163
+ log.error("browser_mcp_spawn_error", { error: err.message });
164
+ });
165
+ await waitForHttp(`http://127.0.0.1:${String(mcpPort)}/mcp`, cfg.readyTimeoutMs);
166
+ let killed = false;
167
+ return {
168
+ cdpUrl,
169
+ mcpUrl,
170
+ kill: async () => {
171
+ if (killed)
172
+ return;
173
+ killed = true;
174
+ await cleanup();
175
+ },
176
+ };
177
+ }
178
+ catch (err) {
179
+ await cleanup();
180
+ throw err;
181
+ }
182
+ },
183
+ };
184
+ }
185
+ /** Adapt one MCP tool-result content block (the SDK's loose shape) to the subset browser_session.ts reads. */
186
+ export function toContentBlock(block) {
187
+ if (block === null || typeof block !== "object")
188
+ return { type: "unknown" };
189
+ const b = block;
190
+ const out = { type: typeof b.type === "string" ? b.type : "unknown" };
191
+ if (typeof b.text === "string")
192
+ out.text = b.text;
193
+ if (typeof b.data === "string")
194
+ out.data = b.data;
195
+ if (typeof b.mimeType === "string")
196
+ out.mimeType = b.mimeType;
197
+ return out;
198
+ }
199
+ /**
200
+ * The program's MCP client factory: open a StreamableHTTP client to a session's Playwright MCP server
201
+ * (the trusted PROGRAM's channel, distinct from the engine's separate connection the AGENT uses). Wraps
202
+ * the SDK Client behind the manager's `SessionMcpCaller` seam. `mcpUrl` must be the `localhost` form.
203
+ */
204
+ export async function connectSessionMcp(mcpUrl) {
205
+ const client = new Client({ name: "boardwalk-runner", version: "1" });
206
+ // The SDK's concrete transport types `sessionId` as `string | undefined`, which trips our
207
+ // exactOptionalPropertyTypes against the `Transport` interface's optional `sessionId?`. Cast at
208
+ // this one seam — a pure type-strictness artifact, not a runtime mismatch.
209
+ const transport = new StreamableHTTPClientTransport(new URL(mcpUrl));
210
+ await client.connect(transport);
211
+ return {
212
+ callTool: async (name, args) => {
213
+ const result = await client.callTool({ name, arguments: args });
214
+ const content = Array.isArray(result.content) ? result.content.map(toContentBlock) : [];
215
+ return { content, isError: result.isError === true };
216
+ },
217
+ close: async () => {
218
+ await client.close();
219
+ },
220
+ };
221
+ }
@@ -2,6 +2,7 @@ import type { AuthContext } from "./support/index.js";
2
2
  import type { Run } from "./wire/run.js";
3
3
  import { type IdentityRelay } from "./identity_relay.js";
4
4
  import type { ByoInferenceProvider } from "../contract.js";
5
+ import { type BrowserBackend } from "./browser_session.js";
5
6
  import { type ProgramWorkerDeps } from "./program_worker.js";
6
7
  /** Constructed primitives the pure assembly consumes (real ones in main(), fakes in tests). The
7
8
  * runner holds NO platform credential — only the per-run control-plane handle (run token + broker
@@ -32,6 +33,10 @@ export interface WorkerRuntime {
32
33
  * boot). When set, the worker suspends by FREEZING — the FreezeCoordinator parks seams over this
33
34
  * relay's suspend/wake channel — instead of the exit-and-replay path. */
34
35
  freezeRelay?: IdentityRelay;
36
+ /** The browser tier's process backend, present ONLY when the runner IMAGE ships the browser stack
37
+ * (Chromium + a pre-installed Playwright MCP + an X display; gated by BOARDWALK_BROWSER_TIER).
38
+ * When absent, `computer.openBrowser()` fails with a clear "not available on this runner image". */
39
+ browserBackend?: BrowserBackend;
35
40
  }
36
41
  /** Durable events are deferred (durable event storage) — live fan-out via the broker only. */
37
42
  /** Synthesize the run's AuthContext. The run was already authorized at trigger time; the
@@ -39,6 +39,8 @@ import { applyIdentityToEnv, connectIdentityRelayFd, relayFdFromEnv, workerDiagn
39
39
  import { FreezeCoordinator } from "./freeze_coordinator.js";
40
40
  import { reseedUserspaceCsprng } from "./uniqueness_reseed.js";
41
41
  import { WorkerWorkflowHost } from "./workflow_host.js";
42
+ import { BrowserSessionManager } from "./browser_session.js";
43
+ import { loadGuestBrowserConfig, makeGuestBrowserBackend, connectSessionMcp, } from "./browser_session_backend.js";
42
44
  import { runProgramWorker, } from "./program_worker.js";
43
45
  import { RunnerControlClient } from "./runner_control_client.js";
44
46
  import { BrokerChildDispatcher } from "./broker_child_dispatcher.js";
@@ -174,6 +176,26 @@ export function assembleWorkerDeps(runtime) {
174
176
  // Broker-backed artifact store (shared by the program's artifacts.write hook AND the engine's
175
177
  // host-backed `artifacts` tool). Presigned for large bodies; the runner holds no S3 creds.
176
178
  const artifactStore = new BrokerArtifactStore(broker);
179
+ // Browser tier (browser-session computer use): a per-run manager over the image's process backend.
180
+ // Only present when the runner image ships the browser stack (runtime.browserBackend set); else
181
+ // `computer.openBrowser()` throws "not available". A captured screenshot is stored as a run artifact
182
+ // through the SAME broker store — decoded from the MCP image block's base64 as binary bytes.
183
+ const browserSessions = runtime.browserBackend !== undefined
184
+ ? new BrowserSessionManager({
185
+ backend: runtime.browserBackend,
186
+ connect: connectSessionMcp,
187
+ writeArtifact: (name, contentType, base64, metadata) => artifactStore
188
+ .write({
189
+ name,
190
+ contentType,
191
+ body: base64,
192
+ encoding: "base64",
193
+ ...(metadata !== undefined ? { metadata } : {}),
194
+ })
195
+ .then((res) => ({ id: res.id, name: res.name, url: res.signedUrl })),
196
+ nextId: () => newId(),
197
+ })
198
+ : undefined;
177
199
  // Backend for the engine's host-backed built-in tools (webfetch/web_search/artifacts): web_search
178
200
  // + artifact read-back go through the broker (no Tavily/S3 creds here); webfetch is an in-process
179
201
  // fetch already gated by the worker's egress proxy.
@@ -323,6 +345,9 @@ export function assembleWorkerDeps(runtime) {
323
345
  },
324
346
  }
325
347
  : {}),
348
+ // Browser tier: `computer.openBrowser()` + `agent({ session })` resolve through this manager
349
+ // (absent ⇒ the host throws "not available on this runner image").
350
+ ...(browserSessions !== undefined ? { browserSessions } : {}),
326
351
  phases: phaseTracker,
327
352
  });
328
353
  // Late-bind the coordinator's per-run hooks now that the run-scoped objects exist.
@@ -379,6 +404,9 @@ export function assembleWorkerDeps(runtime) {
379
404
  // Resolves when a host seam suspends the run; the program runner races it against the body.
380
405
  suspendSignal,
381
406
  ...(workspaceStore !== undefined ? { workspace: workspaceStore } : {}),
407
+ // The orchestrator reaps every still-open browser session on terminal (kill Chromium + its
408
+ // Playwright MCP) so no browser process leaks past the run.
409
+ ...(browserSessions !== undefined ? { browserSessions } : {}),
382
410
  });
383
411
  };
384
412
  return {
@@ -585,6 +613,11 @@ export async function main() {
585
613
  const runId = platform.runId;
586
614
  const byoProviders = parseByoProviders(process.env.BOARDWALK_BYO_PROVIDERS);
587
615
  Reflect.deleteProperty(process.env, "BOARDWALK_BYO_PROVIDERS");
616
+ // Browser tier: only when the runner IMAGE declares the browser stack (BOARDWALK_BROWSER_TIER=1 +
617
+ // a Chrome path). The backend is run-independent (buildHost builds the per-run manager over it);
618
+ // absent ⇒ `computer.openBrowser()` fails clearly. Read here so env-reading stays out of the pure wiring.
619
+ const guestBrowserConfig = loadGuestBrowserConfig(process.env);
620
+ const browserBackend = guestBrowserConfig !== null ? makeGuestBrowserBackend(guestBrowserConfig) : undefined;
588
621
  const deps = assembleWorkerDeps({
589
622
  workerId: process.env.WORKER_ID ?? `worker-${runId}`,
590
623
  workspaceRoot: process.env.WORKSPACE_ROOT ?? "/workspace",
@@ -593,6 +626,7 @@ export async function main() {
593
626
  vcpus: platform.vcpus,
594
627
  ...(byoProviders.length > 0 ? { byoProviders } : {}),
595
628
  ...(freezeRelay !== undefined ? { freezeRelay } : {}),
629
+ ...(browserBackend !== undefined ? { browserBackend } : {}),
596
630
  });
597
631
  // The only thing to drain is the batched telemetry buffer — the runner opens no database, cache, or queue.
598
632
  const cleanup = async () => {
@@ -97,6 +97,12 @@ export type ProgramHostBuilder = (run: Run, manifest: WorkflowManifest, signal:
97
97
  * program runner so a suspend short-circuits the body out of band (the durable-suspension design). Absent ⇒
98
98
  * no durable suspension on this path (a suspend then surfaces as a thrown SuspendError). */
99
99
  suspendSignal?: Promise<SuspendSignal>;
100
+ /** The run's browser-session manager (browser tier). The orchestrator reaps every still-open session
101
+ * on EVERY terminal path so no Chromium / Playwright MCP process leaks past the run. `closeAll` is
102
+ * best-effort + never throws. Absent on images without the browser stack. */
103
+ browserSessions?: {
104
+ closeAll(): Promise<void>;
105
+ };
100
106
  }>;
101
107
  /** Handle to a running per-session loop (metering or credit watch); `stop()` ends + drains it. */
102
108
  export interface RunSessionHandle {
@@ -111,6 +111,7 @@ export async function runProgramWorker(runId, deps) {
111
111
  return { kind: "failed", reason: "host_build_failed" };
112
112
  }
113
113
  const { host, redactor, workspace, phases, activity, setProgramDir, lsp, suspendSignal } = built;
114
+ const browserSessions = built.browserSessions;
114
115
  // Guarantee the /workspace sandbox dir exists for EVERY run (persist or not) so a program can write
115
116
  // to /workspace without a defensive mkdir. Runs before hydrate (whose extract targets the dir).
116
117
  // Best-effort — the image pre-creates the dir; a failure here would resurface at the program's write.
@@ -208,6 +209,16 @@ export async function runProgramWorker(runId, deps) {
208
209
  // is safe (it runs before the workspace snapshot, which is the slowest step).
209
210
  if (lsp !== undefined)
210
211
  await lsp.close();
212
+ // Reap every still-open browser session (kill Chromium + its Playwright MCP) on EVERY terminal
213
+ // path, before the workspace snapshot. Best-effort — a dead session must not mask the run's outcome.
214
+ if (browserSessions !== undefined) {
215
+ await browserSessions.closeAll().catch((err) => {
216
+ log.warn("browser_sessions_close_failed", {
217
+ runId,
218
+ error: err instanceof Error ? err.message : String(err),
219
+ });
220
+ });
221
+ }
211
222
  // Snapshot the final /workspace so the workflow's NEXT run hydrates it. Best-effort.
212
223
  if (workspace !== undefined) {
213
224
  await workspace.persist();
@@ -47,8 +47,9 @@ export type InferenceFrame = {
47
47
  } | {
48
48
  kind: "ping";
49
49
  };
50
- /** Serialize the request body. (Binary message content multimodal image/doc bytes — is not yet
51
- * supported on this path; v0 agent flows are text + JSON tool results.) */
50
+ /** Serialize the request body. Multimodal image content rides transparently: message content parts
51
+ * (incl. `{ type: "image" }`) are plain JSON here the broker's engine adapters render them per
52
+ * provider (@boardwalk-labs/engine ≥ 0.1.32). */
52
53
  export declare function serializeInferenceRequest(req: InferenceProxyRequest): string;
53
54
  /** Parse + minimally validate the request body (the runner is semi-trusted; fail closed on shape).
54
55
  * The conversation is shape-trusted past the array check — it was built by the engine loop on the
@@ -31,8 +31,9 @@ export function runnerInferencePath(runId) {
31
31
  /** Response content type — newline-delimited JSON frames. */
32
32
  export const INFERENCE_NDJSON_CONTENT_TYPE = "application/x-ndjson";
33
33
  // ---- request serialize / parse ----
34
- /** Serialize the request body. (Binary message content multimodal image/doc bytes — is not yet
35
- * supported on this path; v0 agent flows are text + JSON tool results.) */
34
+ /** Serialize the request body. Multimodal image content rides transparently: message content parts
35
+ * (incl. `{ type: "image" }`) are plain JSON here the broker's engine adapters render them per
36
+ * provider (@boardwalk-labs/engine ≥ 0.1.32). */
36
37
  export function serializeInferenceRequest(req) {
37
38
  return JSON.stringify(req);
38
39
  }
@@ -1,4 +1,5 @@
1
- import type { WorkflowHost, AgentOptions, ArtifactBody, ArtifactRef, CallOptions, HumanInputOptions, HumanInputResult, PhaseOptions, SleepArg } from "@boardwalk-labs/workflow/runtime";
1
+ import type { WorkflowHost, AgentOptions, ArtifactBody, ArtifactRef, BrowserSession, BrowserSessionOptions, CallOptions, HumanInputOptions, HumanInputResult, PhaseOptions, SleepArg } from "@boardwalk-labs/workflow/runtime";
2
+ import type { BrowserSessionManager } from "./browser_session.js";
2
3
  import { type LeafResume } from "@boardwalk-labs/engine/core";
3
4
  import { type JournalSeam, type SuspendSignal } from "./suspension.js";
4
5
  import type { FreezeCoordinator } from "./freeze_coordinator.js";
@@ -103,6 +104,10 @@ export interface WorkerWorkflowHostDeps {
103
104
  /** Persists a file artifact for the run (→ broker artifact store); resolves to its id + signed
104
105
  * download URL. Absent ⇒ artifacts.write is unsupported and the host method rejects clearly. */
105
106
  writeArtifact?: (name: string, contentType: string, body: ArtifactBody, metadata: Record<string, unknown> | undefined) => Promise<ArtifactRef>;
107
+ /** Per-run browser-session manager (computer use, the browser tier). Absent ⇒ `computer.openBrowser`
108
+ * is unsupported (no desktop/browser backend) and the host method rejects clearly. When present,
109
+ * `agent({ session })` binds the session's in-VM Playwright MCP to the leaf. */
110
+ browserSessions?: BrowserSessionManager;
106
111
  /** Cooperative-cancellation signal for the run (credit exhaustion today; user cancel later). Every
107
112
  * hook checks it at entry and unwinds (throws RunAbortedError); the spending/blocking hooks
108
113
  * (`agent`/`sleep`/`callWorkflow`) thread it down so an in-flight op stops promptly. Absent ⇒ no
@@ -233,6 +238,15 @@ export declare class WorkerWorkflowHost implements WorkflowHost {
233
238
  scheduleWorkflow(slug: string, input: unknown, opts: ScheduleOptions): Promise<string>;
234
239
  getSecret(name: string): Promise<string>;
235
240
  writeArtifact(name: string, contentType: string, body: ArtifactBody, metadata: Record<string, unknown> | undefined): Promise<ArtifactRef>;
241
+ /** `computer.openBrowser()`: open a program-owned, in-VM browser session (the browser tier of
242
+ * computer use). Not a durable seam — a session is a live resource, reaped at run end, never
243
+ * journaled/replayed. Absent backend ⇒ a clear "not available" error. */
244
+ openBrowserSession(opts: BrowserSessionOptions | undefined): Promise<BrowserSession>;
245
+ /** Translate `agent({ session })` into the leaf's `mcp`: append the browser session's in-VM
246
+ * Playwright MCP (its http ref, which passes assertHostedMcpAllowed and reaches localhost without
247
+ * the egress proxy) and strip the `session` handle (the engine doesn't understand it). No-op when
248
+ * no session is bound. */
249
+ private bindBrowserSession;
236
250
  sleep(arg: SleepArg): Promise<void>;
237
251
  /** A short sleep HOLDS the task in-process (cheaper than a release + replay round-trip); a long one
238
252
  * (≥ {@link SUSPEND_THRESHOLD_MS}) SUSPENDS — releases the task, and a timer re-dispatches the run
@@ -237,9 +237,13 @@ export class WorkerWorkflowHost {
237
237
  resume = leafResumeSchema.parse(existing.result);
238
238
  }
239
239
  }
240
+ // Bind a computer-use session's tools (browser tier ⇒ its in-VM Playwright MCP) if one was passed.
241
+ // Done AFTER the fingerprint so the injected MCP never perturbs determinism (the fingerprint keys
242
+ // on provider/model/prompt/schema only — an mcp/session change must not invalidate a journal hit).
243
+ const effectiveOpts = this.bindBrowserSession(opts);
240
244
  for (;;) {
241
245
  try {
242
- const result = await this.deps.leaf.run(prompt, opts, this.deps.signal, resume);
246
+ const result = await this.deps.leaf.run(prompt, effectiveOpts, this.deps.signal, resume);
243
247
  await this.deps.journal?.put({
244
248
  seq,
245
249
  kind: "agent",
@@ -459,6 +463,32 @@ export class WorkerWorkflowHost {
459
463
  return ref;
460
464
  });
461
465
  }
466
+ /** `computer.openBrowser()`: open a program-owned, in-VM browser session (the browser tier of
467
+ * computer use). Not a durable seam — a session is a live resource, reaped at run end, never
468
+ * journaled/replayed. Absent backend ⇒ a clear "not available" error. */
469
+ openBrowserSession(opts) {
470
+ return this.guarded(async () => {
471
+ if (this.deps.browserSessions === undefined) {
472
+ throw new AppError(ErrorCode.VALIDATION_FAILED, "computer.openBrowser is not available in this runtime (no browser backend)");
473
+ }
474
+ return await this.deps.browserSessions.open(opts);
475
+ });
476
+ }
477
+ /** Translate `agent({ session })` into the leaf's `mcp`: append the browser session's in-VM
478
+ * Playwright MCP (its http ref, which passes assertHostedMcpAllowed and reaches localhost without
479
+ * the egress proxy) and strip the `session` handle (the engine doesn't understand it). No-op when
480
+ * no session is bound. */
481
+ bindBrowserSession(opts) {
482
+ if (opts === undefined || opts.session === undefined)
483
+ return opts;
484
+ const ref = this.deps.browserSessions?.mcpRefFor(opts.session);
485
+ if (ref === null || ref === undefined) {
486
+ throw new AppError(ErrorCode.VALIDATION_FAILED, "agent({ session }) received a browser session that is not open in this run", { kind: "browser_session_not_open" });
487
+ }
488
+ const rest = { ...opts };
489
+ delete rest.session;
490
+ return { ...rest, mcp: [...(rest.mcp ?? []), ref] };
491
+ }
462
492
  sleep(arg) {
463
493
  return this.guarded(() => this.sleepSeam(arg));
464
494
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/runner",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Boardwalk self-hosted runner: execute cloud-scheduled runs on your own machines. Currently: the canonical runner contract types.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -53,23 +53,24 @@
53
53
  "prebuild": "prebuildify --napi --strip -t 24.0.0"
54
54
  },
55
55
  "dependencies": {
56
- "zod": "^4.0.0",
57
- "@boardwalk-labs/engine": "^0.1.29",
58
- "@boardwalk-labs/workflow": "^0.1.17",
56
+ "@boardwalk-labs/engine": "^0.1.34",
57
+ "@boardwalk-labs/workflow": "^0.1.24",
58
+ "@modelcontextprotocol/sdk": "^1.29.0",
59
+ "node-gyp-build": "^4.8.4",
59
60
  "tar": "^7.5.16",
60
- "node-gyp-build": "^4.8.4"
61
+ "zod": "^4.0.0"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@eslint/js": "^9.18.0",
64
65
  "@types/node": "^24.0.0",
65
66
  "@vitest/coverage-v8": "^3.2.6",
66
67
  "eslint": "^9.18.0",
68
+ "node-gyp": "^11.0.0",
69
+ "prebuildify": "^6.0.1",
67
70
  "prettier": "^3.4.0",
68
71
  "typescript": "^5.6.0",
69
72
  "typescript-eslint": "^8.20.0",
70
- "vitest": "^3.0.0",
71
- "prebuildify": "^6.0.1",
72
- "node-gyp": "^11.0.0"
73
+ "vitest": "^3.0.0"
73
74
  },
74
75
  "bin": {
75
76
  "boardwalk-runner": "./dist/bin.js"