@mochi.js/core 0.2.2 → 0.6.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,6 +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";
16
+ import { type GeoConsistencyMode, reconcileGeoConsistency } from "./geo-consistency";
17
+ import { probeExitGeo } from "./geo-probe";
18
+ import { type LinuxServerEnv, probeLinuxServerEnv } from "./linux-server";
15
19
  import { spawnChromium } from "./proc";
16
20
  import { parseProxyUrl } from "./proxy-auth";
17
21
  import { Session } from "./session";
@@ -83,10 +87,70 @@ export interface ChallengeLaunchOptions {
83
87
  * flows — never enable in production.
84
88
  */
85
89
  export interface LaunchOptions {
86
- 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 since task 0272** — 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;
87
116
  seed: string;
88
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 (task 0258):
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
+ */
89
131
  headless?: boolean;
132
+ /**
133
+ * Headless dispatch mode (task 0258). 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";
90
154
  binary?: string;
91
155
  args?: string[];
92
156
  out?: { traceDir?: string };
@@ -151,6 +215,35 @@ export interface LaunchOptions {
151
215
  * solving is v0.3+).
152
216
  */
153
217
  challenges?: ChallengeLaunchOptions;
218
+ /**
219
+ * Reconcile `(matrix.timezone, matrix.locale)` against the proxy's
220
+ * exit-IP geolocation. Closes the cross-layer leak where a US profile
221
+ * over an EU proxy would have `Date.getTimezoneOffset()` reporting PT
222
+ * while the IP geolocates to UTC+1 — the canonical bot signature.
223
+ *
224
+ * - `"privacy-fallback"` *(default)* — on mismatch (or probe failure),
225
+ * override the matrix to UTC + `en-US`. The session fingerprints as
226
+ * a privacy-conscious user (Tor / Brave / hardened-FF style), which
227
+ * is benign in most threat models.
228
+ * - `"auto-correct"` — on mismatch, override the matrix's timezone
229
+ * with the IP's timezone and the locale with a primary-locale
230
+ * guess for the IP's country. Most "stealth" but trusts mochi's
231
+ * IP-derived defaults over the user's declared profile.
232
+ * - `"strict"` — throw `GeoMismatchError` on mismatch. The user must
233
+ * change profile or change proxy. Probe failure (null) does NOT
234
+ * throw under strict — that's a network blip, not a mismatch.
235
+ * - `"off"` — skip the probe entirely. Use in offline tests / when
236
+ * the probe service is rate-limited.
237
+ *
238
+ * The probe is a single GET through wreq (using the matrix's
239
+ * `wreqPreset`, so the geo service sees the same JA4/headers as user
240
+ * traffic). 4-attempt cap, 2s per endpoint. Probe results are NOT
241
+ * cached across sessions — proxy IPs rotate.
242
+ *
243
+ * @see PLAN.md §9 (relational consistency, IP/TZ/Locale axis)
244
+ * @see tasks/0262-ip-tz-locale-exit-consistency.md
245
+ */
246
+ geoConsistency?: GeoConsistencyMode;
154
247
  }
155
248
 
156
249
  /**
@@ -172,13 +265,89 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
172
265
  // are resolved against a placeholder profile until `@mochi.js/profiles`
173
266
  // ships its first capture (phase 0.4). The matrix is bit-stable per
174
267
  // `(profile, seed)` excluding the `derivedAt` timestamp.
175
- const profile = resolveProfile(opts.profile);
176
- 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
+ // task 0272 — 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
+ }
289
+
290
+ // Task 0262 — exit-IP / TZ / locale reconciliation.
291
+ //
292
+ // Probe the apparent exit IP through the configured proxy (using wreq
293
+ // with the matrix's `wreqPreset` so the geo service sees the same JA4
294
+ // / headers as user traffic). Then cross-reference against
295
+ // `(matrix.timezone, matrix.locale)` and apply `geoConsistency`. The
296
+ // adjusted matrix flows into BOTH `spawnChromium` (so `--lang` reflects
297
+ // any override) AND `Session` (so inject + the CDP `Emulation.set
298
+ // TimezoneOverride` send pick it up). PLAN.md §9.
299
+ //
300
+ // `"off"` short-circuits the probe — the probe call itself respects
301
+ // the mode so we don't pay the network round-trip in offline tests.
302
+ const geoMode: GeoConsistencyMode = opts.geoConsistency ?? "privacy-fallback";
303
+ let adjustedMatrix = matrix;
304
+ if (geoMode !== "off") {
305
+ const geo = await probeExitGeo({
306
+ ...(normalized?.netProxy !== undefined ? { proxy: normalized.netProxy } : {}),
307
+ matrix,
308
+ });
309
+ // Strict mode throws GeoMismatchError on real mismatch; let it
310
+ // propagate up so callers can recover (the orchestrator surfaced
311
+ // it as the canonical failure mode for "wrong proxy for profile").
312
+ const result = reconcileGeoConsistency(matrix, geo, geoMode);
313
+ adjustedMatrix = result.matrix;
314
+ if (result.action === "privacy-fallback" || result.action === "auto-correct") {
315
+ console.warn(
316
+ `[mochi] geoConsistency=${geoMode}: ${result.action} applied — ${result.reason ?? "(no reason)"}`,
317
+ );
318
+ }
319
+ }
320
+
321
+ // Resolve headless dispatch BEFORE the spawn call so we can log the
322
+ // env-derived default and let the user introspect via
323
+ // `mochi.detectLinuxServerEnv()`. Task 0258 — the "Linux server, no
324
+ // DISPLAY" case is the common deployment failure mode for `mochi.launch()`,
325
+ // and the previous default (`opts.headless ?? false` → headful) crashed
326
+ // immediately on a fresh Ubuntu / Debian host because there was no display
327
+ // to attach to. We now infer `"new"` on that environment.
328
+ const linuxEnv = probeLinuxServerEnv();
329
+ const resolvedHeadlessMode = resolveHeadlessMode(opts, linuxEnv);
330
+ if (
331
+ resolvedHeadlessMode === "new" &&
332
+ opts.headlessMode === undefined &&
333
+ opts.headless === undefined
334
+ ) {
335
+ // Only chatter when the launcher had to infer (caller said nothing). An
336
+ // explicit `headlessMode: "new"` from the caller is silent — they know
337
+ // what they asked for. The container/root signals are surfaced too so
338
+ // the diagnostic is one log line, not three.
339
+ console.warn(
340
+ `[mochi] Linux server detected (no DISPLAY / WAYLAND_DISPLAY) — defaulting to ` +
341
+ `--headless=new. ${linuxEnv.rationale}. Set headlessMode: "off" to override; ` +
342
+ `see docs/getting-started/linux-server.md for the xvfb path.`,
343
+ );
344
+ }
177
345
 
178
346
  const proc = await spawnChromium({
179
347
  binary,
180
348
  extraArgs: opts.args,
181
349
  headless: opts.headless ?? false,
350
+ headlessMode: resolvedHeadlessMode,
182
351
  // Opt-out for the auto-no-sandbox-as-root fallback (default: fallback
183
352
  // is on so first-run on a Linux server box doesn't crash).
184
353
  ...(opts.allowRootWithSandbox === true ? { allowRootWithSandbox: true } : {}),
@@ -191,17 +360,26 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
191
360
  // inject layer's `navigator.languages` spoof; Chromium derives the
192
361
  // q-weighted `Accept-Language` value from the single `--lang` primary
193
362
  // automatically. Task 0251.
194
- locale: matrix.locale,
363
+ locale: adjustedMatrix.locale,
195
364
  // Pin OS-level outer window from the matrix's display geometry so
196
365
  // `window.outerWidth/outerHeight` (which reads from the OS window,
197
366
  // NOT the JS-spoofed `screen.*`) matches the spoof. Closes the
198
367
  // `fingerprint-scan.com` 800×600 leak under `--headless=new`.
199
368
  // UDC fixes the same issue at `__init__.py:410-411`. Task 0252.
200
- ...(Number.isInteger(matrix.display.width) &&
201
- Number.isInteger(matrix.display.height) &&
202
- matrix.display.width > 0 &&
203
- matrix.display.height > 0
204
- ? { windowSize: { width: matrix.display.width, height: matrix.display.height } }
369
+ //
370
+ // (`adjustedMatrix.display` === `matrix.display` since geo reconcile
371
+ // only touches timezone/locale/languages — but we use the adjusted
372
+ // ref for forward-compat.)
373
+ ...(Number.isInteger(adjustedMatrix.display.width) &&
374
+ Number.isInteger(adjustedMatrix.display.height) &&
375
+ adjustedMatrix.display.width > 0 &&
376
+ adjustedMatrix.display.height > 0
377
+ ? {
378
+ windowSize: {
379
+ width: adjustedMatrix.display.width,
380
+ height: adjustedMatrix.display.height,
381
+ },
382
+ }
205
383
  : {}),
206
384
  // Hermetic harness/CI escape hatch — re-applies the patchright-trim
207
385
  // flags (`--disable-component-update`, `--disable-default-apps`,
@@ -213,7 +391,7 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
213
391
 
214
392
  const session = new Session({
215
393
  proc,
216
- matrix,
394
+ matrix: adjustedMatrix,
217
395
  seed: opts.seed,
218
396
  ...(opts.timeout !== undefined ? { defaultTimeoutMs: opts.timeout } : {}),
219
397
  ...(opts.bypassInject === true ? { bypassInject: true } : {}),
@@ -235,12 +413,52 @@ export const mochi = {
235
413
  version: VERSION,
236
414
  /** Launch a browser session. */
237
415
  launch,
416
+ /**
417
+ * Inspect what mochi would infer about the current process environment for
418
+ * Linux-server detection (drives `headlessMode` defaulting). Pure read of
419
+ * `process.platform`, `process.env.DISPLAY`, `process.env.WAYLAND_DISPLAY`,
420
+ * `process.getuid?.()`, and the container probe paths. Task 0258.
421
+ */
422
+ detectLinuxServerEnv: probeLinuxServerEnv,
423
+ /**
424
+ * Inspect which profile id `mochi.launch` would auto-pick on the current
425
+ * host when `profile` is omitted. Pure read of `process.platform` /
426
+ * `process.arch`. Returns `null` on unsupported hosts — the launcher
427
+ * throws on that path with a list of explicit profile IDs. Task 0272.
428
+ *
429
+ * @see tasks/0271-the-linux-os-thesis.md — the strategic thesis
430
+ * @see https://mochijs.com/docs/concepts/stealth-philosophy
431
+ */
432
+ defaultProfileForHost,
238
433
  } as const;
