@mochi.js/core 0.3.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/launch.ts CHANGED
@@ -12,8 +12,10 @@
12
12
 
13
13
  import { deriveMatrix, type ProfileV1 } from "@mochi.js/consistency";
14
14
  import { resolveBinary } from "./binary";
15
+ import { defaultProfileForHost, unsupportedHostMessage } from "./default-profile";
15
16
  import { type GeoConsistencyMode, reconcileGeoConsistency } from "./geo-consistency";
16
17
  import { probeExitGeo } from "./geo-probe";
18
+ import { type LinuxServerEnv, probeLinuxServerEnv } from "./linux-server";
17
19
  import { spawnChromium } from "./proc";
18
20
  import { parseProxyUrl } from "./proxy-auth";
19
21
  import { Session } from "./session";
@@ -85,10 +87,70 @@ export interface ChallengeLaunchOptions {
85
87
  * flows — never enable in production.
86
88
  */
87
89
  export interface LaunchOptions {
88
- profile: ProfileId | ProfileV1;
90
+ /**
91
+ * Profile to derive the fingerprint matrix from. Either a `ProfileId`
92
+ * string (looked up against `KNOWN_PROFILE_IDS`) or an inline `ProfileV1`
93
+ * object.
94
+ *
95
+ * **Optional** — when omitted, mochi auto-picks the
96
+ * profile whose declared OS matches the host's `process.platform` /
97
+ * `process.arch` pair via {@link defaultProfileForHost}:
98
+ *
99
+ * - `linux/x64` → `linux-chrome-stable`
100
+ * - `darwin/arm64` → `mac-m4-chrome-stable`
101
+ * - `darwin/x64` → `mac-chrome-stable`
102
+ * - `win32/x64` → `windows-chrome-stable`
103
+ *
104
+ * On any unsupported host (FreeBSD, Linux arm64 today, Windows arm64,
105
+ * Alpine musl), launch throws with a precise diagnostic listing the six
106
+ * explicit profile IDs the user can choose from. The default never
107
+ * silently overrides an explicit choice.
108
+ *
109
+ * Strategic rationale: a Linux server defaulting to a Linux profile
110
+ * removes the entire class of "user accidentally spoofed Windows from a
111
+ * Linux DC and looked weird to the WAF" failures. Linux is a real-user
112
+ * signal, not a bot signal — see `concepts/stealth-philosophy` for the
113
+ * thesis + production evidence.
114
+ */
115
+ profile?: ProfileId | ProfileV1;
89
116
  seed: string;
90
117
  proxy?: string | ProxyConfig;
118
+ /**
119
+ * Legacy boolean knob — `true` runs Chromium under `--headless=new`,
120
+ * `false` (default in v0.1) runs headful. New code should prefer
121
+ * {@link headlessMode}, which is more expressive AND env-aware.
122
+ *
123
+ * Resolution priority:
124
+ *
125
+ * 1. `headlessMode` if set.
126
+ * 2. Else `headless: true → "new"`, `headless: false → "off"`.
127
+ * 3. Else env-aware default — Linux without DISPLAY / WAYLAND_DISPLAY
128
+ * auto-resolves to `"new"` (the common server case); everywhere else
129
+ * defaults to `"off"` (headful, requires a display).
130
+ */
91
131
  headless?: boolean;
132
+ /**
133
+ * Headless dispatch mode. One of:
134
+ *
135
+ * - `"new"` — modern Chromium headless (`--headless=new`). Full
136
+ * rendering, near-byte-identical to headful for
137
+ * fingerprinting. The right default on a server.
138
+ * - `"legacy"` — legacy `--headless` (no `=new`). Separate, more-
139
+ * detectable code path; only useful for parity with
140
+ * older tooling. Documented but not recommended.
141
+ * - `"off"` — run headful. Requires a real display server (DISPLAY
142
+ * on X11, WAYLAND_DISPLAY on Wayland) or xvfb.
143
+ *
144
+ * When unset, mochi infers the default from the live env: Linux without
145
+ * DISPLAY / WAYLAND_DISPLAY → `"new"`; otherwise `"off"`. The legacy
146
+ * `headless: boolean` knob (when set) overrides the env default but is
147
+ * itself overridden by an explicit `headlessMode`.
148
+ *
149
+ * Use `mochi.detectLinuxServerEnv()` to introspect what mochi inferred.
150
+ *
151
+ * @see docs/getting-started/linux-server.md
152
+ */
153
+ headlessMode?: "new" | "legacy" | "off";
92
154
  binary?: string;
93
155
  args?: string[];
94
156
  out?: { traceDir?: string };
@@ -116,7 +178,7 @@ export interface LaunchOptions {
116
178
  * trivially fingerprinted as Chromium-for-Testing.
117
179
  *
118
180
  * Defaults to `false`. PLAN.md §12.1 (capture must run against bare
119
- * Chromium); task 0040.
181
+ * Chromium);
120
182
  */
121
183
  bypassInject?: boolean;
122
184
  /**
@@ -137,7 +199,7 @@ export interface LaunchOptions {
137
199
  *
138
200
  * Pairs with — but is independent of — {@link bypassInject}. Capture
139
201
  * flows set both `true`; harness conformance runs set `hermetic: true`
140
- * with full inject pipeline active. PLAN.md §8.6 + task 0256.
202
+ * with full inject pipeline active. PLAN.md §8.6 +
141
203
  */
142
204
  hermetic?: boolean;
143
205
  /**
@@ -173,13 +235,13 @@ export interface LaunchOptions {
173
235
  * - `"off"` — skip the probe entirely. Use in offline tests / when
174
236
  * the probe service is rate-limited.
175
237
  *
176
- * The probe is a single GET through wreq (using the matrix's
177
- * `wreqPreset`, so the geo service sees the same JA4/headers as user
178
- * traffic). 4-attempt cap, 2s per endpoint. Probe results are NOT
179
- * cached across sessions — proxy IPs rotate.
238
+ * The probe is a single GET through Chromium itself (Session.fetch via
239
+ * CDP `Network.loadNetworkResource`), so the geo service sees the same
240
+ * JA4 / headers as user traffic by definition. 4-attempt cap, 2s per
241
+ * endpoint. Probe results are NOT cached across sessions — proxy IPs
242
+ * rotate.
180
243
  *
181
244
  * @see PLAN.md §9 (relational consistency, IP/TZ/Locale axis)
182
- * @see tasks/0262-ip-tz-locale-exit-consistency.md
183
245
  */
184
246
  geoConsistency?: GeoConsistencyMode;
185
247
  }
@@ -193,8 +255,8 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
193
255
  const normalized = normalizeProxy(opts.proxy);
194
256
 
195
257
  // Resolve the `MatrixV1` BEFORE spawning so matrix-derived values flow
196
- // into both the `--lang` flag (task 0251) and `--window-size` flag
197
- // (task 0252). The matrix is otherwise read post-spawn for inject;
258
+ // into both the `--lang` flag and `--window-size` flag
259
+ //. The matrix is otherwise read post-spawn for inject;
198
260
  // deriving early is cheap (~µs, pure function) and lets us close the
199
261
  // I-5 leaks between Chromium's native network/OS-window state and the
200
262
  // JS-layer spoof.
@@ -203,14 +265,34 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
203
265
  // are resolved against a placeholder profile until `@mochi.js/profiles`
204
266
  // ships its first capture (phase 0.4). The matrix is bit-stable per
205
267
  // `(profile, seed)` excluding the `derivedAt` timestamp.
206
- const profile = resolveProfile(opts.profile);
207
- const matrix = deriveMatrix(profile, opts.seed);
268
+ //
269
+ // Task 0272 — when `profile` is omitted, auto-pick the host-OS-matching
270
+ // profile id. Throws with a precise diagnostic if the host is one of the
271
+ // unsupported ones (FreeBSD, Linux arm64 today, Windows arm64, Alpine
272
+ // musl). Explicit `profile:` always wins; the auto-pick never overrides.
273
+ const profileSource = resolveProfileSource(opts.profile);
274
+ const matrix = deriveMatrix(profileSource.profile, opts.seed);
275
+ if (profileSource.autoPicked) {
276
+ // One info-level log line so users can see what mochi inferred without
277
+ // calling `defaultProfileForHost()` themselves. Wording is pinned by
278
+ // — keep stable so docs + LLM-context blocks stay correct.
279
+ // (Routed through `console.warn` to match the existing diagnostic
280
+ // channel for `geoConsistency` / Linux-server inference; `console.info`
281
+ // is gated by the workspace lint config — `noConsole` only allows
282
+ // `error` and `warn` at the moment.)
283
+ console.warn(
284
+ `[mochi] no profile supplied; auto-picked ${profileSource.id} for host ` +
285
+ `${process.platform}/${process.arch}. To override: pass ` +
286
+ `profile: "${profileSource.id}" explicitly.`,
287
+ );
288
+ }
208
289
 
209
290
  // Task 0262 — exit-IP / TZ / locale reconciliation.
210
291
  //
211
- // Probe the apparent exit IP through the configured proxy (using wreq
212
- // with the matrix's `wreqPreset` so the geo service sees the same JA4
213
- // / headers as user traffic). Then cross-reference against
292
+ // Probe the apparent exit IP through the configured proxy. Post-0.7
293
+ // the probe runs through Chromium itself (Session.fetch via CDP
294
+ // `Network.loadNetworkResource`), so the geo service sees the same
295
+ // JA4 / headers as user traffic by definition. Cross-reference against
214
296
  // `(matrix.timezone, matrix.locale)` and apply `geoConsistency`. The
215
297
  // adjusted matrix flows into BOTH `spawnChromium` (so `--lang` reflects
216
298
  // any override) AND `Session` (so inject + the CDP `Emulation.set
@@ -222,7 +304,7 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
222
304
  let adjustedMatrix = matrix;
223
305
  if (geoMode !== "off") {
224
306
  const geo = await probeExitGeo({
225
- ...(normalized?.netProxy !== undefined ? { proxy: normalized.netProxy } : {}),
307
+ ...(normalized?.proxy !== undefined ? { proxy: normalized.proxy } : {}),
226
308
  matrix,
227
309
  });
228
310
  // Strict mode throws GeoMismatchError on real mismatch; let it
@@ -237,10 +319,36 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
237
319
  }
238
320
  }
