@parall/daemon 1.45.0 → 1.47.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.
Files changed (55) hide show
  1. package/bundle/manifest.json +15 -15
  2. package/bundle/parall-browser-pod.js +29796 -379
  3. package/bundle/parall-channel-exec.js +2 -0
  4. package/bundle/parall-claude-agent.js +26161 -277
  5. package/bundle/parall-codex-agent.js +26720 -335
  6. package/bundle/parall-daemon.js +31760 -2001
  7. package/bundle/parall-openclaw-agent.js +1 -0
  8. package/dist/browser-pod.d.ts +23 -1
  9. package/dist/browser-pod.d.ts.map +1 -1
  10. package/dist/browser-pod.js +104 -34
  11. package/dist/browser-profile-reconcile.d.ts +21 -0
  12. package/dist/browser-profile-reconcile.d.ts.map +1 -0
  13. package/dist/browser-profile-reconcile.js +188 -0
  14. package/dist/clip-runtime/browser-cdp.d.ts +40 -0
  15. package/dist/clip-runtime/browser-cdp.d.ts.map +1 -0
  16. package/dist/clip-runtime/browser-cdp.js +218 -0
  17. package/dist/clip-runtime/browser-profile-manager.d.ts +97 -24
  18. package/dist/clip-runtime/browser-profile-manager.d.ts.map +1 -1
  19. package/dist/clip-runtime/browser-profile-manager.js +316 -182
  20. package/dist/clip-runtime/browser-profile-pool.d.ts +3 -0
  21. package/dist/clip-runtime/browser-profile-pool.d.ts.map +1 -1
  22. package/dist/clip-runtime/browser-profile-pool.js +18 -1
  23. package/dist/clip-runtime/browser-proxy-reconcile.d.ts +77 -0
  24. package/dist/clip-runtime/browser-proxy-reconcile.d.ts.map +1 -0
  25. package/dist/clip-runtime/browser-proxy-reconcile.js +139 -0
  26. package/dist/clip-runtime/browser-proxy-state.d.ts +55 -0
  27. package/dist/clip-runtime/browser-proxy-state.d.ts.map +1 -0
  28. package/dist/clip-runtime/browser-proxy-state.js +149 -0
  29. package/dist/clip-runtime/browser-quiescence.d.ts +71 -0
  30. package/dist/clip-runtime/browser-quiescence.d.ts.map +1 -0
  31. package/dist/clip-runtime/browser-quiescence.js +136 -0
  32. package/dist/clip-runtime/browser-readiness.d.ts +64 -0
  33. package/dist/clip-runtime/browser-readiness.d.ts.map +1 -0
  34. package/dist/clip-runtime/browser-readiness.js +161 -0
  35. package/dist/clip-runtime/browser-state-store.d.ts +13 -2
  36. package/dist/clip-runtime/browser-state-store.d.ts.map +1 -1
  37. package/dist/clip-runtime/browser-state-store.js +15 -6
  38. package/dist/clip-runtime/browser-target-registry.d.ts +143 -0
  39. package/dist/clip-runtime/browser-target-registry.d.ts.map +1 -0
  40. package/dist/clip-runtime/browser-target-registry.js +297 -0
  41. package/dist/clip-runtime/browser-viewer-streamer.d.ts +13 -14
  42. package/dist/clip-runtime/browser-viewer-streamer.d.ts.map +1 -1
  43. package/dist/clip-runtime/browser-viewer-streamer.js +11 -63
  44. package/dist/daemon-main.d.ts.map +1 -1
  45. package/dist/daemon-main.js +3 -1
  46. package/dist/local-control.d.ts +59 -0
  47. package/dist/local-control.d.ts.map +1 -0
  48. package/dist/local-control.js +230 -0
  49. package/dist/local-profile-control.d.ts +77 -0
  50. package/dist/local-profile-control.d.ts.map +1 -0
  51. package/dist/local-profile-control.js +108 -0
  52. package/dist/supervisor.d.ts +19 -0
  53. package/dist/supervisor.d.ts.map +1 -1
  54. package/dist/supervisor.js +90 -187
  55. 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
- ensureAccount: (account) => this.ensureAccount(account),
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.prepareInvokeAccount(account), `prepare invoke ${command} for ${profileId}`);
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
- await this.withDaemonRecovery(() => this.ensureAccount(profileId), `ensure runtime for ${profileId}`);
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
- const prepareResult = await this.prepareOpenProfile(profileId, url);
125
- await this.openProfileTabOnce(profileId, url, prepareResult.accountCreated);
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
- async prepareOpenProfile(profileId, url) {
238
- const accountCreated = await this.ensureAccount(profileId, url !== 'about:blank' ? url : undefined);
239
- await this.sendCommand({ method: 'tab_list', account: profileId });
240
- return { accountCreated };
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
- if (await this.profileAlreadyHasOpenTab(profileId, url))
246
- return;
247
- await this.sendCommand({ method: 'tab_new', account: profileId, url });
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
- async prepareInvokeAccount(account) {
256
- await this.ensureAccount(account);
257
- await this.sendCommand({ method: 'tab_list', account });
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
- return true;
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
- if (command === 'eval' && typeof request.domain === 'string' && request.tabId === undefined) {
291
- const tabRef = await this.resolveAccountDomainTab(account, request.domain);
292
- if (tabRef !== undefined)
293
- request.tabId = tabRef;
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
- * Account-scoped version of bb-browser's `resolveTabByDomain` (which is
299
- * account-blind the reason for this daemon-side preselection workaround).
300
- * Two properties the bare `tab_new` + eval approach lacked, both caught on
301
- * the staging closed-loop E2E (2026-06-04):
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
- * 1. Reuse: an existing account tab already on the domain is reused instead
304
- * of opening a new tab per eval (upstream reuses matching tabs too).
305
- * 2. Load wait: after creating a tab, wait for the navigation to commit
306
- * before eval upstream waits (~10s poll + settle); without it the clip
307
- * script races `about:blank` and relative fetches fail
308
- * ("Failed to parse URL from /hot.json").
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 resolveAccountDomainTab(account, domain) {
311
- const url = normalizeDomainUrl(domain);
312
- let host = '';
463
+ async siteAdapterDomain(account, siteName) {
313
464
  try {
314
- host = new URL(url).host;
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
- if (host) {
320
- const existing = await this.findAccountTabOnHost(account, host);
321
- if (existing !== undefined)
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
- async ensureAccount(account, accountUrl) {
365
- if (this.ensuredAccounts.has(account))
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
- try {
373
- await this.sendCommand({ method: 'account_info', account });
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
- catch (err) {
376
- if (!isBrowserAccountInfoUnauthenticatedError(err)) {
377
- if (!isBrowserAccountNotFoundError(err))
378
- throw err;
379
- // Resolve the proxy ONLY here, at the single account-creation choke point.
380
- // A thrown resolve fails closed (no account rather than direct egress for a
381
- // proxy-configured profile). bb-browser binds the proxy at account_create.
382
- const proxy = this.opts.resolveProxy ? await this.opts.resolveProxy(account) : null;
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
- if (await this.hasAccountOwnedTab(account))
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
- }