@deeeed/metamask-harness 0.14.5 → 0.15.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 +96 -6
  2. package/adapters/extension/ensure-browser.sh +4 -2
  3. package/adapters/extension/launch-browser.cjs +73 -34
  4. package/adapters/extension/live.sh +21 -10
  5. package/adapters/extension/reattach.sh +3 -3
  6. package/adapters/extension/sidepanel-toggle.sh +10 -4
  7. package/adapters/extension/start-watch.sh +2 -2
  8. package/adapters/extension/verify.sh +19 -2
  9. package/adapters/extension/wallet-fixture-state.cjs +14 -1
  10. package/adapters/manifest.json +19 -3
  11. package/adapters/mobile/bridge-runtime/setup-wallet.sh +6 -4
  12. package/adapters/mobile/launch-metro.cjs +74 -0
  13. package/adapters/mobile/lib/tmux-viewer.sh +4 -4
  14. package/adapters/mobile/open-device.sh +3 -1
  15. package/adapters/mobile/start-metro.sh +12 -18
  16. package/adapters/mobile/stop-metro.sh +1 -0
  17. package/adapters/mobile/verify.sh +5 -5
  18. package/adapters/shared/install-repo-deps.sh +29 -0
  19. package/adapters/shared/open-debug.mjs +35 -4
  20. package/adapters/shared/open-log-window.sh +1 -1
  21. package/adapters/shared/resolve-slot-ports-core.mjs +72 -1
  22. package/adapters/shared/resolve-slot-ports.sh +21 -17
  23. package/adapters/shared/tmux-session.sh +0 -5
  24. package/adapters/shared/tmux-viewer.sh +7 -2
  25. package/dist/adapters/core/surface.js +3 -0
  26. package/dist/adapters/extension/ensure-ready.js +0 -7
  27. package/dist/adapters/extension/runtime-decision.js +93 -3
  28. package/dist/adapters/extension/surface.js +17 -6
  29. package/dist/adapters/mobile/metro-env.js +67 -0
  30. package/dist/adapters/mobile/perps-env.js +101 -0
  31. package/dist/adapters/mobile/prepare.js +32 -14
  32. package/dist/adapters/mobile/runtime-decision.js +21 -2
  33. package/dist/adapters/mobile/source-freshness.js +116 -0
  34. package/dist/adapters/mobile/surface.js +5 -1
  35. package/dist/adapters/resolve-slot-ports.js +4 -0
  36. package/dist/adapters/slot-ports.js +131 -49
  37. package/dist/adapters.js +7 -2
  38. package/dist/checkout-lock.js +72 -0
  39. package/dist/cli-commands.js +1 -1
  40. package/dist/cli.js +2 -0
  41. package/dist/commands/call.js +91 -54
  42. package/dist/commands/check.js +266 -46
  43. package/dist/commands/checklist.js +136 -0
  44. package/dist/commands/debug.js +21 -2
  45. package/dist/commands/doctor.js +233 -18
  46. package/dist/commands/farmslot-ready.js +25 -0
  47. package/dist/commands/fixtures.js +177 -42
  48. package/dist/commands/launch/extension.js +6 -1
  49. package/dist/commands/launch/index.js +54 -22
  50. package/dist/commands/logs.js +24 -4
  51. package/dist/commands/parse-args.js +1 -0
  52. package/dist/commands/run-engine.js +223 -7
  53. package/dist/commands/run.js +67 -50
  54. package/dist/commands/shared.js +86 -5
  55. package/dist/commands/stop.js +1 -2
  56. package/dist/doctor.js +39 -17
  57. package/dist/harness.js +5 -2
  58. package/dist/heal-bounds.js +1 -1
  59. package/dist/mm-harness-cli.js +41 -12
  60. package/dist/runner.js +35 -5
  61. package/dist/runtime-context.js +228 -0
  62. package/docs/CLI-SPEC.md +15 -11
  63. package/docs/MENTAL-MODEL.md +6 -6
  64. package/library/actions/extension/perps/perps.mjs +29 -8
  65. package/library/actions/extension/platform/cdp.mjs +128 -28
  66. package/library/actions/extension/ui/navigate.mjs +1 -1
  67. package/library/actions/mobile/perps/perps.mjs +13 -1
  68. package/library/actions/mobile/platform/bridge.mjs +302 -29
  69. package/library/actions/mobile/wallet/ensure_unlocked.mjs +7 -2
  70. package/library/manifests/extension.action-manifest.json +32 -12
  71. package/library/manifests/mobile.action-manifest.json +25 -3
  72. package/package.json +4 -4
  73. package/scripts/completions.sh +7 -4
