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