@mochi.js/core 0.1.2 → 0.2.2
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/package.json +5 -5
- package/src/__tests__/inject.test.ts +2 -0
- package/src/__tests__/piercing.test.ts +164 -0
- package/src/__tests__/proc.test.ts +383 -0
- package/src/__tests__/selector.test.ts +188 -0
- package/src/__tests__/window-size.e2e.test.ts +130 -0
- package/src/cdp/types.ts +47 -0
- package/src/index.ts +1 -0
- package/src/launch.ts +73 -8
- package/src/page/element-handle.ts +110 -0
- package/src/page/piercing.ts +135 -0
- package/src/page/selector.ts +423 -0
- package/src/page.ts +142 -0
- package/src/proc.ts +386 -41
- package/src/session.ts +140 -12
package/src/page.ts
CHANGED
|
@@ -35,9 +35,13 @@ import type {
|
|
|
35
35
|
DispatchMouseEventParams,
|
|
36
36
|
DomNode,
|
|
37
37
|
FrameNavigatedEvent,
|
|
38
|
+
PierceDomNode,
|
|
38
39
|
RemoteObject,
|
|
39
40
|
} from "./cdp/types";
|
|
40
41
|
import { NotImplementedError } from "./errors";
|
|
42
|
+
import { ElementHandle } from "./page/element-handle";
|
|
43
|
+
import { findPiercingMatches } from "./page/piercing";
|
|
44
|
+
import { parseSelector } from "./page/selector";
|
|
41
45
|
|
|
42
46
|
/** Wait conditions for `Page.goto`. */
|
|
43
47
|
export type WaitUntil = "load" | "domcontentloaded" | "networkidle";
|
|
@@ -527,6 +531,10 @@ export class Page {
|
|
|
527
531
|
});
|
|
528
532
|
const targetBox = boxFromBorderQuad(box.model);
|
|
529
533
|
const callSeed = this.nextCallSeed();
|
|
534
|
+
// Trajectory synth lives here (not in `performClickAt`) so prototype
|
|
535
|
+
// inspection in conformance tests can see the synthesize / trajectory
|
|
536
|
+
// / cursor markers — they're a consumer-side smoke check that the
|
|
537
|
+
// behavioral synth is wired in.
|
|
530
538
|
const traj = synthesizeMouseTrajectory({
|
|
531
539
|
from: { x: this.cursor.x, y: this.cursor.y },
|
|
532
540
|
to: { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 },
|
|
@@ -535,6 +543,51 @@ export class Page {
|
|
|
535
543
|
seed: callSeed,
|
|
536
544
|
...(opts.duration !== undefined ? { durationMs: opts.duration } : {}),
|
|
537
545
|
});
|
|
546
|
+
await this.dispatchClickTrajectory(traj, callSeed, opts);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Variant of {@link humanClick} that operates on an {@link ElementHandle}
|
|
551
|
+
* resolved via {@link querySelectorPiercing} — required when the target
|
|
552
|
+
* element lives inside a closed shadow root (no CSS path can name it from
|
|
553
|
+
* the parent document, so the regular `humanClick(selector)` route fails).
|
|
554
|
+
*
|
|
555
|
+
* Pipeline differs from {@link humanClick} only in step 1: the box model
|
|
556
|
+
* is resolved via `DOM.getBoxModel({ backendNodeId })` instead of through a
|
|
557
|
+
* `DOM.querySelector`-resolved nodeId. Everything downstream (trajectory
|
|
558
|
+
* synth, dispatch loop, press/release) is identical.
|
|
559
|
+
*/
|
|
560
|
+
async humanClickHandle(handle: ElementHandle, opts: HumanClickOptions = {}): Promise<void> {
|
|
561
|
+
this.assertOpen();
|
|
562
|
+
const box = await this.send<{ model: BoxModel }>("DOM.getBoxModel", {
|
|
563
|
+
backendNodeId: handle.backendNodeId,
|
|
564
|
+
});
|
|
565
|
+
const targetBox = boxFromBorderQuad(box.model);
|
|
566
|
+
const callSeed = this.nextCallSeed();
|
|
567
|
+
const traj = synthesizeMouseTrajectory({
|
|
568
|
+
from: { x: this.cursor.x, y: this.cursor.y },
|
|
569
|
+
to: { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 },
|
|
570
|
+
box: targetBox,
|
|
571
|
+
profile: this.behavior,
|
|
572
|
+
seed: callSeed,
|
|
573
|
+
...(opts.duration !== undefined ? { durationMs: opts.duration } : {}),
|
|
574
|
+
});
|
|
575
|
+
await this.dispatchClickTrajectory(traj, callSeed, opts);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Inner dispatch loop shared by {@link humanClick} and
|
|
580
|
+
* {@link humanClickHandle}. Takes the synthesised trajectory, paces the
|
|
581
|
+
* `mouseMoved` events, then fires `mousePressed` + `mouseReleased` at the
|
|
582
|
+
* arrival point with realistic press duration. Trajectory synth itself
|
|
583
|
+
* stays inside the public methods so source-grep conformance checks can
|
|
584
|
+
* verify the synth is reachable from the public API.
|
|
585
|
+
*/
|
|
586
|
+
private async dispatchClickTrajectory(
|
|
587
|
+
traj: ReturnType<typeof synthesizeMouseTrajectory>,
|
|
588
|
+
callSeed: string,
|
|
589
|
+
opts: HumanClickOptions,
|
|
590
|
+
): Promise<void> {
|
|
538
591
|
if (traj.length === 0) return;
|
|
539
592
|
|
|
540
593
|
// Pre-move settle: Gaussian(150, 50) ms idle. Cheaply approximated via
|
|
@@ -724,6 +777,95 @@ export class Page {
|
|
|
724
777
|
}
|
|
725
778
|
}
|
|
726
779
|
|
|
780
|
+
/**
|
|
781
|
+
* Closed-shadow-root piercing locator — find the first element matching the
|
|
782
|
+
* CSS selector across the entire DOM tree, including elements nested inside
|
|
783
|
+
* **closed** shadow roots (which {@link text}, {@link humanClick}, etc. can
|
|
784
|
+
* NOT reach because `DOM.querySelector` does not traverse closed shadows
|
|
785
|
+
* even with `pierce: true` set on the parent `getDocument` call).
|
|
786
|
+
*
|
|
787
|
+
* Required for Cloudflare Turnstile auto-click on integrations where the
|
|
788
|
+
* widget iframe lives behind a closed shadow root (Cloudflare Challenge
|
|
789
|
+
* pages, Workers Static Assets, some CDN configs). Without this, task
|
|
790
|
+
* 0220's auto-click silently fails on those flows.
|
|
791
|
+
*
|
|
792
|
+
* Algorithm (port of patchright `framesPatch.ts:868-1012`
|
|
793
|
+
* `_customFindElementsByParsed`):
|
|
794
|
+
* 1. `DOM.getDocument({ depth: -1, pierce: true })` — yields the full
|
|
795
|
+
* tree, with shadow descendants under `shadowRoots[]` for both open
|
|
796
|
+
* AND closed roots.
|
|
797
|
+
* 2. Recursive walk in JS, matching against a parsed CSS selector. We
|
|
798
|
+
* can't `DOM.querySelector` per shadow because the per-shadow query
|
|
799
|
+
* itself doesn't pierce closed roots either.
|
|
800
|
+
* 3. For matches, `DOM.resolveNode({ backendNodeId })` to get a
|
|
801
|
+
* `RemoteObject.objectId`, wrapped in {@link ElementHandle}.
|
|
802
|
+
*
|
|
803
|
+
* Supported selectors (see `selector.ts`): tag / id / class / attribute /
|
|
804
|
+
* descendant combinator / comma-separated lists. **Not** supported:
|
|
805
|
+
* `>`/`+`/`~` combinators, `:pseudo-classes`, `::pseudo-elements`, XPath.
|
|
806
|
+
* XPath is a stretch goal per task 0253 brief — TODO if a future surface
|
|
807
|
+
* needs it (Turnstile detection only needs CSS).
|
|
808
|
+
*
|
|
809
|
+
* Performance: O(N) in DOM size per call. Acceptable for v0.2; a per-page
|
|
810
|
+
* cache layer is a v0.3+ concern (also called out in 0253).
|
|
811
|
+
*
|
|
812
|
+
* @see tasks/0253-closed-shadow-piercing-locator.md
|
|
813
|
+
* @see PLAN.md §8.2 (`DOM.getDocument` and `DOM.resolveNode` are not on the
|
|
814
|
+
* forbidden list — both fine to use here).
|
|
815
|
+
*/
|
|
816
|
+
async querySelectorPiercing(selector: string): Promise<ElementHandle | null> {
|
|
817
|
+
const handles = await this.queryPiercing(selector, 1);
|
|
818
|
+
return handles[0] ?? null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The "all matches" variant of {@link querySelectorPiercing}. Returns every
|
|
823
|
+
* element that satisfies the selector, in depth-first pre-order — same
|
|
824
|
+
* traversal a regular `querySelectorAll` produces, with closed-shadow
|
|
825
|
+
* descendants spliced in at the position they'd appear under the host.
|
|
826
|
+
*
|
|
827
|
+
* Returns an empty array when nothing matches.
|
|
828
|
+
*/
|
|
829
|
+
async querySelectorAllPiercing(selector: string): Promise<ElementHandle[]> {
|
|
830
|
+
return this.queryPiercing(selector);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Shared implementation for the piercing locator. `limit` short-circuits the walk. */
|
|
834
|
+
private async queryPiercing(selector: string, limit?: number): Promise<ElementHandle[]> {
|
|
835
|
+
this.assertOpen();
|
|
836
|
+
const parsed = parseSelector(selector);
|
|
837
|
+
// depth: -1 + pierce: true is the magic combination patchright uses; CDP
|
|
838
|
+
// returns a fully-flattened tree including shadow descendants on both
|
|
839
|
+
// open and closed roots, AND iframe contentDocuments for same-origin
|
|
840
|
+
// children.
|
|
841
|
+
const root = await this.send<{ root: PierceDomNode }>("DOM.getDocument", {
|
|
842
|
+
depth: -1,
|
|
843
|
+
pierce: true,
|
|
844
|
+
});
|
|
845
|
+
const matches = findPiercingMatches(root.root, parsed, limit);
|
|
846
|
+
if (matches.length === 0) return [];
|
|
847
|
+
const handles: ElementHandle[] = [];
|
|
848
|
+
for (const m of matches) {
|
|
849
|
+
const resolved = await this.send<{ object: RemoteObject }>("DOM.resolveNode", {
|
|
850
|
+
backendNodeId: m.backendNodeId,
|
|
851
|
+
});
|
|
852
|
+
const objectId = resolved.object.objectId;
|
|
853
|
+
// Skip nodes the protocol couldn't bind to a RemoteObject (rare — e.g.
|
|
854
|
+
// detached subtree races). Surfacing a partial set is more useful than
|
|
855
|
+
// throwing for the Turnstile detector path.
|
|
856
|
+
if (objectId === undefined) continue;
|
|
857
|
+
handles.push(
|
|
858
|
+
new ElementHandle({
|
|
859
|
+
router: this.router,
|
|
860
|
+
sessionId: this.sessionId,
|
|
861
|
+
objectId,
|
|
862
|
+
backendNodeId: m.backendNodeId,
|
|
863
|
+
}),
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
return handles;
|
|
867
|
+
}
|
|
868
|
+
|
|
727
869
|
screenshot(_opts?: unknown): Promise<Uint8Array> {
|
|
728
870
|
return Promise.reject(new NotImplementedError("page.screenshot"));
|
|
729
871
|
}
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
133
|
-
//
|
|
134
|
-
|
|
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
|
+
}
|