@@ -120,18 +120,23 @@ async function captureHelperBrowserPid(context, port) {
120
120
  );
121
121
  }
122
122
 
123
- async function captureHelperSnapshot(page, context, relPath, metadata) {
124
- if (process.platform !== 'darwin') {
125
- throw new Error('Extension ui.screenshot uses capture-helper snapshot and is currently supported only on macOS.');
126
- }
123
+ async function captureCdpViewportSnapshot(page, context, relPath, metadata, captureHelperError = null) {
127
124
  const { relative, absolute } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
128
125
  await mkdir(path.dirname(absolute), { recursive: true });
129
-
130
- await page.session.call('Page.bringToFront');
131
- const pid = await captureHelperBrowserPid(context, page.port);
132
- const timeoutMs = Number(metadata?.timeoutMs ?? 30000);
133
- const sessionSnapshot = await captureActiveRecipeRecordingSnapshot(pid, absolute, timeoutMs);
134
- if (sessionSnapshot) {
126
+ try {
127
+ const timeoutMs = Number(metadata?.cdpTimeoutMs ?? 5000);
128
+ const result = await Promise.race([
129
+ page.session.call('Page.captureScreenshot', {
130
+ format: 'png',
131
+ fromSurface: true,
132
+ captureBeyondViewport: false,
133
+ }),
134
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Chrome Page.captureScreenshot timed out after ${timeoutMs}ms.`)), timeoutMs)),
135
+ ]);
136
+ if (typeof result?.data !== 'string' || result.data.length === 0) {
137
+ throw new Error('Chrome Page.captureScreenshot returned no image data.');
138
+ }
139
+ await writeFile(absolute, Buffer.from(result.data, 'base64'));
135
140
  return {
136
141
  path: relative,
137
142
  type: 'screenshot',
@@ -140,25 +145,62 @@ async function captureHelperSnapshot(page, context, relPath, metadata) {
140
145
  category: metadata?.category ?? 'evidence',
141
146
  mimeType: 'image/png',
142
147
  metadata: {
143
- provider: 'capture-helper',
144
- mode: 'record_session_snapshot',
145
- pid,
146
- captureHelper: sessionSnapshot,
148
+ provider: 'cdp',
149
+ mode: 'Page.captureScreenshot',
150
+ ...(captureHelperError ? { fallbackFrom: 'capture-helper', captureHelperError } : {}),
147
151
  },
148
152
  };
153
+ } catch (error) {
154
+ const cdpError = error instanceof Error ? error.message : String(error);
155
+ return captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError);
149
156
  }
157
+ }
150
158
 
151
- const result = await runProcess(captureHelperPath(), ['snapshot', '--pid', String(pid), '--output', absolute], {
152
- cwd: context.projectRoot,
153
- env: process.env,
154
- timeoutMs,
155
- });
156
- if (result.exitCode !== 0) {
157
- throw new Error(
158
- `capture-helper snapshot failed for Extension ui.screenshot (pid ${pid}): ${result.stderr || result.stdout}`,
159
- );
159
+ async function captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError) {
160
+ const { relative, absolute } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
161
+ const dataUrl = await page.evaluate(`(async () => {
162
+ const width = Math.max(1, window.innerWidth);
163
+ const height = Math.max(1, window.innerHeight);
164
+ const source = document.documentElement;
165
+ const clone = source.cloneNode(true);
166
+ const sourceNodes = [source, ...source.querySelectorAll('*')];
167
+ const cloneNodes = [clone, ...clone.querySelectorAll('*')];
168
+ for (let index = 0; index < sourceNodes.length; index += 1) {
169
+ const sourceNode = sourceNodes[index];
170
+ const cloneNode = cloneNodes[index];
171
+ if (!(sourceNode instanceof Element) || !(cloneNode instanceof Element)) continue;
172
+ const computed = getComputedStyle(sourceNode);
173
+ cloneNode.setAttribute('style', Array.from(computed).map((name) => name + ':' + computed.getPropertyValue(name) + ';').join(''));
174
+ if ('value' in sourceNode && typeof sourceNode.value === 'string') cloneNode.setAttribute('value', sourceNode.value);
175
+ if (sourceNode instanceof HTMLCanvasElement) {
176
+ const image = document.createElement('img');
177
+ try { image.src = sourceNode.toDataURL('image/png'); } catch { image.alt = 'canvas'; }
178
+ image.setAttribute('style', cloneNode.getAttribute('style') || '');
179
+ cloneNode.replaceWith(image);
180
+ }
181
+ }
182
+ clone.querySelectorAll('script').forEach((node) => node.remove());
183
+ clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
184
+ clone.style.width = width + 'px';
185
+ clone.style.height = height + 'px';
186
+ clone.style.overflow = 'hidden';
187
+ const serialized = new XMLSerializer().serializeToString(clone);
188
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '"><foreignObject width="100%" height="100%">' + serialized + '</foreignObject></svg>';
189
+ const image = new Image();
190
+ const loaded = new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(new Error('DOM raster image load failed.')); });
191
+ image.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
192
+ await loaded;
193
+ const canvas = document.createElement('canvas');
194
+ canvas.width = width;
195
+ canvas.height = height;
196
+ const context = canvas.getContext('2d');
197
+ context.drawImage(image, 0, 0, width, height);
198
+ return canvas.toDataURL('image/png');
199
+ })()`);
200
+ if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
201
+ throw new Error(`Extension screenshot fallbacks failed: capture-helper=${captureHelperError ?? 'not attempted'}; cdp=${cdpError}; DOM raster returned no PNG.`);
160
202
  }
161
- const details = parseJsonObject(result.stdout);
203
+ await writeFile(absolute, Buffer.from(dataUrl.slice('data:image/png;base64,'.length), 'base64'));
162
204
  return {
163
205
  path: relative,
164
206
  type: 'screenshot',
@@ -167,14 +209,72 @@ async function captureHelperSnapshot(page, context, relPath, metadata) {
167
209
  category: metadata?.category ?? 'evidence',
168
210
  mimeType: 'image/png',
169
211
  metadata: {
170
- provider: 'capture-helper',
171
- mode: 'snapshot',
172
- pid,
173
- ...(details ? { captureHelper: details } : {}),
212
+ provider: 'dom-raster',
213
+ mode: 'computed-style-viewport',
214
+ fallbackFrom: captureHelperError ? 'capture-helper+cdp' : 'cdp',
215
+ ...(captureHelperError ? { captureHelperError } : {}),
216
+ cdpError,
174
217
  },
175
218
  };
176
219
  }
177
220
 
221
+ export async function captureHelperSnapshot(page, context, relPath, metadata) {
222
+ if (process.platform !== 'darwin') {
223
+ return captureCdpViewportSnapshot(page, context, relPath, metadata);
224
+ }
225
+ const { relative, absolute } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
226
+ await mkdir(path.dirname(absolute), { recursive: true });
227
+
228
+ try {
229
+ const pid = await captureHelperBrowserPid(context, page.port);
230
+ const timeoutMs = Number(metadata?.timeoutMs ?? 30000);
231
+ const sessionSnapshot = await captureActiveRecipeRecordingSnapshot(pid, absolute, timeoutMs);
232
+ if (sessionSnapshot) {
233
+ return {
234
+ path: relative,
235
+ type: 'screenshot',
236
+ nodeId: context.nodeId,
237
+ label: metadata?.label ?? `${context.nodeId} screenshot`,
238
+ category: metadata?.category ?? 'evidence',
239
+ mimeType: 'image/png',
240
+ metadata: {
241
+ provider: 'capture-helper',
242
+ mode: 'record_session_snapshot',
243
+ pid,
244
+ captureHelper: sessionSnapshot,
245
+ },
246
+ };
247
+ }
248
+
249
+ const result = await runProcess(captureHelperPath(), ['snapshot', '--pid', String(pid), '--output', absolute], {
250
+ cwd: context.projectRoot,
251
+ env: process.env,
252
+ timeoutMs,
253
+ });
254
+ if (result.exitCode !== 0) {
255
+ throw new Error(`capture-helper snapshot failed for pid ${pid}: ${result.stderr || result.stdout}`);
256
+ }
257
+ const details = parseJsonObject(result.stdout);
258
+ return {
259
+ path: relative,
260
+ type: 'screenshot',
261
+ nodeId: context.nodeId,
262
+ label: metadata?.label ?? `${context.nodeId} screenshot`,
263
+ category: metadata?.category ?? 'evidence',
264
+ mimeType: 'image/png',
265
+ metadata: {
266
+ provider: 'capture-helper',
267
+ mode: 'snapshot',
268
+ pid,
269
+ ...(details ? { captureHelper: details } : {}),
270
+ },
271
+ };
272
+ } catch (error) {
273
+ const message = error instanceof Error ? error.message : String(error);
274
+ return captureCdpViewportSnapshot(page, context, relPath, metadata, message);
275
+ }
276
+ }
277
+
178
278
  function autolaunchEnabled(input) {
179
279
  return input.node?.launch_existing_dist === true ||
180
280
  input.node?.autolaunch === true ||
@@ -2,7 +2,7 @@ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
2
 
3
3
  const PAGE_HASHES = {
4
4
  home: '#/',
5
- perps: '#/?tab=perps',
5
+ perps: '#/perps-home',
6
6
  };
7
7
 
8
8
  function text(value) {
@@ -1,5 +1,6 @@
1
1
  import { pathToFileURL } from 'node:url';
2
2
  import { evalAsync, navigate, runAdapter } from '../platform/bridge.mjs';
3
+ import { ensureUnlocked } from '../wallet/ensure_unlocked.mjs';
3
4
 
4
5
  function sleep(ms) {
5
6
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -637,6 +638,17 @@ async function applyTutorialState(input, config) {
637
638
  export async function startState(input) {
638
639
  const params = paramsForState(input);
639
640
  const config = mergeStateConfig(profileDefaults(params.profile), params);
641
+ // Product edits require an app restart before proof. Mobile relocks on restart,
642
+ // so a deterministic Perps start state must restore the fixture-backed wallet
643
+ // prerequisite before it navigates or converges controller state.
644
+ const wallet = await ensureUnlocked({
645
+ ...input,
646
+ action: 'metamask.wallet.ensure_unlocked',
647
+ node: { ...input.node, action: 'metamask.wallet.ensure_unlocked' },
648
+ });
649
+ // Entering Perps initializes the provider client on a freshly launched app.
650
+ // State reads before this navigation fail with CLIENT_NOT_INITIALIZED.
651
+ const navigation = await applyStateNavigation(input, config);
640
652
  const provider = await ensureProvider(input, config);
641
653
  const network = await ensureNetwork(input, config);
642
654
  const tutorial = await applyTutorialState(input, config);
@@ -644,11 +656,11 @@ export async function startState(input) {
644
656
  const balance = await assertBalance(input, config);
645
657
  const orders = await applyOrdersState(input, config.orders);
646
658
  const positions = await applyPositionsState(input, config.positions);
647
- const navigation = await applyStateNavigation(input, config);
648
659
  return {
649
660
  action: input.action,
650
661
  profile: config.profile ?? params.profile ?? 'clean_market_testnet',
651
662
  phase: 'start_state',
663
+ wallet,
652
664
  provider,
653
665
  network,
654
666
  tutorial,
@@ -1,4 +1,6 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
4
  import { execFile, spawn } from 'node:child_process';
3
5
  import { promisify } from 'node:util';
4
6
  import path from 'node:path';
@@ -405,25 +407,54 @@ export async function simulatorScreenshot(input, relPath) {
405
407
  const adbSerial = targetInfo.adbSerial;
406
408
  const platform = String(input.node?.platform ?? process.env.PLATFORM ?? '').toLowerCase();
407
409
  if (androidTarget || adbSerial || platform === 'android') {
408
- return androidScreenshot(input, relPath, adbSerial);
410
+ return androidScreenshot(input, relPath);
409
411
  }
410
412
  const target = targetInfo.iosSimulator;
411
413
  if (!target) {
414
+ const { absolute } = resolveArtifactPath(input.context.artifactsDir, relPath || `screenshots/${input.context.nodeId}.png`);
415
+ await rm(absolute, { force: true });
412
416
  throw new Error('iOS screenshot requires node.simulator, node.ios_simulator, or IOS_SIMULATOR so the proof is tied to the same device as the bridge commands.');
413
417
  }
414
418
  const { relative, absolute } = resolveArtifactPath(input.context.artifactsDir, relPath || `screenshots/${input.context.nodeId}.png`);
415
419
  await mkdir(path.dirname(absolute), { recursive: true });
416
- const result = await new Promise((resolve, reject) => {
417
- const child = spawn('xcrun', ['simctl', 'io', String(target), 'screenshot', absolute], { stdio: ['ignore', 'pipe', 'pipe'] });
418
- let stdout = '';
419
- let stderr = '';
420
- child.stdout.on('data', (chunk) => { stdout += chunk; });
421
- child.stderr.on('data', (chunk) => { stderr += chunk; });
422
- child.on('error', reject);
423
- child.on('close', (exitCode) => resolve({ exitCode, stdout, stderr }));
424
- });
425
- if (result.exitCode !== 0) {
426
- throw new Error(`simctl screenshot failed for ${target}: ${result.stderr || result.stdout}`);
420
+ await rm(absolute, { force: true });
421
+ let temporaryDirectory;
422
+ try {
423
+ temporaryDirectory = await createScreenshotTemporaryDirectory(absolute);
424
+ const captured = path.join(temporaryDirectory, 'captured.png');
425
+ await createPrivateScreenshotFile(captured);
426
+ let result;
427
+ try {
428
+ result = await runScreenshotProcess('xcrun', ['simctl', 'io', String(target), 'screenshot', captured], false);
429
+ } catch (error) {
430
+ throw new Error(
431
+ `simctl screenshot could not start for ${target}: ${error instanceof Error ? error.message : String(error)}\n` +
432
+ ' Next: install Xcode Command Line Tools (`xcode-select --install`), then rerun the same mm-harness command.',
433
+ );
434
+ }
435
+ if (result.exitCode !== 0) {
436
+ throw new Error(
437
+ `simctl screenshot failed for ${target}: ${result.stderr || result.stdout}\n` +
438
+ ` Next: mm-harness doctor --adapter mobile --device ${target} --json`,
439
+ );
440
+ }
441
+ const png = await readPrivateScreenshotFile(captured);
442
+ const invalid = pngStructureError(png);
443
+ if (invalid) {
444
+ throw new Error(
445
+ `simctl screenshot produced invalid PNG bytes for ${target}: ${invalid}\n` +
446
+ ` Next: mm-harness doctor --adapter mobile --device ${target} --json`,
447
+ );
448
+ }
449
+ await publishPrivateScreenshot(png, temporaryDirectory, absolute);
450
+ await rm(temporaryDirectory, { recursive: true, force: true });
451
+ } catch (error) {
452
+ await removeScreenshotResidue(temporaryDirectory, absolute);
453
+ if (String(error?.message ?? error).includes('\n Next:')) throw error;
454
+ throw new Error(
455
+ `simctl screenshot could not publish for ${target}: ${error instanceof Error ? error.message : String(error)}\n` +
456
+ ` Next: mm-harness doctor --adapter mobile --device ${target} --json`,
457
+ );
427
458
  }
428
459
  return {
429
460
  path: relative,
@@ -431,35 +462,277 @@ export async function simulatorScreenshot(input, relPath) {
431
462
  nodeId: input.context.nodeId,
432
463
  label: input.node?.description || `${input.action} screenshot`,
433
464
  category: 'evidence',
465
+ mimeType: 'image/png',
466
+ metadata: {
467
+ provider: 'simctl',
468
+ mode: 'xcrun simctl io screenshot',
469
+ selectedDevice: String(target),
470
+ },
434
471
  };
435
472
  }
436
473
 
437
- async function androidScreenshot(input, relPath, androidDevice) {
474
+ async function androidScreenshot(input, relPath) {
438
475
  const { relative, absolute } = resolveArtifactPath(input.context.artifactsDir, relPath || `screenshots/${input.context.nodeId}.png`);
439
476
  await mkdir(path.dirname(absolute), { recursive: true });
440
- const args = [];
441
- if (androidDevice) args.push('-s', String(androidDevice));
442
- args.push('exec-out', 'screencap', '-p');
443
- const result = await new Promise((resolve, reject) => {
444
- const child = spawn('adb', args, { stdio: ['ignore', 'pipe', 'pipe'] });
477
+ await rm(absolute, { force: true });
478
+ let temporaryDirectory;
479
+ try {
480
+ temporaryDirectory = await createScreenshotTemporaryDirectory(absolute);
481
+ const adbSerial = await resolveAndroidScreenshotSerial(input);
482
+ let result;
483
+ try {
484
+ result = await runScreenshotProcess('adb', ['-s', adbSerial, 'exec-out', 'screencap', '-p'], true);
485
+ } catch (error) {
486
+ throw new Error(
487
+ `adb screenshot could not start for ${adbSerial}: ${error instanceof Error ? error.message : String(error)}\n` +
488
+ ' Next: install Android SDK Platform-Tools, ensure adb is on PATH, then rerun the same mm-harness command.',
489
+ );
490
+ }
491
+ if (result.exitCode !== 0) {
492
+ throw new Error(
493
+ `adb screenshot failed for ${adbSerial}: ${result.stderr}\n` +
494
+ ` Next: mm-harness doctor --adapter mobile --device ${adbSerial} --json`,
495
+ );
496
+ }
497
+ const invalid = pngStructureError(result.stdout);
498
+ if (invalid) {
499
+ throw new Error(
500
+ `adb screenshot produced invalid PNG bytes for ${adbSerial}: ${invalid}\n` +
501
+ ` Next: mm-harness doctor --adapter mobile --device ${adbSerial} --json`,
502
+ );
503
+ }
504
+ await publishPrivateScreenshot(result.stdout, temporaryDirectory, absolute);
505
+ await rm(temporaryDirectory, { recursive: true, force: true });
506
+ return {
507
+ path: relative,
508
+ type: 'screenshot',
509
+ nodeId: input.context.nodeId,
510
+ label: input.node?.description || `${input.action} screenshot`,
511
+ category: 'evidence',
512
+ mimeType: 'image/png',
513
+ metadata: {
514
+ provider: 'adb',
515
+ mode: 'adb exec-out screencap -p',
516
+ selectedDevice: adbSerial,
517
+ },
518
+ };
519
+ } catch (error) {
520
+ await removeScreenshotResidue(temporaryDirectory, absolute);
521
+ if (String(error?.message ?? error).includes('\n Next:')) throw error;
522
+ throw new Error(
523
+ `adb screenshot could not publish: ${error instanceof Error ? error.message : String(error)}\n` +
524
+ ' Next: mm-harness doctor --adapter mobile --json',
525
+ );
526
+ }
527
+ }
528
+
529
+ async function resolveAndroidScreenshotSerial(input) {
530
+ const contextEnv = input.context?.env || {};
531
+ const explicit = input.node?.adb_serial ??
532
+ input.node?.android_device ??
533
+ input.node?.device ??
534
+ contextEnv.ADB_SERIAL ??
535
+ contextEnv.ANDROID_SERIAL ??
536
+ contextEnv.ANDROID_DEVICE ??
537
+ process.env.ADB_SERIAL ??
538
+ process.env.ANDROID_SERIAL ??
539
+ process.env.ANDROID_DEVICE;
540
+ if (explicit !== undefined && explicit !== null && String(explicit).trim()) return String(explicit).trim();
541
+
542
+ let result;
543
+ try {
544
+ result = await runScreenshotProcess('adb', ['devices'], false);
545
+ } catch (error) {
546
+ throw new Error(
547
+ `Android screenshot could not resolve an adb serial: ${error instanceof Error ? error.message : String(error)}\n` +
548
+ ' Next: install Android SDK Platform-Tools, ensure adb is on PATH, then rerun with --device <adb-serial>.',
549
+ );
550
+ }
551
+ if (result.exitCode !== 0) {
552
+ throw new Error(
553
+ `Android screenshot could not resolve an adb serial: ${result.stderr || result.stdout}\n` +
554
+ ' Next: mm-harness doctor --adapter mobile --json',
555
+ );
556
+ }
557
+ const serials = result.stdout
558
+ .split(/\r?\n/u)
559
+ .map((line) => line.trim().split(/\s+/u))
560
+ .filter((fields) => fields.length >= 2 && fields[1] === 'device')
561
+ .map(([serial]) => serial);
562
+ if (serials.length === 1) return serials[0];
563
+ throw new Error(
564
+ `Android screenshot requires one exact adb serial, but ${serials.length} ready devices were discovered.\n` +
565
+ ' Next: mm-harness doctor --adapter mobile --json # choose devices[].id and rerun with --device <id>',
566
+ );
567
+ }
568
+
569
+ function runScreenshotProcess(command, args, binaryOutput) {
570
+ return new Promise((resolve, reject) => {
571
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
445
572
  const stdout = [];
446
573
  let stderr = '';
447
574
  child.stdout.on('data', (chunk) => { stdout.push(Buffer.from(chunk)); });
448
575
  child.stderr.on('data', (chunk) => { stderr += chunk; });
449
576
  child.on('error', reject);
450
- child.on('close', (exitCode) => resolve({ exitCode, stdout: Buffer.concat(stdout), stderr }));
577
+ child.on('close', (exitCode) => resolve({
578
+ exitCode,
579
+ stdout: binaryOutput ? Buffer.concat(stdout) : Buffer.concat(stdout).toString('utf8'),
580
+ stderr,
581
+ }));
451
582
  });
452
- if (result.exitCode !== 0) {
453
- throw new Error(`adb screenshot failed${androidDevice ? ` for ${androidDevice}` : ''}: ${result.stderr}`);
583
+ }
584
+
585
+ function pngStructureError(bytes) {
586
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
587
+ if (!Buffer.isBuffer(bytes) || bytes.length < signature.length || !bytes.subarray(0, signature.length).equals(signature)) {
588
+ return `bad signature (${Buffer.isBuffer(bytes) ? bytes.length : 0} bytes)`;
454
589
  }
455
- await writeFile(absolute, result.stdout);
456
- return {
457
- path: relative,
458
- type: 'screenshot',
459
- nodeId: input.context.nodeId,
460
- label: input.node?.description || `${input.action} screenshot`,
461
- category: 'evidence',
590
+ let offset = signature.length;
591
+ let sawHeader = false;
592
+ let sawPalette = false;
593
+ let sawImageData = false;
594
+ let imageDataClosed = false;
595
+ let colorType;
596
+ let bitDepth;
597
+ while (offset < bytes.length) {
598
+ if (bytes.length - offset < 12) return `truncated chunk at byte ${offset}`;
599
+ const length = bytes.readUInt32BE(offset);
600
+ const typeBytes = bytes.subarray(offset + 4, offset + 8);
601
+ const type = typeBytes.toString('ascii');
602
+ const end = offset + 12 + length;
603
+ if (end > bytes.length) return `truncated ${type || 'unknown'} chunk at byte ${offset}`;
604
+ if (![...typeBytes].every((byte) => (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122))) {
605
+ return `invalid chunk type at byte ${offset}`;
606
+ }
607
+ const expectedCrc = bytes.readUInt32BE(offset + 8 + length);
608
+ const actualCrc = pngCrc32(bytes, offset + 4, offset + 8 + length);
609
+ if (actualCrc !== expectedCrc) return `CRC mismatch for ${type} chunk at byte ${offset}`;
610
+ if (!sawHeader && (type !== 'IHDR' || length !== 13)) return `first chunk must be IHDR length 13 (got ${type} length ${length})`;
611
+ if (type === 'IHDR') {
612
+ if (sawHeader) return 'duplicate IHDR chunk';
613
+ const invalidHeader = pngHeaderError(bytes.subarray(offset + 8, offset + 8 + length));
614
+ if (invalidHeader) return invalidHeader;
615
+ sawHeader = true;
616
+ bitDepth = bytes[offset + 16];
617
+ colorType = bytes[offset + 17];
618
+ } else if (type === 'PLTE') {
619
+ if (sawPalette) return 'duplicate PLTE chunk';
620
+ if (sawImageData) return 'PLTE chunk must precede IDAT';
621
+ if (colorType === 0 || colorType === 4) return `PLTE chunk is forbidden for color type ${colorType}`;
622
+ if (length === 0 || length % 3 !== 0 || length > 768) return `invalid PLTE length ${length}`;
623
+ if (colorType === 3 && length / 3 > 2 ** bitDepth) return `PLTE has too many entries for bit depth ${bitDepth}`;
624
+ sawPalette = true;
625
+ } else if (type === 'IDAT') {
626
+ if (imageDataClosed) return 'IDAT chunks must be consecutive';
627
+ if (colorType === 3 && !sawPalette) return 'indexed-color PNG requires PLTE before IDAT';
628
+ sawImageData = true;
629
+ } else {
630
+ if (sawImageData) imageDataClosed = true;
631
+ if (type[0] === type[0].toUpperCase() && type !== 'IEND') return `unknown critical chunk ${type}`;
632
+ }
633
+ if (type === 'IEND') {
634
+ if (length !== 0) return `IEND length must be 0 (got ${length})`;
635
+ if (!sawImageData) return 'missing IDAT chunk';
636
+ if (end !== bytes.length) return `trailing bytes after IEND (${bytes.length - end})`;
637
+ return null;
638
+ }
639
+ offset = end;
640
+ }
641
+ return sawHeader ? 'missing IEND chunk' : 'missing IHDR chunk';
642
+ }
643
+
644
+ function pngHeaderError(header) {
645
+ const width = header.readUInt32BE(0);
646
+ const height = header.readUInt32BE(4);
647
+ const bitDepth = header[8];
648
+ const colorType = header[9];
649
+ const allowedDepths = {
650
+ 0: [1, 2, 4, 8, 16],
651
+ 2: [8, 16],
652
+ 3: [1, 2, 4, 8],
653
+ 4: [8, 16],
654
+ 6: [8, 16],
462
655
  };
656
+ if (width === 0 || width > 0x7fffffff || height === 0 || height > 0x7fffffff) {
657
+ return `invalid IHDR dimensions ${width}x${height}`;
658
+ }
659
+ if (!allowedDepths[colorType]) return `invalid IHDR color type ${colorType}`;
660
+ if (!allowedDepths[colorType].includes(bitDepth)) return `invalid IHDR bit depth ${bitDepth} for color type ${colorType}`;
661
+ if (header[10] !== 0) return `invalid IHDR compression method ${header[10]}`;
662
+ if (header[11] !== 0) return `invalid IHDR filter method ${header[11]}`;
663
+ if (header[12] !== 0 && header[12] !== 1) return `invalid IHDR interlace method ${header[12]}`;
664
+ return null;
665
+ }
666
+
667
+ const PNG_CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
668
+ let crc = value;
669
+ for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) === 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
670
+ return crc >>> 0;
671
+ });
672
+
673
+ function pngCrc32(bytes, start, end) {
674
+ let crc = 0xffffffff;
675
+ for (let index = start; index < end; index += 1) crc = PNG_CRC_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
676
+ return (crc ^ 0xffffffff) >>> 0;
677
+ }
678
+
679
+ async function createScreenshotTemporaryDirectory(absolute) {
680
+ const temporary = path.join(path.dirname(absolute), `.${path.basename(absolute)}.${process.pid}.${randomUUID()}.tmp`);
681
+ await mkdir(temporary, { mode: 0o700 });
682
+ return temporary;
683
+ }
684
+
685
+ async function createPrivateScreenshotFile(file) {
686
+ const handle = await open(
687
+ file,
688
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
689
+ 0o600,
690
+ );
691
+ try {
692
+ const stats = await handle.stat();
693
+ if (!stats.isFile() || stats.nlink !== 1) throw new Error('temporary screenshot is not a private regular file');
694
+ } finally {
695
+ await handle.close();
696
+ }
697
+ }
698
+
699
+ async function readPrivateScreenshotFile(file) {
700
+ let handle;
701
+ try {
702
+ handle = await open(file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
703
+ const stats = await handle.stat();
704
+ if (!stats.isFile() || stats.nlink !== 1) throw new Error('captured screenshot is not a private regular file');
705
+ return await handle.readFile();
706
+ } catch (error) {
707
+ throw new Error(`captured screenshot is not a safe regular file: ${error instanceof Error ? error.message : String(error)}`);
708
+ } finally {
709
+ await handle?.close();
710
+ }
711
+ }
712
+
713
+ async function publishPrivateScreenshot(png, temporaryDirectory, absolute) {
714
+ const publication = path.join(temporaryDirectory, 'validated.png');
715
+ const handle = await open(
716
+ publication,
717
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
718
+ 0o600,
719
+ );
720
+ try {
721
+ const stats = await handle.stat();
722
+ if (!stats.isFile() || stats.nlink !== 1) throw new Error('validated screenshot is not a private regular file');
723
+ await handle.writeFile(png);
724
+ await handle.sync();
725
+ await rename(publication, absolute);
726
+ } finally {
727
+ await handle.close();
728
+ }
729
+ }
730
+
731
+ async function removeScreenshotResidue(temporaryDirectory, absolute) {
732
+ await Promise.all([
733
+ temporaryDirectory ? rm(temporaryDirectory, { recursive: true, force: true }) : Promise.resolve(),
734
+ rm(absolute, { force: true }),
735
+ ]);
463
736
  }
464
737
 
465
738
  function resolveArtifactPath(artifactsDir, relativePath) {
@@ -1,4 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { pathToFileURL } from 'node:url';
2
3
  import { bridgeCommand, runAdapter, selectBridgeStatusEntry } from '../platform/bridge.mjs';
3
4
  import { walletFixturePath } from '../../harness-exports.mjs';
4
5
 
@@ -108,7 +109,7 @@ async function waitForStableUnlocked(input, initialStatus, stableMs = 750) {
108
109
  return last;
109
110
  }
110
111
 
111
- runAdapter(async (input) => {
112
+ export async function ensureUnlocked(input) {
112
113
  const before = await waitForTargetStatus(input, Number(input.node?.target_timeout_ms ?? 20000));
113
114
  if (!selectedAccount(before, input) && routeName(before, input) !== 'Login') {
114
115
  throw new Error(
@@ -153,4 +154,8 @@ runAdapter(async (input) => {
153
154
  proofPath: 'agentic-wallet-status-after-missing-login-input',
154
155
  };
155
156
  }
156
- });
157
+ }
158
+
159
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
160
+ runAdapter(ensureUnlocked);
161
+ }