@mochi.js/core 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/proc.ts CHANGED
@@ -13,9 +13,45 @@ import { join } from "node:path";
13
13
  import type { PipeReader, PipeWriter } from "./cdp/transport";
14
14
 
15
15
  /**
16
- * The chromium flags PLAN.md §8.6 mandates we always pass. Order does not
17
- * matter; Chromium accepts late-arriving overrides for most flags but we
18
- * never override these.
16
+ * The chromium flags PLAN.md §8.6 mandates we always pass in PRODUCTION
17
+ * (non-hermetic) mode. Trimmed against patchright's
18
+ * `chromiumSwitchesPatch.ts:20-34` removal list (task 0256): every flag
19
+ * here passes two tests — (a) it isn't a passive command-line bot-tell that
20
+ * patchright explicitly drops, AND (b) we have a concrete production reason
21
+ * to keep it (CDP transport, UI suppression that matters in headed mode,
22
+ * keychain/keyring, or load-bearing for inject reach).
23
+ *
24
+ * Flags moved to {@link HERMETIC_ONLY_CHROMIUM_FLAGS} (re-applied when
25
+ * `LaunchOptions.hermetic === true`):
26
+ * - `--disable-component-update` — patchright drops; cmdline tell.
27
+ * - `--disable-default-apps` — patchright drops; cmdline tell.
28
+ * - `--disable-background-networking` — patchright drops; updater-traffic suppressor.
29
+ * - `--disable-sync` — patchright drops; cmdline tell.
30
+ * - `--disable-features` extras — `OptimizationHints,MediaRouter,
31
+ * InterestFeedContentSuggestions,CalculateNativeWinOcclusion` are
32
+ * network/noise suppressors valid only for hermetic harness/CI runs;
33
+ * real users want the natural network surface so the production list
34
+ * keeps just the load-bearing entries.
35
+ *
36
+ * Production `--disable-features=` keepers + rationale:
37
+ * - `Translate` — suppresses the translate-prompt UI bar that
38
+ * would surface in headed mode.
39
+ * - `AcceptCHFrame` — keeps UA-CH negotiation off the frame path
40
+ * so our `Sec-CH-UA` headers (R-007) stay the
41
+ * single source of truth.
42
+ * - `IsolateOrigins,site-per-process` — load-bearing for inject reach:
43
+ * mochi doesn't yet resolve cross-origin OOPIF
44
+ * contexts, so disabling site isolation keeps
45
+ * cross-origin frames in the same renderer
46
+ * process where `addScriptToEvaluateOnNewDocument`
47
+ * actually runs.
48
+ *
49
+ * Order does not matter; Chromium accepts late-arriving overrides for most
50
+ * flags but we never override these.
51
+ *
52
+ * @see PLAN.md §8.6 (decision ledger).
53
+ * @see docs/audits/patchright.md MED finding (chromiumSwitchesPatch.ts:20-34).
54
+ * @see docs/audits/puppeteer-real-browser.md LOW finding (lib/cjs/index.js:57-58).
19
55
  */
