@parall/daemon 1.32.0 → 1.33.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/bb-browser-daemon.js +15628 -0
- package/bundle/buildDomTree.js +1501 -0
- package/bundle/manifest.json +19 -11
- package/bundle/parall-claude-agent.js +224 -58
- package/bundle/parall-codex-agent.js +224 -58
- package/bundle/parall-daemon.js +4507 -2668
- package/bundle/parall-openclaw-agent.js +4 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -2
- package/dist/clip-runtime/browser-dependency.d.ts +20 -0
- package/dist/clip-runtime/browser-dependency.d.ts.map +1 -0
- package/dist/clip-runtime/browser-dependency.js +52 -0
- package/dist/clip-runtime/browser-profile-manager.d.ts +67 -0
- package/dist/clip-runtime/browser-profile-manager.d.ts.map +1 -0
- package/dist/clip-runtime/browser-profile-manager.js +595 -0
- package/dist/clip-runtime/bun-resolver.d.ts +24 -0
- package/dist/clip-runtime/bun-resolver.d.ts.map +1 -0
- package/dist/clip-runtime/bun-resolver.js +58 -0
- package/dist/clip-runtime/clip-installer.d.ts.map +1 -1
- package/dist/clip-runtime/clip-installer.js +59 -18
- package/dist/clip-runtime/clip-provider.d.ts +13 -2
- package/dist/clip-runtime/clip-provider.d.ts.map +1 -1
- package/dist/clip-runtime/clip-provider.js +106 -36
- package/dist/clip-runtime/hub-client.d.ts +79 -0
- package/dist/clip-runtime/hub-client.d.ts.map +1 -0
- package/dist/clip-runtime/hub-client.js +320 -0
- package/dist/clip-runtime/index.d.ts +2 -0
- package/dist/clip-runtime/index.d.ts.map +1 -1
- package/dist/clip-runtime/index.js +2 -0
- package/dist/clip-runtime/ipc.d.ts +6 -0
- package/dist/clip-runtime/ipc.d.ts.map +1 -1
- package/dist/clip-runtime/manifest.d.ts +16 -8
- package/dist/clip-runtime/manifest.d.ts.map +1 -1
- package/dist/clip-runtime/manifest.js +13 -0
- package/dist/clip-runtime/process-manager.d.ts +55 -3
- package/dist/clip-runtime/process-manager.d.ts.map +1 -1
- package/dist/clip-runtime/process-manager.js +233 -76
- package/dist/clip-runtime/process.d.ts +15 -1
- package/dist/clip-runtime/process.d.ts.map +1 -1
- package/dist/clip-runtime/process.js +73 -10
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/index.js +46 -5
- package/dist/runtime-bin-resolver.d.ts +7 -0
- package/dist/runtime-bin-resolver.d.ts.map +1 -0
- package/dist/runtime-bin-resolver.js +292 -0
- package/dist/supervisor.d.ts +53 -4
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +449 -117
- package/package.json +7 -6
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import * as net from 'node:net';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
const BB_BROWSER_DAEMON_START_TIMEOUT_MS = 15_000;
|
|
9
|
+
class BrowserCommandError extends Error {
|
|
10
|
+
method;
|
|
11
|
+
account;
|
|
12
|
+
constructor(message, method, account) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.method = method;
|
|
15
|
+
this.account = account;
|
|
16
|
+
this.name = 'BrowserCommandError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class BrowserProfileManager {
|
|
20
|
+
opts;
|
|
21
|
+
daemon = null;
|
|
22
|
+
starting = null;
|
|
23
|
+
restarting = null;
|
|
24
|
+
ensuredAccounts = new Set();
|
|
25
|
+
ensuringAccounts = new Map();
|
|
26
|
+
reportedStatuses = new Map();
|
|
27
|
+
constructor(opts) {
|
|
28
|
+
this.opts = opts;
|
|
29
|
+
mkdirSync(this.opts.homeDir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
async invoke({ profileId, account, command, input }) {
|
|
32
|
+
if (!profileId)
|
|
33
|
+
throw new Error('browser profile id is required');
|
|
34
|
+
if (!account)
|
|
35
|
+
throw new Error('browser account is required');
|
|
36
|
+
try {
|
|
37
|
+
await this.withDaemonRecovery(() => this.prepareInvokeAccount(account), `prepare invoke ${command} for ${profileId}`);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
41
|
+
this.reportStatus(profileId, 'error', message);
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
let request;
|
|
45
|
+
try {
|
|
46
|
+
request = await this.buildCommandRequest(account, command, input);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (isRecoverableBrowserDaemonError(err)) {
|
|
50
|
+
await this.recoverInvokeAvailability(`build invoke ${command} for ${profileId}`, profileId, err);
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
// Do not replay arbitrary forwarded browser commands: click/type/eval can
|
|
56
|
+
// have user-visible side effects if Chrome consumed the first attempt.
|
|
57
|
+
const result = await this.sendCommand(request);
|
|
58
|
+
this.reportStatus(profileId, 'running');
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (isRecoverableBrowserDaemonError(err)) {
|
|
63
|
+
await this.recoverInvokeAvailability(`invoke ${command} for ${profileId}`, profileId, err);
|
|
64
|
+
}
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async ensureRuntime(profileId) {
|
|
69
|
+
if (!profileId)
|
|
70
|
+
throw new Error('browser profile id is required');
|
|
71
|
+
try {
|
|
72
|
+
await this.withDaemonRecovery(() => this.ensureAccount(profileId), `ensure runtime for ${profileId}`);
|
|
73
|
+
this.reportStatus(profileId, 'running');
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
77
|
+
this.reportStatus(profileId, 'error', message);
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async openProfile(profileId, startUrl) {
|
|
82
|
+
if (!profileId)
|
|
83
|
+
throw new Error('browser profile id is required');
|
|
84
|
+
// Explicit lifecycle actions must always report their outcome: the server
|
|
85
|
+
// resets the profile to `pending` on the API call, so a deduped repeat of
|
|
86
|
+
// our previous report (same status+message marker) would leave the profile
|
|
87
|
+
// stuck in pending forever. Clearing the marker keeps the dedup for the
|
|
88
|
+
// chatty invoke path while making lifecycle outcomes authoritative.
|
|
89
|
+
this.reportedStatuses.delete(profileId);
|
|
90
|
+
const url = normalizeStartUrl(startUrl);
|
|
91
|
+
let prepareResult = {
|
|
92
|
+
accountCreated: false,
|
|
93
|
+
accountExistedBeforePrepare: true,
|
|
94
|
+
};
|
|
95
|
+
let firstAccountExistedBeforePrepare;
|
|
96
|
+
let recoveredDuringPrepare = false;
|
|
97
|
+
try {
|
|
98
|
+
prepareResult = await this.withDaemonRecovery(() => this.prepareOpenProfile(profileId, url, (exists) => {
|
|
99
|
+
firstAccountExistedBeforePrepare ??= exists;
|
|
100
|
+
}), `prepare open profile ${profileId}`, () => {
|
|
101
|
+
recoveredDuringPrepare = true;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
106
|
+
this.reportStatus(profileId, 'error', message);
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
await this.openProfileTabOnce(profileId, url, prepareResult.accountCreated, recoveredDuringPrepare, firstAccountExistedBeforePrepare ?? prepareResult.accountExistedBeforePrepare);
|
|
111
|
+
this.reportStatus(profileId, 'running');
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
if (isRecoverableBrowserDaemonError(err)) {
|
|
115
|
+
await this.restartDaemon(`open profile ${profileId}`, err);
|
|
116
|
+
}
|
|
117
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
118
|
+
this.reportStatus(profileId, 'error', message);
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async stopProfile(profileId) {
|
|
123
|
+
if (!profileId)
|
|
124
|
+
throw new Error('browser profile id is required');
|
|
125
|
+
this.reportedStatuses.delete(profileId); // see openProfile — lifecycle outcomes always report
|
|
126
|
+
// bb-browser-pro does not expose a single-account "dispose context" command.
|
|
127
|
+
// Server-side stopped status is the authority that gates future invokes.
|
|
128
|
+
this.reportStatus(profileId, 'stopped');
|
|
129
|
+
}
|
|
130
|
+
async resetProfile(profileId) {
|
|
131
|
+
if (!profileId)
|
|
132
|
+
throw new Error('browser profile id is required');
|
|
133
|
+
this.reportedStatuses.delete(profileId); // see openProfile — lifecycle outcomes always report
|
|
134
|
+
try {
|
|
135
|
+
await this.withDaemonRecovery(() => this.resetProfileOnce(profileId), `reset profile ${profileId}`);
|
|
136
|
+
this.reportStatus(profileId, 'stopped');
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
140
|
+
this.reportStatus(profileId, 'error', message);
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async stop() {
|
|
145
|
+
const daemon = this.daemon;
|
|
146
|
+
this.daemon = null;
|
|
147
|
+
this.starting = null;
|
|
148
|
+
this.ensuredAccounts.clear();
|
|
149
|
+
this.ensuringAccounts.clear();
|
|
150
|
+
this.reportedStatuses.clear();
|
|
151
|
+
if (!daemon)
|
|
152
|
+
return;
|
|
153
|
+
try {
|
|
154
|
+
await this.post('/shutdown', daemon, undefined, 3_000);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* best-effort */
|
|
158
|
+
}
|
|
159
|
+
if (daemon.child.exitCode === null && daemon.child.signalCode === null) {
|
|
160
|
+
daemon.child.kill('SIGTERM');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async prepareOpenProfile(profileId, url, recordAccountExists) {
|
|
164
|
+
const accountExistedBeforePrepare = await this.accountExists(profileId);
|
|
165
|
+
recordAccountExists?.(accountExistedBeforePrepare);
|
|
166
|
+
const accountCreated = await this.ensureAccount(profileId, url !== 'about:blank' ? url : undefined);
|
|
167
|
+
await this.sendCommand({ method: 'tab_list', account: profileId });
|
|
168
|
+
return { accountCreated, accountExistedBeforePrepare };
|
|
169
|
+
}
|
|
170
|
+
async openProfileTabOnce(profileId, url, accountCreated, recoveredDuringPrepare, accountExistedBeforePrepare) {
|
|
171
|
+
if (accountCreated)
|
|
172
|
+
return;
|
|
173
|
+
if (recoveredDuringPrepare && !accountExistedBeforePrepare) {
|
|
174
|
+
if (url === 'about:blank')
|
|
175
|
+
return;
|
|
176
|
+
const host = safeUrlHost(url);
|
|
177
|
+
if (host && (await this.findAccountTabOnHost(profileId, host)) !== undefined)
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await this.sendCommand({ method: 'tab_new', account: profileId, url });
|
|
181
|
+
}
|
|
182
|
+
async resetProfileOnce(profileId) {
|
|
183
|
+
if (await this.accountExists(profileId)) {
|
|
184
|
+
await this.sendCommand({ method: 'account_delete', account: profileId });
|
|
185
|
+
}
|
|
186
|
+
this.ensuredAccounts.delete(profileId);
|
|
187
|
+
}
|
|
188
|
+
async prepareInvokeAccount(account) {
|
|
189
|
+
await this.ensureAccount(account);
|
|
190
|
+
await this.sendCommand({ method: 'tab_list', account });
|
|
191
|
+
}
|
|
192
|
+
async recoverInvokeAvailability(label, profileId, err) {
|
|
193
|
+
try {
|
|
194
|
+
await this.restartDaemon(label, err);
|
|
195
|
+
this.reportStatus(profileId, 'running');
|
|
196
|
+
}
|
|
197
|
+
catch (restartErr) {
|
|
198
|
+
const message = restartErr instanceof Error ? restartErr.message : String(restartErr);
|
|
199
|
+
this.reportStatus(profileId, 'error', message);
|
|
200
|
+
throw restartErr;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async accountExists(account) {
|
|
204
|
+
try {
|
|
205
|
+
await this.sendCommand({ method: 'account_info', account });
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
if (isBrowserAccountInfoUnauthenticatedError(err))
|
|
210
|
+
return true;
|
|
211
|
+
if (isBrowserAccountNotFoundError(err))
|
|
212
|
+
return false;
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async buildCommandRequest(account, command, input) {
|
|
217
|
+
const body = input && typeof input === 'object' && !Array.isArray(input)
|
|
218
|
+
? { ...input }
|
|
219
|
+
: { value: input };
|
|
220
|
+
const request = body;
|
|
221
|
+
request.method = command;
|
|
222
|
+
request.account = account;
|
|
223
|
+
if (command === 'eval' && typeof request.domain === 'string' && request.tabId === undefined) {
|
|
224
|
+
const tabRef = await this.resolveAccountDomainTab(account, request.domain);
|
|
225
|
+
if (tabRef !== undefined)
|
|
226
|
+
request.tabId = tabRef;
|
|
227
|
+
}
|
|
228
|
+
return request;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Account-scoped version of bb-browser's `resolveTabByDomain` (which is
|
|
232
|
+
* account-blind — the reason for this daemon-side preselection workaround).
|
|
233
|
+
* Two properties the bare `tab_new` + eval approach lacked, both caught on
|
|
234
|
+
* the staging closed-loop E2E (2026-06-04):
|
|
235
|
+
*
|
|
236
|
+
* 1. Reuse: an existing account tab already on the domain is reused instead
|
|
237
|
+
* of opening a new tab per eval (upstream reuses matching tabs too).
|
|
238
|
+
* 2. Load wait: after creating a tab, wait for the navigation to commit
|
|
239
|
+
* before eval — upstream waits (~10s poll + settle); without it the clip
|
|
240
|
+
* script races `about:blank` and relative fetches fail
|
|
241
|
+
* ("Failed to parse URL from /hot.json").
|
|
242
|
+
*/
|
|
243
|
+
async resolveAccountDomainTab(account, domain) {
|
|
244
|
+
const url = normalizeDomainUrl(domain);
|
|
245
|
+
let host = '';
|
|
246
|
+
try {
|
|
247
|
+
host = new URL(url).host;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
/* fall through — tab_new with whatever bb-browser makes of it */
|
|
251
|
+
}
|
|
252
|
+
if (host) {
|
|
253
|
+
const existing = await this.findAccountTabOnHost(account, host);
|
|
254
|
+
if (existing !== undefined)
|
|
255
|
+
return existing;
|
|
256
|
+
}
|
|
257
|
+
const tab = await this.sendCommand({ method: 'tab_new', url, account });
|
|
258
|
+
const tabRef = (tab?.tab ?? tab?.tabId);
|
|
259
|
+
if (tabRef === undefined || !host)
|
|
260
|
+
return tabRef;
|
|
261
|
+
// Wait for the created tab to actually reach the domain (navigation
|
|
262
|
+
// committed). Mirrors upstream resolveTabByDomain's bounded wait + settle.
|
|
263
|
+
const deadline = Date.now() + 10_000;
|
|
264
|
+
while (Date.now() < deadline) {
|
|
265
|
+
const found = await this.findAccountTabOnHost(account, host, tabRef);
|
|
266
|
+
if (found !== undefined)
|
|
267
|
+
break;
|
|
268
|
+
await sleep(300);
|
|
269
|
+
}
|
|
270
|
+
await sleep(750); // post-commit settle (upstream uses a flat 2s)
|
|
271
|
+
return tabRef;
|
|
272
|
+
}
|
|
273
|
+
/** Find an account-owned tab whose URL host matches (optionally a specific tab). */
|
|
274
|
+
async findAccountTabOnHost(account, host, onlyTabRef) {
|
|
275
|
+
const list = await this.sendCommand({ method: 'tab_list', account });
|
|
276
|
+
const tabs = Array.isArray(list.tabs)
|
|
277
|
+
? list.tabs
|
|
278
|
+
: [];
|
|
279
|
+
for (const t of tabs) {
|
|
280
|
+
if (t?.account !== account)
|
|
281
|
+
continue;
|
|
282
|
+
const ref = (t.tab ?? t.tabId);
|
|
283
|
+
if (onlyTabRef !== undefined && ref !== onlyTabRef && t.tabId !== onlyTabRef)
|
|
284
|
+
continue;
|
|
285
|
+
if (typeof t.url !== 'string')
|
|
286
|
+
continue;
|
|
287
|
+
try {
|
|
288
|
+
if (hostsMatch(new URL(t.url).host, host))
|
|
289
|
+
return ref;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
/* non-URL tab (about:blank etc.) — skip */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
async ensureAccount(account, accountUrl) {
|
|
298
|
+
if (this.ensuredAccounts.has(account))
|
|
299
|
+
return false;
|
|
300
|
+
const inflight = this.ensuringAccounts.get(account);
|
|
301
|
+
if (inflight)
|
|
302
|
+
return inflight;
|
|
303
|
+
const ensure = (async () => {
|
|
304
|
+
let accountCreated = false;
|
|
305
|
+
try {
|
|
306
|
+
await this.sendCommand({ method: 'account_info', account });
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
if (!isBrowserAccountInfoUnauthenticatedError(err)) {
|
|
310
|
+
if (!isBrowserAccountNotFoundError(err))
|
|
311
|
+
throw err;
|
|
312
|
+
await this.sendCommand({
|
|
313
|
+
method: 'account_create',
|
|
314
|
+
account,
|
|
315
|
+
...(accountUrl ? { accountUrl } : {}),
|
|
316
|
+
});
|
|
317
|
+
accountCreated = true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (!accountUrl) {
|
|
321
|
+
await this.ensureAccountOwnedTab(account);
|
|
322
|
+
}
|
|
323
|
+
this.ensuredAccounts.add(account);
|
|
324
|
+
return accountCreated;
|
|
325
|
+
})();
|
|
326
|
+
this.ensuringAccounts.set(account, ensure);
|
|
327
|
+
try {
|
|
328
|
+
return await ensure;
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
this.ensuringAccounts.delete(account);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async ensureAccountOwnedTab(account) {
|
|
335
|
+
const list = await this.sendCommand({ method: 'tab_list', account });
|
|
336
|
+
const tabs = Array.isArray(list.tabs)
|
|
337
|
+
? list.tabs
|
|
338
|
+
: [];
|
|
339
|
+
if (tabs.some((tab) => tab?.account === account))
|
|
340
|
+
return;
|
|
341
|
+
await this.sendCommand({ method: 'tab_new', account, url: 'about:blank' });
|
|
342
|
+
}
|
|
343
|
+
reportStatus(profileId, status, errorMsg) {
|
|
344
|
+
const marker = `${status}\u0000${errorMsg ?? ''}`;
|
|
345
|
+
if (this.reportedStatuses.get(profileId) === marker)
|
|
346
|
+
return;
|
|
347
|
+
this.reportedStatuses.set(profileId, marker);
|
|
348
|
+
this.opts.reportStatus?.(profileId, status, errorMsg);
|
|
349
|
+
}
|
|
350
|
+
async sendCommand(request) {
|
|
351
|
+
const daemon = await this.ensureDaemon();
|
|
352
|
+
const response = await this.post('/command', daemon, request, 30_000);
|
|
353
|
+
if (response.error) {
|
|
354
|
+
throw new BrowserCommandError(response.error.message || 'bb-browser command failed', request.method, request.account);
|
|
355
|
+
}
|
|
356
|
+
return response.result ?? {};
|
|
357
|
+
}
|
|
358
|
+
async ensureDaemon() {
|
|
359
|
+
if (this.restarting)
|
|
360
|
+
await this.restarting;
|
|
361
|
+
if (this.daemon &&
|
|
362
|
+
this.daemon.child.exitCode === null &&
|
|
363
|
+
this.daemon.child.signalCode === null) {
|
|
364
|
+
return this.daemon;
|
|
365
|
+
}
|
|
366
|
+
if (this.starting)
|
|
367
|
+
return this.starting;
|
|
368
|
+
this.starting = this.startDaemon();
|
|
369
|
+
try {
|
|
370
|
+
this.daemon = await this.starting;
|
|
371
|
+
return this.daemon;
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
this.starting = null;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async withDaemonRecovery(operation, label, onRecovered) {
|
|
378
|
+
try {
|
|
379
|
+
return await operation();
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
if (!isRecoverableBrowserDaemonError(err))
|
|
383
|
+
throw err;
|
|
384
|
+
await this.restartDaemon(label, err);
|
|
385
|
+
onRecovered?.();
|
|
386
|
+
return operation();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async restartDaemon(label, err) {
|
|
390
|
+
if (this.restarting)
|
|
391
|
+
return this.restarting;
|
|
392
|
+
this.restarting = this.restartDaemonOnce(label, err);
|
|
393
|
+
try {
|
|
394
|
+
await this.restarting;
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
this.restarting = null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async restartDaemonOnce(label, err) {
|
|
401
|
+
const daemon = this.daemon;
|
|
402
|
+
this.daemon = null;
|
|
403
|
+
this.starting = null;
|
|
404
|
+
this.ensuredAccounts.clear();
|
|
405
|
+
this.ensuringAccounts.clear();
|
|
406
|
+
this.opts.log.warn(`[bb-browser] ${label} hit a disconnected Chrome/CDP session; restarting bb-browser-daemon (${formatErrorForLog(err)})`);
|
|
407
|
+
if (!daemon)
|
|
408
|
+
return;
|
|
409
|
+
try {
|
|
410
|
+
await this.post('/shutdown', daemon, undefined, 3_000);
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
/* best-effort */
|
|
414
|
+
}
|
|
415
|
+
if (daemon.child.exitCode === null && daemon.child.signalCode === null) {
|
|
416
|
+
daemon.child.kill('SIGTERM');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async startDaemon() {
|
|
420
|
+
const host = '127.0.0.1';
|
|
421
|
+
const port = await findFreePort();
|
|
422
|
+
const token = randomToken();
|
|
423
|
+
const daemonPath = resolveBbBrowserDaemonPath();
|
|
424
|
+
const child = spawn(process.execPath, [daemonPath, '--host', host, '--port', String(port), '--token', token], {
|
|
425
|
+
env: {
|
|
426
|
+
...process.env,
|
|
427
|
+
BB_BROWSER_HOME: this.opts.homeDir,
|
|
428
|
+
},
|
|
429
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
430
|
+
});
|
|
431
|
+
// A spawn/exec failure (ENOENT, EACCES, …) emits 'error' on the child, which
|
|
432
|
+
// — with no listener — Node re-throws as an uncaught exception and crashes
|
|
433
|
+
// the daemon. Capture it so startup fails through the normal path instead.
|
|
434
|
+
let childError = null;
|
|
435
|
+
child.once('error', (err) => {
|
|
436
|
+
childError = err;
|
|
437
|
+
});
|
|
438
|
+
child.stderr?.on('data', (chunk) => {
|
|
439
|
+
const text = chunk.toString('utf8').trim();
|
|
440
|
+
if (text)
|
|
441
|
+
this.opts.log.warn(`[bb-browser] ${text}`);
|
|
442
|
+
});
|
|
443
|
+
const state = { child, host, port, token };
|
|
444
|
+
const deadline = Date.now() + BB_BROWSER_DAEMON_START_TIMEOUT_MS;
|
|
445
|
+
while (Date.now() < deadline) {
|
|
446
|
+
if (childError) {
|
|
447
|
+
throw childError;
|
|
448
|
+
}
|
|
449
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
450
|
+
throw new Error(`bb-browser-daemon exited during startup`);
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
await this.post('/status', state, undefined, 2_000);
|
|
454
|
+
this.opts.log.info(`[bb-browser] daemon ready at ${host}:${port}`);
|
|
455
|
+
return state;
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
await sleep(200);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
child.kill('SIGTERM');
|
|
462
|
+
throw new Error('bb-browser-daemon did not start in time');
|
|
463
|
+
}
|
|
464
|
+
async post(pathName, daemon, body, timeoutMs = 10_000) {
|
|
465
|
+
const controller = new AbortController();
|
|
466
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
467
|
+
try {
|
|
468
|
+
const resp = await fetch(`http://${daemon.host}:${daemon.port}${pathName}`, {
|
|
469
|
+
method: pathName === '/status' ? 'GET' : 'POST',
|
|
470
|
+
headers: {
|
|
471
|
+
Authorization: `Bearer ${daemon.token}`,
|
|
472
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
473
|
+
},
|
|
474
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
475
|
+
signal: controller.signal,
|
|
476
|
+
});
|
|
477
|
+
const text = await resp.text();
|
|
478
|
+
if (!resp.ok) {
|
|
479
|
+
throw new Error(`bb-browser ${pathName} returned ${resp.status}: ${text}`);
|
|
480
|
+
}
|
|
481
|
+
return (text ? JSON.parse(text) : {});
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
clearTimeout(timer);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/** Host comparison tolerant of a `www.` prefix on either side. */
|
|
489
|
+
function hostsMatch(a, b) {
|
|
490
|
+
const norm = (h) => h.toLowerCase().replace(/^www\./, '');
|
|
491
|
+
return norm(a) === norm(b);
|
|
492
|
+
}
|
|
493
|
+
function normalizeDomainUrl(domain) {
|
|
494
|
+
const trimmed = domain.trim();
|
|
495
|
+
if (!trimmed)
|
|
496
|
+
return 'about:blank';
|
|
497
|
+
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
498
|
+
}
|
|
499
|
+
function normalizeStartUrl(value) {
|
|
500
|
+
const trimmed = value?.trim();
|
|
501
|
+
if (!trimmed)
|
|
502
|
+
return 'about:blank';
|
|
503
|
+
if (trimmed === 'about:blank')
|
|
504
|
+
return trimmed;
|
|
505
|
+
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
506
|
+
}
|
|
507
|
+
function isBrowserAccountNotFoundError(err) {
|
|
508
|
+
if (!(err instanceof BrowserCommandError) || err.method !== 'account_info')
|
|
509
|
+
return false;
|
|
510
|
+
const message = err.message.trim().toLowerCase();
|
|
511
|
+
if (!message)
|
|
512
|
+
return false;
|
|
513
|
+
const notFound = message === 'not found' ||
|
|
514
|
+
message.includes('account not found') ||
|
|
515
|
+
message.includes('account does not exist') ||
|
|
516
|
+
message.includes('unknown account') ||
|
|
517
|
+
message.includes('missing account') ||
|
|
518
|
+
// bb-browser-pro 0.15.x interpolates the account name into the message:
|
|
519
|
+
// `Account "<id>" not found`. Without this arm the matcher misses the
|
|
520
|
+
// real daemon's format entirely, so account_create never runs and every
|
|
521
|
+
// open of a fresh profile fails (caught on staging E2E 2026-06-04 with a
|
|
522
|
+
// real local daemon + Chrome).
|
|
523
|
+
/^account\s+"[^"]*"\s+not found\b/.test(message);
|
|
524
|
+
return notFound;
|
|
525
|
+
}
|
|
526
|
+
function isBrowserAccountInfoUnauthenticatedError(err) {
|
|
527
|
+
if (!(err instanceof BrowserCommandError) || err.method !== 'account_info')
|
|
528
|
+
return false;
|
|
529
|
+
const message = err.message.trim().toLowerCase();
|
|
530
|
+
return message.includes('not logged in') || message.includes('not authenticated');
|
|
531
|
+
}
|
|
532
|
+
function isRecoverableBrowserDaemonError(err) {
|
|
533
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
534
|
+
const lower = message.toLowerCase();
|
|
535
|
+
return (lower.includes('chrome not connected') ||
|
|
536
|
+
lower.includes('cdp at 127.0.0.1') ||
|
|
537
|
+
(lower.includes('bb-browser /command returned 503') &&
|
|
538
|
+
(lower.includes('chrome') || lower.includes('cdp'))));
|
|
539
|
+
}
|
|
540
|
+
function safeUrlHost(url) {
|
|
541
|
+
try {
|
|
542
|
+
return new URL(url).host;
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
return '';
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function formatErrorForLog(err) {
|
|
549
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
550
|
+
return message.replace(/\s+/g, ' ').slice(0, 300);
|
|
551
|
+
}
|
|
552
|
+
function resolveBbBrowserDaemonPath() {
|
|
553
|
+
// Bundle channels (CDN self-update, desktop, npm bin — all run the flat
|
|
554
|
+
// esbuild artifact with NO node_modules) ship bb-browser as a sibling flat
|
|
555
|
+
// artifact (scripts/bundle-daemon.mjs). Prefer it; fall through to normal
|
|
556
|
+
// package resolution for dev/dist runs. Without this, every bundle-delivered
|
|
557
|
+
// daemon failed browser profiles with "Cannot find module
|
|
558
|
+
// '@pinixai/bb-browser-pro/package.json'" (desktop staging, 2026-06-04).
|
|
559
|
+
const sibling = path.join(path.dirname(fileURLToPath(import.meta.url)), 'bb-browser-daemon.js');
|
|
560
|
+
if (existsSync(sibling))
|
|
561
|
+
return sibling;
|
|
562
|
+
const require = createRequire(import.meta.url);
|
|
563
|
+
const pkgPath = require.resolve('@pinixai/bb-browser-pro/package.json');
|
|
564
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
565
|
+
const rel = typeof pkg.bin === 'string' ? pkg.bin : (pkg.bin?.['bb-browser-daemon'] ?? './dist/daemon.js');
|
|
566
|
+
const full = path.resolve(path.dirname(pkgPath), rel);
|
|
567
|
+
if (!existsSync(full)) {
|
|
568
|
+
throw new Error(`bb-browser-daemon entrypoint not found at ${full}`);
|
|
569
|
+
}
|
|
570
|
+
return full;
|
|
571
|
+
}
|
|
572
|
+
async function findFreePort() {
|
|
573
|
+
return new Promise((resolve, reject) => {
|
|
574
|
+
const server = net.createServer();
|
|
575
|
+
server.unref();
|
|
576
|
+
server.on('error', reject);
|
|
577
|
+
server.listen(0, '127.0.0.1', () => {
|
|
578
|
+
const address = server.address();
|
|
579
|
+
server.close(() => {
|
|
580
|
+
if (address && typeof address === 'object') {
|
|
581
|
+
resolve(address.port);
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
reject(new Error('failed to allocate free port'));
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
function randomToken() {
|
|
591
|
+
return randomBytes(16).toString('hex');
|
|
592
|
+
}
|
|
593
|
+
function sleep(ms) {
|
|
594
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
595
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun binary resolution — shared by the clip process manager (which spawns
|
|
3
|
+
* `bun run <entry> --ipc`) and the clip installer (which runs `bun install`).
|
|
4
|
+
*
|
|
5
|
+
* Resolution order, embedded-first:
|
|
6
|
+
* 1. Embedded bun shipped alongside the daemon's node binary. In a packaged
|
|
7
|
+
* desktop app the daemon is launched via `Frameworks/parall-node`, so
|
|
8
|
+
* `dirname(process.execPath)` is the `Frameworks/` dir and the embedded
|
|
9
|
+
* bun sits right beside it. electron-builder stages it under the CANONICAL
|
|
10
|
+
* name `Frameworks/bun` (node stays `parall-node` since the daemon only
|
|
11
|
+
* invokes node by absolute path, whereas bun must also be discoverable by
|
|
12
|
+
* name on the child PATH for clip postinstall hooks / clips that shell out
|
|
13
|
+
* to `bun`). This is the reliable path for users who never installed Bun.
|
|
14
|
+
* 2. User-level installs (`~/.bun/bin`, Homebrew) — for dev / CLI daemons.
|
|
15
|
+
* 3. PATH lookup (`which` / `where.exe`).
|
|
16
|
+
*
|
|
17
|
+
* A non-packaged or npm-CLI daemon runs from the system node, so the sibling
|
|
18
|
+
* `bun` does not exist and resolution falls through to the install candidates.
|
|
19
|
+
* The launchd-managed daemon inherits a minimal PATH
|
|
20
|
+
* (`/usr/bin:/bin:/usr/sbin:/sbin`) that contains neither `bun` nor `npm`,
|
|
21
|
+
* which is exactly why resolving an absolute path (not relying on PATH) matters.
|
|
22
|
+
*/
|
|
23
|
+
export declare function findBunBinary(): string;
|
|
24
|
+
//# sourceMappingURL=bun-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bun-resolver.d.ts","sourceRoot":"","sources":["../../src/clip-runtime/bun-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAMH,wBAAgB,aAAa,IAAI,MAAM,CAgCtC"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun binary resolution — shared by the clip process manager (which spawns
|
|
3
|
+
* `bun run <entry> --ipc`) and the clip installer (which runs `bun install`).
|
|
4
|
+
*
|
|
5
|
+
* Resolution order, embedded-first:
|
|
6
|
+
* 1. Embedded bun shipped alongside the daemon's node binary. In a packaged
|
|
7
|
+
* desktop app the daemon is launched via `Frameworks/parall-node`, so
|
|
8
|
+
* `dirname(process.execPath)` is the `Frameworks/` dir and the embedded
|
|
9
|
+
* bun sits right beside it. electron-builder stages it under the CANONICAL
|
|
10
|
+
* name `Frameworks/bun` (node stays `parall-node` since the daemon only
|
|
11
|
+
* invokes node by absolute path, whereas bun must also be discoverable by
|
|
12
|
+
* name on the child PATH for clip postinstall hooks / clips that shell out
|
|
13
|
+
* to `bun`). This is the reliable path for users who never installed Bun.
|
|
14
|
+
* 2. User-level installs (`~/.bun/bin`, Homebrew) — for dev / CLI daemons.
|
|
15
|
+
* 3. PATH lookup (`which` / `where.exe`).
|
|
16
|
+
*
|
|
17
|
+
* A non-packaged or npm-CLI daemon runs from the system node, so the sibling
|
|
18
|
+
* `bun` does not exist and resolution falls through to the install candidates.
|
|
19
|
+
* The launchd-managed daemon inherits a minimal PATH
|
|
20
|
+
* (`/usr/bin:/bin:/usr/sbin:/sbin`) that contains neither `bun` nor `npm`,
|
|
21
|
+
* which is exactly why resolving an absolute path (not relying on PATH) matters.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from 'node:fs';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
export function findBunBinary() {
|
|
27
|
+
const isWindows = process.platform === 'win32';
|
|
28
|
+
const binName = isWindows ? 'bun.exe' : 'bun';
|
|
29
|
+
// 1. Embedded bun — sibling of the daemon's node binary in a packaged app
|
|
30
|
+
// (staged as parall-bun-${arch}, landed as Frameworks/bun).
|
|
31
|
+
const embedded = path.join(path.dirname(process.execPath), binName);
|
|
32
|
+
if (fs.existsSync(embedded))
|
|
33
|
+
return embedded;
|
|
34
|
+
if (!isWindows) {
|
|
35
|
+
const candidates = [
|
|
36
|
+
path.join(process.env.HOME || '', '.bun', 'bin', 'bun'),
|
|
37
|
+
'/usr/local/bin/bun',
|
|
38
|
+
'/opt/homebrew/bin/bun',
|
|
39
|
+
];
|
|
40
|
+
for (const candidate of candidates) {
|
|
41
|
+
if (fs.existsSync(candidate))
|
|
42
|
+
return candidate;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// PATH lookup — platform-aware
|
|
46
|
+
const lookupCmd = isWindows ? 'where.exe' : 'which';
|
|
47
|
+
const lookupArg = isWindows ? 'bun.exe' : 'bun';
|
|
48
|
+
try {
|
|
49
|
+
const result = execFileSync(lookupCmd, [lookupArg], { encoding: 'utf-8' }).trim();
|
|
50
|
+
const firstLine = result.split('\n')[0]?.trim();
|
|
51
|
+
if (firstLine)
|
|
52
|
+
return firstLine;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// not found in PATH
|
|
56
|
+
}
|
|
57
|
+
throw new Error('bun binary not found — install Bun (https://bun.sh) or set bunPath option');
|
|
58
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"clip-installer.d.ts","sourceRoot":"","sources":["../../src/clip-runtime/clip-installer.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;
|
|
1
|
+
{"version":3,"file":"clip-installer.d.ts","sourceRoot":"","sources":["../../src/clip-runtime/clip-installer.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAeH,MAAM,WAAW,cAAc;IAC7B,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,8HAA8H;IAC9H,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,4CAA4C;IAC5C,IAAI,EAAE,MAAM,CAAC;CACd;AAMD,UAAU,YAAY;IACpB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAcD;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAgCxD;AAyeD,wBAAsB,WAAW,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CAiE9E;AAED,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM/E"}
|