@deeeed/metamask-harness 0.82.1 → 0.84.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 +48 -0
  2. package/README.md +8 -1
  3. package/adapters/core/cleanup.sh +5 -29
  4. package/adapters/core/controller-import-probe.mjs +26 -0
  5. package/adapters/manifest.json +22 -102
  6. package/adapters/shared/session-lock.mjs +43 -0
  7. package/adapters/web-dapp/metamask-signer.mjs +212 -0
  8. package/dist/adapters/core/actions.js +0 -3
  9. package/dist/adapters/core/overlay.js +1 -5
  10. package/dist/adapters/core/readiness.js +133 -2
  11. package/dist/adapters/core/surface.js +53 -41
  12. package/dist/adapters/extension/extension-id.js +1 -0
  13. package/dist/adapters/extension/fixtures.js +60 -1
  14. package/dist/adapters/extension/network-observer.js +19 -206
  15. package/dist/adapters/extension/run.js +5 -3
  16. package/dist/adapters/extension/runtime.js +112 -20
  17. package/dist/adapters/mobile/android-gfxinfo-performance.js +3 -1
  18. package/dist/adapters/mobile/devices.js +5 -1
  19. package/dist/adapters/mobile/performance-observer.js +9 -6
  20. package/dist/adapters/mobile/run.js +1 -1
  21. package/dist/adapters/mobile/surface.js +1 -2
  22. package/dist/adapters/performance/trace-kinds.js +36 -0
  23. package/dist/adapters/plugin-surface.js +34 -34
  24. package/dist/adapters/session-lock.js +23 -0
  25. package/dist/adapters/web-dapp/console-capture.js +2 -20
  26. package/dist/adapters/web-dapp/run.js +31 -25
  27. package/dist/adapters/web-dapp/surface.js +32 -108
  28. package/dist/command-contract.js +1363 -630
  29. package/dist/commands/core-readiness.js +1 -9
  30. package/dist/commands/doctor.js +1 -1
  31. package/dist/commands/launch/device.js +1 -1
  32. package/dist/doctor.js +5 -2
  33. package/dist/mm-harness-cli.js +228 -1210
  34. package/dist/performance-observation.js +28 -5
  35. package/docs/CONTRIBUTING.md +6 -4
  36. package/docs/cli-contract.md +1 -1
  37. package/docs/feature-guard.md +1 -1
  38. package/library/actions/core/perps/_controller.mjs +10 -0
  39. package/library/actions/extension/wallet/import.mjs +21 -4
  40. package/library/actions/extension/wallet/visible-session.mjs +32 -12
  41. package/library/actions/shared/console-collector.mjs +1 -1
  42. package/library/actions/web-dapp/app/launch.mjs +1 -1
  43. package/library/actions/web-dapp/platform/adapter-web-lib.mjs +18 -0
  44. package/library/actions/web-dapp/platform/hud.mjs +1 -1
  45. package/library/actions/web-dapp/platform/page.mjs +2 -3
  46. package/library/actions/web-dapp/platform/runtime.mjs +29 -178
  47. package/package.json +7 -6
  48. package/scripts/completions.sh +1 -1
  49. package/adapters/web-dapp/cleanup.mjs +0 -69
  50. package/adapters/web-dapp/inject.mjs +0 -75
  51. package/adapters/web-dapp/launch.mjs +0 -631
  52. package/adapters/web-dapp/lib/blocked-hosts.mjs +0 -24
  53. package/adapters/web-dapp/lib/cdp-client.mjs +0 -84
  54. package/adapters/web-dapp/lib/display.mjs +0 -115
  55. package/adapters/web-dapp/lib/hud.mjs +0 -148
  56. package/adapters/web-dapp/lib/policy.mjs +0 -79
  57. package/adapters/web-dapp/lib/processes.mjs +0 -187
  58. package/adapters/web-dapp/lib/readiness.mjs +0 -272
  59. package/adapters/web-dapp/stop.mjs +0 -38
  60. package/adapters/web-dapp/verify.mjs +0 -51
  61. package/adapters/web-dapp/wallet-host.mjs +0 -520
  62. package/dist/adapters/core/live-script-env.js +0 -36
  63. package/dist/adapters/extension/browser-cdp.js +0 -174
  64. package/dist/adapters/extension/performance-observer.js +0 -75
  65. package/dist/adapters/performance/cdp-trace.js +0 -406
  66. package/dist/adapters/performance/js-task-metrics.js +0 -35
  67. package/dist/adapters/web-dapp/doctor-checks.js +0 -33
  68. package/dist/adapters/web-dapp/launch.js +0 -87
  69. package/dist/adapters/web-dapp/overlay.js +0 -21
  70. package/dist/adapters/web-dapp/readiness.js +0 -10
  71. package/dist/adapters/web-dapp/stop.js +0 -20
  72. package/dist/async.js +0 -19
  73. package/dist/cli.js +0 -202
