@deeeed/metamask-harness 0.28.0 → 0.29.1

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +41 -0
  3. package/adapters/extension/build-lavamoat.sh +2 -1
  4. package/adapters/extension/ensure-browser.sh +82 -9
  5. package/adapters/extension/inject.mjs +1 -0
  6. package/adapters/extension/launch-browser.cjs +83 -1
  7. package/adapters/extension/lib/chrome-args.cjs +325 -1
  8. package/adapters/extension/lib/playwright-cdp.cjs +34 -0
  9. package/adapters/extension/lib/slot-title.cjs +2 -4
  10. package/adapters/extension/lib/validation-launch-supervisor.cjs +292 -0
  11. package/adapters/extension/lib/validation-process-ownership.cjs +69 -0
  12. package/adapters/extension/reattach.sh +2 -1
  13. package/adapters/extension/sidepanel-toggle.sh +14 -96
  14. package/adapters/extension/wallet-fixture-state.cjs +8 -31
  15. package/adapters/manifest.json +16 -0
  16. package/adapters/shared/private-atomic-write.cjs +47 -0
  17. package/adapters/shared/setup-base.sh +864 -0
  18. package/dist/adapters/extension/runtime.js +367 -24
  19. package/dist/adapters/extension/validation-process-ownership.js +10 -0
  20. package/dist/cli-commands.js +1 -0
  21. package/dist/command-contract.js +12 -0
  22. package/dist/commands/launch/extension.js +130 -19
  23. package/dist/commands/setup-base.js +24 -0
  24. package/dist/mm-harness-cli.js +28 -2
  25. package/docs/RECIPES.md +26 -1
  26. package/docs/SECURITY.md +31 -0
  27. package/library/actions/extension/analytics/consent.mjs +203 -0
  28. package/library/actions/extension/analytics/set_consent.mjs +19 -143
  29. package/library/actions/extension/perps/perps.mjs +2 -16
  30. package/library/actions/extension/perps/state.mjs +20 -0
  31. package/library/actions/extension/wallet/list_accounts.mjs +3 -25
  32. package/library/actions/extension/wallet/read_state.mjs +3 -23
  33. package/library/actions/extension/wallet/select_account.mjs +6 -33
  34. package/library/actions/extension/wallet/setup.mjs +2 -20
  35. package/library/actions/extension/wallet/state.mjs +111 -0
  36. package/library/recipes/runner/action-validation.extension.recipe.json +1 -1
  37. package/library/recipes/runner/action-validation.mobile.recipe.json +1 -1
  38. package/package.json +7 -4
  39. package/scripts/site-contrast.mjs +538 -0
  40. package/site/architecture.html +474 -0
  41. package/site/assets/progress.mjs +272 -0
  42. package/site/assets/style.css +808 -0
  43. package/site/cheatsheet.html +305 -0
  44. package/site/index.html +647 -0
  45. package/site/recipes.html +396 -0
  46. package/site/reviewers.html +374 -0
  47. package/site/tutorials/index.html +180 -0
  48. package/site/tutorials/v1.html +211 -0
  49. package/site/tutorials/v2.html +207 -0
  50. package/site/tutorials/v3.html +214 -0
  51. package/site/tutorials/v4.html +195 -0
  52. package/site/tutorials/v5.html +163 -0
  53. package/site/tutorials/v6.html +165 -0
  54. package/site/tutorials/v7.html +184 -0
@@ -1,13 +1,19 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import http from "node:http";
3
3
  import fs from "node:fs";
4
+ import { createRequire } from "node:module";
4
5
  import path from "node:path";
5
6
  import {
6
7
  depsCheck,
7
8
  recordDepsBaseline
8
9
  } from "@farmslot/recipe-harness/runtime/deps-readiness";
9
10
  import { colorHumanMessage } from "../../cli-color.js";
10
- import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "../../paths.js";
11
+ import {
12
+ importRecipeHarnessRuntimeCdp,
13
+ recipeHarnessPath,
14
+ recipeRuntimeDir,
15
+ runnerDir
16
+ } from "../../paths.js";
11
17
  import { extensionIdFromKey } from "../../adapters/extension/extension-id.js";
12
18
  import { extensionProductConfigBlock } from "../../adapters/extension/product-config.js";
13
19
  import { isExtensionDistStale } from "../../adapters/extension/runtime-decision.js";
