@deeeed/metamask-harness 0.36.0 → 0.37.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +128 -0
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +22 -0
  5. package/adapters/extension/live.sh +82 -12
  6. package/adapters/extension/readiness.mjs +77 -36
  7. package/adapters/extension/snapshot-dist.sh +88 -3
  8. package/adapters/extension/verify.sh +6 -2
  9. package/adapters/manifest.json +9 -1
  10. package/adapters/shared/log-tui.mjs +1 -1
  11. package/dist/adapters/extension/artifact-integrity.js +38 -0
  12. package/dist/adapters/extension/extension-id.js +23 -4
  13. package/dist/adapters/extension/release-artifact.js +386 -0
  14. package/dist/adapters/extension/runtime-decision.js +161 -20
  15. package/dist/adapters/extension/runtime.js +127 -0
  16. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  17. package/dist/adapters/mobile/release-artifact.js +295 -0
  18. package/dist/adapters.js +11 -4
  19. package/dist/command-contract.js +16 -0
  20. package/dist/commands/call.js +2 -1
  21. package/dist/commands/launch/extension.js +55 -6
  22. package/dist/commands/launch/mobile.js +2 -0
  23. package/dist/commands/provision.js +2 -0
  24. package/dist/commands/run-engine.js +89 -5
  25. package/dist/commands/run.js +3 -1
  26. package/dist/commands/runtime-launch.js +178 -10
  27. package/dist/heal-bounds.js +1 -1
  28. package/dist/live-adapter-contract.js +3 -1
  29. package/dist/metamask-action-validation.js +47 -1
  30. package/dist/mm-harness-cli.js +31 -1
  31. package/dist/run-diagnostics.js +1 -1
  32. package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
  33. package/library/actions/extension/perps/perps.mjs +2 -0
  34. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  35. package/library/actions/extension/platform/cdp.mjs +6 -3
  36. package/library/actions/extension/wallet/import.mjs +13 -46
  37. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  38. package/library/actions/mobile/platform/observe-ui.mjs +84 -2
  39. package/library/actions/mobile/ui/native-navigation.mjs +225 -0
  40. package/library/actions/mobile/ui/navigate.mjs +7 -0
  41. package/library/actions/mobile/wallet/import.mjs +71 -2
  42. package/library/actions/mobile/wallet/native-ui.mjs +493 -0
  43. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  44. package/library/actions/mobile/wallet/reset.mjs +17 -4
  45. package/library/actions/shared/ui/locators.mjs +7 -0
  46. package/library/manifests/extension.action-manifest.json +116 -0
  47. package/library/manifests/mobile.action-manifest.json +16 -0
  48. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  49. package/library/recipes/wallet/import.recipe.json +20 -1
  50. package/library/recipes/wallet/reset-import.recipe.json +20 -1
  51. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.37.0 - 2026-08-13
6
+
7
+ ### Added
8
+
9
+ - Add archive and extracted-tree integrity checks, caching, provenance, and isolated launch for downloaded Extension release artifacts without using the checkout build or product configuration.
10
+ - Add verified local Android APK install and launch with expected version name, version code, package ID, trusted digest, explicit device selection, terminal provenance, and native-accessibility recipes that do not require a development bridge or HUD.
11
+ - Add an Extension Perps snapshot action for bounded, redacted account, market, position, open-order, history, and watchlist observations when the background test hook is available.
12
+ - Add a release QA capability map covering the current Perps, Assets, Swap and Bridge, and Networks checklists, with a prioritized parity roadmap.
13
+ - Add native Android release navigation and accessibility observation for wallet home, Perps home, and parameterized Perps markets without Metro, a development bridge, a HUD, or fixed screen coordinates.
14
+
15
+ ### Fixed
16
+
17
+ - Probe Ethereum through the Extension UI provider when production builds omit test state hooks, while preserving explicit unreachable failures.
18
+ - Import recovery phrases through production LavaMoat onboarding without browser clipboard permissions, and remove the temporary browser bridge after use.
19
+ - Keep release-artifact recipe failures on the acquired binary and fail closed instead of recovering through a checkout build.
20
+ - Relaunch a stopped verified Extension artifact from its integrity-checked cache without switching builds or deleting the imported wallet profile.
21
+ - Recover a newly opened Extension home tab from Chromium's transient blocked-page shell by navigating the selected extension target once before readiness fails.
22
+ - Prefer the visible side panel after official-build onboarding opens it, instead of leaving recipes attached to the completed onboarding tab.
23
+ - Reject unattended biometric setup explicitly, handle default Mobile notification and wallet-tour choices on bridge-backed onboarding, and keep Extension-only Perps snapshot limits out of Core validation.
24
+ - Confine Extension runtime snapshots and artifact identity state to non-symlinked checkout runtime directories before replacing prior state.
25
+ - Complete opaque Mobile onboarding without Metro or a development bridge by cancelling biometric storage for password-only automation, handling notification and wallet-tour prompts with configurable defaults, and proving the imported address through native UI.
26
+ - Close each physical-device accessibility session only after its wallet proof completes, so composed reset/import/state recipes hand the device to the next node cleanly.
27
+ - Label artifact preparation as preparation instead of reporting it as a client build.
28
+
5
29
  ## 0.36.0 - 2026-08-13
6
30
 
