@deeeed/metamask-harness 0.25.0 → 0.26.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/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.26.0 - 2026-07-31
6
+
7
+ ### Added
8
+
9
+ - Allow analytics event property assertions to use `$exists`, `$type`, and `$gt` predicates for dynamic runtime values.
10
+
11
+ ### Fixed
12
+
13
+ - Avoid direct `globalThis.chrome` access when reading Extension Perps runtime state, so state preparation works with LavaMoat scuttling enabled.
14
+ - Add a harness-owned non-test LavaMoat build mode for production-like Extension proof, while continuing to configure runtime snapshots with the validated Infura project ID. This avoids the placeholder credentials embedded by Extension `--test` builds.
15
+ - Keep macOS awake for the lifetime of a harness-launched Extension browser so long live recipes are not invalidated by display-lock compositor suspension.
16
+ - Retry post-interaction CDP settlement in an isolated world when navigation replaces the page context.
17
+
18
+ ## 0.25.1 - 2026-07-30
19
+
20
+ ### Fixed
21
+
22
+ - Source-checkout dependency readiness works under restrictive caller umasks and uses an isolated npm cache instead of inheriting a broken global cache.
23
+
5
24
  ## 0.25.0 - 2026-07-30
6
25
 
7
26
  ### Added
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+ # build-lavamoat.sh — one-shot production-like Extension build for live proof
3
+ set -euo pipefail
4
+
5
+ TARGET="$PWD"
6
+ RUNTIME_DIR="temp/recipe/runtime"
7
+ while [ "$#" -gt 0 ]; do
8
+ case "$1" in
9
+ --target) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; TARGET="$2"; shift 2 ;;
10
+ --runtime-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; RUNTIME_DIR="$2"; shift 2 ;;
11
+ -h|--help) echo "Usage: build-lavamoat.sh [--target <metamask-extension>] [--runtime-dir <relative path>]"; exit 0 ;;
12
+ *) echo "Unknown arg: $1" >&2; exit 2 ;;
13
+ esac
14
+ done
15
+
16
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ for _arn in "$SCRIPT_DIR/lib/activate-repo-node.sh" "$SCRIPT_DIR/../shared/activate-repo-node.sh"; do
18
+ [ -f "$_arn" ] && { # shellcheck disable=SC1090
19
+ . "$_arn"
20
+ break
21
+ }
22
+ done
23
+ unset _arn
24
+
25
+ TARGET="$(cd "$TARGET" && pwd)"
26
+ cd "$TARGET"
27
+ if command -v require_repo_node >/dev/null 2>&1; then
28
+ require_repo_node "$TARGET"
29
+ elif command -v activate_repo_node >/dev/null 2>&1; then
30
+ activate_repo_node "$TARGET"
31
+ else
32
+ echo "build-lavamoat: activate-repo-node.sh missing from harness; run: mm-harness install" >&2
33
+ exit 1
34
+ fi
35
+
36
+ for pid_file in "$RUNTIME_DIR/webpack.pid" "$RUNTIME_DIR/recipe-harness-webpack.pid"; do
37
+ [ -f "$pid_file" ] || continue
38
+ pid="$(cat "$pid_file" 2>/dev/null || true)"
39
+ if [ -n "$pid" ] && kill -0 "$pid" >/dev/null 2>&1; then
40
+ echo "build-lavamoat: a webpack watcher already owns this checkout (pid $pid). Next: stop the slot watcher and retry." >&2
41
+ exit 1
42
+ fi
43
+ rm -f "$pid_file"
44
+ done
45
+
46
+ # A one-shot build has no watch health to report. Remove logs left by a dead
47
+ # watcher so readiness does not mistake an old failure for the current build.
48
+ rm -f "$RUNTIME_DIR/webpack.log" "$RUNTIME_DIR/recipe-harness-webpack.log"
49
+
50
+ echo "[recipe-harness] clean non-test LavaMoat build"
51
+ rm -rf node_modules/.cache/webpack
52
+ yarn webpack:lavamoat:build
53
+ test -f dist/chrome/manifest.json || {
54
+ echo "build-lavamoat: build completed without dist/chrome/manifest.json" >&2
55
+ exit 1
56
+ }
57
+ echo "[recipe-harness] non-test LavaMoat build completed"
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const {
7
+ resolveExtensionInfuraProjectId,
8
+ } = require('./lib/product-config.cjs');
9
+
10
+ const usage =
11
+ 'Usage: configure-runtime-manifest.cjs --target <checkout> --manifest <runtime manifest>';
12
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
13
+ process.stdout.write(`${usage}\n`);
14
+ process.exit(0);
15
+ }
16
+
17
+ function parseArgs(argv) {
18
+ const args = {};
19
+ for (let index = 0; index < argv.length; index += 1) {
20
+ const flag = argv[index];
21
+ if (!flag.startsWith('--') || index + 1 >= argv.length) {
22
+ throw new Error(usage);
23
+ }
24
+ args[flag.slice(2)] = argv[index + 1];
25
+ index += 1;
26
+ }
27
+ return args;
28
+ }
29
+
30
+ const args = parseArgs(process.argv.slice(2));
31
+ if (!args.target || !args.manifest) {
32
+ throw new Error(usage);
33
+ }
34
+
35
+ const target = path.resolve(args.target);
36
+ const manifestPath = path.resolve(args.manifest);
37
+ const resolution = resolveExtensionInfuraProjectId(target, process.env);
38
+ if (resolution.kind === 'not-required') {
39
+ process.stdout.write(
40
+ 'Runtime manifest credentials are not required by this checkout.\n',
41
+ );
42
+ process.exit(0);
43
+ }
44
+ if (resolution.kind !== 'configured') {
45
+ throw new Error(
46
+ `Extension runtime has no usable INFURA_PROJECT_ID for ${target}. Next: run mm-harness doctor --adapter extension --target ${JSON.stringify(target)} --fix.`,
47
+ );
48
+ }
49
+
50
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
51
+ manifest._flags ??= {};
52
+ manifest._flags.testing ??= {};
53
+ manifest._flags.testing.infuraProjectId = resolution.value;
54
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, {
55
+ mode: 0o600,
56
+ });
57
+ fs.chmodSync(manifestPath, 0o600);
58
+ process.stdout.write('Configured runtime Infura credentials through manifest flags.\n');
@@ -99,8 +99,10 @@ copyFile(path.join(runnerDir, 'adapters/extension/console-tail.mjs'), path.join(
99
99
  copyFile(path.join(runnerDir, 'adapters/extension/launch.sh'), path.join(harnessDir, 'scripts/launch.sh'));
100
100
  copyFile(path.join(runnerDir, 'adapters/extension/live.sh'), path.join(harnessDir, 'scripts/live.sh'));
101
101
  copyFile(path.join(runnerDir, 'adapters/extension/start-watch.sh'), path.join(harnessDir, 'scripts/start-watch.sh'));
102
+ copyFile(path.join(runnerDir, 'adapters/extension/build-lavamoat.sh'), path.join(harnessDir, 'scripts/build-lavamoat.sh'));
102
103
  copyFile(path.join(runnerDir, 'adapters/extension/launch-webpack.cjs'), path.join(harnessDir, 'scripts/launch-webpack.cjs'));
103
104
  copyFile(path.join(runnerDir, 'adapters/extension/sync-webpack-dist.cjs'), path.join(harnessDir, 'scripts/sync-webpack-dist.cjs'));
105
+ copyFile(path.join(runnerDir, 'adapters/extension/configure-runtime-manifest.cjs'), path.join(harnessDir, 'scripts/configure-runtime-manifest.cjs'));
104
106
  copyFile(path.join(runnerDir, 'adapters/extension/stamp-runtime-title.cjs'), path.join(harnessDir, 'scripts/stamp-runtime-title.cjs'));
105
107
  copyFile(path.join(runnerDir, 'adapters/extension/stop-viewers.sh'), path.join(harnessDir, 'scripts/stop-viewers.sh'));
106
108
  copyFile(path.join(runnerDir, 'adapters/extension/snapshot-dist.sh'), path.join(harnessDir, 'scripts/snapshot-dist.sh'));
@@ -127,6 +129,7 @@ copyFile(path.join(runnerDir, 'adapters/extension/lib/extension-id.cjs'), path.j
127
129
  copyFile(path.join(runnerDir, 'adapters/extension/lib/slot-title.cjs'), path.join(harnessDir, 'scripts/lib/slot-title.cjs'));
128
130
  copyFile(path.join(runnerDir, 'adapters/extension/lib/chrome-args.cjs'), path.join(harnessDir, 'scripts/lib/chrome-args.cjs'));
129
131
  copyFile(path.join(runnerDir, 'adapters/extension/lib/macos-focus.cjs'), path.join(harnessDir, 'scripts/lib/macos-focus.cjs'));
132
+ copyFile(path.join(runnerDir, 'adapters/extension/lib/product-config.cjs'), path.join(harnessDir, 'scripts/lib/product-config.cjs'));
130
133
  makeExecutableTree(path.join(harnessDir, 'scripts'));
131
134
  fs.writeFileSync(path.join(harnessDir, 'installed-scripts.sha256'), `${dirContentHash(path.join(harnessDir, 'scripts'))}\n`);
132
135
 
@@ -144,6 +144,13 @@ try {
144
144
  fs.closeSync(logFd);
145
145
  }
146
146
  fs.writeFileSync(args['chrome-pid'], `${browserPid}\n`);
147
+ if (process.platform === 'darwin') {
148
+ const wake = spawn('caffeinate', ['-dimsu', '-w', String(browserPid)], {
149
+ detached: true,
150
+ stdio: 'ignore',
151
+ });
152
+ wake.unref();
153
+ }
147
154
  if (process.env.MM_HARNESS_FOCUS_BROWSER !== '1') {
148
155
  restoreMacFrontmostProcess(previousFrontmostPid);
149
156
  }
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ function isConfiguredInfuraId(value, placeholder) {
7
+ return (
8
+ value.length > 0 &&
9
+ value !== placeholder &&
10
+ value !== 'true' &&
11
+ value !== 'false' &&
12
+ value !== 'null'
13
+ );
14
+ }
15
+
16
+ function readConfigSnapshot(file) {
17
+ let descriptor;
18
+ try {
19
+ descriptor = fs.openSync(
20
+ file,
21
+ fs.constants.O_RDONLY |
22
+ fs.constants.O_NOFOLLOW |
23
+ fs.constants.O_NONBLOCK,
24
+ );
25
+ } catch (error) {
26
+ if (error.code === 'ENOENT') return { kind: 'missing' };
27
+ if (error.code === 'ELOOP') {
28
+ return { kind: 'invalid', reason: 'symlink' };
29
+ }
30
+ return { kind: 'invalid', reason: 'unreadable' };
31
+ }
32
+ try {
33
+ if (!fs.fstatSync(descriptor).isFile()) {
34
+ return { kind: 'invalid', reason: 'not-regular' };
35
+ }
36
+ return { kind: 'file', text: fs.readFileSync(descriptor, 'utf8') };
37
+ } finally {
38
+ fs.closeSync(descriptor);
39
+ }
40
+ }
41
+
42
+ function readInfuraProjectId(text) {
43
+ const lines = text.replace(/\r\n?/gu, '\n');
44
+ const pattern =
45
+ /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gmu;
46
+ let result;
47
+ for (const match of lines.matchAll(pattern)) {
48
+ if (match[1] !== 'INFURA_PROJECT_ID') continue;
49
+ let value = (match[2] ?? '').trim();
50
+ const quote = value[0];
51
+ value = value.replace(/^(['"`])([\s\S]*)\1$/u, '$2');
52
+ if (quote === '"') {
53
+ value = value.replace(/\\n/gu, '\n').replace(/\\r/gu, '\r');
54
+ }
55
+ result = value;
56
+ }
57
+ return result;
58
+ }
59
+
60
+ function resolveExtensionInfuraProjectId(target, environment = process.env) {
61
+ const template = readConfigSnapshot(path.join(target, '.metamaskrc.dist'));
62
+ if (template.kind === 'missing') return { kind: 'not-required' };
63
+ if (template.kind === 'invalid') {
64
+ return {
65
+ kind: 'invalid-file',
66
+ name: '.metamaskrc.dist',
67
+ reason: template.reason,
68
+ };
69
+ }
70
+ const placeholder = readInfuraProjectId(template.text) ?? '';
71
+
72
+ if (environment.INFURA_PROJECT_ID !== undefined) {
73
+ const value = environment.INFURA_PROJECT_ID;
74
+ return isConfiguredInfuraId(value, placeholder)
75
+ ? { kind: 'configured', source: 'environment', value }
76
+ : { kind: 'invalid-value', source: 'environment' };
77
+ }
78
+
79
+ const production = readConfigSnapshot(path.join(target, '.metamaskprodrc'));
80
+ if (production.kind === 'invalid') {
81
+ return {
82
+ kind: 'invalid-file',
83
+ name: '.metamaskprodrc',
84
+ reason: production.reason,
85
+ };
86
+ }
87
+ if (production.kind === 'file') {
88
+ const value = readInfuraProjectId(production.text);
89
+ if (value !== undefined) {
90
+ return isConfiguredInfuraId(value, placeholder)
91
+ ? { kind: 'configured', source: '.metamaskprodrc', value }
92
+ : { kind: 'invalid-value', source: '.metamaskprodrc' };
93
+ }
94
+ }
95
+
96
+ const config = readConfigSnapshot(path.join(target, '.metamaskrc'));
97
+ if (config.kind === 'missing') {
98
+ return { kind: 'missing', name: '.metamaskrc' };
99
+ }
100
+ if (config.kind === 'invalid') {
101
+ return {
102
+ kind: 'invalid-file',
103
+ name: '.metamaskrc',
104
+ reason: config.reason,
105
+ };
106
+ }
107
+ const value = readInfuraProjectId(config.text) ?? '';
108
+ return isConfiguredInfuraId(value, placeholder)
109
+ ? { kind: 'configured', source: '.metamaskrc', value }
110
+ : { kind: 'invalid-value', source: '.metamaskrc' };
111
+ }
112
+
113
+ module.exports = {
114
+ readInfuraProjectId,
115
+ resolveExtensionInfuraProjectId,
116
+ };
@@ -0,0 +1,16 @@
1
+ type ConfigName = '.metamaskrc.dist' | '.metamaskprodrc' | '.metamaskrc';
2
+ type ConfigInvalidReason = 'symlink' | 'not-regular' | 'unreadable';
3
+ type ConfigSource = 'environment' | '.metamaskprodrc' | '.metamaskrc';
4
+
5
+ type ExtensionInfuraProjectIdResolution =
6
+ | { kind: 'configured'; source: ConfigSource; value: string }
7
+ | { kind: 'not-required' }
8
+ | { kind: 'invalid-file'; name: ConfigName; reason: ConfigInvalidReason }
9
+ | { kind: 'missing'; name: '.metamaskrc' }
10
+ | { kind: 'invalid-value'; source: ConfigSource };
11
+
12
+ export function readInfuraProjectId(text: string): string | undefined;
13
+ export function resolveExtensionInfuraProjectId(
14
+ target: string,
15
+ environment?: NodeJS.ProcessEnv,
16
+ ): ExtensionInfuraProjectIdResolution;
@@ -11,7 +11,7 @@
11
11
  # Inputs (flags / env):
12
12
  # --target <metamask-extension> (default $PWD)
13
13
  # --cdp-port <port> (optional; inferred from checkout context/pool when omitted)
14
- # --launch-existing-dist | --start-watch | --prepare-cmd <cmd>
14
+ # --launch-existing-dist | --start-watch | --build-lavamoat | --prepare-cmd <cmd>
15
15
  # (env RECIPE_HARNESS_EXTENSION_LAUNCH_CMD)
16
16
  # --dist-dir <rel> (default dist/chrome), --chrome-user-data-dir <dir>,
17
17
  # --remote-flag <KEY=VARIANT[,KEY=VARIANT...]> (pins manifest _flags into the
@@ -39,6 +39,7 @@ OUT=""
39
39
  PREPARE_CMD="${RECIPE_HARNESS_EXTENSION_LAUNCH_CMD:-}"
40
40
  LAUNCH_EXISTING_DIST=false
41
41
  START_WATCH=false
42
+ BUILD_LAVAMOAT=false
42
43
  DIST_DIR="dist/chrome"
43
44
  CHROME_USER_DATA_DIR="${CHROME_USER_DATA_DIR:-}"
44
45
  REMOTE_FLAGS=""
@@ -53,14 +54,20 @@ while [ "$#" -gt 0 ]; do
53
54
  --launch-existing-dist) LAUNCH_EXISTING_DIST=true; shift ;;
54
55
  --start-url) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; START_URL="$2"; shift 2 ;;
55
56
  --start-watch|--start-test-watch) START_WATCH=true; LAUNCH_EXISTING_DIST=true; shift ;;
57
+ --build-lavamoat) BUILD_LAVAMOAT=true; LAUNCH_EXISTING_DIST=true; shift ;;
56
58
  --dist-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; DIST_DIR="$2"; shift 2 ;;
57
59
  --chrome-user-data-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; CHROME_USER_DATA_DIR="$2"; shift 2 ;;
58
60
  --remote-flag) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; REMOTE_FLAGS="$2"; shift 2 ;;
59
- -h|--help) echo "Usage: live.sh [--target <metamask-extension>] [--out <recipes-dir>] [--cdp-port <port>] [--launch-existing-dist|--start-watch|--prepare-cmd <cmd>] [--dist-dir dist/chrome] [--remote-flag KEY=VARIANT] [--artifacts-dir <dir>]"; exit 0 ;;
61
+ -h|--help) echo "Usage: live.sh [--target <metamask-extension>] [--out <recipes-dir>] [--cdp-port <port>] [--launch-existing-dist|--start-watch|--build-lavamoat|--prepare-cmd <cmd>] [--dist-dir dist/chrome] [--remote-flag KEY=VARIANT] [--artifacts-dir <dir>]"; exit 0 ;;
60
62
  *) echo "Unknown arg: $1" >&2; exit 2 ;;
