@deeeed/metamask-harness 0.3.4 → 0.3.6

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 (42) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/adapters/core/cleanup.sh +0 -0
  3. package/adapters/core/inject.sh +0 -0
  4. package/adapters/extension/cleanup.mjs +0 -0
  5. package/adapters/extension/ensure-browser.sh +0 -0
  6. package/adapters/extension/inject.mjs +0 -0
  7. package/adapters/extension/launch-browser.cjs +0 -0
  8. package/adapters/extension/launch.sh +0 -0
  9. package/adapters/extension/live.sh +0 -0
  10. package/adapters/extension/readiness.mjs +0 -0
  11. package/adapters/extension/refresh-build.sh +0 -0
  12. package/adapters/extension/seed-fixture.sh +0 -0
  13. package/adapters/extension/sidepanel-toggle.sh +0 -0
  14. package/adapters/extension/snapshot-dist.sh +0 -0
  15. package/adapters/extension/start-watch.sh +0 -0
  16. package/adapters/extension/verify.sh +0 -0
  17. package/adapters/extension/wallet-fixture-state.cjs +0 -0
  18. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +0 -0
  19. package/adapters/mobile/bridge-runtime/setup-wallet.sh +0 -0
  20. package/adapters/mobile/cleanup.sh +0 -0
  21. package/adapters/mobile/inject.sh +20 -4
  22. package/adapters/mobile/lib/metro-listener.sh +0 -0
  23. package/adapters/mobile/open-device.sh +0 -0
  24. package/adapters/mobile/prewarm-bundle.sh +0 -0
  25. package/adapters/mobile/start-metro.sh +0 -0
  26. package/adapters/mobile/verify.sh +0 -0
  27. package/adapters/mobile/wait-for-bridge.sh +0 -0
  28. package/adapters/mobile/yarn-setup.sh +0 -0
  29. package/adapters/shared/activate-repo-node.sh +0 -0
  30. package/adapters/shared/cli-ux.sh +0 -0
  31. package/adapters/shared/ensure-runner-deps.sh +0 -0
  32. package/adapters/shared/harness-path.sh +0 -0
  33. package/adapters/shared/hash-helpers.sh +0 -0
  34. package/adapters/shared/json-field.sh +0 -0
  35. package/adapters/shared/resolve-farmslot-ports.sh +0 -0
  36. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  37. package/package.json +2 -2
  38. package/scripts/completions.sh +0 -0
  39. package/scripts/install-completions.sh +0 -0
  40. package/src/commands/shared.ts +20 -6
  41. package/src/harness.ts +51 -10
  42. package/src/leaf-invoke.ts +28 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.6 - 2026-07-04
4
+
5
+ ### Fixed
6
+ - **Install manifest carries target checkout identity (`targetRevision`)** — `adapters/mobile/inject.sh` stamps the target repo's `HEAD` (resolved once, up front, via `git rev-parse --verify HEAD`) and an explicit `installMode` into the mobile install manifest on both install paths (product-owned/metadata-only and overlay). Consumers (the farm recipe hook) can now gate their fast path on manifest identity matching the current checkout instead of manifest existence, closing the stale-manifest hole where a gitignored manifest survives a branch switch to a bridge-less checkout. Install now fails with teaching guidance when the target `HEAD` cannot be resolved (not-a-repo, unborn branch), instead of stamping an unusable `targetRevision`. Contract tests cover the identity stamp on both paths and the unborn-HEAD refusal.
7
+
8
+ ## 0.3.5 - 2026-07-04
9
+
10
+ ### Fixed
11
+ - **IMP-24b: shell leaves run even when packed without the exec bit** — `yarn pack` (used by `yarn npm publish`) strips the executable bit off shipped `*.sh` leaves to `644`, even though git stores `100755` and `npm pack` preserves `755`. The 0.3.4 fix (git `100755` + an `npm pack` tarball test) was therefore insufficient: the published `yarn`-packed tarball still shipped `644` leaves and a fresh `npm i -g` hit `EACCES` on the first leaf. `spawnScript` now invokes shell leaves through `bash <leaf>` instead of exec'ing them directly, so the leaf's file mode is irrelevant (node leaves were already interpreter-invoked via `process.execPath`). Env overlays (`FORCE_COLOR`/`RECIPE_RUNTIME_DIR`), arg passing, cwd, JSON envelope, and exit codes are unchanged. New contract test `tests/contract/leaf-644-runs.test.sh` forces the device leaves to mode `644` and drives them through the real `mm-harness` spawn path, asserting they still run — the regression the `npm pack` tarball test could not catch.
12
+
13
+ ### Changed
14
+ - **Publish with `npm publish`, not `yarn npm publish`** — `npm publish` preserves the git `100755` mode on shipped `*.sh` leaves (defense-in-depth on top of the interpreter invocation above). Documented in `AGENTS.md`; git exec bits remain `100755`.
15
+
3
16
  ## 0.3.4 - 2026-07-04