7
31
  ### Added
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
8
+ console.log('Usage: artifact-runtime-state.cjs <set|clear> --target <absolute-dir> --runtime-dir <relative-dir> [--source-dir <absolute-dir> --runtime-dist <absolute-dir> --provenance <absolute-file>]');
9
+ process.exit(0);
10
+ }
11
+
12
+ const [action, ...argv] = process.argv.slice(2);
13
+ const args = parseArgs(argv);
14
+ const target = requiredPath(args, 'target');
15
+ const runtimeDir = requiredRelative(args, 'runtime-dir');
16
+
17
+ if (action === 'clear') {
18
+ const directory = safeRuntimeDirectory(target, runtimeDir, false);
19
+ if (!directory) process.exit(0);
20
+ const statePath = path.join(directory, 'extension-release-artifact.json');
21
+ const existing = fs.lstatSync(statePath, { throwIfNoEntry: false });
22
+ if (!existing) process.exit(0);
23
+ if (!existing.isFile() || existing.isSymbolicLink()) throw invalidRuntimeState(statePath);
24
+ fs.unlinkSync(statePath);
25
+ process.exit(0);
26
+ }
27
+ if (action !== 'set') throw new Error('Usage: artifact-runtime-state.cjs <set|clear> --target <dir> --runtime-dir <relative> [set flags]');
28
+
29
+ const sourceDir = requiredPath(args, 'source-dir');
30
+ const runtimeDist = requiredPath(args, 'runtime-dist');
31
+ const provenancePath = requiredPath(args, 'provenance');
32
+ const provenance = readRegularJson(provenancePath);
33
+ const relativeExtensionDir = provenance.extensionDir;
34
+ if (
35
+ typeof relativeExtensionDir !== 'string' ||
36
+ !relativeExtensionDir ||
37
+ path.isAbsolute(relativeExtensionDir) ||
38
+ relativeExtensionDir.split(/[\\/]+/).includes('..')
39
+ ) {
40
+ throw new Error('Release artifact provenance has an invalid extensionDir.');
41
+ }
42
+ const provenanceRoot = path.dirname(provenancePath);
43
+ const boundSource = path.resolve(provenanceRoot, relativeExtensionDir);
44
+ if (boundSource !== sourceDir) throw new Error('Release artifact provenance does not bind the selected extension directory.');
45
+ if (!/^[a-f0-9]{64}$/.test(provenance.sha256 || '')) throw new Error('Release artifact provenance has an invalid SHA-256.');
46
+ if (!/^[a-f0-9]{64}$/.test(provenance.treeSha256 || '')) throw new Error('Release artifact provenance has an invalid tree SHA-256.');
47
+ if (!fs.existsSync(path.join(sourceDir, 'manifest.json'))) throw new Error('Release artifact source manifest is missing.');
48
+ if (!fs.existsSync(path.join(runtimeDist, 'manifest.json'))) throw new Error('Release artifact runtime manifest is missing.');
49
+
50
+ const state = {
51
+ schemaVersion: 1,
52
+ sourceDir,
53
+ runtimeDist,
54
+ provenancePath,
55
+ sha256: provenance.sha256,
56
+ treeSha256: provenance.treeSha256,
57
+ expectedVersion: provenance.expectedVersion,
58
+ manifestVersion: provenance.manifestVersion,
59
+ recordedAt: new Date().toISOString(),
60
+ };
61
+ const directory = safeRuntimeDirectory(target, runtimeDir, true);
62
+ const statePath = path.join(directory, 'extension-release-artifact.json');
63
+ const existing = fs.lstatSync(statePath, { throwIfNoEntry: false });
64
+ if (existing && (!existing.isFile() || existing.isSymbolicLink())) throw invalidRuntimeState(statePath);
65
+ const temporary = `${statePath}.${process.pid}.tmp`;
66
+ fs.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
67
+ fs.renameSync(temporary, statePath);
68
+
69
+ function parseArgs(values) {
70
+ const parsed = {};
71
+ for (let index = 0; index < values.length; index += 2) {
72
+ const flag = values[index];
73
+ const value = values[index + 1];
74
+ if (!flag?.startsWith('--') || value === undefined) throw new Error(`Invalid argument: ${flag || ''}`);
75
+ parsed[flag.slice(2)] = value;
76
+ }
77
+ return parsed;
78
+ }
79
+
80
+ function requiredPath(values, key) {
81
+ if (!values[key] || !path.isAbsolute(values[key])) throw new Error(`--${key} must be an absolute path.`);
82
+ return path.resolve(values[key]);
83
+ }
84
+
85
+ function requiredRelative(values, key) {
86
+ const value = values[key];
87
+ const parts = value?.split(/[\\/]+/) ?? [];
88
+ if (!value || path.isAbsolute(value) || parts.some((part) => !part || part === '.' || part === '..')) {
89
+ throw new Error(`--${key} must be a safe relative path.`);
90
+ }
91
+ return value;
92
+ }
93
+
94
+ function safeRuntimeDirectory(target, runtimeDir, create) {
95
+ const targetStat = fs.lstatSync(target, { throwIfNoEntry: false });
96
+ if (!targetStat?.isDirectory() || targetStat.isSymbolicLink()) {
97
+ throw invalidRuntimeState(path.join(target, runtimeDir));
98
+ }
99
+ let current = target;
100
+ for (const component of runtimeDir.split(/[\\/]+/)) {
101
+ current = path.join(current, component);
102
+ const stat = fs.lstatSync(current, { throwIfNoEntry: false });
103
+ if (!stat) {
104
+ if (!create) return null;
105
+ fs.mkdirSync(current, { mode: 0o700 });
106
+ continue;
107
+ }
108
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw invalidRuntimeState(current);
109
+ }
110
+ const realTarget = fs.realpathSync(target);
111
+ const realRuntime = fs.realpathSync(current);
112
+ if (realRuntime !== realTarget && !realRuntime.startsWith(`${realTarget}${path.sep}`)) {
113
+ throw invalidRuntimeState(current);
114
+ }
115
+ return current;
116
+ }
117
+
118
+ function invalidRuntimeState(file) {
119
+ return new Error(`Release artifact runtime state path is unsafe: ${file}`);
120
+ }
121
+
122
+ function readRegularJson(file) {
123
+ const stat = fs.lstatSync(file);
124
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 64 * 1024) {
125
+ throw new Error(`Release artifact provenance must be a regular JSON file: ${file}`);
126
+ }
127
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
128
+ }
@@ -104,6 +104,7 @@ copyFile(path.join(runnerDir, 'adapters/extension/sync-webpack-dist.cjs'), path.
104
104
  copyFile(path.join(runnerDir, 'adapters/extension/configure-runtime-manifest.cjs'), path.join(harnessDir, 'scripts/configure-runtime-manifest.cjs'));
105
105
  copyFile(path.join(runnerDir, 'adapters/extension/check-infura-readiness.cjs'), path.join(harnessDir, 'scripts/check-infura-readiness.cjs'));
106
106
  copyFile(path.join(runnerDir, 'adapters/extension/stamp-runtime-title.cjs'), path.join(harnessDir, 'scripts/stamp-runtime-title.cjs'));
107
+ copyFile(path.join(runnerDir, 'adapters/extension/artifact-runtime-state.cjs'), path.join(harnessDir, 'scripts/artifact-runtime-state.cjs'));
107
108
  copyFile(path.join(runnerDir, 'adapters/extension/stop-viewers.sh'), path.join(harnessDir, 'scripts/stop-viewers.sh'));
108
109
  copyFile(path.join(runnerDir, 'adapters/extension/snapshot-dist.sh'), path.join(harnessDir, 'scripts/snapshot-dist.sh'));
109
110
  copyFile(path.join(runnerDir, 'adapters/extension/pin-remote-flags.cjs'), path.join(harnessDir, 'scripts/pin-remote-flags.cjs'));
@@ -72,6 +72,7 @@ if (args['stop-only'] === undefined) {
72
72
  // injects its own already-isolated profile via runtime-context, so this only trips
73
73
  // on a genuinely personal/shared path — protecting the user's browser data.
74
74
  assertIsolatedProfile(args.profile);
75
+ if (args['reset-profile'] !== undefined) assertResettableProfile(args.profile, runtimeDir);
75
76
 
76
77
  fs.mkdirSync(args.profile, { recursive: true });
77
78
  fs.mkdirSync(path.dirname(args['chrome-log']), { recursive: true });
@@ -301,6 +302,27 @@ function assertIsolatedProfile(profile) {
301
302
  }
302
303
  }
303
304
 
305
+ function assertResettableProfile(profile, expectedRuntimeDir) {
306
+ const resolved = path.resolve(profile);
307
+ const runtime = path.resolve(expectedRuntimeDir);
308
+ if (!resolved.startsWith(`${runtime}${path.sep}`)) {
309
+ throw new Error(`Refusing to reset --profile ${resolved}: it must be a dedicated child of ${runtime}.`);
310
+ }
311
+ const runtimeStat = fs.lstatSync(runtime);
312
+ if (!runtimeStat.isDirectory() || runtimeStat.isSymbolicLink()) {
313
+ throw new Error(`Refusing to reset --profile: runtime directory is not a regular directory: ${runtime}.`);
314
+ }
315
+ let current = runtime;
316
+ for (const segment of path.relative(runtime, resolved).split(path.sep)) {
317
+ current = path.join(current, segment);
318
+ if (!fs.existsSync(current)) break;
319
+ const stat = fs.lstatSync(current);
320
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
321
+ throw new Error(`Refusing to reset --profile: path contains an unsafe entry: ${current}.`);
322
+ }
323
+ }
324
+ }
325
+
304
326
  // Proof of ownership: a process is ours iff its command line loads the exact
