@farmslot/adapter-web 0.1.0 → 0.3.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.
@@ -0,0 +1,718 @@
1
+ 'use strict';
2
+ // launch-browser.cjs — per-instance isolated detached Chrome launch
3
+ //
4
+ // Starts a detached Chrome bound to 127.0.0.1:<cdpPort> with its own profile and
5
+ // an unpacked extension loaded: by --load-extension, or over CDP
6
+ // (Extensions.loadUnpacked) for branded Chrome, which ignores that flag. The
7
+ // browser choice comes from the resolver record (`browserResolution`) when it
8
+ // names `chromeBin`.
9
+ //
10
+ // Writes: the pid file, an append-only Chrome log, and under `runtimeDir` the
11
+ // resolution record (browser-resolution.json) and the runtime identity. Never
12
+ // touches the extension dir (read-only) or anything outside the paths it is given.
13
+ //
14
+ // Product knowledge comes from the caller: the extension page to open
15
+ // (`homePage`), that page's own title (`defaultTitle`), how to recognise an
16
+ // owned browser by its extension path (`extensionOwnerRoot`), a lock held for
17
+ // the whole launch (`acquireRuntimeLock`), and the rerun command for hints.
18
+
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+ const { execFileSync, spawn, spawnSync } = require('node:child_process');
23
+
24
+ const { cdpListenerPids } = require('./browser-cdp.cjs');
25
+ const {
26
+ CDP_LOAD_UNPACKED,
27
+ describeBrowser,
28
+ extensionLaunchArgs,
29
+ waitForCdpOwner,
30
+ } = require('./browser-resolver.cjs');
31
+ const {
32
+ automationRuntimeArgs,
33
+ clearDetachedLaunchUnproven,
34
+ clearValidationPortQuarantine,
35
+ createRuntimeIdentityNonce,
36
+ hasDetachedLaunchUnproven,
37
+ isolatedProfileArgs,
38
+ markDetachedLaunchUnproven,
39
+ markValidationPortLaunchUnproven,
40
+ readValidationPortQuarantine,
41
+ remoteDebuggingArgs,
42
+ removeRuntimeIdentity,
43
+ runtimeIdentityArgs,
44
+ validationLaunchQuarantineError,
45
+ writeRuntimeIdentity,
46
+ } = require('./chrome-args.cjs');
47
+ const { extensionIdFromExtensionDir } = require('./extension-id.cjs');
48
+ const {
49
+ captureMacFrontmost,
50
+ macBackgroundOpenArgs,
51
+ restoreFrontmostIfOurs,
52
+ } = require('./macos-focus.cjs');
53
+ const {
54
+ profileProcessPids,
55
+ stopProfileProcessesSync,
56
+ } = require('./validation-process-ownership.cjs');
57
+
58
+ // The resolver CLI runs as a bounded child for CDP calls the launch must not hang on.
59
+ const BROWSER_RESOLVER_CLI = require.resolve('./browser-resolver.cjs');
60
+
61
+ /**
62
+ * @typedef {object} LaunchBrowserOptions
63
+ * @property {number} cdpPort
64
+ * @property {string} chromeBin
65
+ * @property {string} profile
66
+ * @property {string} extensionDir Unpacked extension to load.
67
+ * @property {string} runtimeDir Holds browser-resolution.json and the runtime identity.
68
+ * @property {string} chromeLog
69
+ * @property {string} chromePid
70
+ * @property {string} [browserResolution] Resolver record for this launch; reused when it names `chromeBin`.
71
+ * @property {string} [startUrl] First tab; defaults to the extension's `homePage`, else chrome://extensions/.
72
+ * @property {boolean} [stopOnly] Release the profile (owned browser stopped, singleton locks cleared) without launching.
73
+ * @property {boolean} [resetProfile] Recreate the profile; it must be a dedicated child of `runtimeDir`.
74
+ * @property {string} [homePage] Extension page (e.g. `home.html`) opened at start and kept to one tab.
75
+ * @property {string} [defaultTitle] That page's own title: a home tab with any other title (a slot-stamped one) is the one kept.
76
+ * @property {(extensionDir: string) => string | null} [extensionOwnerRoot] Path prefix whose `--load-extension=` also proves a browser is ours.
77
+ * @property {(runtimeDir: string) => () => void} [acquireRuntimeLock] Taken once the profile, log and pid directories exist, before any browser is stopped or started; the returned release runs when the launch returns or throws.
78
+ * @property {string} [rerunCommand] Command named in "Next:" hints.
79
+ * @property {number} [focusSettleMs] macOS: wait before the one focus check (default 1000).
80
+ * @property {(line: string) => void} [log] Progress lines (default stderr).
81
+ */
82
+
83
+ /**
84
+ * @param {LaunchBrowserOptions} options
85
+ * @returns {{ stopped: true } | { stopped: false, pid: number }}
86
+ */
87
+ function launchBrowser(options) {
88
+ const opts = normalizeOptions(options);
89
+ let release = null;
90
+ try {
91
+ return runLaunch(opts, () => {
92
+ if (opts.acquireRuntimeLock) release = opts.acquireRuntimeLock(opts.runtimeDir);
93
+ });
94
+ } finally {
95
+ if (typeof release === 'function') release();
96
+ }
97
+ }
98
+
99
+ // The rerun step named in "Next:" hints.
100
+ function rerunHint({ rerunCommand }) {
101
+ return rerunCommand ? `rerun: ${rerunCommand}` : 'rerun the launch';
102
+ }
103
+
104
+ function normalizeOptions(options) {
105
+ const opts = { ...options };
106
+ if (!Number.isInteger(opts.cdpPort) || opts.cdpPort <= 0)
107
+ throw new Error(`Invalid --cdp-port: ${opts.cdpPort ?? ''}`);
108
+ const flags = {
109
+ chromeBin: 'chrome-bin',
110
+ profile: 'profile',
111
+ extensionDir: 'extension-dir',
112
+ runtimeDir: 'runtime-dir',
113
+ chromeLog: 'chrome-log',
114
+ chromePid: 'chrome-pid',
115
+ };
116
+ for (const [key, flag] of Object.entries(flags)) {
117
+ if (!opts[key]) throw new Error(`Missing --${flag}`);
118
+ }
119
+ opts.log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
120
+ return opts;
121
+ }
122
+
123
+ function runLaunch(opts, acquireLock) {
124
+ const { cdpPort, profile, extensionDir, runtimeDir, log } = opts;
125
+ const rerun = rerunHint(opts);
126
+ // stopOnly releases the profile before the extension snapshot exists on a first
127
+ // run, so launch-input validation applies only to a real launch.
128
+ if (!opts.stopOnly) {
129
+ if (!fs.existsSync(opts.chromeBin))
130
+ throw new Error(`Chrome binary not found: ${opts.chromeBin}`);
131
+ if (!fs.existsSync(path.join(extensionDir, 'manifest.json'))) {
132
+ throw new Error(
133
+ `Extension dist manifest not found: ${path.join(extensionDir, 'manifest.json')}`,
134
+ );
135
+ }
136
+ }
137
+
138
+ // Isolation guard: a standalone launch must use a per-checkout profile, never a
139
+ // shared (e.g. ~/.chrome-farmslot) or the OS-default browser profile. Farm dispatch
140
+ // injects its own already-isolated profile, so this only trips on a genuinely
141
+ // personal/shared path — protecting the user's browser data.
142
+ assertIsolatedProfile(profile);
143
+ if (opts.resetProfile) assertResettableProfile(profile, runtimeDir);
144
+
145
+ fs.mkdirSync(profile, { recursive: true });
146
+ fs.mkdirSync(path.dirname(opts.chromeLog), { recursive: true });
147
+ fs.mkdirSync(path.dirname(opts.chromePid), { recursive: true });
148
+ acquireLock();
149
+
150
+ const ownedBrowser = (pid) =>
151
+ processIsOwnedBrowser(pid, profile, extensionDir, opts.extensionOwnerRoot);
152
+
153
+ // Ownership guard: only ever take over a CDP endpoint we provably launched. A pid
154
+ // on this port is ours iff its command line loads our --user-data-dir; anything
155
+ // else is a browser this harness did not launch (possibly the user's personal
156
+ // Chrome), so we refuse rather than kill it or attach to it.
157
+ const listenerPids = cdpListenerPids(cdpPort);
158
+ const foreignPids = listenerPids.filter((pid) => !ownedBrowser(pid));
159
+ if (foreignPids.length > 0) {
160
+ throw new Error(
161
+ `Refusing to launch on CDP port ${cdpPort}: it is held by a browser this harness did not launch (pid ${foreignPids.join(', ')}). ` +
162
+ `Next: pick a free --cdp-port, or stop that browser.`,
163
+ );
164
+ }
165
+ const ownedPids = new Set(listenerPids);
166
+ for (const pid of profileProcessPids(profile)) ownedPids.add(pid);
167
+ const previousPid = readPidFile(opts.chromePid);
168
+ if (previousPid !== null && ownedBrowser(previousPid)) ownedPids.add(previousPid);
169
+ try {
170
+ removeRuntimeIdentity(runtimeDir);
171
+ } catch (error) {
172
+ const detail = error instanceof Error ? error.message : String(error);
173
+ throw new Error(
174
+ `Failed to invalidate Extension runtime identity under ${runtimeDir}: ${detail}. ` +
175
+ `Next: remove the invalid runtime identity and fix owner permissions, then ${rerun}`,
176
+ );
177
+ }
178
+ stopProfileProcessesSync(profile, { extraPids: [...ownedPids] });
179
+ if (opts.resetProfile) {
180
+ fs.rmSync(profile, { recursive: true, force: true });
181
+ fs.mkdirSync(profile, { recursive: true });
182
+ }
183
+ removeProfileSingletonLocks(profile);
184
+
185
+ // stopOnly: release the profile without launching. A caller that writes into
186
+ // the profile (fixture prefill into LevelDB) needs it unlocked first; a prior
187
+ // run's Chrome would hold the lock and the write fails.
188
+ if (opts.stopOnly) return { stopped: true };
189
+
190
+ const requestedProfileQuarantined = hasDetachedLaunchUnproven(profile);
191
+ const portQuarantine = readValidationPortQuarantine(cdpPort);
192
+ const validationPortLease = process.env.FARMSLOT_VALIDATION_PORT_LEASE;
193
+ const activeValidationLease = Boolean(
194
+ portQuarantine?.profile === path.resolve(profile) &&
195
+ portQuarantine.lease === validationPortLease &&
196
+ /^[a-f0-9]{64}$/u.test(validationPortLease || ''),
197
+ );
198
+ if (requestedProfileQuarantined || (portQuarantine && !activeValidationLease)) {
199
+ throw validationLaunchQuarantineError(
200
+ cdpPort,
201
+ portQuarantine?.profile || (requestedProfileQuarantined ? profile : undefined),
202
+ requestedProfileQuarantined ? profile : undefined,
203
+ );
204
+ }
205
+ const clearLaunchMarkers = () => {
206
+ clearDetachedLaunchUnproven(profile);
207
+ if (!activeValidationLease) clearValidationPortQuarantine(cdpPort, profile);
208
+ };
209
+
210
+ const initialUrl =
211
+ opts.startUrl || extensionPageUrl(extensionDir, opts.homePage) || 'chrome://extensions/';
212
+ const browser = launchBrowserResolution(opts.browserResolution, opts.chromeBin);
213
+ const loadsOverCdp = browser.extensionLoading === CDP_LOAD_UNPACKED;
214
+ let cdpLoad = null;
215
+ let owner = null;
216
+ const runtimeNonce = createRuntimeIdentityNonce();
217
+ const startedAt = Date.now();
218
+ const chromeArgs = [
219
+ `--user-data-dir=${profile}`,
220
+ // Debug-port trio (address + port + scoped allow-origins) from the shared module,
221
+ // so the DevTools origin allow-list can never drift away from the port.
222
+ ...remoteDebuggingArgs(cdpPort),
223
+ '--no-first-run',
224
+ '--disable-first-run-ui',
225
+ ...automationRuntimeArgs(),
226
+ ...runtimeIdentityArgs(runtimeNonce),
227
+ ...isolatedProfileArgs(),
228
+ '--disable-default-apps',
229
+ '--disable-popup-blocking',
230
+ '--disable-extensions-file-access-check',
231
+ '--disable-extensions-content-verification',
232
+ '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
233
+ ...extensionLaunchArgs(extensionDir, browser.extensionLoading),
234
+ ];
235
+ // Initial tab: the caller's URL when provided, else the extension home. The
236
+ // extension home avoids leaving disposable chrome://newtab/ or extensions
237
+ // manager tabs in the operator's slot browser. On macOS the browser starts
238
+ // through Launch Services in the background with no startup window (Chrome
239
+ // activates itself when it opens one), and the first window is opened over
240
+ // CDP as a background window. A CDP-loaded extension does not exist yet at
241
+ // startup, so its window is opened after the load.
242
+ const application =
243
+ process.platform === 'darwin' ? macApplicationForExecutable(opts.chromeBin) : null;
244
+ if (application) chromeArgs.push('--no-startup-window');
245
+ else chromeArgs.push(loadsOverCdp ? 'about:blank' : initialUrl);
246
+ const ownedListenerMissing = () =>
247
+ new Error(
248
+ `Chrome launched but did not expose an owned CDP listener on 127.0.0.1:${cdpPort}. ` +
249
+ `Next: inspect ${opts.chromeLog}, then ${rerun}`,
250
+ );
251
+ const logFd = fs.openSync(opts.chromeLog, 'a');
252
+ const previousFrontmost = captureMacFrontmost();
253
+ let browserPid;
254
+ try {
255
+ try {
256
+ beginDetachedLaunch(cdpPort, profile, activeValidationLease);
257
+ if (application) {
258
+ execFileSync('open', macBackgroundOpenArgs(application, chromeArgs), {
259
+ env: sanitizedChildEnv(),
260
+ stdio: ['ignore', logFd, logFd],
261
+ });
262
+ browserPid = waitForOwnedCdpPid(cdpPort, ownedBrowser);
263
+ if (browserPid === null) {
264
+ stopProfileProcessesSync(profile, { waitForAppearanceMs: 400 });
265
+ throw ownedListenerMissing();
266
+ }
267
+ } else {
268
+ const child = spawn(opts.chromeBin, chromeArgs, {
269
+ detached: true,
270
+ env: sanitizedChildEnv(),
271
+ stdio: ['ignore', logFd, logFd],
272
+ });
273
+ child.unref();
274
+ browserPid = waitForOwnedCdpPid(cdpPort, ownedBrowser);
275
+ if (browserPid === null) {
276
+ stopProfileProcessesSync(profile, { extraPids: child.pid ? [child.pid] : [] });
277
+ throw ownedListenerMissing();
278
+ }
279
+ }
280
+ if (loadsOverCdp)
281
+ cdpLoad = loadExtensionOverCdp(opts, browserPid, initialUrl, clearLaunchMarkers);
282
+ else if (application) openBackgroundWindow(opts, browserPid, initialUrl, clearLaunchMarkers);
283
+ owner = cdpLoad?.owner ?? ownerIdentity(cdpPort, profile, browserPid, log);
284
+ for (const problem of pruneExtraHomeTabs(cdpPort, extensionDir, opts)) {
285
+ log(`[launch] home-tab pruning: ${problem}`);
286
+ }
287
+ } finally {
288
+ fs.closeSync(logFd);
289
+ }
290
+ fs.writeFileSync(opts.chromePid, `${browserPid}\n`);
291
+ writeBrowserResolution(runtimeDir, {
292
+ ...browser,
293
+ launch: {
294
+ pid: browserPid,
295
+ cdpPort,
296
+ startedAt: new Date(startedAt).toISOString(),
297
+ ...(owner ? { profile: owner.profile, processStartedAt: owner.startedAt } : {}),
298
+ ...(cdpLoad ? { extensionId: cdpLoad.id, otherExtensions: cdpLoad.otherExtensions } : {}),
299
+ },
300
+ });
301
+ try {
302
+ writeRuntimeIdentity(runtimeDir, {
303
+ port: cdpPort,
304
+ pid: browserPid,
305
+ startedAt,
306
+ nonce: runtimeNonce,
307
+ });
308
+ clearLaunchMarkers();
309
+ } catch (error) {
310
+ terminatePids([browserPid]);
311
+ fs.rmSync(opts.chromePid, { force: true });
312
+ const detail = error instanceof Error ? error.message : String(error);
313
+ throw new Error(
314
+ `Failed to persist Extension runtime identity under ${runtimeDir}: ${detail}. ` +
315
+ `Next: fix owner write permissions for the runtime directory, then ${rerun}`,
316
+ );
317
+ }
318
+ if (process.platform === 'darwin') {
319
+ const wake = spawn('caffeinate', ['-dims', '-w', String(browserPid)], {
320
+ detached: true,
321
+ stdio: 'ignore',
322
+ });
323
+ wake.unref();
324
+ // Let the first window settle before the one focus check below.
325
+ const settleMs =
326
+ Number.isFinite(opts.focusSettleMs) && opts.focusSettleMs >= 0 ? opts.focusSettleMs : 1000;
327
+ if (settleMs > 0) spawnSync('sleep', [String(settleMs / 1000)]);
328
+ }
329
+ } finally {
330
+ // One check, at most one restore, and only if OUR browser (this launch's
331
+ // CDP owner pid) holds the front; any other app in front is left alone.
332
+ if (browserPid) {
333
+ const outcome = restoreFrontmostIfOurs(previousFrontmost, [Number(browserPid)]);
334
+ if (outcome === 'restored')
335
+ log(`[launch] focus: the slot browser took the front; restored ${previousFrontmost.name}`);
336
+ }
337
+ }
338
+ return { stopped: false, pid: browserPid };
339
+ }
340
+
341
+ // The first window of a browser started without one, opened in the background.
342
+ // Without it the slot has no window at all, so a failure fails the launch:
343
+ // the owned browser is stopped (its markers kept if it cannot be).
344
+ function openBackgroundWindow(opts, pid, url, clearLaunchMarkers) {
345
+ const result = spawnSync(
346
+ process.execPath,
347
+ [BROWSER_RESOLVER_CLI, 'open-window', '--port', String(opts.cdpPort), '--url', url],
348
+ { encoding: 'utf8', timeout: 30_000 },
349
+ );
350
+ if (result.status === 0) return;
351
+ const openError = (result.stderr || result.error?.message || `exit ${result.status}`).trim();
352
+ try {
353
+ stopProfileProcessesSync(opts.profile, { extraPids: [pid] });
354
+ } catch (stopError) {
355
+ throw new Error(
356
+ `Chrome started but its start window could not be opened (${openError}), and stopping it failed: ${stopError.message}. ` +
357
+ `The launch markers for port ${opts.cdpPort} and ${opts.profile} are kept. Next: stop pid ${pid}, then ${rerunHint(opts)}`,
358
+ );
359
+ }
360
+ clearLaunchMarkers();
361
+ throw new Error(
362
+ `Chrome started but its start window could not be opened: ${openError}. Next: inspect ${opts.chromeLog}, then ${rerunHint(opts)}`,
363
+ );
364
+ }
365
+
366
+ // The caller passes the record its resolver just wrote; reuse it when it names
367
+ // the same binary. A direct chromeBin without it is the caller's own explicit
368
+ // choice and is recorded as such, whatever an older record of this profile says.
369
+ function launchBrowserResolution(file, chromeBin) {
370
+ if (!file) return explicitResolution(chromeBin);
371
+ try {
372
+ const recorded = JSON.parse(fs.readFileSync(file, 'utf8'));
373
+ if (recorded && recorded.bin === chromeBin && typeof recorded.extensionLoading === 'string') {
374
+ const { launch: _previousLaunch, ...resolution } = recorded;
375
+ return resolution;
376
+ }
377
+ } catch {
378
+ // No resolver record for this launch; fall through to a direct description.
379
+ }
380
+ return explicitResolution(chromeBin);
381
+ }
382
+
383
+ function explicitResolution(chromeBin) {
384
+ return {
385
+ schemaVersion: 2,
386
+ ...describeBrowser(chromeBin),
387
+ source: '--chrome-bin',
388
+ mode: 'explicit',
389
+ selection: 'explicit',
390
+ resolvedAt: new Date().toISOString(),
391
+ };
392
+ }
393
+
394
+ function writeBrowserResolution(runtime, resolution) {
395
+ fs.writeFileSync(
396
+ path.join(runtime, 'browser-resolution.json'),
397
+ `${JSON.stringify(resolution, null, 2)}\n`,
398
+ );
399
+ }
400
+
401
+ function loadExtensionOverCdp(opts, pid, initialUrl, clearLaunchMarkers) {
402
+ const result = spawnSync(
403
+ process.execPath,
404
+ [
405
+ BROWSER_RESOLVER_CLI,
406
+ 'load-unpacked',
407
+ '--port',
408
+ String(opts.cdpPort),
409
+ '--path',
410
+ opts.extensionDir,
411
+ '--profile',
412
+ opts.profile,
413
+ '--open-url',
414
+ initialUrl,
415
+ ],
416
+ { encoding: 'utf8', timeout: 130_000 },
417
+ );
418
+ if (result.status === 0) {
419
+ const loaded = JSON.parse(result.stdout);
420
+ opts.log(`[launch] extension ${loaded.id} loaded over CDP`);
421
+ for (const entry of loaded.otherExtensions?.disabled ?? []) {
422
+ opts.log(`[launch] disabled ${entry.id} (${entry.name}) in the slot profile`);
423
+ }
424
+ for (const entry of loaded.otherExtensions?.policyPinned ?? []) {
425
+ opts.log(
426
+ `[launch] WARN: enterprise policy keeps ${entry.id} (${entry.name}) enabled in the slot profile`,
427
+ );
428
+ }
429
+ return loaded;
430
+ }
431
+ // Ownership was proven; once the browser is stopped the launch markers would
432
+ // only quarantine a port and profile that nothing holds. If it cannot be
433
+ // stopped, the markers stay and both failures are reported.
434
+ const loadError = (result.stderr || result.error?.message || '').trim();
435
+ try {
436
+ stopProfileProcessesSync(opts.profile, { extraPids: [pid] });
437
+ } catch (stopError) {
438
+ throw new Error(
439
+ `Chrome started but the extension did not load over CDP (${loadError}), and stopping it failed: ${stopError.message}. ` +
440
+ `The launch markers for port ${opts.cdpPort} and ${opts.profile} are kept. Next: stop pid ${pid}, then ${rerunHint(opts)}`,
441
+ );
442
+ }
443
+ clearLaunchMarkers();
444
+ throw new Error(
445
+ `Chrome started but the extension did not load over CDP: ${loadError}. ` +
446
+ `Next: inspect ${opts.chromeLog}, or set RECIPE_HARNESS_BROWSER=cft to use Chrome for Testing, then ${rerunHint(opts)}`,
447
+ );
448
+ }
449
+
450
+ // Identity of the browser that holds the profile (pid + start time), recorded
451
+ // so run evidence can later prove it names this exact process.
452
+ // Best effort, so a short budget: a browser that never proves ownership (a
453
+ // stub without a SingletonLock, say) costs seconds, not the full default.
454
+ function ownerIdentity(cdpPort, profile, pid, log) {
455
+ try {
456
+ const identity = waitForCdpOwner(cdpPort, profile, { timeoutMs: 15000 });
457
+ return identity.pid === pid ? identity : null;
458
+ } catch (error) {
459
+ log(`[launch] could not record the browser identity: ${error.message}`);
460
+ return null;
461
+ }
462
+ }
463
+
464
+ function sanitizedChildEnv() {
465
+ return {
466
+ ...process.env,
467
+ BUNDLED_DEBUGPY_PATH: undefined,
468
+ PYTHONHOME: undefined,
469
+ PYTHONPATH: undefined,
470
+ DYLD_LIBRARY_PATH: undefined,
471
+ DYLD_FALLBACK_LIBRARY_PATH: undefined,
472
+ DYLD_INSERT_LIBRARIES: undefined,
473
+ };
474
+ }
475
+
476
+ function beginDetachedLaunch(port, profile, activeLease) {
477
+ try {
478
+ if (!activeLease) markValidationPortLaunchUnproven(port, profile);
479
+ markDetachedLaunchUnproven(profile);
480
+ } catch (error) {
481
+ if (error.code === 'EEXIST') {
482
+ const quarantine = readValidationPortQuarantine(port);
483
+ throw validationLaunchQuarantineError(port, quarantine?.profile || profile);
484
+ }
485
+ throw error;
486
+ }
487
+ }
488
+
489
+ function macApplicationForExecutable(executable) {
490
+ let current = path.resolve(executable);
491
+ while (current !== path.dirname(current)) {
492
+ if (current.endsWith('.app')) return current;
493
+ current = path.dirname(current);
494
+ }
495
+ return null;
496
+ }
497
+
498
+ function waitForOwnedCdpPid(port, ownedBrowser) {
499
+ for (let i = 0; i < 300; i += 1) {
500
+ const pid = cdpListenerPids(port).find(ownedBrowser);
501
+ if (pid !== undefined) return pid;
502
+ spawnSync('sleep', ['0.1']);
503
+ }
504
+ return null;
505
+ }
506
+
507
+ function assertIsolatedProfile(profile) {
508
+ const resolved = path.resolve(profile);
509
+ const home = os.homedir();
510
+ const osDefaults = [
511
+ path.join(home, 'Library/Application Support/Google/Chrome'),
512
+ path.join(home, 'Library/Application Support/Chromium'),
513
+ path.join(home, 'Library/Application Support/Microsoft Edge'),
514
+ path.join(home, '.config/google-chrome'),
515
+ path.join(home, '.config/chromium'),
516
+ path.join(home, '.config/microsoft-edge'),
517
+ ];
518
+ const underDefault = (dir) => resolved === dir || resolved.startsWith(`${dir}${path.sep}`);
519
+ if (
520
+ resolved === home ||
521
+ resolved.includes(`${path.sep}.chrome-farmslot`) ||
522
+ osDefaults.some(underDefault)
523
+ ) {
524
+ throw new Error(
525
+ `Refusing to launch: --profile ${resolved} is a shared or default browser profile; ` +
526
+ `standalone launches must use a per-checkout profile under the harness runtime dir.`,
527
+ );
528
+ }
529
+ }
530
+
531
+ function assertResettableProfile(profile, expectedRuntimeDir) {
532
+ const resolved = path.resolve(profile);
533
+ const runtime = path.resolve(expectedRuntimeDir);
534
+ if (!resolved.startsWith(`${runtime}${path.sep}`)) {
535
+ throw new Error(
536
+ `Refusing to reset --profile ${resolved}: it must be a dedicated child of ${runtime}.`,
537
+ );
538
+ }
539
+ const runtimeStat = fs.lstatSync(runtime);
540
+ if (!runtimeStat.isDirectory() || runtimeStat.isSymbolicLink()) {
541
+ throw new Error(
542
+ `Refusing to reset --profile: runtime directory is not a regular directory: ${runtime}.`,
543
+ );
544
+ }
545
+ let current = runtime;
546
+ for (const segment of path.relative(runtime, resolved).split(path.sep)) {
547
+ current = path.join(current, segment);
548
+ if (!fs.existsSync(current)) break;
549
+ const stat = fs.lstatSync(current);
550
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
551
+ throw new Error(`Refusing to reset --profile: path contains an unsafe entry: ${current}.`);
552
+ }
553
+ }
554
+ }
555
+
556
+ // Proof of ownership: a process is ours iff its command line loads the exact
557
+ // --user-data-dir we launch with, or an extension under the caller's owner root.
558
+ // If the process can't be inspected we treat it as NOT ours (fail safe — never
559
+ // assume ownership of an opaque process).
560
+ function processIsOwnedBrowser(pid, profile, extensionDir, extensionOwnerRoot) {
561
+ const command = processCommand(pid);
562
+ if (!command) return false;
563
+ if (command.includes(`--user-data-dir=${profile}`)) return true;
564
+ const ownerRoot = extensionOwnerRoot ? extensionOwnerRoot(path.resolve(extensionDir)) : null;
565
+ return Boolean(ownerRoot && command.includes(`--load-extension=${ownerRoot}`));
566
+ }
567
+
568
+ function processCommand(pid) {
569
+ if (!Number.isInteger(pid) || pid <= 0) return null;
570
+ try {
571
+ return execFileSync('ps', ['-ww', '-o', 'command=', '-p', String(pid)], {
572
+ encoding: 'utf8',
573
+ stdio: ['ignore', 'pipe', 'ignore'],
574
+ });
575
+ } catch {
576
+ return null;
577
+ }
578
+ }
579
+
580
+ function readPidFile(file) {
581
+ if (!fs.existsSync(file)) return null;
582
+ const pid = Number.parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
583
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
584
+ }
585
+
586
+ function terminatePids(pids) {
587
+ const unique = [...new Set(pids)].filter((pid) => pid !== process.pid);
588
+ for (const pid of unique) signalPid(pid, 'SIGTERM');
589
+ for (let i = 0; i < 10 && unique.some(processRespondsToSignal); i += 1) {
590
+ spawnSync('sleep', ['0.2']);
591
+ }
592
+ for (const pid of unique) {
593
+ if (processRespondsToSignal(pid)) signalPid(pid, 'SIGKILL');
594
+ }
595
+ }
596
+
597
+ function signalPid(pid, signal) {
598
+ try {
599
+ process.kill(pid, signal);
600
+ return true;
601
+ } catch (error) {
602
+ if (error.code === 'ESRCH') return false;
603
+ throw error;
604
+ }
605
+ }
606
+
607
+ function processRespondsToSignal(pid) {
608
+ try {
609
+ process.kill(pid, 0);
610
+ return true;
611
+ } catch (error) {
612
+ if (error.code === 'ESRCH') return false;
613
+ throw error;
614
+ }
615
+ }
616
+
617
+ function removeProfileSingletonLocks(profile) {
618
+ for (const name of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
619
+ const file = path.join(profile, name);
620
+ if (fs.existsSync(file)) fs.rmSync(file, { force: true });
621
+ }
622
+ }
623
+
624
+ function extensionPageUrl(extensionDir, page) {
625
+ if (!page) return '';
626
+ const id = extensionIdFromExtensionDir(extensionDir);
627
+ return id ? `chrome-extension://${id}/${page}` : '';
628
+ }
629
+
630
+ function cdpHttp(port, pathname) {
631
+ const result = spawnSync(
632
+ process.execPath,
633
+ [
634
+ '-e',
635
+ `
636
+ const http = require('http');
637
+ http.get('http://127.0.0.1:${Number(port)}${pathname}', (res) => {
638
+ let body = '';
639
+ res.on('data', (chunk) => { body += chunk; });
640
+ res.on('end', () => {
641
+ process.stdout.write(body);
642
+ process.exitCode = res.statusCode === 200 ? 0 : 1;
643
+ });
644
+ }).on('error', () => process.exit(1));
645
+ `,
646
+ ],
647
+ { encoding: 'utf8', timeout: 4000 },
648
+ );
649
+ if (result.status !== 0) return null;
650
+ return result.stdout;
651
+ }
652
+
653
+ /**
654
+ * The page targets to close so one `homePage` tab of the extension remains and no
655
+ * disposable blank/new-tab page is left. The kept home tab is the first whose
656
+ * title is set and differs from `defaultTitle` (a slot-stamped tab), else the first.
657
+ * @param {unknown[]} targets CDP /json/list entries.
658
+ * @param {{ extensionId: string, homePage: string, defaultTitle?: string }} options
659
+ * @returns {{ id: string, url: string }[]}
660
+ */
661
+ function homeTabsToClose(targets, { extensionId, homePage, defaultTitle }) {
662
+ const pages = targets.filter(
663
+ (target) =>
664
+ target &&
665
+ target.type === 'page' &&
666
+ typeof target.id === 'string' &&
667
+ typeof target.url === 'string',
668
+ );
669
+ const homes = pages.filter(
670
+ (target) =>
671
+ target.url.startsWith(`chrome-extension://${extensionId}/`) &&
672
+ target.url.includes(`/${homePage}`),
673
+ );
674
+ if (homes.length === 0) return [];
675
+ const strays = pages.filter(
676
+ (target) =>
677
+ target.url === 'about:blank' ||
678
+ target.url === 'chrome://newtab/' ||
679
+ target.url.startsWith('chrome://new-tab-page'),
680
+ );
681
+ const keeper =
682
+ homes.find(
683
+ (target) =>
684
+ typeof target.title === 'string' &&
685
+ target.title.trim() !== '' &&
686
+ target.title.trim() !== defaultTitle,
687
+ ) ?? homes[0];
688
+ return [...homes, ...strays]
689
+ .filter((target) => target.id !== keeper.id)
690
+ .map((target) => ({ id: target.id, url: target.url }));
691
+ }
692
+
693
+ // Best effort by design: CDP ownership is already proven and the caller's
694
+ // readiness converges home tabs again, so a pruning failure must not fail the
695
+ // launch. Every failure is returned so the launch log says why a duplicate tab
696
+ // survived instead of hiding it.
697
+ function pruneExtraHomeTabs(port, extensionDir, { homePage, defaultTitle }) {
698
+ if (!homePage) return [];
699
+ const extensionId = extensionIdFromExtensionDir(extensionDir);
700
+ if (!extensionId) return ['extension id unavailable; skipped'];
701
+ const raw = cdpHttp(port, '/json/list');
702
+ if (raw === null) return [`GET http://127.0.0.1:${port}/json/list failed`];
703
+ let targets;
704
+ try {
705
+ targets = JSON.parse(raw);
706
+ } catch (error) {
707
+ return [`/json/list returned malformed JSON: ${error.message}`];
708
+ }
709
+ if (!Array.isArray(targets)) return ['/json/list did not return a target list'];
710
+ const problems = [];
711
+ for (const target of homeTabsToClose(targets, { extensionId, homePage, defaultTitle })) {
712
+ if (cdpHttp(port, `/json/close/${target.id}`) === null)
713
+ problems.push(`could not close ${target.url} (${target.id})`);
714
+ }
715
+ return problems;
716
+ }
717
+
718
+ module.exports = { launchBrowser, homeTabsToClose };