61
63
  esac
62
64
  done
63
65
 
66
+ if $BUILD_LAVAMOAT && $START_WATCH; then
67
+ echo "--build-lavamoat cannot be combined with --start-watch" >&2
68
+ exit 2
69
+ fi
70
+
64
71
  case "$CHROME_USER_DATA_DIR" in
65
72
  ""|/*) ;;
66
73
  *) CHROME_USER_DATA_DIR="$INVOCATION_DIR/$CHROME_USER_DATA_DIR" ;;
@@ -167,7 +174,9 @@ NODE
167
174
  quoted_profile="$(printf '%q' "$PROFILE_ABS")"
168
175
  quoted_seed_fixture="$(printf '%q' "$SCRIPT_DIR/seed-fixture.sh")"
169
176
  quoted_start_watch="$(printf '%q' "$SCRIPT_DIR/start-watch.sh")"
177
+ quoted_build_lavamoat="$(printf '%q' "$SCRIPT_DIR/build-lavamoat.sh")"
170
178
  quoted_snapshot_dist="$(printf '%q' "$SCRIPT_DIR/snapshot-dist.sh")"
179
+ quoted_configure_manifest="$(printf '%q' "$SCRIPT_DIR/configure-runtime-manifest.cjs")"
171
180
  quoted_stamp_title="$(printf '%q' "$SCRIPT_DIR/stamp-runtime-title.cjs")"
172
181
  quoted_chrome_launcher="$(printf '%q' "$SCRIPT_DIR/launch-browser.cjs")"
173
182
  quoted_fixture_state="$(printf '%q' "$FIXTURE_STATE_ABS")"
@@ -245,8 +254,12 @@ NODE
245
254
  if $START_WATCH; then
246
255
  prepare_parts+=("bash ${quoted_start_watch} --runtime-dir ${quoted_runtime_dir} --runner-bin ${quoted_runner}")
247
256
  fi
257
+ if $BUILD_LAVAMOAT; then
258
+ prepare_parts+=("bash ${quoted_build_lavamoat} --target ${quoted_target} --runtime-dir ${quoted_runtime_dir}")
259
+ fi
248
260
  prepare_parts+=("node ${quoted_chrome_launcher} --stop-only 1 --reset-profile 1 --chrome-bin ${quoted_chrome} --profile ${quoted_profile} --cdp-port ${CDP_PORT} --extension-dir ${quoted_runtime_dist} --chrome-log ${quoted_chrome_log} --chrome-pid ${quoted_chrome_pid}")
249
261
  prepare_parts+=("bash ${quoted_snapshot_dist} --dist ${quoted_dist} --runtime-dist ${quoted_runtime_dist}")
262
+ prepare_parts+=("node ${quoted_configure_manifest} --target ${quoted_target} --manifest ${quoted_runtime_dist}/manifest.json")
250
263
  prepare_parts+=("node ${quoted_stamp_title} --target ${quoted_target} --runtime-dist ${quoted_runtime_dist} --runtime-dir ${quoted_runtime_dir}")
251
264
  # Optional A/B feature-flag pinning: patch the ephemeral snapshot manifest so
252
265
  # manifest._flags wins over the fetched ClientConfigApi value. No-op unless
@@ -275,12 +288,13 @@ NODE
275
288
  fi
276
289
 
277
290
  if [ -n "$REMOTE_FLAGS" ] && [ "$REMOTE_FLAGS_APPLIED" != "true" ]; then
278
- echo "[recipe-harness] WARN: --remote-flag '$REMOTE_FLAGS' had no effect: flag pinning only applies to the snapshot launch path (--launch-existing-dist/--start-watch without a custom --prepare-cmd)." >&2
291
+ echo "[recipe-harness] WARN: --remote-flag '$REMOTE_FLAGS' had no effect: flag pinning only applies to the snapshot launch path (--launch-existing-dist/--start-watch/--build-lavamoat without a custom --prepare-cmd)." >&2
279
292
  fi
280
293
 
281
294
  echo "Extension live validation command:"
282
295
  display_args=(mm-harness runtime-launch --adapter extension --target "$TARGET" --cdp-port "$CDP_PORT")
283
296
  $START_WATCH && display_args+=(--start-watch)
297
+ $BUILD_LAVAMOAT && display_args+=(--build-lavamoat)
284
298
  [ -n "$REMOTE_FLAGS" ] && display_args+=(--remote-flag "$REMOTE_FLAGS")
285
299
  [ -n "$CHROME_USER_DATA_DIR" ] && display_args+=(--chrome-user-data-dir "$CHROME_USER_DATA_DIR")
286
300
  printf ' '
@@ -309,7 +323,7 @@ else
309
323
  echo "Skipping Extension live verify because launch failed; see $ARTIFACTS/launch/summary.json" >&2
310
324
  fi
311
325
 
312
- TARGET_FOR_SUMMARY="$TARGET" ARTIFACTS_FOR_SUMMARY="$ARTIFACTS" CDP_PORT_FOR_SUMMARY="$CDP_PORT" LAUNCH_STATUS="$launch_status" VERIFY_STATUS="$verify_status" LAUNCH_EXISTING_DIST="$LAUNCH_EXISTING_DIST" START_WATCH="$START_WATCH" node <<'NODE'
326
+ TARGET_FOR_SUMMARY="$TARGET" ARTIFACTS_FOR_SUMMARY="$ARTIFACTS" CDP_PORT_FOR_SUMMARY="$CDP_PORT" LAUNCH_STATUS="$launch_status" VERIFY_STATUS="$verify_status" LAUNCH_EXISTING_DIST="$LAUNCH_EXISTING_DIST" START_WATCH="$START_WATCH" BUILD_LAVAMOAT="$BUILD_LAVAMOAT" node <<'NODE'
313
327
  const fs = require('fs');
314
328
  const path = require('path');
315
329
  const artifacts = process.env.ARTIFACTS_FOR_SUMMARY;
@@ -325,6 +339,7 @@ fs.writeFileSync(path.join(artifacts, 'summary.json'), `${JSON.stringify({
325
339
  cdpPort: process.env.CDP_PORT_FOR_SUMMARY,
326
340
  launchExistingDist: process.env.LAUNCH_EXISTING_DIST === 'true',
327
341
  startWatch: process.env.START_WATCH === 'true',
342
+ buildLavaMoat: process.env.BUILD_LAVAMOAT === 'true',
328
343
  launch: { exitCode: launchStatus, summaryPath: fs.existsSync(launchSummary) ? launchSummary : null },
329
344
  verify: { exitCode: verifyStatus, summaryPath: fs.existsSync(verifySummary) ? verifySummary : null },
330
345
  easyCommand: `mm-harness launch --verify --target <repo>`,
@@ -103,6 +103,7 @@ echo "[reattach] reusing loaded runtime-dist: $RUNTIME_DIST_ABS" >&2
103
103
  if [ -d "$DIST_ABS" ] && [ -d "$RUNTIME_DIST_ABS" ]; then
104
104
  echo "[reattach] refreshing loaded runtime-dist in place" >&2
105
105
  rsync -a --exclude _metadata "$DIST_ABS/" "$RUNTIME_DIST_ABS/" >&2
106
+ node "$SCRIPT_DIR/configure-runtime-manifest.cjs" --target "$TARGET" --manifest "$RUNTIME_DIST_ABS/manifest.json" >&2
106
107
  node "$SCRIPT_DIR/stamp-runtime-title.cjs" --target "$TARGET" --runtime-dist "$RUNTIME_DIST_ABS" --runtime-dir "$RUNTIME_DIR" >&2
107
108
  fi
108
109
 
@@ -58,6 +58,23 @@ function syncRuntimeDist() {
58
58
  process.stderr.write(`runtime-dist sync failed: ${(result.stderr || result.stdout || `rsync exited ${result.status}`).trim()}\n`);
59
59
  return;
60
60
  }
61
+ const configure = spawnSync(
62
+ process.execPath,
63
+ [
64
+ path.join(__dirname, 'configure-runtime-manifest.cjs'),
65
+ '--target',
66
+ path.resolve(args.target),
67
+ '--manifest',
68
+ path.join(runtimeDist, 'manifest.json'),
69
+ ],
70
+ { encoding: 'utf8' },
71
+ );
72
+ if (configure.status !== 0) {
73
+ process.stderr.write(
74
+ `runtime-dist configuration failed: ${(configure.stderr || configure.stdout || `node exited ${configure.status}`).trim()}\n`,
75
+ );
76
+ return;
77
+ }
61
78
  stampRuntimeTitles({ target: path.resolve(args.target), runtimeDist });
62
79
  process.stdout.write(`runtime-dist updated after webpack compile (${new Date().toISOString()})\n`);
63
80
  }
@@ -119,7 +119,7 @@
119
119
  "entry": "adapters/extension/live.sh",
120
120
  "kind": "bash",
121
121
  "purpose": "Sequencer: builds the prepare command from the named feature scripts, then launch + verify.",
122
- "inputs": "--target --cdp-port --launch-existing-dist|--start-watch|--prepare-cmd --dist-dir --chrome-user-data-dir --out --artifacts-dir; env RECIPE_HARNESS_CHROME_BIN, RECIPE_WALLET_FIXTURE, RECIPE_HARNESS_LIVE_KEEP",
122
+ "inputs": "--target --cdp-port --launch-existing-dist|--start-watch|--build-lavamoat|--prepare-cmd --dist-dir --chrome-user-data-dir --out --artifacts-dir; env RECIPE_HARNESS_CHROME_BIN, RECIPE_WALLET_FIXTURE, RECIPE_HARNESS_LIVE_KEEP",
123
123
  "outputs": "<artifacts>/summary.json + launch/ + verify/ + logs/fixture-source.json; exit 0/1/2"
124
124
  },
125
125
  {
@@ -138,6 +138,14 @@
138
138
  "inputs": "--target --runtime-dir --runner-bin --summary",
139
139
  "outputs": "<runtime-dir>/recipe-harness-webpack.{pid,log}; optional summary; exit 0/1/2"
140
140
  },
141
+ {
142
+ "id": "extension/build-lavamoat",
143
+ "entry": "adapters/extension/build-lavamoat.sh",
144
+ "kind": "bash",
145
+ "purpose": "Build a production-like Extension runtime with LavaMoat without enabling test-build fixture semantics.",
146
+ "inputs": "--target --runtime-dir",
147
+ "outputs": "dist/chrome; exit 0/1/2"
148
+ },
141
149
  {
142
150
  "id": "extension/launch-webpack",
143
151
  "entry": "adapters/extension/launch-webpack.cjs",
@@ -154,6 +162,14 @@
154
162
  "inputs": "--watcher-pid --watch-log --target --dist --runtime-dist --pid-file",
155
163
  "outputs": "runtime-dist updated in place after successful compile markers; exits with its watcher"
156
164
  },
165
+ {
166
+ "id": "extension/configure-runtime-manifest",
167
+ "entry": "adapters/extension/configure-runtime-manifest.cjs",
168
+ "kind": "node",
169
+ "purpose": "Inject the checkout's configured Infura project id into an isolated runtime manifest so Extension test builds cannot trigger browser-native authentication prompts.",
170
+ "inputs": "--target --manifest",
171
+ "outputs": "runtime manifest _flags.testing.infuraProjectId; exit 0/1"
172
+ },
157
173
  {
158
174
  "id": "extension/stamp-runtime-title",
159
175
  "entry": "adapters/extension/stamp-runtime-title.cjs",
@@ -402,6 +418,14 @@
402
418
  "inputs": "ensure-runner-deps.sh [RUNNER_DIR]; env FARMSLOT_ROOT / METAMASK_RUNNER_PROTOCOL_ROOT optional local link target",
403
419
  "outputs": "installed node_modules in RUNNER_DIR; exit 0 when deps ready"
404
420
  },
421
+ {
422
+ "id": "lib/runner-deps-ready",
423
+ "entry": "adapters/shared/runner-deps-ready.mjs",
424
+ "kind": "node",
425
+ "purpose": "Validate installed runner dependency versions, accepting only explicit local protocol-root links as development overrides.",
426
+ "inputs": "<runner-dir> [local-protocol-root]",
427
+ "outputs": "exit 0 when dependencies satisfy package.json; exit 1 otherwise"
428
+ },
405
429
  {
406
430
  "id": "lib/install-repo-deps",
407
431
  "entry": "adapters/shared/install-repo-deps.sh",
@@ -10,9 +10,9 @@
10
10
  # Env: FARMSLOT_ROOT / METAMASK_RUNNER_PROTOCOL_ROOT — optional local link target
11
11
  set -euo pipefail
12
12
 
13
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
14
  RUNNER_DIR="${1:-}"
14
15
  if [ -z "$RUNNER_DIR" ]; then
15
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
16
  RUNNER_DIR="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
17
17
  fi
18
18
  RUNNER_DIR="$(cd "$RUNNER_DIR" && pwd -P)"
@@ -23,59 +23,8 @@ if [ -z "$PROTOCOL_ROOT" ] && [ -f "$RUNNER_DIR/.farmslot-root" ]; then
23
23
  fi
24
24
 
25
25
  deps_ready() {
26
- node --input-type=module - "$RUNNER_DIR" "$PROTOCOL_ROOT" <<'NODE'
27
- import fs from 'node:fs';
28
- import { createRequire } from 'node:module';
29
- import path from 'node:path';
30
- import { pathToFileURL } from 'node:url';
31
-
32
- const runnerDir = process.argv[2];
33
- const overrideRootInput = process.argv[3];
34
- const pkg = JSON.parse(fs.readFileSync(path.join(runnerDir, 'package.json'), 'utf8'));
35
- let dependencyVersionSatisfies;
36
- try {
37
- const requireFromRunner = createRequire(path.join(runnerDir, 'package.json'));
38
- const helperPath = requireFromRunner.resolve(
39
- '@farmslot/recipe-harness/runtime/deps-readiness',
40
- );
41
- ({ dependencyVersionSatisfies } = await import(pathToFileURL(helperPath).href));
42
- if (typeof dependencyVersionSatisfies !== 'function') process.exit(1);
43
- } catch {
44
- process.exit(1);
45
- }
46
- let overrideRoot = null;
47
- if (overrideRootInput) {
48
- try {
49
- overrideRoot = fs.realpathSync(overrideRootInput);
50
- } catch {
51
- overrideRoot = null;
52
- }
53
- }
54
- function isOverrideLink(packageDir) {
55
- if (!overrideRoot) return false;
56
- try {
57
- if (!fs.lstatSync(packageDir).isSymbolicLink()) return false;
58
- const resolved = fs.realpathSync(packageDir);
59
- const relative = path.relative(overrideRoot, resolved);
60
- return relative === '' || (
61
- relative !== '..' &&
62
- !relative.startsWith(`..${path.sep}`) &&
63
- !path.isAbsolute(relative)
64
- );
65
- } catch {
66
- return false;
67
- }
68
- }
69
- const invalid = Object.entries(pkg.dependencies ?? {}).filter(([name, request]) => {
70
- const packageDir = path.join(runnerDir, 'node_modules', ...name.split('/'));
71
- const installedPath = path.join(packageDir, 'package.json');
72
- if (!fs.existsSync(installedPath)) return true;
73
- if (isOverrideLink(packageDir)) return false;
74
- const installed = JSON.parse(fs.readFileSync(installedPath, 'utf8'));
75
- return !dependencyVersionSatisfies(installed.version, request);
76
- });
77
- process.exit(invalid.length === 0 ? 0 : 1);
78
- NODE
26
+ node "$SCRIPT_DIR/runner-deps-ready.mjs" \
27
+ "$RUNNER_DIR" "$PROTOCOL_ROOT"
79
28
  }
80
29
 
81
30
  # Library actions import @deeeed/metamask-harness by package name; a source checkout
@@ -127,7 +76,9 @@ fi
127
76
  echo "mm-harness: installing runner dependencies via npm in $RUNNER_DIR" >&2
128
77
  (
129
78
  cd "$RUNNER_DIR"
130
- npm install --ignore-scripts >&2
79
+ umask 022
80
+ npm_config_cache="${MM_HARNESS_NPM_CACHE:-${TMPDIR:-/tmp}/mm-harness-npm-cache-${UID:-$(id -u)}}" \
81
+ npm install --ignore-scripts >&2
131
82
  )
132
83
 
133
84
  # npm may replace the local protocol links with registry packages. Restore the
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
7
+ console.log('Usage: runner-deps-ready.mjs <runner-dir> [local-protocol-root]');
8
+ process.exit(0);
9
+ }
10
+
11
+ const [runnerDir, overrideRootInput] = process.argv.slice(2);
12
+ if (!runnerDir) process.exit(1);
13
+ const pkg = JSON.parse(
14
+ fs.readFileSync(path.join(runnerDir, 'package.json'), 'utf8'),
15
+ );
16
+
17
+ let dependencyVersionSatisfies;
18
+ try {
19
+ const requireFromRunner = createRequire(path.join(runnerDir, 'package.json'));
20
+ const helperPath = requireFromRunner.resolve(
21
+ '@farmslot/recipe-harness/runtime/deps-readiness',
22
+ );
23
+ ({ dependencyVersionSatisfies } = await import(pathToFileURL(helperPath).href));
24
+ if (typeof dependencyVersionSatisfies !== 'function') process.exit(1);
25
+ } catch {
26
+ process.exit(1);
27
+ }
28
+
29
+ let overrideRoot = null;
30
+ if (overrideRootInput) {
31
+ try {
32
+ overrideRoot = fs.realpathSync(overrideRootInput);
33
+ } catch {
34
+ overrideRoot = null;
35
+ }
36
+ }
37
+
38
+ function isOverrideLink(packageDir) {
39
+ if (!overrideRoot) return false;
40
+ try {
41
+ if (!fs.lstatSync(packageDir).isSymbolicLink()) return false;
42
+ const resolved = fs.realpathSync(packageDir);
43
+ const relative = path.relative(overrideRoot, resolved);
44
+ return (
45
+ relative === '' ||
46
+ (relative !== '..' &&
47
+ !relative.startsWith(`..${path.sep}`) &&
48
+ !path.isAbsolute(relative))
49
+ );
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ const invalid = Object.entries(pkg.dependencies ?? {}).filter(
56
+ ([name, request]) => {
57
+ const packageDir = path.join(
58
+ runnerDir,
59
+ 'node_modules',
60
+ ...name.split('/'),
61
+ );
62
+ const installedPath = path.join(packageDir, 'package.json');
63
+ if (!fs.existsSync(installedPath)) return true;
64
+ if (isOverrideLink(packageDir)) return false;
65
+ const installed = JSON.parse(fs.readFileSync(installedPath, 'utf8'));
66
+ return !dependencyVersionSatisfies(installed.version, request);
67
+ },
68
+ );
69
+
70
+ process.exit(invalid.length === 0 ? 0 : 1);
@@ -1,77 +1,40 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
2
+ import productConfig from "../../../adapters/extension/lib/product-config.cjs";
3
3
  import { shellQuote } from "../../commands/parse-args.js";
4
- function isConfiguredInfuraId(value, placeholder) {
5
- return value.length > 0 && value !== placeholder && value !== "true" && value !== "false" && value !== "null";
6
- }
7
4
  function extensionProductConfigBlock(target) {
8
- const template = path.join(target, ".metamaskrc.dist");
9
- const templateSnapshot = readConfigSnapshot(template);
10
- if (templateSnapshot.kind === "missing") return null;
11
- if (templateSnapshot.kind === "invalid") {
12
- return invalidConfigBlock(target, ".metamaskrc.dist", templateSnapshot.reason);
5
+ const resolution = productConfig.resolveExtensionInfuraProjectId(
6
+ target,
7
+ process.env
8
+ );
9
+ if (resolution.kind === "configured" || resolution.kind === "not-required") {
10
+ return null;
11
+ }
12
+ if (resolution.kind === "invalid-file") {
13
+ return invalidConfigBlock(target, resolution.name, resolution.reason);
13
14
  }
14
- const placeholder = readInfuraProjectId(templateSnapshot.text) ?? "";
15
- const configuredFromEnvironment = process.env.INFURA_PROJECT_ID;
16
- if (configuredFromEnvironment !== void 0) {
17
- if (isConfiguredInfuraId(configuredFromEnvironment, placeholder)) return null;
15
+ if (resolution.kind === "missing") {
16
+ return {
17
+ message: "Extension product configuration is required before launch: create .metamaskrc and set a non-placeholder INFURA_PROJECT_ID (the value is never displayed).",
18
+ userAction: `cd ${shellQuote(path.resolve(target))} && cp .metamaskrc.dist .metamaskrc && \${EDITOR:-vi} .metamaskrc`
19
+ };
20
+ }
21
+ if (resolution.source === "environment") {
18
22
  return {
19
23
  message: "Extension product configuration is blocked by an unusable exported INFURA_PROJECT_ID (the value is never displayed).",
20
24
  userAction: `unset INFURA_PROJECT_ID; cd ${shellQuote(path.resolve(target))} && cp -n .metamaskrc.dist .metamaskrc && \${EDITOR:-vi} .metamaskrc`
21
25
  };
22
26
  }
23
- const productionConfig = readConfigSnapshot(path.join(target, ".metamaskprodrc"));
24
- if (productionConfig.kind === "invalid") {
25
- return invalidConfigBlock(target, ".metamaskprodrc", productionConfig.reason);
26
- }
27
- if (productionConfig.kind === "file") {
28
- const value2 = readInfuraProjectId(productionConfig.text);
29
- if (value2 !== void 0) {
30
- if (isConfiguredInfuraId(value2, placeholder)) return null;
31
- return {
32
- message: "Extension product configuration is incomplete: .metamaskprodrc shadows .metamaskrc but does not contain a usable INFURA_PROJECT_ID (the value is never displayed).",
33
- userAction: `cd ${shellQuote(path.resolve(target))} && \${EDITOR:-vi} .metamaskprodrc`
34
- };
35
- }
36
- }
37
- const config = path.join(target, ".metamaskrc");
38
- const configSnapshot = readConfigSnapshot(config);
39
- if (configSnapshot.kind === "missing") {
27
+ if (resolution.source === ".metamaskprodrc") {
40
28
  return {
41
- message: "Extension product configuration is required before launch: create .metamaskrc and set a non-placeholder INFURA_PROJECT_ID (the value is never displayed).",
42
- userAction: `cd ${shellQuote(path.resolve(target))} && cp .metamaskrc.dist .metamaskrc && \${EDITOR:-vi} .metamaskrc`
29
+ message: "Extension product configuration is incomplete: .metamaskprodrc shadows .metamaskrc but does not contain a usable INFURA_PROJECT_ID (the value is never displayed).",
30
+ userAction: `cd ${shellQuote(path.resolve(target))} && \${EDITOR:-vi} .metamaskprodrc`
43
31
  };
44
32
  }
45
- if (configSnapshot.kind === "invalid") {
46
- return invalidConfigBlock(target, ".metamaskrc", configSnapshot.reason);
47
- }
48
- const value = readInfuraProjectId(configSnapshot.text) ?? "";
49
- if (isConfiguredInfuraId(value, placeholder)) return null;
50
33
  return {
51
34
  message: "Extension product configuration is incomplete: .metamaskrc must contain a non-placeholder INFURA_PROJECT_ID (the value is never displayed).",
52
35
  userAction: `cd ${shellQuote(path.resolve(target))} && \${EDITOR:-vi} .metamaskrc`
53
36
  };
54
37
  }
55
- function readConfigSnapshot(file) {
56
- let descriptor;
57
- try {
58
- descriptor = fs.openSync(
59
- file,
60
- fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK
61
- );
62
- } catch (error) {
63
- const code = error.code;
64
- if (code === "ENOENT") return { kind: "missing" };
65
- if (code === "ELOOP") return { kind: "invalid", reason: "symlink" };
66
- return { kind: "invalid", reason: "unreadable" };
67
- }
68
- try {
69
- if (!fs.fstatSync(descriptor).isFile()) return { kind: "invalid", reason: "not-regular" };
70
- return { kind: "file", text: fs.readFileSync(descriptor, "utf8") };
71
- } finally {
72
- fs.closeSync(descriptor);
73
- }
74
- }
75
38
  function invalidConfigBlock(target, name, reason) {
76
39
  const resolved = shellQuote(path.resolve(target));
77
40
  if (name === ".metamaskrc.dist") {
@@ -91,20 +54,6 @@ function invalidConfigBlock(target, name, reason) {
91
54
  userAction: `cd ${resolved} && rm ${name} && ${name === ".metamaskrc" ? "cp .metamaskrc.dist .metamaskrc && " : ""}\${EDITOR:-vi} ${name}`
92
55
  };
93
56
  }
94
- function readInfuraProjectId(text) {
95
- const lines = text.replace(/\r\n?/gu, "\n");
96
- const pattern = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gmu;
97
- let result;
98
- for (const match of lines.matchAll(pattern)) {
99
- if (match[1] !== "INFURA_PROJECT_ID") continue;
100
- let value = (match[2] ?? "").trim();
101
- const quote = value[0];
102
- value = value.replace(/^(['"`])([\s\S]*)\1$/u, "$2");
103
- if (quote === '"') value = value.replace(/\\n/gu, "\n").replace(/\\r/gu, "\r");
104
- result = value;
105
- }
106
- return result;
107
- }
108
57
  export {
109
58
  extensionProductConfigBlock
110
59
  };
@@ -169,6 +169,8 @@ function runtimeDistCheck(target) {
169
169
  "home.html",
170
170
  "--exclude",
171
171
  "sidepanel.html",
172
+ "--exclude",
173
+ "manifest.json",
172
174
  "--out-format=%n",
173
175
  `${dist}${path.sep}`,
174
176
  `${runtimeDist}${path.sep}`
@@ -182,6 +184,9 @@ function runtimeDistCheck(target) {
182
184
  const loaded = path.join(runtimeDist, name);
183
185
  if (normalizedRuntimeHtml(source) !== normalizedRuntimeHtml(loaded)) modified.push(name);
184
186
  }
187
+ if (normalizedRuntimeManifest(path.join(dist, "manifest.json")) !== normalizedRuntimeManifest(path.join(runtimeDist, "manifest.json"))) {
188
+ modified.push("manifest.json");
189
+ }
185
190
  const boundedModified = [...new Set(modified)].slice(0, 10);
186
191
  return boundedModified.length > 0 ? { status: "stale", modified: boundedModified } : { status: "fresh" };
187
192
  }
@@ -193,6 +198,25 @@ function normalizedRuntimeHtml(file) {
193
198
  return `<title>${base}</title>`;
194
199
  });
195
200
  }
201
+ function normalizedRuntimeManifest(file) {
202
+ if (!fs.existsSync(file)) return null;
203
+ try {
204
+ const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
205
+ if (manifest?._flags?.testing) {
206
+ delete manifest._flags.testing.infuraProjectId;
207
+ if (Object.keys(manifest._flags.testing).length === 0) {
208
+ delete manifest._flags.testing;
209
+ }
210
+ }
211
+ if (manifest?._flags) {
212
+ delete manifest._flags.remoteFeatureFlags;
213
+ if (Object.keys(manifest._flags).length === 0) delete manifest._flags;
214
+ }
215
+ return JSON.stringify(manifest);
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
196
220
  function distCheck(target) {
197
221
  const manifestPath = path.join(target, "dist/chrome/manifest.json");
198
222
  if (!fs.existsSync(manifestPath)) return { status: "no-build" };
@@ -222,10 +222,10 @@ async function extensionRebuild(target) {
222
222
  fs.mkdirSync(path.dirname(rebuildLog), { recursive: true });
223
223
  fs.writeFileSync(rebuildLog, "");
224
224
  const liveScript = recipeHarnessPath(target, "extension", "scripts", "live.sh");
225
- const liveArgs = ["--target", target, "--start-watch"];
225
+ const liveArgs = ["--target", target, "--build-lavamoat"];
226
226
  if (process.env.CDP_PORT) liveArgs.push("--cdp-port", process.env.CDP_PORT);
227
227
  if (process.env.EXTENSION_START_URL) liveArgs.push("--start-url", process.env.EXTENSION_START_URL);
228
- console.error(colorHumanMessage(`\u2192 extension quick relaunch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} \xB7 CDP :${process.env.CDP_PORT ?? "default"} (output streams below)`));
228
+ console.error(colorHumanMessage(`\u2192 extension quick relaunch \u2014 production-like LavaMoat build \xB7 CDP :${process.env.CDP_PORT ?? "default"} (output streams below)`));
229
229
  const result = await spawnScriptStreaming(liveScript, liveArgs, target);
230
230
  if (result.output) {
231
231
  try {
@@ -23,6 +23,7 @@ function parseArgs(argv, command) {
23
23
  "jsonStream",
24
24
  "launchExistingDist",
25
25
  "startWatch",
26
+ "buildLavamoat",
26
27
  "record",
27
28
  "plan",
28
29
  "list",
@@ -9,7 +9,8 @@ import {
9
9
  optionString,
10
10
  parsePort,
11
11
  shellQuote,
12
- targetPath
12
+ targetPath,
13
+ usageError
13
14
  } from "./parse-args.js";
14
15
  async function handleRuntimeLaunch({ options }) {
15
16
  const adapter = adapterOption(options);
@@ -24,6 +25,11 @@ async function handleRuntimeLaunch({ options }) {
24
25
  optionString(options, "artifactsDir") ?? recipeHarnessPath(target, "extension", "runtime-launch", (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/gu, ""))
25
26
  );
26
27
  const startWatch = optionFlag(options, "startWatch");
28
+ const buildLavaMoat = optionFlag(options, "buildLavamoat");
29
+ if (startWatch && buildLavaMoat) {
30
+ throw usageError("runtime-launch accepts only one of --start-watch or --build-lavamoat.");
31
+ }
32
+ const mode = buildLavaMoat ? "build-lavamoat" : startWatch ? "start-watch" : "existing-dist";
27
33
  const liveScript = recipeHarnessPath(target, "extension", "scripts", "live.sh");
28
34
  const command = [
29
35
  "bash",
@@ -32,7 +38,7 @@ async function handleRuntimeLaunch({ options }) {
32
38
  target,
33
39
  "--cdp-port",
34
40
  String(cdpPort),
35
- startWatch ? "--start-watch" : "--launch-existing-dist",
41
+ `--${mode}`,
36
42
  "--artifacts-dir",
37
43
  artifactsDir
38
44
  ];
@@ -46,7 +52,7 @@ async function handleRuntimeLaunch({ options }) {
46
52
  cdpPort,
47
53
  artifactsDir,
48
54
  reason: "harness_live_script_missing",
49
- fix: `Run mm-harness install --adapter extension --target ${shellQuote(target)}, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir)}`,
55
+ fix: `Run mm-harness install --adapter extension --target ${shellQuote(target)}, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, mode)}`,
50
56
  command
51
57
  });
52
58
  printRuntimeLaunchReport(report2, optionFlag(options, "json"));
@@ -55,14 +61,15 @@ async function handleRuntimeLaunch({ options }) {
55
61
  fs.mkdirSync(artifactsDir, { recursive: true });
56
62
  const jsonMode = optionFlag(options, "json");
57
63
  if (!jsonMode) {
58
- console.error(`recipe: runtime-launch starting (cdp=${cdpPort}${startWatch ? ", clean webpack build" : ", existing dist"})`);
64
+ const detail = mode === "build-lavamoat" ? "production-like LavaMoat build" : mode === "start-watch" ? "test webpack watcher" : "existing dist";
65
+ console.error(`recipe: runtime-launch starting (cdp=${cdpPort}, ${detail})`);
59
66
  } else {
60
67
  console.error(JSON.stringify({
61
68
  schemaVersion: 1,
62
69
  type: "progress",
63
70
  command: "rebuild",
64
71
  phase: "runtime-launch",
65
- message: startWatch ? "clean webpack build starting" : "launching existing dist",
72
+ message: mode === "build-lavamoat" ? "production-like LavaMoat build starting" : mode === "start-watch" ? "test webpack watcher starting" : "launching existing dist",
66
73
  cdpPort
67
74
  }));
68
75
  }
@@ -102,16 +109,16 @@ async function handleRuntimeLaunch({ options }) {
102
109
  launchLogPath: fs.existsSync(launchLogPath) ? launchLogPath : void 0,
103
110
  verifySummaryPath: fs.existsSync(verifySummaryPath) ? verifySummaryPath : void 0,
104
111
  reason,
105
- fix: `Read ${failurePath}, fix the first error, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, startWatch)}`,
112
+ fix: `Read ${failurePath}, fix the first error, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, mode)}`,
106
113
  command,
107
114
  exitCode: result.status ?? 1
108
115
  });
109
116
  printRuntimeLaunchReport(report, optionFlag(options, "json"));
110
117
  return 1;
111
118
  }
112
- function runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, startWatch = false) {
119
+ function runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, mode = "existing-dist") {
113
120
  const base = `mm-harness runtime-launch --adapter extension --target ${shellQuote(target)} --cdp-port ${cdpPort}`;
114
- const withMode = startWatch ? `${base} --start-watch` : base;
121
+ const withMode = mode === "existing-dist" ? base : `${base} --${mode}`;
115
122
  return chromeUserDataDir ? `${withMode} --chrome-user-data-dir ${shellQuote(chromeUserDataDir)}` : withMode;
116
123
  }
117
124
  function runtimeLaunchReport(status, fields) {
@@ -709,10 +709,8 @@ async function readPerpsRuntimeState(page) {
709
709
  const request = hooks.submitRequestToBackground;
710
710
  const reduxState = hooks.store?.getState?.() || {};
711
711
  const cleanState = (await hooks.getCleanAppState?.()) || hooks.getState?.() || {};
712
- const persisted = await globalThis.chrome?.storage?.local?.get?.('data');
713
- // Extension flattens PerpsController state into state.metamask via ComposableObservableStore.getFlatState().
714
712
  const metamask = reduxState.metamask || cleanState.metamask || {};
715
- const nestedPerps = persisted?.data?.PerpsController || metamask.PerpsController || cleanState.PerpsController || cleanState.perps || {};
713
+ const nestedPerps = metamask.PerpsController || cleanState.PerpsController || cleanState.perps || {};
716
714
  return {
717
715
  available: typeof request === 'function',
718
716
  activeProvider: metamask.activeProvider || nestedPerps.activeProvider || 'hyperliquid',
@@ -27,20 +27,75 @@ function nonNegativeInteger(value, name, fallback) {
27
27
  return parsed;
28
28
  }
29
29
 
30
+ const PROPERTY_MATCHERS = new Set(['$exists', '$type', '$gt']);
31
+
32
+ function validatePropertyExpectation(value, key) {
33
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return;
34
+ const keys = Object.keys(value);
35
+ const matcherKeys = keys.filter((entry) => entry.startsWith('$'));
36
+ if (matcherKeys.length === 0) return;
37
+ if (matcherKeys.length !== keys.length) {
38
+ throw new Error(`Property matcher for "${key}" cannot mix matcher and literal keys.`);
39
+ }
40
+ const unsupported = matcherKeys.filter((entry) => !PROPERTY_MATCHERS.has(entry));
41
+ if (unsupported.length > 0) {
42
+ throw new Error(`Unsupported property matcher for "${key}": ${unsupported.join(', ')}.`);
43
+ }
44
+ if ('$exists' in value && typeof value.$exists !== 'boolean') {
45
+ throw new Error(`$exists matcher for "${key}" must be boolean.`);
46
+ }
47
+ if (
48
+ '$type' in value &&
49
+ !['string', 'number', 'boolean', 'object', 'array'].includes(value.$type)
50
+ ) {
51
+ throw new Error(`$type matcher for "${key}" is invalid.`);
52
+ }
53
+ if ('$gt' in value && !isNumericValue(value.$gt)) {
54
+ throw new Error(`$gt matcher for "${key}" must be numeric.`);
55
+ }
56
+ }
57
+
58
+ function isNumericValue(value) {
59
+ return (
60
+ (typeof value === 'number' && Number.isFinite(value)) ||
61
+ (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)))
62
+ );
63
+ }
64
+
65
+ function matchesValue(got, want) {
66
+ if (want !== null && typeof want === 'object' && !Array.isArray(want)) {
67
+ const matcherKeys = Object.keys(want).filter((entry) => entry.startsWith('$'));
68
+ if (matcherKeys.length > 0) {
69
+ if ('$exists' in want && (got !== undefined) !== want.$exists) return false;
70
+ if ('$type' in want) {
71
+ const actualType = got === null ? 'null' : Array.isArray(got) ? 'array' : typeof got;
72
+ if (actualType !== want.$type) return false;
73
+ }
74
+ if ('$gt' in want) {
75
+ if (!isNumericValue(got) || Number(got) <= Number(want.$gt)) {
76
+ return false;
77
+ }
78
+ }
79
+ return true;
80
+ }
81
+ }
82
+ if (want !== null && typeof want === 'object') {
83
+ return JSON.stringify(got) === JSON.stringify(want);
84
+ }
85
+ // Segment serialises numbers inconsistently across clients.
86
+ if (typeof want === 'number' && typeof got === 'string' && got.trim() !== '') {
87
+ return Number(got) === want;
88
+ }
89
+ if (typeof got === 'number' && typeof want === 'string' && want.trim() !== '') {
90
+ return got === Number(want);
91
+ }
92
+ return Object.is(got, want);
93
+ }
94
+
30
95
  function matchesProperties(actual, expected) {
31
96
  return Object.entries(expected).every(([key, want]) => {
32
97
  const got = actual?.[key];
33
- if (want !== null && typeof want === 'object') {
34
- return JSON.stringify(got) === JSON.stringify(want);
35
- }
36
- // Segment serialises numbers inconsistently across clients.
37
- if (typeof want === 'number' && typeof got === 'string' && got.trim() !== '') {
38
- return Number(got) === want;
39
- }
40
- if (typeof got === 'number' && typeof want === 'string' && want.trim() !== '') {
41
- return got === Number(want);
42
- }
43
- return Object.is(got, want);
98
+ return matchesValue(got, want);
44
99
  });
45
100
  }
46
101
 
@@ -66,6 +121,9 @@ function expectationsFrom(input) {
66
121
  if (hasCount && hasBounds) {
67
122
  throw new Error(`expect entry for "${entry.event}" sets both count and min/max; pick one.`);
68
123
  }
124
+ for (const [key, value] of Object.entries(entry.properties ?? {})) {
125
+ validatePropertyExpectation(value, key);
126
+ }
69
127
  const expectation = {
70
128
  event: entry.event,
71
129
  properties: entry.properties ?? null,
@@ -2875,7 +2875,7 @@
2875
2875
  },
2876
2876
  "properties": {
2877
2877
  "type": "object",
2878
- "description": "Subset match on event properties; only events matching all of them are counted."
2878
+ "description": "Subset match on event properties; values may be exact or use $exists, $type, and $gt predicates."
2879
2879
  }
2880
2880
  },
2881
2881
  "required": [
@@ -2997,7 +2997,7 @@
2997
2997
  },
2998
2998
  "properties": {
2999
2999
  "type": "object",
3000
- "description": "Subset match on event properties; only events matching all of them are counted."
3000
+ "description": "Subset match on event properties; values may be exact or use $exists, $type, and $gt predicates."
3001
3001
  }
3002
3002
  },
3003
3003
  "required": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -22,7 +22,7 @@
22
22
  "@farmslot/agent-runtime": "^0.4.0",
23
23
  "@farmslot/handoff": "^0.3.1",
24
24
  "@farmslot/protocol": "^0.14.0",
25
- "@farmslot/recipe-harness": "^0.10.3",
25
+ "@farmslot/recipe-harness": "^0.10.4",
26
26
  "commander": "^12.0.0",
27
27
  "es-module-lexer": "2.3.1",
28
28
  "esbuild": "0.28.1",