@deeeed/metamask-harness 0.35.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 (73) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +9 -1
  3. package/adapters/extension/artifact-runtime-state.cjs +128 -0
  4. package/adapters/extension/check-infura-readiness.cjs +102 -0
  5. package/adapters/extension/inject.mjs +2 -0
  6. package/adapters/extension/launch-browser.cjs +22 -0
  7. package/adapters/extension/live.sh +103 -18
  8. package/adapters/extension/readiness.mjs +77 -36
  9. package/adapters/extension/snapshot-dist.sh +88 -3
  10. package/adapters/extension/start-watch.sh +15 -0
  11. package/adapters/extension/verify.sh +6 -2
  12. package/adapters/extension/wallet-fixture-state.cjs +3 -1
  13. package/adapters/manifest.json +25 -1
  14. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +73 -42
  15. package/adapters/mobile/reset-app-data.sh +154 -0
  16. package/adapters/shared/log-tui.mjs +1 -1
  17. package/dist/adapters/extension/artifact-integrity.js +38 -0
  18. package/dist/adapters/extension/extension-id.js +23 -4
  19. package/dist/adapters/extension/product-config.js +29 -1
  20. package/dist/adapters/extension/release-artifact.js +386 -0
  21. package/dist/adapters/extension/runtime-decision.js +161 -20
  22. package/dist/adapters/extension/runtime.js +127 -0
  23. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  24. package/dist/adapters/mobile/release-artifact.js +295 -0
  25. package/dist/adapters.js +29 -4
  26. package/dist/cli-commands.js +1 -1
  27. package/dist/cli.js +2 -2
  28. package/dist/command-contract.js +17 -1
  29. package/dist/commands/call.js +2 -1
  30. package/dist/commands/device-target.js +5 -0
  31. package/dist/commands/fixtures.js +106 -31
  32. package/dist/commands/launch/extension.js +92 -7
  33. package/dist/commands/launch/index.js +13 -0
  34. package/dist/commands/launch/mobile.js +2 -0
  35. package/dist/commands/provision.js +2 -0
  36. package/dist/commands/run-engine.js +89 -5
  37. package/dist/commands/run.js +3 -1
  38. package/dist/commands/runtime-launch.js +178 -10
  39. package/dist/heal-bounds.js +1 -1
  40. package/dist/live-adapter-contract.js +3 -1
  41. package/dist/metamask-action-validation.js +47 -1
  42. package/dist/mm-harness-cli.js +37 -4
  43. package/dist/recipe-security.js +3 -0
  44. package/dist/run-diagnostics.js +1 -1
  45. package/docs/RECIPES.md +29 -0
  46. package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
  47. package/library/actions/extension/perps/perps.mjs +2 -0
  48. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  49. package/library/actions/extension/platform/cdp.mjs +6 -3
  50. package/library/actions/extension/wallet/import.mjs +201 -0
  51. package/library/actions/extension/wallet/reset.mjs +98 -0
  52. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  53. package/library/actions/extension/wallet/state.mjs +1 -0
  54. package/library/actions/mobile/analytics/consent-settings.mjs +112 -0
  55. package/library/actions/mobile/analytics/set_consent.mjs +4 -112
  56. package/library/actions/mobile/platform/bridge.mjs +8 -0
  57. package/library/actions/mobile/platform/observe-ui.mjs +84 -2
  58. package/library/actions/mobile/ui/native-navigation.mjs +225 -0
  59. package/library/actions/mobile/ui/navigate.mjs +7 -0
  60. package/library/actions/mobile/wallet/import.mjs +328 -0
  61. package/library/actions/mobile/wallet/native-ui.mjs +493 -0
  62. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  63. package/library/actions/mobile/wallet/reset-helper.mjs +99 -0
  64. package/library/actions/mobile/wallet/reset.mjs +20 -0
  65. package/library/actions/shared/ui/locators.mjs +7 -0
  66. package/library/actions/shared/wallet/import-source.mjs +101 -0
  67. package/library/manifests/extension.action-manifest.json +228 -0
  68. package/library/manifests/mobile.action-manifest.json +124 -0
  69. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  70. package/library/recipes/wallet/import.recipe.json +102 -0
  71. package/library/recipes/wallet/reset-import.recipe.json +107 -0
  72. package/package.json +1 -1
  73. package/scripts/completions.sh +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
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
+
29
+ ## 0.36.0 - 2026-08-13
30
+
31
+ ### Added
32
+
33
+ - Add a composable Mobile and Extension wallet import recipe that either verifies an existing fixture-backed profile or imports the primary mnemonic through visible onboarding UI, with secret-safe inputs, privacy-preserving defaults, and configurable MetaMetrics and Mobile interests.
34
+ - Add `fixtures reset` plus a shared Mobile/Extension reset-import recipe. Extension resets its owned browser profile; Mobile clears only the selected app's data while preserving its installed build. The visible recipe separately proves the real user reset and import flow.
35
+
36
+ ### Fixed
37
+
38
+ - Restart a harness-owned Extension watcher when its effective Infura credential differs from the current product configuration, and preflight compiled Ethereum and Linea credentials before Chrome starts, preventing a forced-red validation from poisoning later launches or opening a native HTTP authentication dialog.
39
+ - Make normal Extension launch and runtime recovery use the product's `yarn start` development build, while keeping production-like LavaMoat builds behind explicit `launch --build`.
40
+ - Require wallet profile reuse to match the requested account, wait for Mobile onboarding to reach a terminal route, and resolve the exact Mobile reset device and prewarm its bundle before relaunching after app-data reset.
41
+ - Refuse ambiguous installed Mobile app variants during reset, prefer complete environment wallet credentials without parsing a stale fixture, and reject bad Extension RPC configuration before replacing the loaded runtime snapshot.
42
+ - Validate recovery credentials, fixture structure, and platform-specific onboarding choices before destructive resets; prove Mobile reset by the visible import control; honor persisted MetaMetrics consent through Security settings; and keep file-backed wallet secrets out of bridge errors.
43
+
5
44
  ## 0.35.0 - 2026-08-12