4
17
 
5
18
  ### Fixed
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -60,6 +60,19 @@ if ! command -v harness_root >/dev/null 2>&1; then
60
60
  exit 1
61
61
  fi
62
62
  TARGET="$(cd "$TARGET" && pwd)"
63
+ # The install stamps this checkout's HEAD into the manifest as its identity. If we
64
+ # cannot resolve it (not a git repo, unborn HEAD, git unavailable) we must not
65
+ # install with an unidentifiable target: a downstream consumer could not tell a
66
+ # stale manifest (left behind by a previous branch/tag) from a current one. Fail
67
+ # loud with escape guidance rather than stamping an "unknown" revision.
68
+ # --verify fails cleanly (no stdout) on an unborn HEAD, where plain `rev-parse
69
+ # HEAD` would echo the literal "HEAD" and slip past the emptiness check below.
70
+ TARGET_REV="$(git -C "$TARGET" rev-parse --verify HEAD 2>/dev/null || true)"
71
+ if [ -z "$TARGET_REV" ]; then
72
+ echo "Refusing mobile recipe harness install: cannot resolve the target checkout HEAD in $TARGET." >&2
73
+ echo "Next: ensure the target is a git checkout with at least one commit (git -C \"$TARGET\" rev-parse HEAD succeeds), then retry the install." >&2
74
+ exit 1
75
+ fi
63
76
  METAMASK_RUNNER_DIR="$RUNNER_DIR"
64
77
  METAMASK_RUNNER_SOURCE_KIND="runner-self"
65
78
  METAMASK_RUNNER_REVISION="$(git -C "$RUNNER_DIR" rev-parse HEAD 2>/dev/null || echo unknown)"
@@ -272,10 +285,11 @@ if [ "$FORCE_OVERLAY" = false ] && has_product_owned_mobile_harness; then
272
285
  fi
273
286
  node -e '
274
287
  const fs = require("fs");