305
327
  // --user-data-dir we launch with. If the process can't be inspected we treat it
306
328
  // as NOT ours (fail safe — never assume ownership of an opaque process).
@@ -43,6 +43,9 @@ PREPARE_CMD="${RECIPE_HARNESS_EXTENSION_LAUNCH_CMD:-}"
43
43
  LAUNCH_EXISTING_DIST=false
44
44
  START_WATCH=false
45
45
  BUILD_LAVAMOAT=false
46
+ EXTERNAL_ARTIFACT=false
47
+ ARTIFACT_PROVENANCE=""
48
+ RESET_PROFILE=true
46
49
  DIST_DIR="dist/chrome"
47
50
  CHROME_USER_DATA_DIR="${CHROME_USER_DATA_DIR:-}"
48
51
  REMOTE_FLAGS=""
@@ -56,6 +59,9 @@ while [ "$#" -gt 0 ]; do
56
59
  --artifacts-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; ARTIFACTS="$2"; shift 2 ;;
57
60
  --prepare-cmd) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; PREPARE_CMD="$2"; shift 2 ;;
58
61
  --launch-existing-dist) LAUNCH_EXISTING_DIST=true; shift ;;
62
+ --external-artifact) EXTERNAL_ARTIFACT=true; LAUNCH_EXISTING_DIST=true; shift ;;
63
+ --artifact-provenance) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; ARTIFACT_PROVENANCE="$2"; shift 2 ;;
64
+ --preserve-profile) RESET_PROFILE=false; shift ;;
59
65
  --start-url) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; START_URL="$2"; shift 2 ;;
