@trusty-squire/mcp 1.1.13 → 1.1.14-rc.2

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 (42) hide show
  1. package/README.md +19 -11
  2. package/dist/bot/browser-process-owner.d.ts +75 -0
  3. package/dist/bot/browser-process-owner.d.ts.map +1 -0
  4. package/dist/bot/browser-process-owner.js +931 -0
  5. package/dist/bot/browser-process-owner.js.map +1 -0
  6. package/dist/bot/browser-process-runtime.d.ts +159 -0
  7. package/dist/bot/browser-process-runtime.d.ts.map +1 -0
  8. package/dist/bot/browser-process-runtime.js +905 -0
  9. package/dist/bot/browser-process-runtime.js.map +1 -0
  10. package/dist/bot/browser.d.ts +45 -194
  11. package/dist/bot/browser.d.ts.map +1 -1
  12. package/dist/bot/browser.js +666 -2358
  13. package/dist/bot/browser.js.map +1 -1
  14. package/dist/bot/compact-observation-v2.d.ts +9 -0
  15. package/dist/bot/compact-observation-v2.d.ts.map +1 -1
  16. package/dist/bot/compact-observation-v2.js +141 -82
  17. package/dist/bot/compact-observation-v2.js.map +1 -1
  18. package/dist/bot/identity-runtime.d.ts +56 -0
  19. package/dist/bot/identity-runtime.d.ts.map +1 -0
  20. package/dist/bot/identity-runtime.js +141 -0
  21. package/dist/bot/identity-runtime.js.map +1 -0
  22. package/dist/bot/owned-pages.d.ts +16 -0
  23. package/dist/bot/owned-pages.d.ts.map +1 -0
  24. package/dist/bot/owned-pages.js +58 -0
  25. package/dist/bot/owned-pages.js.map +1 -0
  26. package/dist/bot/page-driver.d.ts +35 -0
  27. package/dist/bot/page-driver.d.ts.map +1 -0
  28. package/dist/bot/page-driver.js +302 -0
  29. package/dist/bot/page-driver.js.map +1 -0
  30. package/dist/bot/provision-session.d.ts +4 -0
  31. package/dist/bot/provision-session.d.ts.map +1 -1
  32. package/dist/bot/provision-session.js +122 -29
  33. package/dist/bot/provision-session.js.map +1 -1
  34. package/dist/bot/session/lifecycle.d.ts.map +1 -1
  35. package/dist/bot/session/lifecycle.js +218 -14
  36. package/dist/bot/session/lifecycle.js.map +1 -1
  37. package/dist/bot/session/multisession-flag.d.ts +2 -0
  38. package/dist/bot/session/multisession-flag.d.ts.map +1 -0
  39. package/dist/bot/session/multisession-flag.js +23 -0
  40. package/dist/bot/session/multisession-flag.js.map +1 -0
  41. package/dist/tools/provision-drive.d.ts +12 -12
  42. package/package.json +1 -1
