@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.
- 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__/init-injector.e2e.test.ts +144 -0
- package/src/__tests__/init-injector.test.ts +249 -0
- package/src/__tests__/inject.test.ts +80 -164
- 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/index.ts +33 -1
- package/src/launch.ts +199 -10
- package/src/linux-server.ts +157 -0
- package/src/page.ts +410 -8
- package/src/proc.ts +48 -5
- package/src/proxy-auth.ts +26 -107
- package/src/session.ts +367 -68
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
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
|
|
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
|
+
}
|