239
321
 
322
+ // Resolve headless dispatch BEFORE the spawn call so we can log the
323
+ // env-derived default and let the user introspect via
324
+ // `mochi.detectLinuxServerEnv()`. Task 0258 — the "Linux server, no
325
+ // DISPLAY" case is the common deployment failure mode for `mochi.launch()`,
326
+ // and the previous default (`opts.headless ?? false` → headful) crashed
327
+ // immediately on a fresh Ubuntu / Debian host because there was no display
328
+ // to attach to. We now infer `"new"` on that environment.
329
+ const linuxEnv = probeLinuxServerEnv();
330
+ const resolvedHeadlessMode = resolveHeadlessMode(opts, linuxEnv);
331
+ if (
332
+ resolvedHeadlessMode === "new" &&
333
+ opts.headlessMode === undefined &&
334
+ opts.headless === undefined
335
+ ) {
336
+ // Only chatter when the launcher had to infer (caller said nothing). An
337
+ // explicit `headlessMode: "new"` from the caller is silent — they know
338
+ // what they asked for. The container/root signals are surfaced too so
339
+ // the diagnostic is one log line, not three.
340
+ console.warn(
341
+ `[mochi] Linux server detected (no DISPLAY / WAYLAND_DISPLAY) — defaulting to ` +
342
+ `--headless=new. ${linuxEnv.rationale}. Set headlessMode: "off" to override; ` +
343
+ `see docs/getting-started/linux-server.md for the xvfb path.`,
344
+ );
345
+ }
346
+
240
347
  const proc = await spawnChromium({
241
348
  binary,
242
349
  extraArgs: opts.args,
243
350
  headless: opts.headless ?? false,
351
+ headlessMode: resolvedHeadlessMode,
244
352
  // Opt-out for the auto-no-sandbox-as-root fallback (default: fallback
245
353
  // is on so first-run on a Linux server box doesn't crash).
246
354
  ...(opts.allowRootWithSandbox === true ? { allowRootWithSandbox: true } : {}),
@@ -252,13 +360,13 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
252
360
  // multi-locale list still flows through `matrix.languages` to the
253
361
  // inject layer's `navigator.languages` spoof; Chromium derives the
254
362
  // q-weighted `Accept-Language` value from the single `--lang` primary
255
- // automatically. Task 0251.
363
+ // automatically.
256
364
  locale: adjustedMatrix.locale,
257
365
  // Pin OS-level outer window from the matrix's display geometry so
258
366
  // `window.outerWidth/outerHeight` (which reads from the OS window,
259
367
  // NOT the JS-spoofed `screen.*`) matches the spoof. Closes the
260
368
  // `fingerprint-scan.com` 800×600 leak under `--headless=new`.
261
- // UDC fixes the same issue at `__init__.py:410-411`. Task 0252.
369
+ // UDC fixes the same issue at `__init__.py:410-411`.
262
370
  //
263
371
  // (`adjustedMatrix.display` === `matrix.display` since geo reconcile
264
372
  // only touches timezone/locale/languages — but we use the adjusted
@@ -288,10 +396,11 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
288
396
  seed: opts.seed,
289
397
  ...(opts.timeout !== undefined ? { defaultTimeoutMs: opts.timeout } : {}),
290
398
  ...(opts.bypassInject === true ? { bypassInject: true } : {}),
291
- // Forward the same proxy (with auth, if any) to the net FFI so
292
- // out-of-band Session.fetch traffic shares the apparent egress.
293
- // `@mochi.js/net` (wreq) accepts the full `user:pass@host` URL form.
294
- ...(normalized !== undefined ? { netProxy: normalized.netProxy } : {}),
399
+ // Proxy auth is the only piece that needs explicit Session-side
400
+ // wiring (the `--proxy-server` flag is already on Chromium's command
401
+ // line above). Out-of-band `Session.fetch` traffic rides Chromium's
402
+ // network stack post-0.7, so it inherits the `--proxy-server` egress
403
+ // automatically — no per-call proxy URL needed.
295
404
  ...(normalized?.auth !== undefined ? { proxyAuth: normalized.auth } : {}),
296
405
  ...(opts.challenges !== undefined ? { challenges: opts.challenges } : {}),
297
406
  });