@@ -0,0 +1,905 @@
1
+ import { chromium as baseChromium } from "playwright";
2
+ import { createRequire } from "node:module";
3
+ import { Socket } from "node:net";
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
5
+ import { readFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { clearStaleSingletonLock, currentProfileHolderPid, processBirthIdentity, processBirthIdentityState, PROFILE_BUSY_MESSAGE, ProfileBusyError, profileProcessIdentity, profileProcessMatches, reapProfileHolderIfOwned, signalProfileProcess, } from "./profile.js";
9
+ import { createOperatorBrowserMarker, OPERATOR_BROWSER_MARKER_ENV, operatorBrowserProcessMarker, } from "./operator-browser-watchdog.js";
10
+ import { bindOwnerBrowserLaunch, markOwnerBrowserLaunchTerminal, reconcileOwnerBrowserLaunchAfterLeaderExit, terminateOwnerBrowserLaunch, trackOwnerBrowserLaunch, trackOwnerProcess, untrackOwnerBrowserLaunch, untrackOwnerProcess, } from "./owner-process-reaper.js";
11
+ // Lazy registration: installing the plugin mutates the chromium singleton
12
+ // from playwright-extra so we only do it once per process. We require()
13
+ // the CJS modules lazily (the stealth toolchain only ships CJS) and treat
14
+ // stealth as best-effort — a missing dep should never crash the bot.
15
+ const require = createRequire(import.meta.url);
16
+ // Operator signup runs are deliberately headed. Google, Stytch, and Cloudflare
17
+ // routinely reject a headless Chrome even when it is otherwise self-launched.
18
+ export const OPERATOR_BROWSER_HEADLESS = false;
19
+ export function registerLocalBrowserLaunch(profileDir, baseEnv = process.env, marker = createOperatorBrowserMarker()) {
20
+ trackOwnerBrowserLaunch(marker, profileDir);
21
+ return {
22
+ marker,
23
+ env: { ...baseEnv, [OPERATOR_BROWSER_MARKER_ENV]: marker },
24
+ };
25
+ }
26
+ export async function closeBrowserContextWithin(context, timeoutMs = 2_000) {
27
+ let timer;
28
+ const outcome = await Promise.race([
29
+ Promise.resolve()
30
+ .then(() => context.close())
31
+ .then(() => true, () => false),
32
+ new Promise((resolveTimeout) => {
33
+ timer = setTimeout(() => resolveTimeout(false), timeoutMs);
34
+ }),
35
+ ]);
36
+ if (timer !== undefined)
37
+ clearTimeout(timer);
38
+ return outcome;
39
+ }
40
+ export function spawnLocalBrowser(binary, args, profileDir, options) {
41
+ const ownership = registerLocalBrowserLaunch(profileDir, options.env, options.marker);
42
+ try {
43
+ const child = spawn(binary, [...args], {
44
+ env: ownership.env,
45
+ stdio: options.stdio,
46
+ detached: options.detached,
47
+ });
48
+ localBrowserLaunchMarkers.set(child, ownership.marker);
49
+ child.once("exit", () => {
50
+ setTimeout(() => {
51
+ reconcileOwnerBrowserLaunchAfterLeaderExit(ownership.marker, profileDir);
52
+ }, 0).unref();
53
+ });
54
+ return child;
55
+ }
56
+ catch (error) {
57
+ untrackOwnerBrowserLaunch(ownership.marker);
58
+ throw error;
59
+ }
60
+ }
61
+ const localBrowserLaunchMarkers = new WeakMap();
62
+ export function markLocalBrowserLaunchTerminal(child) {
63
+ if (child === null)
64
+ return;
65
+ const marker = localBrowserLaunchMarkers.get(child);
66
+ if (marker !== undefined)
67
+ markOwnerBrowserLaunchTerminal(marker);
68
+ }
69
+ export async function closeLocalBrowserLaunch(marker, profileDir, runtime = {}) {
70
+ if (marker === undefined)
71
+ return;
72
+ (runtime.markTerminal ?? markOwnerBrowserLaunchTerminal)(marker);
73
+ if (!(await (runtime.terminate ?? terminateOwnerBrowserLaunch)(marker, profileDir))) {
74
+ throw new Error("local login browser closure unproven");
75
+ }
76
+ (runtime.untrack ?? untrackOwnerBrowserLaunch)(marker);
77
+ }
78
+ // Whether to use the CDP-hardened launcher (patchright, which runs
79
+ // evaluations in an isolated world and removes the automation tells —
80
+ // mainWorldExecution, navigator.webdriver, viewport — that Turnstile /
81
+ // reCAPTCHA-v3 / Google's consent SPA score on). See
82
+ // docs/ARCHITECTURE.md.
83
+ //
84
+ // 2026-06-08 — DEFAULT FLIPPED ON. The baseline (playwright-extra +
85
+ // stealth) self-inflicts a detectable navigator.webdriver via its manual
86
+ // defineProperty patch, so it is strictly WORSE on the fingerprint. The
87
+ // hardened launcher is all-green on the rebrowser bot-detector and was
88
+ // live-A/B'd: meilisearch's Google consent-SPA block became a (handleable)
89
+ // FedCM path, and render still signed up + extracted a key cleanly — no
90
+ // crash on either (the old crash was the retired rebrowser fork, not
91
+ // patchright). Default to hardened; opt out with BOT_CDP_HARDENED=0 for
92
+ // the baseline. If patchright isn't installed, getChromium() falls back to
93
+ // baseline gracefully.
94
+ function cdpHardeningRequested() {
95
+ const v = process.env.BOT_CDP_HARDENED;
96
+ if (v === "0" || v === "false" || v === "off")
97
+ return false;
98
+ return true;
99
+ }
100
+ let cachedChromium = null;
101
+ // The stealth profile the cached launcher actually represents. Set the
102
+ // first time getChromium() resolves a launcher and read back via
103
+ // BrowserController.stealthProfile for the CaptchaEvent A/B tag. A
104
+ // patchright load failure degrades it to "baseline" truthfully rather
105
+ // than over-claiming "cdp_hardened" on a run that never got the patch.
106
+ let activeStealthProfile = "baseline";
107
+ export function activeStealthProfileValue() {
108
+ return activeStealthProfile;
109
+ }
110
+ export function getChromium() {
111
+ if (cachedChromium !== null)
112
+ return cachedChromium;
113
+ const hardened = cdpHardeningRequested();
114
+ try {
115
+ if (hardened) {
116
+ // patchright — a maintained Playwright fork that runs every
117
+ // evaluation in an ISOLATED world (so the bot's DOM probing is
118
+ // invisible to a page that traps DOM methods → closes the
119
+ // `mainWorldExecution` tell) and handles `navigator.webdriver`
120
+ // natively + correctly. Verified ALL-GREEN against the maintained
121
+ // rebrowser bot-detector (mainWorldExecution, navigatorWebdriver,
122
+ // viewport, runtimeEnableLeak all clean). It drives real Chrome
123
+ // (channel) directly — the earlier rebrowser fork couldn't, which is
124
+ // why the old hardened arm was forced onto bundled chromium and then
125
+ // crashed the OAuth flow. NO playwright-extra/stealth wrap here: the
126
+ // stealth plugin's manual `navigator.webdriver` defineProperty
127
+ // RE-ADDS a detectable property (proven counterproductive) — patchright
128
+ // does it right. See docs/ARCHITECTURE.md.
129
+ const patchright = require("patchright");
130
+ cachedChromium = patchright.chromium;
131
+ activeStealthProfile = "cdp_hardened";
132
+ return cachedChromium;
133
+ }
134
+ // Baseline: playwright-extra + stealth (unchanged). addExtra(baseChromium)
135
+ // is exactly what playwright-extra's default `chromium` export already is.
136
+ const { addExtra } = require("playwright-extra");
137
+ const stealth = require("puppeteer-extra-plugin-stealth");
138
+ activeStealthProfile = "baseline";
139
+ const extra = addExtra(baseChromium);
140
+ extra.use(stealth());
141
+ cachedChromium = extra;
142
+ }
143
+ catch (err) {
144
+ // Fall back to vanilla playwright if stealth (or the rebrowser fork)
145
+ // isn't installed. The bot still works, it's just easier to
146
+ // fingerprint as a bot — and the A/B tag stays truthfully "baseline".
147
+ console.warn(`[operator] hardened launcher unavailable, falling back to vanilla chromium: ${err instanceof Error ? err.message : String(err)}`);
148
+ cachedChromium = baseChromium;
149
+ activeStealthProfile = "baseline";
150
+ }
151
+ return cachedChromium;
152
+ }
153
+ // Real-Chromium-family browser channels we'll prefer over the bundled
154
+ // Chromium binary when available. Chromium ships without Widevine,
155
+ // without proprietary codecs, with an empty navigator.plugins array,
156
+ // and with a chrome.runtime API surface that bot-detection scripts
157
+ // know to look for. Using a *real* installation papers over ~6 of
158
+ // those fingerprint bits at zero engineering cost.
159
+ //
160
+ // Order matters: pick the channel most likely to be present *and*
161
+ // hardest to fingerprint as automation. Stable Chrome > Edge >
162
+ // Beta/Canary > Brave. Brave isn't a Playwright channel but its
163
+ // binary path is well-known; we resolve it explicitly below.
164
+ const PREFERRED_CHANNELS = ["chrome", "msedge", "chrome-beta", "chrome-canary"];
165
+ // Per-channel binary search paths. Playwright's `executablePath()` is
166
+ // argumentless (returns the bundled Chromium path), so we can't ask it
167
+ // "is Chrome installed?" — we have to look ourselves. These are the
168
+ // canonical install locations on each platform; the first hit wins.
169
+ //
170
+ // Limitation: this misses sideloaded installs (Chrome installed via
171
+ // the user's package manager to a non-default path, dev-builds in
172
+ // home directories, etc.). For those, the user can set
173
+ // UNIVERSAL_BOT_CHANNEL=chrome to force Playwright to find it
174
+ // through its own resolution. We accept the false-negative because
175
+ // the alternative (asking Playwright to launch and seeing if it
176
+ // succeeds) costs ~1s of process startup per probe.
177
+ const CHANNEL_PATHS = {
178
+ chrome: [
179
+ // macOS
180
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
181
+ // Linux
182
+ "/usr/bin/google-chrome",
183
+ "/usr/bin/google-chrome-stable",
184
+ "/opt/google/chrome/chrome",
185
+ // Windows — Playwright resolves these via channel anyway, but list
186
+ // for completeness on cross-platform Node runs.
187
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
188
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
189
+ ],
190
+ msedge: [
191
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
192
+ "/usr/bin/microsoft-edge",
193
+ "/usr/bin/microsoft-edge-stable",
194
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
195
+ ],
196
+ "chrome-beta": [
197
+ "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
198
+ "/usr/bin/google-chrome-beta",
199
+ ],
200
+ "chrome-canary": [
201
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
202
+ "/usr/bin/google-chrome-unstable",
203
+ ],
204
+ };
205
+ // Detect a real-Chromium-family browser channel without launching it.
206
+ // Returns the channel name (passable as `channel:` to .launch) or null
207
+ // to mean "use bundled Chromium." Logs the selection to stderr so the
208
+ // telemetry path can see which browser the run ended up on without
209
+ // having to thread it through the agent state machine.
210
+ export async function detectChromiumChannel() {
211
+ // Skip detection in tests / when explicitly opting out. The unit tests
212
+ // launch hundreds of browsers and shouldn't probe the filesystem each
213
+ // time; they also can't rely on real Chrome being present.
214
+ if (process.env.UNIVERSAL_BOT_CHANNEL === "bundled")
215
+ return null;
216
+ if (process.env.UNIVERSAL_BOT_CHANNEL !== undefined) {
217
+ // Explicit override — caller knows what they want.
218
+ return process.env.UNIVERSAL_BOT_CHANNEL;
219
+ }
220
+ const fsMod = await import("node:fs");
221
+ for (const channel of PREFERRED_CHANNELS) {
222
+ const candidatePaths = CHANNEL_PATHS[channel] ?? [];
223
+ for (const candidate of candidatePaths) {
224
+ try {
225
+ if (fsMod.existsSync(candidate))
226
+ return channel;
227
+ }
228
+ catch {
229
+ // permission errors etc. — skip this candidate, try the next
230
+ }
231
+ }
232
+ }
233
+ return null;
234
+ }
235
+ // Resolve the on-disk Chrome binary for a detected channel, for the
236
+ // self-launch path (see launchSelfManagedContext). Playwright launches a
237
+ // channel by name; we have to spawn the binary ourselves, so we need the
238
+ // path. Returns null when the channel is unknown / not found on disk
239
+ // (caller falls back to launchPersistentContext).
240
+ export function resolveChannelBinary(channel) {
241
+ if (channel === null)
242
+ return null; // bundled Chromium — no self-launch
243
+ const explicit = process.env.UNIVERSAL_BOT_CHROME_BINARY;
244
+ if (explicit !== undefined && explicit.length > 0) {
245
+ return existsSync(explicit) ? explicit : null;
246
+ }
247
+ const candidates = CHANNEL_PATHS[channel] ?? [];
248
+ for (const c of candidates) {
249
+ try {
250
+ if (existsSync(c))
251
+ return c;
252
+ }
253
+ catch {
254
+ // skip unreadable candidate
255
+ }
256
+ }
257
+ return null;
258
+ }
259
+ // Whether to launch Chrome ourselves and attach over CDP, instead of
260
+ // Playwright's launchPersistentContext.
261
+ //
262
+ // WHY THIS EXISTS — the single decisive finding (2026-06-12, fully
263
+ // reproduced + falsifiable; see STATE.md "Cloudflare-Turnstile wall").
264
+ // Cloudflare Turnstile's interactive challenge FAILS a Playwright/patchright
265
+ // launchPersistentContext-driven Chrome and PASSES a Chrome the operator
266
+ // launches itself and then attaches to over CDP — every other variable held
267
+ // constant (same box, same datacenter IP, same headed display, same Chrome 148
268
+ // binary, same software-WebGL, same humanized click). The discriminator
269
+ // matrix:
270
+ // launchPersistentContext + CDP click → "Verification failed"
271
+ // launchPersistentContext + OS click → "Verification failed"
272
+ // plain google-chrome + OS click → "Success!"
273
+ // plain google-chrome + connectOverCDP + page.mouse → token issued (len816)
274
+ // So the tell is NEITHER the live CDP attachment NOR the click mechanism —
275
+ // it is specifically the launch flags/instrumentation Playwright injects at
276
+ // launchPersistentContext time. Self-launching the binary (no
277
+ // --enable-automation et al.) and attaching with connectOverCDP avoids it.
278
+ // Default-ON; opt out with BOT_SELF_LAUNCH=0 for the persistent-context path. Exported for tests.
279
+ export function selfLaunchEnabled() {
280
+ const v = process.env.BOT_SELF_LAUNCH;
281
+ return v !== "0" && v !== "false" && v !== "off";
282
+ }
283
+ const PERSISTENT_CONTEXT_LAUNCH_TIMEOUT_MS = 30_000;
284
+ export const PERSISTENT_CONTEXT_CANCELLATION_SETTLE_MS = 2_000;
285
+ const PERSISTENT_CONTEXT_CANCELLATION_POLL_MS = 25;
286
+ export const PROFILE_IDENTITY_PROOF_TIMEOUT_MS = 2_000;
287
+ export const PROFILE_IDENTITY_POLL_MS = 25;
288
+ const PROFILE_HOLDER_ABSENCE_GRACE_MS = 100;
289
+ export async function resolvePersistentFallbackIdentity(opts) {
290
+ if ((opts.platform ?? process.platform) !== "linux")
291
+ return { state: "unknown" };
292
+ const timeoutMs = opts.timeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS;
293
+ const pollMs = opts.pollMs ?? PROFILE_IDENTITY_POLL_MS;
294
+ const absenceGraceMs = opts.absenceGraceMs ?? PROFILE_HOLDER_ABSENCE_GRACE_MS;
295
+ const readHolder = opts.currentHolderPid ?? currentProfileHolderPid;
296
+ const readIdentity = opts.readIdentity ?? profileProcessIdentity;
297
+ const clearStaleLock = opts.clearStaleLock ?? clearStaleSingletonLock;
298
+ const deadline = Date.now() + timeoutMs;
299
+ let absentSince = null;
300
+ for (;;) {
301
+ const holderPid = readHolder(opts.profileDir);
302
+ if (holderPid === null) {
303
+ absentSince ??= Date.now();
304
+ if (Date.now() - absentSince >= absenceGraceMs)
305
+ return { state: "absent" };
306
+ }
307
+ else {
308
+ absentSince = null;
309
+ const identity = readIdentity(holderPid, opts.profileDir);
310
+ if (identity !== null)
311
+ return { state: "owned", identity };
312
+ if (clearStaleLock(opts.profileDir))
313
+ return { state: "absent" };
314
+ }
315
+ if (Date.now() >= deadline)
316
+ return { state: "unknown" };
317
+ await new Promise((resolveWait) => {
318
+ const timer = setTimeout(resolveWait, Math.min(pollMs, Math.max(1, deadline - Date.now())));
319
+ timer.unref();
320
+ });
321
+ }
322
+ }
323
+ export async function launchCancellablePersistentContext(opts) {
324
+ const launchTimeoutMs = opts.launchTimeoutMs ?? PERSISTENT_CONTEXT_LAUNCH_TIMEOUT_MS;
325
+ const launchDeadline = Date.now() + launchTimeoutMs;
326
+ const launch = Promise.resolve().then(() => opts.launch({ ...opts.options, timeout: launchTimeoutMs }));
327
+ const outcome = await Promise.race([
328
+ launch.then((value) => ({ status: "launched", value })),
329
+ opts.cancellation.then(() => ({ status: "cancelled" })),
330
+ ]);
331
+ if (outcome.status === "launched")
332
+ return outcome;
333
+ let rejectedCleanup = null;
334
+ const cleanupRejected = () => {
335
+ if (rejectedCleanup !== null)
336
+ return rejectedCleanup;
337
+ const cleanup = Promise.resolve()
338
+ .then(opts.cleanupRejected)
339
+ .catch(() => "unknown")
340
+ .finally(() => {
341
+ if (rejectedCleanup === cleanup)
342
+ rejectedCleanup = null;
343
+ });
344
+ rejectedCleanup = cleanup;
345
+ return cleanup;
346
+ };
347
+ const lateCleanup = launch
348
+ .then(opts.cleanupCancelled, cleanupRejected)
349
+ .catch(() => "unknown");
350
+ const settleMs = opts.cancellationSettleMs ?? PERSISTENT_CONTEXT_CANCELLATION_SETTLE_MS;
351
+ const pollMs = opts.cancellationPollMs ?? PERSISTENT_CONTEXT_CANCELLATION_POLL_MS;
352
+ const cancellationDeadline = Math.max(Date.now(), launchDeadline) + settleMs;
353
+ let settledCloseState = null;
354
+ void lateCleanup.then((closeState) => {
355
+ settledCloseState = closeState;
356
+ });
357
+ while (settledCloseState === null && Date.now() < cancellationDeadline) {
358
+ await cleanupRejected();
359
+ if (settledCloseState !== null)
360
+ break;
361
+ const remaining = cancellationDeadline - Date.now();
362
+ if (remaining <= 0)
363
+ break;
364
+ await Promise.race([
365
+ lateCleanup,
366
+ new Promise((resolveWait) => {
367
+ const timer = setTimeout(resolveWait, Math.min(pollMs, remaining));
368
+ timer.unref();
369
+ }),
370
+ ]);
371
+ }
372
+ if (settledCloseState !== null) {
373
+ return { status: "cancelled", closeState: settledCloseState };
374
+ }
375
+ await cleanupRejected();
376
+ void lateCleanup;
377
+ return { status: "cancelled", closeState: "unknown" };
378
+ }
379
+ export const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
380
+ export async function waitForOwnedDevtoolsEndpoint(profileDir, deadlineMs, child) {
381
+ const activePortPath = join(profileDir, DEVTOOLS_ACTIVE_PORT_FILE);
382
+ const deadline = Date.now() + deadlineMs;
383
+ let lastErr = "";
384
+ while (Date.now() < deadline) {
385
+ if (!childProcessIsRunning(child)) {
386
+ throw new Error("Chrome exited before its owned DevTools endpoint became available");
387
+ }
388
+ try {
389
+ const [portText, browserPath] = (await readFile(activePortPath, "utf8")).split(/\r?\n/);
390
+ const port = Number(portText);
391
+ if (!Number.isInteger(port) ||
392
+ port < 1 ||
393
+ port > 65_535 ||
394
+ browserPath === undefined ||
395
+ !/^\/devtools\/browser\/[A-Za-z0-9-]+$/.test(browserPath)) {
396
+ throw new Error("invalid DevToolsActivePort contents");
397
+ }
398
+ return `ws://127.0.0.1:${port}${browserPath}`;
399
+ }
400
+ catch (error) {
401
+ lastErr = error instanceof Error ? error.message : String(error);
402
+ }
403
+ await new Promise((resolveWait) => {
404
+ const timer = setTimeout(resolveWait, 200);
405
+ timer.unref();
406
+ });
407
+ }
408
+ throw new Error(`Owned Chrome DevTools endpoint was not published (${lastErr})`);
409
+ }
410
+ export async function withChromeStartupLock(fn, opts = {}) {
411
+ const lockDir = opts.lockDir ?? "/tmp/trusty-squire-chrome-start.lock";
412
+ const deadlineMs = opts.deadlineMs ?? 60_000;
413
+ const deadline = Date.now() + deadlineMs;
414
+ for (;;) {
415
+ try {
416
+ mkdirSync(lockDir);
417
+ break;
418
+ }
419
+ catch (err) {
420
+ try {
421
+ const ageMs = Date.now() - statSync(lockDir).mtimeMs;
422
+ if (ageMs > 120_000) {
423
+ rmSync(lockDir, { recursive: true, force: true });
424
+ continue;
425
+ }
426
+ }
427
+ catch {
428
+ rmSync(lockDir, { recursive: true, force: true });
429
+ continue;
430
+ }
431
+ if (Date.now() >= deadline) {
432
+ if (deadlineMs === 0)
433
+ throw new ProfileBusyError(PROFILE_BUSY_MESSAGE);
434
+ throw new Error(`Timed out waiting for Chrome startup lock at ${lockDir}: ${err instanceof Error ? err.message : String(err)}`);
435
+ }
436
+ await new Promise((resolve) => setTimeout(resolve, 100));
437
+ }
438
+ }
439
+ try {
440
+ return await fn();
441
+ }
442
+ finally {
443
+ rmSync(lockDir, { recursive: true, force: true });
444
+ }
445
+ }
446
+ export const selfManagedChromes = new Map();
447
+ const ownedChromeProcessTrees = new Set();
448
+ let selfManagedCleanupInstalled = false;
449
+ let selfManagedTerminationSignalExitEnabled = true;
450
+ function cleanupSelfManagedChromes() {
451
+ for (const proof of ownedChromeProcessTrees) {
452
+ signalOwnedChromeProcessTree(proof.identity, proof.processGroup, "SIGKILL", { proof });
453
+ untrackOwnerProcess(proof.identity);
454
+ }
455
+ selfManagedChromes.clear();
456
+ }
457
+ const exitForSelfManagedSignal = (code) => {
458
+ cleanupSelfManagedChromes();
459
+ process.exit(128 + code);
460
+ };
461
+ const onSelfManagedSigint = () => exitForSelfManagedSignal(2);
462
+ const onSelfManagedSigterm = () => exitForSelfManagedSignal(15);
463
+ const onSelfManagedSighup = () => exitForSelfManagedSignal(1);
464
+ const selfManagedTerminationSignalHandlers = [
465
+ ["SIGHUP", onSelfManagedSighup],
466
+ ["SIGINT", onSelfManagedSigint],
467
+ ["SIGTERM", onSelfManagedSigterm],
468
+ ];
469
+ export function synchronizeSelfManagedChromeTerminationSignalHandlers(enabled, runtime = process) {
470
+ for (const [signal, handler] of selfManagedTerminationSignalHandlers) {
471
+ if (enabled)
472
+ runtime.once(signal, handler);
473
+ else
474
+ runtime.removeListener(signal, handler);
475
+ }
476
+ }
477
+ // Whether the self-managed termination-signal handlers may exit the process.
478
+ // False means another shutdown owner (the MCP server's disconnect coordinator,
479
+ // or an in-flight interactive login) holds process-exit responsibility.
480
+ export function isSelfManagedChromeTerminationSignalExitEnabled() {
481
+ return selfManagedTerminationSignalExitEnabled;
482
+ }
483
+ export function setSelfManagedChromeTerminationSignalExitEnabled(enabled) {
484
+ if (selfManagedTerminationSignalExitEnabled === enabled)
485
+ return;
486
+ selfManagedTerminationSignalExitEnabled = enabled;
487
+ if (!selfManagedCleanupInstalled)
488
+ return;
489
+ synchronizeSelfManagedChromeTerminationSignalHandlers(enabled);
490
+ }
491
+ function installSelfManagedChromeCleanup() {
492
+ if (selfManagedCleanupInstalled)
493
+ return;
494
+ selfManagedCleanupInstalled = true;
495
+ process.once("exit", cleanupSelfManagedChromes);
496
+ if (selfManagedTerminationSignalExitEnabled) {
497
+ synchronizeSelfManagedChromeTerminationSignalHandlers(true);
498
+ }
499
+ }
500
+ export function registerSelfManagedChrome(child, profileDir, processGroup = false) {
501
+ installSelfManagedChromeCleanup();
502
+ const identity = child.pid === undefined ? null : profileProcessIdentity(child.pid, profileDir);
503
+ if (identity !== null) {
504
+ const proof = trackOwnedChromeProcessTree(identity, processGroup);
505
+ if (proof !== null) {
506
+ const marker = proof.identity.process_marker;
507
+ if (marker !== undefined && !bindOwnerBrowserLaunch(marker, proof.identity)) {
508
+ releaseOwnedChromeProcessTree(proof);
509
+ throw new Error("local browser launch identity could not be bound to owner custody");
510
+ }
511
+ selfManagedChromes.set(identity.pid, { identity, processGroup, proof });
512
+ }
513
+ }
514
+ child.once("exit", () => {
515
+ if (child.pid === undefined)
516
+ return;
517
+ const tracked = selfManagedChromes.get(child.pid);
518
+ if (tracked === undefined)
519
+ return;
520
+ if (ownedChromeProcessTreeState(tracked.proof) === "stale") {
521
+ releaseOwnedChromeProcessTree(tracked.proof);
522
+ selfManagedChromes.delete(child.pid);
523
+ }
524
+ });
525
+ return identity;
526
+ }
527
+ async function waitForTrackedProfileChildIdentity(child, profileDir, readIdentity, timeoutMs, pollMs, processGroup = false) {
528
+ const deadline = Date.now() + timeoutMs;
529
+ while (childProcessIsRunning(child)) {
530
+ const identity = child.pid === undefined ? null : readIdentity(child.pid, profileDir);
531
+ if (identity !== null) {
532
+ const existing = selfManagedChromes.get(identity.pid);
533
+ const proof = existing?.identity.start_time === identity.start_time
534
+ ? existing.proof
535
+ : trackOwnedChromeProcessTree(identity, processGroup);
536
+ if (proof !== null)
537
+ selfManagedChromes.set(identity.pid, { identity, processGroup, proof });
538
+ return identity;
539
+ }
540
+ if (Date.now() >= deadline)
541
+ return null;
542
+ await new Promise((resolveWait) => {
543
+ const timer = setTimeout(resolveWait, Math.min(pollMs, Math.max(1, deadline - Date.now())));
544
+ timer.unref();
545
+ });
546
+ }
547
+ return null;
548
+ }
549
+ export async function resolveAttachedProfileChildIdentity(child, profileDir, identity, options = {}) {
550
+ if (identity !== null || (options.platform ?? process.platform) !== "linux")
551
+ return identity;
552
+ return await waitForTrackedProfileChildIdentity(child, profileDir, options.readIdentity ?? profileProcessIdentity, options.identityTimeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS, options.identityPollMs ?? PROFILE_IDENTITY_POLL_MS, options.processGroup ?? false);
553
+ }
554
+ // Call this ONLY for a Chrome child spawned with detached:true. The identity
555
+ // check protects against PID reuse, then POSIX negative-PID signalling reaches
556
+ // Chrome's renderer/GPU/helper tree in one operation. A normal profile-root
557
+ // signal remains the portable fallback for launchPersistentContext and Windows.
558
+ export function signalOwnedChromeProcessTree(identity, processGroup, signal, options = {}) {
559
+ const profileMatches = options.profileMatches ?? profileProcessMatches;
560
+ const kill = options.kill ?? process.kill;
561
+ const proof = options.proof ??
562
+ captureOwnedChromeProcessTreeProof(identity, processGroup, {
563
+ profileMatches,
564
+ ...(options.platform === undefined ? {} : { platform: options.platform }),
565
+ ...(options.processTreePids === undefined
566
+ ? {}
567
+ : { processTreePids: options.processTreePids }),
568
+ ...(options.readBirthIdentity === undefined
569
+ ? {}
570
+ : { readBirthIdentity: options.readBirthIdentity }),
571
+ });
572
+ if (proof === null)
573
+ return false;
574
+ const platform = options.platform ?? process.platform;
575
+ const memberState = options.memberState ?? processBirthIdentityState;
576
+ const matchingMembers = proof.members.filter((member) => memberState(member) === "matching");
577
+ const matchingGroupMember = proof.processGroup && platform !== "win32"
578
+ ? matchingMembers.some((member) => platform !== "linux" ||
579
+ (options.processGroupId ?? linuxProcessGroupId)(member.pid) === proof.identity.pid)
580
+ : false;
581
+ if (matchingGroupMember) {
582
+ try {
583
+ kill(-proof.identity.pid, signal);
584
+ return true;
585
+ }
586
+ catch {
587
+ // A process may exit between the proof and the signal. Fall through to
588
+ // the root PID only while it is still identity-proven.
589
+ }
590
+ }
591
+ let signalled = false;
592
+ // Signal leaves first. This covers the Playwright persistent-context fallback
593
+ // (including chrome-headless-shell), whose child is not a detached process
594
+ // group leader but whose renderer tree is still rooted at the identity-proven
595
+ // browser PID.
596
+ for (const member of [...proof.members].reverse()) {
597
+ if (memberState(member) !== "matching")
598
+ continue;
599
+ try {
600
+ kill(member.pid, signal);
601
+ signalled = true;
602
+ }
603
+ catch {
604
+ // A child can naturally exit while the tree is being walked.
605
+ }
606
+ }
607
+ return signalled;
608
+ }
609
+ export function captureOwnedChromeProcessTreeProof(identity, processGroup, options = {}) {
610
+ const profileMatches = options.profileMatches ?? profileProcessMatches;
611
+ if (!profileMatches(identity, identity.user_data_dir))
612
+ return null;
613
+ const platform = options.platform ?? process.platform;
614
+ const pids = platform === "linux"
615
+ ? (options.processTreePids ?? linuxProcessTreePids)(identity.pid)
616
+ : [identity.pid];
617
+ const readBirthIdentity = options.readBirthIdentity ?? processBirthIdentity;
618
+ const members = pids.flatMap((pid) => {
619
+ if (pid === identity.pid)
620
+ return [{ pid, start_time: identity.start_time }];
621
+ const member = readBirthIdentity(pid);
622
+ return member === null ? [] : [member];
623
+ });
624
+ if (!members.some((member) => member.pid === identity.pid)) {
625
+ members.unshift({ pid: identity.pid, start_time: identity.start_time });
626
+ }
627
+ return { identity, processGroup, members };
628
+ }
629
+ export function trackOwnedChromeProcessTree(identity, processGroup) {
630
+ installSelfManagedChromeCleanup();
631
+ const marker = operatorBrowserProcessMarker(identity.pid);
632
+ const trackedIdentity = marker === null ? identity : { ...identity, process_marker: marker };
633
+ const proof = captureOwnedChromeProcessTreeProof(trackedIdentity, processGroup);
634
+ if (proof === null)
635
+ return null;
636
+ ownedChromeProcessTrees.add(proof);
637
+ trackOwnerProcess(proof.identity);
638
+ return proof;
639
+ }
640
+ export function releaseOwnedChromeProcessTree(proof) {
641
+ if (proof === null)
642
+ return;
643
+ ownedChromeProcessTrees.delete(proof);
644
+ untrackOwnerProcess(proof.identity);
645
+ }
646
+ export function ownedChromeProcessTreeState(proof, options = {}) {
647
+ const memberState = options.memberState ?? processBirthIdentityState;
648
+ let sawUnknown = false;
649
+ for (const member of proof.members) {
650
+ const state = memberState(member);
651
+ if (state === "matching")
652
+ return "matching";
653
+ if (state === "unknown")
654
+ sawUnknown = true;
655
+ }
656
+ return sawUnknown ? "unknown" : "stale";
657
+ }
658
+ function linuxProcessGroupId(pid) {
659
+ try {
660
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
661
+ const closeParen = stat.lastIndexOf(")");
662
+ if (closeParen < 0)
663
+ return null;
664
+ const processGroupId = Number(stat
665
+ .slice(closeParen + 2)
666
+ .trim()
667
+ .split(/\s+/)[2]);
668
+ return Number.isSafeInteger(processGroupId) ? processGroupId : null;
669
+ }
670
+ catch {
671
+ return null;
672
+ }
673
+ }
674
+ function linuxProcessTreePids(rootPid) {
675
+ try {
676
+ const childrenByParent = new Map();
677
+ for (const entry of readdirSync("/proc")) {
678
+ if (!/^\d+$/.test(entry))
679
+ continue;
680
+ const pid = Number(entry);
681
+ try {
682
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
683
+ const closeParen = stat.lastIndexOf(")");
684
+ if (closeParen < 0)
685
+ continue;
686
+ const parentPid = Number(stat
687
+ .slice(closeParen + 2)
688
+ .trim()
689
+ .split(/\s+/)[1]);
690
+ if (!Number.isSafeInteger(parentPid))
691
+ continue;
692
+ const children = childrenByParent.get(parentPid) ?? [];
693
+ children.push(pid);
694
+ childrenByParent.set(parentPid, children);
695
+ }
696
+ catch {
697
+ // Processes leave /proc constantly; a partial tree is still safer than
698
+ // abandoning the profile-root browser after a failed close.
699
+ }
700
+ }
701
+ const pids = [];
702
+ const pending = [rootPid];
703
+ const seen = new Set();
704
+ while (pending.length > 0) {
705
+ const pid = pending.pop();
706
+ if (seen.has(pid))
707
+ continue;
708
+ seen.add(pid);
709
+ pids.push(pid);
710
+ for (const child of childrenByParent.get(pid) ?? [])
711
+ pending.push(child);
712
+ }
713
+ return pids;
714
+ }
715
+ catch {
716
+ return [rootPid];
717
+ }
718
+ }
719
+ export async function terminateTrackedProfileChild(child, profileDir, options = {}) {
720
+ const readIdentity = options.readIdentity ?? profileProcessIdentity;
721
+ const terminate = options.terminate ??
722
+ ((ownedIdentity, ownedProfileDir) => {
723
+ const signalled = signalProfileProcess(ownedIdentity, ownedProfileDir, "SIGKILL");
724
+ reapProfileHolderIfOwned(ownedProfileDir, ownedIdentity);
725
+ return signalled;
726
+ });
727
+ let identity = options.identity ?? null;
728
+ if (identity === null && (options.platform ?? process.platform) !== "linux")
729
+ return null;
730
+ while (childProcessIsRunning(child)) {
731
+ identity ??= await waitForTrackedProfileChildIdentity(child, profileDir, readIdentity, options.identityTimeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS, options.identityPollMs ?? PROFILE_IDENTITY_POLL_MS, options.processGroup ?? false);
732
+ if (identity === null)
733
+ break;
734
+ const existing = selfManagedChromes.get(identity.pid);
735
+ const proof = existing?.identity.start_time === identity.start_time
736
+ ? existing.proof
737
+ : trackOwnedChromeProcessTree(identity, options.processGroup ?? false);
738
+ if (proof !== null) {
739
+ selfManagedChromes.set(identity.pid, {
740
+ identity,
741
+ processGroup: options.processGroup ?? false,
742
+ proof,
743
+ });
744
+ }
745
+ const terminated = terminate(identity, profileDir);
746
+ if (!terminated) {
747
+ identity = null;
748
+ continue;
749
+ }
750
+ while (childProcessIsRunning(child)) {
751
+ await new Promise((resolveWait) => {
752
+ const timer = setTimeout(resolveWait, 25);
753
+ timer.unref();
754
+ });
755
+ }
756
+ }
757
+ return identity;
758
+ }
759
+ export function childProcessIsRunning(child) {
760
+ return child !== null && child.exitCode === null && child.signalCode === null;
761
+ }
762
+ export function profileCollisionFromStderr(stderr) {
763
+ return /ProcessSingleton|SingletonLock|profile.*in use/i.test(stderr)
764
+ ? new ProfileBusyError(PROFILE_BUSY_MESSAGE)
765
+ : null;
766
+ }
767
+ export function proxyHasCredentials(proxy) {
768
+ return (proxy !== null &&
769
+ ((typeof proxy.username === "string" && proxy.username.length > 0) ||
770
+ (typeof proxy.password === "string" && proxy.password.length > 0)));
771
+ }
772
+ // Parse a per-session proxy URL — e.g. "http://user:pass@host:8080" or
773
+ // "socks5://host:1080" — into Playwright's proxy option shape. Playwright
774
+ // wants credentials separate from `server`, so we split them out and
775
+ // percent-decode them (residential providers embed session IDs with
776
+ // reserved characters in the username, which arrive %-encoded).
777
+ //
778
+ // Throws on a URL the WHATWG parser rejects, or one with no host (a bare
779
+ // "host:port" parses as a scheme with an empty host).
780
+ //
781
+ // Exported for unit testing — URL parsing is the error-prone bit.
782
+ // Cheap TCP liveness probe for a proxy `server` string ("socks5://host:port").
783
+ // A SOCKS5 proxy listens on TCP; if a connect succeeds within the timeout the
784
+ // proxy is up. Resolves false on connect error / timeout / a malformed server.
785
+ // Pure (no class state) so resolveProxy can call it before launching Chrome.
786
+ export async function isProxyReachable(server, timeoutMs = 4000) {
787
+ let host;
788
+ let port;
789
+ try {
790
+ const u = new URL(server);
791
+ host = u.hostname;
792
+ port = Number(u.port) || proxyDefaultPort(u.protocol);
793
+ }
794
+ catch {
795
+ return false;
796
+ }
797
+ if (host.length === 0 || !Number.isFinite(port))
798
+ return false;
799
+ return await new Promise((resolve) => {
800
+ const sock = new Socket();
801
+ let settled = false;
802
+ const finish = (ok) => {
803
+ if (settled)
804
+ return;
805
+ settled = true;
806
+ try {
807
+ sock.destroy();
808
+ }
809
+ catch {
810
+ // already closed
811
+ }
812
+ resolve(ok);
813
+ };
814
+ sock.setTimeout(timeoutMs);
815
+ sock.once("connect", () => finish(true));
816
+ sock.once("timeout", () => finish(false));
817
+ sock.once("error", () => finish(false));
818
+ sock.connect(port, host);
819
+ });
820
+ }
821
+ export function proxyDefaultPort(protocol) {
822
+ if (protocol === "http:")
823
+ return 80;
824
+ if (protocol === "https:")
825
+ return 443;
826
+ if (protocol.startsWith("socks"))
827
+ return 1080;
828
+ return 8080;
829
+ }
830
+ export function parseProxyUrl(raw) {
831
+ const u = new URL(raw.trim());
832
+ if (u.hostname.length === 0) {
833
+ throw new Error("proxy URL has no host");
834
+ }
835
+ // `host` includes the port; `protocol` keeps its trailing ":".
836
+ const settings = { server: `${u.protocol}//${u.host}` };
837
+ if (u.username.length > 0)
838
+ settings.username = decodeURIComponent(u.username);
839
+ if (u.password.length > 0)
840
+ settings.password = decodeURIComponent(u.password);
841
+ return settings;
842
+ }
843
+ /** Resolve an explicit session proxy, refusing an unsafe direct fallback. */
844
+ export async function resolveExplicitProxy(raw, probe = isProxyReachable) {
845
+ let proxy;
846
+ try {
847
+ proxy = parseProxyUrl(raw);
848
+ }
849
+ catch (err) {
850
+ throw new Error(`explicit session proxy is malformed; refusing direct egress: ${err instanceof Error ? err.message : String(err)}`);
851
+ }
852
+ if (!(await probe(proxy.server))) {
853
+ throw new Error(`explicit session proxy ${proxy.server} is unreachable; refusing direct egress`);
854
+ }
855
+ return proxy;
856
+ }
857
+ /** Self-launched Chrome cannot authenticate an HTTP/SOCKS proxy. */
858
+ export function canSelfLaunchWithProxy(proxy) {
859
+ return !proxyHasCredentials(proxy);
860
+ }
861
+ /** Options passed to launchPersistentContext, including proxy credentials. */
862
+ export function persistentProxyOptions(proxy) {
863
+ return proxy === null ? {} : { proxy };
864
+ }
865
+ // Parse an ipinfo.io/json response body into EgressGeo. Returns null
866
+ // when the timezone is absent or not a plausible IANA zone — the
867
+ // caller then keeps a default rather than handing Playwright a bad
868
+ // timezoneId (which would throw inside newContext()).
869
+ //
870
+ // geolocation is optional: a valid `loc` ("lat,long") sets it; a
871
+ // missing or malformed one leaves a timezone-only result. Exported
872
+ // for unit testing — JSON-shape handling is the error-prone bit.
873
+ export function parseEgressGeo(text) {
874
+ let data;
875
+ try {
876
+ data = JSON.parse(text);
877
+ }
878
+ catch {
879
+ return null;
880
+ }
881
+ if (data === null || typeof data !== "object")
882
+ return null;
883
+ const d = data;
884
+ const tz = typeof d.timezone === "string" ? d.timezone : null;
885
+ // IANA zones look like "Asia/Seoul" or "America/Argentina/Buenos_Aires".
886
+ // Reject anything else so a garbage value never reaches newContext().
887
+ if (tz === null || !/^[A-Za-z]+(?:\/[A-Za-z0-9_+-]+)+$/.test(tz))
888
+ return null;
889
+ const geo = { timezoneId: tz };
890
+ if (typeof d.loc === "string") {
891
+ const parts = d.loc.split(",");
892
+ if (parts.length === 2) {
893
+ const latitude = Number(parts[0]);
894
+ const longitude = Number(parts[1]);
895
+ if (Number.isFinite(latitude) &&
896
+ Number.isFinite(longitude) &&
897
+ Math.abs(latitude) <= 90 &&
898
+ Math.abs(longitude) <= 180) {
899
+ geo.geolocation = { latitude, longitude };
900
+ }
901
+ }
902
+ }
903
+ return geo;
904
+ }
905
+ //# sourceMappingURL=browser-process-runtime.js.map