6
45
 
7
46
  ### Fixed
package/README.md CHANGED
@@ -88,10 +88,14 @@ mm-harness stop
88
88
  mm-harness logs
89
89
  mm-harness debug
90
90
  mm-harness fixtures set
91
+
92
+ # Delete the current wallet, then reapply the canonical fixture from clean state.
93
+ mm-harness fixtures reset
91
94
  ```
92
95
 
93
96
  Extension `launch` keeps its incremental watcher running. Refresh the active
94
- page after a successful rebuild; use `launch --build` only for a clean rebuild.
97
+ page after a successful rebuild; use `launch --build` only for an explicit
98
+ production-like LavaMoat rebuild.
95
99
 
96
100
  Log sources stay separate:
97
101
 
@@ -115,6 +119,10 @@ mm-harness call metamask.wallet.ensure_unlocked
115
119
  mm-harness run --list
116
120
  mm-harness run perps.clean-market-testnet --describe
117
121
  mm-harness run wallet.smoke --describe
122
+ mm-harness run wallet.import method=auto
123
+
124
+ # Use visible client UI to delete and import the wallet again.
125
+ mm-harness run wallet.reset-import
118
126
  mm-harness run path/to/recipe.json market=ETH --plan
119
127
  mm-harness run path/to/recipe.json
120
128
  ```