@@ -306,19 +415,59 @@ export const mochi = {
306
415
  version: VERSION,
307
416
  /** Launch a browser session. */
308
417
  launch,
418
+ /**
419
+ * Inspect what mochi would infer about the current process environment for
420
+ * Linux-server detection (drives `headlessMode` defaulting). Pure read of
421
+ * `process.platform`, `process.env.DISPLAY`, `process.env.WAYLAND_DISPLAY`,
422
+ * `process.getuid?.()`, and the container probe paths.
423
+ */
424
+ detectLinuxServerEnv: probeLinuxServerEnv,
425
+ /**
426
+ * Inspect which profile id `mochi.launch` would auto-pick on the current
427
+ * host when `profile` is omitted. Pure read of `process.platform` /
428
+ * `process.arch`. Returns `null` on unsupported hosts — the launcher
429
+ * throws on that path with a list of explicit profile IDs.
430
+ *
431
+ * @see https://mochijs.com/docs/concepts/stealth-philosophy
432
+ */
433
+ defaultProfileForHost,
309
434
  } as const;
310
435
 
311
436
  export type Mochi = typeof mochi;
312
437
 
313
438
  // ---- helpers ----------------------------------------------------------------
314
439
 
440
+ /**
441
+ * Resolve the effective {@link LaunchOptions.headlessMode} given a snapshot
442
+ * of `(opts, env)`. Pure / synchronous so tests can drive both axes without
443
+ * stubbing globals. Resolution order — task 0258:
444
+ *
445
+ * 1. Explicit `opts.headlessMode` wins.
446
+ * 2. Else legacy `opts.headless: true | false` maps to `"new"` / `"off"`.
447
+ * 3. Else env-aware default — Linux without DISPLAY / WAYLAND_DISPLAY →
448
+ * `"new"`; otherwise `"off"`.
449
+ *
450
+ * Exported so the unit tests can lock the resolution table without spawning
451
+ * a Chromium or stubbing `process.platform`.
452
+ */
453
+ export function resolveHeadlessMode(
454
+ opts: Pick<LaunchOptions, "headless" | "headlessMode">,
455
+ env: LinuxServerEnv,
456
+ ): "new" | "legacy" | "off" {
457
+ if (opts.headlessMode !== undefined) return opts.headlessMode;
458
+ if (opts.headless === true) return "new";
459
+ if (opts.headless === false) return "off";
460
+ return env.serverNoDisplay ? "new" : "off";
461
+ }
462
+
315
463
  /**
316
464
  * Reconcile the two `LaunchOptions.proxy` shapes (URL string and
317
465
  * `ProxyConfig` record) into a single normalized record carrying:
318
466
  * - `server`: auth-stripped URL safe to feed `--proxy-server=`.
319
- * - `netProxy`: the URL handed to the network FFI. Preserves credentials
320
- * (wreq accepts `user:pass@host` inline) so out-of-band fetches
321
- * authenticate against the same proxy.
467
+ * - `proxy`: the auth-stripped URL forwarded to the geo-probe so it
468
+ * can record the egress on diagnostics. (Kept for API parity even
469
+ * though the probe now rides Session.fetch + Chromium's network
470
+ * stack — i.e. picks up `--proxy-server` automatically.)
322
471
  * - `auth`: parsed credentials for the CDP auth handler. Undefined when
323
472
  * no creds were supplied.
324
473
  *
@@ -327,7 +476,7 @@ export type Mochi = typeof mochi;
327
476
  function normalizeProxy(p: LaunchOptions["proxy"]):
328
477
  | {
329
478
  server: string;
330
- netProxy: string;
479
+ proxy: string;
331
480
  auth?: { username: string; password: string };
332
481
  }
333
482
  | undefined {
@@ -337,7 +486,7 @@ function normalizeProxy(p: LaunchOptions["proxy"]):
337
486
  const parsed = parseProxyUrl(p);
338
487
  return {
339
488
  server: parsed.server,
340
- netProxy: p,
489
+ proxy: parsed.server,
341
490
  ...(parsed.auth !== undefined ? { auth: parsed.auth } : {}),
342
491
  };
343
492
  }
@@ -346,40 +495,64 @@ function normalizeProxy(p: LaunchOptions["proxy"]):
346
495
  const parsed = parseProxyUrl(p.server);
347
496
  const auth =
348
497
  p.username !== undefined ? { username: p.username, password: p.password ?? "" } : parsed.auth;
349
- // Reconstruct the netProxy URL preserving any explicit auth (wreq path).
350
- const netProxy = auth !== undefined ? injectAuth(parsed.server, auth) : parsed.server;
351
498
  return {
352
499
  server: parsed.server,
353
- netProxy,
500
+ proxy: parsed.server,
354
501
  ...(auth !== undefined ? { auth } : {}),
355
502
  };
356
503
  }
357
504
 
358
505
  /**
359
- * Inject `username:password@` into a server URL, percent-encoding both
360
- * components so reserved characters round-trip cleanly through wreq's URL
361
- * parser.
506
+ * Resolve `LaunchOptions.profile` into a concrete `ProfileV1` plus the
507
+ * meta-flag the launcher needs to decide whether to log the auto-pick
508
+ * INFO line. Three branches:
509
+ *
510
+ * 1. Explicit `ProfileV1` object — flows through unchanged. `autoPicked`
511
+ * false; `id` taken from the inline object.
512
+ * 2. Explicit `ProfileId` string — same placeholder synthesis as before.
513
+ * `autoPicked` false.
514
+ * 3. `undefined` — task 0272: call `defaultProfileForHost()`. Throw with
515
+ * the unsupported-host diagnostic when the resolver returns `null`.
516
+ * `autoPicked` true.
517
+ *
518
+ * Pure function — does not log. The launcher emits the INFO line itself
519
+ * after observing `autoPicked === true` so test fixtures can assert the
520
+ * resolution without intercepting `console`.
362
521
  */