@@ -1,84 +0,0 @@
1
- // Minimal browser-level Chrome DevTools Protocol client with flattened target
2
- // sessions, for the long-running web-dapp wallet host. Page-level UI actions use
3
- // @farmslot/recipe-runner/runtime/cdp instead.
4
-
5
- import WebSocket from 'ws';
6
-
7
- export async function browserWebSocketUrl(port, timeoutMs = 15000) {
8
- const deadline = Date.now() + timeoutMs;
9
- let lastError;
10
- while (Date.now() < deadline) {
11
- try {
12
- const response = await fetch(`http://127.0.0.1:${port}/json/version`);
13
- if (response.ok) return (await response.json()).webSocketDebuggerUrl;
14
- } catch (error) {
15
- lastError = error;
16
- }
17
- await new Promise((resolve) => setTimeout(resolve, 250));
18
- }
19
- throw new Error(`CDP on 127.0.0.1:${port} did not answer within ${timeoutMs}ms: ${lastError?.message ?? 'no response'}`);
20
- }
21
-
22
- export class CdpClient {
23
- #ws;
24
- #nextId = 1;
25
- #pending = new Map();
26
- #handlers = new Map();
27
-
28
- static async connect(url) {
29
- const client = new CdpClient();
30
- client.#ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 });
31
- await new Promise((resolve, reject) => {
32
- client.#ws.once('open', resolve);
33
- client.#ws.once('error', reject);
34
- });
35
- client.#ws.on('message', (data) => client.#dispatch(JSON.parse(String(data))));
36
- client.#ws.on('close', () => {
37
- for (const { reject } of client.#pending.values()) reject(new Error('CDP connection closed'));
38
- client.#pending.clear();
39
- client.#emit('close', {}, undefined);
40
- });
41
- return client;
42
- }
43
-
44
- #dispatch(message) {
45
- if (message.id !== undefined) {
46
- const pending = this.#pending.get(message.id);
47
- if (!pending) return;
48
- this.#pending.delete(message.id);
49
- if (message.error) pending.reject(new Error(`${pending.method}: ${message.error.message}`));
50
- else pending.resolve(message.result ?? {});
51
- return;
52
- }
53
- this.#emit(message.method, message.params ?? {}, message.sessionId);
54
- }
55
-
56
- #emit(method, params, sessionId) {
57
- for (const handler of this.#handlers.get(method) ?? []) {
58
- try {
59
- handler(params, sessionId);
60
- } catch (error) {
61
- process.stderr.write(`[cdp] handler for ${method} failed: ${error?.stack ?? error}\n`);
62
- }
63
- }
64
- }
65
-
66
- on(method, handler) {
67
- if (!this.#handlers.has(method)) this.#handlers.set(method, new Set());
68
- this.#handlers.get(method).add(handler);
69
- return () => this.#handlers.get(method)?.delete(handler);
70
- }
71
-
72
- send(method, params = {}, sessionId = undefined) {
73
- const id = this.#nextId++;
74
- const payload = { id, method, params, ...(sessionId ? { sessionId } : {}) };
75
- return new Promise((resolve, reject) => {
76
- this.#pending.set(id, { resolve, reject, method });
77
- this.#ws.send(JSON.stringify(payload));
78
- });
79
- }
80
-
81
- close() {
82
- this.#ws?.close();
83
- }
84
- }
@@ -1,115 +0,0 @@
1
- // How the slot browser is shown: headful (default) or headless, where its
2
- // window sits on screen, the page zoom that keeps the app's desktop layout
3
- // inside that window, and the optional slow-mo between UI actions.
4
-
5
- import { execFileSync } from 'node:child_process';
6
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
7
- import path from 'node:path';
8
-
9
- const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
10
- const FALSE_VALUES = new Set(['0', 'false', 'no', 'off', '']);
11
-
12
- function envFlag(raw, label) {
13
- if (raw == null) return undefined;
14
- const value = String(raw).trim().toLowerCase();
15
- if (TRUE_VALUES.has(value)) return true;
16
- if (FALSE_VALUES.has(value)) return false;
17
- throw new Error(`${label} must be 1/0 or true/false, got ${JSON.stringify(raw)}.`);
18
- }
19
-
20
- // Headless precedence: an explicit flag (--headless, or --headful as its
21
- // opposite) → the launch node (headless, or headful as its opposite) → env
22
- // TERMINAL_HEADLESS → headful.
23
- export function resolveHeadless({ headless, headful, nodeHeadless, nodeHeadful, env = process.env } = {}) {
24
- if (headless === true && headful === true) throw new Error('--headless and --headful are mutually exclusive.');
25
- if (headless === true) return true;
26
- if (headful === true) return false;
27
- if (typeof nodeHeadless === 'boolean') return nodeHeadless;
28
- if (nodeHeadful === true) return false;
29
- return envFlag(env.TERMINAL_HEADLESS, 'TERMINAL_HEADLESS') ?? false;
30
- }
31
-
32
- function slowMoValue(raw, label) {
33
- if (raw == null || raw === '') return undefined;
34
- const value = Number(raw);
35
- if (!Number.isInteger(value) || value < 0 || value > 60000) {
36
- throw new Error(`${label} must be whole milliseconds between 0 and 60000, got ${JSON.stringify(raw)}.`);
37
- }
38
- return value;
39
- }
40
-
41
- // Slow-mo precedence: --slow-mo → launch node slow_mo_ms → env TERMINAL_SLOW_MO → 0.
42
- export function resolveSlowMo({ flag, node, env = process.env } = {}) {
43
- return slowMoValue(flag, '--slow-mo') ?? slowMoValue(node, 'slow_mo_ms') ?? slowMoValue(env.TERMINAL_SLOW_MO, 'TERMINAL_SLOW_MO') ?? 0;
44
- }
45
-
46
- // The order page switches layout at 800 and 1440 CSS px. Recipes were proven on
47
- // a 1600 CSS-px wide viewport, so a smaller window zooms out until the page
48
- // still lays out at least this wide.
49
- export const MIN_LAYOUT_WIDTH = 1460;
50
- export const MIN_ZOOM = 0.5;
51
- const FALLBACK_SCREEN = Object.freeze({ left: 0, top: 25, width: 1512, height: 920 });
52
-
53
- // The main screen's usable area in points (menu bar and Dock excluded).
54
- export function detectScreen(env = process.env) {
55
- const explicit = /^(\d+)x(\d+)(?:\+(\d+)\+(\d+))?$/u.exec(String(env.TERMINAL_SCREEN ?? '').trim());
56
- if (explicit) {
57
- return { left: Number(explicit[3] ?? 0), top: Number(explicit[4] ?? 0), width: Number(explicit[1]), height: Number(explicit[2]), source: 'TERMINAL_SCREEN' };
58
- }
59
- if (process.platform === 'darwin') {
60
- try {
61
- const out = execFileSync('osascript', ['-l', 'JavaScript', '-e', [
62
- 'ObjC.import("AppKit");',
63
- 'const s = $.NSScreen.mainScreen; const f = s.frame; const v = s.visibleFrame;',
64
- 'JSON.stringify({ left: v.origin.x, top: f.size.height - v.origin.y - v.size.height, width: v.size.width, height: v.size.height })',
65
- ].join(' ')], { encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'] });
66
- const screen = JSON.parse(out.trim());
67
- if (screen.width > 0 && screen.height > 0) return { ...screen, source: 'main-screen' };
68
- } catch {
69
- // Fall through to a size that fits any current MacBook.
70
- }
71
- }
72
- return { ...FALLBACK_SCREEN, source: 'fallback' };
73
- }
74
-
75
- // One window per slot, side by side: odd CDP ports (mmt-1: 9541) take the left
76
- // half of the main screen, even ports (mmt-2: 9542) the right half.
77
- // TERMINAL_WINDOW=x,y,width,height places the window explicitly.
78
- export function windowPlacement({ cdpPort, env = process.env, screen = null } = {}) {
79
- const explicit = /^(-?\d+),(-?\d+),(\d+),(\d+)$/u.exec(String(env.TERMINAL_WINDOW ?? '').replace(/\s/gu, ''));
80
- let bounds;
81
- if (explicit) {
82
- bounds = { x: Number(explicit[1]), y: Number(explicit[2]), width: Number(explicit[3]), height: Number(explicit[4]), source: 'TERMINAL_WINDOW' };
83
- } else {
84
- const area = screen ?? detectScreen(env);
85
- const width = Math.floor(area.width / 2);
86
- const column = Number(cdpPort) % 2 === 1 ? 0 : 1;
87
- bounds = { x: Math.round(area.left) + column * width, y: Math.round(area.top), width, height: Math.floor(area.height), source: area.source ?? 'screen' };
88
- }
89
- const zoom = Math.max(MIN_ZOOM, Math.min(1, Math.floor((bounds.width / MIN_LAYOUT_WIDTH) * 100) / 100));
90
- return { ...bounds, zoom };
91
- }
92
-
93
- // Chromium stores page zoom as a level: zoom = 1.2 ^ level. The profile's
94
- // default zoom applies to every page the slot browser opens.
95
- export function zoomLevel(zoom) {
96
- return zoom === 1 ? 0 : Math.log(zoom) / Math.log(1.2);
97
- }
98
-
99
- export function writeProfileZoom(profile, zoom) {
100
- const file = path.join(profile, 'Default', 'Preferences');
101
- mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
102
- const prefs = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
103
- prefs.partition ??= {};
104
- prefs.partition.default_zoom_level = { ...(prefs.partition.default_zoom_level ?? {}), x: zoomLevel(zoom) };
105
- const temporary = `${file}.${process.pid}.tmp`;
106
- writeFileSync(temporary, JSON.stringify(prefs), { mode: 0o600 });
107
- renameSync(temporary, file);
108
- }
109
-
110
- // Headless keeps the tall viewport the recipes were first proven on; headful
111
- // opens a placed window instead.
112
- export function browserDisplayArgs({ headless, placement }) {
113
- if (headless) return ['--headless=new', '--window-size=1600,2400'];
114
- return [`--window-position=${placement.x},${placement.y}`, `--window-size=${placement.width},${placement.height}`];
115
- }
@@ -1,148 +0,0 @@
1
- // Recipe HUD for the web-dapp app tab: state handling and the page expression
2
- // that draws it.
3
- //
4
- // The overlay is an <mm-harness-hud> element under <html> (outside <body>) with
5
- // a closed shadow root, aria-hidden and pointer-events:none: page text, test-id
6
- // selectors, elementFromPoint clicks and the accessibility tree never see it.
7
- // It sits over the chart's top-left corner, below the chart toolbar, away from
8
- // the nav bar, the order form, the order rows and the bottom-left dev badge.
9
- // State lives in <runtime>/hud.json so the wallet host can redraw it after
10
- // every navigation; the expression only draws on the app origin, never inside
11
- // MetaMask's extension pages.
12
-
13
- import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
14
- import path from 'node:path';
15
-
16
- export const HUD_ELEMENT = 'mm-harness-hud';
17
- export const HUD_FILE = 'hud.json';
18
- const MAX_TEXT = 180;
19
-
20
- function text(value, max = MAX_TEXT) {
21
- if (typeof value !== 'string' && typeof value !== 'number') return '';
22
- const flat = String(value).replace(/\s+/gu, ' ').trim();
23
- return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
24
- }
25
-
26
- function progressOf(raw) {
27
- if (!raw || typeof raw !== 'object') return null;
28
- const current = Number(raw.current);
29
- const total = Number(raw.total);
30
- if (!Number.isFinite(current) || !Number.isFinite(total) || total <= 0) return null;
31
- return { current, total, complete: raw.complete === true };
32
- }
33
-
34
- // Fold one app.hud node into the previous state. A running node becomes the
35
- // current step; a pass/fail node also becomes the "last result" line, and the
36
- // recipe-complete node closes the run.
37
- export function nextHudState(prior, node = {}, nodeId = '') {
38
- // A finished run's HUD stays up until the next run's first update replaces it.
39
- const previous = prior?.finished ? { seq: prior.seq } : prior;
40
- const status = ['running', 'pass', 'fail'].includes(node.status) ? node.status : 'running';
41
- const progress = progressOf(node.progress);
42
- const intent = text(node.intent ?? node.text ?? node.detail) || text(nodeId);
43
- const id = text(node.node_id ?? node.nodeId ?? nodeId, 80);
44
- const state = {
45
- seq: (previous?.seq ?? 0) + 1,
46
- title: text(node.title, 80) || previous?.title || 'Recipe run',
47
- current: previous?.current ?? null,
48
- last: previous?.last ?? null,
49
- finished: null,
50
- };
51
- if (status === 'running') {
52
- state.current = { nodeId: id, intent, progress: progress ?? previous?.current?.progress ?? null };
53
- return state;
54
- }
55
- const error = status === 'fail' ? text(node.error) : '';
56
- state.last = { nodeId: id, intent, status, ...(error ? { error } : {}) };
57
- if (progress?.complete || id === 'recipe-complete') {
58
- state.finished = status;
59
- state.current = { nodeId: id, intent: status === 'pass' ? 'Recipe passed' : 'Recipe failed', progress };
60
- } else if (progress) {
61
- state.current = { ...(state.current ?? { nodeId: id, intent }), progress };
62
- }
63
- return state;
64
- }
65
-
66
- export function hudFile(runtimeDir) {
67
- return path.join(runtimeDir, HUD_FILE);
68
- }
69
-
70
- export function readHudState(runtimeDir) {
71
- try {
72
- return JSON.parse(readFileSync(hudFile(runtimeDir), 'utf8'));
73
- } catch {
74
- return null;
75
- }
76
- }
77
-
78
- export function writeHudState(runtimeDir, state) {
79
- const file = hudFile(runtimeDir);
80
- const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
81
- writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 });
82
- renameSync(temporary, file);
83
- }
84
-
85
- export function clearHudState(runtimeDir) {
86
- rmSync(hudFile(runtimeDir), { force: true });
87
- }
88
-
89
- const STYLE = `
90
- :host { all: initial; }
91
- .hud { position: fixed; top: 220px; left: 12px; z-index: 2147483647; width: 360px; max-width: calc(50vw - 24px);
92
- box-sizing: border-box; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,.18);
93
- background: rgba(16,18,24,.82); color: #f2f4f7; font: 12px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
94
- pointer-events: none; user-select: none; box-shadow: 0 4px 16px rgba(0,0,0,.35); }
95
- .row { display: flex; gap: 6px; align-items: baseline; }
96
- .title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
97
- .step { color: #a9b1bd; font-variant-numeric: tabular-nums; }
98
- .intent { margin-top: 4px; }
99
- .badge { display: inline-block; padding: 0 5px; border-radius: 4px; font-size: 10px; font-weight: 700; letter-spacing: .03em; }
100
- .running { background: #2f6fed; color: #fff; }
101
- .pass { background: #1f9d55; color: #fff; }
102
- .fail { background: #d64545; color: #fff; }
103
- .last { margin-top: 4px; color: #c7cdd6; }
104
- .error { color: #ffb4b4; }
105
- `;
106
-
107
- // A self-contained page expression: draws (or clears, for null) the HUD when
108
- // the document is on the app origin, and skips the work when the same state
109
- // is already drawn.
110
- export function hudRenderExpression(state, appOrigin) {
111
- return `(() => {
112
- const state = ${JSON.stringify(state ?? null)};
113
- if (location.origin !== ${JSON.stringify(String(appOrigin).replace(/\/$/u, ''))}) return { hud: false, reason: 'not-app-origin' };
114
- const existing = document.documentElement.querySelector(':scope > ${HUD_ELEMENT}');
115
- if (!state) { existing?.remove(); return { hud: false, cleared: true }; }
116
- if (existing && existing.dataset.seq === String(state.seq)) return { hud: true, seq: state.seq, unchanged: true };
117
- existing?.remove();
118
- const host = document.createElement('${HUD_ELEMENT}');
119
- host.dataset.seq = String(state.seq);
120
- host.setAttribute('aria-hidden', 'true');
121
- host.inert = true;
122
- host.style.cssText = 'all: initial; position: fixed; inset: 0 auto auto 0; width: 0; height: 0; pointer-events: none; z-index: 2147483647;';
123
- const root = host.attachShadow({ mode: 'closed' });
124
- const el = (tag, cls, value) => { const node = document.createElement(tag); if (cls) node.className = cls; if (value != null) node.textContent = value; return node; };
125
- const style = el('style', null, ${JSON.stringify(STYLE)});
126
- const box = el('div', 'hud');
127
- const head = el('div', 'row');
128
- head.append(el('span', 'title', state.title));
129
- const progress = state.current && state.current.progress;
130
- if (progress) head.append(el('span', 'step', 'step ' + progress.current + '/' + progress.total));
131
- box.append(head);
132
- if (state.current) {
133
- const line = el('div', 'intent');
134
- const status = state.finished || 'running';
135
- line.append(el('span', 'badge ' + status, status === 'running' ? 'RUN' : status.toUpperCase()), ' ', state.current.intent);
136
- box.append(line);
137
- }
138
- if (state.last && !state.finished) {
139
- const line = el('div', 'last');
140
- line.append(el('span', 'badge ' + state.last.status, state.last.status.toUpperCase()), ' ', state.last.intent);
141
- box.append(line);
142
- }
143
- if (state.last && state.last.error) box.append(el('div', 'last error', state.last.error));
144
- root.append(style, box);
145
- document.documentElement.append(host);
146
- return { hud: true, seq: state.seq };
147
- })()`;
148
- }
@@ -1,79 +0,0 @@
1
- // The venue policy of the adapter that extends web-dapp (e.g. the Terminal
2
- // plugin's plugins/terminal/policy.mjs): what web-dapp's launch, wallet host,
3
- // readiness and actions know about the app's venue. mm-harness sets its module
4
- // path in RECIPE_WEB_DAPP_POLICY when it binds such an adapter, so every
5
- // web-dapp leaf process and live action it starts reads the same policy.
6
-
7
- import { createRequire } from 'node:module';
8
- import path from 'node:path';
9
-
10
- export const POLICY_ENV = 'RECIPE_WEB_DAPP_POLICY';
11
- // The policy contract this web-dapp reads; a policy declares the version it was written for.
12
- export const POLICY_VERSION = 1;
13
- let loaded;
14
-
15
- // Every member web-dapp reads, checked once when the policy loads: a missing or mistyped
16
- // member fails here, naming the module, instead of deep in a launch or an assertion (a
17
- // missing signatureLog would silently drop the venue's L1 and mainnet rules).
18
- const MEMBERS = {
19
- adapterId: 'string',
20
- checkout: 'object',
21
- venueHosts: 'function',
22
- linkHosts: 'array',
23
- probe: 'object',
24
- startChain: 'number',
25
- refuseTypedData: 'object',
26
- signatureLog: 'object',
27
- startPath: 'string',
28
- pagePath: 'function',
29
- dependencies: 'array',
30
- testnetVariable: 'string',
31
- module: 'string',
32
- };
33
-
34
- function checkPolicy(policy, file) {
35
- const problems = [];
36
- if (!policy || typeof policy !== 'object') problems.push('exports no policy object');
37
- else {
38
- if (policy.policyVersion !== POLICY_VERSION) problems.push(`policyVersion is ${policy.policyVersion ?? 'missing'}, this web-dapp reads ${POLICY_VERSION}`);
39
- for (const [name, type] of Object.entries(MEMBERS)) {
40
- const value = policy[name];
41
- const ok = type === 'array' ? Array.isArray(value) : type === 'object' ? value !== null && typeof value === 'object' : typeof value === type;
42
- if (!ok) problems.push(`${name} must be ${type === 'array' || type === 'object' ? `an ${type}` : `a ${type}`}`);
43
- }
44
- const { checkout, refuseTypedData, signatureLog } = policy;
45
- if (checkout && typeof checkout.matches !== 'function') problems.push('checkout.matches must be a function');
46
- for (const name of ['name', 'label', 'needs']) if (checkout && typeof checkout[name] !== 'string') problems.push(`checkout.${name} must be a string`);
47
- if (refuseTypedData && typeof refuseTypedData.reason !== 'function') problems.push('refuseTypedData.reason must be a function');
48
- for (const name of ['kind', 'message']) if (refuseTypedData && typeof refuseTypedData[name] !== 'string') problems.push(`refuseTypedData.${name} must be a string`);
49
- // The venue's request-log rules: both maps are required (empty only on purpose), so a
50
- // policy cannot drop its L1 or mainnet rules by omission.
51
- for (const name of ['typedDataClasses', 'forbiddenEntries']) {
52
- if (signatureLog && (signatureLog[name] === null || typeof signatureLog[name] !== 'object')) problems.push(`signatureLog.${name} must be an object`);
53
- }
54
- }
55
- if (problems.length) {
56
- throw new Error(`web-dapp venue policy ${file}: ${problems.join('; ')}.\nNext: update the library that declares the adapter to a revision written for this mm-harness.`);
57
- }
58
- return policy;
59
- }
60
-
61
- export function webDappPolicy(env = process.env) {
62
- const file = env[POLICY_ENV];
63
- if (!file) {
64
- throw new Error(
65
- 'web-dapp needs a venue policy: select an adapter that extends it.\n' +
66
- 'Next: put the library that declares it on RECIPE_LIBRARY_PATH and pass its id, e.g. --adapter terminal.',
67
- );
68
- }
69
- if (loaded?.file !== file) {
70
- // The policy module uses node built-ins only and has no top-level await, so it loads synchronously.
71
- loaded = { file, policy: checkPolicy(createRequire(import.meta.url)(path.resolve(file)).policy, file) };
72
- }
73
- return loaded.policy;
74
- }
75
-
76
- // The bound adapter's id, for names and paths; web-dapp's own when none is bound.
77
- export function webDappAdapterId(env = process.env) {
78
- return env[POLICY_ENV] ? webDappPolicy(env).adapterId : 'web-dapp';
79
- }
@@ -1,187 +0,0 @@
1
- // Stop the web-dapp slot browser and wallet host by their pid files and prove
2
- // the slot CDP port is released. A pid is signalled only when its command line
3
- // carries this slot's owner marker for that process kind. A pid file naming a
4
- // dead process is removed; one naming a live process without the marker is
5
- // kept and reported, never signalled.
6
-
7
- import { execFileSync } from 'node:child_process';
8
- import { createHash } from 'node:crypto';
9
- import { existsSync, readFileSync, rmSync } from 'node:fs';
10
- import net from 'node:net';
11
- import path from 'node:path';
12
-
13
- import { webDappPolicy, webDappRuntimeDir } from '../../../library/actions/web-dapp/platform/runtime.mjs';
14
-
15
- export function pidAlive(pid) {
16
- if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
17
- try {
18
- process.kill(Number(pid), 0);
19
- return true;
20
- } catch (error) {
21
- return error.code === 'EPERM';
22
- }
23
- }
24
-
25
- function groupAlive(pgid) {
26
- try {
27
- process.kill(-pgid, 0);
28
- return true;
29
- } catch (error) {
30
- return error.code === 'EPERM';
31
- }
32
- }
33
-
34
- function readPid(file) {
35
- if (!existsSync(file)) return null;
36
- const pid = Number(readFileSync(file, 'utf8').trim());
37
- return Number.isInteger(pid) && pid > 0 ? pid : null;
38
- }
39
-
40
- function psField(pid, field) {
41
- try {
42
- return execFileSync('ps', ['-ww', '-o', `${field}=`, '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
43
- } catch {
44
- return '';
45
- }
46
- }
47
-
48
- export function processCommand(pid) {
49
- return pidAlive(pid) ? psField(pid, 'command') : '';
50
- }
51
-
52
- // Each process the slot starts carries `--mm-harness-owner=<kind>-<id>`, where
53
- // <id> is derived from the canonical runtime dir. Ownership is that exact
54
- // argument, compared as a whole whitespace-free token, so a runtime dir that
55
- // shares a prefix with this one (".../terminal backup", ".../terminal2")
56
- // never matches.
57
- export const OWNER_FLAG = '--mm-harness-owner';
58
- export const OWNER_KINDS = Object.freeze(['browser', 'wallet-host', 'console-collector']);
59
-
60
- export function ownerMarker(kind, runtimeDir) {
61
- if (!OWNER_KINDS.includes(kind)) throw new Error(`unknown web-dapp process kind ${kind}`);
62
- const id = createHash('sha256').update(path.resolve(runtimeDir)).digest('hex').slice(0, 32);
63
- return `${OWNER_FLAG}=${kind}-${id}`;
64
- }
65
-
66
- export function ownsProcess(kind, pid, runtimeDir, command = processCommand(pid)) {
67
- if (!command) return false;
68
- return command.split(/\s+/u).includes(ownerMarker(kind, runtimeDir));
69
- }
70
-
71
- export function portFree(port) {
72
- return new Promise((resolve) => {
73
- const socket = net.connect({ host: '127.0.0.1', port }, () => {
74
- socket.destroy();
75
- resolve(false);
76
- });
77
- socket.on('error', () => resolve(true));
78
- });
79
- }
80
-
81
- // Browsers and hosts are spawned detached, each leading its own process group.
82
- // When the pid leads its group the whole group is signalled and waited for,
83
- // so children that outlive the leader (renderers, a child ignoring SIGTERM)
84
- // are killed too; otherwise only the pid.
85
- export async function terminate(pid, graceMs = 8000) {
86
- if (!pidAlive(pid)) return false;
87
- const group = Number(psField(pid, 'pgid')) === pid;
88
- const alive = () => (group ? groupAlive(pid) : pidAlive(pid));
89
- const signal = (name) => {
90
- try {
91
- process.kill(group ? -pid : pid, name);
92
- } catch {
93
- // Already gone.
94
- }
95
- };
96
- signal('SIGTERM');
97
- const deadline = Date.now() + graceMs;
98
- while (alive() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 200));
99
- if (alive()) {
100
- signal('SIGKILL');
101
- const killDeadline = Date.now() + 5000;
102
- while (alive() && Date.now() < killDeadline) await new Promise((resolve) => setTimeout(resolve, 100));
103
- }
104
- return true;
105
- }
106
-
107
- // The console collector a run started (src/adapters/web-dapp/console-capture.ts)
108
- // outlives browser relaunches inside the run; stop and cleanup end it.
109
- export async function stopConsoleCollector(target, { log = () => {}, runtimeDir } = {}) {
110
- const runtime = runtimeDir ?? webDappRuntimeDir(target);
111
- const pidFile = path.join(runtime, 'console-tail.pid');
112
- const pid = readPid(pidFile);
113
- if (!pid) return { stopped: null };
114
- if (ownsProcess('console-collector', pid, runtime)) {
115
- await terminate(pid, 3000);
116
- rmSync(pidFile, { force: true });
117
- log(`stopped console collector pid ${pid}`);
118
- return { stopped: pid };
119
- }
120
- if (pidAlive(pid)) {
121
- log(`console collector pid ${pid} is alive but is not this slot's collector; left it alone`);
122
- return { stopped: null, foreign: pid };
123
- }
124
- rmSync(pidFile, { force: true });
125
- return { stopped: null, stale: pid };
126
- }
127
-
128
- // Every live process that proves it is a browser on this slot's profiles,
129
- // found by command line: a browser started through LaunchServices has no
130
- // child handle, and its pid is only known once it owns the CDP port.
131
- export function ownedBrowserPids(runtimeDir) {
132
- const runtime = path.resolve(runtimeDir);
133
- let lines = '';
134
- try {
135
- // `--` ends pgrep's options: the pattern itself starts with dashes.
136
- lines = execFileSync('pgrep', ['-f', '--', ownerMarker('browser', runtime)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
137
- } catch {
138
- return [];
139
- }
140
- return lines.split('\n').map(Number).filter((pid) => pid > 0 && ownsProcess('browser', pid, runtime));
141
- }
142
-
143
- export function cdpListenerPids(port) {
144
- try {
145
- return execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
146
- .split('\n').map(Number).filter((pid) => pid > 0);
147
- } catch {
148
- return [];
149
- }
150
- }
151
-
152
- export async function stopWebDappBrowser(target, { cdpPort, log = () => {}, runtimeDir } = {}) {
153
- const runtime = runtimeDir ?? webDappRuntimeDir(target);
154
- const stopped = [];
155
- const stale = [];
156
- const foreign = [];
157
- for (const name of ['wallet-host', 'browser']) {
158
- const pidFile = path.join(runtime, `${name}.pid`);
159
- const pid = readPid(pidFile);
160
- if (pid && ownsProcess(name, pid, runtime)) {
161
- if (await terminate(pid)) {
162
- stopped.push({ name, pid });
163
- log(`stopped ${name} pid ${pid}`);
164
- }
165
- rmSync(pidFile, { force: true });
166
- } else if (pid && pidAlive(pid)) {
167
- foreign.push({ name, pid });
168
- log(`${name} pid ${pid} is alive but is not this slot's ${name}; left it and its pid file alone`);
169
- } else {
170
- if (pid) stale.push({ name, pid });
171
- rmSync(pidFile, { force: true });
172
- }
173
- }
174
- rmSync(path.join(runtime, 'wallet-host.ready'), { force: true });
175
- rmSync(path.join(runtime, 'browser.json'), { force: true });
176
- let free = null;
177
- if (cdpPort) {
178
- const deadline = Date.now() + 10000;
179
- free = await portFree(cdpPort);
180
- while (!free && Date.now() < deadline) {
181
- await new Promise((resolve) => setTimeout(resolve, 250));
182
- free = await portFree(cdpPort);
183
- }
184
- if (!free) throw new Error(`CDP port ${cdpPort} is still in use after stopping the ${webDappPolicy().adapterId} browser; another process owns it.`);
185
- }
186
- return { stopped, stale, foreign, cdpPortFree: free };
187
- }