@@ -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
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const https = require('node:https');
6
+ const path = require('node:path');
7
+
8
+ const args = {};
9
+ for (let index = 2; index < process.argv.length; index += 1) {
10
+ const key = process.argv[index];
11
+ if (key === '--help' || key === '-h') {
12
+ process.stdout.write('Usage: check-infura-readiness.cjs --target <metamask-extension> --runtime-dist <dist>\n');
13
+ process.exit(0);
14
+ }
15
+ if (!key.startsWith('--') || index + 1 >= process.argv.length) {
16
+ throw new Error(`invalid argument: ${key}`);
17
+ }
18
+ args[key.slice(2)] = process.argv[++index];
19
+ }
20
+
21
+ for (const key of ['target', 'runtime-dist']) {
22
+ if (!args[key]) throw new Error(`--${key} is required`);
23
+ }
24
+
25
+ const target = path.resolve(args.target);
26
+ const runtimeDist = path.resolve(args['runtime-dist']);
27
+ const config = require('./lib/product-config.cjs');
28
+
29
+ async function main() {
30
+ const resolution = config.resolveExtensionInfuraProjectId(target, process.env);
31
+ if (resolution.kind !== 'configured') {
32
+ fail('the effective Extension product configuration has no usable Infura project ID');
33
+ }
34
+ if (!compiledScriptsContain(runtimeDist, resolution.value)) {
35
+ fail('the compiled Extension scripts do not match the effective Infura project ID');
36
+ }
37
+ for (const host of ['mainnet.infura.io', 'linea-mainnet.infura.io']) {
38
+ const response = await rpcProbe(host, resolution.value);
39
+ if (response.statusCode < 200 || response.statusCode >= 300 || response.jsonRpcResult !== true) {
40
+ fail(`${host} rejected the effective Infura project ID (HTTP ${response.statusCode})`);
41
+ }
42
+ }
43
+ process.stdout.write('Infura readiness preflight passed for Ethereum and Linea.\n');
44
+ }
45
+
46
+ function compiledScriptsContain(root, value) {
47
+ const pending = [root];
48
+ while (pending.length > 0) {
49
+ const current = pending.pop();
50
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
51
+ const file = path.join(current, entry.name);
52
+ if (entry.isDirectory()) pending.push(file);
53
+ else if (entry.isFile() && entry.name.endsWith('.js') && fs.readFileSync(file, 'utf8').includes(value)) {
54
+ return true;
55
+ }
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+
61
+ function rpcProbe(host, projectId) {
62
+ const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_chainId', params: [] });
63
+ return new Promise((resolve, reject) => {
64
+ const request = https.request({
65
+ hostname: host,
66
+ path: `/v3/${encodeURIComponent(projectId)}`,
67
+ method: 'POST',
68
+ timeout: 10_000,
69
+ headers: {
70
+ 'content-type': 'application/json',
71
+ 'content-length': Buffer.byteLength(body),
72
+ },
73
+ }, (response) => {
74
+ let text = '';
75
+ response.setEncoding('utf8');
76
+ response.on('data', (chunk) => { text += chunk; });
77
+ response.on('end', () => {
78
+ let jsonRpcResult = false;
79
+ try {
80
+ const parsed = JSON.parse(text);
81
+ jsonRpcResult = typeof parsed.result === 'string' && parsed.result.startsWith('0x');
82
+ } catch {
83
+ jsonRpcResult = false;
84
+ }
85
+ resolve({ statusCode: response.statusCode || 0, jsonRpcResult });
86
+ });
87
+ });
88
+ request.on('timeout', () => request.destroy(new Error(`${host} timed out`)));
89
+ request.on('error', reject);
90
+ request.end(body);
91
+ });
92
+ }
93
+
94
+ function fail(detail) {
95
+ process.stderr.write(
96
+ `EVM_RPC_UNREACHABLE: Infura readiness preflight failed before browser launch: ${detail}.\n` +
97
+ `Next: stop the stale watcher, confirm ${path.join(target, '.metamaskrc')} has a real INFURA_PROJECT_ID, then rerun mm-harness launch --verify --target ${JSON.stringify(target)}.\n`,
98
+ );
99
+ process.exit(1);
100
+ }
101
+
102
+ main().catch((error) => fail(error instanceof Error ? error.message : String(error)));
@@ -102,7 +102,9 @@ copyFile(path.join(runnerDir, 'adapters/extension/build-lavamoat.sh'), path.join
102
102
  copyFile(path.join(runnerDir, 'adapters/extension/launch-webpack.cjs'), path.join(harnessDir, 'scripts/launch-webpack.cjs'));
103
103
  copyFile(path.join(runnerDir, 'adapters/extension/sync-webpack-dist.cjs'), path.join(harnessDir, 'scripts/sync-webpack-dist.cjs'));
104
104
  copyFile(path.join(runnerDir, 'adapters/extension/configure-runtime-manifest.cjs'), path.join(harnessDir, 'scripts/configure-runtime-manifest.cjs'));
105
+ copyFile(path.join(runnerDir, 'adapters/extension/check-infura-readiness.cjs'), path.join(harnessDir, 'scripts/check-infura-readiness.cjs'));
105
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'));
106
108
  copyFile(path.join(runnerDir, 'adapters/extension/stop-viewers.sh'), path.join(harnessDir, 'scripts/stop-viewers.sh'));
107
109
  copyFile(path.join(runnerDir, 'adapters/extension/snapshot-dist.sh'), path.join(harnessDir, 'scripts/snapshot-dist.sh'));
108
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).
@@ -6,13 +6,16 @@
6
6
  # Purpose:
7
7
  # Builds the per-run prepare command from the named feature scripts
8
8
  # (watch, dist snapshot, fixture seed, detached chrome, CDP poll), then
9
- # runs launch.sh and verify.sh under a timestamped artifact dir.
9
+ # runs launch.sh and, unless --launch-only is set, verify.sh under a
10
+ # timestamped artifact dir.
10
11
  #
11
12
  # Inputs (flags / env):
12
13
  # --target <metamask-extension> (default $PWD)
13
14
  # --cdp-port <port> (optional; inferred from checkout context/pool when omitted)
14
15
  # --launch-existing-dist | --start-watch | --build-lavamoat | --prepare-cmd <cmd>
15
16
  # (env RECIPE_HARNESS_EXTENSION_LAUNCH_CMD)
17
+ # --launch-only (skip recipe verification; used by fixture application while
18
+ # its checkout lock is held)
16
19
  # --dist-dir <rel> (default dist/chrome), --chrome-user-data-dir <dir>,
17
20
  # --remote-flag <KEY=VARIANT[,KEY=VARIANT...]> (pins manifest _flags into the
18
21
  # ephemeral runtime-dist snapshot before launch; omit for default behavior)
@@ -40,10 +43,14 @@ PREPARE_CMD="${RECIPE_HARNESS_EXTENSION_LAUNCH_CMD:-}"
40
43
  LAUNCH_EXISTING_DIST=false
41
44
  START_WATCH=false
42
45
  BUILD_LAVAMOAT=false
46
+ EXTERNAL_ARTIFACT=false
47
+ ARTIFACT_PROVENANCE=""
48
+ RESET_PROFILE=true
43
49
  DIST_DIR="dist/chrome"
44
50
  CHROME_USER_DATA_DIR="${CHROME_USER_DATA_DIR:-}"
45
51
  REMOTE_FLAGS=""
46
52
  REMOTE_FLAGS_APPLIED=false
53
+ RUN_VERIFY=true
47
54
  while [ "$#" -gt 0 ]; do
48
55
  case "$1" in
49
56
  --target) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; TARGET="$2"; shift 2 ;;
@@ -52,13 +59,17 @@ while [ "$#" -gt 0 ]; do
52
59
  --artifacts-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; ARTIFACTS="$2"; shift 2 ;;
53
60
  --prepare-cmd) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; PREPARE_CMD="$2"; shift 2 ;;
