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