wb-browser-runtime 0.5.3 → 0.6.1

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
@@ -28,9 +28,15 @@ specific run.
28
28
  Verb arguments support two substitutions at dispatch time:
29
29
 
30
30
  - `{{ env.NAME }}` — reads `process.env.NAME`. Use for static secrets injected via Doppler / the agent's env.
31
- - `{{ artifacts.NAME }}` — reads `$WB_ARTIFACTS_DIR/NAME.txt` (falling back to `$WB_ARTIFACTS_DIR/NAME`). Use for dynamic values produced by an earlier bash cell — OTPs, magic-link URLs, export IDs, anything polled from an external system mid-run. Each read happens per-verb with no caching, so writes land immediately.
31
+ - `{{ artifacts.NAME }}` — reads `$WB_ARTIFACTS_DIR/NAME.txt` (falling back to `$WB_ARTIFACTS_DIR/NAME`). Use for dynamic values produced by an earlier bash cell — OTPs, magic-link URLs, export IDs, anything polled from an external system mid-run. Reads are cached for the duration of one slice; a bash cell that runs *between* slices is always picked up by the next slice's verbs.
32
32
 
33
- Both forms are redacted in stdout summaries — only the verb name + selector make it into the log.
33
+ Both forms are redacted in stdout summaries — only the verb name + selector make it into the log. Expanded values are also scrubbed from `verb.failed` / `slice.failed` error messages before they cross the stdio boundary.
34
+
35
+ **Missing-value policy.** Set `WB_SUBSTITUTION_ON_MISSING` to choose how a missing `env.X` or `artifacts.X` is handled:
36
+
37
+ - `warn` (default) — log a stderr warning and substitute an empty string; the verb continues.
38
+ - `error` — throw, failing the slice. Use in CI so a missing OTP doesn't silently dispatch an empty selector.
39
+ - `empty` — substitute empty silently (suppresses the warning).
34
40
 
35
41
  ## Optional: anti-detection
36
42
 
@@ -77,7 +83,7 @@ Each POST carries headers `Authorization: Bearer <secret>`,
77
83
  `step.recording.*` on the callback stream:
78
84
 
79
85
  - `step.recording.started` — once per session, payload includes `run_id`, `kinds`.
80
- - `step.recording.uploaded` — on 2xx PUT, payload includes `kind`, `bytes`.
86
+ - `step.recording.uploaded` — on 2xx POST, payload includes `kind`, `bytes`.
81
87
  - `step.recording.failed` — on network/ffmpeg/upload error, payload includes `kind`, `status?`, `reason`. Non-fatal: the slice still completes.
82
88
 
83
89
  ## Usage
@@ -49,7 +49,7 @@ const SUPPORTS = [
49
49
  ];
50
50
 
51
51
  const BB_BASE = "https://api.browserbase.com";
52
- const VERSION = "0.5.3";
52
+ const VERSION = "0.6.0";
53
53
 
54
54
  // --- Recording config -------------------------------------------------------
55
55
  //
