@farmslot/adapter-web 0.5.0 → 0.6.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.
- package/CHANGELOG.md +5 -0
- package/README.md +24 -1
- package/dist/web-dapp/cleanup.d.mts +15 -0
- package/dist/web-dapp/index.d.mts +123 -0
- package/dist/web-dapp/inject.d.mts +12 -0
- package/dist/web-dapp/launch.d.mts +56 -0
- package/dist/web-dapp/lib/blocked-hosts.d.mts +3 -0
- package/dist/web-dapp/lib/cdp-client.d.mts +8 -0
- package/dist/web-dapp/lib/display.d.mts +26 -0
- package/dist/web-dapp/lib/hud.d.mts +14 -0
- package/dist/web-dapp/lib/paths.d.mts +11 -0
- package/dist/web-dapp/lib/policy-fence.d.mts +18 -0
- package/dist/web-dapp/lib/policy.d.mts +4 -0
- package/dist/web-dapp/lib/processes.d.mts +57 -0
- package/dist/web-dapp/lib/readiness.d.mts +85 -0
- package/dist/web-dapp/lib/runtime.d.mts +29 -0
- package/dist/web-dapp/lib/signers.d.mts +13 -0
- package/dist/web-dapp/lib/viem.d.mts +1 -0
- package/dist/web-dapp/stop.d.mts +2 -0
- package/dist/web-dapp/verify.d.mts +2 -0
- package/dist/web-dapp/wallet-host.d.mts +2 -0
- package/package.json +17 -10
- package/src/web-dapp/cleanup.mjs +74 -0
- package/src/web-dapp/index.mjs +590 -0
- package/src/web-dapp/inject.mjs +100 -0
- package/src/web-dapp/launch.mjs +806 -0
- package/src/web-dapp/lib/blocked-hosts.mjs +24 -0
- package/src/web-dapp/lib/cdp-client.mjs +86 -0
- package/src/web-dapp/lib/display.mjs +169 -0
- package/src/web-dapp/lib/hud.mjs +156 -0
- package/src/web-dapp/lib/paths.mjs +55 -0
- package/src/web-dapp/lib/policy-fence.mjs +128 -0
- package/src/web-dapp/lib/policy.mjs +108 -0
- package/src/web-dapp/lib/processes.mjs +215 -0
- package/src/web-dapp/lib/readiness.mjs +438 -0
- package/src/web-dapp/lib/runtime.mjs +195 -0
- package/src/web-dapp/lib/signers.mjs +105 -0
- package/src/web-dapp/lib/viem.mjs +26 -0
- package/src/web-dapp/stop.mjs +44 -0
- package/src/web-dapp/verify.mjs +68 -0
- package/src/web-dapp/wallet-host.mjs +728 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Testnet enforcement mechanics for the web-dapp slot browser: the hosts a
|
|
2
|
+
// venue policy blocks become unresolvable for the whole browser (pages,
|
|
3
|
+
// workers, WebSockets), and the wallet host fails and logs each request it
|
|
4
|
+
// sees to one of them. Which hosts is the venue policy's (venueHosts).
|
|
5
|
+
|
|
6
|
+
// A URL's host as the network sees it: lowercase, no trailing dot. null when
|
|
7
|
+
// it does not parse.
|
|
8
|
+
export function hostOf(url) {
|
|
9
|
+
try {
|
|
10
|
+
return new URL(String(url)).hostname.toLowerCase().replace(/\.$/u, '');
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isBlockedUrl(url, hosts) {
|
|
17
|
+
const host = hostOf(url);
|
|
18
|
+
return host !== null && hosts.includes(host);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function hostResolverRules(hosts) {
|
|
22
|
+
// The host and its fully qualified form (trailing dot) alike.
|
|
23
|
+
return `--host-resolver-rules=${hosts.flatMap((host) => [`MAP ${host} ~NOTFOUND`, `MAP ${host}. ~NOTFOUND`]).join(', ')}`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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(
|
|
20
|
+
`CDP on 127.0.0.1:${port} did not answer within ${timeoutMs}ms: ${lastError?.message ?? 'no response'}`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class CdpClient {
|
|
25
|
+
#ws;
|
|
26
|
+
#nextId = 1;
|
|
27
|
+
#pending = new Map();
|
|
28
|
+
#handlers = new Map();
|
|
29
|
+
|
|
30
|
+
static async connect(url) {
|
|
31
|
+
const client = new CdpClient();
|
|
32
|
+
client.#ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 });
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
client.#ws.once('open', resolve);
|
|
35
|
+
client.#ws.once('error', reject);
|
|
36
|
+
});
|
|
37
|
+
client.#ws.on('message', (data) => client.#dispatch(JSON.parse(String(data))));
|
|
38
|
+
client.#ws.on('close', () => {
|
|
39
|
+
for (const { reject } of client.#pending.values()) reject(new Error('CDP connection closed'));
|
|
40
|
+
client.#pending.clear();
|
|
41
|
+
client.#emit('close', {}, undefined);
|
|
42
|
+
});
|
|
43
|
+
return client;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#dispatch(message) {
|
|
47
|
+
if (message.id !== undefined) {
|
|
48
|
+
const pending = this.#pending.get(message.id);
|
|
49
|
+
if (!pending) return;
|
|
50
|
+
this.#pending.delete(message.id);
|
|
51
|
+
if (message.error) pending.reject(new Error(`${pending.method}: ${message.error.message}`));
|
|
52
|
+
else pending.resolve(message.result ?? {});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this.#emit(message.method, message.params ?? {}, message.sessionId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#emit(method, params, sessionId) {
|
|
59
|
+
for (const handler of this.#handlers.get(method) ?? []) {
|
|
60
|
+
try {
|
|
61
|
+
handler(params, sessionId);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
process.stderr.write(`[cdp] handler for ${method} failed: ${error?.stack ?? error}\n`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
on(method, handler) {
|
|
69
|
+
if (!this.#handlers.has(method)) this.#handlers.set(method, new Set());
|
|
70
|
+
this.#handlers.get(method).add(handler);
|
|
71
|
+
return () => this.#handlers.get(method)?.delete(handler);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
send(method, params = {}, sessionId = undefined) {
|
|
75
|
+
const id = this.#nextId++;
|
|
76
|
+
const payload = { id, method, params, ...(sessionId ? { sessionId } : {}) };
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
this.#pending.set(id, { resolve, reject, method });
|
|
79
|
+
this.#ws.send(JSON.stringify(payload));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
close() {
|
|
84
|
+
this.#ws?.close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
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({
|
|
24
|
+
headless,
|
|
25
|
+
headful,
|
|
26
|
+
nodeHeadless,
|
|
27
|
+
nodeHeadful,
|
|
28
|
+
env = process.env,
|
|
29
|
+
} = {}) {
|
|
30
|
+
if (headless === true && headful === true)
|
|
31
|
+
throw new Error('--headless and --headful are mutually exclusive.');
|
|
32
|
+
if (headless === true) return true;
|
|
33
|
+
if (headful === true) return false;
|
|
34
|
+
if (typeof nodeHeadless === 'boolean') return nodeHeadless;
|
|
35
|
+
if (nodeHeadful === true) return false;
|
|
36
|
+
return envFlag(env.TERMINAL_HEADLESS, 'TERMINAL_HEADLESS') ?? false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function slowMoValue(raw, label) {
|
|
40
|
+
if (raw == null || raw === '') return undefined;
|
|
41
|
+
const value = Number(raw);
|
|
42
|
+
if (!Number.isInteger(value) || value < 0 || value > 60000) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`${label} must be whole milliseconds between 0 and 60000, got ${JSON.stringify(raw)}.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Slow-mo precedence: --slow-mo → launch node slow_mo_ms → env TERMINAL_SLOW_MO → 0.
|
|
51
|
+
export function resolveSlowMo({ flag, node, env = process.env } = {}) {
|
|
52
|
+
return (
|
|
53
|
+
slowMoValue(flag, '--slow-mo') ??
|
|
54
|
+
slowMoValue(node, 'slow_mo_ms') ??
|
|
55
|
+
slowMoValue(env.TERMINAL_SLOW_MO, 'TERMINAL_SLOW_MO') ??
|
|
56
|
+
0
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The order page switches layout at 800 and 1440 CSS px. Recipes were proven on
|
|
61
|
+
// a 1600 CSS-px wide viewport, so a smaller window zooms out until the page
|
|
62
|
+
// still lays out at least this wide.
|
|
63
|
+
export const MIN_LAYOUT_WIDTH = 1460;
|
|
64
|
+
export const MIN_ZOOM = 0.5;
|
|
65
|
+
const FALLBACK_SCREEN = Object.freeze({ left: 0, top: 25, width: 1512, height: 920 });
|
|
66
|
+
|
|
67
|
+
// The main screen's usable area in points (menu bar and Dock excluded).
|
|
68
|
+
export function detectScreen(env = process.env) {
|
|
69
|
+
const explicit = /^(\d+)x(\d+)(?:\+(\d+)\+(\d+))?$/u.exec(
|
|
70
|
+
String(env.TERMINAL_SCREEN ?? '').trim(),
|
|
71
|
+
);
|
|
72
|
+
if (explicit) {
|
|
73
|
+
return {
|
|
74
|
+
left: Number(explicit[3] ?? 0),
|
|
75
|
+
top: Number(explicit[4] ?? 0),
|
|
76
|
+
width: Number(explicit[1]),
|
|
77
|
+
height: Number(explicit[2]),
|
|
78
|
+
source: 'TERMINAL_SCREEN',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (process.platform === 'darwin') {
|
|
82
|
+
try {
|
|
83
|
+
const out = execFileSync(
|
|
84
|
+
'osascript',
|
|
85
|
+
[
|
|
86
|
+
'-l',
|
|
87
|
+
'JavaScript',
|
|
88
|
+
'-e',
|
|
89
|
+
[
|
|
90
|
+
'ObjC.import("AppKit");',
|
|
91
|
+
'const s = $.NSScreen.mainScreen; const f = s.frame; const v = s.visibleFrame;',
|
|
92
|
+
'JSON.stringify({ left: v.origin.x, top: f.size.height - v.origin.y - v.size.height, width: v.size.width, height: v.size.height })',
|
|
93
|
+
].join(' '),
|
|
94
|
+
],
|
|
95
|
+
{ encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
96
|
+
);
|
|
97
|
+
const screen = JSON.parse(out.trim());
|
|
98
|
+
if (screen.width > 0 && screen.height > 0) return { ...screen, source: 'main-screen' };
|
|
99
|
+
} catch {
|
|
100
|
+
// Fall through to a size that fits any current MacBook.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { ...FALLBACK_SCREEN, source: 'fallback' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// One window per slot, side by side: odd CDP ports (mmt-1: 9541) take the left
|
|
107
|
+
// half of the main screen, even ports (mmt-2: 9542) the right half.
|
|
108
|
+
// TERMINAL_WINDOW=x,y,width,height places the window explicitly.
|
|
109
|
+
export function windowPlacement({ cdpPort, env = process.env, screen = null } = {}) {
|
|
110
|
+
const explicit = /^(-?\d+),(-?\d+),(\d+),(\d+)$/u.exec(
|
|
111
|
+
String(env.TERMINAL_WINDOW ?? '').replace(/\s/gu, ''),
|
|
112
|
+
);
|
|
113
|
+
let bounds;
|
|
114
|
+
if (explicit) {
|
|
115
|
+
bounds = {
|
|
116
|
+
x: Number(explicit[1]),
|
|
117
|
+
y: Number(explicit[2]),
|
|
118
|
+
width: Number(explicit[3]),
|
|
119
|
+
height: Number(explicit[4]),
|
|
120
|
+
source: 'TERMINAL_WINDOW',
|
|
121
|
+
};
|
|
122
|
+
} else {
|
|
123
|
+
const area = screen ?? detectScreen(env);
|
|
124
|
+
const width = Math.floor(area.width / 2);
|
|
125
|
+
const column = Number(cdpPort) % 2 === 1 ? 0 : 1;
|
|
126
|
+
bounds = {
|
|
127
|
+
x: Math.round(area.left) + column * width,
|
|
128
|
+
y: Math.round(area.top),
|
|
129
|
+
width,
|
|
130
|
+
height: Math.floor(area.height),
|
|
131
|
+
source: area.source ?? 'screen',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const zoom = Math.max(
|
|
135
|
+
MIN_ZOOM,
|
|
136
|
+
Math.min(1, Math.floor((bounds.width / MIN_LAYOUT_WIDTH) * 100) / 100),
|
|
137
|
+
);
|
|
138
|
+
return { ...bounds, zoom };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Chromium stores page zoom as a level: zoom = 1.2 ^ level. The profile's
|
|
142
|
+
// default zoom applies to every page the slot browser opens.
|
|
143
|
+
export function zoomLevel(zoom) {
|
|
144
|
+
return zoom === 1 ? 0 : Math.log(zoom) / Math.log(1.2);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function writeProfileZoom(profile, zoom) {
|
|
148
|
+
const file = path.join(profile, 'Default', 'Preferences');
|
|
149
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
150
|
+
const prefs = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
|
|
151
|
+
prefs.partition ??= {};
|
|
152
|
+
prefs.partition.default_zoom_level = {
|
|
153
|
+
...(prefs.partition.default_zoom_level ?? {}),
|
|
154
|
+
x: zoomLevel(zoom),
|
|
155
|
+
};
|
|
156
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
157
|
+
writeFileSync(temporary, JSON.stringify(prefs), { mode: 0o600 });
|
|
158
|
+
renameSync(temporary, file);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Headless keeps the tall viewport the recipes were first proven on; headful
|
|
162
|
+
// opens a placed window instead.
|
|
163
|
+
export function browserDisplayArgs({ headless, placement }) {
|
|
164
|
+
if (headless) return ['--headless=new', '--window-size=1600,2400'];
|
|
165
|
+
return [
|
|
166
|
+
`--window-position=${placement.x},${placement.y}`,
|
|
167
|
+
`--window-size=${placement.width},${placement.height}`,
|
|
168
|
+
];
|
|
169
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
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 = {
|
|
53
|
+
nodeId: id,
|
|
54
|
+
intent,
|
|
55
|
+
progress: progress ?? previous?.current?.progress ?? null,
|
|
56
|
+
};
|
|
57
|
+
return state;
|
|
58
|
+
}
|
|
59
|
+
const error = status === 'fail' ? text(node.error) : '';
|
|
60
|
+
state.last = { nodeId: id, intent, status, ...(error ? { error } : {}) };
|
|
61
|
+
if (progress?.complete || id === 'recipe-complete') {
|
|
62
|
+
state.finished = status;
|
|
63
|
+
state.current = {
|
|
64
|
+
nodeId: id,
|
|
65
|
+
intent: status === 'pass' ? 'Recipe passed' : 'Recipe failed',
|
|
66
|
+
progress,
|
|
67
|
+
};
|
|
68
|
+
} else if (progress) {
|
|
69
|
+
state.current = { ...(state.current ?? { nodeId: id, intent }), progress };
|
|
70
|
+
}
|
|
71
|
+
return state;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function hudFile(runtimeDir) {
|
|
75
|
+
return path.join(runtimeDir, HUD_FILE);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function readHudState(runtimeDir) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(readFileSync(hudFile(runtimeDir), 'utf8'));
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function writeHudState(runtimeDir, state) {
|
|
87
|
+
const file = hudFile(runtimeDir);
|
|
88
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
89
|
+
writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
90
|
+
renameSync(temporary, file);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function clearHudState(runtimeDir) {
|
|
94
|
+
rmSync(hudFile(runtimeDir), { force: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const STYLE = `
|
|
98
|
+
:host { all: initial; }
|
|
99
|
+
.hud { position: fixed; top: 220px; left: 12px; z-index: 2147483647; width: 360px; max-width: calc(50vw - 24px);
|
|
100
|
+
box-sizing: border-box; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,.18);
|
|
101
|
+
background: rgba(16,18,24,.82); color: #f2f4f7; font: 12px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
102
|
+
pointer-events: none; user-select: none; box-shadow: 0 4px 16px rgba(0,0,0,.35); }
|
|
103
|
+
.row { display: flex; gap: 6px; align-items: baseline; }
|
|
104
|
+
.title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
|
105
|
+
.step { color: #a9b1bd; font-variant-numeric: tabular-nums; }
|
|
106
|
+
.intent { margin-top: 4px; }
|
|
107
|
+
.badge { display: inline-block; padding: 0 5px; border-radius: 4px; font-size: 10px; font-weight: 700; letter-spacing: .03em; }
|
|
108
|
+
.running { background: #2f6fed; color: #fff; }
|
|
109
|
+
.pass { background: #1f9d55; color: #fff; }
|
|
110
|
+
.fail { background: #d64545; color: #fff; }
|
|
111
|
+
.last { margin-top: 4px; color: #c7cdd6; }
|
|
112
|
+
.error { color: #ffb4b4; }
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
// A self-contained page expression: draws (or clears, for null) the HUD when
|
|
116
|
+
// the document is on the app origin, and skips the work when the same state
|
|
117
|
+
// is already drawn.
|
|
118
|
+
export function hudRenderExpression(state, appOrigin) {
|
|
119
|
+
return `(() => {
|
|
120
|
+
const state = ${JSON.stringify(state ?? null)};
|
|
121
|
+
if (location.origin !== ${JSON.stringify(String(appOrigin).replace(/\/$/u, ''))}) return { hud: false, reason: 'not-app-origin' };
|
|
122
|
+
const existing = document.documentElement.querySelector(':scope > ${HUD_ELEMENT}');
|
|
123
|
+
if (!state) { existing?.remove(); return { hud: false, cleared: true }; }
|
|
124
|
+
if (existing && existing.dataset.seq === String(state.seq)) return { hud: true, seq: state.seq, unchanged: true };
|
|
125
|
+
existing?.remove();
|
|
126
|
+
const host = document.createElement('${HUD_ELEMENT}');
|
|
127
|
+
host.dataset.seq = String(state.seq);
|
|
128
|
+
host.setAttribute('aria-hidden', 'true');
|
|
129
|
+
host.inert = true;
|
|
130
|
+
host.style.cssText = 'all: initial; position: fixed; inset: 0 auto auto 0; width: 0; height: 0; pointer-events: none; z-index: 2147483647;';
|
|
131
|
+
const root = host.attachShadow({ mode: 'closed' });
|
|
132
|
+
const el = (tag, cls, value) => { const node = document.createElement(tag); if (cls) node.className = cls; if (value != null) node.textContent = value; return node; };
|
|
133
|
+
const style = el('style', null, ${JSON.stringify(STYLE)});
|
|
134
|
+
const box = el('div', 'hud');
|
|
135
|
+
const head = el('div', 'row');
|
|
136
|
+
head.append(el('span', 'title', state.title));
|
|
137
|
+
const progress = state.current && state.current.progress;
|
|
138
|
+
if (progress) head.append(el('span', 'step', 'step ' + progress.current + '/' + progress.total));
|
|
139
|
+
box.append(head);
|
|
140
|
+
if (state.current) {
|
|
141
|
+
const line = el('div', 'intent');
|
|
142
|
+
const status = state.finished || 'running';
|
|
143
|
+
line.append(el('span', 'badge ' + status, status === 'running' ? 'RUN' : status.toUpperCase()), ' ', state.current.intent);
|
|
144
|
+
box.append(line);
|
|
145
|
+
}
|
|
146
|
+
if (state.last && !state.finished) {
|
|
147
|
+
const line = el('div', 'last');
|
|
148
|
+
line.append(el('span', 'badge ' + state.last.status, state.last.status.toUpperCase()), ' ', state.last.intent);
|
|
149
|
+
box.append(line);
|
|
150
|
+
}
|
|
151
|
+
if (state.last && state.last.error) box.append(el('div', 'last error', state.last.error));
|
|
152
|
+
root.append(style, box);
|
|
153
|
+
document.documentElement.append(host);
|
|
154
|
+
return { hud: true, seq: state.seq };
|
|
155
|
+
})()`;
|
|
156
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The checkout layout the web-dapp leaves share (`temp/recipe/runtime/<adapter>/`).
|
|
2
|
+
// Local copies of the recipe-cli harness path helpers: adapter-web does not
|
|
3
|
+
// depend on @farmslot/recipe-cli. The environment names and defaults match.
|
|
4
|
+
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_RECIPE_RUNTIME_DIR = 'temp/recipe/runtime';
|
|
8
|
+
export const DEFAULT_RECIPE_HARNESS_ROOT = 'temp/recipe/harness';
|
|
9
|
+
|
|
10
|
+
export function validateRelativeRecipePath(name, value) {
|
|
11
|
+
if (!value || path.isAbsolute(value)) {
|
|
12
|
+
throw new Error(`${name} must be a non-empty relative path: ${value}`);
|
|
13
|
+
}
|
|
14
|
+
if (!/^[A-Za-z0-9._/-]+$/u.test(value)) {
|
|
15
|
+
throw new Error(`${name} contains unsupported characters: ${value}`);
|
|
16
|
+
}
|
|
17
|
+
for (const part of value.split('/')) {
|
|
18
|
+
if (!part || part === '.' || part === '..') {
|
|
19
|
+
throw new Error(`${name} contains unsafe path component: ${value}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The checkout-relative runtime directory: `RECIPE_RUNTIME_DIR`, else the default. */
|
|
26
|
+
export function recipeRuntimeDir() {
|
|
27
|
+
return validateRelativeRecipePath(
|
|
28
|
+
'RECIPE_RUNTIME_DIR',
|
|
29
|
+
process.env.RECIPE_RUNTIME_DIR || DEFAULT_RECIPE_RUNTIME_DIR,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The checkout-relative overlay directory: `RECIPE_HARNESS_ROOT`, else the default. */
|
|
34
|
+
export function recipeHarnessRoot() {
|
|
35
|
+
return validateRelativeRecipePath(
|
|
36
|
+
'RECIPE_HARNESS_ROOT',
|
|
37
|
+
process.env.RECIPE_HARNESS_ROOT || DEFAULT_RECIPE_HARNESS_ROOT,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function recipeRuntimePath(projectRoot, ...segments) {
|
|
42
|
+
return path.join(projectRoot, recipeRuntimeDir(), ...segments);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function recipeHarnessPath(projectRoot, ...segments) {
|
|
46
|
+
return path.join(projectRoot, recipeHarnessRoot(), ...segments);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function walletFixturePath(projectRoot) {
|
|
50
|
+
return recipeRuntimePath(projectRoot, 'wallet-fixture.json');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function shellQuote(value) {
|
|
54
|
+
return `'${value.replace(/'/gu, `'\\''`)}'`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The venue policy module runs outside the plugin loader's import fence: the
|
|
2
|
+
// host and every web-dapp leaf process require it by path. So mm-harness checks
|
|
3
|
+
// it itself when it binds the adapter: the policy file and everything it
|
|
4
|
+
// imports must be files the plugin digest covers (the loader's
|
|
5
|
+
// libraryAdapterFiles, by real path), and imports must be literal relative
|
|
6
|
+
// paths or node built-ins. The digest of those files goes in
|
|
7
|
+
// RECIPE_WEB_DAPP_POLICY_DIGEST, and a process that loads the policy with it
|
|
8
|
+
// set refuses files that changed since.
|
|
9
|
+
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import { isBuiltin } from 'node:module';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
export const POLICY_DIGEST_ENV = 'RECIPE_WEB_DAPP_POLICY_DIGEST';
|
|
16
|
+
|
|
17
|
+
export class PolicyFenceError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.code = 'ADAPTER_PLUGIN_INVALID';
|
|
21
|
+
this.userAction =
|
|
22
|
+
"keep the venue policy and every file it imports in the plugin module's directory (or the library's actions/), importing node built-ins and literal relative paths only";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const STATIC_IMPORT = /(?:^|[\s;])(?:import|export)\b[^'"`;]*?\bfrom\s*(['"])([^'"]+)\1/gu;
|
|
27
|
+
const BARE_IMPORT = /(?:^|[\s;])import\s*(['"])([^'"]+)\1/gu;
|
|
28
|
+
const LITERAL_CALL = /(?<![.\w$])(?:import|require)\s*\(\s*(['"])([^'"]+)\1\s*\)/gu;
|
|
29
|
+
const ANY_CALL = /(?<![.\w$])(?:import|require)\s*\(/gu;
|
|
30
|
+
|
|
31
|
+
// Whole-line comments may mention imports and are dropped. Nothing else is: a
|
|
32
|
+
// block or trailing comment that mentions an import is read as one and refused,
|
|
33
|
+
// so the scan errs on the side of refusing, never of missing an import.
|
|
34
|
+
function stripComments(source) {
|
|
35
|
+
return source.replace(/^\s*\/\/.*$/gmu, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function specifiers(source, file) {
|
|
39
|
+
const code = stripComments(source);
|
|
40
|
+
const found = [];
|
|
41
|
+
for (const pattern of [STATIC_IMPORT, BARE_IMPORT, LITERAL_CALL]) {
|
|
42
|
+
for (const match of code.matchAll(pattern)) found.push(match[2]);
|
|
43
|
+
}
|
|
44
|
+
const literalCalls = [...code.matchAll(LITERAL_CALL)].length;
|
|
45
|
+
if ([...code.matchAll(ANY_CALL)].length > literalCalls) {
|
|
46
|
+
throw new PolicyFenceError(
|
|
47
|
+
`web-dapp venue policy file ${file} imports or requires a computed specifier, which the host can't check.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function realFile(file) {
|
|
54
|
+
try {
|
|
55
|
+
const real = fs.realpathSync(file);
|
|
56
|
+
if (fs.statSync(real).isFile()) return real;
|
|
57
|
+
} catch {
|
|
58
|
+
// reported below
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The real paths of the policy module and every file it imports, in import
|
|
65
|
+
* order. `allowed(real)` decides each file; relative specifiers resolve
|
|
66
|
+
* against the importer's real path, as Node does.
|
|
67
|
+
*/
|
|
68
|
+
function policyClosure(file, allowed) {
|
|
69
|
+
const start = realFile(path.resolve(file));
|
|
70
|
+
if (!start) throw new PolicyFenceError(`web-dapp venue policy file ${file} does not exist.`);
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const visit = (real) => {
|
|
73
|
+
if (seen.has(real)) return;
|
|
74
|
+
allowed(real);
|
|
75
|
+
seen.add(real);
|
|
76
|
+
for (const specifier of specifiers(fs.readFileSync(real, 'utf8'), real)) {
|
|
77
|
+
if (specifier.startsWith('node:') || isBuiltin(specifier)) continue;
|
|
78
|
+
if (!specifier.startsWith('./') && !specifier.startsWith('../')) {
|
|
79
|
+
throw new PolicyFenceError(
|
|
80
|
+
`web-dapp venue policy file ${real} imports '${specifier}', which is neither a node built-in nor a relative path the plugin digest covers.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const target = realFile(path.resolve(path.dirname(real), specifier));
|
|
84
|
+
if (!target)
|
|
85
|
+
throw new PolicyFenceError(
|
|
86
|
+
`web-dapp venue policy file ${real} imports '${specifier}', which does not resolve to a file.`,
|
|
87
|
+
);
|
|
88
|
+
visit(target);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
visit(start);
|
|
92
|
+
return [...seen];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function digestFiles(files) {
|
|
96
|
+
const hash = createHash('sha256');
|
|
97
|
+
for (const file of [...files].sort()) {
|
|
98
|
+
hash.update(file).update('\0').update(fs.readFileSync(file)).update('\0');
|
|
99
|
+
}
|
|
100
|
+
return hash.digest('hex');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check a policy module against `covered`, the real paths of the files the
|
|
105
|
+
* plugin digest covers, and return the digest of the policy's files. Throws
|
|
106
|
+
* PolicyFenceError when one of them is not covered.
|
|
107
|
+
*/
|
|
108
|
+
export function fencePolicy({ file, covered, scope }) {
|
|
109
|
+
const allowed = (real) => {
|
|
110
|
+
if (!covered.has(real)) {
|
|
111
|
+
throw new PolicyFenceError(
|
|
112
|
+
`web-dapp venue policy file ${real} is outside the files the plugin digest covers (${scope}).`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
return digestFiles(policyClosure(file, allowed));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Refuse a policy whose files changed since mm-harness bound it (`digest`). */
|
|
120
|
+
export function assertPolicyDigest(file, digest) {
|
|
121
|
+
const now = digestFiles(policyClosure(file, () => {}));
|
|
122
|
+
if (now !== digest) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`web-dapp venue policy ${file} or a file it imports changed since the host bound it.\n` +
|
|
125
|
+
'Next: rerun the command, so it binds and checks the policy again.',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|