@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/src/session.ts CHANGED
@@ -184,6 +184,18 @@ export class Session {
184
184
  */
185
185
  private readonly challengesOpts: SessionInit["challenges"] | undefined;
186
186
  private readonly challengeHandles: ChallengeHandle[] = [];
187
+ /**
188
+ * Cache of resolved execution-context ids for worker-style targets,
189
+ * keyed by the worker session id. Populated by
190
+ * {@link extractWorkerExecutionContextId} on first attach and reused by
191
+ * any later worker CDP op that needs an `executionContextId`. Patchright
192
+ * keeps this on a per-target `CRExecutionContext`; mochi keeps the
193
+ * Session-local map until we grow a real worker-target abstraction.
194
+ *
195
+ * @see crServiceWorkerPatch.ts:32-43, crPagePatch.ts:404-417
196
+ * @internal
197
+ */
198
+ private readonly workerExecutionContextIds = new Map<string, number>();
187
199
 
188
200
  constructor(init: SessionInit) {
189
201
  this.proc = init.proc;
@@ -254,6 +266,30 @@ export class Session {
254
266
  // (only Runtime.enable is forbidden). We enable here so subsequent
255
267
  // addScriptToEvaluateOnNewDocument is honoured by the page domain.
256
268
  await this.router.send("Page.enable", undefined, { sessionId: attached.sessionId });
269
+ // Task 0255: defensive UA override at the network layer.
270
+ //
271
+ // The inject payload (Page.addScriptToEvaluateOnNewDocument) spoofs
272
+ // `navigator.userAgent` in the JS surface, but `Network.requestWillBeSent`
273
+ // events (and the request line itself) carry the BARE browser UA — which
274
+ // under `--headless=new` still contains the substring "HeadlessChrome".
275
+ // The inject can never reach those bytes because they're emitted before
276
+ // any document script runs.
277
+ //
278
+ // `Network.setUserAgentOverride` is a per-target setter that does NOT
279
+ // require `Network.enable` (it only stores override state); §8.2's ban
280
+ // on `Network.enable` is therefore unaffected. Sent immediately after
281
+ // attach and before any navigation so the very first request the page
282
+ // issues already carries the matrix UA.
283
+ //
284
+ // Skipped under `bypassInject:true` (PLAN.md §12.1) — capture flows must
285
+ // record the bare browser fingerprint, including its raw UA.
286
+ if (!this.bypassInject) {
287
+ await this.router.send(
288
+ "Network.setUserAgentOverride",
289
+ { userAgent: this.profile.userAgent },
290
+ { sessionId: attached.sessionId },
291
+ );
292
+ }
257
293
  // PLAN.md §12.1 / task 0040 — capture flow short-circuits inject so the
258
294
  // browser reports its bare fingerprint. Otherwise install the payload
259
295
  // main-world via §8.4. worldName MUST be the empty string.
@@ -557,18 +593,35 @@ export class Session {
557
593
 
558
594
  /**
559
595
  * Inject the payload into a freshly-attached target if it's a worker-
560
- * style target (dedicated worker, shared worker, service worker, audio
561
- * worklet, etc.), then resume it.
596
+ * style target (dedicated worker, shared worker, audio worklet — service
597
+ * workers go through the same path; see notes below), then resume it.
562
598
  *
563
599
  * Worker targets do NOT support `Page.addScriptToEvaluateOnNewDocument`
564
- * (no Page domain). PLAN.md §8.4 calls out that we use `Runtime.evaluate`
565
- * against the paused worker session before issuing
566
- * `Runtime.runIfWaitingForDebugger`. The §8.2 forbidden-method assertion
567
- * does NOT trip because we never send `Runtime.enable` — only
568
- * `Runtime.evaluate` against an already-paused worker target.
600
+ * (no Page domain). PLAN.md §8.4 calls out that the worker target accepts
601
+ * `Runtime.evaluate` even though `Runtime.enable` is forbidden by §8.2.
602
+ *
603
+ * The Patchright-cited bootstrap (task 0254 `crServiceWorkerPatch.ts:32-43`,
604
+ * `crPagePatch.ts:404-417`) tightens the inject race window:
605
+ * 1. `Runtime.evaluate("globalThis", { serialization: "idOnly" })` —
606
+ * returns a `RemoteObject` whose `objectId` carries the worker's
607
+ * execution-context id. `serialization: "idOnly"` skips the value
608
+ * preview round-trip we don't need.
609
+ * 2. Parse `objectId.split(".")[1]` for the contextId. The wire format
610
+ * is `"<runtimeAgentId>.<contextId>.<remoteObjectId>"`; we validate
611
+ * the split and fail loudly if Chromium has moved the goalposts.
612
+ * 3. Inject the payload via `Runtime.callFunctionOn({ functionDeclaration,
613
+ * executionContextId, returnByValue: true })`. This binds the call
614
+ * to the worker's own context rather than relying on
615
+ * `Runtime.evaluate`'s implicit context resolution, which is the
616
+ * coarser pattern v0.1.x used.
617
+ * 4. `Runtime.runIfWaitingForDebugger` to resume the target.
569
618
  *
570
- * Caveat: worker injection has a smaller stealth ceiling than main-
571
- * world Page injection. Documented in `docs/limits.md`.
619
+ * We never send `Runtime.enable` that's the whole point of extracting
620
+ * the contextId via the idOnly trick instead of waiting for an
621
+ * `Runtime.executionContextCreated` event.
622
+ *
623
+ * Caveat: worker injection has a smaller stealth ceiling than main-world
624
+ * Page injection. Documented in `docs/limits.md`.
572
625
  */
573
626
  private async handleAttachedTarget(
574
627
  ev: AttachedToTargetEvent,
@@ -584,12 +637,20 @@ export class Session {
584
637
  // PLAN.md §12.1 / task 0040 — capture flow skips worker injection too.
585
638
  if (isWorkerLike && !this.bypassInject && this._payload !== null) {
586
639
  try {
640
+ const executionContextId = await this.extractWorkerExecutionContextId(childSessionId);
641
+ this.workerExecutionContextIds.set(childSessionId, executionContextId);
642
+ // `Runtime.callFunctionOn` requires either an `objectId` OR an
643
+ // `executionContextId`. We use the latter — patchright's pattern —
644
+ // so the call binds to the worker's own context, not whatever
645
+ // `Runtime.evaluate` happens to resolve. The payload IIFE is wrapped
646
+ // as a function declaration so `callFunctionOn` accepts it.
587
647
  await this.router.send(
588
- "Runtime.evaluate",
648
+ "Runtime.callFunctionOn",
589
649
  {
590
- expression: this._payload.code,
650
+ functionDeclaration: `function() { ${this._payload.code} }`,
651
+ executionContextId,
652
+ returnByValue: true,
591
653
  awaitPromise: false,
592
- returnByValue: false,
593
654
  // includeCommandLineAPI must remain false (§8.2).
594
655
  },
595
656
  { sessionId: childSessionId },
@@ -617,6 +678,73 @@ export class Session {
617
678
  }
618
679
  }
619
680
 
681
+ /**
682
+ * Resolve the worker target's execution-context id WITHOUT
683
+ * `Runtime.enable` — patchright's trick.
684
+ *
685
+ * Sends `Runtime.evaluate("globalThis", { serialization: "idOnly" })`
686
+ * against the paused worker session. The returned `RemoteObject.objectId`
687
+ * has the on-the-wire shape `"<runtimeAgentId>.<contextId>.<localId>"`
688
+ * (Chromium >= v131; verified against patchright's parser). We extract
689
+ * `split(".")[1]` and assert it's a positive integer.
690
+ *
691
+ * Throws with a precise diagnostic if Chromium changes the format —
692
+ * silent fallback would mask a real wire-protocol shift, which we want
693
+ * to catch in CI rather than ship as a degraded inject path.
694
+ *
695
+ * @see crServiceWorkerPatch.ts:32-43
696
+ */
697
+ private async extractWorkerExecutionContextId(childSessionId: string): Promise<number> {
698
+ const evalRes = await this.router.send<{ result: { objectId?: string; type?: string } }>(
699
+ "Runtime.evaluate",
700
+ {
701
+ expression: "globalThis",
702
+ // idOnly skips full value serialisation — we want the objectId
703
+ // alone. Supported on Chromium >= v124 (chrome-for-testing v131+
704
+ // in the mochi profile floor).
705
+ serialization: "idOnly",
706
+ // includeCommandLineAPI must remain false (§8.2).
707
+ },
708
+ { sessionId: childSessionId },
709
+ );
710
+ const objectId = evalRes.result.objectId;
711
+ if (typeof objectId !== "string" || objectId.length === 0) {
712
+ throw new Error(
713
+ `[mochi] worker idOnly bootstrap: Runtime.evaluate("globalThis") returned no objectId (got ${JSON.stringify(evalRes.result)})`,
714
+ );
715
+ }
716
+ const parts = objectId.split(".");
717
+ // Format: "<runtimeAgentId>.<contextId>.<localId>" — patchright also
718
+ // pulls index [1]. Refuse to guess if the segment count shifts.
719
+ if (parts.length < 2) {
720
+ throw new Error(
721
+ `[mochi] worker idOnly bootstrap: unexpected objectId shape "${objectId}" (expected dotted segments)`,
722
+ );
723
+ }
724
+ const ctxRaw = parts[1];
725
+ if (ctxRaw === undefined || ctxRaw.length === 0) {
726
+ throw new Error(
727
+ `[mochi] worker idOnly bootstrap: objectId "${objectId}" has empty contextId segment`,
728
+ );
729
+ }
730
+ const contextId = Number.parseInt(ctxRaw, 10);
731
+ if (!Number.isInteger(contextId) || contextId <= 0 || String(contextId) !== ctxRaw) {
732
+ throw new Error(
733
+ `[mochi] worker idOnly bootstrap: contextId segment "${ctxRaw}" of objectId "${objectId}" is not a positive integer`,
734
+ );
735
+ }
736
+ return contextId;
737
+ }
738
+
739
+ /**
740
+ * Snapshot of the worker → executionContextId cache. Test-only.
741
+ *
742
+ * @internal
743
+ */
744
+ _internalWorkerExecutionContextIds(): ReadonlyMap<string, number> {
745
+ return new Map(this.workerExecutionContextIds);
746
+ }
747
+
620
748
  private installCrashGuard(): void {
621
749
  // If Chromium dies unexpectedly, we want to mark the session closed so
622
750
  // pending and future calls reject cleanly.