@deeeed/metamask-harness 0.70.1 → 0.71.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 +18 -0
- package/adapters/extension/console-tail.mjs +38 -6
- package/adapters/manifest.json +128 -0
- package/adapters/terminal/cleanup.mjs +68 -0
- package/adapters/terminal/inject.mjs +74 -0
- package/adapters/terminal/launch.mjs +628 -0
- package/adapters/terminal/lib/cdp-client.mjs +84 -0
- package/adapters/terminal/lib/display.mjs +115 -0
- package/adapters/terminal/lib/hud.mjs +148 -0
- package/adapters/terminal/lib/mainnet-guard.mjs +177 -0
- package/adapters/terminal/lib/origin.mjs +30 -0
- package/adapters/terminal/lib/page-script.mjs +246 -0
- package/adapters/terminal/lib/processes.mjs +187 -0
- package/adapters/terminal/lib/readiness.mjs +274 -0
- package/adapters/terminal/lib/strict-wallet.mjs +138 -0
- package/adapters/terminal/stop.mjs +37 -0
- package/adapters/terminal/verify.mjs +48 -0
- package/adapters/terminal/wallet-host.mjs +703 -0
- package/dist/adapters/console-capture.js +122 -0
- package/dist/adapters/extension/console-capture.js +22 -85
- package/dist/adapters/surface.js +3 -1
- package/dist/adapters/terminal/console-capture.js +46 -0
- package/dist/adapters/terminal/stop.js +19 -0
- package/dist/adapters/terminal/surface.js +104 -0
- package/dist/adapters.js +27 -4
- package/dist/cli.js +2 -2
- package/dist/command-contract.js +12 -4
- package/dist/commands/check.js +1 -1
- package/dist/commands/config.js +1 -1
- package/dist/commands/doctor.js +24 -1
- package/dist/commands/domain.js +1 -1
- package/dist/commands/farmslot-ready.js +2 -0
- package/dist/commands/help.js +2 -2
- package/dist/commands/launch/index.js +97 -2
- package/dist/commands/manifest.js +3 -3
- package/dist/commands/recipe-advice.js +1 -1
- package/dist/commands/review.js +1 -1
- package/dist/commands/run-engine.js +1 -1
- package/dist/commands/shared.js +1 -1
- package/dist/doctor.js +3 -0
- package/dist/harness.js +17 -11
- package/dist/manifest.js +5 -0
- package/dist/mm-harness-cli.js +34 -20
- package/dist/paths.js +2 -2
- package/dist/recipe-security.js +12 -3
- package/dist/review/knowledge.js +1 -1
- package/dist/run-diagnostics.js +150 -33
- package/dist/runner.js +1 -1
- package/dist/runtime-context.js +3 -3
- package/docs/CONTRIBUTING.md +1 -1
- package/library/actions/shared/console-allowlist-add.mjs +137 -0
- package/library/actions/shared/console-collector.mjs +230 -0
- package/library/actions/shared/console-findings.mjs +511 -0
- package/library/actions/terminal/app/assert_no_console_errors.mjs +119 -0
- package/library/actions/terminal/app/launch.mjs +45 -0
- package/library/actions/terminal/perps/_venue.mjs +242 -0
- package/library/actions/terminal/perps/assert_orders.mjs +54 -0
- package/library/actions/terminal/perps/assert_positions.mjs +40 -0
- package/library/actions/terminal/perps/read_orders.mjs +26 -0
- package/library/actions/terminal/perps/read_positions.mjs +25 -0
- package/library/actions/terminal/perps/read_snapshot.mjs +40 -0
- package/library/actions/terminal/perps/revoke_agent.mjs +58 -0
- package/library/actions/terminal/perps/teardown_state.mjs +104 -0
- package/library/actions/terminal/platform/hud.mjs +71 -0
- package/library/actions/terminal/platform/page.mjs +247 -0
- package/library/actions/terminal/platform/runtime.mjs +200 -0
- package/library/actions/terminal/ui/navigate.mjs +31 -0
- package/library/actions/terminal/wallet/_signature-log.mjs +177 -0
- package/library/actions/terminal/wallet/assert_signatures.mjs +43 -0
- package/library/actions/terminal/wallet/list_accounts.mjs +27 -0
- package/library/actions/terminal/wallet/read_signatures.mjs +37 -0
- package/library/manifests/terminal.action-manifest.json +1786 -0
- package/package.json +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Minimal browser-level Chrome DevTools Protocol client with flattened target
|
|
2
|
+
// sessions, for the long-running terminal wallet host. Page-level UI actions use
|
|
3
|
+
// @farmslot/recipe-harness/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
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// How the slot browser is shown: headful (default) or headless, where its
|
|
2
|
+
// window sits on screen, the page zoom that keeps the terminal'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
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Recipe HUD for the terminal 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
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Testnet enforcement for the terminal slot browser. In testnet mode nothing
|
|
2
|
+
// the browser runs may reach a mainnet venue (Hyperliquid, or Lighter when the
|
|
3
|
+
// controller carries it), whatever the dev server happens to serve:
|
|
4
|
+
// - the network layer: --host-resolver-rules makes every mainnet venue host
|
|
5
|
+
// unresolvable for the whole browser (pages, workers, WebSockets), and
|
|
6
|
+
// the wallet host fails and logs each attempt it sees (blocked-mainnet);
|
|
7
|
+
// - the wallet layer: a typed-data request for a mainnet action is refused
|
|
8
|
+
// before MetaMask or the injected signer sees it (refused-mainnet).
|
|
9
|
+
// The venue hosts are the endpoints the venue SDK, the perps controller and
|
|
10
|
+
// the app's source in the checkout connect to.
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
const SDK_PACKAGE = '@nktkas/hyperliquid';
|
|
17
|
+
const CONTROLLER_PACKAGE = '@metamask/perps-controller';
|
|
18
|
+
// Venue API hosts of the perps providers the terminal can configure: the
|
|
19
|
+
// Hyperliquid API/RPC hosts and Lighter's zkLighter API.
|
|
20
|
+
const VENUE_HOST = /^(?:[a-z0-9-]+\.)*(?:hyperliquid(?:-testnet)?\.xyz|zklighter\.elliot\.ai)$/u;
|
|
21
|
+
// A URL in source text, any case; it is then parsed (canonicalUrl).
|
|
22
|
+
const URL_CANDIDATE = /\b(?:https?|wss?):\/\/[^\s'"`<>(){}\\]+/giu;
|
|
23
|
+
// Any mention of a venue host, URL or not: the cross-check of the SDK scan.
|
|
24
|
+
const VENUE_HOST_MENTION = /(?<![\w.-])(?:[a-z0-9-]+\.)*(?:hyperliquid(?:-testnet)?\.xyz|zklighter\.elliot\.ai)(?![\w-])/giu;
|
|
25
|
+
// Request paths of the venue APIs (info, exchange, WebSocket, EVM RPC,
|
|
26
|
+
// Lighter's stream and REST API).
|
|
27
|
+
const API_PATH = /^\/(?:ws|info|exchange|evm|stream|api)(?:\/|$)/u;
|
|
28
|
+
const SOURCE_FILE = /\.(?:m?js|cjs|tsx?)$/u;
|
|
29
|
+
// The venues' web hosts: the Hyperliquid app, which serves the coin icons the
|
|
30
|
+
// terminal shows and the explorer pages it links to. Never an API endpoint,
|
|
31
|
+
// on either network, so never blocked and never proof of the served network.
|
|
32
|
+
export const LINK_HOSTS = new Set(['app.hyperliquid.xyz', 'app.hyperliquid-testnet.xyz']);
|
|
33
|
+
|
|
34
|
+
function packageRoot(checkout, name) {
|
|
35
|
+
try {
|
|
36
|
+
const require = createRequire(path.join(checkout, 'package.json'));
|
|
37
|
+
return path.dirname(require.resolve(`${name}/package.json`));
|
|
38
|
+
} catch {
|
|
39
|
+
const direct = path.join(checkout, 'node_modules', name);
|
|
40
|
+
return existsSync(path.join(direct, 'package.json')) ? direct : null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function* sourceFiles(dir, depth = 0) {
|
|
45
|
+
if (depth > 12) return;
|
|
46
|
+
for (const entry of readdirSync(dir)) {
|
|
47
|
+
if (entry === 'node_modules') continue;
|
|
48
|
+
const file = path.join(dir, entry);
|
|
49
|
+
const stat = statSync(file);
|
|
50
|
+
if (stat.isDirectory()) yield* sourceFiles(file, depth + 1);
|
|
51
|
+
else if (SOURCE_FILE.test(entry)) yield file;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isTestnetHost(host) {
|
|
56
|
+
return /(?:^|[.-])testnet(?:[.-]|$)/u.test(host);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Venue URLs in source text, by kind: hosts used with an API path, hosts
|
|
60
|
+
// used bare (a base URL whose paths are appended elsewhere), and hosts with
|
|
61
|
+
// any other path (/rpc, /health, /coins/BTC.svg). Link hosts are left out,
|
|
62
|
+
// except that a link host with an API path is reported (linkApi).
|
|
63
|
+
export function venueUrlHosts(text) {
|
|
64
|
+
const found = { api: new Set(), bare: new Set(), other: new Set(), linkApi: new Set() };
|
|
65
|
+
for (const [candidate] of text.matchAll(URL_CANDIDATE)) {
|
|
66
|
+
const url = canonicalUrl(candidate);
|
|
67
|
+
if (!url || !VENUE_HOST.test(url.hostname)) continue;
|
|
68
|
+
const { hostname: host, pathname } = url;
|
|
69
|
+
// The path as a server reads it: decoded, any case.
|
|
70
|
+
const apiPath = API_PATH.test(safeDecode(pathname).toLowerCase());
|
|
71
|
+
if (LINK_HOSTS.has(host)) {
|
|
72
|
+
// A link host used as an API: the exemption would hide an endpoint.
|
|
73
|
+
if (apiPath) found.linkApi.add(`${host}${pathname}`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (apiPath) found.api.add(host);
|
|
77
|
+
else if (pathname === '/') found.bare.add(host);
|
|
78
|
+
else found.other.add(host);
|
|
79
|
+
}
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
// Endpoint hosts in one text: with anyPath (the venue SDK) every venue host;
|
|
85
|
+
// otherwise API URLs and base URLs.
|
|
86
|
+
export function venueEndpointHosts(text, { anyPath = false } = {}) {
|
|
87
|
+
const { api, bare, other } = venueUrlHosts(text);
|
|
88
|
+
return new Set([...api, ...bare, ...(anyPath ? other : [])]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The venue hosts the checkout can connect to. { mainnet: [...], testnet: [...] }
|
|
92
|
+
// - the venue SDK: every venue host, whatever the path;
|
|
93
|
+
// - the perps controller and the app's own source: API URLs and base URLs.
|
|
94
|
+
// Link hosts (LINK_HOSTS) are never on either list, and a link host with an
|
|
95
|
+
// API path in any scanned source fails the launch. Every mainnet host the SDK
|
|
96
|
+
// mentions (as a URL or not) must be on the list, or the launch fails.
|
|
97
|
+
export function venueHosts(checkout) {
|
|
98
|
+
const sdk = packageRoot(checkout, SDK_PACKAGE);
|
|
99
|
+
if (!sdk) throw new Error(`${SDK_PACKAGE} is not installed in ${checkout}; the mainnet block list comes from it.`);
|
|
100
|
+
const sources = [
|
|
101
|
+
{ root: sdk, anyPath: true },
|
|
102
|
+
{ root: packageRoot(checkout, CONTROLLER_PACKAGE), anyPath: false },
|
|
103
|
+
{ root: path.join(checkout, 'src'), anyPath: false },
|
|
104
|
+
].filter(({ root }) => root && existsSync(root));
|
|
105
|
+
const hosts = new Set();
|
|
106
|
+
const sdkMentions = new Set();
|
|
107
|
+
for (const { root, anyPath } of sources) {
|
|
108
|
+
for (const file of sourceFiles(root)) {
|
|
109
|
+
const text = readFileSync(file, 'utf8');
|
|
110
|
+
const { linkApi } = venueUrlHosts(text);
|
|
111
|
+
if (linkApi.size > 0) throw new Error(`${file} uses the venue web host as an API (${[...linkApi].join(', ')}); it is exempt from the mainnet block, so refusing to launch.`);
|
|
112
|
+
for (const host of venueEndpointHosts(text, { anyPath })) hosts.add(host);
|
|
113
|
+
if (anyPath) {
|
|
114
|
+
for (const [mention] of text.matchAll(VENUE_HOST_MENTION)) {
|
|
115
|
+
const host = mention.toLowerCase();
|
|
116
|
+
if (!LINK_HOSTS.has(host)) sdkMentions.add(host);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const mainnet = [...hosts].filter((host) => !isTestnetHost(host)).sort();
|
|
122
|
+
const testnet = [...hosts].filter((host) => isTestnetHost(host)).sort();
|
|
123
|
+
if (mainnet.length === 0) throw new Error(`no mainnet venue host found in ${SDK_PACKAGE} at ${sdk}; refusing to launch without a block list.`);
|
|
124
|
+
const missing = [...sdkMentions].filter((host) => !isTestnetHost(host) && !mainnet.includes(host));
|
|
125
|
+
if (missing.length > 0) throw new Error(`the mainnet block list misses ${missing.join(', ')}, which ${SDK_PACKAGE} names; refusing to launch.`);
|
|
126
|
+
return { mainnet, testnet };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function mainnetVenueHosts(checkout) {
|
|
130
|
+
return venueHosts(checkout).mainnet;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function hostResolverRules(hosts) {
|
|
134
|
+
// The host and its fully qualified form (trailing dot) alike.
|
|
135
|
+
return `--host-resolver-rules=${hosts.flatMap((host) => [`MAP ${host} ~NOTFOUND`, `MAP ${host}. ~NOTFOUND`]).join(', ')}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A URL as the network sees it: new URL() lowercases the host, drops a
|
|
139
|
+
// default port and resolves dot segments; a trailing dot names the same host.
|
|
140
|
+
// { hostname, pathname }, or null.
|
|
141
|
+
export function canonicalUrl(value) {
|
|
142
|
+
try {
|
|
143
|
+
const url = new URL(String(value));
|
|
144
|
+
return { hostname: url.hostname.toLowerCase().replace(/\.$/u, ''), pathname: url.pathname };
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function safeDecode(value) {
|
|
151
|
+
try {
|
|
152
|
+
return decodeURIComponent(value);
|
|
153
|
+
} catch {
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function hostOf(url) {
|
|
159
|
+
return canonicalUrl(url)?.hostname ?? null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function isMainnetVenueUrl(url, hosts) {
|
|
163
|
+
const host = hostOf(url);
|
|
164
|
+
return host !== null && hosts.includes(host);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// A Hyperliquid action meant for mainnet: user-signed actions carry
|
|
168
|
+
// hyperliquidChain "Mainnet"; L1 actions are signed as a phantom Agent whose
|
|
169
|
+
// source is "a" on mainnet ("b" on testnet).
|
|
170
|
+
export function mainnetTypedDataReason(data) {
|
|
171
|
+
const message = data?.message ?? {};
|
|
172
|
+
if (typeof message.hyperliquidChain === 'string' && message.hyperliquidChain.toLowerCase() === 'mainnet') {
|
|
173
|
+
return 'hyperliquidChain=Mainnet';
|
|
174
|
+
}
|
|
175
|
+
if (data?.primaryType === 'Agent' && message.source === 'a') return 'L1 Agent source=a';
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Exact app-origin checks for the terminal slot. A prefix match would let
|
|
2
|
+
// http://localhost:30001 pass for http://localhost:3000, so every check parses
|
|
3
|
+
// the URL and compares origins.
|
|
4
|
+
|
|
5
|
+
export function originOf(url) {
|
|
6
|
+
try {
|
|
7
|
+
return new URL(String(url)).origin;
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isAppUrl(url, appOrigin) {
|
|
14
|
+
const origin = originOf(url);
|
|
15
|
+
return origin !== null && origin !== 'null' && origin === originOf(appOrigin);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A wallet request (or log entry) is accepted only from the default execution
|
|
19
|
+
// context of the tab's top frame, on the app origin, while that frame's
|
|
20
|
+
// committed document is an app URL. The committed URL matters because an
|
|
21
|
+
// about:blank document (a popup the app opened) inherits the app origin. For
|
|
22
|
+
// a page target the main frame id equals the target id.
|
|
23
|
+
export function isAppTopFrameContext(context, { targetId, appOrigin, committedUrl }) {
|
|
24
|
+
if (!context || !targetId) return false;
|
|
25
|
+
const aux = context.auxData ?? {};
|
|
26
|
+
return aux.isDefault === true &&
|
|
27
|
+
aux.frameId === targetId &&
|
|
28
|
+
isAppUrl(context.origin, appOrigin) &&
|
|
29
|
+
isAppUrl(committedUrl, appOrigin);
|
|
30
|
+
}
|