275
- const [runnerOwnerDir, runnerOwnerRevision, runnerDir, runnerRevision, runnerSourceKind, adapterRuntime, target, scriptDir, manifestPath, harnessRoot, harnessRel, cleanupCommand] = process.argv.slice(1);
288
+ const [runnerOwnerDir, runnerOwnerRevision, runnerDir, runnerRevision, runnerSourceKind, adapterRuntime, target, scriptDir, manifestPath, harnessRoot, harnessRel, cleanupCommand, targetRevision] = process.argv.slice(1);
276
289
  const m = {
277
290
  adapter: "mobile",
278
291
  installMode: "product-owned",
292
+ targetRevision,
279
293
  installedAt: new Date().toISOString(),
280
294
  source: {
281
295
  runnerOwnerDir,
@@ -308,7 +322,7 @@ if [ "$FORCE_OVERLAY" = false ] && has_product_owned_mobile_harness; then
308
322
  note: "This checkout already contains the Mobile in-app bridge. Runner install only writes recipe-harness metadata and must not overwrite tracked in-app bridge files."
309
323
  };
310
324
  fs.writeFileSync(manifestPath, JSON.stringify(m, null, 2) + "\n");
311
- ' "$RUNNER_DIR" "$SOURCE_REV" "$METAMASK_RUNNER_DIR" "$METAMASK_RUNNER_REVISION" "$METAMASK_RUNNER_SOURCE_KIND" "$ADAPTER_DIR" "$TARGET" "$SCRIPT_DIR" "$HARNESS_DIR/manifest.json" "$HARNESS_ROOT" "$HARNESS_REL" "$CLEANUP_COMMAND"
325
+ ' "$RUNNER_DIR" "$SOURCE_REV" "$METAMASK_RUNNER_DIR" "$METAMASK_RUNNER_REVISION" "$METAMASK_RUNNER_SOURCE_KIND" "$ADAPTER_DIR" "$TARGET" "$SCRIPT_DIR" "$HARNESS_DIR/manifest.json" "$HARNESS_ROOT" "$HARNESS_REL" "$CLEANUP_COMMAND" "$TARGET_REV"
312
326
  echo "Installed mobile recipe harness metadata only (in-app bridge detected): $HARNESS_DIR/manifest.json"
313
327
  exit 0
314
328
  fi
@@ -637,9 +651,11 @@ SOURCE_REV="$(git -C "$RUNNER_DIR" rev-parse HEAD 2>/dev/null || echo unknown)"
637
651
  CLEANUP_COMMAND="RECIPE_HARNESS_ROOT=$HARNESS_ROOT $(printf '%q' "$CLEANUP_SH") --target $(printf '%q' "$TARGET")"
638
652
  node -e '
639
653
  const fs = require("fs");
640
- const [runnerOwnerDir, runnerOwnerRevision, runnerDir, runnerRevision, runnerSourceKind, adapterRuntime, target, backupDir, scriptDir, manifestPath, mobileAgenticSource, harnessRoot, harnessRel, cleanupCommand] = process.argv.slice(1);
654
+ const [runnerOwnerDir, runnerOwnerRevision, runnerDir, runnerRevision, runnerSourceKind, adapterRuntime, target, backupDir, scriptDir, manifestPath, mobileAgenticSource, harnessRoot, harnessRel, cleanupCommand, targetRevision] = process.argv.slice(1);
641
655
  const m = {
642
656
  adapter: "mobile",
657
+ installMode: "overlay",
658
+ targetRevision,
643
659
  installedAt: new Date().toISOString(),
644
660
  source: {
645
661
  runnerOwnerDir,
@@ -669,7 +685,7 @@ node -e '
669
685
  ]
670
686
  };
671
687
  fs.writeFileSync(manifestPath, JSON.stringify(m, null, 2) + "\n");
672
- ' "$RUNNER_DIR" "$SOURCE_REV" "$METAMASK_RUNNER_DIR" "$METAMASK_RUNNER_REVISION" "$METAMASK_RUNNER_SOURCE_KIND" "$ADAPTER_DIR" "$TARGET" "$BACKUP_DIR" "$SCRIPT_DIR" "$HARNESS_DIR/manifest.json" "${MOBILE_AGENTIC_SOURCE:-}" "$HARNESS_ROOT" "$HARNESS_REL" "$CLEANUP_COMMAND"
688
+ ' "$RUNNER_DIR" "$SOURCE_REV" "$METAMASK_RUNNER_DIR" "$METAMASK_RUNNER_REVISION" "$METAMASK_RUNNER_SOURCE_KIND" "$ADAPTER_DIR" "$TARGET" "$BACKUP_DIR" "$SCRIPT_DIR" "$HARNESS_DIR/manifest.json" "${MOBILE_AGENTIC_SOURCE:-}" "$HARNESS_ROOT" "$HARNESS_REL" "$CLEANUP_COMMAND" "$TARGET_REV"
673
689
 
674
690
  ENSURE_DEPS="$SCRIPT_DIR/../shared/ensure-runner-deps.sh"
675
691
  if [ -f "$ENSURE_DEPS" ]; then
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -56,4 +56,4 @@
56
56
  "url": "https://github.com/MetaMask/experimental-metamask-harness/issues"
57
57
  },
58
58
  "homepage": "https://github.com/MetaMask/experimental-metamask-harness#readme"
59
- }
59
+ }
File without changes
File without changes
@@ -11,6 +11,7 @@ import { spawnSync } from 'node:child_process';
11
11
  import path from 'node:path';
12
12
 
13
13
  import { detectAdapter } from '../harness.ts';
14
+ import { resolveLeafInvoke, shellLeafMissing } from '../leaf-invoke.ts';
14
15
  import { runnerDir } from '../paths.ts';
15
16
  import type { MetaMaskRecipeAdapter } from '../types.ts';
16
17
 
@@ -107,23 +108,36 @@ export function spawnScript(
107
108
  // For node invocations (script === process.execPath) the seam stem is derived
108
109
  // from args[0] so each spawned script has its own override key.
109
110
  const isNodeScript = script === process.execPath && args.length > 0;
111
+ // Seam stem: for node invocations derive from args[0] so each script has its
112
+ // own override key; for shell and other leaves derive from the script basename.
110
113
  const stem = isNodeScript
111
114
  ? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, '_').toUpperCase()
112
115
  : path.basename(script).replace(/[^A-Za-z0-9]/gu, '_').toUpperCase();
113
116
  const override = process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
114
117
  const bin = override ?? script;
