@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.
- package/bundle/manifest.json +13 -9
- package/bundle/parall-browser-pod.js +40665 -0
- package/bundle/parall-claude-agent.js +564 -91
- package/bundle/parall-codex-agent.js +559 -90
- package/bundle/parall-daemon.js +1000 -66
- package/dist/browser-pod.d.ts +294 -0
- package/dist/browser-pod.d.ts.map +1 -0
- package/dist/browser-pod.js +765 -0
- package/dist/clip-runtime/browser-profile-manager.d.ts +10 -0
- package/dist/clip-runtime/browser-profile-manager.d.ts.map +1 -1
- package/dist/clip-runtime/browser-profile-manager.js +38 -26
- package/dist/clip-runtime/browser-state-store.d.ts +157 -0
- package/dist/clip-runtime/browser-state-store.d.ts.map +1 -0
- package/dist/clip-runtime/browser-state-store.js +370 -0
- package/dist/clip-runtime/browser-viewer-streamer.d.ts +222 -0
- package/dist/clip-runtime/browser-viewer-streamer.d.ts.map +1 -0
- package/dist/clip-runtime/browser-viewer-streamer.js +691 -0
- package/dist/clip-runtime/clip-provider.d.ts +56 -0
- package/dist/clip-runtime/clip-provider.d.ts.map +1 -1
- package/dist/clip-runtime/clip-provider.js +141 -17
- package/dist/clip-runtime/subprocess.d.ts +11 -0
- package/dist/clip-runtime/subprocess.d.ts.map +1 -0
- package/dist/clip-runtime/subprocess.js +33 -0
- package/dist/supervisor.d.ts +10 -2
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +61 -19
- package/package.json +11 -8
|
@@ -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
|
+
}
|