wb-browser-runtime 0.5.2 → 0.6.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 +9 -3
- package/bin/wb-browser-runtime.js +468 -137
- package/package.json +1 -1
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
"
|
|
201
|
-
|
|
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
|
-
|
|
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
|
|
216
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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) =>
|
|
646
|
+
new Promise((r) =>
|
|
647
|
+
setTimeout(() => r({ __timeout: true }), timeoutMs),
|
|
648
|
+
),
|
|
488
649
|
]);
|
|
489
|
-
if (settled === "
|
|
490
|
-
log(
|
|
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
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
777
|
+
|
|
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
|
+
}
|
|
587
803
|
|
|
588
|
-
function
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
810
|
+
for (const candidate of [`${name}.txt`, name]) {
|
|
811
|
+
const full = resolveInside(dir, candidate);
|
|
812
|
+
if (!full) continue;
|
|
597
813
|
try {
|
|
598
|
-
return readFileSync(
|
|
814
|
+
return readFileSync(full, "utf8").trimEnd();
|
|
599
815
|
} catch {
|
|
600
816
|
// try next candidate
|
|
601
817
|
}
|
|
602
818
|
}
|
|
603
|
-
|
|
604
|
-
return "";
|
|
819
|
+
return null;
|
|
605
820
|
}
|
|
606
821
|
|
|
607
|
-
function
|
|
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;
|
|
832
|
+
}
|
|
833
|
+
|
|
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
|
-
|
|
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) =>
|
|
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))
|
|
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))
|
|
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(
|
|
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,9 +929,37 @@ async function runVerb(page, verb, index, ctx) {
|
|
|
682
929
|
return `${selector} (${state})`;
|
|
683
930
|
}
|
|
684
931
|
case "screenshot": {
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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.
|
|
935
|
+
const requested = a.path ?? `screenshot-${Date.now()}.png`;
|
|
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
|
+
// Write to a unique .tmp first, then atomically rename, so a crash
|
|
950
|
+
// mid-capture can't leave a truncated PNG that's already been announced
|
|
951
|
+
// via slice.artifact_saved and uploaded to R2.
|
|
952
|
+
const tmp = `${full}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
953
|
+
try {
|
|
954
|
+
await page.screenshot({ path: tmp, fullPage: !!a.full_page });
|
|
955
|
+
await fsPromises.rename(tmp, full);
|
|
956
|
+
} catch (e) {
|
|
957
|
+
try {
|
|
958
|
+
await fsPromises.unlink(tmp);
|
|
959
|
+
} catch {}
|
|
960
|
+
throw e;
|
|
961
|
+
}
|
|
962
|
+
return `→ ${requested}`;
|
|
688
963
|
}
|
|
689
964
|
case "extract": {
|
|
690
965
|
// Pull structured rows out of the page. Each `field` entry is either:
|
|
@@ -770,16 +1045,24 @@ async function runVerb(page, verb, index, ctx) {
|
|
|
770
1045
|
const filename = name.endsWith(".json") ? name : `${name}.json`;
|
|
771
1046
|
const full = path.join(artifactsDir, filename);
|
|
772
1047
|
await fsPromises.mkdir(artifactsDir, { recursive: true });
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
1048
|
+
// Atomic write: serialize to .tmp, then rename. Announce the artifact
|
|
1049
|
+
// AFTER rename so a partial write can never be seen by wb's uploader.
|
|
1050
|
+
const serialized = JSON.stringify(payload, null, 2);
|
|
1051
|
+
const tmp = `${full}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
1052
|
+
try {
|
|
1053
|
+
await fsPromises.writeFile(tmp, serialized, "utf8");
|
|
1054
|
+
await fsPromises.rename(tmp, full);
|
|
1055
|
+
} catch (e) {
|
|
1056
|
+
try {
|
|
1057
|
+
await fsPromises.unlink(tmp);
|
|
1058
|
+
} catch {}
|
|
1059
|
+
throw e;
|
|
1060
|
+
}
|
|
778
1061
|
send({
|
|
779
1062
|
type: "slice.artifact_saved",
|
|
780
1063
|
filename,
|
|
781
1064
|
path: full,
|
|
782
|
-
bytes: Buffer.byteLength(
|
|
1065
|
+
bytes: Buffer.byteLength(serialized),
|
|
783
1066
|
});
|
|
784
1067
|
return `→ ${filename}`;
|
|
785
1068
|
}
|
|
@@ -830,57 +1113,79 @@ function redact(value) {
|
|
|
830
1113
|
// --- Slice handler ----------------------------------------------------------
|
|
831
1114
|
|
|
832
1115
|
async function handleSlice(msg) {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1116
|
+
// Declared outside the inner try so the outer catch can scrub error
|
|
1117
|
+
// messages using whatever secrets were collected before the throw.
|
|
1118
|
+
const sliceCtx = {
|
|
1119
|
+
lastResult: undefined,
|
|
1120
|
+
blockIndex:
|
|
1121
|
+
typeof msg?.block_index === "number" ? msg.block_index : null,
|
|
1122
|
+
secrets: new Set(),
|
|
1123
|
+
// Per-slice cache so `{{ artifacts.otp }}` referenced from 5 verbs
|
|
1124
|
+
// hits disk once instead of 5× and doesn't block the event loop
|
|
1125
|
+
// per-verb. Freshness across slices is preserved because the cache is
|
|
1126
|
+
// scoped to one slice — a bash cell that rewrites the file between
|
|
1127
|
+
// slices is seen on the next slice's first read.
|
|
1128
|
+
artifactCache: new Map(),
|
|
1129
|
+
};
|
|
1130
|
+
// Top-level guard: any unhandled error must emit slice.failed so the Rust
|
|
1131
|
+
// side sees a terminal frame instead of waiting out SLICE_EVENT_TIMEOUT.
|
|
840
1132
|
try {
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
type: "slice.failed",
|
|
845
|
-
error: `session start failed: ${e.message}`,
|
|
846
|
-
});
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
1133
|
+
const verbs = Array.isArray(msg.verbs) ? msg.verbs : [];
|
|
1134
|
+
const sessionName = msg.session || "default";
|
|
1135
|
+
const restore = msg.restore || null;
|
|
849
1136
|
|
|
850
|
-
|
|
851
|
-
// The sidecar protocol leaves room for it; when wait_for_mfa lands, this
|
|
852
|
-
// is where we'd jump to verbs[restore.state.verb_index].
|
|
853
|
-
const startAt = restore?.state?.verb_index ?? 0;
|
|
854
|
-
|
|
855
|
-
// Per-slice scratch so `save:` can capture the prior verb's JSON output.
|
|
856
|
-
const sliceCtx = { lastResult: undefined, blockIndex };
|
|
857
|
-
|
|
858
|
-
for (let i = startAt; i < verbs.length; i++) {
|
|
859
|
-
const v = verbs[i];
|
|
860
|
-
const name = verbName(v);
|
|
1137
|
+
let session;
|
|
861
1138
|
try {
|
|
862
|
-
|
|
863
|
-
send({
|
|
864
|
-
type: "verb.complete",
|
|
865
|
-
verb: name,
|
|
866
|
-
verb_index: i,
|
|
867
|
-
summary,
|
|
868
|
-
});
|
|
1139
|
+
session = await ensureSession(sessionName);
|
|
869
1140
|
} catch (e) {
|
|
870
|
-
send({
|
|
871
|
-
type: "verb.failed",
|
|
872
|
-
verb: name,
|
|
873
|
-
verb_index: i,
|
|
874
|
-
error: e.message,
|
|
875
|
-
});
|
|
876
1141
|
send({
|
|
877
1142
|
type: "slice.failed",
|
|
878
|
-
error: `
|
|
1143
|
+
error: `session start failed: ${scrubSecrets(e.message, sliceCtx.secrets)}`,
|
|
879
1144
|
});
|
|
880
1145
|
return;
|
|
881
1146
|
}
|
|
1147
|
+
|
|
1148
|
+
// Restore-from-pause is not implemented yet (no pause verb wired here).
|
|
1149
|
+
// The sidecar protocol leaves room for it; when wait_for_mfa lands, this
|
|
1150
|
+
// is where we'd jump to verbs[restore.state.verb_index].
|
|
1151
|
+
const startAt = restore?.state?.verb_index ?? 0;
|
|
1152
|
+
|
|
1153
|
+
for (let i = startAt; i < verbs.length; i++) {
|
|
1154
|
+
const v = verbs[i];
|
|
1155
|
+
const name = verbName(v);
|
|
1156
|
+
try {
|
|
1157
|
+
const summary = await runVerb(session.page, v, i, sliceCtx);
|
|
1158
|
+
send({
|
|
1159
|
+
type: "verb.complete",
|
|
1160
|
+
verb: name,
|
|
1161
|
+
verb_index: i,
|
|
1162
|
+
summary,
|
|
1163
|
+
});
|
|
1164
|
+
} catch (e) {
|
|
1165
|
+
const clean = scrubSecrets(e.message, sliceCtx.secrets);
|
|
1166
|
+
send({
|
|
1167
|
+
type: "verb.failed",
|
|
1168
|
+
verb: name,
|
|
1169
|
+
verb_index: i,
|
|
1170
|
+
error: clean,
|
|
1171
|
+
});
|
|
1172
|
+
send({
|
|
1173
|
+
type: "slice.failed",
|
|
1174
|
+
error: `verb ${name} (index ${i}): ${clean}`,
|
|
1175
|
+
});
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
send({ type: "slice.complete" });
|
|
1180
|
+
} catch (e) {
|
|
1181
|
+
log(`[slice] unhandled: ${e.stack || e.message}`);
|
|
1182
|
+
try {
|
|
1183
|
+
send({
|
|
1184
|
+
type: "slice.failed",
|
|
1185
|
+
error: `sidecar error: ${scrubSecrets(e.message, sliceCtx.secrets)}`,
|
|
1186
|
+
});
|
|
1187
|
+
} catch {}
|
|
882
1188
|
}
|
|
883
|
-
send({ type: "slice.complete" });
|
|
884
1189
|
}
|
|
885
1190
|
|
|
886
1191
|
// --- Shutdown ---------------------------------------------------------------
|
|
@@ -920,8 +1225,17 @@ const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
|
920
1225
|
// Serialize incoming messages — Playwright operations are async and we don't
|
|
921
1226
|
// want concurrent slice handlers stomping on the shared page.
|
|
922
1227
|
let chain = Promise.resolve();
|
|
923
|
-
function enqueue(fn) {
|
|
924
|
-
chain = chain.then(fn).catch((e) =>
|
|
1228
|
+
function enqueue(fn, kind) {
|
|
1229
|
+
chain = chain.then(fn).catch((e) => {
|
|
1230
|
+
log(`[loop] ${e.stack || e.message}`);
|
|
1231
|
+
// Last-resort terminal frame so a bug in the handler can never strand
|
|
1232
|
+
// the Rust parent waiting for a slice to finish.
|
|
1233
|
+
if (kind === "slice") {
|
|
1234
|
+
try {
|
|
1235
|
+
send({ type: "slice.failed", error: `sidecar loop error: ${e.message}` });
|
|
1236
|
+
} catch {}
|
|
1237
|
+
}
|
|
1238
|
+
});
|
|
925
1239
|
return chain;
|
|
926
1240
|
}
|
|
927
1241
|
|
|
@@ -947,7 +1261,7 @@ rl.on("line", (line) => {
|
|
|
947
1261
|
});
|
|
948
1262
|
break;
|
|
949
1263
|
case "slice":
|
|
950
|
-
enqueue(() => handleSlice(msg));
|
|
1264
|
+
enqueue(() => handleSlice(msg), "slice");
|
|
951
1265
|
break;
|
|
952
1266
|
case "shutdown":
|
|
953
1267
|
enqueue(shutdown);
|
|
@@ -961,3 +1275,20 @@ rl.on("close", () => {
|
|
|
961
1275
|
// stdin closed — drain pending work then exit.
|
|
962
1276
|
enqueue(shutdown);
|
|
963
1277
|
});
|
|
1278
|
+
|
|
1279
|
+
// If the Rust parent SIGTERMs us (timeout, abort, crash), Node's default is
|
|
1280
|
+
// to exit without running shutdown() — which leaves ffmpeg processes and
|
|
1281
|
+
// Browserbase sessions orphaned. Route signals through the same drain path.
|
|
1282
|
+
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
1283
|
+
process.on(sig, () => {
|
|
1284
|
+
log(`[shutdown] received ${sig}`);
|
|
1285
|
+
enqueue(shutdown);
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Log unhandled rejections so a dropped promise doesn't exit the process
|
|
1290
|
+
// silently between slices. The top-level guards in handleSlice / enqueue
|
|
1291
|
+
// cover the hot paths; this catches background work (recording uploads, etc).
|
|
1292
|
+
process.on("unhandledRejection", (reason) => {
|
|
1293
|
+
log(`[unhandledRejection] ${reason?.stack || reason}`);
|
|
1294
|
+
});
|
package/package.json
CHANGED