@@ -15,6 +21,10 @@ import { checkExtensionRuntimeHealth } from "../../adapters/extension/runtime.js
15
21
  import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
16
22
  import { stopExtensionWatcher } from "../../adapters/slot-ports.js";
17
23
  import { EXIT, spawnScriptStreaming } from "../shared.js";
24
+ const { CdpSession } = await importRecipeHarnessRuntimeCdp();
25
+ const { RUNTIME_IDENTITY_FILENAME, RUNTIME_NONCE_PREFIX } = createRequire(import.meta.url)(
26
+ path.join(runnerDir, "adapters/extension/lib/chrome-args.cjs")
27
+ );
18
28
  function extensionDepsBlock(target) {
19
29
  if (!fs.existsSync(path.join(target, "package.json"))) return null;
20
30
  const deps = depsCheck(target);
@@ -73,7 +83,8 @@ async function extensionRuntimeReusable(target) {
73
83
  if (isExtensionDistStale(target)) return false;
74
84
  for (let attempt = 0; attempt < 3; attempt += 1) {
75
85
  const reachable = await cdpVersionReachable(cdpPort);
76
- const owned = reachable && cdpOwnedByExpectedRuntime(cdpPort, target);
86
+ const nonceOwned = reachable && await cdpOwnedByRuntimeNonce(cdpPort, target);
87
+ const owned = nonceOwned || reachable && cdpOwnedByExpectedRuntime(cdpPort, target);
77
88
  const targetPresent = owned && await cdpHasExpectedExtensionTarget(cdpPort, target);
78
89
  const healthy = targetPresent && await cdpRuntimeHealthy(cdpPort, target);
79
90
  if (healthy) return true;
@@ -88,17 +99,20 @@ function expectedChromeProfile(target) {
88
99
  function expectedRuntimeDist(target) {
89
100
  return path.resolve(path.join(target, recipeRuntimeDir(), process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist"));
90
101
  }
91
- function cdpOwnedByExpectedRuntime(port, target) {
92
- let pids = [];
93
- try {
94
- const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
95
- encoding: "utf8",
96
- stdio: ["ignore", "pipe", "ignore"]
97
- });
98
- pids = out.split(/\s+/u).filter((value) => /^\d+$/u.test(value));
99
- } catch {
100
- return false;
102
+ function commandHasExactFlagValue(command, flag, value) {
103
+ const expected = `${flag}=${value}`;
104
+ let offset = command.indexOf(expected);
105
+ while (offset !== -1) {
106
+ const before = offset === 0 ? "" : command[offset - 1];
107
+ const afterOffset = offset + expected.length;
108
+ const after = afterOffset === command.length ? "" : command[afterOffset];
109
+ if ((!before || /\s/u.test(before)) && (!after || /\s/u.test(after))) return true;
110
+ offset = command.indexOf(expected, offset + 1);
101
111
  }
112
+ return false;
113
+ }
114
+ function cdpOwnedByExpectedRuntime(port, target) {
115
+ const pids = cdpListenerPids(port);
102
116
  const profile = expectedChromeProfile(target);
103
117
  const runtimeDist = expectedRuntimeDist(target);
104
118
  for (const pid of pids) {
@@ -107,7 +121,7 @@ function cdpOwnedByExpectedRuntime(port, target) {
107
121
  encoding: "utf8",
108
122
  stdio: ["ignore", "pipe", "ignore"]
109
123
  });
110
- if (command.includes(`--user-data-dir=${profile}`) && (command.includes(`--load-extension=${runtimeDist}`) || command.includes(`--disable-extensions-except=${runtimeDist}`))) {
124
+ if (commandHasExactFlagValue(command, "--user-data-dir", profile) && (commandHasExactFlagValue(command, "--load-extension", runtimeDist) || commandHasExactFlagValue(command, "--disable-extensions-except", runtimeDist))) {
111
125
  return true;
112
126
  }
113
127
  } catch {
@@ -115,21 +129,118 @@ function cdpOwnedByExpectedRuntime(port, target) {
115
129
  }
116
130
  return false;
117
131
  }
118
- function cdpVersionReachable(port) {
132
+ async function cdpOwnedByRuntimeNonce(port, target) {
133
+ const identity = readRuntimeIdentity(port, target);
134
+ if (!identity) return false;
135
+ const version = await cdpVersion(port);
136
+ const webSocketDebuggerUrl = version?.webSocketDebuggerUrl;
137
+ if (typeof webSocketDebuggerUrl !== "string" || !webSocketDebuggerUrl) return false;
138
+ let session;
139
+ try {
140
+ session = await CdpSession.connect(webSocketDebuggerUrl, { timeoutMs: 2e3 });
141
+ const result = await boundedBrowserCdpCall(
142
+ session,
143
+ "Browser.getBrowserCommandLine",
144
+ {},
145
+ 2e3
146
+ );
147
+ if (!Array.isArray(result.arguments)) return false;
148
+ const nonceArgs = result.arguments.filter(
149
+ (argument) => typeof argument === "string" && argument.startsWith(RUNTIME_NONCE_PREFIX)
150
+ );
151
+ return nonceArgs.length === 1 && nonceArgs[0] === `${RUNTIME_NONCE_PREFIX}${identity.nonce}`;
152
+ } catch {
153
+ return false;
154
+ } finally {
155
+ session?.close();
156
+ }
157
+ }
158
+ async function boundedBrowserCdpCall(session, method, params, timeoutMs) {
159
+ let timer;
160
+ try {
161
+ return await Promise.race([
162
+ session.call(method, params),
163
+ new Promise((_resolve, reject) => {
164
+ timer = setTimeout(
165
+ () => reject(new Error(`${method} timed out after ${timeoutMs}ms.`)),
166
+ timeoutMs
167
+ );
168
+ })
169
+ ]);
170
+ } finally {
171
+ if (timer) clearTimeout(timer);
172
+ }
173
+ }
174
+ function readRuntimeIdentity(port, target) {
175
+ const expectedPort = Number(port);
176
+ if (!Number.isInteger(expectedPort) || expectedPort <= 0 || expectedPort > 65535) return null;
177
+ const identityPath = path.join(target, recipeRuntimeDir(), RUNTIME_IDENTITY_FILENAME);
178
+ let descriptor;
179
+ try {
180
+ descriptor = fs.openSync(
181
+ identityPath,
182
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW
183
+ );
184
+ const stat = fs.fstatSync(descriptor);
185
+ if (!stat.isFile() || stat.size <= 0 || stat.size > 4096 || (stat.mode & 63) !== 0) {
186
+ return null;
187
+ }
188
+ const parsed = JSON.parse(fs.readFileSync(descriptor, "utf8"));
189
+ if (parsed.port !== expectedPort || !Number.isInteger(parsed.pid) || Number(parsed.pid) <= 0 || !Number.isInteger(parsed.startedAt) || Number(parsed.startedAt) <= 0 || Number(parsed.startedAt) > Date.now() + 6e4 || typeof parsed.nonce !== "string" || !/^[a-f0-9]{64}$/u.test(parsed.nonce)) {
190
+ return null;
191
+ }
192
+ if (!cdpListenerPids(port).includes(String(parsed.pid))) return null;
193
+ return parsed;
194
+ } catch {
195
+ return null;
196
+ } finally {
197
+ if (descriptor !== void 0) fs.closeSync(descriptor);
198
+ }
199
+ }
200
+ function cdpListenerPids(port) {
201
+ try {
202
+ const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
203
+ encoding: "utf8",
204
+ stdio: ["ignore", "pipe", "ignore"]
205
+ });
206
+ return out.split(/\s+/u).filter((value) => /^\d+$/u.test(value));
207
+ } catch {
208
+ return [];
209
+ }
210
+ }
211
+ async function cdpVersionReachable(port) {
212
+ return Boolean(await cdpVersion(port));
213
+ }
214
+ function cdpVersion(port) {
119
215
  return new Promise((resolve) => {
120
216
  const request = http.get(
121
217
  { host: "127.0.0.1", port: Number(port), path: "/json/version", timeout: 2e3 },
122
218
  (response) => {
123
- response.resume();
124
- const code = response.statusCode ?? 0;
125
- resolve(code >= 200 && code < 300);
219
+ let body = "";
220
+ response.setEncoding("utf8");
221
+ response.on("data", (chunk) => {
222
+ body += chunk;
223
+ });
224
+ response.on("end", () => {
225
+ const code = response.statusCode ?? 0;
226
+ if (code < 200 || code >= 300) {
227
+ resolve(null);
228
+ return;
229
+ }
230
+ try {
231
+ const parsed = JSON.parse(body);
232
+ resolve(parsed && typeof parsed === "object" ? parsed : null);
233
+ } catch {
234
+ resolve(null);
235
+ }
236
+ });
126
237
  }
127
238
  );
128
239
  request.on("timeout", () => {
129
240
  request.destroy();
130
- resolve(false);
241
+ resolve(null);
131
242
  });
132
- request.on("error", () => resolve(false));
243
+ request.on("error", () => resolve(null));
133
244
  });
134
245
  }
135
246
  function expectedExtensionId(target) {
@@ -0,0 +1,24 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { runnerDir } from "../paths.js";
4
+ function handleSetupBase(argv) {
5
+ const script = path.join(runnerDir, "adapters", "shared", "setup-base.sh");
6
+ const result = spawnSync("bash", [script, ...argv], {
7
+ cwd: process.cwd(),
8
+ env: { ...process.env, MM_HARNESS_NODE: process.execPath },
9
+ stdio: "inherit"
10
+ });
11
+ if (result.error) {
12
+ const code = result.error.code ?? "ESPAWN";
13
+ process.stderr.write(
14
+ `setup-base could not start (${code}).
15
+ Next: npm i -g @deeeed/metamask-harness@latest
16
+ `
17
+ );
18
+ return 3;
19
+ }
20
+ return result.status ?? 1;
21
+ }
22
+ export {
23
+ handleSetupBase
24
+ };
@@ -8,6 +8,7 @@ import { withCommandJournal } from "./command-journal.js";
8
8
  import { JsonStreamWriter } from "./json-stream.js";
9
9
  import { handleUpdate, maybeNudge } from "./commands/update.js";
10
10
  import { handleCallHelp } from "./commands/call.js";
11
+ import { handleSetupBase } from "./commands/setup-base.js";
11
12
  import { getAdapterSurface } from "./adapters/surface.js";
12
13
  import { detectAdapter } from "./harness.js";
13
14
  import {
@@ -20,6 +21,28 @@ globalThis.__MM_HARNESS_WRAPPER__ = true;
20
21
  const { main: recipeMain } = await import("./cli.js");
21
22
  const rawArgv = process.argv.slice(2);
22
23
  const REAL = [
24
+ {
25
+ name: "setup-base",
26
+ summary: "Bootstrap numbered MetaMask product checkouts and install their dependencies.",
27
+ example: "mm-harness setup-base --only core --counts core=1",
28
+ helpText: `mm-harness setup-base [flags]
29
+
30
+ Bootstrap numbered MetaMask Extension, Mobile, and Core checkouts under one
31
+ base directory, then run each checkout's pinned dependency install.
32
+
33
+ --dir <base> Destination root
34
+ --counts <repo=n,...> Copies per selected repository
35
+ --only <extension,mobile,core> Repository subset
36
+ --dry-run Print the plan without changing anything
37
+ --force Override the free-space refusal
38
+ --json Machine-readable terminal summary
39
+ --show-config Print saved preferences and exit
40
+ --reset-config Remove saved preferences and exit
41
+ --skip-harness-update Skip the installed-version notice
42
+
43
+ Example:
44
+ mm-harness setup-base --only core --counts core=1`
45
+ },
23
46
  {
24
47
  name: "status",
25
48
  aliases: ["health", "home"],
@@ -603,7 +626,7 @@ const HELP_GROUPS = [
603
626
  {
604
627
  title: "MAINTAIN",
605
628
  blurb: "keep the installed harness current with the npm registry",
606
- commands: ["update"]
629
+ commands: ["setup-base", "update"]
607
630
  }
608
631
  ];
609
632
  function commandMeta(name) {
@@ -732,7 +755,7 @@ program.command("completions").description("Install/print bundled shell tab-comp
732
755
  const result = spawnSync("bash", [script, ...rawArgv.slice(1)], { stdio: "inherit" });
733
756
  process.exit(result.status ?? 1);
734
757
  });
735
- const NUDGE_SKIP = ["update", "completions", "completion-candidates"];
758
+ const NUDGE_SKIP = ["setup-base", "update", "completions", "completion-candidates"];
736
759
  if (rawArgv.length > 0 && !NUDGE_SKIP.includes(rawArgv[0])) {
737
760
  maybeNudge();
738
761
  }
@@ -794,6 +817,9 @@ if (rawArgv.length === 0) {
794
817
  process.stdout.write(groupedHelp());
795
818
  process.exit(0);
796
819
  }
820
+ if (rawArgv[0] === "setup-base") {
821
+ process.exit(handleSetupBase(rawArgv.slice(1)));
822
+ }
797
823
  const preflightBypass = /* @__PURE__ */ new Set([
798
824
  ...HIDDEN,
799
825
  "completions"
package/docs/RECIPES.md CHANGED
@@ -64,8 +64,33 @@ Rules:
64
64
  - Use real product paths; never mutate hidden state to fabricate proof.
65
65
  - Prove preparation with an independent read/assert or visible UI postcondition.
66
66
  - Keep ticket-specific claims in task-local recipes.
67
- - Parameterize repeated behavior instead of multiplying names.
67
+ - Compose, repair, or parameterize repeated behavior instead of multiplying names.
68
68
  - Keep secrets out of recipes, libraries, and evidence.
69
+ - Limit claims to the boundary actually exercised. In-app navigation cannot
70
+ prove external ingress, and security-boundary proofs require accepted and
71
+ rejected cases.
72
+
73
+ ## Classify failures before changing either layer
74
+
75
+ Recipes are maintained proof assets, not immutable descriptions of the app.
76
+ Locators, labels, routes, and screen structure can legitimately change, so a
77
+ failed recipe is not by itself evidence of either recipe drift or a product
78
+ regression.
79
+
80
+ 1. Reproduce the failing node and compare it with authoritative acceptance
81
+ criteria, product contracts, or known-good evidence.
82
+ 2. Inspect the current product surface and resolved recipe source, then classify
83
+ the cause as product regression, recipe drift, fixture/runtime drift, or
84
+ harness defect.
85
+ 3. If an accepted product change establishes recipe drift, repair the smallest
86
+ shared action or recipe node that owns it. Otherwise preserve the failure
87
+ and fix or report the owning product, runtime, or harness layer.
88
+ 4. Rerun the repaired path and every shared recipe that composes it.
89
+ 5. Report the classification, owning source, change, and fresh evidence together.
90
+
91
+ Do not duplicate a stale recipe to avoid maintaining it. Do not change product
92
+ code merely to satisfy an obsolete proof path, or update a recipe merely to
93
+ make a real regression pass.
69
94
 
70
95
  Keep responsibilities narrow:
71
96
 
package/docs/SECURITY.md CHANGED
@@ -31,6 +31,37 @@ Fixture setup is allowed before the proof window when it is explicit in the
31
31
  trace. Mutating setup must finish with a real read/assert or visible UI
32
32
  postcondition.
33
33
 
34
+ ## Signed deeplink boundary
35
+
36
+ Treat every raw or original URL parameter as attacker-controlled. Route handlers
37
+ may receive only canonical parameters authorized by a valid signature and the
38
+ link's `sig_params` list. Never add a handler fallback to original parameters,
39
+ including for attribution or a destination considered read-only.
40
+
41
+ Attribution fields such as `utm_*` may pass through only when they are inside
42
+ the signed parameter set, explicitly allowlisted for the destination, and
43
+ validated for bounded length and character format before use.
44
+
45
+ A publishable signed-deeplink proof covers the whole ingress boundary:
46
+
47
+ 1. a valid, signed, destination-allowlisted, format-valid, within-limit
48
+ attribution value reaches the destination;
49
+ 2. an unsigned candidate parameter reaches neither handler nor destination;
50
+ 3. tampering with a signed value causes authorization failure or interstitial
51
+ demotion, and no candidate parameter reaches handler or destination;
52
+ 4. malformed `sig_params` and invalid signatures cause authorization failure or
53
+ interstitial demotion, and no candidate parameter reaches handler or
54
+ destination;
55
+ 5. a signed attribution key outside the destination allowlist does not reach
56
+ the destination or attribution context;
57
+ 6. signed attribution values with invalid characters or excessive length do
58
+ not reach the destination or attribution context;
59
+ 7. handler observations for accepted links contain canonical values only.
60
+
61
+ Opening the same route from inside an already-running app proves downstream
62
+ navigation or analytics consumption only. It is not evidence for external link
63
+ parsing, signature verification, or canonicalization.
64
+
34
65
  ## Wallet fixtures
35
66
 
36
67
  Each checkout has one canonical fixture:
@@ -0,0 +1,203 @@
1
+ export function consentControllerExpression({
2
+ bridgeSymbol,
3
+ participate,
4
+ marketing,
5
+ timeoutMs,
6
+ }) {
7
+ return `(async () => {
8
+ const submit = globalThis.stateHooks?.submitRequestToBackground;
9
+ const bridgeKey = Symbol.for(${JSON.stringify(bridgeSymbol)});
10
+ const capturedChrome = globalThis[bridgeKey];
11
+ let port;
12
+ let nextId = Date.now();
13
+ // Popup connections are ref-counted, so temporary ports cannot erase the
14
+ // live fullscreen or side-panel registration for the same tab.
15
+ const connectionName = 'popup';
16
+
17
+ const rawSubmit = (method, params) => new Promise((resolve, reject) => {
18
+ if (!port) {
19
+ if (typeof capturedChrome?.runtime?.connect !== 'function') {
20
+ reject(new Error('Extension consent setup could not reach the background controller.'));
21
+ return;
22
+ }
23
+ port = capturedChrome.runtime.connect({ name: connectionName });
24
+ }
25
+ const id = nextId++;
26
+ const timer = setTimeout(() => {
27
+ port.onMessage.removeListener(listener);
28
+ reject(new Error(method + ' timed out after ${Number(timeoutMs)}ms'));
29
+ }, ${Number(timeoutMs)});
30
+ const listener = (message) => {
31
+ const data = message?.name === 'controller' ? message.data : null;
32
+ if (data?.id !== id) return;
33
+ clearTimeout(timer);
34
+ port.onMessage.removeListener(listener);
35
+ if (data.error) reject(new Error(JSON.stringify(data.error)));
36
+ else resolve(data.result);
37
+ };
38
+ port.onMessage.addListener(listener);
39
+ try {
40
+ port.postMessage({
41
+ name: 'controller',
42
+ data: { jsonrpc: '2.0', id, method, params }
43
+ });
44
+ } catch (error) {
45
+ clearTimeout(timer);
46
+ port.onMessage.removeListener(listener);
47
+ reject(error);
48
+ }
49
+ });
50
+
51
+ const callController = typeof submit === 'function' ? submit : rawSubmit;
52
+ const readBackgroundState = () => new Promise((resolve, reject) => {
53
+ if (typeof capturedChrome?.runtime?.connect !== 'function') {
54
+ reject(new Error('Extension consent state is unavailable.'));
55
+ return;
56
+ }
57
+ const statePort = capturedChrome.runtime.connect({ name: connectionName });
58
+ const timer = setTimeout(() => {
59
+ statePort.onMessage.removeListener(listener);
60
+ statePort.disconnect();
61
+ reject(new Error('Extension consent state timed out after ${Number(timeoutMs)}ms.'));
62
+ }, ${Number(timeoutMs)});
63
+ const listener = (message) => {
64
+ const data = message?.name === 'controller' ? message.data : null;
65
+ if (data?.method !== 'START_UI_SYNC') return;
66
+ clearTimeout(timer);
67
+ statePort.onMessage.removeListener(listener);
68
+ statePort.disconnect();
69
+ resolve(data.params?.[0]);
70
+ };
71
+ statePort.onMessage.addListener(listener);
72
+ });
73
+ const readConsentState = async () => {
74
+ const hooks = globalThis.stateHooks || {};
75
+ const storeState = hooks.store?.getState?.();
76
+ const cleanState = typeof hooks.getCleanAppState === 'function'
77
+ ? await hooks.getCleanAppState()
78
+ : undefined;
79
+ const root = capturedChrome
80
+ ? await readBackgroundState()
81
+ : storeState ?? cleanState;
82
+ if (!root) {
83
+ throw new Error('Extension consent state is unavailable.');
84
+ }
85
+ const metamask = root?.metamask ?? root ?? {};
86
+ if (
87
+ typeof metamask.optedIn !== 'boolean' ||
88
+ typeof metamask.dataCollectionForMarketing !== 'boolean'
89
+ ) {
90
+ throw new Error('Extension consent state did not contain boolean consent fields.');
91
+ }
92
+ return {
93
+ state: {
94
+ optedIn: metamask.optedIn,
95
+ dataCollectionForMarketing: metamask.dataCollectionForMarketing,
96
+ analyticsId: metamask.analyticsId ? 'set' : null
97
+ },
98
+ source: capturedChrome
99
+ ? 'background-controller-flat-state'
100
+ : storeState
101
+ ? 'debug-page-store'
102
+ : 'debug-clean-app-state'
103
+ };
104
+ };
105
+ const waitForConsentState = async () => {
106
+ const expected = {
107
+ optedIn: ${JSON.stringify(participate)},
108
+ dataCollectionForMarketing: ${JSON.stringify(marketing)}
109
+ };
110
+ const deadline = Date.now() + ${Number(timeoutMs)};
111
+ let result;
112
+ do {
113
+ result = await readConsentState();
114
+ if (Object.entries(expected).every(([key, value]) => result.state[key] === value)) {
115
+ return result;
116
+ }
117
+ await new Promise((resolve) => setTimeout(resolve, 100));
118
+ } while (Date.now() < deadline);
119
+ throw new Error(
120
+ 'Consent state did not settle to ' + JSON.stringify(expected) +
121
+ '; got ' + JSON.stringify(result?.state) + '.',
122
+ );
123
+ };
124
+ const setMarketing = async (value) => {
125
+ await callController('setDataCollectionForMarketing', [value]);
126
+ };
127
+
128
+ try {
129
+ if (!${JSON.stringify(participate)}) await setMarketing(false);
130
+ const analyticsId = await callController(
131
+ 'setParticipateInMetaMetrics',
132
+ [${JSON.stringify(participate)}],
133
+ );
134
+ if (${JSON.stringify(participate)}) {
135
+ await setMarketing(${JSON.stringify(marketing)});
136
+ }
137
+ const settled = await waitForConsentState();
138
+ return {
139
+ analyticsId: analyticsId ? 'set' : null,
140
+ state: settled.state,
141
+ stateSource: settled.source
142
+ };
143
+ } finally {
144
+ port?.disconnect();
145
+ if (capturedChrome) delete globalThis[bridgeKey];
146
+ }
147
+ })()`;
148
+ }
149
+
150
+ export async function withConsentChromeBridge(page, {
151
+ bridgeSymbol,
152
+ timeoutMs,
153
+ }, operation) {
154
+ const preload = await page.session.call('Page.addScriptToEvaluateOnNewDocument', {
155
+ source: `Object.defineProperty(
156
+ globalThis,
157
+ Symbol.for(${JSON.stringify(bridgeSymbol)}),
158
+ { value: globalThis.chrome, configurable: true }
159
+ );`,
160
+ });
161
+ if (!preload?.identifier) {
162
+ throw new Error('Extension consent setup could not register its temporary Chrome bridge.');
163
+ }
164
+
165
+ try {
166
+ let notifyLoaded;
167
+ const loaded = new Promise((resolve) => {
168
+ notifyLoaded = resolve;
169
+ });
170
+ const unsubscribe = page.session.on('Page.loadEventFired', () => notifyLoaded?.());
171
+ let reloadTimeout;
172
+ try {
173
+ await page.session.call('Page.reload', { ignoreCache: false });
174
+ await Promise.race([
175
+ loaded,
176
+ new Promise((_, reject) => {
177
+ reloadTimeout = setTimeout(
178
+ () => reject(new Error(`Extension consent reload timed out after ${timeoutMs}ms.`)),
179
+ timeoutMs,
180
+ );
181
+ }),
182
+ ]);
183
+ } finally {
184
+ clearTimeout(reloadTimeout);
185
+ unsubscribe();
186
+ }
187
+ await page.waitForExpression(
188
+ `typeof globalThis[Symbol.for(${JSON.stringify(bridgeSymbol)})]?.runtime?.connect === 'function'`,
189
+ { timeoutMs },
190
+ );
191
+ return await operation();
192
+ } finally {
193
+ try {
194
+ await page.session.call('Page.removeScriptToEvaluateOnNewDocument', {
195
+ identifier: preload.identifier,
196
+ });
197
+ } finally {
198
+ await page.evaluate(
199
+ `delete globalThis[Symbol.for(${JSON.stringify(bridgeSymbol)})]`,
200
+ );
201
+ }
202
+ }
203
+ }