239
434
 
240
435
  export type Mochi = typeof mochi;
241
436
 
242
437
  // ---- helpers ----------------------------------------------------------------
243
438
 
439
+ /**
440
+ * Resolve the effective {@link LaunchOptions.headlessMode} given a snapshot
441
+ * of `(opts, env)`. Pure / synchronous so tests can drive both axes without
442
+ * stubbing globals. Resolution order — task 0258:
443
+ *
444
+ * 1. Explicit `opts.headlessMode` wins.
445
+ * 2. Else legacy `opts.headless: true | false` maps to `"new"` / `"off"`.
446
+ * 3. Else env-aware default — Linux without DISPLAY / WAYLAND_DISPLAY →
447
+ * `"new"`; otherwise `"off"`.
448
+ *
449
+ * Exported so the unit tests can lock the resolution table without spawning
450
+ * a Chromium or stubbing `process.platform`.
451
+ */
452
+ export function resolveHeadlessMode(
453
+ opts: Pick<LaunchOptions, "headless" | "headlessMode">,
454
+ env: LinuxServerEnv,
455
+ ): "new" | "legacy" | "off" {
456
+ if (opts.headlessMode !== undefined) return opts.headlessMode;
457
+ if (opts.headless === true) return "new";
458
+ if (opts.headless === false) return "off";
459
+ return env.serverNoDisplay ? "new" : "off";
460
+ }
461
+
244
462
  /**
245
463
  * Reconcile the two `LaunchOptions.proxy` shapes (URL string and
246
464
  * `ProxyConfig` record) into a single normalized record carrying:
@@ -301,14 +519,56 @@ function injectAuth(server: string, auth: { username: string; password: string }
301
519
  }
302
520
 
303
521
  /**
304
- * Resolve `LaunchOptions.profile` into a concrete `ProfileV1`. Inline
305
- * profiles flow through unchanged. String profile ids until
306
- * `@mochi.js/profiles` ships (phase 0.4) resolve to a generic placeholder
307
- * stamped with the id; the consistency engine still produces a real,
308
- * relationally-locked Matrix from it.
522
+ * Resolve `LaunchOptions.profile` into a concrete `ProfileV1` plus the
523
+ * meta-flag the launcher needs to decide whether to log the auto-pick
524
+ * INFO line. Three branches:
525
+ *
526
+ * 1. Explicit `ProfileV1` object — flows through unchanged. `autoPicked`
527
+ * false; `id` taken from the inline object.
528
+ * 2. Explicit `ProfileId` string — same placeholder synthesis as before.
529
+ * `autoPicked` false.
530
+ * 3. `undefined` — task 0272: call `defaultProfileForHost()`. Throw with
531
+ * the unsupported-host diagnostic when the resolver returns `null`.
532
+ * `autoPicked` true.
533
+ *
534
+ * Pure function — does not log. The launcher emits the INFO line itself
535
+ * after observing `autoPicked === true` so test fixtures can assert the
536
+ * resolution without intercepting `console`.
537
+ */
538
+ function resolveProfileSource(profile: ProfileId | ProfileV1 | undefined): {
539
+ profile: ProfileV1;
540
+ id: ProfileId;
541
+ autoPicked: boolean;
542
+ } {
543
+ if (typeof profile === "object") {
544
+ return { profile, id: profile.id, autoPicked: false };
545
+ }
546
+ if (typeof profile === "string") {
547
+ return {
548
+ profile: synthesizePlaceholderProfile(profile),
549
+ id: profile,
550
+ autoPicked: false,
551
+ };
552
+ }
553
+ // Auto-pick branch — task 0272.
554
+ const picked = defaultProfileForHost();
555
+ if (picked === null) {
556
+ throw new Error(unsupportedHostMessage(process.platform, process.arch));
557
+ }
558
+ return {
559
+ profile: synthesizePlaceholderProfile(picked),
560
+ id: picked,
561
+ autoPicked: true,
562
+ };
563
+ }
564
+
565
+ /**
566
+ * Synthesize a generic placeholder `ProfileV1` from a profile id. Until
567
+ * `@mochi.js/profiles.getProfile` lands (phase 0.4), the consistency engine
568
+ * still produces a real, relationally-locked Matrix from this skeleton —
569
+ * the id is what flows into `sha256(profile.id + seed)`.
309
570
  */
310
- function resolveProfile(profile: ProfileId | ProfileV1): ProfileV1 {
311
- if (typeof profile === "object") return profile;
571
+ function synthesizePlaceholderProfile(profile: ProfileId): ProfileV1 {
312
572
  return {
313
573
  id: profile,
314
574
  version: "0.0.0-placeholder",
@@ -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
+ }