@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.
- package/bundle/manifest.json +9 -9
- package/bundle/parall-claude-agent.js +168 -92
- package/bundle/parall-codex-agent.js +163 -91
- package/bundle/parall-daemon.js +702 -38
- 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-viewer-streamer.d.ts +188 -0
- package/dist/clip-runtime/browser-viewer-streamer.d.ts.map +1 -0
- package/dist/clip-runtime/browser-viewer-streamer.js +595 -0
- 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 -1
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +48 -6
- package/package.json +6 -6
|
@@ -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
|
+
}
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -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
|
|
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;
|