stillhunter 0.0.1 → 0.2.1

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,456 @@
1
+ // Runs one probe inside the contained child process. The host starts this file
2
+ // under Node's permission model and sends the job over IPC; the child connects
3
+ // to the browser the host launched, through the host's relay, and gives the
4
+ // probe `rr`: guarded pages, faults on requests, an observation of what the
5
+ // page shows, screenshots and named expectations. The result goes back to the
6
+ // host over IPC.
7
+ import { existsSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { layoutCandidates } from './layout.mjs';
11
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
12
+ const DEFAULT_VIEWPORT = { width: 1280, height: 800 };
13
+ const SHOT_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
14
+ function isLoopback(url) {
15
+ try {
16
+ const u = new URL(url);
17
+ if (u.protocol === 'data:' || u.protocol === 'blob:' || u.protocol === 'about:')
18
+ return true;
19
+ return LOOPBACK.has(u.hostname) || u.hostname.endsWith('.localhost');
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
26
+ const isApi = (req) => ['xhr', 'fetch'].includes(req.resourceType());
27
+ const NOT_IN_A_PROBE = 'is not available in a probe: every page and context comes from rr.newPage(), which keeps the loopback-only route, the watchers and the session';
28
+ // The probe gets Playwright objects, and through them the browser and its
29
+ // type. Their constructors are shared, so the methods that would make an
30
+ // unguarded context or another browser are replaced on the prototypes; the
31
+ // factory alone may call the originals.
32
+ function lockDown(browser) {
33
+ const proto = Object.getPrototypeOf(browser);
34
+ const realNewContext = proto.newContext;
35
+ let allowed = false;
36
+ for (const method of ['newContext', 'newPage', 'newBrowserCDPSession', '_newContextForReuse', '_innerNewContext']) {
37
+ const original = proto[method];
38
+ if (typeof original !== 'function')
39
+ continue;
40
+ proto[method] = function (...args) {
41
+ if (!allowed)
42
+ throw new Error(`browser.${method}() ${NOT_IN_A_PROBE}`);
43
+ return original.apply(this, args);
44
+ };
45
+ }
46
+ const typeProto = Object.getPrototypeOf(browser.browserType());
47
+ for (const method of ['launch', 'launchPersistentContext', 'launchServer', 'connect', 'connectOverCDP']) {
48
+ if (typeof typeProto[method] === 'function')
49
+ typeProto[method] = () => { throw new Error(`browserType.${method}() ${NOT_IN_A_PROBE}`); };
50
+ }
51
+ return (options) => {
52
+ allowed = true;
53
+ try {
54
+ return realNewContext.call(browser, options);
55
+ }
56
+ finally {
57
+ allowed = false;
58
+ }
59
+ };
60
+ }
61
+ async function run(job) {
62
+ const startedAt = Date.now();
63
+ const { chromium } = await import('playwright-core');
64
+ const browser = await chromium.connect(job.wsEndpoint, { slowMo: job.slowMo || undefined, timeout: 30_000 });
65
+ const guardedContext = lockDown(browser);
66
+ const checks = [];
67
+ const notes = [];
68
+ const consoleErrors = [];
69
+ const pageErrors = [];
70
+ const requests = [];
71
+ const records = new Map();
72
+ const faulted = new WeakMap();
73
+ const faults = [];
74
+ const blocked = [];
75
+ const shots = [];
76
+ const pageIds = new Map();
77
+ const contexts = [];
78
+ const activeFaults = [];
79
+ let offline = false;
80
+ let savedSession = false;
81
+ let signInFlow = null;
82
+ // Where in `requests` each page's latest navigation began, so a sign-in can
83
+ // be told apart from the page it lands on.
84
+ const navigatedAt = new Map();
85
+ // The page the probe last worked with is the one `final` observes.
86
+ let lastPage = null;
87
+ const visit = (p) => {
88
+ const rec = pageIds.get(p);
89
+ if (!rec)
90
+ return;
91
+ if (p.url() && p.url() !== 'about:blank')
92
+ lastPage = p;
93
+ const vp = p.viewportSize();
94
+ rec.viewport = vp;
95
+ const url = p.url();
96
+ if (!url || url === 'about:blank')
97
+ return;
98
+ if (!rec.visited.some((v) => v.url === url && v.width === (vp?.width ?? null)))
99
+ rec.visited.push({ url, width: vp?.width ?? null });
100
+ };
101
+ const watch = (p) => {
102
+ if (pageIds.has(p))
103
+ return;
104
+ pageIds.set(p, { id: pageIds.size + 1, viewport: p.viewportSize(), visited: [] });
105
+ const id = pageIds.get(p).id;
106
+ p.on('console', (m) => { if (m.type() === 'error')
107
+ consoleErrors.push(clip(m.text(), 300)); });
108
+ p.on('pageerror', (e) => pageErrors.push(clip(String(e?.message ?? e), 300)));
109
+ p.on('framenavigated', (f) => {
110
+ if (f !== p.mainFrame())
111
+ return;
112
+ navigatedAt.set(p, requests.length);
113
+ visit(p);
114
+ });
115
+ p.on('request', (req) => {
116
+ if (!isApi(req))
117
+ return;
118
+ const rec = { method: req.method(), url: req.url(), status: null, fault: null, via: 'page', page: id };
119
+ records.set(req, rec);
120
+ requests.push(rec);
121
+ });
122
+ p.on('response', (r) => {
123
+ const rec = records.get(r.request());
124
+ if (rec) {
125
+ rec.status = r.status();
126
+ rec.fault = faulted.get(r.request()) ?? null;
127
+ }
128
+ });
129
+ p.on('requestfailed', (req) => {
130
+ const rec = records.get(req);
131
+ if (rec) {
132
+ rec.fault = faulted.get(req) ?? null;
133
+ rec.failure = req.failure()?.errorText ?? 'failed';
134
+ }
135
+ });
136
+ };
137
+ const loopbackOnly = (route) => {
138
+ if (isLoopback(route.request().url()))
139
+ return route.fallback();
140
+ if (blocked.length < 20)
141
+ blocked.push(clip(route.request().url(), 200));
142
+ return route.abort('blockedbyclient');
143
+ };
144
+ // The one place pages and contexts come from.
145
+ const makeContext = async (opts) => {
146
+ const ctx = await guardedContext({
147
+ baseURL: job.target,
148
+ viewport: { width: opts.width ?? DEFAULT_VIEWPORT.width, height: opts.height ?? DEFAULT_VIEWPORT.height },
149
+ ...(opts.timezoneId ? { timezoneId: opts.timezoneId } : {}),
150
+ ...(opts.locale ? { locale: opts.locale } : {}),
151
+ ...(existsSync(job.sessionFile) ? { storageState: job.sessionFile } : {}),
152
+ });
153
+ ctx.on('page', watch);
154
+ await ctx.route('**/*', loopbackOnly);
155
+ for (const f of activeFaults)
156
+ await ctx.route(f.pattern, f.handler);
157
+ if (offline)
158
+ await ctx.setOffline(true);
159
+ contexts.push(ctx);
160
+ return ctx;
161
+ };
162
+ const mainContext = await makeContext({});
163
+ const page = await mainContext.newPage();
164
+ watch(page);
165
+ // A context- or page-level CDP session is another way to reach an unguarded
166
+ // target (Target.createTarget in the default context, a file: URL), so it is
167
+ // refused like the browser-level one. The factory never needs it.
168
+ for (const obj of [mainContext, page]) {
169
+ const proto = Object.getPrototypeOf(obj);
170
+ if (typeof proto.newCDPSession === 'function')
171
+ proto.newCDPSession = () => { throw new Error(`newCDPSession() ${NOT_IN_A_PROBE}`); };
172
+ }
173
+ const observe = async (p = page) => {
174
+ visit(p);
175
+ const view = await p.evaluate(() => {
176
+ const visible = (el) => {
177
+ const r = el.getBoundingClientRect();
178
+ const s = getComputedStyle(el);
179
+ return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none';
180
+ };
181
+ const name = (el) => (el.getAttribute('aria-label') || el.innerText || el.value || el.getAttribute('name') || '').trim().replace(/\s+/g, ' ');
182
+ const lines = (document.body?.innerText ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
183
+ return {
184
+ url: location.pathname + location.search,
185
+ title: document.title,
186
+ headings: [...document.querySelectorAll('h1,h2,h3')].filter(visible).map((h) => h.innerText.trim()),
187
+ alerts: [...document.querySelectorAll('[role=alert],[role=status],[aria-live]')].filter(visible).map((a) => a.innerText.trim()).filter(Boolean),
188
+ text: lines.slice(0, 150),
189
+ disabled: [...document.querySelectorAll('button:disabled,input:disabled,select:disabled,textarea:disabled,[aria-disabled=true]')]
190
+ .filter(visible).map((el) => ({ name: name(el), reason: el.getAttribute('title') || el.getAttribute('aria-describedby') || null })),
191
+ fields: [...document.querySelectorAll('input,textarea,select')].filter(visible).map((el) => ({ label: el.labels?.[0]?.innerText?.trim() ?? el.getAttribute('name'), value: el.value })),
192
+ overflowX: document.documentElement.scrollWidth > window.innerWidth + 1,
193
+ };
194
+ });
195
+ const layout = await p.evaluate(layoutCandidates, 10).catch((e) => [{ kind: 'error', selector: '', text: '', detail: clip(String(e.message), 200) }]);
196
+ return {
197
+ ...view,
198
+ viewport: p.viewportSize(),
199
+ text: view.text.map((l) => clip(l, 200)),
200
+ layout,
201
+ consoleErrors: [...consoleErrors],
202
+ pageErrors: [...pageErrors],
203
+ requests: requests.slice(-30).map(({ method, url, status, fault }) => ({ method, url, status, fault })),
204
+ };
205
+ };
206
+ const rr = {
207
+ web: job.target,
208
+ page,
209
+ context: mainContext,
210
+ // A page in the main context, or in a fresh guarded context when it needs
211
+ // its own time zone or locale.
212
+ async newPage(opts = {}) {
213
+ const viewport = { width: opts.width ?? DEFAULT_VIEWPORT.width, height: opts.height ?? DEFAULT_VIEWPORT.height };
214
+ if (opts.timezoneId || opts.locale) {
215
+ const ctx = await makeContext(opts);
216
+ const p = await ctx.newPage();
217
+ watch(p);
218
+ return p;
219
+ }
220
+ const p = await mainContext.newPage();
221
+ watch(p);
222
+ await p.setViewportSize(viewport);
223
+ pageIds.get(p).viewport = viewport;
224
+ return p;
225
+ },
226
+ // Load settled: the load event, a quiet network (at most 5 s), and a Nuxt or
227
+ // other client app mounted, when the page has one.
228
+ async ready(p = page) {
229
+ await p.waitForLoadState('load').catch(() => { });
230
+ await p.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { });
231
+ await p.waitForFunction(() => {
232
+ const nuxt = document.querySelector('#__nuxt');
233
+ return nuxt ? Boolean(nuxt.__vue_app__) : true;
234
+ }, undefined, { timeout: 10_000 }).catch(() => { });
235
+ await p.waitForTimeout(300);
236
+ },
237
+ async goto(url, p = page) {
238
+ await p.goto(url);
239
+ await rr.ready(p);
240
+ return p;
241
+ },
242
+ async saveSession() {
243
+ await mainContext.storageState({ path: job.sessionFile });
244
+ // The first save ends the sign-in: the pages before the one it landed
245
+ // on, and the requests made before that page, are how it signed in.
246
+ if (!savedSession) {
247
+ const landed = lastPage ?? page;
248
+ const landing = landed.url();
249
+ const seen = [...pageIds.values()].flatMap((r) => r.visited.map((v) => v.url));
250
+ signInFlow = { requests: navigatedAt.get(landed) ?? requests.length, pages: [...new Set(seen)].filter((u) => u !== landing) };
251
+ }
252
+ savedSession = true;
253
+ },
254
+ // Makes matching requests fail, hang, slow down or answer with other data,
255
+ // in every context the probe has or makes. `times` limits how many requests
256
+ // it hits (default: all), `method` filters.
257
+ async fault(pattern, spec, opts = {}) {
258
+ let hits = 0;
259
+ const label = typeof spec === 'object' ? JSON.stringify(spec) : String(spec);
260
+ const handler = async (route) => {
261
+ const req = route.request();
262
+ if (opts.method && req.method() !== opts.method.toUpperCase())
263
+ return route.fallback();
264
+ if (opts.times !== undefined && hits >= opts.times)
265
+ return route.fallback();
266
+ hits += 1;
267
+ faulted.set(req, label);
268
+ faults.push({ method: req.method(), url: req.url(), fault: label });
269
+ if (spec === 'hang')
270
+ return; // never answered
271
+ if (spec === 'reset')
272
+ return route.abort('connectionreset');
273
+ if (typeof spec === 'number')
274
+ return route.fulfill({ status: spec, contentType: 'application/json', body: JSON.stringify({ detail: `Injected ${spec}` }) });
275
+ if (spec === 'malformed')
276
+ return route.fulfill({ status: 200, contentType: 'application/json', body: '{"oops": [1, 2' });
277
+ if (spec === 'empty')
278
+ return route.fulfill({ status: 200, contentType: 'application/json', body: '' });
279
+ if (spec === 'empty-list')
280
+ return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
281
+ if (spec === 'empty-object')
282
+ return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
283
+ if ('delay' in spec) {
284
+ await new Promise((r) => setTimeout(r, spec.delay));
285
+ return route.fallback();
286
+ }
287
+ if ('json' in spec)
288
+ return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(spec.json) });
289
+ return route.fulfill({ status: spec.status, contentType: 'application/json', body: spec.body ?? JSON.stringify({ detail: `Injected ${spec.status}` }) });
290
+ };
291
+ const entry = { pattern, handler };
292
+ activeFaults.push(entry);
293
+ for (const ctx of contexts)
294
+ await ctx.route(pattern, handler);
295
+ return {
296
+ hits: () => hits,
297
+ clear: async () => {
298
+ activeFaults.splice(activeFaults.indexOf(entry), 1);
299
+ for (const ctx of contexts)
300
+ await ctx.unroute(pattern, handler).catch(() => { });
301
+ },
302
+ };
303
+ },
304
+ async offline(on) {
305
+ offline = on;
306
+ for (const ctx of contexts)
307
+ await ctx.setOffline(on);
308
+ },
309
+ // What a person sees: headings, alerts, visible text lines, disabled
310
+ // controls (with any reason given), fields, layout candidates and sideways
311
+ // overflow, plus the errors and API requests seen so far.
312
+ observe,
313
+ async screenshot(name, opts = {}) {
314
+ const base = name.replace(/\.png$/i, '');
315
+ if (!SHOT_NAME.test(base))
316
+ throw new Error('rr.screenshot needs a name of letters, digits, dashes and underscores');
317
+ const file = path.join(job.shotsDir, `${base}.png`);
318
+ const p = opts.page ?? page;
319
+ visit(p);
320
+ if (opts.locator) {
321
+ const locator = typeof opts.locator === 'string' ? p.locator(opts.locator) : opts.locator;
322
+ await locator.screenshot({ path: file });
323
+ }
324
+ else {
325
+ await p.screenshot({ path: file, fullPage: Boolean(opts.fullPage) });
326
+ }
327
+ if (!shots.includes(file))
328
+ shots.push(file);
329
+ return file;
330
+ },
331
+ // A named expectation. A finding names the probe and the expectation that
332
+ // fails; confirmation re-runs the probe and needs the same one to fail.
333
+ expect(name, ok, detail = '') {
334
+ checks.push({ name: String(name), pass: Boolean(ok), detail: clip(typeof detail === 'string' ? detail : JSON.stringify(detail) ?? '', 600) });
335
+ return Boolean(ok);
336
+ },
337
+ note(label, value) {
338
+ notes.push({ label: String(label), value: typeof value === 'string' ? clip(value, 2000) : value });
339
+ },
340
+ // A request with the browser's cookies; a path is relative to the web
341
+ // origin. A body is sent as JSON unless `raw` is set, when the string goes
342
+ // as it is (as JSON unless a content-type header says otherwise).
343
+ async request(method, url, body, opts = {}) {
344
+ const absolute = new URL(url, job.target).toString();
345
+ if (!isLoopback(absolute))
346
+ throw new Error('rr.request reaches loopback hosts only');
347
+ const headers = { ...(opts.headers ?? {}) };
348
+ const hasType = Object.keys(headers).some((k) => k.toLowerCase() === 'content-type');
349
+ // A Buffer, because Playwright re-encodes a string that is not valid JSON.
350
+ let data;
351
+ if (body !== undefined) {
352
+ if (opts.raw && typeof body !== 'string')
353
+ throw new Error('rr.request with raw: true needs the body as a string');
354
+ data = Buffer.from(opts.raw ? body : JSON.stringify(body), 'utf8');
355
+ if (!hasType)
356
+ headers['content-type'] = 'application/json';
357
+ }
358
+ const rec = { method: method.toUpperCase(), url: absolute, status: null, fault: null, via: 'rr.request' };
359
+ requests.push(rec);
360
+ try {
361
+ const res = await mainContext.request.fetch(absolute, { method, headers, ...(data === undefined ? {} : { data }), failOnStatusCode: false, maxRedirects: 0 });
362
+ rec.status = res.status();
363
+ const text = await res.text();
364
+ let json = null;
365
+ try {
366
+ json = JSON.parse(text);
367
+ }
368
+ catch { }
369
+ return { status: res.status(), headers: res.headers(), json, text: clip(text, 4000) };
370
+ }
371
+ catch (e) {
372
+ rec.failure = clip(String(e.message), 200);
373
+ throw e;
374
+ }
375
+ },
376
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
377
+ };
378
+ let error = null;
379
+ let timedOut = false;
380
+ let timer;
381
+ // A stray rejected promise or a throw from a detached callback (an un-awaited
382
+ // click that times out, a route handler that throws) would otherwise kill the
383
+ // child and lose every check, note and request already recorded. Catch it,
384
+ // record it once as the probe's error, and let the probe run on so its
385
+ // partial result still reaches the host.
386
+ const onStray = (kind) => (e) => {
387
+ error ??= clip(`${kind}: ${String(e?.stack ?? e).split('\n').slice(0, 3).join(' | ')}`, 800);
388
+ };
389
+ process.on('unhandledRejection', onStray('unhandled rejection'));
390
+ process.on('uncaughtException', onStray('uncaught exception'));
391
+ const deadline = new Promise((resolve) => {
392
+ timer = setTimeout(() => { timedOut = true; error = `probe timed out after ${Math.round(job.timeoutMs / 1000)} s`; resolve(); }, job.timeoutMs);
393
+ });
394
+ // Node's permission model has no rule for signals, so a probe could end the
395
+ // runner, the app or any process the user owns. Take the means away, for
396
+ // good, before the probe's code loads; the child itself never signals.
397
+ for (const name of ['kill', '_kill']) {
398
+ Object.defineProperty(process, name, {
399
+ value: () => { throw new Error('A probe may not signal other processes.'); },
400
+ writable: false,
401
+ configurable: false,
402
+ });
403
+ }
404
+ const body = (async () => {
405
+ const mod = await import(pathToFileURL(job.probeFile).href);
406
+ if (typeof mod.default !== 'function')
407
+ throw new Error('a probe must `export default async function (rr) { ... }`');
408
+ await mod.default(rr);
409
+ })().catch((e) => {
410
+ error ??= clip(String(e?.stack ?? e).split('\n').slice(0, 4).join(' | '), 800);
411
+ });
412
+ await Promise.race([body, deadline]);
413
+ clearTimeout(timer);
414
+ let final = null;
415
+ try {
416
+ const current = lastPage && !lastPage.isClosed() ? lastPage : page;
417
+ final = await Promise.race([observe(current), new Promise((_, reject) => setTimeout(() => reject(new Error('observe timed out')), 5000))]);
418
+ }
419
+ catch { }
420
+ for (const [req, rec] of records)
421
+ rec.fault ??= faulted.get(req) ?? null;
422
+ for (const p of pageIds.keys())
423
+ if (!p.isClosed())
424
+ visit(p);
425
+ if (job.keepOpenMs > 0)
426
+ await new Promise((r) => setTimeout(r, job.keepOpenMs));
427
+ return {
428
+ probe: job.probe,
429
+ startedAt,
430
+ finishedAt: Date.now(),
431
+ checks,
432
+ notes,
433
+ error,
434
+ hostError: timedOut ? 'timeout' : null,
435
+ blocked,
436
+ savedSession,
437
+ ...(signInFlow ? { signInFlow } : {}),
438
+ shots,
439
+ requests,
440
+ faults,
441
+ pages: [...pageIds.values()],
442
+ final,
443
+ output: '',
444
+ };
445
+ }
446
+ // A failure before the probe ran: its message, the path the permission model
447
+ // refused when that is the cause (Node's message leaves it out), and where.
448
+ export function fatalText(e) {
449
+ const err = e;
450
+ const refused = err?.resource ? `(${err.code ?? 'refused'}: ${err.resource})` : '';
451
+ const where = String(err?.stack ?? '').split('\n').slice(1, 4).map((l) => l.trim()).join(' | ');
452
+ return [String(err?.message ?? e), refused, where].filter(Boolean).join(' ');
453
+ }
454
+ process.once('message', (job) => {
455
+ run(job).then((result) => process.send?.({ nonce: job.nonce, result }, () => process.exit(0)), (e) => process.send?.({ nonce: job.nonce, fatal: clip(fatalText(e), 800) }, () => process.exit(0)));
456
+ });
@@ -0,0 +1,206 @@
1
+ // The probe host: it launches the browser, then runs the probe in a child Node
2
+ // process under Node's permission model. The child may read the run folder and
3
+ // this package's own code, but write only a scratch folder and its shots
4
+ // folder, so it cannot touch the runner's own files (context.json, the result
5
+ // files, findings.json, finish-state.json) that the runner trusts. It starts
6
+ // no processes, workers or addons, sees only PATH and the sign-in variables
7
+ // `start` named, and never launches a browser: it connects to this one, and
8
+ // only through the relay, which checks every protocol message it sends.
9
+ import { spawn } from 'node:child_process';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { mkdirSync, readFileSync, realpathSync } from 'node:fs';
12
+ import { createServer } from 'node:net';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { playwrightCoreDir } from './browser.mjs';
16
+ import { startRelay } from './relay.mjs';
17
+ import { emptyResult } from './results.mjs';
18
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
19
+ const CHILD = path.join(HERE, 'probe-child.mjs');
20
+ export const PROBE_TIMEOUT_MS = 100_000;
21
+ export const KILL_GRACE_MS = 20_000;
22
+ const OUTPUT_TAIL = 2000;
23
+ // The child's one writable place for a session and its own scratch, shared by
24
+ // every probe of a run so a saved session carries from one probe to the next.
25
+ export const scratchDir = (runDir) => path.join(runDir, 'scratch');
26
+ // The session the run's sign-in saved, which every probe of the run starts from.
27
+ export const sessionPath = (runDir) => path.join(scratchDir(runDir), 'session.json');
28
+ // Node 22 has no network permission; later versions have one switch for all
29
+ // of it, and the child needs it to reach the browser. Either way a probe's own
30
+ // Node code can reach the network; the browser is held to loopback below.
31
+ export const NODE_NET_PERMISSION = process.allowedNodeEnvironmentFlags.has('--allow-net');
32
+ function both(p) {
33
+ try {
34
+ const real = realpathSync.native(p);
35
+ return real === p ? [p] : [p, real];
36
+ }
37
+ catch {
38
+ return [p];
39
+ }
40
+ }
41
+ // When playwright-core loads on Linux it asks whether it runs under WSL or in a
42
+ // container, by reading these system files. One of those checks is not
43
+ // wrapped in a try, and the permission model makes a refused existsSync throw,
44
+ // so without them every probe would stop before it ran. They hold no user data,
45
+ // and they are granted as exact paths.
46
+ export const LINUX_PLATFORM_FILES = ['/proc/version', '/proc/sys/fs/binfmt_misc/WSLInterop', '/run/WSL', '/run/.containerenv', '/.dockerenv', '/proc/self/cgroup'];
47
+ // Reads cover the run folder (its own probe, session and results) and this
48
+ // package's compiled code and playwright-core, but not the package root, so a
49
+ // dev checkout's repository, node_modules and benchmark answers stay unread.
50
+ // Writes cover only the folders the child owns.
51
+ export function permissionArgs(runDir, writeDirs, platform = process.platform) {
52
+ const reads = [
53
+ ...new Set([
54
+ ...[runDir, HERE, playwrightCoreDir()].map((p) => path.resolve(p)).flatMap(both),
55
+ ...(platform === 'linux' ? LINUX_PLATFORM_FILES : []),
56
+ ]),
57
+ ];
58
+ const writes = [...new Set(writeDirs.map((p) => path.resolve(p)).flatMap(both))];
59
+ return [
60
+ '--permission',
61
+ ...reads.map((p) => `--allow-fs-read=${p}`),
62
+ ...writes.map((p) => `--allow-fs-write=${p}`),
63
+ ...(NODE_NET_PERMISSION ? ['--allow-net'] : []),
64
+ '--disable-warning=ExperimentalWarning',
65
+ ];
66
+ }
67
+ export function childEnv(names, env = process.env, platform = process.platform) {
68
+ const out = {};
69
+ const pathKey = Object.keys(env).find((k) => k.toUpperCase() === 'PATH');
70
+ if (pathKey && env[pathKey] !== undefined)
71
+ out[pathKey] = env[pathKey];
72
+ // Windows cannot open a socket in a process without SystemRoot.
73
+ if (platform === 'win32') {
74
+ const root = Object.keys(env).find((k) => k.toUpperCase() === 'SYSTEMROOT');
75
+ if (root && env[root] !== undefined)
76
+ out[root] = env[root];
77
+ }
78
+ for (const name of names)
79
+ if (env[name] !== undefined)
80
+ out[name] = env[name];
81
+ return out;
82
+ }
83
+ // A local port that drops every connection. The browser's proxy points here
84
+ // and WebRTC is held to the proxy, so a connection to any host but loopback
85
+ // (which Chrome never proxies) fails, whatever context or socket a page opens.
86
+ function closedProxy() {
87
+ return new Promise((resolve, reject) => {
88
+ const server = createServer((socket) => socket.destroy());
89
+ server.once('error', reject);
90
+ server.listen(0, '127.0.0.1', () => resolve(server));
91
+ });
92
+ }
93
+ export const REDACTED = '[session value]';
94
+ const MIN_REDACTED = 8;
95
+ // The cookie and storage values of the run's saved session. The child must
96
+ // read the session to start signed in, so a probe could print it; the result
97
+ // the hunter reads never holds these values as they are.
98
+ export function sessionValues(file) {
99
+ let state;
100
+ try {
101
+ state = JSON.parse(readFileSync(file, 'utf8'));
102
+ }
103
+ catch {
104
+ return [];
105
+ }
106
+ const values = [...(state.cookies ?? []).map((c) => c?.value), ...(state.origins ?? []).flatMap((o) => (o?.localStorage ?? []).map((i) => i?.value))];
107
+ return [...new Set(values.filter((v) => typeof v === 'string' && v.length >= MIN_REDACTED))].sort((a, b) => b.length - a.length);
108
+ }
109
+ export function redact(value, secrets) {
110
+ if (secrets.length === 0)
111
+ return value;
112
+ const walk = (v) => {
113
+ if (typeof v === 'string')
114
+ return secrets.reduce((s, x) => s.split(x).join(REDACTED), v);
115
+ if (Array.isArray(v))
116
+ return v.map(walk);
117
+ if (v && typeof v === 'object')
118
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
119
+ return v;
120
+ };
121
+ return walk(value);
122
+ }
123
+ const tail = (s) => (s.length > OUTPUT_TAIL ? `…${s.slice(-OUTPUT_TAIL)}` : s);
124
+ export async function runProbe(job) {
125
+ const scratch = scratchDir(job.runDir);
126
+ mkdirSync(job.shotsDir, { recursive: true });
127
+ mkdirSync(scratch, { recursive: true });
128
+ const sessionFile = sessionPath(job.runDir);
129
+ const before = sessionValues(sessionFile);
130
+ const timeoutMs = job.timeoutMs ?? PROBE_TIMEOUT_MS;
131
+ const { chromium } = await import('playwright-core');
132
+ let proxy = null;
133
+ let server = null;
134
+ let relay = null;
135
+ try {
136
+ proxy = await closedProxy();
137
+ const { port } = proxy.address();
138
+ server = await chromium.launchServer({ channel: job.browser, headless: !job.headed, host: '127.0.0.1', args: [`--proxy-server=http://127.0.0.1:${port}`, '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'] });
139
+ relay = await startRelay(server.wsEndpoint(), job.target);
140
+ }
141
+ catch (e) {
142
+ await server?.kill().catch(() => { });
143
+ proxy?.close();
144
+ return emptyResult(job.probe, `the browser did not start: ${String(e.message).split('\n')[0]}`, 'crash');
145
+ }
146
+ const nonce = randomBytes(16).toString('hex');
147
+ const child = spawn(process.execPath, [...permissionArgs(job.runDir, [scratch, job.shotsDir]), CHILD], {
148
+ cwd: scratch,
149
+ env: childEnv(job.signInVariables),
150
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
151
+ });
152
+ let output = '';
153
+ child.stdout?.on('data', (d) => { output = tail(output + d.toString('utf8')); });
154
+ child.stderr?.on('data', (d) => { output = tail(output + d.toString('utf8')); });
155
+ const result = await new Promise((resolve) => {
156
+ let settled = false;
157
+ const settle = (r) => { if (!settled) {
158
+ settled = true;
159
+ clearTimeout(killer);
160
+ resolve(r);
161
+ } };
162
+ const killer = setTimeout(() => {
163
+ child.kill('SIGKILL');
164
+ settle(emptyResult(job.probe, `probe timed out after ${Math.round((timeoutMs + KILL_GRACE_MS) / 1000)} s and was stopped`, 'timeout'));
165
+ }, timeoutMs + KILL_GRACE_MS + (job.keepOpenMs ?? 0));
166
+ child.on('message', (m) => {
167
+ if (m?.nonce !== nonce)
168
+ return;
169
+ if (m.result)
170
+ settle(m.result);
171
+ else
172
+ settle(emptyResult(job.probe, `probe host failed: ${m.fatal ?? 'unknown error'}`, 'crash'));
173
+ });
174
+ child.on('error', (e) => settle(emptyResult(job.probe, `probe host failed to start: ${e.message}`, 'crash')));
175
+ child.on('exit', (code, signal) => {
176
+ // Let a message sent just before exit arrive first.
177
+ setImmediate(() => settle(emptyResult(job.probe, `probe host gave no result (exit ${code ?? signal})`, code === 0 ? 'no-result' : 'crash')));
178
+ });
179
+ const message = {
180
+ nonce,
181
+ wsEndpoint: relay.endpoint,
182
+ probe: job.probe,
183
+ probeFile: job.probeFile,
184
+ target: job.target,
185
+ sessionFile,
186
+ shotsDir: job.shotsDir,
187
+ timeoutMs,
188
+ slowMo: job.slowMo ?? 0,
189
+ keepOpenMs: job.keepOpenMs ?? 0,
190
+ };
191
+ child.send(message);
192
+ });
193
+ if (child.exitCode === null)
194
+ child.kill('SIGKILL');
195
+ // Each run starts from scratch, so the browser is killed rather than closed
196
+ // gracefully: it is much faster, and its profile folder is removed either way.
197
+ await relay.close().catch(() => { });
198
+ await server.kill().catch(() => { });
199
+ proxy.close();
200
+ result.refused = [...relay.refused];
201
+ result.output = output.trim();
202
+ if (result.hostError && result.output)
203
+ result.error = `${result.error ?? ''} | output: ${result.output.slice(-400)}`;
204
+ // The session as it was before this run and as the run may have saved it.
205
+ return redact(result, [...new Set([...before, ...sessionValues(sessionFile)])]);
206
+ }