wb-browser-runtime 0.11.0 → 0.12.0

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
@@ -33,8 +33,8 @@ specific run.
33
33
 
34
34
  ## Vendor selection
35
35
 
36
- `WB_BROWSER_VENDOR` — `browserbase` (default) or `browser-use`. Resolved once
37
- at sidecar boot; there is no per-slice override.
36
+ `WB_BROWSER_VENDOR` — `browserbase` (default), `browser-use`, or `local`.
37
+ Resolved once at sidecar boot; there is no per-slice override.
38
38
 
39
39
  ### Browserbase (default)
40
40
 
@@ -55,6 +55,40 @@ Profile (auth state) is selected per-runbook via the `profile_id:` field on a
55
55
  default when the browser block omits `profile_id:`; a per-runbook `profile_id:`
56
56
  always wins over the env var.
57
57
 
58
+ ### local
59
+
60
+ `WB_BROWSER_VENDOR=local` drives a host-installed Playwright Chromium directly
61
+ — no API keys, no network calls, no per-session cost. Use for dev iteration
62
+ when you'd otherwise burn vendor minutes on broken selectors.
63
+
64
+ | Env var | Default | Purpose |
65
+ |--------------------------------------|--------------|------------------------------------------------------|
66
+ | `WB_BROWSER_LOCAL_HEADLESS` | `1` | Set `0`/`false` for a visible browser window. |
67
+ | `WB_BROWSER_LOCAL_EXECUTABLE_PATH` | *(unset)* | Absolute path to a Chrome/Chromium binary. Overrides Playwright's bundled download. |
68
+ | `WB_BROWSER_LOCAL_CHANNEL` | *(unset)* | Playwright channel name (`chrome`, `msedge`, `chrome-beta`, ...) for an OS-installed browser. Mutually exclusive with `EXECUTABLE_PATH`. |
69
+
70
+ Trade-offs vs cloud vendors:
71
+
72
+ - **No live URL.** `slice.session_started.live_url` is `null` — no remote
73
+ inspector, no Loom-style live preview. Use a non-headless run with
74
+ `WB_BROWSER_LOCAL_HEADLESS=0` if you want to watch.
75
+ - **No persistent profile.** Each run starts with a clean Chromium state.
76
+ Cloud-side "profile" features (auth-state binding) aren't available.
77
+ - **No resume after pause.** If a workbook hits a `wait` fence that suspends
78
+ the sidecar, the in-process Chromium dies with it. On `wb resume` the
79
+ local provider re-allocates a fresh browser. Cloud vendors can keep the
80
+ session alive for resume.
81
+
82
+ First-time install:
83
+
84
+ ```bash
85
+ npx playwright install chromium
86
+ ```
87
+
88
+ If you skip this, `allocate()` fails with a hint pointing back at the
89
+ install command. Or set `WB_BROWSER_LOCAL_EXECUTABLE_PATH` /
90
+ `WB_BROWSER_LOCAL_CHANNEL` to use a system browser without the download.
91
+
58
92
  ## Profiles
59
93
 
60
94
  Some vendors expose persistent browser profiles — cookies, localStorage, saved
