@mochi.js/core 0.3.0 → 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.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Auto-pick the host-OS-matching profile when `LaunchOptions.profile` is
3
+ * omitted (task 0272). The function below is the pure decision table the
4
+ * launcher consults; tests stub `(platform, arch)` and assert the mapping
5
+ * without spinning a Chromium.
6
+ *
7
+ * ## Why
8
+ *
9
+ * Task 0271 documents the strategic thesis: spoofing Windows from a Linux
10
+ * server is the wrong default. Linux is a real-user signal, not a bot
11
+ * signal. WAFs trained on real traffic do not penalize Linux UAs because
12
+ * Linux desktops are massively overrepresented in high-LTV segments
13
+ * (developers, engineers, researchers). The signal was always
14
+ * `HeadlessChrome`, never Linux.
15
+ *
16
+ * Lifting host-OS-matching from "user types `profile: 'linux-chrome-stable'`
17
+ * by hand" into a default removes the entire class of "user accidentally
18
+ * spoofed Windows from a Linux DC and looked weird to the WAF" failures —
19
+ * the same argument that drove `detectLinuxServerEnv` for headless mode in
20
+ * task 0259.
21
+ *
22
+ * ## Mapping
23
+ *
24
+ * The host pairs `(process.platform, process.arch)` we currently support:
25
+ *
26
+ * - `linux/x64` → `linux-chrome-stable`
27
+ * - `darwin/arm64` → `mac-m4-chrome-stable`
28
+ * - `darwin/x64` → `mac-chrome-stable`
29
+ * - `win32/x64` → `windows-chrome-stable`
30
+ *
31
+ * Everything else (linux/arm64, freebsd, alpine-musl detection, win32/arm64,
32
+ * etc.) returns `null` — the launcher then throws with a precise diagnostic
33
+ * listing the six explicit profile IDs and a pointer to the
34
+ * choose-your-profile guide. We never silently fall back to a placeholder.
35
+ *
36
+ * ## Caveat — darwin/x64
37
+ *
38
+ * The current profile catalog (`packages/profiles/data/`) ships
39
+ * `mac-chrome-stable` as a darwin/arm64 capture (its `os.arch === "arm64"`
40
+ * in `profile.json`). The mapping above still routes darwin/x64 to
41
+ * `mac-chrome-stable` per task 0272's success criteria; users on Intel Macs
42
+ * who want a strict arch match should pass `profile` explicitly until an
43
+ * `mac-intel-chrome-stable` capture lands.
44
+ *
45
+ * @see tasks/0271-the-linux-os-thesis.md — the strategic thesis + evidence
46
+ * @see tasks/0272-host-os-profile-auto-default.md — engineering brief
47
+ */
48
+
49
+ import type { ProfileId } from "./launch";
50
+
51
+ /**
52
+ * Pure decision table: given the current host's `(process.platform,
53
+ * process.arch)` pair, return the profile id that best matches the host
54
+ * OS axis. Returns `null` for unsupported hosts so the launcher can throw
55
+ * with a precise diagnostic.
56
+ *
57
+ * No I/O, no logging — call sites can introspect the value cheaply (e.g.
58
+ * `console.log(mochi.defaultProfileForHost())`).
59
+ */
60
+ export function defaultProfileForHost(): ProfileId | null {
61
+ return resolveDefaultProfileForHost(process.platform, process.arch);
62
+ }
63
+
64
+ /**
65
+ * Internal pure resolver, exposed so the unit tests can drive the table
66
+ * without stubbing global `process`. Mirrors the precedence-table style of
67
+ * `resolveHeadlessMode` (task 0258).
68
+ *
69
+ * @internal
70
+ */
71
+ export function resolveDefaultProfileForHost(
72
+ platform: NodeJS.Platform,
73
+ arch: string,
74
+ ): ProfileId | null {
75
+ if (platform === "linux" && arch === "x64") return "linux-chrome-stable";
76
+ if (platform === "darwin" && arch === "arm64") return "mac-m4-chrome-stable";
77
+ if (platform === "darwin" && arch === "x64") return "mac-chrome-stable";
78
+ if (platform === "win32" && arch === "x64") return "windows-chrome-stable";
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * The six real-device profile IDs that `defaultProfileForHost` can return,
84
+ * surfaced by the launcher's failure-mode diagnostic. Order matches the
85
+ * task 0272 brief verbatim so the user-facing message is stable.
86
+ *
87
+ * @internal
88
+ */
89
+ export const EXPLICIT_PROFILE_IDS = [
90
+ "mac-m4-chrome-stable",
91
+ "mac-chrome-stable",
92
+ "mac-chrome-beta",
93
+ "windows-chrome-stable",
94
+ "linux-chrome-stable",
95
+ "mac-brave-stable",
96
+ ] as const satisfies readonly ProfileId[];
97
+
98
+ /**
99
+ * Build the precise diagnostic emitted when `profile` is omitted on an
100
+ * unsupported host. Format pinned by task 0272 — keep stable so docs +
101
+ * LLM-context blocks stay correct.
102
+ *
103
+ * @internal
104
+ */
105
+ export function unsupportedHostMessage(platform: NodeJS.Platform, arch: string): string {
106
+ const list = EXPLICIT_PROFILE_IDS.map((id) => ` - ${id}`).join("\n");
107
+ return (
108
+ `[mochi] launch: no profile supplied and no host-matching default for ` +
109
+ `platform=${platform} arch=${arch}. Pick one explicitly:\n${list}\n` +
110
+ `See https://mochijs.com/docs/guides/choose-your-profile for the decision aid.`
111
+ );
112
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,13 @@ export {
19
19
  type SendOptions,
20
20
  type Unsubscribe,
21
21
  } from "./cdp/router";
22
+ // Auto-pick host-OS-matching profile when `LaunchOptions.profile` is omitted
23
+ // (task 0272 — paired with the strategic thesis in task 0271).
24
+ export {
25
+ defaultProfileForHost,
26
+ EXPLICIT_PROFILE_IDS,
27
+ resolveDefaultProfileForHost,
28
+ } from "./default-profile";
22
29
  // Error surface.
23
30
  export { NotImplementedError } from "./errors";
24
31
  // Exit-IP / TZ / locale reconciliation (task 0262, PLAN.md §9).
@@ -40,16 +47,33 @@ export {
40
47
  mochi,
41
48
  type ProfileId,
42
49
  type ProxyConfig,
50
+ resolveHeadlessMode,
43
51
  } from "./launch";
52
+ // Linux-server environment detection. Pure helpers for users who want to
53
+ // introspect what mochi inferred (and override `headlessMode` from there).
54
+ // Task 0258 — `mochi.detectLinuxServerEnv()` calls `probeLinuxServerEnv`.
55
+ export {
56
+ detectLinuxServerEnv,
57
+ type LinuxServerEnv,
58
+ type LinuxServerProbes,
59
+ probeLinuxServerEnv,
60
+ snapshotProbes,
61
+ } from "./linux-server";
44
62
  export {
63
+ ALL_BROWSER_PERMISSIONS,
64
+ type BrowserPermission,
45
65
  type Cookie,
66
+ type DomStorage,
67
+ type DomStorageOptions,
46
68
  type GotoOptions,
69
+ type GrantAllPermissionsOptions,
47
70
  type HumanClickOptions,
48
71
  type HumanMoveOptions,
49
72
  type HumanScrollOptions,
50
73
  type HumanTypeOptions,
51
74
  Page,
52
75
  type PageInit,
76
+ type ScreenshotOptions,
53
77
  type WaitForOptions,
54
78
  type WaitState,
55
79
  type WaitUntil,
@@ -58,5 +82,13 @@ export { ElementHandle, type ElementHandleInit } from "./page/element-handle";
58
82
  // Proxy URL parsing — exported so tests + downstream tools can normalize
59
83
  // proxy strings without going through `launch()`.
60
84
  export { type ParsedProxy, parseProxyUrl } from "./proxy-auth";
61
- export { Session, type SessionInit, type StorageSnapshot } from "./session";
85
+ export {
86
+ COOKIE_JAR_FORMAT_VERSION,
87
+ type CookieJar,
88
+ type CookieJarFile,
89
+ type CookieJarOptions,
90
+ Session,
91
+ type SessionInit,
92
+ type StorageSnapshot,
93
+ } from "./session";
62
94
  export { VERSION } from "./version";
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 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;
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 (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
+ */
91
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";
92
154
  binary?: string;
93
155
  args?: string[];
94
156
  out?: { traceDir?: string };
@@ -203,8 +265,27 @@ 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
+ // 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
+ }
208
289
 
209
290
  // Task 0262 — exit-IP / TZ / locale reconciliation.
210
291
  //
@@ -237,10 +318,36 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
237
318
  }
238
319
  }
239
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
+ }
345
+
240
346
  const proc = await spawnChromium({
241
347
  binary,
242
348
  extraArgs: opts.args,
243
349
  headless: opts.headless ?? false,
350
+ headlessMode: resolvedHeadlessMode,
244
351
  // Opt-out for the auto-no-sandbox-as-root fallback (default: fallback
245
352
  // is on so first-run on a Linux server box doesn't crash).
246
353
  ...(opts.allowRootWithSandbox === true ? { allowRootWithSandbox: true } : {}),
@@ -306,12 +413,52 @@ export const mochi = {
306
413
  version: VERSION,
307
414
  /** Launch a browser session. */
308
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,
309
433
  } as const;
310
434
 
311
435
  export type Mochi = typeof mochi;
312
436
 
313
437
  // ---- helpers ----------------------------------------------------------------
314
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
+
315
462
  /**
316
463
  * Reconcile the two `LaunchOptions.proxy` shapes (URL string and
317
464
  * `ProxyConfig` record) into a single normalized record carrying:
@@ -372,14 +519,56 @@ function injectAuth(server: string, auth: { username: string; password: string }
372
519
  }
373
520
 
374
521
  /**
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.
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)`.
380
570
  */
381
- function resolveProfile(profile: ProfileId | ProfileV1): ProfileV1 {
382
- if (typeof profile === "object") return profile;
571
+ function synthesizePlaceholderProfile(profile: ProfileId): ProfileV1 {
383
572
  return {
384
573
  id: profile,
385
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
+ }