60
66
  --start-watch|--start-test-watch) START_WATCH=true; LAUNCH_EXISTING_DIST=true; shift ;;
61
67
  --build-lavamoat) BUILD_LAVAMOAT=true; LAUNCH_EXISTING_DIST=true; shift ;;
@@ -63,7 +69,7 @@ while [ "$#" -gt 0 ]; do
63
69
  --dist-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; DIST_DIR="$2"; shift 2 ;;
64
70
  --chrome-user-data-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; CHROME_USER_DATA_DIR="$2"; shift 2 ;;
65
71
  --remote-flag) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; REMOTE_FLAGS="$2"; shift 2 ;;
66
- -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>] [--launch-only] [--dist-dir dist/chrome] [--remote-flag KEY=VARIANT] [--artifacts-dir <dir>]"; exit 0 ;;
72
+ -h|--help) echo "Usage: live.sh [--target <metamask-extension>] [--out <recipes-dir>] [--cdp-port <port>] [--launch-existing-dist|--start-watch|--build-lavamoat|--external-artifact --dist-dir <absolute-dir> --artifact-provenance <absolute-file> [--preserve-profile]|--prepare-cmd <cmd>] [--launch-only] [--dist-dir dist/chrome] [--remote-flag KEY=VARIANT] [--artifacts-dir <dir>]"; exit 0 ;;
67
73
  *) echo "Unknown arg: $1" >&2; exit 2 ;;
68
74
  esac
69
75
  done
@@ -72,6 +78,24 @@ if $BUILD_LAVAMOAT && $START_WATCH; then
72
78
  echo "--build-lavamoat cannot be combined with --start-watch" >&2
73
79
  exit 2
74
80
  fi
81
+ if $EXTERNAL_ARTIFACT && { $BUILD_LAVAMOAT || $START_WATCH || [ -n "$REMOTE_FLAGS" ]; }; then
82
+ echo "--external-artifact cannot be combined with build, watch, or remote-flag options" >&2
83
+ exit 2
84
+ fi
85
+ if $EXTERNAL_ARTIFACT && [ ! -f "$ARTIFACT_PROVENANCE" ]; then
86
+ echo "--external-artifact requires --artifact-provenance <file>" >&2
87
+ exit 2
88
+ fi
89
+ if ! $RESET_PROFILE && ! $EXTERNAL_ARTIFACT; then
90
+ echo "--preserve-profile is only valid for a verified external artifact" >&2
91
+ exit 2
92
+ fi
93
+ if $EXTERNAL_ARTIFACT; then
94
+ case "$ARTIFACT_PROVENANCE" in
95
+ /*) ;;
96
+ *) echo "--artifact-provenance must be an absolute file path" >&2; exit 2 ;;
97
+ esac
98
+ fi
75
99
 
76
100
  case "$CHROME_USER_DATA_DIR" in
77
101
  ""|/*) ;;
@@ -120,6 +144,12 @@ if [ -z "$VERIFY_SH" ]; then
120
144
  fi
121
145
  RUNTIME_DIR="$(recipe_runtime_dir)"
122
146
  RUNTIME_DIST_DIR="${RECIPE_RUNTIME_DIST_DIR:-runtime-dist}"
147
+ case "$RUNTIME_DIST_DIR" in
148
+ ""|.|..|*/*|*[!A-Za-z0-9._-]*)
149
+ echo "RECIPE_RUNTIME_DIST_DIR must be one safe directory name: '$RUNTIME_DIST_DIR'" >&2
150
+ exit 2
151
+ ;;
152
+ esac
123
153
  CHROME_PROFILE_NAME="${RECIPE_CHROME_PROFILE_NAME:-chrome-profile}"
124
154
  # Runner bin (installed wrapper → source runner). Used by the watch prepare path
125
155
  # to defer the cache-clear DECISION to `runtime-decision` (single source) and to
@@ -145,21 +175,36 @@ if [ -d "$LIVE_ROOT" ]; then
145
175
  fi
146
176
 
147
177
  if $LAUNCH_EXISTING_DIST && [ -z "$PREPARE_CMD" ]; then