@@ -212,6 +246,40 @@ When `WB_ARTIFACTS_UPLOAD_URL` is set (template supports `{run_id}` and
212
246
  produced it completes. Auth reuses `WB_RECORDING_UPLOAD_SECRET`
213
247
  (`Authorization: Bearer <…>`); failures are logged and non-fatal.
214
248
 
249
+ ### Auto-captured downloads
250
+
251
+ The sidecar attaches a context-level `download` listener at session
252
+ start, so any file the browser downloads — clicked attachments, redirect
253
+ chains that end in a binary, popup-driven Save As — is saved to
254
+ `$WB_ARTIFACTS_DIR` automatically and emitted as a `slice.artifact_saved`
255
+ frame (with `source: "download"` and a `provenance` block: source URL,
256
+ page URL, the verb that was running, suggested filename). No verb call
257
+ required. For cloud-provider browsers, Playwright streams the bytes back
258
+ over CDP, so the file always lands on the sidecar machine where the
259
+ artifacts dir + uploader live.
260
+
261
+ Filename collisions in a single session get `-2`, `-3`, … suffixed
262
+ (Playwright's `download.saveAs()` blindly overwrites, so we apply the
263
+ suffixing ourselves).
264
+
265
+ There is no size cap — `download.saveAs()` only resolves once the bytes
266
+ are fully streamed, so a hung download trips the cell's own timeout
267
+ (default 120s slice deadline; bump via `WB_SLICE_DEADLINE_MS`) and
268
+ surfaces as a normal cell failure.
269
+
270
+ To filter, set `WB_BROWSER_DOWNLOAD_EXTENSIONS` to a comma-separated
271
+ list (case-insensitive, leading dots ignored):
272
+
273
+ ```yaml
274
+ env:
275
+ WB_BROWSER_DOWNLOAD_EXTENSIONS: pdf,xlsx,csv,docx
276
+ ```
277
+
278
+ When an allowlist is set, non-matching downloads are cancelled and
279
+ emitted as `slice.download_skipped` (with `reason:
280
+ "extension_not_in_allowlist"`) so the operator sees what was discarded.
281
+ Unset = capture everything.
282
+
215
283
  ## Protocol
216
284
 
217
285
  Line-framed JSON, one message per line, on stdin/stdout. `stderr` is treated as
@@ -34,6 +34,12 @@ import {
34
34
  loadRecordingConfig,
35
35
  } from "../lib/recording-manager.js";
36
36
  import { getProvider } from "../lib/providers/index.js";
37
+ import {
38
+ attachConsoleBuffer,
39
+ captureFailureDiagnostics,
40
+ classifyError,
41
+ } from "../lib/failure.js";
42
+ import { installDownloadCapture } from "../lib/download-capture.js";
37
43
  import { SUPPORTS, runVerb, verbName } from "../verbs/index.js";
38
44
 
39
45
  const VERSION = "0.8.0";
@@ -85,10 +91,22 @@ async function ensureSession(name, { profile, restoreSession } = {}) {
85
91
  let browser = null;
86
92
  try {
87
93
  const liveUrl = allocated._liveUrl ?? (await provider.getLiveUrl(allocated));
88
- browser = await chromium.connectOverCDP(allocated.cdpUrl);
94
+ // Local provider returns a pre-built Browser via `_browser` (no CDP
95
+ // round-trip — chromium is already launched in-process). Cloud
96
+ // providers return a `cdpUrl` we connect to. Restored sessions
97
+ // always reconnect via CDP.
98
+ browser =
99
+ allocated._browser ??
100
+ (await chromium.connectOverCDP(allocated.cdpUrl));
89
101
  const tConnected = Date.now();
90
- const context = browser.contexts()[0] ?? (await browser.newContext());
102
+ // acceptDownloads is true by default for Playwright-launched contexts,
103
+ // but we set it explicitly so the listener installed below isn't a
104
+ // no-op against a vendor-provided context that opted out.
105
+ const context =
106
+ browser.contexts()[0] ??
107
+ (await browser.newContext({ acceptDownloads: true }));
91
108
  const page = context.pages()[0] ?? (await context.newPage());
109
+ const consoleBuffer = attachConsoleBuffer(page);
92
110
  const tPageReady = Date.now();
93
111
 
94
112
  const info = {
@@ -100,8 +118,17 @@ async function ensureSession(name, { profile, restoreSession } = {}) {
100
118
  page,
101
119
  liveUrl,
102
120
  recording: null,
121
+ consoleBuffer,
122
+ // Updated by handleSlice's verb loop so the download listener
123
+ // can attach `verb_index`/`verb_name` provenance to artifacts
124
+ // captured while a verb is running. Null between slices.
125
+ currentVerb: null,
103
126
  };
104
127
 
128
+ // Install the always-on download listener now, before any slice
129
+ // runs, so a download fired by the very first verb is captured.
130
+ installDownloadCapture(context, () => info.currentVerb);
131
+
105
132
  send({
106
133
  type: "slice.session_started",
107
134
  session: name,
@@ -277,6 +304,7 @@ async function handleSlice(msg) {
277
304
  } catch (e) {
278
305
  send({
279
306
  type: "slice.failed",
307
+ code: classifyError(e, "session"),
280
308
  error: `session start failed: ${scrubSecrets(e.message, sliceCtx.secrets)}`,
281
309
  });
282
310
  return;
@@ -298,6 +326,7 @@ async function handleSlice(msg) {
298
326
  if (Date.now() >= sliceDeadline) {
299
327
  send({
300
328
  type: "slice.failed",
329
+ code: "SLICE_TIMEOUT",
301
330
  error: `slice exceeded deadline (${sliceDeadlineMs}ms); aborted before verb index ${i} of ${verbs.length}`,
302
331
  });
303
332
  return;
@@ -305,6 +334,12 @@ async function handleSlice(msg) {
305
334
  const v = verbs[i];
306
335
  const name = verbName(v);
307
336
  const verbStart = Date.now();
337
+ // Tell the passive download listener which verb to blame for any
338
+ // download that fires during this iteration. Cleared in `finally`
339
+ // so a download arriving between verbs (rare, but possible during
340
+ // a settle/redirect) records as "no current verb" instead of
341
+ // sticking the previous one's name on it.
342
+ session.currentVerb = { index: i, name };
308
343
  try {
309
344
  const summary = await runVerb(session.page, v, i, sliceCtx, expand);
310
345
  // Pause-sentinel escape hatch: a verb signals a mid-slice halt by
@@ -353,26 +388,44 @@ async function handleSlice(msg) {
353
388
  } catch (e) {
354
389
  const duration_ms = Date.now() - verbStart;
355
390
  const clean = scrubSecrets(e.message, sliceCtx.secrets);
391
+ const code = classifyError(e, name);
392
+ const diagnostics = await captureFailureDiagnostics({
393
+ page: session.page,
394
+ artifactsDir: (process.env.WB_ARTIFACTS_DIR || "").trim() || null,
395
+ verbIndex: i,
396
+ consoleBuffer: session.consoleBuffer,
397
+ scrubSecrets,
398
+ secrets: sliceCtx.secrets,
399
+ });
356
400
  send({
357
401
  type: "verb.failed",
358
402
  verb: name,
359
403
  verb_index: i,
404
+ code,
360
405
  error: clean,
361
406
  duration_ms,
407
+ screenshot_path: diagnostics.screenshot_path,
408
+ console_tail: diagnostics.console_tail,
362
409
  });
363
410
  send({
364
411
  type: "slice.failed",
412
+ code,
365
413
  error: `verb ${name} (index ${i}): ${clean}`,
366
414
  });
367
415
  return;
368
416
  }
369
417
  }
418
+ // Slice ended cleanly — clear the listener's "currently running verb"
419
+ // pointer so a stray late-arriving download doesn't get stamped with
420
+ // the last verb's name.
421
+ session.currentVerb = null;
370
422
  send({ type: "slice.complete" });
371
423
  } catch (e) {
372
424
  log(`[slice] unhandled: ${e.stack || e.message}`);
373
425
  try {
374
426
  send({
375
427
  type: "slice.failed",
428
+ code: classifyError(e, "sidecar"),
376
429
  error: `sidecar error: ${scrubSecrets(e.message, sliceCtx.secrets)}`,
377
430
  });
378
431
  } catch {}
@@ -0,0 +1,180 @@
1
+ // download-capture — passive capture of any file the browser downloads
2
+ // during a session, regardless of which verb (or page redirect, or popup)
3
+ // triggered it.
4
+ //
5
+ // The runbook author doesn't have to predict downloads. We attach a
6
+ // `download` listener to the BrowserContext at session start; every file
7
+ // the browser saves lands in `$WB_ARTIFACTS_DIR` and gets announced via a
8
+ // `slice.artifact_saved` frame so wb's existing R2 uploader picks it up
9
+ // for free. Provenance (page URL, source URL, which verb was running, ts)
10
+ // rides along on the frame so the run-page event feed can show *why* a
11
+ // given file appeared.
12
+ //
13
+ // Filtering: if WB_BROWSER_DOWNLOAD_EXTENSIONS is set, only files whose
14
+ // extension matches the allowlist are kept. Skipped downloads still get a
15
+ // `slice.download_skipped` frame so the operator sees what was discarded
16
+ // (rare in practice — `download` events fire on real attachments, not
17
+ // inline analytics pings — but useful when a SPA emits noisy JSON blobs).
18
+ //
19
+ // Big files: there is no size cap. R2 is bottomless and the runbook's own
20
+ // timeout governs hung downloads — `download.saveAs()` only resolves once
21
+ // bytes are fully streamed, so a stuck download will trip the cell deadline
22
+ // and surface as a normal cell failure.
23
+ //
24
+ // Cloud vs local: `download.saveAs(absPath)` works for both. Playwright
25
+ // streams the bytes back over CDP for cloud-attached browsers, so the file
26
+ // always lands on the sidecar machine where $WB_ARTIFACTS_DIR lives.
27
+
28
+ import path from "node:path";
29
+ import { promises as fsPromises } from "node:fs";
30
+ import { send, log, logWarn } from "./io.js";
31
+ import {
32
+ uniquePathInside,
33
+ parseExtensionAllowlist,
34
+ extensionAllowed,
35
+ } from "./util.js";
36
+
37
+ // Marker that the explicit (future) `download:` gating verb sets on a
38
+ // Download object once it's claimed it. The passive listener checks for
39
+ // this and skips, so the same file isn't saved twice.
40
+ export const HANDLED_MARK = Symbol.for("wb.download.handled");
41
+
42
+ // Sentinel filename used when Playwright reports an empty suggestedFilename
43
+ // (rare, but theoretically possible for downloads with no Content-
44
+ // Disposition header and an empty URL path).
45
+ const FALLBACK_NAME = "download.bin";
46
+
47
+ // Install the always-on download listener on `context`. Returns a no-op
48
+ // when WB_ARTIFACTS_DIR isn't set — without an artifacts dir there's
49
+ // nowhere to put the file, and bailing here is preferable to inventing a
50
+ // temp dir that wb's uploader doesn't watch.
51
+ //
52
+ // `getCurrentVerb()` is a callback the entry point updates each iteration
53
+ // of the slice loop, so the listener can attach `verb_index` / `verb_name`
54
+ // to the announcement without the slice loop having to reach back into
55
+ // this module.
56
+ export function installDownloadCapture(context, getCurrentVerb) {
57
+ const artifactsDir = (process.env.WB_ARTIFACTS_DIR || "").trim();
58
+ if (!artifactsDir) {
59
+ log("[download-capture] WB_ARTIFACTS_DIR not set; auto-capture disabled");
60
+ return;
61
+ }
62
+ const allowlist = parseExtensionAllowlist(
63
+ process.env.WB_BROWSER_DOWNLOAD_EXTENSIONS,
64
+ );
65
+ if (allowlist) {
66
+ log(
67
+ `[download-capture] enabled; extension allowlist: ${[...allowlist].join(",")}`,
68
+ );
69
+ } else {
70
+ log("[download-capture] enabled; capturing all downloads");
71
+ }
72
+
73
+ context.on("download", (download) => {
74
+ captureOne({ download, artifactsDir, allowlist, getCurrentVerb }).catch(
75
+ (e) => {
76
+ // Never let a failed capture take down the slice — emit a frame
77
+ // so the operator sees the failure, then drop it.
78
+ logWarn(`[download-capture] ${e.stack || e.message}`);
79
+ try {
80
+ send({
81
+ type: "slice.download_failed",
82
+ error: String(e.message || e),
83
+ url: safeUrl(download),
84
+ suggested_filename: safeSuggested(download),
85
+ });
86
+ } catch {}
87
+ },
88
+ );
89
+ });
90
+ }
91
+
92
+ async function captureOne({
93
+ download,
94
+ artifactsDir,
95
+ allowlist,
96
+ getCurrentVerb,
97
+ }) {
98
+ if (download[HANDLED_MARK]) return;
99
+
100
+ const suggested = safeSuggested(download);
101
+ const sourceUrl = safeUrl(download);
102
+ const pageUrl = (() => {
103
+ try {
104
+ return download.page().url();
105
+ } catch {
106
+ return null;
107
+ }
108
+ })();
109
+ const verb = (typeof getCurrentVerb === "function" && getCurrentVerb()) || {};
110
+
111
+ if (!extensionAllowed(suggested, allowlist)) {
112
+ send({
113
+ type: "slice.download_skipped",
114
+ reason: "extension_not_in_allowlist",
115
+ suggested_filename: suggested,
116
+ url: sourceUrl,
117
+ page_url: pageUrl,
118
+ verb_index: verb.index ?? null,
119
+ verb_name: verb.name ?? null,
120
+ ts: Date.now(),
121
+ });
122
+ // Cancel the download so Playwright doesn't keep the temp file alive.
123
+ try {
124
+ await download.cancel();
125
+ } catch {}
126
+ return;
127
+ }
128
+
129
+ await fsPromises.mkdir(artifactsDir, { recursive: true });
130
+ const target = uniquePathInside(artifactsDir, suggested);
131
+ if (!target) {
132
+ throw new Error(
133
+ `download-capture: refusing to save "${suggested}" — resolves outside $WB_ARTIFACTS_DIR`,
134
+ );
135
+ }
136
+
137
+ await download.saveAs(target);
138
+
139
+ let bytes = null;
140
+ try {
141
+ bytes = (await fsPromises.stat(target)).size;
142
+ } catch {
143
+ // saveAs resolved successfully so the file should exist; if stat fails
144
+ // we still announce, just without size. Better partial info than no
145
+ // event at all.
146
+ }
147
+
148
+ send({
149
+ type: "slice.artifact_saved",
150
+ filename: path.basename(target),
151
+ path: target,
152
+ bytes,
153
+ source: "download",
154
+ provenance: {
155
+ url: sourceUrl,
156
+ suggested_filename: suggested,
157
+ page_url: pageUrl,
158
+ verb_index: verb.index ?? null,
159
+ verb_name: verb.name ?? null,
160
+ ts: Date.now(),
161
+ },
162
+ });
163
+ }
164
+
165
+ function safeSuggested(download) {
166
+ try {
167
+ const s = download.suggestedFilename();
168
+ return s && s.trim() ? s : FALLBACK_NAME;
169
+ } catch {
170
+ return FALLBACK_NAME;
171
+ }
172
+ }
173
+
174
+ function safeUrl(download) {
175
+ try {
176
+ return download.url();
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
package/lib/failure.js ADDED
@@ -0,0 +1,99 @@
1
+ // Failure-event helpers — classifier + screenshot/console capture.
2
+ //
3
+ // `verb.failed` and `slice.failed` carry a stable `code` field so agents can
4
+ // switch on category instead of regex-matching English. Verb failures also
5
+ // snapshot a screenshot (best-effort) and the recent console buffer so
6
+ // post-hoc debugging doesn't depend on a single line of stderr.
7
+ //
8
+ // All capture is best-effort: a failed screenshot or a missing artifacts dir
9
+ // must NOT prevent the failure event from emitting.
10
+
11
+ import { promises as fs } from "node:fs";
12
+ import path from "node:path";
13
+ import { randomUUID } from "node:crypto";
14
+
15
+ const MAX_CONSOLE_ENTRIES = 50;
16
+ const MAX_LINE_CHARS = 512;
17
+
18
+ // Map a verb-execution error to a stable code. Order matters: an explicit
19
+ // `err.code` (e.g. set by a provider for AUTH_FAILED) wins over inference.
20
+ export function classifyError(err, verbName) {
21
+ if (err && typeof err.code === "string" && err.code) return err.code;
22
+ if (!err) return "INTERNAL_ERROR";
23
+ const name = err.name || "";
24
+ const msg = String(err.message || "");
25
+ if (name === "TimeoutError") {
26
+ if (verbName === "goto") return "NAV_TIMEOUT";
27
+ if (/load\s*state|networkidle|navigation|wait\s+for\s+url/i.test(msg)) {
28
+ return "NAV_TIMEOUT";
29
+ }
30
+ return "SELECTOR_NOT_FOUND";
31
+ }
32
+ if (verbName === "eval" || verbName === "extract") return "SCRIPT_ERROR";
33
+ return "INTERNAL_ERROR";
34
+ }
35
+
36
+ // Attach console + pageerror listeners to a Page. Returns the buffer object
37
+ // (FIFO-capped) so callers can stash it next to the Page (e.g. on the
38
+ // SessionManager `info`). Calling twice on the same Page would double-record;
39
+ // callers are expected to only invoke once per page.
40
+ export function attachConsoleBuffer(page) {
41
+ const buffer = [];
42
+ const push = (entry) => {
43
+ const text = String(entry.text ?? "");
44
+ buffer.push({
45
+ type: entry.type,
46
+ text: text.length > MAX_LINE_CHARS ? text.slice(0, MAX_LINE_CHARS) : text,
47
+ at: entry.at ?? Date.now(),
48
+ });
49
+ while (buffer.length > MAX_CONSOLE_ENTRIES) buffer.shift();
50
+ };
51
+ page.on("console", (msg) => {
52
+ push({ type: msg.type(), text: msg.text() });
53
+ });
54
+ page.on("pageerror", (err) => {
55
+ push({ type: "pageerror", text: err?.message ?? String(err) });
56
+ });
57
+ return buffer;
58
+ }
59
+
60
+ // Snapshot console buffer (with secret scrubbing) and capture a screenshot.
61
+ // Returns `{ screenshot_path, console_tail }`. Both fields may be null/empty;
62
+ // caller decides whether to attach them to the failure event.
63
+ export async function captureFailureDiagnostics({
64
+ page,
65
+ artifactsDir,
66
+ verbIndex,
67
+ consoleBuffer,
68
+ scrubSecrets,
69
+ secrets,
70
+ }) {
71
+ const out = { screenshot_path: null, console_tail: [] };
72
+
73
+ if (Array.isArray(consoleBuffer)) {
74
+ const scrub = typeof scrubSecrets === "function" ? scrubSecrets : null;
75
+ out.console_tail = consoleBuffer.map((entry) => ({
76
+ type: entry.type,
77
+ text: scrub ? scrub(entry.text, secrets) : String(entry.text),
78
+ at: entry.at,
79
+ }));
80
+ }
81
+
82
+ if (page && artifactsDir) {
83
+ try {
84
+ const filename = `wb-failure-${verbIndex}-${Date.now()}.png`;
85
+ const fullPath = path.join(artifactsDir, filename);
86
+ const tmp = `${fullPath}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
87
+ const buf = await page.screenshot({ type: "png" });
88
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
89
+ await fs.writeFile(tmp, buf);
90
+ await fs.rename(tmp, fullPath);
91
+ out.screenshot_path = filename;
92
+ } catch {
93
+ // Screenshot capture is best-effort; don't let a Page crash or a
94
+ // permission error mask the underlying verb failure.
95
+ }
96
+ }
97
+
98
+ return out;
99
+ }
@@ -77,15 +77,22 @@ export function createBrowserUseProvider() {
77
77
  "bu.create",
78
78
  );
79
79
  if (!res.ok) {
80
- throw new Error(
80
+ const err = new Error(
81
81
  `browser-use create failed (${res.status}): ${await safeText(res)}`,
82
82
  );
83
+ err.code =
84
+ res.status === 401 || res.status === 403
85
+ ? "AUTH_FAILED"
86
+ : "SESSION_ALLOCATE_FAILED";
87
+ throw err;
83
88
  }
84
89
  const created = await res.json();
85
90
  if (!created.cdpUrl) {
86
- throw new Error(
91
+ const err = new Error(
87
92
  `browser-use create returned no cdpUrl (status=${created.status ?? "?"}); session unusable`,
88
93
  );
94
+ err.code = "SESSION_ALLOCATE_FAILED";
95
+ throw err;
89
96
  }
90
97
  return {
91
98
  sid: created.id,
@@ -65,9 +65,14 @@ export function createBrowserbaseProvider() {
65
65
  "bb.create",
66
66
  );
67
67
  if (!res.ok) {
68
- throw new Error(
68
+ const err = new Error(
69
69
  `Browserbase create failed (${res.status}): ${await safeText(res)}`,
70
70
  );
71
+ err.code =
72
+ res.status === 401 || res.status === 403
73
+ ? "AUTH_FAILED"
74
+ : "SESSION_ALLOCATE_FAILED";
75
+ throw err;
71
76
  }
72
77
  const created = await res.json();
73
78
  return { sid: created.id, cdpUrl: created.connectUrl };
@@ -81,9 +86,14 @@ export function createBrowserbaseProvider() {
81
86
  "bb.debug",
82
87
  );
83
88
  if (!res.ok) {
84
- throw new Error(
89
+ const err = new Error(
85
90
  `Browserbase debug fetch failed (${res.status}): ${await safeText(res)}`,
86
91
  );
92
+ err.code =
93
+ res.status === 401 || res.status === 403
94
+ ? "AUTH_FAILED"
95
+ : "SESSION_ALLOCATE_FAILED";
96
+ throw err;
87
97
  }
88
98
  const body = await res.json();
89
99
  return body.debuggerFullscreenUrl;
@@ -21,10 +21,12 @@
21
21
  //
22
22
  // Vendor selection is a single env var, resolved once at sidecar boot:
23
23
  // WB_BROWSER_VENDOR=browserbase (default)
24
- // WB_BROWSER_VENDOR=browser-use (future)
24
+ // WB_BROWSER_VENDOR=browser-use
25
+ // WB_BROWSER_VENDOR=local — host-installed Chromium (dev iteration)
25
26
 
26
27
  import { createBrowserbaseProvider } from "./browserbase.js";
27
28
  import { createBrowserUseProvider } from "./browser-use.js";
29
+ import { createLocalProvider } from "./local.js";
28
30
 
29
31
  export function getProvider() {
30
32
  const raw = (process.env.WB_BROWSER_VENDOR || "browserbase")
@@ -35,9 +37,11 @@ export function getProvider() {
35
37
  return createBrowserbaseProvider();
36
38
  case "browser-use":
37
39
  return createBrowserUseProvider();
40
+ case "local":
41
+ return createLocalProvider();
38
42
  default:
39
43
  throw new Error(
40
- `WB_BROWSER_VENDOR="${raw}" is not a known vendor (expected: browserbase | browser-use)`,
44
+ `WB_BROWSER_VENDOR="${raw}" is not a known vendor (expected: browserbase | browser-use | local)`,
41
45
  );
42
46
  }
43
47
  }
@@ -0,0 +1,120 @@
1
+ // Local provider — drives a host-installed Playwright Chromium directly
2
+ // instead of going to a cloud vendor. Use for dev iteration without
3
+ // Browserbase / browser-use cost or latency. Selected via
4
+ // WB_BROWSER_VENDOR=local.
5
+ //
6
+ // Differences from cloud providers:
7
+ // 1. allocate() launches a real Chromium via `playwright-core`'s
8
+ // chromium.launch() and returns a pre-built Browser handle in
9
+ // `_browser`. The entry point checks for it and skips the
10
+ // connectOverCDP step that cloud providers require.
11
+ // 2. getLiveUrl() returns null — there's no public live-inspector URL
12
+ // for a locally-launched browser. The Rust side just renders the
13
+ // "session started" line without a clickable URL.
14
+ // 3. release() is a no-op. The shutdown path already does
15
+ // `info.browser.close()` on every cached session, which terminates
16
+ // the local Chromium process.
17
+ // 4. Profile binding is not supported (logged + ignored). For persistent
18
+ // auth across runs use a vendor with profile support, or pin
19
+ // WB_BROWSER_LOCAL_EXECUTABLE_PATH at a Chrome instance with a
20
+ // pre-warmed user-data-dir (advanced; not the supported path).
21
+ //
22
+ // Resume-after-pause: not supported. The Browser is process-local memory
23
+ // and dies with the sidecar; on resume the sidecar re-allocates a fresh
24
+ // session. This matches the dev-iteration use case (you're running the
25
+ // runbook end-to-end, not pausing on a real wait fence).
26
+
27
+ import { chromium } from "playwright-core";
28
+ import { log } from "../io.js";
29
+
30
+ // Truthiness for env knobs that default to ON. "0" / "false" / "no" / "off"
31
+ // disables; anything else enables. Mirrors the convention used elsewhere.
32
+ function isOff(v) {
33
+ if (v === undefined || v === null) return false;
34
+ const s = String(v).trim().toLowerCase();
35
+ return s === "0" || s === "false" || s === "no" || s === "off";
36
+ }
37
+
38
+ export function createLocalProvider() {
39
+ return {
40
+ name: "local",
41
+
42
+ async allocate({ profile, sessionName: _sessionName } = {}) {
43
+ if (profile) {
44
+ log(
45
+ `[local] profile="${profile}" ignored — local vendor has no profile binding. ` +
46
+ `Use a cloud vendor or persist auth via WB_BROWSER_LOCAL_EXECUTABLE_PATH on a pre-warmed Chrome.`,
47
+ );
48
+ }
49
+
50
+ // Headless ON by default; flip with WB_BROWSER_LOCAL_HEADLESS=0 for
51
+ // visible-window dev. Operators debugging a brittle workbook can flip
52
+ // to headed without touching the runbook.
53
+ const headless = !isOff(process.env.WB_BROWSER_LOCAL_HEADLESS);
54
+
55
+ // executablePath: explicit override for system Chrome / Chromium.
56
+ // channel: "chrome" / "msedge" / "chrome-beta" — Playwright's named
57
+ // channels for OS-installed browsers (no separate download). At most
58
+ // one of executablePath / channel should be set; if both arrive,
59
+ // executablePath wins (Playwright honors it).
60
+ const executablePath =
61
+ process.env.WB_BROWSER_LOCAL_EXECUTABLE_PATH || undefined;
62
+ const channel = process.env.WB_BROWSER_LOCAL_CHANNEL || undefined;
63
+
64
+ log(
65
+ `[local] launching chromium headless=${headless}` +
66
+ ` executablePath=${executablePath ?? "<bundled>"}` +
67
+ ` channel=${channel ?? "<none>"}`,
68
+ );
69
+
70
+ let browser;
71
+ try {
72
+ browser = await chromium.launch({
73
+ headless,
74
+ executablePath,
75
+ channel,
76
+ });
77
+ } catch (e) {
78
+ // Most common cause: Playwright's chromium binary not installed.
79
+ // playwright-core ships the API but no browser; the user runs
80
+ // `npx playwright install chromium` once to fetch it. Surface the
81
+ // hint inline so this isn't a guessing game on first run.
82
+ const err = new Error(
83
+ `local browser launch failed: ${e.message}\n` +
84
+ `Hint: install Chromium with \`npx playwright install chromium\`, ` +
85
+ `or set WB_BROWSER_LOCAL_EXECUTABLE_PATH / WB_BROWSER_LOCAL_CHANNEL to use a system browser.`,
86
+ );
87
+ err.code = "SESSION_ALLOCATE_FAILED";
88
+ throw err;
89
+ }
90
+
91
+ // sid is for telemetry only — there's no remote session to release.
92
+ // Format: `local-<ms>-<rand>` so it's distinguishable from vendor sids
93
+ // in callback streams and logs.
94
+ const sid = `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
95
+
96
+ return {
97
+ sid,
98
+ // No CDP URL — the entry point sees `_browser` and skips the
99
+ // connectOverCDP path that cloud providers go through.
100
+ cdpUrl: null,
101
+ // Stashed so getLiveUrl() is a sync property read like browser-use.
102
+ _liveUrl: null,
103
+ _browser: browser,
104
+ };
105
+ },
106
+
107
+ async getLiveUrl(_allocated) {
108
+ // No public inspector URL for local Chromium. Returning null tells
109
+ // the Rust side to render the "session started" line without a link.
110
+ return null;
111
+ },
112
+
113
+ async release(_sid) {
114
+ // Browser teardown happens in the entry-point shutdown loop via
115
+ // `info.browser.close()`, which kills the local Chromium process.
116
+ // Cloud providers need a separate vendor REST call here; local
117
+ // doesn't.
118
+ },
119
+ };
120
+ }
package/lib/stub-page.js CHANGED
@@ -73,6 +73,22 @@ export function createStubPage(opts = {}) {
73
73
  record({ verb: "evaluate", script });
74
74
  return evalResult;
75
75
  },
76
+ async waitForLoadState(state, options) {
77
+ record({ verb: "waitForLoadState", state, options });
78
+ },
79
+ getByText(text, options) {
80
+ record({ verb: "getByText", text, options });
81
+ const locator = {
82
+ first() {
83
+ return {
84
+ async click(opts) {
85
+ record({ verb: "getByText.first.click", text, options: opts });
86
+ },
87
+ };
88
+ },
89
+ };
90
+ return locator;
91
+ },
76
92
  };
77
93
  }
78
94
 
package/lib/util.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { existsSync } from "node:fs";
3
4
 
4
5
  // Resolve `candidate` inside `dir`, rejecting traversal and absolute paths.
5
6
  // Returns null when the resolved path escapes `dir` (or is `dir` itself).
@@ -14,6 +15,63 @@ export function resolveInside(dir, candidate) {
14
15
  return resolved;
15
16
  }
16
17
 
18
+ // Collision-safe path inside `dir`. Returns the first path of the form
19
+ // `<base><ext>`, `<base>-2<ext>`, `<base>-3<ext>`, ... that doesn't already
20
+ // exist on disk. Playwright's `download.saveAs(path)` blindly overwrites,
21
+ // so this is the only thing standing between two same-named downloads
22
+ // (e.g. two `report.pdf` saves in one session) silently clobbering each
23
+ // other. Returns null if `name` would resolve outside `dir`.
24
+ //
25
+ // The check is racy (two concurrent downloads with the same suggestedName
26
+ // can both observe the same free slot before either writes) — acceptable
27
+ // here because downloads in a single session serialize through the same
28
+ // page in practice, and a stray collision would just produce one
29
+ // overwritten file rather than corrupting state.
30
+ export function uniquePathInside(dir, name) {
31
+ const safe = sanitizeArtifactName(name);
32
+ const first = resolveInside(dir, safe);
33
+ if (!first) return null;
34
+ if (!existsSync(first)) return first;
35
+ const ext = path.extname(safe);
36
+ const base = ext ? safe.slice(0, -ext.length) : safe;
37
+ for (let n = 2; n < 1000; n++) {
38
+ const candidate = resolveInside(dir, `${base}-${n}${ext}`);
39
+ if (!candidate) return null;
40
+ if (!existsSync(candidate)) return candidate;
41
+ }
42
+ // Fallback: append a random suffix. 1000 collisions on the same name in
43
+ // one session is unrealistic, but we'd rather degrade than throw.
44
+ const rand = randomUUID().slice(0, 8);
45
+ return resolveInside(dir, `${base}-${rand}${ext}`);
46
+ }
47
+
48
+ // Parse a comma-separated extension allowlist from raw env (e.g.
49
+ // "pdf, xlsx,CSV"). Returns a Set of lowercase extensions without leading
50
+ // dots, or null when the input is empty/unset (callers treat null as "no
51
+ // filter — capture everything").
52
+ export function parseExtensionAllowlist(raw) {
53
+ if (raw == null) return null;
54
+ const s = String(raw).trim();
55
+ if (!s) return null;
56
+ const parts = s
57
+ .split(",")
58
+ .map((x) => x.trim().toLowerCase().replace(/^\./, ""))
59
+ .filter(Boolean);
60
+ if (parts.length === 0) return null;
61
+ return new Set(parts);
62
+ }
63
+
64
+ // Match a filename against an extension allowlist. `null` allowlist means
65
+ // no filter (anything passes). Files with no extension never pass a
66
+ // non-null allowlist — the caller wanted a specific set, an unknown blob
67
+ // isn't it.
68
+ export function extensionAllowed(filename, allowlist) {
69
+ if (!allowlist) return true;
70
+ const ext = path.extname(String(filename || "")).toLowerCase().replace(/^\./, "");
71
+ if (!ext) return false;
72
+ return allowlist.has(ext);
73
+ }
74
+
17
75
  export function sanitizeArtifactName(s) {
18
76
  // Keep author-chosen names readable but safe as filenames. Drop anything
19
77
  // that could escape the artifacts dir (slashes, NULs, etc.).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wb-browser-runtime",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Browser sidecar runtime for wb — Playwright over CDP (Browserbase, browser-use) via the wb-sidecar/1 line-framed JSON protocol.",
5
5
  "bin": {
6
6
  "wb-browser-runtime": "bin/wb-browser-runtime.js"
package/verbs/click.js CHANGED
@@ -2,7 +2,29 @@ export default {
2
2
  name: "click",
3
3
  primaryKey: "selector",
4
4
  async execute(page, args) {
5
- await page.click(args.selector, { timeout: args.timeout ?? 10_000 });
6
- return `${args.selector}`;
5
+ const timeout = args.timeout ?? 10_000;
6
+ try {
7
+ await page.click(args.selector, { timeout });
8
+ return `${args.selector}`;
9
+ } catch (err) {
10
+ // Text-fallback: when the selector times out (typically a brittle
11
+ // class/id rename), retry against visible text. We DELIBERATELY
12
+ // re-throw the ORIGINAL error if the fallback also fails — the
13
+ // selector failure is the actionable signal for error classification
14
+ // upstream; the fallback's failure would obscure it.
15
+ const isTimeout = err && err.name === "TimeoutError";
16
+ if (isTimeout && args.text_fallback) {
17
+ try {
18
+ await page
19
+ .getByText(args.text_fallback, { exact: false })
20
+ .first()
21
+ .click({ timeout });
22
+ return `${args.selector} (via text="${args.text_fallback}")`;
23
+ } catch {
24
+ throw err;
25
+ }
26
+ }
27
+ throw err;
28
+ }
7
29
  },
8
30
  };
package/verbs/index.js CHANGED
@@ -13,6 +13,7 @@ import fillVerb from "./fill.js";
13
13
  import clickVerb from "./click.js";
14
14
  import pressVerb from "./press.js";
15
15
  import waitForVerb from "./wait_for.js";
16
+ import waitForNetworkIdleVerb from "./wait_for_network_idle.js";
16
17
  import screenshotVerb from "./screenshot.js";
17
18
  import extractVerb from "./extract.js";
18
19
  import assertVerb from "./assert.js";
@@ -28,6 +29,7 @@ const VERBS = [
28
29
  clickVerb,
29
30
  pressVerb,
30
31
  waitForVerb,
32
+ waitForNetworkIdleVerb,
31
33
  screenshotVerb,
32
34
  extractVerb,
33
35
  assertVerb,
@@ -0,0 +1,51 @@
1
+ // Wait until the page reports "networkidle" — at most one in-flight request
2
+ // for >=500ms. SPA flows that don't have a stable selector to wait on need
3
+ // this; otherwise the next verb fires before async XHRs settle and reads
4
+ // stale DOM.
5
+
6
+ const DEFAULT_TIMEOUT_MS = 30_000;
7
+
8
+ // Parse "30s" / "2m" / "500ms" / 5000 / "5000" into ms. Anything malformed
9
+ // falls back to the default — the sidecar would rather time out at a known
10
+ // bound than throw on a typo.
11
+ function parseTimeoutMs(value) {
12
+ if (value == null) return DEFAULT_TIMEOUT_MS;
13
+ if (typeof value === "number" && Number.isFinite(value)) return value;
14
+ if (typeof value !== "string") return DEFAULT_TIMEOUT_MS;
15
+ const trimmed = value.trim();
16
+ if (trimmed === "") return DEFAULT_TIMEOUT_MS;
17
+ const m = trimmed.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/i);
18
+ if (!m) {
19
+ const asNum = Number(trimmed);
20
+ return Number.isFinite(asNum) ? asNum : DEFAULT_TIMEOUT_MS;
21
+ }
22
+ const n = Number(m[1]);
23
+ const unit = (m[2] || "ms").toLowerCase();
24
+ switch (unit) {
25
+ case "ms":
26
+ return n;
27
+ case "s":
28
+ return n * 1000;
29
+ case "m":
30
+ return n * 60_000;
31
+ case "h":
32
+ return n * 3_600_000;
33
+ default:
34
+ return DEFAULT_TIMEOUT_MS;
35
+ }
36
+ }
37
+
38
+ export default {
39
+ name: "wait_for_network_idle",
40
+ primaryKey: "timeout",
41
+ async execute(page, args) {
42
+ const raw = args.timeout;
43
+ const timeout = parseTimeoutMs(raw);
44
+ await page.waitForLoadState("networkidle", { timeout });
45
+ const summary =
46
+ typeof raw === "string" && raw.trim() !== ""
47
+ ? `network idle (timeout=${raw.trim()})`
48
+ : `network idle (timeout=${timeout}ms)`;
49
+ return summary;
50
+ },
51
+ };