54
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 ;;
55
65
  --start-url) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; START_URL="$2"; shift 2 ;;
56
66
  --start-watch|--start-test-watch) START_WATCH=true; LAUNCH_EXISTING_DIST=true; shift ;;
57
67
  --build-lavamoat) BUILD_LAVAMOAT=true; LAUNCH_EXISTING_DIST=true; shift ;;
68
+ --launch-only) RUN_VERIFY=false; shift ;;
58
69
  --dist-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; DIST_DIR="$2"; shift 2 ;;
59
70
  --chrome-user-data-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; CHROME_USER_DATA_DIR="$2"; shift 2 ;;
60
71
  --remote-flag) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; REMOTE_FLAGS="$2"; shift 2 ;;
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 ;;
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 ;;
62
73
  *) echo "Unknown arg: $1" >&2; exit 2 ;;
63
74
  esac
64
75
  done
@@ -67,6 +78,24 @@ if $BUILD_LAVAMOAT && $START_WATCH; then
67
78
  echo "--build-lavamoat cannot be combined with --start-watch" >&2
68
79
  exit 2
69
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
70
99
 
71
100
  case "$CHROME_USER_DATA_DIR" in
72
101
  ""|/*) ;;
@@ -115,6 +144,12 @@ if [ -z "$VERIFY_SH" ]; then
115
144
  fi
116
145
  RUNTIME_DIR="$(recipe_runtime_dir)"
117
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
118
153
  CHROME_PROFILE_NAME="${RECIPE_CHROME_PROFILE_NAME:-chrome-profile}"
119
154
  # Runner bin (installed wrapper → source runner). Used by the watch prepare path
120
155
  # to defer the cache-clear DECISION to `runtime-decision` (single source) and to
@@ -140,21 +175,36 @@ if [ -d "$LIVE_ROOT" ]; then
140
175
  fi
141
176
 
142
177
  if $LAUNCH_EXISTING_DIST && [ -z "$PREPARE_CMD" ]; then
143
- 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
144
186
  # Stable beside the profile: the reused profile registers the unpacked
145
187
  # extension by PATH; a per-run snapshot path gets pruned and the next launch
146
188
  # loads nothing ("No EIP-6963 Provider Detected").
147
- 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"
148
191
  # Stable per-checkout profile: successive runs recognize their own Chrome
149
192
  # (ownership guard matches on --user-data-dir), take it over cleanly, and the
150
193
  # seeded wallet persists across relaunches. A per-run profile made every
151
194
  # relaunch "foreign" and let live/ pruning delete profiles still in use.
152
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
153
200
  FIXTURE_STATE_ABS="$ARTIFACTS/fixture-state.json"
154
201
  FIXTURE_VALIDATION_ABS="$ARTIFACTS/logs/fixture-account-parity.json"
155
- # Wallet fixture resolution chain + provenance live in seed-fixture.sh.
156
- WALLET_FIXTURE_ABS="$(bash "$SCRIPT_DIR/seed-fixture.sh" resolve --target "$TARGET" --cdp-port "$CDP_PORT" --source-out "$ARTIFACTS/logs/fixture-source.json")"
157
- 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
158
208
  # resolve failed before writing provenance (invalid RECIPE_WALLET_FIXTURE);
159
209
  # keep the documented missing-provenance artifact.
160
210
  node - "$ARTIFACTS/logs/fixture-source.json" "" <<'NODE'
@@ -170,6 +220,7 @@ NODE
170
220
  fi
171
221
  mkdir -p "$PROFILE_ABS"
172
222
  quoted_dist="$(printf '%q' "$DIST_ABS")"
223
+ quoted_runtime_root="$(printf '%q' "$RUNTIME_ROOT_ABS")"
173
224
  quoted_runtime_dist="$(printf '%q' "$RUNTIME_DIST_ABS")"
174
225
  quoted_profile="$(printf '%q' "$PROFILE_ABS")"
175
226
  quoted_seed_fixture="$(printf '%q' "$SCRIPT_DIR/seed-fixture.sh")"
@@ -177,8 +228,10 @@ NODE
177
228
  quoted_build_lavamoat="$(printf '%q' "$SCRIPT_DIR/build-lavamoat.sh")"
178
229
  quoted_snapshot_dist="$(printf '%q' "$SCRIPT_DIR/snapshot-dist.sh")"
179
230
  quoted_configure_manifest="$(printf '%q' "$SCRIPT_DIR/configure-runtime-manifest.cjs")"
231
+ quoted_check_infura="$(printf '%q' "$SCRIPT_DIR/check-infura-readiness.cjs")"
180
232
  quoted_stamp_title="$(printf '%q' "$SCRIPT_DIR/stamp-runtime-title.cjs")"
181
233
  quoted_chrome_launcher="$(printf '%q' "$SCRIPT_DIR/launch-browser.cjs")"
234
+ quoted_artifact_state="$(printf '%q' "$SCRIPT_DIR/artifact-runtime-state.cjs")"
182
235
  quoted_fixture_state="$(printf '%q' "$FIXTURE_STATE_ABS")"
183
236
  quoted_fixture_validation="$(printf '%q' "$FIXTURE_VALIDATION_ABS")"
184
237
  quoted_extension_id_file="$(printf '%q' "$TARGET/$RUNTIME_DIR/extension.id")"
@@ -251,16 +304,37 @@ NODE
251
304
  quoted_chrome_log="$(printf '%q' "$ARTIFACTS/logs/chrome.log")"
252
305
  quoted_chrome_pid="$(printf '%q' "$ARTIFACTS/logs/chrome.pid")"
253
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
254
312
  if $START_WATCH; then
255
313
  prepare_parts+=("bash ${quoted_start_watch} --runtime-dir ${quoted_runtime_dir} --runner-bin ${quoted_runner}")
256
314
  fi
257
315
  if $BUILD_LAVAMOAT; then
258
316
  prepare_parts+=("bash ${quoted_build_lavamoat} --target ${quoted_target} --runtime-dir ${quoted_runtime_dir}")
259
317
  fi
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}")
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")
263
- 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
264
338
  # Optional A/B feature-flag pinning: patch the ephemeral snapshot manifest so
265
339
  # manifest._flags wins over the fetched ClientConfigApi value. No-op unless
266
340
  # --remote-flag was passed; the snapshot is re-created each run, so no revert.
@@ -295,13 +369,15 @@ echo "Extension live validation command:"
295
369
  display_args=(mm-harness runtime-launch --adapter extension --target "$TARGET" --cdp-port "$CDP_PORT")
296
370
  $START_WATCH && display_args+=(--start-watch)
297
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)
298
374
  [ -n "$REMOTE_FLAGS" ] && display_args+=(--remote-flag "$REMOTE_FLAGS")
299
375
  [ -n "$CHROME_USER_DATA_DIR" ] && display_args+=(--chrome-user-data-dir "$CHROME_USER_DATA_DIR")
300
376
  printf ' '
301
377
  printf '%q ' "${display_args[@]}"
302
378
  printf '\n'
303
379
  echo "Launch artifacts: $ARTIFACTS/launch"
304
- echo "Verify artifacts: $ARTIFACTS/verify"
380
+ $RUN_VERIFY && echo "Verify artifacts: $ARTIFACTS/verify"
305
381
 
306
382
  launch_args=(--target "$TARGET" --cdp-port "$CDP_PORT" --artifacts-dir "$ARTIFACTS/launch")
307
383
  [ -n "$PREPARE_CMD" ] && launch_args+=(--prepare-cmd "$PREPARE_CMD")
@@ -312,18 +388,21 @@ set +e
312
388
  launch_status=$?
313
389
  set -e
314
390
 
315
- verify_status=1
316
- if [ "$launch_status" -eq 0 ]; then
391
+ verify_status=0
392
+ if [ "$launch_status" -eq 0 ] && $RUN_VERIFY; then
317
393
  echo "[recipe-harness] phase 2/2: live verify (readiness + smoke recipe)" >&2
318
394
  set +e
319
395
  "$VERIFY_SH" --target "$TARGET" --out "$OUT" --cdp-port "$CDP_PORT" --artifacts-dir "$ARTIFACTS/verify"
320
396
  verify_status=$?
321
397
  set -e
322
- else
398
+ elif [ "$launch_status" -ne 0 ]; then
399
+ verify_status=1
323
400
  echo "Skipping Extension live verify because launch failed; see $ARTIFACTS/launch/summary.json" >&2
401
+ else
402
+ echo "Skipping Extension live verify for launch-only fixture application." >&2
324
403
  fi
325
404
 
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'
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'
327
406
  const fs = require('fs');
328
407
  const path = require('path');
329
408
  const artifacts = process.env.ARTIFACTS_FOR_SUMMARY;
@@ -331,6 +410,7 @@ const launchSummary = path.join(artifacts, 'launch', 'summary.json');
331
410
  const verifySummary = path.join(artifacts, 'verify', 'summary.json');
332
411
  const launchStatus = Number(process.env.LAUNCH_STATUS);
333
412
  const verifyStatus = Number(process.env.VERIFY_STATUS);
413
+ const runVerify = process.env.RUN_VERIFY === 'true';
334
414
  fs.writeFileSync(path.join(artifacts, 'summary.json'), `${JSON.stringify({
335
415
  adapter: 'extension',
336
416
  action: 'live',
@@ -340,10 +420,15 @@ fs.writeFileSync(path.join(artifacts, 'summary.json'), `${JSON.stringify({
340
420
  launchExistingDist: process.env.LAUNCH_EXISTING_DIST === 'true',
341
421
  startWatch: process.env.START_WATCH === 'true',
342
422
  buildLavaMoat: process.env.BUILD_LAVAMOAT === 'true',
423
+ externalArtifact: process.env.EXTERNAL_ARTIFACT === 'true',
343
424
  launch: { exitCode: launchStatus, summaryPath: fs.existsSync(launchSummary) ? launchSummary : null },
344
- verify: { exitCode: verifyStatus, summaryPath: fs.existsSync(verifySummary) ? verifySummary : null },
425
+ verify: runVerify
426
+ ? { status: verifyStatus === 0 ? 'pass' : 'fail', exitCode: verifyStatus, summaryPath: fs.existsSync(verifySummary) ? verifySummary : null }
427
+ : { status: 'skipped', exitCode: null, summaryPath: null },
345
428
  easyCommand: `mm-harness launch --verify --target <repo>`,
346
- note: 'Runs launch then live verify so a developer can validate browser startup, CDP readiness, recipe bridge, screenshots/fallback classification, and sample recipes from one runner-owned command.',
429
+ note: runVerify
430
+ ? 'Runs launch then live verify so a developer can validate browser startup, CDP readiness, recipe bridge, screenshots/fallback classification, and sample recipes from one runner-owned command.'
431
+ : 'Runs launch validation only for fixture application while the checkout lock is held.',
347
432
  generatedAt: new Date().toISOString(),
348
433
  }, null, 2)}\n`);
349
434
  NODE