115
- const spawnArgs = override !== undefined && isNodeScript ? args.slice(1) : args;
116
- const result = spawnSync(bin, spawnArgs, {
118
+ const directArgs = override !== undefined && isNodeScript ? args.slice(1) : args;
119
+
120
+ // Shell leaves (.sh) run through bash so file mode need not be executable.
121
+ // Bash exits 127 (not a Node spawn error) for a missing file, so pre-check.
122
+ if (shellLeafMissing(bin)) {
123
+ const message =
124
+ `leaf could not start: ${path.basename(bin)} (ENOENT)\n` +
125
+ ` Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) — the shell leaf is missing or not executable`;
126
+ process.stderr.write(`${message}\n`);
127
+ return { status: 1, output: message };
128
+ }
129
+
130
+ const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
131
+ const result = spawnSync(invokeBin, spawnArgs, {
117
132
  cwd,
118
133
  encoding: 'utf8',
119
134
  env: env ? { ...process.env, ...env } : process.env,
120
135
  maxBuffer: 64 * 1024 * 1024,
121
136
  });
122
137
  if (result.error) {
123
- // A spawn failure (EACCES on a non-executable leaf, ENOENT on a missing one)
124
- // is otherwise silent because the human-mode stderr forward below is skipped
125
- // on early return. Always emit the diagnostic so a leaf that can't start is
126
- // never a mystery exit-1. For node invocations the leaf is args[0], not node.
138
+ // Spawn failure for non-shell leaves (ENOENT/EACCES) or when bash itself
139
+ // cannot start. Always emit so a leaf that cannot start is never silent.
140
+ // For node invocations the leaf name comes from args[0], not process.execPath.
127
141
  const leaf = isNodeScript ? path.basename(args[0]) : path.basename(script);
128
142
  const code = (result.error as NodeJS.ErrnoException).code ?? 'ESPAWN';
129
143
  const message =
package/src/harness.ts CHANGED
@@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
+ import { resolveLeafInvoke, shellLeafMissing, missingShellLeafMessage } from './leaf-invoke.ts';
5
6
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from './paths.ts';
6
7
  import type { MetaMaskRecipeAdapter } from './types.ts';
7
8
  import { prepareMobile } from './adapters/mobile/prepare.ts';
@@ -334,7 +335,8 @@ function resolveHarnessDispatch(
334
335
  if (adapter === 'extension') {
335
336
  return { command: process.execPath, prefixArgs: [resolveEntry(runnerDir, [entry, fallback], 'file')] };
336
337
  }
337
- return { command: resolveEntry(runnerDir, [entry, fallback], 'exec'), prefixArgs: [] };
338
+ // Shell leaves are invoked through bash; file existence is the only requirement.
339
+ return { command: resolveEntry(runnerDir, [entry, fallback], 'file'), prefixArgs: [] };
338
340
  }
339
341
 
340
342
  if (action === 'cleanup') {
@@ -343,7 +345,7 @@ function resolveHarnessDispatch(
343
345
  if (adapter === 'extension') {
344
346
  return { command: process.execPath, prefixArgs: [resolveEntry(base, [entry, fallback], 'file')] };
345
347
  }
346
- return { command: resolveEntry(base, [entry, fallback], 'exec'), prefixArgs: [] };
348
+ return { command: resolveEntry(base, [entry, fallback], 'file'), prefixArgs: [] };
347
349
  }
348
350
 
349
351
  if (adapter === 'core' && action === 'verify') {
@@ -360,16 +362,16 @@ function resolveHarnessDispatch(
360
362
  return { command: delegate, prefixArgs: ['doctor', '--adapter', 'core'] };
361
363
  }
362
364
 
363
- // mobile/extension live|verify: prefer the installed helper, else the runner's
364
- // own adapters/scripts helper (first executable candidate wins).
365
+ // mobile/extension live|verify: prefer the installed helper (any mode), else
366
+ // the runner's own adapters/scripts candidate. Bash invocation makes +x irrelevant.
365
367
  const installedScript = path.join(recipeHarnessPath(target, adapter), 'scripts', `${action}.sh`);
366
- if (isExecutable(installedScript)) {
368
+ if (fs.existsSync(installedScript)) {
367
369
  return { command: installedScript, prefixArgs: [] };
368
370
  }
369
371
  const command = resolveEntry(
370
372
  runnerDir,
371
373
  [`adapters/${adapter}/${action}.sh`, `scripts/${adapter}/${action}.sh`],
372
- 'exec',
374
+ 'file',
373
375
  );
374
376
  return { command, prefixArgs: [] };
375
377
  }
@@ -414,15 +416,32 @@ async function handleMobileLive(
414
416
 
415
417
  // Verify leaf: --no-auto-start because launch already started the app.
416
418
  const installedVerify = path.join(recipeHarnessPath(target, 'mobile'), 'scripts', 'verify.sh');
417
- const verifySh = isExecutable(installedVerify)
419
+ const verifySh = fs.existsSync(installedVerify)
418
420
  ? installedVerify
419
- : resolveEntry(runnerDir, ['adapters/mobile/verify.sh', 'scripts/mobile/verify.sh'], 'exec');
421
+ : resolveEntry(runnerDir, ['adapters/mobile/verify.sh', 'scripts/mobile/verify.sh'], 'file');
420
422
 
421
423
  const verifyArgs = hasArg(forwardArgs, '--no-auto-start')
422
424
  ? [...forwardArgs]
423
425
  : ['--no-auto-start', ...forwardArgs];
424
426
 
425
- const result = spawnSync(verifySh, verifyArgs, {
427
+ if (shellLeafMissing(verifySh)) {
428
+ const message = missingShellLeafMessage(verifySh);
429
+ if (json) {
430
+ console.log(
431
+ harnessSummary('live', 'mobile', target, 'fail', 1, autoDetected, {
432
+ code: 'HARNESS_SPAWN_FAILED',
433
+ message,
434
+ userAction: failureHint('mobile', 'live'),
435
+ }),
436
+ );
437
+ } else {
438
+ console.error(`✗ ${message}`);
439
+ }
440
+ return 1;
441
+ }
442
+
443
+ const { bin: verifyBin, args: verifySpawnArgs } = resolveLeafInvoke(verifySh, verifyArgs);
444
+ const result = spawnSync(verifyBin, verifySpawnArgs, {
426
445
  stdio: json ? ['inherit', 2, 'inherit'] : 'inherit',
427
446
  env: process.env,
428
447
  });
@@ -544,7 +563,29 @@ export async function handleHarness(argv: string[]): Promise<number> {
544
563
  }
545
564
 
546
565
  const start = Date.now();
547
- const result = spawnSync(dispatch.command, [...dispatch.prefixArgs, ...forwardArgs], {
566
+
567
+ // Shell leaves need bash invocation; bash exits 127 (not ENOENT) for missing files.
568
+ if (shellLeafMissing(dispatch.command)) {
569
+ const message = missingShellLeafMessage(dispatch.command);
570
+ if (json) {
571
+ console.log(
572
+ harnessSummary(harnessAction, adapter, target, 'fail', 1, autoDetected, {
573
+ code: 'HARNESS_SPAWN_FAILED',
574
+ message,
575
+ userAction: failureHint(adapter, harnessAction),
576
+ }),
577
+ );
578
+ } else {
579
+ console.error(`✗ ${message}`);
580
+ }
581
+ return 1;
582
+ }
583
+
584
+ const { bin: dispatchBin, args: dispatchArgs } = resolveLeafInvoke(dispatch.command, [
585
+ ...dispatch.prefixArgs,
586
+ ...forwardArgs,
587
+ ]);
588
+ const result = spawnSync(dispatchBin, dispatchArgs, {
548
589
  // Default: stream child output verbatim (byte-identical to the skill path).
549
590
  // --json: route child output to our stderr so stdout carries only the summary.
550
591
  stdio: json ? ['inherit', 2, 'inherit'] : 'inherit',
@@ -0,0 +1,28 @@
1
+ // Interpreter routing for adapter shell and node leaves.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ // Returns the interpreter and args for invoking a leaf. Shell leaves (.sh) run
6
+ // through bash so the file mode does not need to be executable. Node leaves
7
+ // are already invoked through process.execPath by their callers and are
8
+ // returned unchanged.
9
+ export function resolveLeafInvoke(command: string, args: string[]): { bin: string; args: string[] } {
10
+ if (command.endsWith('.sh')) return { bin: 'bash', args: [command, ...args] };
11
+ return { bin: command, args };
12
+ }
13
+
14
+ // Teaching diagnostic for a shell leaf that is absent from disk. Bash exits
15
+ // 127 (not a Node spawn error) for a missing file, so callers pre-check and
16
+ // emit this rather than relying on result.error.
17
+ export function missingShellLeafMessage(leafPath: string): string {
18
+ const leaf = path.basename(leafPath);
19
+ return (
20
+ `leaf could not start: ${leaf} (ENOENT)\n` +
21
+ ` Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) — the shell leaf is missing or not executable`
22
+ );
23
+ }
24
+
25
+ // Returns true when a .sh leaf path does not exist on disk.
26
+ export function shellLeafMissing(leafPath: string): boolean {
27
+ return leafPath.endsWith('.sh') && !fs.existsSync(leafPath);
28
+ }