@parall/daemon 1.34.0 → 1.36.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.
@@ -0,0 +1,691 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { findFreePort, formatErrorForLog, sleep } from './subprocess.js';
3
+ const BB_VIEWER_HEALTH_TIMEOUT_MS = 10_000;
4
+ const BB_VIEWER_COMMAND_TIMEOUT_MS = 15_000;
5
+ // A streamer whose session never received a stream.answer is abandoned: the
6
+ // client vanished mid-handshake (hard tab close before its session id arrived)
7
+ // and can never send a scoped stream.close. Reap it so the bb-viewer process +
8
+ // CDP screencast don't park until the next stream.start. The handshake
9
+ // (answer follows start by seconds once the client has the offer) finishes
10
+ // far inside this window even on a cold pod.
11
+ const BB_VIEWER_UNANSWERED_REAP_MS = 90_000;
12
+ // Grace between SIGTERM and the SIGKILL escalation in killChild.
13
+ const BB_VIEWER_TERM_GRACE_MS = 5_000;
14
+ /**
15
+ * bb-viewer's /command control plane is UNAUTHENTICATED, so on the shared agents
16
+ * cluster its bind host MUST be loopback — a 0.0.0.0 / pod-IP / typo'd value would
17
+ * silently re-open it cross-tenant. Only these exact loopback forms are accepted;
18
+ * anything else is rejected (fail fast) at spawn.
19
+ */
20
+ export function isLoopbackBindHost(host) {
21
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]';
22
+ }
23
+ /**
24
+ * Whether a kick targets the CURRENT live streamer (so its process is torn down). A
25
+ * kick with no explicit target hits whoever is live; an explicit target tears down the
26
+ * current streamer ONLY when it matches — a stale/replayed session_id must not kill a
27
+ * newer live viewer (it is only recorded for straggler rejection). currentSessionId
28
+ * undefined (no live streamer) → never a current-kill.
29
+ */
30
+ export function kickHitsCurrentSession(requested, currentSessionId) {
31
+ if (currentSessionId === undefined)
32
+ return false;
33
+ return requested === undefined || requested === currentSessionId;
34
+ }
35
+ export class BrowserViewerStreamer {
36
+ host;
37
+ // Live bb-viewer (WebRTC streamer) subprocess per profile. One per profile;
38
+ // see StreamerState. Cleaned up on stream.close, stopProfile, resetProfile,
39
+ // and stop().
40
+ streamers = new Map();
41
+ // The most-recently server-side-kicked viewer session per profile (design §3.6
42
+ // PR7). After a kick the streamer is gone, so the session-staleness guards
43
+ // (which key off a live streamer) can no longer reject the kicked viewer's
44
+ // straggler nav commands — this map does. Reset whenever the profile's streamer
45
+ // is (re)killed via killProfileStreamer, so a fresh stream.start clears it. One
46
+ // entry per profile (overwritten per kick); bounded, no leak.
47
+ kickedSessions = new Map();
48
+ // Set by shutdown(): an in-flight spawnStreamer has no map entry yet, so a
49
+ // shutdown during its health poll would otherwise let the bb-viewer child
50
+ // survive daemon stop (on BYOC nothing else reaps it).
51
+ stopping = false;
52
+ constructor(host) {
53
+ this.host = host;
54
+ }
55
+ /**
56
+ * Handle a viewer control command for a profile. Throws on any failure (the
57
+ * supervisor maps a throw to a `{error:{message}}` reply); never returns a
58
+ * partial result. `data.input` / `data.turn` / `data.session_id` come from
59
+ * MachineBrowserProfileViewerData.
60
+ */
61
+ async handleViewerCommand(profileId, sessionId, command, input, turn) {
62
+ if (!profileId)
63
+ throw new Error('browser profile id is required');
64
+ // Reject a kicked viewer's straggler commands (design §3.6 PR7). After a kick
65
+ // there is no streamer, so the per-command staleness guards (which compare
66
+ // against a live streamer's sessionId) would let a kicked viewer keep driving
67
+ // nav. A fresh stream.start re-establishes a session (killProfileStreamer
68
+ // clears the marker), so it is exempt.
69
+ if (command !== 'stream.start' &&
70
+ sessionId &&
71
+ this.kickedSessions.get(profileId) === sessionId) {
72
+ throw new Error('viewer session was terminated');
73
+ }
74
+ switch (command) {
75
+ case 'stream.start':
76
+ return this.viewerStreamStart(profileId, sessionId, turn);
77
+ case 'stream.answer':
78
+ return this.viewerStreamAnswer(profileId, sessionId, input);
79
+ case 'stream.close':
80
+ return this.viewerStreamClose(profileId, sessionId, input);
81
+ case 'stream.switch':
82
+ return this.viewerStreamSwitch(profileId, sessionId, input);
83
+ case 'kick':
84
+ return this.viewerKick(profileId, sessionId, input);
85
+ case 'close':
86
+ return this.viewerCloseTab(profileId, sessionId, input);
87
+ case 'tab_list':
88
+ case 'tab_new':
89
+ case 'open':
90
+ case 'reload':
91
+ case 'back':
92
+ case 'forward':
93
+ return this.viewerForwardNav(profileId, sessionId, command, input);
94
+ default:
95
+ throw new Error(`unknown viewer command: ${command}`);
96
+ }
97
+ }
98
+ /**
99
+ * kick — server-side per-viewer disconnect (design §3.6 PR7). Terminates ONE
100
+ * viewer's WebRTC session: stop its bb-viewer streamer (killing the process
101
+ * tears down the peer connection + datachannel) and reject the kicked session's
102
+ * subsequent commands — while bb-browser + Chromium (the agent's live browser)
103
+ * keep running. This is explicitly DISTINCT from stopping the pod / profile,
104
+ * which would kill the agent's browser too; a viewer session is a sub-session of
105
+ * the profile lease, so a kick does NOT release the lease.
106
+ *
107
+ * Targets `input.session_id` if given, else the current streamer's session.
108
+ * Idempotent: kicking with no live streamer still records the kicked session.
109
+ */
110
+ viewerKick(profileId, sessionId, input) {
111
+ const streamer = this.streamers.get(profileId);
112
+ const requested = typeof input?.session_id === 'string' ? input.session_id : undefined;
113
+ const target = requested || streamer?.sessionId || sessionId;
114
+ // Only tear down the LIVE streamer when the kick targets the current session — a
115
+ // replayed/delayed stale session_id must not kill a newer live viewer. A kick with
116
+ // no explicit target (kick "whoever is live") also tears it down. A stale target is
117
+ // still recorded so its straggler commands are rejected by the staleness guard.
118
+ const killsCurrent = kickHitsCurrentSession(requested, streamer?.sessionId);
119
+ if (killsCurrent) {
120
+ // killProfileStreamer clears the kick marker, so record the target AFTER it.
121
+ this.killProfileStreamer(profileId);
122
+ }
123
+ if (target)
124
+ this.kickedSessions.set(profileId, target);
125
+ this.host.log.info(`[bb-viewer] kicked viewer session ${target || '(none)'} for profile ${profileId}; pod + agent browser keep running`);
126
+ return target ? { ok: true, kicked: true, session_id: target } : { ok: true, kicked: true };
127
+ }
128
+ /**
129
+ * stream.start — spawn a FRESH bb-viewer for this profile (killing any prior
130
+ * one), resolve the profile's account-scoped page-target CDP ws URL, and run
131
+ * the streamer's `connect` command. Records sessionId on the streamer entry.
132
+ */
133
+ async viewerStreamStart(profileId, sessionId, turn) {
134
+ // Fresh streamer per stream.start: a leftover viewer would still hold the
135
+ // single-viewer slot inside bb-viewer (connect cleans up its prior viewer,
136
+ // but we also want a clean process + sessionId per server-side session).
137
+ this.killProfileStreamer(profileId);
138
+ // Wrap the bb-browser CDP resolution in the same auto-recovery the agent
139
+ // invoke path uses: a disconnected Chrome/CDP restarts bb-browser + retries
140
+ // rather than failing the viewer outright.
141
+ const cdpUrl = await this.host.withDaemonRecovery(() => this.resolveAccountPageCdpUrl(profileId), `viewer stream.start ${profileId}`);
142
+ // CDP screencast only emits frames for the tab Chrome is compositing (the
143
+ // foreground tab of its window). With multiple account tabs the streamed
144
+ // target is often backgrounded → 0 frames → black viewer. Activate it first.
145
+ await this.bringTargetToFront(cdpUrl);
146
+ const streamer = await this.spawnStreamer(profileId, sessionId, turn);
147
+ try {
148
+ const result = await this.streamerCommand(streamer, 'connect', { cdpUrl });
149
+ // Tell the client the EXACT tab being streamed so it targets nav commands
150
+ // (open/back/forward/reload) at the same tab — no client-side guessing.
151
+ streamer.streamedTabId = targetIdFromCdpUrl(cdpUrl);
152
+ // Reap an abandoned (never-answered) streamer — identity-checked so a
153
+ // kill/respawn in the meantime is never affected. unref: the timer must
154
+ // not hold the daemon process open.
155
+ const reapTimer = setTimeout(() => {
156
+ const current = this.streamers.get(profileId);
157
+ if (current === streamer && !current.answered) {
158
+ this.host.log.warn(`[bb-viewer] no stream.answer for ${profileId} within ${BB_VIEWER_UNANSWERED_REAP_MS / 1000}s; reaping abandoned streamer`);
159
+ this.killProfileStreamer(profileId);
160
+ }
161
+ }, BB_VIEWER_UNANSWERED_REAP_MS);
162
+ reapTimer.unref?.();
163
+ return { ...result, streamed_tab_id: streamer.streamedTabId };
164
+ }
165
+ catch (err) {
166
+ // connect failed → don't leave a dead/idle streamer parked on the profile.
167
+ this.killProfileStreamer(profileId);
168
+ throw err;
169
+ }
170
+ }
171
+ /**
172
+ * stream.answer — apply the client's WebRTC answer to the profile's streamer.
173
+ * Rejects if the streamer's sessionId no longer matches (a stale viewer
174
+ * session from a superseded stream.start).
175
+ */
176
+ async viewerStreamAnswer(profileId, sessionId, input) {
177
+ const streamer = this.streamers.get(profileId);
178
+ if (!streamer)
179
+ throw new Error('no active viewer streamer for profile');
180
+ if (streamer.sessionId !== sessionId)
181
+ throw new Error('stale viewer session');
182
+ // Mark BEFORE the (up to 15s) bb-viewer await: an answer accepted near the
183
+ // reap deadline must not be torn down mid-processing by the timer. A failed
184
+ // answer leaves the streamer un-reaped, which is fine — the client surfaces
185
+ // the error and the next stream.start replaces the streamer anyway.
186
+ streamer.answered = true;
187
+ return this.streamerCommand(streamer, 'answer', input ?? {});
188
+ }
189
+ /**
190
+ * stream.close — best-effort stop the streamer, then kill + remove it. A
191
+ * stale session's close (independent HTTP legs can arrive out of order after
192
+ * a retry) must NOT tear down a successor session's live streamer, so a
193
+ * mismatched sessionId is ignored. An EMPTY sessionId gets the same stale
194
+ * treatment — a client closed mid-handshake never received a session id, and
195
+ * its unscoped close racing the next session's stream.start would kill that
196
+ * session's live streamer (React StrictMode's dev double-mount exercises
197
+ * exactly this ordering). The only unscoped close is an explicit
198
+ * `{force:true}` input, reserved for a server-initiated admin disconnect.
199
+ */
200
+ async viewerStreamClose(profileId, sessionId, input) {
201
+ const streamer = this.streamers.get(profileId);
202
+ if (streamer) {
203
+ const force = input?.force === true;
204
+ if (!force && streamer.sessionId !== sessionId) {
205
+ return { ok: true, stale: true };
206
+ }
207
+ try {
208
+ await this.streamerCommand(streamer, 'stop', {});
209
+ }
210
+ catch (err) {
211
+ this.host.log.warn(`[bb-viewer] stop command failed for ${profileId} (killing anyway): ${formatErrorForLog(err)}`);
212
+ }
213
+ this.killProfileStreamer(profileId);
214
+ }
215
+ return { ok: true };
216
+ }
217
+ /**
218
+ * stream.switch — point the existing streamer at a different account-scoped
219
+ * tab. input {tab} is the bb-browser short tab id / target id. Guarded by
220
+ * sessionId like stream.answer: a superseded session must not steer the
221
+ * current streamer, and an EMPTY sessionId is rejected the same way — every
222
+ * real caller has its session id by the time it can switch (it arrives with
223
+ * the stream.start response), so a session-less switch is by definition not
224
+ * the current viewer.
225
+ */
226
+ async viewerStreamSwitch(profileId, sessionId, input) {
227
+ const streamer = this.streamers.get(profileId);
228
+ if (!streamer)
229
+ throw new Error('no active viewer streamer for profile');
230
+ if (!sessionId || streamer.sessionId !== sessionId)
231
+ throw new Error('stale viewer session');
232
+ const tab = input?.tab;
233
+ if (tab === undefined || tab === null || tab === '') {
234
+ throw new Error('stream.switch requires input.tab');
235
+ }
236
+ const cdpUrl = await this.host.withDaemonRecovery(() => this.resolveAccountPageCdpUrl(profileId, tab), `viewer stream.switch ${profileId}`);
237
+ // Foreground the new tab so Chrome composites it (see viewerStreamStart).
238
+ await this.bringTargetToFront(cdpUrl);
239
+ const result = await this.streamerCommand(streamer, 'switch', { cdpUrl });
240
+ streamer.streamedTabId = targetIdFromCdpUrl(cdpUrl);
241
+ return { ...result, streamed_tab_id: streamer.streamedTabId };
242
+ }
243
+ /**
244
+ * close — close an account tab via bb-browser, then, if the closed tab was
245
+ * the one being streamed, re-point the streamer at the profile's next active
246
+ * tab. Without the re-switch the streamer stays bound to a destroyed CDP
247
+ * target and the viewer goes black. Returns `streamed_tab_id` when a
248
+ * re-switch happened so the client can adopt the new active tab.
249
+ */
250
+ async viewerCloseTab(profileId, sessionId, input) {
251
+ // Resolve the client's ref to the canonical full target id BEFORE closing:
252
+ // streamedTabId stores the full id, so comparing the raw input (possibly a
253
+ // short `tab` ref) would close the right tab but skip the re-switch and
254
+ // leave the stream on a destroyed target. A ref-less close pins to the
255
+ // account's active tab, matching viewerForwardNav's ref-less behavior.
256
+ const ref = viewerTabRef(input ?? {});
257
+ const closedTargetId = ref !== undefined
258
+ ? await this.accountTabTargetId(profileId, ref)
259
+ : await this.accountActivePageTargetId(profileId);
260
+ const result = await this.viewerForwardNav(profileId, sessionId, 'close', {
261
+ ...(input ?? {}),
262
+ tabId: closedTargetId,
263
+ });
264
+ const streamer = this.streamers.get(profileId);
265
+ if (!streamer || !streamer.streamedTabId || streamer.streamedTabId !== closedTargetId) {
266
+ return result;
267
+ }
268
+ const cdpUrl = await this.host.withDaemonRecovery(() => this.resolveAccountPageCdpUrl(profileId), `viewer close re-switch ${profileId}`);
269
+ await this.bringTargetToFront(cdpUrl);
270
+ const sw = await this.streamerCommand(streamer, 'switch', { cdpUrl });
271
+ streamer.streamedTabId = targetIdFromCdpUrl(cdpUrl);
272
+ return { ...result, ...sw, streamed_tab_id: streamer.streamedTabId };
273
+ }
274
+ /**
275
+ * Bring a CDP page target to the foreground via Chrome's DevTools HTTP
276
+ * endpoint (`/json/activate/{targetId}`, same host:port as the CDP ws). Chrome
277
+ * does not composite backgrounded tabs, so `Page.startScreencast` on a
278
+ * background tab yields zero frames (black viewer). Best-effort: a failure
279
+ * must not block the stream. host:port + targetId are parsed from the cdpUrl
280
+ * (`ws://host:port/devtools/page/<targetId>`) to avoid an extra /status call.
281
+ */
282
+ async bringTargetToFront(cdpUrl) {
283
+ const m = /^ws:\/\/([^/]+)\/devtools\/page\/(.+)$/.exec(cdpUrl);
284
+ if (!m)
285
+ return;
286
+ const [, hostPort, targetId] = m;
287
+ try {
288
+ const resp = await fetch(`http://${hostPort}/json/activate/${targetId}`, {
289
+ method: 'GET',
290
+ signal: AbortSignal.timeout(5_000),
291
+ });
292
+ if (!resp.ok) {
293
+ this.host.log.warn(`[bb-viewer] activate target ${targetId} returned ${resp.status} (continuing)`);
294
+ }
295
+ }
296
+ catch (err) {
297
+ this.host.log.warn(`[bb-viewer] activate target ${targetId} failed (continuing): ${formatErrorForLog(err)}`);
298
+ }
299
+ }
300
+ /**
301
+ * Forward a navigation command (tab_list/tab_new/open/reload/back/forward/
302
+ * close) to bb-browser scoped to the profile's account. This lets the human
303
+ * in the live viewer navigate and handle OAuth popups directly. Reuses the
304
+ * same account-scoped sendCommand path the agent invokes use.
305
+ *
306
+ * SECURITY — the `account` field alone does NOT scope tab addressing:
307
+ * bb-browser's ensurePageTarget resolves a client-supplied tab/tabId against
308
+ * EVERY page target in the shared Chrome (all accounts), and tab_list
309
+ * returns every tab with `account` as a mere annotation (verified in
310
+ * @pinixai/bb-browser-pro@0.15.0 dist/daemon.js ensurePageTarget/tab_list).
311
+ * On a multi-profile host that would let one profile's viewer list,
312
+ * navigate, and close other profiles' logged-in tabs. So before forwarding:
313
+ * (a) any tab ref must resolve through accountTabTargetId, which throws on
314
+ * tabs the profile's account does not own; (b) ref-less tab-addressed
315
+ * commands are pinned to the profile's own active tab instead of
316
+ * bb-browser's account-blind global current tab; (c) tab_list output is
317
+ * filtered to the profile's own rows; and (d) open/tab_new URLs must be
318
+ * http(s)/about:blank — file:// or chrome:// would read the host
319
+ * filesystem / browser internals straight into the video stream.
320
+ */
321
+ async viewerForwardNav(profileId, sessionId, command, input) {
322
+ // Takeover isolation: when a live streamer exists, mutating nav/tab
323
+ // commands must come from ITS session — a superseded viewer tab (older or
324
+ // missing session id) must not keep steering the profile after a newer
325
+ // stream.start took over. Read-only tab_list stays unscoped. With no live
326
+ // streamer there is no takeover to protect (server-side profile authz
327
+ // already gates the caller).
328
+ if (command !== 'tab_list') {
329
+ const streamer = this.streamers.get(profileId);
330
+ if (streamer && streamer.sessionId !== sessionId) {
331
+ throw new Error('stale viewer session');
332
+ }
333
+ }
334
+ return this.host.withDaemonRecovery(async () => {
335
+ await this.host.ensureAccount(profileId);
336
+ const request = { ...(input ?? {}) };
337
+ if (command === 'open' || command === 'tab_new') {
338
+ assertViewerNavigableUrl(request.url);
339
+ }
340
+ const ref = viewerTabRef(request);
341
+ if (ref !== undefined) {
342
+ request.tabId = await this.accountTabTargetId(profileId, ref);
343
+ delete request.tab;
344
+ }
345
+ else if (REFLESS_PIN_NAV.has(command)) {
346
+ // `open` without a ref creates a tab inside the account context
347
+ // (bb-browser createTabInContext) — safe without pinning.
348
+ request.tabId = await this.accountActivePageTargetId(profileId);
349
+ }
350
+ const result = await this.host.sendBrowserCommand({
351
+ ...request,
352
+ method: command,
353
+ account: profileId,
354
+ });
355
+ return command === 'tab_list' ? filterTabListToAccount(result, profileId) : result;
356
+ }, `viewer ${command} ${profileId}`);
357
+ }
358
+ /**
359
+ * Resolve `ws://<cdpHost>:<cdpPort>/devtools/page/<targetId>` for a PAGE
360
+ * target owned by THIS profile's account — NOT bb-browser's global current
361
+ * tab (which `getCurrentTabCdpUrl`/`getTabCdpUrl` in the bundled daemon use
362
+ * via `cdp.currentTargetId`, an account-blind global; see daemon.js
363
+ * line ~10389/10410). When `tabRef` is given (stream.switch) we resolve that
364
+ * specific account tab; otherwise the profile's active page tab.
365
+ *
366
+ * Evidence from the installed @pinixai/bb-browser-pro@0.15.0 dist/daemon.js:
367
+ * - GET /status (handleStatus, line ~1659-1668) returns `cdpHost` + `cdpPort`
368
+ * — the CDP host/port the daemon's Chrome listens on.
369
+ * - The CDP page ws URL format is `ws://<cdp.host>:<cdp.port>/devtools/page/<page.id>`
370
+ * (getCurrentTabCdpUrl, line ~10392), where `page.id` is the full CDP
371
+ * targetId (getTargets maps `t.targetId` → `.id`, line ~2461-2466).
372
+ * - tab_list (command handler, line ~1041-1059) returns per-tab
373
+ * `{ tabId: t.id, tab: <shortId>, account: <accountName>, url, ... }`, so
374
+ * `tabId` is exactly the full targetId for the devtools URL and `account`
375
+ * is the per-tab attribution we filter on (reused by findAccountTabOnHost).
376
+ */
377
+ async resolveAccountPageCdpUrl(profileId, tabRef) {
378
+ await this.host.ensureAccount(profileId);
379
+ const { host, port } = await this.host.cdpEndpoint();
380
+ const targetId = tabRef !== undefined
381
+ ? await this.accountTabTargetId(profileId, tabRef)
382
+ : await this.accountActivePageTargetId(profileId);
383
+ return `ws://${host}:${port}/devtools/page/${targetId}`;
384
+ }
385
+ /**
386
+ * Full CDP targetId of the profile account's active page tab (the owned tab
387
+ * Chrome marks active, else the first owned tab). If the account owns no
388
+ * page tab yet, open `about:blank` for it first (mirrors the agent-invoke
389
+ * path's "always give a fresh account an owned tab" rule).
390
+ */
391
+ async accountActivePageTargetId(profileId) {
392
+ let targetId = await this.firstAccountPageTargetId(profileId);
393
+ if (targetId !== undefined)
394
+ return targetId;
395
+ await this.host.sendBrowserCommand({
396
+ method: 'tab_new',
397
+ account: profileId,
398
+ url: 'about:blank',
399
+ });
400
+ targetId = await this.firstAccountPageTargetId(profileId);
401
+ if (targetId === undefined) {
402
+ throw new Error(`no page tab found for profile account ${profileId}`);
403
+ }
404
+ return targetId;
405
+ }
406
+ /**
407
+ * Full CDP targetId (`tabId`) of the account's active page tab: the owned tab
408
+ * Chrome marks `active` when it belongs to this account, else the first owned
409
+ * tab (the global active flag is account-blind — another profile's tab may
410
+ * hold it, which must not leak here).
411
+ */
412
+ async firstAccountPageTargetId(profileId) {
413
+ const tabs = await this.accountTabs(profileId);
414
+ let firstOwned;
415
+ for (const t of tabs) {
416
+ if (t.account !== profileId)
417
+ continue;
418
+ const tabId = t.tabId;
419
+ if (typeof tabId !== 'string' || !tabId)
420
+ continue;
421
+ if (t.active === true)
422
+ return tabId;
423
+ firstOwned ??= tabId;
424
+ }
425
+ return firstOwned;
426
+ }
427
+ /**
428
+ * Full CDP targetId for a specific account tab, addressed by the bb-browser
429
+ * short tab id (`tab`) or full target id (`tabId`). Used by stream.switch.
430
+ */
431
+ async accountTabTargetId(profileId, tabRef) {
432
+ const ref = String(tabRef);
433
+ const tabs = await this.accountTabs(profileId);
434
+ for (const t of tabs) {
435
+ if (t.account !== profileId)
436
+ continue;
437
+ const tabId = typeof t.tabId === 'string' ? t.tabId : undefined;
438
+ const shortTab = t.tab === undefined ? undefined : String(t.tab);
439
+ if ((tabId && tabId === ref) || (shortTab && shortTab === ref)) {
440
+ if (!tabId)
441
+ break;
442
+ return tabId;
443
+ }
444
+ }
445
+ throw new Error(`tab ${ref} not found for profile account ${profileId}`);
446
+ }
447
+ /** `tab_list {account}` rows, typed to the fields we read (tabId/tab/account/active). */
448
+ async accountTabs(profileId) {
449
+ const list = await this.host.sendBrowserCommand({ method: 'tab_list', account: profileId });
450
+ return Array.isArray(list.tabs)
451
+ ? list.tabs
452
+ : [];
453
+ }
454
+ // ---- bb-viewer streamer lifecycle ----
455
+ /**
456
+ * Spawn a bb-viewer streamer for the profile and wait until healthy. Binary
457
+ * is PRLL_BB_VIEWER_BIN (our image sets it to /usr/local/bin/bb-viewer),
458
+ * defaulting to `bb-viewer` on PATH — we NEVER download it. A free port is
459
+ * allocated per streamer. Stores the entry in `this.streamers` once healthy.
460
+ */
461
+ async spawnStreamer(profileId, sessionId, turn) {
462
+ if (this.stopping)
463
+ throw new Error('viewer streamer is shutting down');
464
+ const bin = process.env.PRLL_BB_VIEWER_BIN ?? 'bb-viewer';
465
+ const port = await findFreePort();
466
+ const args = ['--api-only', '--port', String(port)];
467
+ // Bind bb-viewer's HTTP control surface (/command + /health) to a specific
468
+ // host when the image opts in via PRLL_BB_VIEWER_HOST (the hosted browser pod
469
+ // sets it to 127.0.0.1; design §3.6 PR7). The streamer ALWAYS reaches
470
+ // bb-viewer over loopback (streamerCommand + the health poll below both use
471
+ // 127.0.0.1), so a loopback bind never breaks the in-pod path — it only
472
+ // removes the 0.0.0.0 exposure that, on the shared agents cluster, would let
473
+ // another tenant's pod re-point this streamer or start its own stream. Left
474
+ // unset (no --host) for BYOC and the current pinned bb-viewer, which has no
475
+ // --host flag — see deploy/browser-docker/AGENTS.md "bb-viewer loopback bind".
476
+ const bindHost = process.env.PRLL_BB_VIEWER_HOST?.trim();
477
+ if (bindHost) {
478
+ // SECURITY: bb-viewer's /command is unauthenticated, so on the shared agents
479
+ // cluster its bind host MUST be loopback — a 0.0.0.0 / pod-IP / typo'd value would
480
+ // silently re-open the cross-tenant control plane this env exists to close. Fail
481
+ // fast on anything non-loopback rather than pass it through to --host.
482
+ if (!isLoopbackBindHost(bindHost)) {
483
+ throw new Error(`PRLL_BB_VIEWER_HOST must be loopback (127.0.0.1, localhost, ::1, [::1]) — refusing to bind bb-viewer's unauthenticated /command to ${bindHost}`);
484
+ }
485
+ args.push('--host', bindHost);
486
+ }
487
+ if (turn?.url) {
488
+ args.push('--turn-url', turn.url, '--turn-user', turn.username ?? '', '--turn-cred', turn.credential ?? '');
489
+ }
490
+ // Do NOT inherit the daemon's full env: it carries the machine key
491
+ // (PRLL_API_KEY = mck_) plus provider/cloud creds that bb-viewer must never
492
+ // see. bb-viewer only talks to a localhost CDP ws URL + its own HTTP port
493
+ // (via flags/commands), so a minimal allowlist is sufficient.
494
+ const streamerEnv = {};
495
+ for (const key of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'DISPLAY']) {
496
+ const val = process.env[key];
497
+ if (val !== undefined)
498
+ streamerEnv[key] = val;
499
+ }
500
+ this.host.log.info(`[bb-viewer] spawning for profile ${profileId} on port ${port}`);
501
+ const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], env: streamerEnv });
502
+ // Capture spawn failure (ENOENT/EACCES) so startup fails through the normal
503
+ // path instead of crashing the daemon on an unhandled 'error' event.
504
+ let childError = null;
505
+ child.once('error', (err) => {
506
+ childError = err;
507
+ });
508
+ // A streamer that dies AFTER passing health (bb-viewer crash) must not
509
+ // leave a stale entry behind — later stream.answer/switch would hammer a
510
+ // dead port until the next stream.start. Only clear the entry if it still
511
+ // points at THIS child (killProfileStreamer deletes first, and a respawn
512
+ // may have replaced it).
513
+ child.once('exit', () => {
514
+ const current = this.streamers.get(profileId);
515
+ if (current?.child === child) {
516
+ this.streamers.delete(profileId);
517
+ this.host.log.warn(`[bb-viewer] streamer for ${profileId} exited; cleared stale entry`);
518
+ }
519
+ });
520
+ child.stdout?.on('data', (chunk) => {
521
+ const text = chunk.toString('utf8').trim();
522
+ if (text)
523
+ this.host.log.info(`[bb-viewer] ${text}`);
524
+ });
525
+ child.stderr?.on('data', (chunk) => {
526
+ const text = chunk.toString('utf8').trim();
527
+ if (text)
528
+ this.host.log.warn(`[bb-viewer] ${text}`);
529
+ });
530
+ const deadline = Date.now() + BB_VIEWER_HEALTH_TIMEOUT_MS;
531
+ while (Date.now() < deadline) {
532
+ if (this.stopping) {
533
+ this.killChild(child, 'streamer for stopping daemon');
534
+ throw new Error('viewer streamer is shutting down');
535
+ }
536
+ if (childError) {
537
+ throw childError;
538
+ }
539
+ if (child.exitCode !== null || child.signalCode !== null) {
540
+ throw new Error('bb-viewer exited during startup');
541
+ }
542
+ try {
543
+ const resp = await fetch(`http://127.0.0.1:${port}/health`, {
544
+ signal: AbortSignal.timeout(2_000),
545
+ });
546
+ if (resp.ok) {
547
+ const streamer = { child, port, sessionId };
548
+ this.streamers.set(profileId, streamer);
549
+ this.host.log.info(`[bb-viewer] ready for profile ${profileId} on port ${port}`);
550
+ return streamer;
551
+ }
552
+ }
553
+ catch {
554
+ /* not up yet */
555
+ }
556
+ await sleep(200);
557
+ }
558
+ this.killChild(child, 'unhealthy streamer');
559
+ throw new Error('bb-viewer did not become healthy in time');
560
+ }
561
+ /**
562
+ * SIGTERM with a SIGKILL escalation: a bb-viewer that hangs or ignores TERM
563
+ * would otherwise outlive its teardown holding the port + CDP session (the
564
+ * shell-level pattern-kill only exists on the hosted pod, not BYOC). The
565
+ * escalation timer is unref'd so it never holds the daemon open; it is
566
+ * cleared on exit. Caveat: a daemon process that exits immediately after
567
+ * shutdown() abandons the timer — acceptable, the TERM was still sent.
568
+ */
569
+ killChild(child, label) {
570
+ if (child.exitCode !== null || child.signalCode !== null)
571
+ return;
572
+ try {
573
+ child.kill('SIGTERM');
574
+ }
575
+ catch {
576
+ return; /* already gone */
577
+ }
578
+ const killTimer = setTimeout(() => {
579
+ if (child.exitCode === null && child.signalCode === null) {
580
+ this.host.log.warn(`[bb-viewer] ${label} ignored SIGTERM; escalating to SIGKILL`);
581
+ try {
582
+ child.kill('SIGKILL');
583
+ }
584
+ catch {
585
+ /* already gone */
586
+ }
587
+ }
588
+ }, BB_VIEWER_TERM_GRACE_MS);
589
+ killTimer.unref?.();
590
+ child.once('exit', () => clearTimeout(killTimer));
591
+ }
592
+ /**
593
+ * POST a command to the profile's bb-viewer `/command` endpoint. Parses the
594
+ * `{result}` / `{error:{message}}` envelope (bb-viewer api.go) and returns
595
+ * the inner result, throwing the error message on failure.
596
+ */
597
+ async streamerCommand(streamer, method, params) {
598
+ const controller = new AbortController();
599
+ const timer = setTimeout(() => controller.abort(), BB_VIEWER_COMMAND_TIMEOUT_MS);
600
+ try {
601
+ const resp = await fetch(`http://127.0.0.1:${streamer.port}/command`, {
602
+ method: 'POST',
603
+ headers: { 'Content-Type': 'application/json' },
604
+ body: JSON.stringify({ method, params }),
605
+ signal: controller.signal,
606
+ });
607
+ const text = await resp.text();
608
+ const parsed = (text ? JSON.parse(text) : {});
609
+ if (parsed.error) {
610
+ throw new Error(parsed.error.message || `bb-viewer ${method} failed`);
611
+ }
612
+ if (!resp.ok) {
613
+ throw new Error(`bb-viewer ${method} returned ${resp.status}: ${text}`);
614
+ }
615
+ return parsed.result ?? {};
616
+ }
617
+ finally {
618
+ clearTimeout(timer);
619
+ }
620
+ }
621
+ /**
622
+ * Kill + remove the profile's bb-viewer streamer, if any. Idempotent. Also
623
+ * clears any kick marker for the profile: a (re)kill establishes a clean
624
+ * streamer slot, so a subsequent stream.start starts un-kicked. viewerKick
625
+ * re-records the kicked session AFTER calling this.
626
+ */
627
+ killProfileStreamer(profileId) {
628
+ this.kickedSessions.delete(profileId);
629
+ const streamer = this.streamers.get(profileId);
630
+ if (!streamer)
631
+ return;
632
+ this.streamers.delete(profileId);
633
+ this.killChild(streamer.child, `streamer for ${profileId}`);
634
+ }
635
+ /**
636
+ * Tear down every profile streamer and refuse new spawns (daemon stop).
637
+ * Idempotent.
638
+ */
639
+ shutdown() {
640
+ this.stopping = true;
641
+ for (const profileId of [...this.streamers.keys()]) {
642
+ this.killProfileStreamer(profileId);
643
+ }
644
+ }
645
+ }
646
+ /** Extract the full CDP target id from a `ws://host:port/devtools/page/<id>` URL. */
647
+ function targetIdFromCdpUrl(cdpUrl) {
648
+ return /\/devtools\/page\/(.+)$/.exec(cdpUrl)?.[1];
649
+ }
650
+ /**
651
+ * Tab-addressed nav commands that, when called WITHOUT an explicit tab ref,
652
+ * must be pinned to the profile's own active tab — bb-browser would otherwise
653
+ * fall back to its account-blind global current tab (another profile's).
654
+ */
655
+ const REFLESS_PIN_NAV = new Set(['reload', 'back', 'forward', 'close']);
656
+ /** Client-supplied tab reference (full target id preferred, short id fallback). */
657
+ function viewerTabRef(request) {
658
+ for (const key of ['tabId', 'tab']) {
659
+ const v = request[key];
660
+ if ((typeof v === 'string' && v !== '') || typeof v === 'number')
661
+ return String(v);
662
+ }
663
+ return undefined;
664
+ }
665
+ const VIEWER_NAVIGABLE_PROTOCOLS = new Set(['http:', 'https:']);
666
+ /** open/tab_new URL gate: http(s) or about:blank only (see viewerForwardNav). */
667
+ function assertViewerNavigableUrl(raw) {
668
+ // Presence/emptiness is bb-browser's contract to enforce ("Missing url").
669
+ if (raw === undefined || raw === null || raw === '')
670
+ return;
671
+ if (typeof raw !== 'string')
672
+ throw new Error('url must be a string');
673
+ if (raw === 'about:blank')
674
+ return;
675
+ let parsed;
676
+ try {
677
+ parsed = new URL(raw);
678
+ }
679
+ catch {
680
+ throw new Error(`invalid url: ${raw}`);
681
+ }
682
+ if (!VIEWER_NAVIGABLE_PROTOCOLS.has(parsed.protocol)) {
683
+ throw new Error(`url scheme not allowed in the live viewer: ${parsed.protocol.replace(/:$/, '')}`);
684
+ }
685
+ }
686
+ /** tab_list rows are account-annotated but unfiltered — keep only the profile's own. */
687
+ function filterTabListToAccount(result, account) {
688
+ const tabs = Array.isArray(result.tabs) ? result.tabs : [];
689
+ const owned = tabs.filter((t) => t?.account === account);
690
+ return { ...result, tabs: owned, activeIndex: owned.findIndex((t) => t?.active === true) };
691
+ }