363
- function injectAuth(server: string, auth: { username: string; password: string }): string {
364
- const u = encodeURIComponent(auth.username);
365
- const p = encodeURIComponent(auth.password);
366
- // server is `<protocol>://<host>:<port>` (per parseProxyUrl).
367
- const idx = server.indexOf("://");
368
- if (idx < 0) return server;
369
- const head = server.slice(0, idx + 3);
370
- const tail = server.slice(idx + 3);
371
- return `${head}${u}:${p}@${tail}`;
522
+ function resolveProfileSource(profile: ProfileId | ProfileV1 | undefined): {
523
+ profile: ProfileV1;
524
+ id: ProfileId;
525
+ autoPicked: boolean;
526
+ } {
527
+ if (typeof profile === "object") {
528
+ return { profile, id: profile.id, autoPicked: false };
529
+ }
530
+ if (typeof profile === "string") {
531
+ return {
532
+ profile: synthesizePlaceholderProfile(profile),
533
+ id: profile,
534
+ autoPicked: false,
535
+ };
536
+ }
537
+ // Auto-pick branch —
538
+ const picked = defaultProfileForHost();
539
+ if (picked === null) {
540
+ throw new Error(unsupportedHostMessage(process.platform, process.arch));
541
+ }
542
+ return {
543
+ profile: synthesizePlaceholderProfile(picked),
544
+ id: picked,
545
+ autoPicked: true,
546
+ };
372
547
  }
373
548
 
374
549
  /**
375
- * Resolve `LaunchOptions.profile` into a concrete `ProfileV1`. Inline
376
- * profiles flow through unchanged. String profile ids — until
377
- * `@mochi.js/profiles` ships (phase 0.4) resolve to a generic placeholder
378
- * stamped with the id; the consistency engine still produces a real,
379
- * relationally-locked Matrix from it.
550
+ * Synthesize a generic placeholder `ProfileV1` from a profile id. Until
551
+ * `@mochi.js/profiles.getProfile` lands (phase 0.4), the consistency engine
552
+ * still produces a real, relationally-locked Matrix from this skeleton
553
+ * the id is what flows into `sha256(profile.id + seed)`.
380
554
  */
381
- function resolveProfile(profile: ProfileId | ProfileV1): ProfileV1 {
382
- if (typeof profile === "object") return profile;
555
+ function synthesizePlaceholderProfile(profile: ProfileId): ProfileV1 {
383
556
  return {
384
557
  id: profile,
385
558
  version: "0.0.0-placeholder",
@@ -409,7 +582,9 @@ function resolveProfile(profile: ProfileId | ProfileV1): ProfileV1 {
409
582
  locale: "en-US",
410
583
  languages: ["en-US", "en"],
411
584
  behavior: { hand: "right", tremor: 0.18, wpm: 60, scrollStyle: "smooth" },
412
- wreqPreset: "chrome_131_linux",
585
+ // Deprecated — kept for one release for migration; runtime no longer
586
+ // reads the field. Drops in 0.8.
587
+ wreqPreset: "chrome_148_linux",
413
588
  userAgent:
414
589
  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
415
590
  uaCh: {},
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Linux-server environment detection.
3
+ *
4
+ * The "common deployment env" failure mode for `mochi.launch()`: a fresh
5
+ * Ubuntu / Debian server, no DISPLAY, no Wayland — Chromium spawns but cannot
6
+ * render and either hangs or crashes on the first paint. The fix is to drive
7
+ * Chromium through `--headless=new` (the modern headless that ships full
8
+ * rendering and is near-byte-identical to headful for fingerprinting; the
9
+ * legacy `--headless` is a separate, more-detectable code path).
10
+ *
11
+ * This module exposes:
12
+ *
13
+ * - {@link detectLinuxServerEnv} — pure function. Given a snapshot of
14
+ * `(platform, env, container probes)`, returns a record describing what
15
+ * mochi inferred (Linux-without-display? root? containerised?). Pure /
16
+ * synchronous so callers can stub the inputs and unit-test without
17
+ * touching `process.*`.
18
+ * - {@link DEFAULT_LINUX_SERVER_PROBES} — convenience that snapshots the
19
+ * real `process.platform`, `process.env.DISPLAY`,
20
+ * `process.env.WAYLAND_DISPLAY`, `process.getuid?.()`, and the container
21
+ * filesystem probes (`/.dockerenv`, `/proc/1/cgroup`). Calls
22
+ * {@link detectLinuxServerEnv} with that snapshot and returns the result.
23
+ *
24
+ * Detection rules:
25
+ *
26
+ * 1. `platform === "linux"` AND no `DISPLAY` AND no `WAYLAND_DISPLAY`
27
+ * → `serverNoDisplay = true`. This is the load-bearing signal for
28
+ * auto-defaulting `headlessMode` to `"new"`.
29
+ * 2. `getuid?.() === 0` → `root = true`. Orthogonal to #1; drives the
30
+ * existing auto-`--no-sandbox` path in `proc.ts` (kept verbatim — this
31
+ * module does not own that decision).
32
+ * 3. `/.dockerenv` exists OR `/proc/1/cgroup` mentions
33
+ * `docker | containerd | kubepods` → `container = true`. Tertiary
34
+ * signal; surfaced for diagnostics only (a container with DISPLAY set
35
+ * is still a "with display" environment).
36
+ *
37
+ * @see tasks/0259 (Linux first-run experience)
38
+ * @see tasks/0258 (Linux server env auto-detection)
39
+ * @see docs/getting-started/linux-server.md
40
+ */
41
+
42
+ import { existsSync, readFileSync } from "node:fs";
43
+
44
+ /**
45
+ * Snapshot of the runtime probes that {@link detectLinuxServerEnv} consumes.
46
+ * Defined as a record (not direct `process.*` reads) so unit tests can stub
47
+ * each axis independently.
48
+ */
49
+ export interface LinuxServerProbes {
50
+ /** `process.platform`. */
51
+ platform: NodeJS.Platform;
52
+ /** Value of `process.env.DISPLAY` (X11 display server). */
53
+ display: string | undefined;
54
+ /** Value of `process.env.WAYLAND_DISPLAY` (Wayland display server). */
55
+ waylandDisplay: string | undefined;
56
+ /** UID, or `undefined` on platforms without a getuid (Windows). */
57
+ uid: number | undefined;
58
+ /** `true` when `/.dockerenv` exists. */
59
+ hasDockerEnvFile: boolean;
60
+ /**
61
+ * Contents of `/proc/1/cgroup`, or `undefined` if absent / unreadable.
62
+ * Container detection scans this for `docker | containerd | kubepods`.
63
+ */
64
+ cgroup: string | undefined;
65
+ }
66
+
67
+ /**
68
+ * Result returned by {@link detectLinuxServerEnv}. Structured so callers
69
+ * (the launcher, the diagnostic helper, an end-user calling
70
+ * `mochi.detectLinuxServerEnv()` directly) can introspect each axis without
71
+ * re-running the probes.
72
+ */
73
+ export interface LinuxServerEnv {
74
+ /** `true` iff Linux + no DISPLAY + no WAYLAND_DISPLAY. */
75
+ serverNoDisplay: boolean;
76
+ /** `true` iff the process is running as uid 0 on Linux. */
77
+ root: boolean;
78
+ /** `true` iff a container indicator (`/.dockerenv` or cgroup mention) hit. */
79
+ container: boolean;
80
+ /** Human-readable rationale string. Intended for `console.debug`. */
81
+ rationale: string;
82
+ }
83
+
84
+ /**
85
+ * Pure, synchronous classifier. Given a `LinuxServerProbes` snapshot, returns
86
+ * a `LinuxServerEnv` summary. No I/O, no global reads — every input is on the
87
+ * `probes` argument. Exposed for unit tests AND for users who want to drive
88
+ * the classification with their own probes (e.g. a CI matrix that wants to
89
+ * pretend it's running under DISPLAY=:0 to validate a code path).
90
+ */
91
+ export function detectLinuxServerEnv(probes: LinuxServerProbes): LinuxServerEnv {
92
+ const isLinux = probes.platform === "linux";
93
+ const hasDisplay =
94
+ (probes.display !== undefined && probes.display.length > 0) ||
95
+ (probes.waylandDisplay !== undefined && probes.waylandDisplay.length > 0);
96
+ const serverNoDisplay = isLinux && !hasDisplay;
97
+ const root = isLinux && probes.uid === 0;
98
+ const container =
99
+ probes.hasDockerEnvFile ||
100
+ (probes.cgroup !== undefined && /docker|containerd|kubepods/.test(probes.cgroup));
101
+
102
+ const parts: string[] = [];
103
+ parts.push(`platform=${probes.platform}`);
104
+ parts.push(`display=${probes.display ?? "(unset)"}`);
105
+ parts.push(`waylandDisplay=${probes.waylandDisplay ?? "(unset)"}`);
106
+ parts.push(`uid=${probes.uid ?? "(none)"}`);
107
+ parts.push(`container=${container}`);
108
+ parts.push(`serverNoDisplay=${serverNoDisplay}`);
109
+ const rationale = parts.join(" ");
110
+
111
+ return { serverNoDisplay, root, container, rationale };
112
+ }
113
+
114
+ /**
115
+ * Snapshot the live process state and run {@link detectLinuxServerEnv}
116
+ * against it. The convenience entry point used by {@link launch} and the
117
+ * inspection helper exposed on the public surface (`mochi.detectLinuxServerEnv()`).
118
+ *
119
+ * Filesystem probes (`/.dockerenv`, `/proc/1/cgroup`) are guarded with
120
+ * `existsSync` + a try/catch so the call is safe on macOS / Windows / sandboxed
121
+ * environments where the paths don't exist.
122
+ */
123
+ export function probeLinuxServerEnv(): LinuxServerEnv {
124
+ return detectLinuxServerEnv(snapshotProbes());
125
+ }
126
+
127
+ /**
128
+ * Build a {@link LinuxServerProbes} record from the live `process.*` and
129
+ * filesystem state. Exported so callers debugging an environment-detection
130
+ * issue can inspect the raw inputs the classifier saw.
131
+ */
132
+ export function snapshotProbes(): LinuxServerProbes {
133
+ const platform = process.platform;
134
+ const display = process.env.DISPLAY;
135
+ const waylandDisplay = process.env.WAYLAND_DISPLAY;
136
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
137
+ const hasDockerEnvFile = safeExists("/.dockerenv");
138
+ const cgroup = safeReadText("/proc/1/cgroup");
139
+ return { platform, display, waylandDisplay, uid, hasDockerEnvFile, cgroup };
140
+ }
141
+
142
+ function safeExists(path: string): boolean {
143
+ try {
144
+ return existsSync(path);
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ function safeReadText(path: string): string | undefined {
151
+ try {
152
+ if (!existsSync(path)) return undefined;
153
+ return readFileSync(path, "utf8");
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ }
@@ -17,7 +17,6 @@
17
17
  * which is fine for a v0.2 surface.
18
18
  *
19
19
  * @see PLAN.md §8.2 / §8.3
20
- * @see tasks/0253-closed-shadow-piercing-locator.md
21
20
  */
22
21
 
23
22
  import type { MessageRouter } from "../cdp/router";
@@ -33,7 +33,6 @@
33
33
  * brief — a per-page cache layer is a v0.3+ concern).
34
34
  *
35
35
  * @see PLAN.md §8.2 — `DOM.getDocument` / `DOM.resolveNode` are not forbidden
36
- * @see tasks/0253-closed-shadow-piercing-locator.md
37
36
  */
38
37
 
39
38
  import type { PierceDomNode } from "../cdp/types";
@@ -31,7 +31,6 @@
31
31
  * Throws `SelectorParseError` on syntactically invalid input. The matcher
32
32
  * itself never throws — unsupported nodes just don't match.
33
33
  *
34
- * @see tasks/0253-closed-shadow-piercing-locator.md
35
34
  * @see PLAN.md §8.2 (forbidden CDP — neither `DOM.getDocument` nor
36
35
  * `DOM.resolveNode` is forbidden; both fine).
37
36
  */