@@ -94,11 +94,25 @@ function loadRecordingConfig() {
94
94
  (process.env.TRIGGER_RUN_ID || "").trim() ||
95
95
  `wb-${randomUUID()}`;
96
96
 
97
- const fps =
97
+ // Clamp to ranges ffmpeg/libvpx-vp9 actually handles. Requesting fps=120
98
+ // silently blew up memory; quality=0 produced unwatchable garbage. Clamp
99
+ // + log so operators see the effective value.
100
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
101
+ const rawFps =
98
102
  Number.parseInt(process.env.WB_RECORDING_SCREENCAST_FPS || "", 10) || 5;
99
- const quality =
103
+ const rawQuality =
100
104
  Number.parseInt(process.env.WB_RECORDING_SCREENCAST_QUALITY || "", 10) ||
101
105
  60;
106
+ const fps = clamp(rawFps, 1, 30);
107
+ const quality = clamp(rawQuality, 10, 95);
108
+ if (fps !== rawFps) {
109
+ log(`[recording] fps=${rawFps} clamped to ${fps} (valid range 1..30)`);
110
+ }
111
+ if (quality !== rawQuality) {
112
+ log(
113
+ `[recording] quality=${rawQuality} clamped to ${quality} (valid range 10..95)`,
114
+ );
115
+ }
102
116
 
103
117
  const rrwebRequested = process.env.WB_RECORDING_RRWEB !== "0";
104
118
  const videoRequested = process.env.WB_RECORDING_VIDEO !== "0";
@@ -131,6 +145,10 @@ function loadRecordingConfig() {
131
145
  return { enabled: false, reason: "all-kinds-disabled" };
132
146
  }
133
147
 
148
+ const rrwebMaxEvents =
149
+ Number.parseInt(process.env.WB_RECORDING_RRWEB_MAX_EVENTS || "", 10) ||
150
+ 50_000;
151
+
134
152
  return {
135
153
  enabled: true,
136
154
  uploadUrl,
@@ -140,6 +158,7 @@ function loadRecordingConfig() {
140
158
  quality,
141
159
  kinds,
142
160
  rrwebSource,
161
+ rrwebMaxEvents,
143
162
  };
144
163
  }
145
164
 
@@ -194,14 +213,18 @@ async function bbCreateSession() {
194
213
 
195
214
  log(`[bb] session create advancedStealth=${advancedStealth} proxies=${proxies}`);
196
215
 
197
- const res = await fetch(`${BB_BASE}/v1/sessions`, {
198
- method: "POST",
199
- headers: {
200
- "X-BB-API-Key": apiKey,
201
- "Content-Type": "application/json",
216
+ const res = await retryableFetch(
217
+ `${BB_BASE}/v1/sessions`,
218
+ {
219
+ method: "POST",
220
+ headers: {
221
+ "X-BB-API-Key": apiKey,
222
+ "Content-Type": "application/json",
223
+ },
224
+ body: JSON.stringify(body),
202
225
  },
203
- body: JSON.stringify(body),
204
- });
226
+ "bb.create",
227
+ );
205
228
  if (!res.ok) {
206
229
  throw new Error(
207
230
  `Browserbase create failed (${res.status}): ${await safeText(res)}`,
@@ -212,9 +235,11 @@ async function bbCreateSession() {
212
235
 
213
236
  async function bbGetLiveUrl(sessionId) {
214
237
  const apiKey = process.env.BROWSERBASE_API_KEY;
215
- const res = await fetch(`${BB_BASE}/v1/sessions/${sessionId}/debug`, {
216
- headers: { "X-BB-API-Key": apiKey },
217
- });
238
+ const res = await retryableFetch(
239
+ `${BB_BASE}/v1/sessions/${sessionId}/debug`,
240
+ { headers: { "X-BB-API-Key": apiKey } },
241
+ "bb.debug",
242
+ );
218
243
  if (!res.ok) {
219
244
  throw new Error(
220
245
  `Browserbase debug fetch failed (${res.status}): ${await safeText(res)}`,
@@ -228,11 +253,15 @@ async function bbReleaseSession(sessionId) {
228
253
  const apiKey = process.env.BROWSERBASE_API_KEY;
229
254
  const projectId = process.env.BROWSERBASE_PROJECT_ID;
230
255
  try {
231
- await fetch(`${BB_BASE}/v1/sessions/${sessionId}`, {
232
- method: "POST",
233
- headers: { "X-BB-API-Key": apiKey, "Content-Type": "application/json" },
234
- body: JSON.stringify({ projectId, status: "REQUEST_RELEASE" }),
235
- });
256
+ await retryableFetch(
257
+ `${BB_BASE}/v1/sessions/${sessionId}`,
258
+ {
259
+ method: "POST",
260
+ headers: { "X-BB-API-Key": apiKey, "Content-Type": "application/json" },
261
+ body: JSON.stringify({ projectId, status: "REQUEST_RELEASE" }),
262
+ },
263
+ "bb.release",
264
+ );
236
265
  } catch (e) {
237
266
  log(`[shutdown] release session ${sessionId} failed: ${e.message}`);
238
267
  }
@@ -246,6 +275,44 @@ async function safeText(res) {
246
275
  }
247
276
  }
248
277
 
278
+ // Retry transient network + 5xx/429 failures with short exponential backoff.
279
+ // Each attempt gets its own AbortController + timeout; caller-passed signals
280
+ // are not plumbed through since we don't have a cancellation story above this
281
+ // layer. Non-retryable statuses (4xx except 429) are returned immediately for
282
+ // the caller to handle.
283
+ async function retryableFetch(url, opts = {}, label, { timeoutMs = 30_000 } = {}) {
284
+ const delays = [100, 500];
285
+ let lastErr = null;
286
+ let lastRes = null;
287
+ for (let attempt = 0; attempt <= delays.length; attempt++) {
288
+ if (attempt > 0) {
289
+ await new Promise((r) => setTimeout(r, delays[attempt - 1]));
290
+ const prev = lastRes
291
+ ? `status=${lastRes.status}`
292
+ : `err=${lastErr?.message || lastErr}`;
293
+ log(`[retry] ${label} attempt ${attempt + 1}/3 (${prev})`);
294
+ }
295
+ const controller = new AbortController();
296
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
297
+ try {
298
+ const res = await fetch(url, { ...opts, signal: controller.signal });
299
+ if (res.ok) return res;
300
+ if (res.status === 429 || (res.status >= 500 && res.status < 600)) {
301
+ lastRes = res;
302
+ continue;
303
+ }
304
+ return res;
305
+ } catch (e) {
306
+ lastErr = e;
307
+ continue;
308
+ } finally {
309
+ clearTimeout(timer);
310
+ }
311
+ }
312
+ if (lastRes) return lastRes;
313
+ throw lastErr;
314
+ }
315
+
249
316
  // --- Session cache ----------------------------------------------------------
250
317
 
251
318
  const sessions = new Map(); // name -> { sid, browser, context, page, liveUrl, recording }
@@ -253,32 +320,48 @@ const sessions = new Map(); // name -> { sid, browser, context, page, liveUrl, r
253
320
  async function ensureSession(name) {
254
321
  if (sessions.has(name)) return sessions.get(name);
255
322
 
323
+ // Browserbase charges for the session the moment it's created; if anything
324
+ // after this point throws (debug URL, CDP connect, newContext, recording
325
+ // setup) we must release it explicitly or quota leaks until BB's idle
326
+ // timeout.
256
327
  const created = await bbCreateSession();
257
- const liveUrl = await bbGetLiveUrl(created.id);
258
- const browser = await chromium.connectOverCDP(created.connectUrl);
259
- const context = browser.contexts()[0] ?? (await browser.newContext());
260
- const page = context.pages()[0] ?? (await context.newPage());
261
-
262
- const info = {
263
- sid: created.id,
264
- browser,
265
- context,
266
- page,
267
- liveUrl,
268
- recording: null,
269
- };
270
- sessions.set(name, info);
271
-
272
- send({
273
- type: "slice.session_started",
274
- session: name,
275
- session_id: created.id,
276
- live_url: liveUrl,
277
- started_at: new Date().toISOString(),
278
- });
328
+ let browser = null;
329
+ try {
330
+ const liveUrl = await bbGetLiveUrl(created.id);
331
+ browser = await chromium.connectOverCDP(created.connectUrl);
332
+ const context = browser.contexts()[0] ?? (await browser.newContext());
333
+ const page = context.pages()[0] ?? (await context.newPage());
334
+
335
+ const info = {
336
+ sid: created.id,
337
+ browser,
338
+ context,
339
+ page,
340
+ liveUrl,
341
+ recording: null,
342
+ };
343
+ sessions.set(name, info);
344
+
345
+ send({
346
+ type: "slice.session_started",
347
+ session: name,
348
+ session_id: created.id,
349
+ live_url: liveUrl,
350
+ started_at: new Date().toISOString(),
351
+ });
279
352
 
280
- await startRecording(info, name);
281
- return info;
353
+ await startRecording(info, name);
354
+ return info;
355
+ } catch (e) {
356
+ if (browser) {
357
+ try {
358
+ await browser.close();
359
+ } catch {}
360
+ }
361
+ sessions.delete(name);
362
+ await bbReleaseSession(created.id);
363
+ throw e;
364
+ }
282
365
  }
283
366
 
284
367
  // --- Recording (rrweb + CDP screencast) ------------------------------------
@@ -301,6 +384,8 @@ async function startRecording(info, sessionName) {
301
384
  info.recording = {
302
385
  kinds: { ...RECORDING.kinds },
303
386
  rrwebEvents: [],
387
+ rrwebDropped: 0,
388
+ rrwebOverflowLogged: false,
304
389
  cdp: null,
305
390
  ffmpeg: null,
306
391
  ffmpegDone: null,
@@ -308,11 +393,28 @@ async function startRecording(info, sessionName) {
308
393
  };
309
394
  const rec = info.recording;
310
395
 
396
+ // Drop oldest events once the buffer exceeds the cap — keeps the tail of a
397
+ // long run (usually the interesting bit) rather than failing the upload or
398
+ // OOMing the sidecar. One warning per session so ops can spot it.
399
+ const pushRrweb = (e) => {
400
+ if (rec.rrwebEvents.length >= RECORDING.rrwebMaxEvents) {
401
+ rec.rrwebEvents.shift();
402
+ rec.rrwebDropped++;
403
+ if (!rec.rrwebOverflowLogged) {
404
+ rec.rrwebOverflowLogged = true;
405
+ log(
406
+ `[recording] rrweb buffer hit cap (${RECORDING.rrwebMaxEvents}); dropping oldest events`,
407
+ );
408
+ }
409
+ }
410
+ rec.rrwebEvents.push(e);
411
+ };
412
+
311
413
  if (rec.kinds.rrweb) {
312
414
  try {
313
415
  await info.context.exposeBinding("__wbRrwebFlush", (_src, batch) => {
314
416
  if (Array.isArray(batch)) {
315
- for (const e of batch) rec.rrwebEvents.push(e);
417
+ for (const e of batch) pushRrweb(e);
316
418
  }
317
419
  });
318
420
  const bootstrap = `
@@ -397,10 +499,56 @@ async function startRecording(info, sessionName) {
397
499
 
398
500
  const cdp = await info.context.newCDPSession(info.page);
399
501
  rec.cdp = cdp;
502
+ // Dedup identical consecutive frames. CDP emits repeats when nothing
503
+ // changed on screen; encoding them as distinct frames bloats the WebM
504
+ // and mis-paces playback. Compare the base64 string directly — it's
505
+ // cheaper than hashing and equivalent for exact equality.
506
+ let lastFrameData = null;
507
+ let dedupCount = 0;
508
+ let dedupLogged = false;
509
+
400
510
  cdp.on("Page.screencastFrame", async (frame) => {
401
511
  try {
402
512
  if (ff.stdin.writable && !ff.killed) {
403
- ff.stdin.write(Buffer.from(frame.data, "base64"));
513
+ if (frame.data === lastFrameData) {
514
+ dedupCount++;
515
+ if (!dedupLogged && dedupCount >= 100) {
516
+ dedupLogged = true;
517
+ log(
518
+ `[recording] dedup active (${dedupCount} duplicate frames skipped so far)`,
519
+ );
520
+ }
521
+ // Still ack — Chrome needs it to keep streaming.
522
+ await cdp.send("Page.screencastFrameAck", {
523
+ sessionId: frame.sessionId,
524
+ });
525
+ return;
526
+ }
527
+ lastFrameData = frame.data;
528
+ const buf = Buffer.from(frame.data, "base64");
529
+ const ok = ff.stdin.write(buf);
530
+ // Backpressure: if ffmpeg's stdin buffer is full, wait for drain
531
+ // before acking so Chrome slows frame production instead of
532
+ // piling JPEG frames in Node heap. 5s fail-open so a wedged
533
+ // ffmpeg can't stall the protocol indefinitely.
534
+ if (!ok) {
535
+ await new Promise((resolve) => {
536
+ let fired = false;
537
+ const done = () => {
538
+ if (fired) return;
539
+ fired = true;
540
+ ff.stdin.off("drain", done);
541
+ ff.stdin.off("close", done);
542
+ ff.stdin.off("error", done);
543
+ clearTimeout(timer);
544
+ resolve();
545
+ };
546
+ const timer = setTimeout(done, 5000);
547
+ ff.stdin.once("drain", done);
548
+ ff.stdin.once("close", done);
549
+ ff.stdin.once("error", done);
550
+ });
551
+ }
404
552
  }
405
553
  // Must ack each frame or Chrome stops streaming.
406
554
  await cdp.send("Page.screencastFrameAck", {
@@ -453,7 +601,13 @@ async function flushRecording(info, sessionName) {
453
601
  return out;
454
602
  });
455
603
  if (Array.isArray(tail)) {
456
- for (const e of tail) rec.rrwebEvents.push(e);
604
+ for (const e of tail) {
605
+ if (rec.rrwebEvents.length >= RECORDING.rrwebMaxEvents) {
606
+ rec.rrwebEvents.shift();
607
+ rec.rrwebDropped++;
608
+ }
609
+ rec.rrwebEvents.push(e);
610
+ }
457
611
  }
458
612
  } catch (e) {
459
613
  log(`[recording] rrweb final drain failed: ${e.message}`);
@@ -464,6 +618,7 @@ async function flushRecording(info, sessionName) {
464
618
  run_id: RECORDING.runId,
465
619
  session: sessionName,
466
620
  event_count: rec.rrwebEvents.length,
621
+ dropped: rec.rrwebDropped,
467
622
  events: rec.rrwebEvents,
468
623
  });
469
624
  rrwebBody = await gzip(Buffer.from(json, "utf8"));
@@ -474,31 +629,46 @@ async function flushRecording(info, sessionName) {
474
629
  }
475
630
 
476
631
  let videoBody = null;
632
+ let videoFailure = null;
477
633
  if (rec.kinds.video && rec.cdp && rec.ffmpeg) {
478
634
  try {
479
635
  await rec.cdp.send("Page.stopScreencast");
480
636
  } catch {
481
637
  // Browser may already be tearing down.
482
638
  }
639
+ const timeoutMs =
640
+ Number.parseInt(process.env.WB_RECORDING_FFMPEG_TIMEOUT_MS || "", 10) ||
641
+ 30_000;
483
642
  try {
484
643
  rec.ffmpeg.stdin.end();
485
644
  const settled = await Promise.race([
486
645
  rec.ffmpegDone,
487
- new Promise((r) => setTimeout(() => r("timeout"), 15_000)),
646
+ new Promise((r) =>
647
+ setTimeout(() => r({ __timeout: true }), timeoutMs),
648
+ ),
488
649
  ]);
489
- if (settled === "timeout") {
490
- log("[recording] ffmpeg did not exit within 15s; killing");
650
+ if (settled && typeof settled === "object" && settled.__timeout) {
651
+ log(`[recording] ffmpeg did not exit within ${timeoutMs}ms; killing`);
491
652
  try {
492
653
  rec.ffmpeg.kill("SIGKILL");
493
654
  } catch {}
655
+ videoFailure = `ffmpeg_timeout_${timeoutMs}ms`;
656
+ } else if (typeof settled === "number" && settled !== 0) {
657
+ // ff.on('close') resolves with the exit code — non-zero means ffmpeg
658
+ // produced a corrupt/partial webm that we should not upload.
659
+ videoFailure = `ffmpeg_exit_code_${settled}`;
660
+ log(`[recording] ffmpeg exited with code ${settled}`);
494
661
  }
495
- if (rec.videoPath && existsSync(rec.videoPath)) {
662
+ if (!videoFailure && rec.videoPath && existsSync(rec.videoPath)) {
496
663
  videoBody = await fsPromises.readFile(rec.videoPath);
664
+ }
665
+ if (rec.videoPath && existsSync(rec.videoPath)) {
497
666
  try {
498
667
  await fsPromises.unlink(rec.videoPath);
499
668
  } catch {}
500
669
  }
501
670
  } catch (e) {
671
+ videoFailure = `finalize_error: ${e.message}`;
502
672
  log(`[recording] video finalize failed: ${e.message}`);
503
673
  }
504
674
  }
@@ -521,6 +691,16 @@ async function flushRecording(info, sessionName) {
521
691
  fps: RECORDING.fps,
522
692
  }),
523
693
  );
694
+ } else if (videoFailure) {
695
+ // Surface a terminal recording failure to the callback stream so the
696
+ // consumer knows the video was lost rather than silently missing.
697
+ send({
698
+ type: "slice.recording.failed",
699
+ session: sessionName,
700
+ run_id: RECORDING.runId,
701
+ kind: "video",
702
+ reason: videoFailure,
703
+ });
524
704
  }
525
705
  await Promise.allSettled(uploads);
526
706
  }
@@ -529,21 +709,23 @@ async function uploadArtifact(kind, body, contentType, sessionName, extra) {
529
709
  const url = RECORDING.uploadUrl
530
710
  .replace("{run_id}", encodeURIComponent(RECORDING.runId))
531
711
  .replace("{kind}", encodeURIComponent(kind));
532
- const controller = new AbortController();
533
- const timer = setTimeout(() => controller.abort(), 30_000);
534
712
  try {
535
- const res = await fetch(url, {
536
- method: "POST",
537
- headers: {
538
- Authorization: `Bearer ${RECORDING.secret}`,
539
- "Content-Type": contentType,
540
- "X-WB-Run-Id": RECORDING.runId,
541
- "X-WB-Recording-Kind": kind,
542
- "X-WB-Session": sessionName,
713
+ const res = await retryableFetch(
714
+ url,
715
+ {
716
+ method: "POST",
717
+ headers: {
718
+ Authorization: `Bearer ${RECORDING.secret}`,
719
+ "Content-Type": contentType,
720
+ "X-WB-Run-Id": RECORDING.runId,
721
+ "X-WB-Recording-Kind": kind,
722
+ "X-WB-Session": sessionName,
723
+ },
724
+ body,
543
725
  },
544
- body,
545
- signal: controller.signal,
546
- });
726
+ `upload.${kind}`,
727
+ { timeoutMs: 30_000 },
728
+ );
547
729
  if (!res.ok) {
548
730
  send({
549
731
  type: "slice.recording.failed",
@@ -571,8 +753,6 @@ async function uploadArtifact(kind, body, contentType, sessionName, extra) {
571
753
  kind,
572
754
  reason: e.name === "AbortError" ? "timeout" : e.message,
573
755
  });
574
- } finally {
575
- clearTimeout(timer);
576
756
  }
577
757
  }
578
758
 
@@ -583,51 +763,114 @@ function sanitize(s) {
583
763
  // --- {{ env.X }} / {{ artifacts.X }} substitution --------------------------
584
764
 
585
765
  const ENV_RE = /\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
586
- const ARTIFACT_RE = /\{\{\s*artifacts\.([A-Za-z_][A-Za-z0-9_.-]*)\s*\}\}/g;
766
+ // Artifact names are bare identifiers — no dots, no slashes. Anything more
767
+ // exotic would invite path traversal once composed with WB_ARTIFACTS_DIR.
768
+ const ARTIFACT_RE = /\{\{\s*artifacts\.([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}/g;
769
+
770
+ function resolveInside(dir, candidate) {
771
+ const resolvedDir = path.resolve(dir);
772
+ const resolved = path.resolve(resolvedDir, candidate);
773
+ const rel = path.relative(resolvedDir, resolved);
774
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return null;
775
+ return resolved;
776
+ }
587
777
 
588
- function readArtifact(name) {
778
+ // Resolved once at module load. `warn` matches historical behavior
779
+ // (log + empty string, runbook continues). `error` throws so a missing OTP
780
+ // or env var fails the slice instead of silently sending an empty value
781
+ // into a Playwright action. `empty` is the silent variant.
782
+ const ON_MISSING = (() => {
783
+ const raw = (process.env.WB_SUBSTITUTION_ON_MISSING || "warn")
784
+ .trim()
785
+ .toLowerCase();
786
+ if (raw === "error" || raw === "empty" || raw === "warn") return raw;
787
+ log(
788
+ `[warn] WB_SUBSTITUTION_ON_MISSING=${raw} is not valid (warn|error|empty); defaulting to warn`,
789
+ );
790
+ return "warn";
791
+ })();
792
+
793
+ function handleMissingSubstitution(kind, name) {
794
+ const msg = `${kind}.${name} is not set`;
795
+ if (ON_MISSING === "error") {
796
+ throw new Error(`substitution: ${msg}`);
797
+ }
798
+ if (ON_MISSING === "warn") {
799
+ log(`[warn] ${msg}; substituting empty string`);
800
+ }
801
+ return "";
802
+ }
803
+
804
+ function readArtifactRaw(name) {
589
805
  const dir = (process.env.WB_ARTIFACTS_DIR || "").trim();
590
806
  if (!dir) {
591
807
  log(`[warn] artifacts.${name} referenced but WB_ARTIFACTS_DIR is not set`);
592
- return "";
808
+ return null;
593
809
  }
594
- // Per-verb read (no cache) so a bash cell that writes the artifact between
595
- // slices is always picked up by the next browser verb.
596
- for (const p of [path.join(dir, `${name}.txt`), path.join(dir, name)]) {
810
+ for (const candidate of [`${name}.txt`, name]) {
811
+ const full = resolveInside(dir, candidate);
812
+ if (!full) continue;
597
813
  try {
598
- return readFileSync(p, "utf8").trimEnd();
814
+ return readFileSync(full, "utf8").trimEnd();
599
815
  } catch {
600
816
  // try next candidate
601
817
  }
602
818
  }
603
- log(`[warn] artifact ${name} not found in ${dir}; leaving placeholder`);
604
- return "";
819
+ return null;
820
+ }
821
+
822
+ function readArtifact(name, cache) {
823
+ if (cache && cache.has(name)) {
824
+ const hit = cache.get(name);
825
+ if (hit === null) return handleMissingSubstitution("artifacts", name);
826
+ return hit;
827
+ }
828
+ const v = readArtifactRaw(name);
829
+ if (cache) cache.set(name, v);
830
+ if (v === null) return handleMissingSubstitution("artifacts", name);
831
+ return v;
605
832
  }
606
833
 
607
- function expand(value) {
834
+ function expand(value, collected, artifactCache) {
608
835
  if (typeof value === "string") {
609
836
  return value
610
837
  .replace(ENV_RE, (_, name) => {
611
838
  const v = process.env[name];
612
- if (v === undefined) {
613
- // Leave the placeholder visible so failures surface in stderr
614
- // summaries instead of silently turning into empty strings.
615
- log(`[warn] env var ${name} is not set; leaving placeholder`);
616
- return "";
617
- }
839
+ if (v === undefined) return handleMissingSubstitution("env", name);
840
+ if (collected && v.length >= 3) collected.add(v);
618
841
  return v;
619
842
  })
620
- .replace(ARTIFACT_RE, (_, name) => readArtifact(name));
843
+ .replace(ARTIFACT_RE, (_, name) => {
844
+ const v = readArtifact(name, artifactCache);
845
+ if (collected && v && v.length >= 3) collected.add(v);
846
+ return v;
847
+ });
621
848
  }
622
- if (Array.isArray(value)) return value.map(expand);
849
+ if (Array.isArray(value))
850
+ return value.map((v) => expand(v, collected, artifactCache));
623
851
  if (value && typeof value === "object") {
624
852
  const out = {};
625
- for (const [k, v] of Object.entries(value)) out[k] = expand(v);
853
+ for (const [k, v] of Object.entries(value))
854
+ out[k] = expand(v, collected, artifactCache);
626
855
  return out;
627
856
  }
628
857
  return value;
629
858
  }
630
859
 
860
+ // Scrub any values that came from {{ env.X }} / {{ artifacts.X }} expansion
861
+ // out of error messages before they cross the stdio boundary — Playwright and
862
+ // fetch errors sometimes echo their inputs (URLs, script bodies, assertion
863
+ // text) and those inputs may contain credentials.
864
+ function scrubSecrets(msg, secrets) {
865
+ let out = String(msg == null ? "" : msg);
866
+ if (!secrets) return out;
867
+ for (const s of secrets) {
868
+ if (!s) continue;
869
+ out = out.split(s).join("«***»");
870
+ }
871
+ return out;
872
+ }
873
+
631
874
  // --- Verb dispatch ----------------------------------------------------------
632
875
 
633
876
  function verbName(verb) {
@@ -647,7 +890,11 @@ function arg(value, primaryKey) {
647
890
  async function runVerb(page, verb, index, ctx) {
648
891
  const name = verbName(verb);
649
892
  const raw = verb[name];
650
- const a = expand(arg(raw, defaultKey(name)));
893
+ const a = expand(
894
+ arg(raw, defaultKey(name)),
895
+ ctx?.secrets,
896
+ ctx?.artifactCache,
897
+ );
651
898
 
652
899
  switch (name) {
653
900
  case "goto": {
@@ -682,15 +929,42 @@ async function runVerb(page, verb, index, ctx) {
682
929
  return `${selector} (${state})`;
683
930
  }
684
931
  case "screenshot": {
685
- // Relative paths resolve into $WB_ARTIFACTS_DIR so wb's main-loop
686
- // `artifacts.sync()` picks them up and uploads to R2. Absolute paths
687
- // are respected as-is (escape hatch for one-off local dumps).
932
+ // Always resolve inside $WB_ARTIFACTS_DIR (or cwd when unset). Absolute
933
+ // paths and traversals are rejected screenshots are controlled by
934
+ // runbook authors whose content we don't want to grant arbitrary-write.
688
935
  const requested = a.path ?? `screenshot-${Date.now()}.png`;
689
- const artifactsDir = (process.env.WB_ARTIFACTS_DIR || "").trim();
690
- const full = path.isAbsolute(requested)
691
- ? requested
692
- : path.join(artifactsDir || ".", requested);
693
- await page.screenshot({ path: full, fullPage: !!a.full_page });
936
+ const artifactsDir = (process.env.WB_ARTIFACTS_DIR || "").trim() || ".";
937
+ if (path.isAbsolute(requested)) {
938
+ throw new Error(
939
+ `screenshot: absolute paths are not allowed (got ${requested})`,
940
+ );
941
+ }
942
+ const full = resolveInside(artifactsDir, requested);
943
+ if (!full) {
944
+ throw new Error(
945
+ `screenshot: path escapes artifacts dir (got ${requested})`,
946
+ );
947
+ }
948
+ await fsPromises.mkdir(path.dirname(full), { recursive: true });
949
+ // Atomic write via tmp + rename so a crash mid-capture can't leave a
950
+ // truncated PNG that's already been announced via slice.artifact_saved
951
+ // and uploaded to R2. We capture to a Buffer (with `type` derived from
952
+ // the requested extension) and write it ourselves — passing a `.tmp`
953
+ // path directly to Playwright fails because it infers format from the
954
+ // file extension and rejects unknown ones.
955
+ const ext = path.extname(full).toLowerCase();
956
+ const type = ext === ".jpg" || ext === ".jpeg" ? "jpeg" : "png";
957
+ const tmp = `${full}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
958
+ try {
959
+ const buf = await page.screenshot({ type, fullPage: !!a.full_page });
960
+ await fsPromises.writeFile(tmp, buf);
961
+ await fsPromises.rename(tmp, full);
962
+ } catch (e) {
963
+ try {
964
+ await fsPromises.unlink(tmp);
965
+ } catch {}
966
+ throw e;
967
+ }
694
968
  return `→ ${requested}`;
695
969
  }
696
970
  case "extract": {
@@ -777,16 +1051,24 @@ async function runVerb(page, verb, index, ctx) {
777
1051
  const filename = name.endsWith(".json") ? name : `${name}.json`;
778
1052
  const full = path.join(artifactsDir, filename);
779
1053
  await fsPromises.mkdir(artifactsDir, { recursive: true });
780
- await fsPromises.writeFile(
781
- full,
782
- JSON.stringify(payload, null, 2),
783
- "utf8",
784
- );
1054
+ // Atomic write: serialize to .tmp, then rename. Announce the artifact
1055
+ // AFTER rename so a partial write can never be seen by wb's uploader.
1056
+ const serialized = JSON.stringify(payload, null, 2);
1057
+ const tmp = `${full}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
1058
+ try {
1059
+ await fsPromises.writeFile(tmp, serialized, "utf8");
1060
+ await fsPromises.rename(tmp, full);
1061
+ } catch (e) {
1062
+ try {
1063
+ await fsPromises.unlink(tmp);
1064
+ } catch {}
1065
+ throw e;
1066
+ }
785
1067
  send({
786
1068
  type: "slice.artifact_saved",
787
1069
  filename,
788
1070
  path: full,
789
- bytes: Buffer.byteLength(JSON.stringify(payload)),
1071
+ bytes: Buffer.byteLength(serialized),
790
1072
  });
791
1073
  return `→ ${filename}`;
792
1074
  }
@@ -837,57 +1119,79 @@ function redact(value) {
837
1119
  // --- Slice handler ----------------------------------------------------------
838
1120
 
839
1121
  async function handleSlice(msg) {
840
- const verbs = Array.isArray(msg.verbs) ? msg.verbs : [];
841
- const sessionName = msg.session || "default";
842
- const restore = msg.restore || null;
843
- const blockIndex =
844
- typeof msg.block_index === "number" ? msg.block_index : null;
845
-
846
- let session;
1122
+ // Declared outside the inner try so the outer catch can scrub error
1123
+ // messages using whatever secrets were collected before the throw.
1124
+ const sliceCtx = {
1125
+ lastResult: undefined,
1126
+ blockIndex:
1127
+ typeof msg?.block_index === "number" ? msg.block_index : null,
1128
+ secrets: new Set(),
1129
+ // Per-slice cache so `{{ artifacts.otp }}` referenced from 5 verbs
1130
+ // hits disk once instead of 5× and doesn't block the event loop
1131
+ // per-verb. Freshness across slices is preserved because the cache is
1132
+ // scoped to one slice — a bash cell that rewrites the file between
1133
+ // slices is seen on the next slice's first read.
1134
+ artifactCache: new Map(),
1135
+ };
1136
+ // Top-level guard: any unhandled error must emit slice.failed so the Rust
1137
+ // side sees a terminal frame instead of waiting out SLICE_EVENT_TIMEOUT.
847
1138
  try {
848
- session = await ensureSession(sessionName);
849
- } catch (e) {
850
- send({
851
- type: "slice.failed",
852
- error: `session start failed: ${e.message}`,
853
- });
854
- return;
855
- }
1139
+ const verbs = Array.isArray(msg.verbs) ? msg.verbs : [];
1140
+ const sessionName = msg.session || "default";
1141
+ const restore = msg.restore || null;
856
1142
 
857
- // Restore-from-pause is not implemented yet (no pause verb wired here).
858
- // The sidecar protocol leaves room for it; when wait_for_mfa lands, this
859
- // is where we'd jump to verbs[restore.state.verb_index].
860
- const startAt = restore?.state?.verb_index ?? 0;
861
-
862
- // Per-slice scratch so `save:` can capture the prior verb's JSON output.
863
- const sliceCtx = { lastResult: undefined, blockIndex };
864
-
865
- for (let i = startAt; i < verbs.length; i++) {
866
- const v = verbs[i];
867
- const name = verbName(v);
1143
+ let session;
868
1144
  try {
869
- const summary = await runVerb(session.page, v, i, sliceCtx);
870
- send({
871
- type: "verb.complete",
872
- verb: name,
873
- verb_index: i,
874
- summary,
875
- });
1145
+ session = await ensureSession(sessionName);
876
1146
  } catch (e) {
877
- send({
878
- type: "verb.failed",
879
- verb: name,
880
- verb_index: i,
881
- error: e.message,
882
- });
883
1147
  send({
884
1148
  type: "slice.failed",
885
- error: `verb ${name} (index ${i}): ${e.message}`,
1149
+ error: `session start failed: ${scrubSecrets(e.message, sliceCtx.secrets)}`,
886
1150
  });
887
1151
  return;
888
1152
  }
1153
+
1154
+ // Restore-from-pause is not implemented yet (no pause verb wired here).
1155
+ // The sidecar protocol leaves room for it; when wait_for_mfa lands, this
1156
+ // is where we'd jump to verbs[restore.state.verb_index].
1157
+ const startAt = restore?.state?.verb_index ?? 0;
1158
+
1159
+ for (let i = startAt; i < verbs.length; i++) {
1160
+ const v = verbs[i];
1161
+ const name = verbName(v);
1162
+ try {
1163
+ const summary = await runVerb(session.page, v, i, sliceCtx);
1164
+ send({
1165
+ type: "verb.complete",
1166
+ verb: name,
1167
+ verb_index: i,
1168
+ summary,
1169
+ });
1170
+ } catch (e) {
1171
+ const clean = scrubSecrets(e.message, sliceCtx.secrets);
1172
+ send({
1173
+ type: "verb.failed",
1174
+ verb: name,
1175
+ verb_index: i,
1176
+ error: clean,
1177
+ });
1178
+ send({
1179
+ type: "slice.failed",
1180
+ error: `verb ${name} (index ${i}): ${clean}`,
1181
+ });
1182
+ return;
1183
+ }
1184
+ }
1185
+ send({ type: "slice.complete" });
1186
+ } catch (e) {
1187
+ log(`[slice] unhandled: ${e.stack || e.message}`);
1188
+ try {
1189
+ send({
1190
+ type: "slice.failed",
1191
+ error: `sidecar error: ${scrubSecrets(e.message, sliceCtx.secrets)}`,
1192
+ });
1193
+ } catch {}
889
1194
  }
890
- send({ type: "slice.complete" });
891
1195
  }
892
1196
 
893
1197
  // --- Shutdown ---------------------------------------------------------------
@@ -927,8 +1231,17 @@ const rl = readline.createInterface({ input: process.stdin, terminal: false });
927
1231
  // Serialize incoming messages — Playwright operations are async and we don't
928
1232
  // want concurrent slice handlers stomping on the shared page.
929
1233
  let chain = Promise.resolve();
930
- function enqueue(fn) {
931
- chain = chain.then(fn).catch((e) => log(`[loop] ${e.message}`));
1234
+ function enqueue(fn, kind) {
1235
+ chain = chain.then(fn).catch((e) => {
1236
+ log(`[loop] ${e.stack || e.message}`);
1237
+ // Last-resort terminal frame so a bug in the handler can never strand
1238
+ // the Rust parent waiting for a slice to finish.
1239
+ if (kind === "slice") {
1240
+ try {
1241
+ send({ type: "slice.failed", error: `sidecar loop error: ${e.message}` });
1242
+ } catch {}
1243
+ }
1244
+ });
932
1245
  return chain;
933
1246
  }
934
1247
 
@@ -954,7 +1267,7 @@ rl.on("line", (line) => {
954
1267
  });
955
1268
  break;
956
1269
  case "slice":
957
- enqueue(() => handleSlice(msg));
1270
+ enqueue(() => handleSlice(msg), "slice");
958
1271
  break;
959
1272
  case "shutdown":
960
1273
  enqueue(shutdown);
@@ -968,3 +1281,20 @@ rl.on("close", () => {
968
1281
  // stdin closed — drain pending work then exit.
969
1282
  enqueue(shutdown);
970
1283
  });
1284
+
1285
+ // If the Rust parent SIGTERMs us (timeout, abort, crash), Node's default is
1286
+ // to exit without running shutdown() — which leaves ffmpeg processes and
1287
+ // Browserbase sessions orphaned. Route signals through the same drain path.
1288
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
1289
+ process.on(sig, () => {
1290
+ log(`[shutdown] received ${sig}`);
1291
+ enqueue(shutdown);
1292
+ });
1293
+ }
1294
+
1295
+ // Log unhandled rejections so a dropped promise doesn't exit the process
1296
+ // silently between slices. The top-level guards in handleSlice / enqueue
1297
+ // cover the hot paths; this catches background work (recording uploads, etc).
1298
+ process.on("unhandledRejection", (reason) => {
1299
+ log(`[unhandledRejection] ${reason?.stack || reason}`);
1300
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wb-browser-runtime",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "description": "Browser sidecar runtime for wb — Browserbase + Playwright over the wb-sidecar/1 line-framed JSON protocol.",
5
5
  "bin": {
6
6
  "wb-browser-runtime": "bin/wb-browser-runtime.js"