@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/README.md +19 -10
- package/package.json +4 -4
- package/src/__tests__/cookies-jar.test.ts +361 -0
- package/src/__tests__/default-profile.test.ts +181 -0
- package/src/__tests__/dx-cluster.e2e.test.ts +245 -0
- package/src/__tests__/geo-consistency.test.ts +277 -0
- package/src/__tests__/geo-probe.test.ts +415 -0
- package/src/__tests__/init-injector.e2e.test.ts +144 -0
- package/src/__tests__/init-injector.test.ts +249 -0
- package/src/__tests__/inject.test.ts +80 -162
- package/src/__tests__/integration.e2e.test.ts +24 -0
- package/src/__tests__/page-dx-cluster.test.ts +292 -0
- package/src/__tests__/proc-linux-server.test.ts +243 -0
- package/src/__tests__/proxy-auth.test.ts +22 -55
- package/src/__tests__/screenshot.e2e.test.ts +126 -0
- package/src/__tests__/screenshot.test.ts +363 -0
- package/src/cdp/init-injector.ts +644 -0
- package/src/default-profile.ts +112 -0
- package/src/geo-consistency.ts +343 -0
- package/src/geo-probe.ts +603 -0
- package/src/index.ts +43 -1
- package/src/launch.ts +277 -17
- package/src/linux-server.ts +157 -0
- package/src/page.ts +420 -9
- package/src/proc.ts +48 -5
- package/src/proxy-auth.ts +26 -107
- package/src/session.ts +595 -78
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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:
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
|
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
|
+
}
|