20
56
  export const DEFAULT_CHROMIUM_FLAGS: readonly string[] = [
21
57
  "--remote-debugging-pipe",
@@ -24,13 +60,41 @@ export const DEFAULT_CHROMIUM_FLAGS: readonly string[] = [
24
60
  "--no-service-autorun",
25
61
  "--password-store=basic",
26
62
  "--use-mock-keychain",
63
+ "--disable-features=Translate,AcceptCHFrame,IsolateOrigins,site-per-process",
64
+ "--enable-features=NetworkService,NetworkServiceInProcess",
65
+ ];
66
+
67
+ /**
68
+ * Flags re-applied on top of {@link DEFAULT_CHROMIUM_FLAGS} when
69
+ * `LaunchOptions.hermetic === true`. The harness fixture matrix, CI runs,
70
+ * and capture flows pair `bypassInject: true` with `hermetic: true` so
71
+ * baseline collection isn't perturbed by updater traffic, default-apps
72
+ * auto-install, sync, or feed prefetches.
73
+ *
74
+ * Production users (the non-hermetic default) get a clean production flag
75
+ * set: no obvious cmdline tells, normal-looking updater + sync traffic.
76
+ *
77
+ * Each entry here was either explicitly removed by patchright as a passive
78
+ * bot-tell (`--disable-component-update`, `--disable-default-apps`,
79
+ * `--disable-background-networking`, `--disable-sync`) or is a noise-
80
+ * reduction `--disable-features=` token whose suppression is desirable for
81
+ * hermetic determinism but undesirable for production stealth.
82
+ *
83
+ * The hermetic `--disable-features=` token is appended SEPARATELY from the
84
+ * production one — Chromium merges multiple `--disable-features=` flags on
85
+ * the command line into a union, so the final disabled set is
86
+ * `{Translate,AcceptCHFrame,IsolateOrigins,site-per-process} ∪
87
+ * {OptimizationHints,MediaRouter,InterestFeedContentSuggestions,
88
+ * CalculateNativeWinOcclusion}`. Keeping them separate makes the
89
+ * production-only subset legible and avoids fingerprintable list-order
90
+ * coincidence with Playwright defaults.
91
+ */
92
+ export const HERMETIC_ONLY_CHROMIUM_FLAGS: readonly string[] = [
27
93
  "--disable-default-apps",
28
94
  "--disable-component-update",
29
- // Single comma-joined --disable-features flag (Chromium accepts comma list).
30
- "--disable-features=Translate,OptimizationHints,MediaRouter,AcceptCHFrame,InterestFeedContentSuggestions,CalculateNativeWinOcclusion,IsolateOrigins,site-per-process",
31
- "--enable-features=NetworkService,NetworkServiceInProcess",
32
95
  "--disable-background-networking",
33
96
  "--disable-sync",
97
+ "--disable-features=OptimizationHints,MediaRouter,InterestFeedContentSuggestions,CalculateNativeWinOcclusion",
34
98
  ];
35
99
 
36
100
  const SIGTERM_GRACE_MS = 2000;
@@ -48,8 +112,82 @@ export interface SpawnConfig {
48
112
  headless: boolean;
49
113
  /** Optional proxy server, e.g. "http://host:port" or "socks5://host:port". */
50
114
  proxy?: string;
115
+ /**
116
+ * Opt out of mochi's "auto-add `--no-sandbox` when running as root on Linux"
117
+ * fallback. Chromium refuses to launch as root with the user-namespace
118
+ * sandbox enabled; mochi normally injects `--no-sandbox` (with a warning)
119
+ * so the launch succeeds. Set to `true` if you have a working
120
+ * `chrome-sandbox` SUID helper and want to keep the sandbox under root —
121
+ * the launch will then crash with the original `EPIPE` if the SUID setup
122
+ * is wrong. PLAN.md §8.6 + `docs/quickstart.md` "Linux gotcha — Chromium
123
+ * and root".
124
+ */
125
+ allowRootWithSandbox?: boolean;
126
+ /**
127
+ * Primary BCP-47 locale for the spawned Chromium. Passed as `--lang=<value>`
128
+ * so Chromium's network stack derives an `Accept-Language` header that
129
+ * agrees with the JS-layer `navigator.language(s)` spoof. Without this,
130
+ * Chromium falls back to the host OS locale (or `en-US,en;q=0.9`), which a
131
+ * site can cross-reference against `navigator.languages` to detect the
132
+ * mismatch — direct PLAN.md I-5 violation.
133
+ *
134
+ * Sourced from `MatrixV1.locale` (the canonical primary BCP-47 string,
135
+ * e.g. `"en-US"`). Multi-locale `Accept-Language` q-weighting is derived
136
+ * by Chromium itself from this single primary; the broader list is
137
+ * surfaced separately via the JS-side `navigator.languages` spoof.
138
+ *
139
+ * Honored under `--headless=new` — the flag drives `ICU::Locale::Default`
140
+ * and `IOThread::Globals::system_request_context_->set_accept_language()`,
141
+ * both of which run regardless of headless mode.
142
+ *
143
+ * Source-cited from undetected-chromedriver `__init__.py:359-369` (which
144
+ * falls back to `locale.getdefaultlocale()` → `en-US`); we deliberately
145
+ * do NOT fall back to host locale — locale must come from the matrix.
146
+ */
147
+ locale?: string;
148
+ /**
149
+ * Outer window geometry to pin via `--window-size=<width>,<height>`. When
150
+ * supplied, Chromium's OS-level outer-window dimensions match the spoofed
151
+ * `screen.*` so `window.outerWidth/outerHeight` (read at the OS level
152
+ * under `--headless=new`) don't expose the default 800×600 leak that
153
+ * `fingerprint-scan.com` flags. Both dimensions must be finite positive
154
+ * integers; otherwise the flag is omitted. Sourced from
155
+ * `matrix.display.{width,height}` by `launch.ts` — the matrix is canonical.
156
+ *
157
+ * @see UDC `__init__.py:410-411`, UDC issue #2242, task 0252.
158
+ */
159
+ windowSize?: { width: number; height: number };
160
+ /**
161
+ * When `true`, re-apply {@link HERMETIC_ONLY_CHROMIUM_FLAGS} on top of
162
+ * {@link DEFAULT_CHROMIUM_FLAGS}. Used by the harness, CI, and
163
+ * `mochi capture` flows where update-checks, sync traffic, default-apps
164
+ * auto-install, and feed prefetches would inject non-determinism.
165
+ *
166
+ * Defaults to `false` (production posture). Production users get the
167
+ * cleaner flag set without obvious command-line bot-tells.
168
+ *
169
+ * Sourced from `LaunchOptions.hermetic` (see `launch.ts`). Pairs with
170
+ * `bypassInject: true` for capture flows but is independent — a hermetic
171
+ * launch with full inject is the harness's fingerprint-conformance run.
172
+ *
173
+ * @see task 0256, PLAN.md §8.6.
174
+ */
175
+ hermetic?: boolean;
51
176
  }
52
177
 
178
+ /**
179
+ * Flags we deliberately strip from any user-supplied extra args. UDC ships
180
+ * with `--start-maximized`; mochi must not — it produces host-OS-dependent
181
+ * geometry that drifts from the matrix's `display.*` spoof and re-introduces
182
+ * the same outer-window mismatch `--window-size` is here to close.
183
+ *
184
+ * Applied to `extraArgs` and to the `MOCHI_EXTRA_ARGS` env split so users /
185
+ * CI cannot accidentally re-introduce non-determinism.
186
+ *
187
+ * @see task 0252 success criterion #3.
188
+ */
189
+ const FORBIDDEN_FLAG_PREFIXES: readonly string[] = ["--start-maximized"];
190
+
53
191
  /**
54
192
  * The handle returned by {@link spawnChromium}. Owns the user-data-dir, the
55
193
  * subprocess, and the BunFile FD wrappers used by the CDP transport.
@@ -73,43 +211,47 @@ export interface ChromiumProcess {
73
211
  }
74
212
 
75
213
  /**
76
- * Spawn Chromium with `--remote-debugging-pipe` and the standard flag set.
77
- *
78
- * Pipe FD convention (Chromium CDP pipe spec, matches Puppeteer / Playwright):
79
- * - FD 3 in the *child* is the read end. The parent writes commands to it.
80
- * - FD 4 in the *child* is the write end. The parent reads responses from it.
214
+ * Build the full Chromium arg vector for a given spawn config + user-data-dir.
81
215
  *
82
- * Note: task brief 0011 has the FD direction labels reversed; we follow
83
- * Chromium's actual convention here so the protocol works. Either way Bun's
84
- * `stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"]` allocates two extra pipes
85
- * and gives us back numeric FDs at `proc.stdio[3]` and `proc.stdio[4]`.
216
+ * Pure / synchronous so the launcher can unit-test the flag set without
217
+ * spawning a real process. Order of pushes is documented in line — the only
218
+ * load-bearing ordering is `--lang` BEFORE `extraArgs` so a deliberate
219
+ * user-supplied `--lang=<override>` in `args` wins (Chromium honors last
220
+ * occurrence on the command line for this flag).
86
221
  */
87
222
  export async function spawnChromium(cfg: SpawnConfig): Promise<ChromiumProcess> {
88
223
  const userDataDir = await mkdtemp(join(tmpdir(), "mochi-"));
89
-
90
- const args: string[] = [`--user-data-dir=${userDataDir}`, ...DEFAULT_CHROMIUM_FLAGS];
91
- if (cfg.headless) {
92
- // Modern headless mode (matches stable Chrome behavior more closely than
93
- // legacy --headless). The `=new` is critical — old `--headless` is
94
- // detectable.
95
- args.push("--headless=new");
96
- }
97
- if (cfg.proxy !== undefined && cfg.proxy.length > 0) {
98
- args.push(`--proxy-server=${cfg.proxy}`);
99
- }
100
- if (cfg.extraArgs !== undefined && cfg.extraArgs.length > 0) {
101
- args.push(...cfg.extraArgs);
102
- }
103
- // Whitespace-separated extra args from the environment. Same effect as
104
- // `LaunchOptions.args` but settable from outside the calling code — load-
105
- // bearing for CI environments that need `--no-sandbox` (Linux user-namespace
106
- // sandbox doesn't work in unprivileged containers / GH Actions runners) and
107
- // for ad-hoc local debugging without touching test fixtures. Production code
108
- // SHOULD NOT set this — `--no-sandbox` is a fingerprint leak in real-user
109
- // contexts. PLAN.md §8.6 explicitly omits it from DEFAULT_CHROMIUM_FLAGS.
110
224
  const envExtra = process.env.MOCHI_EXTRA_ARGS;
111
- if (typeof envExtra === "string" && envExtra.trim().length > 0) {
112
- args.push(...envExtra.trim().split(/\s+/));
225
+ const args = buildChromiumArgs(cfg, userDataDir, envExtra);
226
+
227
+ // Linux + uid 0 (root) + no `--no-sandbox` anywhere → Chromium will refuse
228
+ // to start with the user-namespace sandbox. We auto-inject `--no-sandbox`
229
+ // (with a one-line warning naming the fingerprint trade-off) instead of
230
+ // letting `spawnChromium` crash with `EPIPE`. Users who explicitly want
231
+ // the sandbox under root can either run as a non-root user, `chmod 4755`
232
+ // the chrome-sandbox SUID helper, or pass their own `--no-sandbox` (which
233
+ // we'd see in args and skip this branch).
234
+ //
235
+ // We DO NOT add `--no-sandbox` to DEFAULT_CHROMIUM_FLAGS (PLAN.md §8.6
236
+ // explicitly omits it as a fingerprint leak). This is a runtime fallback,
237
+ // not a default — only fires under the specific environment that would
238
+ // otherwise crash. The fingerprint-leak risk is documented in
239
+ // docs/quickstart.md "Linux gotcha — Chromium and root".
240
+ if (
241
+ process.platform === "linux" &&
242
+ process.getuid?.() === 0 &&
243
+ !args.some((a) => a === "--no-sandbox" || a.startsWith("--no-sandbox=")) &&
244
+ !cfg.allowRootWithSandbox
245
+ ) {
246
+ console.warn(
247
+ "[mochi] Detected root + Linux + missing --no-sandbox. " +
248
+ "Auto-adding --no-sandbox so Chromium can launch. " +
249
+ "This is a fingerprint leak per PLAN.md §8.6 — run as non-root or " +
250
+ "use the chrome-sandbox SUID helper for stealth-critical workloads. " +
251
+ "See docs/quickstart.md 'Linux gotcha — Chromium and root'. " +
252
+ "Pass `allowRootWithSandbox: true` to mochi.launch() to opt out of this fallback.",
253
+ );
254
+ args.push("--no-sandbox");
113
255
  }
114
256
 
115
257
  const proc = Bun.spawn([cfg.binary, ...args], {
@@ -129,11 +271,35 @@ export async function spawnChromium(cfg: SpawnConfig): Promise<ChromiumProcess>
129
271
  );
130
272
  }
131
273
 
132
- // Drain stderr so Chromium doesn't block writing diagnostics. We don't read
133
- // it (yet); piping to /dev/null keeps the buffer empty.
134
- void drainToVoid(proc.stderr);
274
+ // Drain stderr so Chromium doesn't block writing diagnostics. We capture
275
+ // the tail (last ~4KB) so the early-exit diagnostic below has something
276
+ // human-readable to surface — e.g. Chromium's own
277
+ // "Running as root without --no-sandbox is not supported" message.
278
+ const stderrTail: string[] = [];
279
+ void drainToText(proc.stderr, stderrTail);
135
280
  void drainToVoid(proc.stdout);
136
281
 
282
+ // Diagnose early process death: Chromium that dies within ~750ms of spawn
283
+ // is almost always failing on a startup precondition (sandbox refusal under
284
+ // root, missing libs, malformed flags). We watch `proc.exited` race with
285
+ // a short timer and surface a clearer error than the eventual EPIPE on the
286
+ // first CDP write. See docs/quickstart.md "Linux gotcha — Chromium and root".
287
+ const earlyExitCode = await Promise.race([
288
+ proc.exited.then((c) => ({ kind: "exited" as const, code: c })),
289
+ new Promise<{ kind: "alive" }>((resolve) => setTimeout(() => resolve({ kind: "alive" }), 750)),
290
+ ]);
291
+ if (earlyExitCode.kind === "exited") {
292
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
293
+ const tail = stderrTail.join("").trim().split("\n").slice(-12).join("\n");
294
+ throw new Error(
295
+ `[mochi] Chromium exited (code ${earlyExitCode.code}) within 750ms of spawn — ` +
296
+ "the CDP pipe never opened. Most likely a startup precondition failure " +
297
+ "(sandbox refusal, missing libs, malformed flags).\n\n" +
298
+ `Stderr tail:\n${tail || "(empty)"}` +
299
+ diagnoseEarlyExitTail(tail),
300
+ );
301
+ }
302
+
137
303
  // Build PipeReader/PipeWriter wrappers around the raw FDs.
138
304
  const writer: PipeWriter = (() => {
139
305
  const sink = Bun.file(writeFd).writer();
@@ -196,6 +362,148 @@ export async function spawnChromium(cfg: SpawnConfig): Promise<ChromiumProcess>
196
362
  };
197
363
  }
198
364
 
365
+ /**
366
+ * Pure builder for the Chromium argv used by {@link spawnChromium}. Extracted
367
+ * so tests can assert flag composition (window-size, headless, forbidden-flag
368
+ * scrub, env extras) without spawning a real binary.
369
+ *
370
+ * @param cfg — the {@link SpawnConfig} the caller passed.
371
+ * @param userDataDir — absolute path to the ephemeral profile dir.
372
+ * @param envExtra — value of `MOCHI_EXTRA_ARGS` (pass `process.env.MOCHI_EXTRA_ARGS`
373
+ * in production; tests pass a string or `undefined`).
374
+ */
375
+ export function buildChromiumArgs(
376
+ cfg: SpawnConfig,
377
+ userDataDir: string,
378
+ envExtra: string | undefined,
379
+ ): string[] {
380
+ const args: string[] = [`--user-data-dir=${userDataDir}`, ...DEFAULT_CHROMIUM_FLAGS];
381
+ // Hermetic harness/CI escape hatch: re-apply the trim-list flags Chromium
382
+ // would otherwise leak as passive bot-tells. Inserted directly after the
383
+ // production defaults so the relative order is `defaults → hermetic-extras
384
+ // → headless → proxy → lang → window-size → extras → env-extras` — i.e. a
385
+ // user-supplied `--disable-features=…` in `extraArgs` still wins by virtue
386
+ // of Chromium's last-occurrence semantics for repeated `--disable-features`
387
+ // tokens (which are merged, not overwritten — but ordering matters for
388
+ // tooling that greps argv).
389
+ if (cfg.hermetic === true) {
390
+ args.push(...HERMETIC_ONLY_CHROMIUM_FLAGS);
391
+ }
392
+ if (cfg.headless) {
393
+ // Modern headless mode (matches stable Chrome behavior more closely than
394
+ // legacy --headless). The `=new` is critical — old `--headless` is
395
+ // detectable.
396
+ args.push("--headless=new");
397
+ }
398
+ if (cfg.proxy !== undefined && cfg.proxy.length > 0) {
399
+ args.push(`--proxy-server=${cfg.proxy}`);
400
+ }
401
+ // Matrix-derived primary locale — feeds Chromium's `Accept-Language`
402
+ // header so the network surface matches the JS-layer `navigator.language`
403
+ // spoof (PLAN.md I-5). Pushed BEFORE `extraArgs` so a user-supplied
404
+ // override in `args` can win on the command line if absolutely needed —
405
+ // Chromium honors the last-occurrence on the line for `--lang`. Task 0251.
406
+ if (cfg.locale !== undefined && cfg.locale.length > 0) {
407
+ args.push(`--lang=${cfg.locale}`);
408
+ }
409
+ // `--window-size=<W>,<H>` — pin the OS-level outer window so
410
+ // `window.outerWidth/outerHeight` match `matrix.display.*` instead of
411
+ // Chromium's headless 800×600 default. The matrix is canonical: when
412
+ // `display.{width,height}` is missing or non-finite we omit the flag
413
+ // rather than fall back to a hardcoded value (a hardcoded value would
414
+ // mismatch a profile that legitimately uses different dimensions). Task 0252.
415
+ if (cfg.windowSize !== undefined) {
416
+ const { width, height } = cfg.windowSize;
417
+ if (
418
+ Number.isFinite(width) &&
419
+ Number.isFinite(height) &&
420
+ Number.isInteger(width) &&
421
+ Number.isInteger(height) &&
422
+ width > 0 &&
423
+ height > 0
424
+ ) {
425
+ args.push(`--window-size=${width},${height}`);
426
+ }
427
+ }
428
+ if (cfg.extraArgs !== undefined && cfg.extraArgs.length > 0) {
429
+ args.push(...stripForbiddenFlags(cfg.extraArgs));
430
+ }
431
+ // Whitespace-separated extra args from the environment. Same effect as
432
+ // `LaunchOptions.args` but settable from outside the calling code — load-
433
+ // bearing for CI environments that need `--no-sandbox` (Linux user-namespace
434
+ // sandbox doesn't work in unprivileged containers / GH Actions runners) and
435
+ // for ad-hoc local debugging without touching test fixtures. Production code
436
+ // SHOULD NOT set this — `--no-sandbox` is a fingerprint leak in real-user
437
+ // contexts. PLAN.md §8.6 explicitly omits it from DEFAULT_CHROMIUM_FLAGS.
438
+ if (typeof envExtra === "string" && envExtra.trim().length > 0) {
439
+ args.push(...stripForbiddenFlags(envExtra.trim().split(/\s+/)));
440
+ }
441
+ return args;
442
+ }
443
+
444
+ /**
445
+ * Heuristic-classify a stderr tail from a Chromium that died within 750ms of
446
+ * spawn and emit a remediation hint. Two patterns matter today:
447
+ *
448
+ * 1. "Running as root without --no-sandbox is not supported" — the user-
449
+ * namespace sandbox refusal under root. Fixes: non-root, SUID helper,
450
+ * or `--no-sandbox` (with the documented fingerprint cost).
451
+ * 2. "error while loading shared libraries: <name>.so" — fresh Linux server
452
+ * without the Chromium runtime deps. Fix: apt-install the canonical dep
453
+ * list (full bytes live in `@mochi.js/cli/src/lib/linux-deps.ts` — we
454
+ * keep just a short pointer here because @mochi.js/core cannot depend on
455
+ * @mochi.js/cli without inverting the package graph).
456
+ *
457
+ * Returns the empty string when no pattern matches, in which case the caller
458
+ * surfaces only the raw stderr tail. Exported for unit tests so we can lock
459
+ * the regexes against regressions without spawning Chromium.
460
+ *
461
+ * @see tasks/0259-linux-first-run-experience.md
462
+ */
463
+ export function diagnoseEarlyExitTail(tail: string): string {
464
+ if (/running.*root.*without.*--no-sandbox|--no-sandbox.*required/i.test(tail)) {
465
+ return (
466
+ "\n\nChromium refuses to start as root with the user-namespace sandbox enabled.\n" +
467
+ "Fixes (preferred → workaround):\n" +
468
+ " 1. Run as a non-root user.\n" +
469
+ " 2. `chmod 4755 chrome-sandbox` on the SUID helper next to the CfT binary.\n" +
470
+ " 3. Pass args: ['--no-sandbox'] to mochi.launch() — fingerprint leak (PLAN §8.6),\n" +
471
+ " OK for testing, not for stealth-critical production."
472
+ );
473
+ }
474
+ const libMatch = /error while loading shared libraries:\s+([^\s:]+)/i.exec(tail);
475
+ if (libMatch !== null) {
476
+ const lib = libMatch[1] ?? "(unknown .so)";
477
+ return (
478
+ `\n\nChromium failed to load a system library: '${lib}'.\n` +
479
+ "Chromium-for-Testing ships only the binary; on a fresh Linux server the\n" +
480
+ "system libs Chromium links against are not preinstalled. Install the\n" +
481
+ "canonical dep list with apt:\n\n" +
482
+ " bunx mochi browsers install # re-run; the install command prints the\n" +
483
+ " # exact apt line for your system.\n\n" +
484
+ "Or install directly — full list at\n" +
485
+ " https://mochijs.com/docs/getting-started/install#linux-runtime-dependencies"
486
+ );
487
+ }
488
+ return "";
489
+ }
490
+
491
+ /**
492
+ * Drop any flag in `args` whose prefix matches {@link FORBIDDEN_FLAG_PREFIXES}.
493
+ * Match is `=` / boundary-aware so `--start-maximized` and
494
+ * `--start-maximized=1` both go, but `--start-maximized-foo` (hypothetical)
495
+ * would not. Preserves order of surviving args.
496
+ */
497
+ function stripForbiddenFlags(args: readonly string[]): string[] {
498
+ return args.filter((arg) => {
499
+ for (const prefix of FORBIDDEN_FLAG_PREFIXES) {
500
+ if (arg === prefix) return false;
501
+ if (arg.startsWith(`${prefix}=`)) return false;
502
+ }
503
+ return true;
504
+ });
505
+ }
506
+
199
507
  /** Read-and-discard a ReadableStream so Chromium's pipe buffers don't fill. */
200
508
  async function drainToVoid(stream: ReadableStream<Uint8Array> | null): Promise<void> {
201
509
  if (stream === null) return;
@@ -211,3 +519,40 @@ async function drainToVoid(stream: ReadableStream<Uint8Array> | null): Promise<v
211
519
  reader.releaseLock();
212
520
  }
213
521
  }
522
+
523
+ /**
524
+ * Read a ReadableStream and append decoded chunks to `tail`, capping the
525
+ * accumulated buffer to ~4KB so a chatty Chromium can't blow memory. Used
526
+ * by `spawnChromium`'s early-exit diagnostic to recover the last few lines
527
+ * of stderr from a process that died within 750ms of spawn.
528
+ */
529
+ async function drainToText(
530
+ stream: ReadableStream<Uint8Array> | null,
531
+ tail: string[],
532
+ ): Promise<void> {
533
+ if (stream === null) return;
534
+ const reader = stream.getReader();
535
+ const decoder = new TextDecoder();
536
+ let bufferedLen = 0;
537
+ const cap = 4096;
538
+ try {
539
+ while (true) {
540
+ const { done, value } = await reader.read();
541
+ if (done) return;
542
+ if (value !== undefined) {
543
+ const text = decoder.decode(value, { stream: true });
544
+ tail.push(text);
545
+ bufferedLen += text.length;
546
+ // Trim from the front when over cap so we always keep the *tail*.
547
+ while (bufferedLen > cap && tail.length > 1) {
548
+ const dropped = tail.shift();
549
+ bufferedLen -= dropped !== undefined ? dropped.length : 0;
550
+ }
551
+ }
552
+ }
553
+ } catch {
554
+ // ignore — stream errored or was cancelled
555
+ } finally {
556
+ reader.releaseLock();
557
+ }
558
+ }