@parall/daemon 1.45.0 → 1.46.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 +15 -15
- package/bundle/parall-browser-pod.js +29726 -375
- package/bundle/parall-channel-exec.js +2 -0
- package/bundle/parall-claude-agent.js +26091 -273
- package/bundle/parall-codex-agent.js +26650 -331
- package/bundle/parall-daemon.js +31384 -1998
- package/bundle/parall-openclaw-agent.js +1 -0
- package/dist/browser-pod.d.ts +23 -1
- package/dist/browser-pod.d.ts.map +1 -1
- package/dist/browser-pod.js +104 -34
- package/dist/browser-profile-reconcile.d.ts +21 -0
- package/dist/browser-profile-reconcile.d.ts.map +1 -0
- package/dist/browser-profile-reconcile.js +188 -0
- package/dist/clip-runtime/browser-cdp.d.ts +40 -0
- package/dist/clip-runtime/browser-cdp.d.ts.map +1 -0
- package/dist/clip-runtime/browser-cdp.js +218 -0
- package/dist/clip-runtime/browser-profile-manager.d.ts +97 -24
- package/dist/clip-runtime/browser-profile-manager.d.ts.map +1 -1
- package/dist/clip-runtime/browser-profile-manager.js +316 -182
- package/dist/clip-runtime/browser-profile-pool.d.ts +3 -0
- package/dist/clip-runtime/browser-profile-pool.d.ts.map +1 -1
- package/dist/clip-runtime/browser-profile-pool.js +18 -1
- package/dist/clip-runtime/browser-proxy-reconcile.d.ts +77 -0
- package/dist/clip-runtime/browser-proxy-reconcile.d.ts.map +1 -0
- package/dist/clip-runtime/browser-proxy-reconcile.js +139 -0
- package/dist/clip-runtime/browser-proxy-state.d.ts +55 -0
- package/dist/clip-runtime/browser-proxy-state.d.ts.map +1 -0
- package/dist/clip-runtime/browser-proxy-state.js +149 -0
- package/dist/clip-runtime/browser-quiescence.d.ts +71 -0
- package/dist/clip-runtime/browser-quiescence.d.ts.map +1 -0
- package/dist/clip-runtime/browser-quiescence.js +136 -0
- package/dist/clip-runtime/browser-readiness.d.ts +64 -0
- package/dist/clip-runtime/browser-readiness.d.ts.map +1 -0
- package/dist/clip-runtime/browser-readiness.js +161 -0
- package/dist/clip-runtime/browser-state-store.d.ts +13 -2
- package/dist/clip-runtime/browser-state-store.d.ts.map +1 -1
- package/dist/clip-runtime/browser-state-store.js +15 -6
- package/dist/clip-runtime/browser-target-registry.d.ts +143 -0
- package/dist/clip-runtime/browser-target-registry.d.ts.map +1 -0
- package/dist/clip-runtime/browser-target-registry.js +297 -0
- package/dist/clip-runtime/browser-viewer-streamer.d.ts +13 -14
- package/dist/clip-runtime/browser-viewer-streamer.d.ts.map +1 -1
- package/dist/clip-runtime/browser-viewer-streamer.js +11 -63
- package/dist/daemon-main.d.ts.map +1 -1
- package/dist/daemon-main.js +3 -1
- package/dist/supervisor.d.ts +6 -0
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +48 -187
- package/package.json +8 -6
|
@@ -5,6 +5,10 @@ import { createRequire } from 'node:module';
|
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { buildBrowserDaemonEnv } from './browser-daemon-env.js';
|
|
8
|
+
import { proxyCreateFields, proxyReconcileNeeded, reconcileAccountProxy, settlePendingCookieBackup, } from './browser-proxy-reconcile.js';
|
|
9
|
+
import { appliedProxyFingerprint, clearCookieBackup, normalizeProxyConfig, proxyFingerprint, writeAppliedProxyFingerprint, } from './browser-proxy-state.js';
|
|
10
|
+
import { assertOpenReady } from './browser-readiness.js';
|
|
11
|
+
import { BrowserTargetError, BrowserTargetRegistry, TABLESS_COMMANDS, } from './browser-target-registry.js';
|
|
8
12
|
import { BrowserViewerStreamer } from './browser-viewer-streamer.js';
|
|
9
13
|
import { findFreePort, formatErrorForLog, sleep, waitForChildExit } from './subprocess.js';
|
|
10
14
|
const BB_BROWSER_DAEMON_START_TIMEOUT_MS = 15_000;
|
|
@@ -31,9 +35,21 @@ export class BrowserProfileManager {
|
|
|
31
35
|
// stop() began un-adoptable, so a late child can't be reassigned to this.daemon
|
|
32
36
|
// and escape teardown.
|
|
33
37
|
stopping = false;
|
|
38
|
+
// Three DISTINCT facts, tracked separately (do not conflate):
|
|
39
|
+
// - ensuredAccounts: the bb-browser account EXISTS (created or confirmed).
|
|
40
|
+
// - preparedAccounts: FIRST-USE preparation completed this session — desired
|
|
41
|
+
// proxy reconciled to Parall's config AND readiness verified. A hydrated
|
|
42
|
+
// (hosted, S3-restored) account exists but is NOT prepared until its first
|
|
43
|
+
// invoke/viewer/open reconciles the proxy + probes readiness.
|
|
34
44
|
ensuredAccounts = new Set();
|
|
45
|
+
preparedAccounts = new Set();
|
|
35
46
|
ensuringAccounts = new Map();
|
|
47
|
+
preparingAccounts = new Map();
|
|
36
48
|
reportedStatuses = new Map();
|
|
49
|
+
// Per-account owned-target binding: every tab-addressed command is pinned to a
|
|
50
|
+
// page in the profile's own BrowserContext (see browser-target-registry.ts for
|
|
51
|
+
// the account-blind-routing hazard this closes).
|
|
52
|
+
targets;
|
|
37
53
|
// Live viewer orchestration (bb-viewer streamer subprocesses + viewer nav)
|
|
38
54
|
// lives in BrowserViewerStreamer; the manager hands it the account-scoped
|
|
39
55
|
// bb-browser command path via the host interface.
|
|
@@ -41,12 +57,16 @@ export class BrowserProfileManager {
|
|
|
41
57
|
constructor(opts) {
|
|
42
58
|
this.opts = opts;
|
|
43
59
|
mkdirSync(this.opts.homeDir, { recursive: true });
|
|
60
|
+
this.targets = new BrowserTargetRegistry({
|
|
61
|
+
sendCommand: (request) => this.sendCommand(request),
|
|
62
|
+
});
|
|
44
63
|
this.viewer = new BrowserViewerStreamer({
|
|
45
64
|
log: this.opts.log,
|
|
46
65
|
sendBrowserCommand: (request) => this.sendCommand(request),
|
|
47
|
-
|
|
66
|
+
prepareForUse: (account) => this.prepareForUse(account).then(() => { }),
|
|
48
67
|
withDaemonRecovery: (operation, label) => this.withDaemonRecovery(operation, label),
|
|
49
68
|
cdpEndpoint: () => this.cdpEndpoint(),
|
|
69
|
+
targets: this.targets,
|
|
50
70
|
});
|
|
51
71
|
}
|
|
52
72
|
async invoke({ profileId, account, command, input }) {
|
|
@@ -55,7 +75,7 @@ export class BrowserProfileManager {
|
|
|
55
75
|
if (!account)
|
|
56
76
|
throw new Error('browser account is required');
|
|
57
77
|
try {
|
|
58
|
-
await this.withDaemonRecovery(() => this.
|
|
78
|
+
await this.withDaemonRecovery(() => this.prepareForUse(account), `prepare invoke ${command} for ${profileId}`);
|
|
59
79
|
}
|
|
60
80
|
catch (err) {
|
|
61
81
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -72,9 +92,13 @@ export class BrowserProfileManager {
|
|
|
72
92
|
}
|
|
73
93
|
throw err;
|
|
74
94
|
}
|
|
95
|
+
// Dispatch exactly once. A pinned tab that vanished between resolve and
|
|
96
|
+
// dispatch surfaces bb-browser's "Tab not found" as a hard error — the
|
|
97
|
+
// command is NEVER re-bound to a different tab and replayed (a click/type/
|
|
98
|
+
// eval retargeted mid-flight could act on a page the caller never meant).
|
|
99
|
+
// The next independent command re-establishes an owned target through the
|
|
100
|
+
// normal resolve path; that is the only recovery.
|
|
75
101
|
try {
|
|
76
|
-
// Do not replay arbitrary forwarded browser commands: click/type/eval can
|
|
77
|
-
// have user-visible side effects if Chrome consumed the first attempt.
|
|
78
102
|
const result = await this.sendCommand(request);
|
|
79
103
|
this.reportStatus(profileId, 'running');
|
|
80
104
|
return result;
|
|
@@ -100,7 +124,10 @@ export class BrowserProfileManager {
|
|
|
100
124
|
this.reportedStatuses.delete(profileId);
|
|
101
125
|
}
|
|
102
126
|
try {
|
|
103
|
-
|
|
127
|
+
// Shared first-use invariant. A steady reconcile tick (already prepared,
|
|
128
|
+
// not a pending-row recovery) takes the fast path inside prepareForUse;
|
|
129
|
+
// forceStatusReport forces a full re-prepare (proxy + readiness).
|
|
130
|
+
await this.withDaemonRecovery(() => this.prepareForUse(profileId, { force: opts.forceStatusReport }), `ensure runtime for ${profileId}`);
|
|
104
131
|
this.reportStatus(profileId, 'running');
|
|
105
132
|
}
|
|
106
133
|
catch (err) {
|
|
@@ -121,8 +148,17 @@ export class BrowserProfileManager {
|
|
|
121
148
|
const url = normalizeStartUrl(startUrl);
|
|
122
149
|
try {
|
|
123
150
|
await this.withDaemonRecovery(async () => {
|
|
124
|
-
|
|
125
|
-
|
|
151
|
+
// Open is an explicit lifecycle action: FORCE a full first-use prepare so
|
|
152
|
+
// a proxy edit made while the profile was stopped takes effect now
|
|
153
|
+
// (cookie-preserving reconcile) and readiness is re-verified — never
|
|
154
|
+
// bb-browser's stored proxy. prepareForUse handles proxy + readiness; the
|
|
155
|
+
// readiness probe runs on a sacrificial tab, so landing the start URL on
|
|
156
|
+
// the primary owned tab afterward is independent.
|
|
157
|
+
const { accountCreated } = await this.prepareForUse(profileId, {
|
|
158
|
+
startUrl: url,
|
|
159
|
+
force: true,
|
|
160
|
+
});
|
|
161
|
+
await this.openProfileTabOnce(profileId, url, accountCreated);
|
|
126
162
|
}, `open profile ${profileId}`);
|
|
127
163
|
this.reportStatus(profileId, 'running');
|
|
128
164
|
}
|
|
@@ -132,6 +168,19 @@ export class BrowserProfileManager {
|
|
|
132
168
|
throw err;
|
|
133
169
|
}
|
|
134
170
|
}
|
|
171
|
+
/** Resolve + normalize/validate the desired proxy (fail closed on legacy-invalid
|
|
172
|
+
* configs — see browser-proxy-state.ts). null = direct egress. */
|
|
173
|
+
async resolveDesiredProxy(profileId) {
|
|
174
|
+
const desired = this.opts.resolveProxy ? await this.opts.resolveProxy(profileId) : null;
|
|
175
|
+
return normalizeProxyConfig(desired);
|
|
176
|
+
}
|
|
177
|
+
assertReady(profileId, proxy) {
|
|
178
|
+
return assertOpenReady({
|
|
179
|
+
log: this.opts.log,
|
|
180
|
+
targets: this.targets,
|
|
181
|
+
sendCommand: (request) => this.sendCommand(request),
|
|
182
|
+
}, profileId, { proxy, probeUrl: this.opts.proxyProbeUrl });
|
|
183
|
+
}
|
|
135
184
|
async stopProfile(profileId) {
|
|
136
185
|
if (!profileId)
|
|
137
186
|
throw new Error('browser profile id is required');
|
|
@@ -179,7 +228,9 @@ export class BrowserProfileManager {
|
|
|
179
228
|
this.daemon = null;
|
|
180
229
|
this.starting = null;
|
|
181
230
|
this.ensuredAccounts.clear();
|
|
231
|
+
this.preparedAccounts.clear();
|
|
182
232
|
this.ensuringAccounts.clear();
|
|
233
|
+
this.preparingAccounts.clear();
|
|
183
234
|
this.reportedStatuses.clear();
|
|
184
235
|
if (restarting) {
|
|
185
236
|
try {
|
|
@@ -232,29 +283,60 @@ export class BrowserProfileManager {
|
|
|
232
283
|
if (!host || typeof port !== 'number' || !Number.isFinite(port)) {
|
|
233
284
|
throw new Error('bb-browser /status did not report a CDP endpoint');
|
|
234
285
|
}
|
|
286
|
+
this.lastCdpEndpoint = { host, port };
|
|
235
287
|
return { host, port };
|
|
236
288
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
289
|
+
// Last CDP endpoint observed from /status — a PASSIVE record for shutdown-time
|
|
290
|
+
// checks (browser-quiescence): null when Chrome never started this run, and
|
|
291
|
+
// possibly stale after a daemon restart (a stale port refuses connections,
|
|
292
|
+
// which reads as "gone" — exactly the safe answer).
|
|
293
|
+
lastCdpEndpoint = null;
|
|
294
|
+
/** Process-group id of the last spawned bb-browser tree (the detached child's
|
|
295
|
+
* pid). Survives the child's exit — quiescence probes it AFTER stop() to
|
|
296
|
+
* confirm the orphaned Chrome is gone too. null = never spawned. */
|
|
297
|
+
lastPgid = null;
|
|
298
|
+
/** The browser tree's process-group id for the quiescence barrier. */
|
|
299
|
+
lastKnownProcessGroup() {
|
|
300
|
+
return this.lastPgid;
|
|
301
|
+
}
|
|
302
|
+
/** Peek the last known Chrome CDP endpoint WITHOUT starting anything. */
|
|
303
|
+
lastKnownCdpEndpoint() {
|
|
304
|
+
return this.lastCdpEndpoint;
|
|
241
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Land the profile on its start URL, idempotently, always inside its own
|
|
308
|
+
* context. A fresh account already opened `url` via `account_create`
|
|
309
|
+
* (accountCreated) — just bind it. Otherwise: reuse an owned tab already on
|
|
310
|
+
* the URL if any (repeated Open is a no-op), else NAVIGATE the profile's
|
|
311
|
+
* primary owned tab (`open {tabId}`) — never stack a new tab per Open.
|
|
312
|
+
*/
|
|
242
313
|
async openProfileTabOnce(profileId, url, accountCreated) {
|
|
243
|
-
if (accountCreated)
|
|
314
|
+
if (accountCreated) {
|
|
315
|
+
await this.targets.ensureOwnedTab(profileId);
|
|
244
316
|
return;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
317
|
+
}
|
|
318
|
+
if (url !== 'about:blank') {
|
|
319
|
+
const existing = await this.targets.findOwnedTabMatchingUrl(profileId, url);
|
|
320
|
+
if (existing !== undefined)
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const tabId = await this.targets.ensureOwnedTab(profileId);
|
|
324
|
+
if (url !== 'about:blank') {
|
|
325
|
+
await this.sendCommand({ method: 'open', account: profileId, tabId, url });
|
|
326
|
+
}
|
|
248
327
|
}
|
|
249
328
|
async resetProfileOnce(profileId) {
|
|
250
329
|
if (await this.accountExists(profileId)) {
|
|
251
330
|
await this.sendCommand({ method: 'account_delete', account: profileId });
|
|
252
331
|
}
|
|
332
|
+
// Account gone → both cached facts invalidated (exists, prepared).
|
|
253
333
|
this.ensuredAccounts.delete(profileId);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
334
|
+
this.preparedAccounts.delete(profileId);
|
|
335
|
+
// Reset means "erase this profile's persisted state". A proxy reconcile that
|
|
336
|
+
// crashed mid-flight left a pending cookie backup on disk; the next Open's
|
|
337
|
+
// ensureAccount would restore it into the fresh account and RESURRECT the very
|
|
338
|
+
// login state Reset just erased. Drop it so Reset is a true clean slate.
|
|
339
|
+
clearCookieBackup(this.opts.homeDir, profileId);
|
|
258
340
|
}
|
|
259
341
|
async recoverInvokeAvailability(label, profileId, err) {
|
|
260
342
|
try {
|
|
@@ -268,18 +350,40 @@ export class BrowserProfileManager {
|
|
|
268
350
|
}
|
|
269
351
|
}
|
|
270
352
|
async accountExists(account) {
|
|
353
|
+
return (await this.getAccountInfo(account)).exists;
|
|
354
|
+
}
|
|
355
|
+
/** One account_info round trip: existence + whether bb-browser holds a proxy for
|
|
356
|
+
* the account (server/username only — bb-browser never returns the password). */
|
|
357
|
+
async getAccountInfo(account) {
|
|
271
358
|
try {
|
|
272
|
-
await this.sendCommand({ method: 'account_info', account });
|
|
273
|
-
|
|
359
|
+
const result = await this.sendCommand({ method: 'account_info', account });
|
|
360
|
+
const accounts = Array.isArray(result.accounts)
|
|
361
|
+
? result.accounts
|
|
362
|
+
: [];
|
|
363
|
+
const info = accounts.find((a) => a?.name === account) ?? accounts[0];
|
|
364
|
+
return { exists: true, showsProxy: info?.proxy != null };
|
|
274
365
|
}
|
|
275
366
|
catch (err) {
|
|
276
367
|
if (isBrowserAccountInfoUnauthenticatedError(err))
|
|
277
|
-
return true;
|
|
368
|
+
return { exists: true, showsProxy: false };
|
|
278
369
|
if (isBrowserAccountNotFoundError(err))
|
|
279
|
-
return false;
|
|
370
|
+
return { exists: false, showsProxy: false };
|
|
280
371
|
throw err;
|
|
281
372
|
}
|
|
282
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Build the wire request for a forwarded browser command, binding it to a
|
|
376
|
+
* page the profile's account OWNS. bb-browser's own routing is account-blind
|
|
377
|
+
* (tab-less → global current tab → `targets[0]`, which is the default-context
|
|
378
|
+
* `about:blank` Chrome launches with — no cookies, NO PROXY), so:
|
|
379
|
+
*
|
|
380
|
+
* - a caller-supplied `tabId`/`tab` must resolve to one of the profile's own
|
|
381
|
+
* tabs (numeric global indices are rejected outright);
|
|
382
|
+
* - every other tab-addressed command gets the profile's primary owned tab
|
|
383
|
+
* stamped in (recreated inside the profile's BrowserContext if its pages
|
|
384
|
+
* were closed — never silently retargeted at another page);
|
|
385
|
+
* - only genuinely tab-less commands (TABLESS_COMMANDS) go unpinned.
|
|
386
|
+
*/
|
|
283
387
|
async buildCommandRequest(account, command, input) {
|
|
284
388
|
const body = input && typeof input === 'object' && !Array.isArray(input)
|
|
285
389
|
? { ...input }
|
|
@@ -287,106 +391,187 @@ export class BrowserProfileManager {
|
|
|
287
391
|
const request = body;
|
|
288
392
|
request.method = command;
|
|
289
393
|
request.account = account;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
394
|
+
// Did the CALLER pin a specific tab? (`tabId`/`tab` present in the input.)
|
|
395
|
+
const ref = request.tabId ?? request.tab;
|
|
396
|
+
const explicitTarget = ref !== undefined && ref !== null && ref !== '';
|
|
397
|
+
// Domain-addressed eval resolves a page by domain — but ONLY when the caller
|
|
398
|
+
// pinned no explicit tab. An explicit `tab`/`tabId` (even alongside `domain`)
|
|
399
|
+
// wins; guarding on `!explicitTarget` (not just `tabId === undefined`) so a
|
|
400
|
+
// short `tab` ref isn't silently overridden.
|
|
401
|
+
if (command === 'eval' && typeof request.domain === 'string' && !explicitTarget) {
|
|
402
|
+
const tabRef = await this.targets.resolveDomainTab(account, request.domain);
|
|
403
|
+
if (tabRef === undefined) {
|
|
404
|
+
// Never fall through to bb-browser's account-blind domain resolution: it
|
|
405
|
+
// searches ALL contexts and creates missing tabs in the DEFAULT context
|
|
406
|
+
// (unproxied, cookie-less).
|
|
407
|
+
throw new BrowserTargetError(`could not resolve a ${request.domain} page inside browser profile ${account}`, 'BROWSER_TARGET_UNAVAILABLE');
|
|
408
|
+
}
|
|
409
|
+
request.tabId = tabRef;
|
|
410
|
+
return request;
|
|
411
|
+
}
|
|
412
|
+
if (command === 'site_run' && !explicitTarget) {
|
|
413
|
+
// bb-browser's site_run routes account-BLIND to the global current tab (the
|
|
414
|
+
// unowned, unproxied default-context about:blank → real-IP egress) when the
|
|
415
|
+
// adapter has no domain and no explicit tab. Decide safety from the ADAPTER's
|
|
416
|
+
// OWN metadata — its `domain` via site_info(siteName) — NEVER a caller-supplied
|
|
417
|
+
// request.domain (upstream site_run ignores that field, so it can't gate
|
|
418
|
+
// routing). Domain-ful adapter → pin an owned tab ON that domain; domain-less
|
|
419
|
+
// adapter → require an explicit owned tab, else fail closed.
|
|
420
|
+
const siteName = typeof request.siteName === 'string' ? request.siteName.trim() : '';
|
|
421
|
+
if (siteName === '') {
|
|
422
|
+
throw new BrowserTargetError(`site_run needs a siteName in browser profile ${account}`, 'BROWSER_TARGET_UNAVAILABLE');
|
|
423
|
+
}
|
|
424
|
+
const domain = await this.siteAdapterDomain(account, siteName);
|
|
425
|
+
if (domain === '') {
|
|
426
|
+
throw new BrowserTargetError(`site_run adapter "${siteName}" has no domain — it needs an explicit owned tab in browser profile ${account}, refusing account-blind global routing`, 'BROWSER_TARGET_UNAVAILABLE');
|
|
427
|
+
}
|
|
428
|
+
const tabRef = await this.targets.resolveDomainTab(account, domain);
|
|
429
|
+
if (tabRef === undefined) {
|
|
430
|
+
throw new BrowserTargetError(`could not resolve a ${domain} page for site_run "${siteName}" in browser profile ${account}`, 'BROWSER_TARGET_UNAVAILABLE');
|
|
431
|
+
}
|
|
432
|
+
// Pin the owned, on-domain tab so upstream executeSiteAdapter uses THIS tab
|
|
433
|
+
// (an explicit tabId short-circuits its own domain/global resolution).
|
|
434
|
+
request.tabId = tabRef;
|
|
435
|
+
return request;
|
|
436
|
+
}
|
|
437
|
+
if (TABLESS_COMMANDS.has(command)) {
|
|
438
|
+
// Pure account/list commands (site_info/site_list/account_*/tab_list/tab_new)
|
|
439
|
+
// take no pinned tab; an explicit ref still gets the ownership check below.
|
|
440
|
+
if (!explicitTarget)
|
|
441
|
+
return request;
|
|
442
|
+
}
|
|
443
|
+
request.tabId = await this.targets.resolveCommandTarget(account, ref);
|
|
444
|
+
delete request.tab;
|
|
295
445
|
return request;
|
|
296
446
|
}
|
|
297
447
|
/**
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
448
|
+
* The domain bb-browser's adapter metadata declares for `siteName` (via
|
|
449
|
+
* site_info), or '' when the adapter genuinely has no domain / does not exist.
|
|
450
|
+
* This is the ONLY trustworthy domain source for site_run routing — a
|
|
451
|
+
* caller-supplied request.domain is ignored by upstream site_run, so it can never
|
|
452
|
+
* gate account-safe routing.
|
|
302
453
|
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
454
|
+
* Error handling is deliberately split: ONLY a genuine application-level answer
|
|
455
|
+
* from bb-browser ("no such site") yields '' → the caller fails closed. A
|
|
456
|
+
* RECOVERABLE daemon/CDP failure (or any transport error) RETHROWS, so
|
|
457
|
+
* buildCommandRequest's existing `isRecoverableBrowserDaemonError` →
|
|
458
|
+
* `recoverInvokeAvailability` restart+retry path handles it. Masking those as a
|
|
459
|
+
* domain-less adapter would turn a transient hiccup into a permanent
|
|
460
|
+
* BROWSER_TARGET_UNAVAILABLE. Note a BrowserCommandError can itself carry a
|
|
461
|
+
* recoverable message ("Chrome not connected"), so the recoverable check wins.
|
|
309
462
|
*/
|
|
310
|
-
async
|
|
311
|
-
const url = normalizeDomainUrl(domain);
|
|
312
|
-
let host = '';
|
|
463
|
+
async siteAdapterDomain(account, siteName) {
|
|
313
464
|
try {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
catch {
|
|
317
|
-
/* fall through — tab_new with whatever bb-browser makes of it */
|
|
465
|
+
const info = await this.sendCommand({ method: 'site_info', siteName, account });
|
|
466
|
+
return typeof info.domain === 'string' ? info.domain.trim() : '';
|
|
318
467
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
return existing;
|
|
323
|
-
}
|
|
324
|
-
const tab = await this.sendCommand({ method: 'tab_new', url, account });
|
|
325
|
-
const tabRef = (tab?.tab ?? tab?.tabId);
|
|
326
|
-
if (tabRef === undefined || !host)
|
|
327
|
-
return tabRef;
|
|
328
|
-
// Wait for the created tab to actually reach the domain (navigation
|
|
329
|
-
// committed). Mirrors upstream resolveTabByDomain's bounded wait + settle.
|
|
330
|
-
const deadline = Date.now() + 10_000;
|
|
331
|
-
while (Date.now() < deadline) {
|
|
332
|
-
const found = await this.findAccountTabOnHost(account, host, tabRef);
|
|
333
|
-
if (found !== undefined)
|
|
334
|
-
break;
|
|
335
|
-
await sleep(300);
|
|
336
|
-
}
|
|
337
|
-
await sleep(750); // post-commit settle (upstream uses a flat 2s)
|
|
338
|
-
return tabRef;
|
|
339
|
-
}
|
|
340
|
-
/** Find an account-owned tab whose URL host matches (optionally a specific tab). */
|
|
341
|
-
async findAccountTabOnHost(account, host, onlyTabRef) {
|
|
342
|
-
const list = await this.sendCommand({ method: 'tab_list', account });
|
|
343
|
-
const tabs = Array.isArray(list.tabs)
|
|
344
|
-
? list.tabs
|
|
345
|
-
: [];
|
|
346
|
-
for (const t of tabs) {
|
|
347
|
-
if (t?.account !== account)
|
|
348
|
-
continue;
|
|
349
|
-
const ref = (t.tab ?? t.tabId);
|
|
350
|
-
if (onlyTabRef !== undefined && ref !== onlyTabRef && t.tabId !== onlyTabRef)
|
|
351
|
-
continue;
|
|
352
|
-
if (typeof t.url !== 'string')
|
|
353
|
-
continue;
|
|
354
|
-
try {
|
|
355
|
-
if (hostsMatch(new URL(t.url).host, host))
|
|
356
|
-
return ref;
|
|
357
|
-
}
|
|
358
|
-
catch {
|
|
359
|
-
/* non-URL tab (about:blank etc.) — skip */
|
|
468
|
+
catch (err) {
|
|
469
|
+
if (err instanceof BrowserCommandError && !isRecoverableBrowserDaemonError(err)) {
|
|
470
|
+
return '';
|
|
360
471
|
}
|
|
472
|
+
throw err;
|
|
361
473
|
}
|
|
362
|
-
return undefined;
|
|
363
474
|
}
|
|
364
|
-
|
|
365
|
-
|
|
475
|
+
/**
|
|
476
|
+
* First-use preparation — the SINGLE invariant shared by invoke, viewer, open,
|
|
477
|
+
* and ensureRuntime (fix #4). Runs ONCE per account per session:
|
|
478
|
+
* 1. resolve Parall's desired proxy (fail closed on invalid config);
|
|
479
|
+
* 2. ensure the account exists AND its applied proxy matches desired
|
|
480
|
+
* (cookie-preserving reconcile if it drifted — e.g. a hydrated hosted
|
|
481
|
+
* account created under an old proxy);
|
|
482
|
+
* 3. verify readiness (owned target answers; when proxied, a real request
|
|
483
|
+
* traverses the proxy without a transport error).
|
|
484
|
+
* Subsequent calls take the steady fast path — no config fetch, no probe —
|
|
485
|
+
* unless `force` is set (explicit lifecycle open / pending-row recovery). A
|
|
486
|
+
* daemon restart, reset, or proxy reconcile clears the prepared marker so the
|
|
487
|
+
* next use re-prepares. Deduped so concurrent first-uses share one run.
|
|
488
|
+
*/
|
|
489
|
+
prepareForUse(account, opts = {}) {
|
|
490
|
+
if (this.preparedAccounts.has(account) && !opts.force) {
|
|
491
|
+
// Steady path: confirm the account still exists + owns a tab (no-op once
|
|
492
|
+
// ensured); no desired-proxy fetch, no readiness probe.
|
|
493
|
+
return this.ensureAccount(account).then(() => ({ accountCreated: false }));
|
|
494
|
+
}
|
|
495
|
+
const inflight = this.preparingAccounts.get(account);
|
|
496
|
+
if (inflight && !opts.force)
|
|
497
|
+
return inflight;
|
|
498
|
+
// Serialize per account: even a FORCED prepare (openProfile) chains AFTER any
|
|
499
|
+
// in-flight one, so a proxy reconcile's account_delete/account_create can never
|
|
500
|
+
// run concurrently for the same account (which would corrupt its context /
|
|
501
|
+
// cookie jar). A non-forced caller dedups onto the in-flight promise above; a
|
|
502
|
+
// forced caller waits for it, then does its own (now-cheap, fingerprint-matched)
|
|
503
|
+
// pass. The prior error is swallowed so a failed prepare doesn't poison the next.
|
|
504
|
+
const prior = inflight ? inflight.catch(() => undefined) : Promise.resolve(undefined);
|
|
505
|
+
const run = prior.then(async () => {
|
|
506
|
+
// Drop any stale "prepared" fact up front: if this (possibly forced) full
|
|
507
|
+
// prepare fails at reconcile or readiness, the account must NOT stay marked
|
|
508
|
+
// prepared, or the next non-forced invoke would fast-path past proxy
|
|
509
|
+
// alignment + the probe and run on an old proxy / half-rebuilt account. The
|
|
510
|
+
// flag is re-added only after a fully successful prepare below.
|
|
511
|
+
this.preparedAccounts.delete(account);
|
|
512
|
+
const desired = await this.resolveDesiredProxy(account);
|
|
513
|
+
const accountCreated = await this.ensureAccount(account, {
|
|
514
|
+
desiredProxy: desired,
|
|
515
|
+
accountUrl: opts.startUrl && opts.startUrl !== 'about:blank' ? opts.startUrl : undefined,
|
|
516
|
+
});
|
|
517
|
+
await this.assertReady(account, desired);
|
|
518
|
+
this.preparedAccounts.add(account);
|
|
519
|
+
return { accountCreated };
|
|
520
|
+
});
|
|
521
|
+
this.preparingAccounts.set(account, run);
|
|
522
|
+
return run.finally(() => {
|
|
523
|
+
if (this.preparingAccounts.get(account) === run)
|
|
524
|
+
this.preparingAccounts.delete(account);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Ensure the profile's bb-browser account exists and honors the DESIRED proxy.
|
|
529
|
+
*
|
|
530
|
+
* `desiredProxy` semantics:
|
|
531
|
+
* - undefined (invoke fast path): don't fetch/reconcile — create-only proxy
|
|
532
|
+
* resolution via opts.resolveProxy when the account is missing. A proxy
|
|
533
|
+
* change applies at the next open / first ensure.
|
|
534
|
+
* - null / config (open + first ensure): Parall's config is the SSOT. A
|
|
535
|
+
* missing account is created with it; an existing account whose APPLIED
|
|
536
|
+
* fingerprint (Parall-owned sidecar) differs is rebuilt with cookies
|
|
537
|
+
* preserved (reconcileAccountProxy) — bb-browser's persisted account proxy
|
|
538
|
+
* never wins.
|
|
539
|
+
*/
|
|
540
|
+
async ensureAccount(account, opts = {}) {
|
|
541
|
+
const { accountUrl, desiredProxy } = opts;
|
|
542
|
+
if (this.ensuredAccounts.has(account) && desiredProxy === undefined)
|
|
366
543
|
return false;
|
|
367
544
|
const inflight = this.ensuringAccounts.get(account);
|
|
368
545
|
if (inflight)
|
|
369
546
|
return inflight;
|
|
370
547
|
const ensure = (async () => {
|
|
371
548
|
let accountCreated = false;
|
|
372
|
-
|
|
373
|
-
|
|
549
|
+
const info = await this.getAccountInfo(account);
|
|
550
|
+
if (!info.exists) {
|
|
551
|
+
// Create with the desired proxy — pre-resolved by the caller, or resolved
|
|
552
|
+
// here (invoke path). Validation fails closed: no account rather than
|
|
553
|
+
// direct egress for a proxy-configured profile.
|
|
554
|
+
const proxy = desiredProxy !== undefined ? desiredProxy : await this.resolveDesiredProxy(account);
|
|
555
|
+
await this.sendCommand({
|
|
556
|
+
method: 'account_create',
|
|
557
|
+
account,
|
|
558
|
+
...(accountUrl ? { accountUrl } : {}),
|
|
559
|
+
...proxyCreateFields(proxy),
|
|
560
|
+
});
|
|
561
|
+
accountCreated = true;
|
|
562
|
+
// A proxy reconcile that crashed after account_delete left a cookie
|
|
563
|
+
// backup — this fresh account is its continuation. One shared settle
|
|
564
|
+
// tail (restore → clear backup → fingerprint LAST) finishes it.
|
|
565
|
+
await settlePendingCookieBackup(this.reconcileHost(), account, proxyFingerprint(proxy));
|
|
374
566
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
await this.sendCommand({
|
|
384
|
-
method: 'account_create',
|
|
385
|
-
account,
|
|
386
|
-
...(accountUrl ? { accountUrl } : {}),
|
|
387
|
-
...proxyCreateFields(proxy),
|
|
388
|
-
});
|
|
389
|
-
accountCreated = true;
|
|
567
|
+
else if (desiredProxy !== undefined) {
|
|
568
|
+
const recorded = appliedProxyFingerprint(this.opts.homeDir, account);
|
|
569
|
+
if (proxyReconcileNeeded(recorded, desiredProxy, info.showsProxy)) {
|
|
570
|
+
await reconcileAccountProxy(this.reconcileHost(), account, desiredProxy);
|
|
571
|
+
}
|
|
572
|
+
else if (recorded === undefined) {
|
|
573
|
+
// Both sides direct — adopt the fingerprint so later checks are exact.
|
|
574
|
+
writeAppliedProxyFingerprint(this.opts.homeDir, account, proxyFingerprint(desiredProxy));
|
|
390
575
|
}
|
|
391
576
|
}
|
|
392
577
|
if (!accountUrl) {
|
|
@@ -403,47 +588,17 @@ export class BrowserProfileManager {
|
|
|
403
588
|
this.ensuringAccounts.delete(account);
|
|
404
589
|
}
|
|
405
590
|
}
|
|
591
|
+
reconcileHost() {
|
|
592
|
+
return {
|
|
593
|
+
log: this.opts.log,
|
|
594
|
+
homeDir: this.opts.homeDir,
|
|
595
|
+
targets: this.targets,
|
|
596
|
+
sendCommand: (request) => this.sendCommand(request),
|
|
597
|
+
cdpEndpoint: () => this.cdpEndpoint(),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
406
600
|
async ensureAccountOwnedTab(account) {
|
|
407
|
-
|
|
408
|
-
return;
|
|
409
|
-
await this.sendCommand({ method: 'tab_new', account, url: 'about:blank' });
|
|
410
|
-
}
|
|
411
|
-
async profileAlreadyHasOpenTab(profileId, url) {
|
|
412
|
-
// Lifecycle `open` can be delivered more than once (WS + pending reconcile,
|
|
413
|
-
// retry, or duplicate user action). Treat only the requested target URL as
|
|
414
|
-
// already-open; a same-host different path is still a distinct user intent.
|
|
415
|
-
if (url === 'about:blank')
|
|
416
|
-
return this.hasAccountOwnedTab(profileId);
|
|
417
|
-
return this.findAccountTabMatchingUrl(profileId, url);
|
|
418
|
-
}
|
|
419
|
-
async hasAccountOwnedTab(account) {
|
|
420
|
-
const list = await this.sendCommand({ method: 'tab_list', account });
|
|
421
|
-
const tabs = Array.isArray(list.tabs)
|
|
422
|
-
? list.tabs
|
|
423
|
-
: [];
|
|
424
|
-
return tabs.some((tab) => tab?.account === account);
|
|
425
|
-
}
|
|
426
|
-
async findAccountTabMatchingUrl(account, url) {
|
|
427
|
-
const expected = comparableUrl(url);
|
|
428
|
-
if (!expected)
|
|
429
|
-
return false;
|
|
430
|
-
const list = await this.sendCommand({ method: 'tab_list', account });
|
|
431
|
-
const tabs = Array.isArray(list.tabs)
|
|
432
|
-
? list.tabs
|
|
433
|
-
: [];
|
|
434
|
-
for (const tab of tabs) {
|
|
435
|
-
if (tab?.account !== account || typeof tab.url !== 'string')
|
|
436
|
-
continue;
|
|
437
|
-
const actual = comparableUrl(tab.url);
|
|
438
|
-
if (!actual)
|
|
439
|
-
continue;
|
|
440
|
-
const sameResource = hostsMatch(actual.host, expected.host) && actual.target === expected.target;
|
|
441
|
-
const sameProtocol = actual.protocol === expected.protocol;
|
|
442
|
-
const redirectedUpgrade = expected.protocol === 'http:' && actual.protocol === 'https:';
|
|
443
|
-
if (sameResource && (sameProtocol || redirectedUpgrade))
|
|
444
|
-
return true;
|
|
445
|
-
}
|
|
446
|
-
return false;
|
|
601
|
+
await this.targets.ensureOwnedTab(account);
|
|
447
602
|
}
|
|
448
603
|
reportStatus(profileId, status, errorMsg) {
|
|
449
604
|
const marker = `${status}\u0000${errorMsg ?? ''}`;
|
|
@@ -525,7 +680,9 @@ export class BrowserProfileManager {
|
|
|
525
680
|
this.daemon = null;
|
|
526
681
|
this.starting = null;
|
|
527
682
|
this.ensuredAccounts.clear();
|
|
683
|
+
this.preparedAccounts.clear();
|
|
528
684
|
this.ensuringAccounts.clear();
|
|
685
|
+
this.preparingAccounts.clear();
|
|
529
686
|
this.opts.log.warn(`[bb-browser] ${label} hit an unavailable Chrome/CDP page target; restarting bb-browser-daemon (${formatErrorForLog(err)})`);
|
|
530
687
|
if (!daemon)
|
|
531
688
|
return;
|
|
@@ -597,7 +754,15 @@ export class BrowserProfileManager {
|
|
|
597
754
|
], {
|
|
598
755
|
env: buildBrowserDaemonEnv(process.env, this.opts.homeDir),
|
|
599
756
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
757
|
+
// detached: bb-browser becomes the LEADER of a fresh process group, and
|
|
758
|
+
// Chrome + its renderers (spawned by bb-browser with detached:false)
|
|
759
|
+
// inherit it. That group id (= this child's pid) is the daemon's owned
|
|
760
|
+
// handle on the whole browser tree — browser-quiescence.ts signals and
|
|
761
|
+
// awaits `-pgid` instead of scanning /proc for user-data-dir strings.
|
|
762
|
+
// Direct child management (waitForChildExit, child.kill) is unaffected.
|
|
763
|
+
detached: true,
|
|
600
764
|
});
|
|
765
|
+
this.lastPgid = child.pid ?? null;
|
|
601
766
|
// A spawn/exec failure (ENOENT, EACCES, …) emits 'error' on the child, which
|
|
602
767
|
// — with no listener — Node re-throws as an uncaught exception and crashes
|
|
603
768
|
// the daemon. Capture it so startup fails through the normal path instead.
|
|
@@ -659,17 +824,6 @@ export class BrowserProfileManager {
|
|
|
659
824
|
}
|
|
660
825
|
}
|
|
661
826
|
}
|
|
662
|
-
/** Host comparison tolerant of a `www.` prefix on either side. */
|
|
663
|
-
function hostsMatch(a, b) {
|
|
664
|
-
const norm = (h) => h.toLowerCase().replace(/^www\./, '');
|
|
665
|
-
return norm(a) === norm(b);
|
|
666
|
-
}
|
|
667
|
-
function normalizeDomainUrl(domain) {
|
|
668
|
-
const trimmed = domain.trim();
|
|
669
|
-
if (!trimmed)
|
|
670
|
-
return 'about:blank';
|
|
671
|
-
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
672
|
-
}
|
|
673
827
|
function normalizeStartUrl(value) {
|
|
674
828
|
const trimmed = value?.trim();
|
|
675
829
|
if (!trimmed)
|
|
@@ -678,6 +832,11 @@ function normalizeStartUrl(value) {
|
|
|
678
832
|
return trimmed;
|
|
679
833
|
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
680
834
|
}
|
|
835
|
+
/**
|
|
836
|
+
* bb-browser's `ensurePageTarget` throws `Tab not found: <ref>` when a string
|
|
837
|
+
* ref no longer resolves — strictly BEFORE the command dispatch switch runs, so
|
|
838
|
+
* the command did not execute and a single re-resolve + retry is safe.
|
|
839
|
+
*/
|
|
681
840
|
function isBrowserAccountNotFoundError(err) {
|
|
682
841
|
if (!(err instanceof BrowserCommandError) || err.method !== 'account_info')
|
|
683
842
|
return false;
|
|
@@ -712,19 +871,6 @@ function isRecoverableBrowserDaemonError(err) {
|
|
|
712
871
|
(lower.includes('bb-browser /command returned 503') &&
|
|
713
872
|
(lower.includes('chrome') || lower.includes('cdp'))));
|
|
714
873
|
}
|
|
715
|
-
function comparableUrl(url) {
|
|
716
|
-
try {
|
|
717
|
-
const parsed = new URL(url);
|
|
718
|
-
return {
|
|
719
|
-
protocol: parsed.protocol,
|
|
720
|
-
host: parsed.host,
|
|
721
|
-
target: `${parsed.pathname}${parsed.search}${parsed.hash}`,
|
|
722
|
-
};
|
|
723
|
-
}
|
|
724
|
-
catch {
|
|
725
|
-
return null;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
874
|
function resolveBbBrowserDaemonPath() {
|
|
729
875
|
// Bundle channels (CDN self-update, desktop, npm bin — all run the flat
|
|
730
876
|
// esbuild artifact with NO node_modules) ship bb-browser as a sibling flat
|
|
@@ -748,15 +894,3 @@ function resolveBbBrowserDaemonPath() {
|
|
|
748
894
|
function randomToken() {
|
|
749
895
|
return randomBytes(16).toString('hex');
|
|
750
896
|
}
|
|
751
|
-
/** Render a proxy config into bb-browser `account_create` fields (camelCase, the
|
|
752
|
-
* bb-browser-pro 0.15 contract). Empty when there is no proxy → direct egress. */
|
|
753
|
-
function proxyCreateFields(proxy) {
|
|
754
|
-
if (!proxy?.server)
|
|
755
|
-
return {};
|
|
756
|
-
const fields = { proxyServer: proxy.server };
|
|
757
|
-
if (proxy.username)
|
|
758
|
-
fields.proxyUsername = proxy.username;
|
|
759
|
-
if (proxy.password)
|
|
760
|
-
fields.proxyPassword = proxy.password;
|
|
761
|
-
return fields;
|
|
762
|
-
}
|