148
- DIST_ABS="$TARGET/$DIST_DIR"
178
+ if $EXTERNAL_ARTIFACT; then
179
+ case "$DIST_DIR" in
180
+ /*) DIST_ABS="$DIST_DIR" ;;
181
+ *) echo "--external-artifact requires an absolute --dist-dir" >&2; exit 2 ;;
182
+ esac
183
+ else
184
+ DIST_ABS="$TARGET/$DIST_DIR"
185
+ fi
149
186
  # Stable beside the profile: the reused profile registers the unpacked
150
187
  # extension by PATH; a per-run snapshot path gets pruned and the next launch
151
188
  # loads nothing ("No EIP-6963 Provider Detected").
152
- RUNTIME_DIST_ABS="$TARGET/$RUNTIME_DIR/$RUNTIME_DIST_DIR"
189
+ RUNTIME_ROOT_ABS="$TARGET/$RUNTIME_DIR"
190
+ RUNTIME_DIST_ABS="$RUNTIME_ROOT_ABS/$RUNTIME_DIST_DIR"
153
191
  # Stable per-checkout profile: successive runs recognize their own Chrome
154
192
  # (ownership guard matches on --user-data-dir), take it over cleanly, and the
155
193
  # seeded wallet persists across relaunches. A per-run profile made every
156
194
  # relaunch "foreign" and let live/ pruning delete profiles still in use.
157
195
  PROFILE_ABS="${CHROME_USER_DATA_DIR:-$TARGET/$RUNTIME_DIR/$CHROME_PROFILE_NAME}"
196
+ case "$PROFILE_ABS" in
197
+ "$TARGET/$RUNTIME_DIR/"*) ;;
198
+ *) echo "--chrome-user-data-dir must be a dedicated child of $TARGET/$RUNTIME_DIR" >&2; exit 2 ;;
199
+ esac
158
200
  FIXTURE_STATE_ABS="$ARTIFACTS/fixture-state.json"
159
201
  FIXTURE_VALIDATION_ABS="$ARTIFACTS/logs/fixture-account-parity.json"
160
- # Wallet fixture resolution chain + provenance live in seed-fixture.sh.
161
- WALLET_FIXTURE_ABS="$(bash "$SCRIPT_DIR/seed-fixture.sh" resolve --target "$TARGET" --cdp-port "$CDP_PORT" --source-out "$ARTIFACTS/logs/fixture-source.json")"
162
- if [ ! -f "$ARTIFACTS/logs/fixture-source.json" ]; then
202
+ WALLET_FIXTURE_ABS=""
203
+ if ! $EXTERNAL_ARTIFACT; then
204
+ # Wallet fixture resolution chain + provenance live in seed-fixture.sh.
205
+ WALLET_FIXTURE_ABS="$(bash "$SCRIPT_DIR/seed-fixture.sh" resolve --target "$TARGET" --cdp-port "$CDP_PORT" --source-out "$ARTIFACTS/logs/fixture-source.json")"
206
+ fi
207
+ if ! $EXTERNAL_ARTIFACT && [ ! -f "$ARTIFACTS/logs/fixture-source.json" ]; then
163
208
  # resolve failed before writing provenance (invalid RECIPE_WALLET_FIXTURE);
164
209
  # keep the documented missing-provenance artifact.
165
210
  node - "$ARTIFACTS/logs/fixture-source.json" "" <<'NODE'
@@ -175,6 +220,7 @@ NODE
175
220
  fi
176
221
  mkdir -p "$PROFILE_ABS"
177
222
  quoted_dist="$(printf '%q' "$DIST_ABS")"
223
+ quoted_runtime_root="$(printf '%q' "$RUNTIME_ROOT_ABS")"
178
224
  quoted_runtime_dist="$(printf '%q' "$RUNTIME_DIST_ABS")"
179
225
  quoted_profile="$(printf '%q' "$PROFILE_ABS")"
180
226
  quoted_seed_fixture="$(printf '%q' "$SCRIPT_DIR/seed-fixture.sh")"
@@ -185,6 +231,7 @@ NODE
185
231
  quoted_check_infura="$(printf '%q' "$SCRIPT_DIR/check-infura-readiness.cjs")"
186
232
  quoted_stamp_title="$(printf '%q' "$SCRIPT_DIR/stamp-runtime-title.cjs")"
187
233
  quoted_chrome_launcher="$(printf '%q' "$SCRIPT_DIR/launch-browser.cjs")"
234
+ quoted_artifact_state="$(printf '%q' "$SCRIPT_DIR/artifact-runtime-state.cjs")"
188
235
  quoted_fixture_state="$(printf '%q' "$FIXTURE_STATE_ABS")"
189
236
  quoted_fixture_validation="$(printf '%q' "$FIXTURE_VALIDATION_ABS")"
190
237
  quoted_extension_id_file="$(printf '%q' "$TARGET/$RUNTIME_DIR/extension.id")"
@@ -257,17 +304,37 @@ NODE
257
304
  quoted_chrome_log="$(printf '%q' "$ARTIFACTS/logs/chrome.log")"
258
305
  quoted_chrome_pid="$(printf '%q' "$ARTIFACTS/logs/chrome.pid")"
259
306
  prepare_parts=()
307
+ if $EXTERNAL_ARTIFACT; then
308
+ quoted_artifact_provenance="$(printf '%q' "$ARTIFACT_PROVENANCE")"
309
+ else
310
+ node "$SCRIPT_DIR/artifact-runtime-state.cjs" clear --target "$TARGET" --runtime-dir "$RUNTIME_DIR"
311
+ fi
260
312
  if $START_WATCH; then
261
313
  prepare_parts+=("bash ${quoted_start_watch} --runtime-dir ${quoted_runtime_dir} --runner-bin ${quoted_runner}")
262
314
  fi
263
315
  if $BUILD_LAVAMOAT; then
264
316
  prepare_parts+=("bash ${quoted_build_lavamoat} --target ${quoted_target} --runtime-dir ${quoted_runtime_dir}")
265
317
  fi
266
- prepare_parts+=("node ${quoted_check_infura} --target ${quoted_target} --runtime-dist ${quoted_dist}")
267
- prepare_parts+=("bash ${quoted_snapshot_dist} --dist ${quoted_dist} --runtime-dist ${quoted_runtime_dist}")
268
- prepare_parts+=("node ${quoted_configure_manifest} --target ${quoted_target} --manifest ${quoted_runtime_dist}/manifest.json")
269
- 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}")
270
- prepare_parts+=("node ${quoted_stamp_title} --target ${quoted_target} --runtime-dist ${quoted_runtime_dist} --runtime-dir ${quoted_runtime_dir}")
318
+ if ! $EXTERNAL_ARTIFACT; then
319
+ prepare_parts+=("node ${quoted_check_infura} --target ${quoted_target} --runtime-dist ${quoted_dist}")
320
+ fi
321
+ if $EXTERNAL_ARTIFACT; then
322
+ prepare_parts+=("bash ${quoted_snapshot_dist} --dist ${quoted_dist} --target ${quoted_target} --runtime-root ${quoted_runtime_root} --runtime-dist ${quoted_runtime_dist} --exact")
323
+ else
324
+ prepare_parts+=("bash ${quoted_snapshot_dist} --dist ${quoted_dist} --target ${quoted_target} --runtime-root ${quoted_runtime_root} --runtime-dist ${quoted_runtime_dist}")
325
+ fi
326
+ if $EXTERNAL_ARTIFACT; then
327
+ prepare_parts+=("node ${quoted_artifact_state} set --target ${quoted_target} --runtime-dir ${quoted_runtime_dir} --source-dir ${quoted_dist} --runtime-dist ${quoted_runtime_dist} --provenance ${quoted_artifact_provenance}")
328
+ fi
329
+ if ! $EXTERNAL_ARTIFACT; then
330
+ prepare_parts+=("node ${quoted_configure_manifest} --target ${quoted_target} --manifest ${quoted_runtime_dist}/manifest.json")
331
+ fi
332
+ reset_profile_arg=""
333
+ $RESET_PROFILE && reset_profile_arg=" --reset-profile 1"
334
+ prepare_parts+=("node ${quoted_chrome_launcher} --stop-only 1${reset_profile_arg} --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}")
335
+ if ! $EXTERNAL_ARTIFACT; then
336
+ prepare_parts+=("node ${quoted_stamp_title} --target ${quoted_target} --runtime-dist ${quoted_runtime_dist} --runtime-dir ${quoted_runtime_dir}")
337
+ fi
271
338
  # Optional A/B feature-flag pinning: patch the ephemeral snapshot manifest so
272
339
  # manifest._flags wins over the fetched ClientConfigApi value. No-op unless
273
340
  # --remote-flag was passed; the snapshot is re-created each run, so no revert.
@@ -302,6 +369,8 @@ echo "Extension live validation command:"
302
369
  display_args=(mm-harness runtime-launch --adapter extension --target "$TARGET" --cdp-port "$CDP_PORT")
303
370
  $START_WATCH && display_args+=(--start-watch)
304
371
  $BUILD_LAVAMOAT && display_args+=(--build-lavamoat)
372
+ $EXTERNAL_ARTIFACT && display_args+=(--external-artifact --dist-dir "$DIST_DIR" --artifact-provenance "$ARTIFACT_PROVENANCE")
373
+ $RESET_PROFILE || display_args+=(--preserve-profile)
305
374
  [ -n "$REMOTE_FLAGS" ] && display_args+=(--remote-flag "$REMOTE_FLAGS")
306
375
  [ -n "$CHROME_USER_DATA_DIR" ] && display_args+=(--chrome-user-data-dir "$CHROME_USER_DATA_DIR")
307
376
  printf ' '
@@ -333,7 +402,7 @@ else
333
402
  echo "Skipping Extension live verify for launch-only fixture application." >&2
334
403
  fi
335
404
 
336
- TARGET_FOR_SUMMARY="$TARGET" ARTIFACTS_FOR_SUMMARY="$ARTIFACTS" CDP_PORT_FOR_SUMMARY="$CDP_PORT" LAUNCH_STATUS="$launch_status" VERIFY_STATUS="$verify_status" RUN_VERIFY="$RUN_VERIFY" LAUNCH_EXISTING_DIST="$LAUNCH_EXISTING_DIST" START_WATCH="$START_WATCH" BUILD_LAVAMOAT="$BUILD_LAVAMOAT" node <<'NODE'
405
+ TARGET_FOR_SUMMARY="$TARGET" ARTIFACTS_FOR_SUMMARY="$ARTIFACTS" CDP_PORT_FOR_SUMMARY="$CDP_PORT" LAUNCH_STATUS="$launch_status" VERIFY_STATUS="$verify_status" RUN_VERIFY="$RUN_VERIFY" LAUNCH_EXISTING_DIST="$LAUNCH_EXISTING_DIST" START_WATCH="$START_WATCH" BUILD_LAVAMOAT="$BUILD_LAVAMOAT" EXTERNAL_ARTIFACT="$EXTERNAL_ARTIFACT" node <<'NODE'
337
406
  const fs = require('fs');
338
407
  const path = require('path');
339
408
  const artifacts = process.env.ARTIFACTS_FOR_SUMMARY;
@@ -351,6 +420,7 @@ fs.writeFileSync(path.join(artifacts, 'summary.json'), `${JSON.stringify({
351
420
  launchExistingDist: process.env.LAUNCH_EXISTING_DIST === 'true',
352
421
  startWatch: process.env.START_WATCH === 'true',
353
422
  buildLavaMoat: process.env.BUILD_LAVAMOAT === 'true',
423
+ externalArtifact: process.env.EXTERNAL_ARTIFACT === 'true',
354
424
  launch: { exitCode: launchStatus, summaryPath: fs.existsSync(launchSummary) ? launchSummary : null },
355
425
  verify: runVerify
356
426
  ? { status: verifyStatus === 0 ? 'pass' : 'fail', exitCode: verifyStatus, summaryPath: fs.existsSync(verifySummary) ? verifySummary : null }
@@ -64,10 +64,29 @@ function parseArgs(argv) {
64
64
  return out;
65
65
  }
66
66
 
67
+ function extensionBuildRoot(target) {
68
+ const runtimeRoot = path.join(target, recipeRuntimeDir());
69
+ const statePath = path.join(runtimeRoot, 'extension-release-artifact.json');
70
+ if (!fs.existsSync(statePath)) return path.join(target, 'dist/chrome');
71
+ try {
72
+ const stat = fs.lstatSync(statePath);
73
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 64 * 1024) {
74
+ throw new Error('Loaded release artifact identity is not a regular bounded file.');
75
+ }
76
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
77
+ const expected = path.join(runtimeRoot, process.env.RECIPE_RUNTIME_DIST_DIR || 'runtime-dist');
78
+ if (state.schemaVersion === 1 && path.resolve(state.runtimeDist || '') === expected) return expected;
79
+ } catch (error) {
80
+ throw new Error(`Loaded release artifact identity is invalid: ${error instanceof Error ? error.message : String(error)}`);
81
+ }
82
+ throw new Error('Loaded release artifact identity is invalid.');
83
+ }
84
+
67
85
  function readManifest(target) {
68
- const manifestPath = path.join(target, 'dist/chrome/manifest.json');
86
+ const buildRoot = extensionBuildRoot(target);
87
+ const manifestPath = path.join(buildRoot, 'manifest.json');
69
88
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
70
- return { manifestPath, manifest };
89
+ return { buildRoot, manifestPath, manifest };
71
90
  }
72
91
 
73
92
  function manifestExpectedFiles(manifest) {
@@ -190,6 +209,17 @@ function resolveWebSocket(target) {
190
209
  }
191
210
 
192
211
  async function cdpEvaluate(target, webSocketDebuggerUrl, expression, timeoutMs = 5000) {
212
+ const result = await cdpCall(
213
+ target,
214
+ webSocketDebuggerUrl,
215
+ 'Runtime.evaluate',
216
+ { expression, awaitPromise: true, returnByValue: true },
217
+ timeoutMs,
218
+ );
219
+ return result?.result?.value ?? null;
220
+ }
221
+
222
+ async function cdpCall(target, webSocketDebuggerUrl, method, params, timeoutMs = 5000) {
193
223
  assertLocalWebSocketUrl(webSocketDebuggerUrl);
194
224
  const WebSocketImpl = resolveWebSocket(target);
195
225
  if (!WebSocketImpl) return { skipped: true, reason: 'WebSocket unavailable in this Node runtime' };
@@ -206,8 +236,8 @@ async function cdpEvaluate(target, webSocketDebuggerUrl, expression, timeoutMs =
206
236
  const onOpen = () => {
207
237
  ws.send(JSON.stringify({
208
238
  id: 1,
209
- method: 'Runtime.evaluate',
210
- params: { expression, awaitPromise: true, returnByValue: true },
239
+ method,
240
+ params,
211
241
  }));
212
242
  };
213
243
  const onMessage = (event) => {
@@ -220,11 +250,11 @@ async function cdpEvaluate(target, webSocketDebuggerUrl, expression, timeoutMs =
220
250
  reject(new Error(msg.error.message || JSON.stringify(msg.error)));
221
251
  return;
222
252
  }
223
- resolve(msg.result?.result?.value ?? null);
253
+ resolve(msg.result ?? null);
224
254
  };
225
255
  const onError = (err) => {
226
256
  clearTimeout(timer);
227
- reject(new Error(`CDP websocket error while inspecting extension page: ${err?.message || err || 'unknown'}`));
257
+ reject(new Error(`CDP websocket error during ${method}: ${err?.message || err || 'unknown'}`));
228
258
  };
229
259
  if (typeof ws.on === 'function') {
230
260
  ws.on('open', onOpen);
@@ -286,6 +316,23 @@ async function openExtensionPage(cdpPort, extensionId, pagePath) {
286
316
  }
287
317
  }
288
318
 
319
+ const PAGE_UI_EXPRESSION = `(() => {
320
+ const text = document.body?.innerText || '';
321
+ const cell = (sel) => document.querySelector(sel)?.innerText?.trim() || '';
322
+ const errorBoundaryMessage = cell('[data-testid="error-page-error-message"]');
323
+ const errorBoundaryName = cell('[data-testid="error-page-error-name"]');
324
+ const hasErrorBoundary = Boolean(errorBoundaryMessage) || Boolean(errorBoundaryName);
325
+ return {
326
+ title: document.title,
327
+ url: location.href,
328
+ textSample: text.slice(0, 500),
329
+ hasStartupError: /MetaMask had trouble starting|Background connection unresponsive|Unknown Infura network/i.test(text),
330
+ hasErrorBoundary,
331
+ errorBoundaryName,
332
+ errorBoundaryMessage: errorBoundaryMessage || (hasErrorBoundary ? text.slice(0, 300) : ''),
333
+ };
334
+ })()`;
335
+
289
336
  // The page path only, with any '#fragment' or '?query' stripped. The app router
290
337
  // rewrites home.html to home.html#/ once loaded, so an exact '/home.html' suffix
291
338
  // match must compare against this, not the raw url.
@@ -349,29 +396,7 @@ async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceW
349
396
  // tab has none; skip inspection and report it rather than opening a duplicate.
350
397
  if (pageTarget && typeof pageTarget.webSocketDebuggerUrl === 'string') {
351
398
  const slotId = readSlotId(target);
352
- ui = await cdpEvaluate(
353
- target,
354
- pageTarget.webSocketDebuggerUrl,
355
- `(() => {
356
- const text = document.body?.innerText || '';
357
- const cell = (sel) => document.querySelector(sel)?.innerText?.trim() || '';
358
- // React error-boundary screen (ui/pages/error-page); deterministic testids.
359
- const errorBoundaryMessage = cell('[data-testid="error-page-error-message"]');
360
- const errorBoundaryName = cell('[data-testid="error-page-error-name"]');
361
- // Testid-only trigger: ui/pages/error-page reliably renders these testids.
362
- // Avoid matching the visible phrase in body text (false positives).
363
- const hasErrorBoundary = Boolean(errorBoundaryMessage) || Boolean(errorBoundaryName);
364
- return {
365
- title: document.title,
366
- url: location.href,
367
- textSample: text.slice(0, 500),
368
- hasStartupError: /MetaMask had trouble starting|Background connection unresponsive|Unknown Infura network/i.test(text),
369
- hasErrorBoundary,
370
- errorBoundaryName,
371
- errorBoundaryMessage: errorBoundaryMessage || (hasErrorBoundary ? text.slice(0, 300) : ''),
372
- };
373
- })()`,
374
- );
399
+ ui = await cdpEvaluate(target, pageTarget.webSocketDebuggerUrl, PAGE_UI_EXPRESSION);
375
400
  pageInspected = Boolean(ui) && !ui.skipped;
376
401
  if (pageInspected && ui.hasStartupError) {
377
402
  throw Object.assign(new Error('MetaMask extension page loaded startup error UI'), {
@@ -385,9 +410,25 @@ async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceW
385
410
  });
386
411
  }
387
412
  if (pageInspected && String(ui.url || '').startsWith('chrome-error://')) {
388
- throw Object.assign(new Error('MetaMask extension page loaded Chrome error UI'), {
389
- report: { cdp: { browser: version.Browser || 'unknown', selectedExtensionId, ui } },
390
- });
413
+ const destination = `chrome-extension://${selectedExtensionId}/${String(extensionPagePath || 'home.html').replace(/^\/+/u, '')}`;
414
+ await cdpCall(
415
+ target,
416
+ pageTarget.webSocketDebuggerUrl,
417
+ 'Page.navigate',
418
+ { url: destination },
419
+ );
420
+ await new Promise((resolve) => setTimeout(resolve, 500));
421
+ ui = await cdpEvaluate(target, pageTarget.webSocketDebuggerUrl, PAGE_UI_EXPRESSION);
422
+ if (
423
+ !ui ||
424
+ String(ui.url || '').startsWith('chrome-error://') ||
425
+ ui.hasStartupError ||
426
+ ui.hasErrorBoundary
427
+ ) {
428
+ throw Object.assign(new Error('MetaMask extension page loaded Chrome error UI'), {
429
+ report: { cdp: { browser: version.Browser || 'unknown', selectedExtensionId, ui } },
430
+ });
431
+ }
391
432
  }
392
433
  if (pageInspected && slotId) {
393
434
  const stampedTitle = await cdpEvaluate(
@@ -419,12 +460,12 @@ async function main() {
419
460
  const args = parseArgs(process.argv.slice(2));
420
461
  const target = path.resolve(args.target);
421
462
  const checks = [];
422
- const { manifestPath, manifest } = readManifest(target);
463
+ const { buildRoot, manifestPath, manifest } = readManifest(target);
423
464
  const expectedFiles = manifestExpectedFiles(manifest);
424
465
  const missingFiles = [];
425
466
  for (const rel of expectedFiles) {
426
- const exists = fs.existsSync(path.join(target, 'dist/chrome', rel));
427
- checks.push({ name: `dist/chrome/${rel}`, status: exists ? 'pass' : 'fail' });
467
+ const exists = fs.existsSync(path.join(buildRoot, rel));
468
+ checks.push({ name: `${path.relative(target, buildRoot)}/${rel}`, status: exists ? 'pass' : 'fail' });
428
469
  if (!exists) missingFiles.push(rel);
429
470
  }
430
471
  if (missingFiles.length > 0) {
@@ -434,7 +475,7 @@ async function main() {
434
475
  );
435
476
  }
436
477
 
437
- const defaultPage = fs.existsSync(path.join(target, 'dist/chrome/home.html'))
478
+ const defaultPage = fs.existsSync(path.join(buildRoot, 'home.html'))
438
479
  ? 'home.html'
439
480
  : manifestDefaultPage(manifest);
440
481
  const report = {