@deeeed/metamask-harness 0.80.0 → 0.81.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 (76) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/adapters/manifest.json +14 -30
  3. package/adapters/terminal/launch.mjs +17 -13
  4. package/adapters/terminal/lib/mainnet-guard.mjs +9 -0
  5. package/adapters/terminal/lib/readiness.mjs +20 -12
  6. package/adapters/terminal/policy.mjs +35 -0
  7. package/adapters/terminal/verify.mjs +10 -3
  8. package/adapters/terminal/wallet-host.mjs +43 -225
  9. package/dist/adapters/core/actions.js +4 -1
  10. package/dist/adapters/extension/actions.js +6 -2
  11. package/dist/adapters/extension/network-observer.js +13 -0
  12. package/dist/adapters/extension/run.js +112 -5
  13. package/dist/adapters/mobile/actions.js +4 -1
  14. package/dist/adapters/mobile/network-observer.js +68 -0
  15. package/dist/adapters/mobile/performance-observer.js +1 -3
  16. package/dist/adapters/mobile/run.js +32 -9
  17. package/dist/adapters/perps-order-inputs.js +36 -0
  18. package/dist/adapters/proof-failure.js +11 -0
  19. package/dist/adapters/terminal/actions.js +4 -1
  20. package/dist/adapters/terminal/doctor-checks.js +2 -0
  21. package/dist/adapters/terminal/launch.js +3 -0
  22. package/dist/adapters/terminal/overlay.js +8 -1
  23. package/dist/adapters/terminal/run.js +8 -3
  24. package/dist/adapters/terminal/surface.js +2 -0
  25. package/dist/adapters.js +20 -4
  26. package/dist/cli.js +31 -6
  27. package/dist/commands/check.js +30 -6
  28. package/dist/commands/completion-candidates.js +5 -5
  29. package/dist/commands/self-test.js +4 -3
  30. package/dist/engine.js +57 -0
  31. package/dist/funding-execution-context.js +81 -0
  32. package/dist/mm-harness-cli.js +3 -3
  33. package/dist/performance-observation.js +1 -3
  34. package/dist/recipe-assistance-context.js +6 -7
  35. package/dist/runner.js +2 -1
  36. package/docs/CONTRIBUTING.md +4 -2
  37. package/docs/RECIPES.md +31 -3
  38. package/library/actions/core/perps/_controller.mjs +24 -28
  39. package/library/actions/core/perps/_rewards.mjs +186 -0
  40. package/library/actions/core/perps/close_positions.mjs +1 -1
  41. package/library/actions/core/perps/place_order.mjs +167 -45
  42. package/library/actions/core/perps/quote_fees.mjs +7 -4
  43. package/library/actions/core/wallet/list_accounts.mjs +1 -1
  44. package/library/actions/extension/perps/close_visible_position.mjs +5 -0
  45. package/library/actions/extension/perps/edit_margin.mjs +4 -0
  46. package/library/actions/extension/perps/mutation-receipt.mjs +95 -3
  47. package/library/actions/extension/perps/perps.mjs +320 -3
  48. package/library/actions/extension/perps/place_order.mjs +3 -1
  49. package/library/actions/extension/perps/update_position_tpsl.mjs +4 -0
  50. package/library/actions/extension/platform/cdp.mjs +71 -3
  51. package/library/actions/mobile/perps/perps.mjs +216 -11
  52. package/library/actions/mobile/perps/place_order.mjs +3 -1
  53. package/library/actions/shared/perps/order-policy.mjs +177 -0
  54. package/library/actions/{core/wallet/_fixture-accounts.mjs → shared/wallet/fixture-accounts.mjs} +18 -3
  55. package/library/actions/terminal/app/launch.mjs +3 -2
  56. package/library/actions/terminal/platform/hud.mjs +2 -1
  57. package/library/actions/terminal/platform/page.mjs +2 -1
  58. package/library/actions/terminal/wallet/_signature-log.mjs +40 -161
  59. package/library/actions/terminal/wallet/assert_signatures.mjs +6 -12
  60. package/library/actions/terminal/wallet/read_signatures.mjs +10 -9
  61. package/library/manifests/core.action-manifest.json +6 -2
  62. package/library/manifests/extension.action-manifest.json +4 -0
  63. package/library/manifests/mobile.action-manifest.json +12 -0
  64. package/package.json +4 -4
  65. package/site/assets/help-recipes.json +1 -0
  66. package/adapters/terminal/lib/origin.mjs +0 -30
  67. package/adapters/terminal/lib/page-script.mjs +0 -246
  68. package/adapters/terminal/lib/strict-wallet.mjs +0 -138
  69. package/dist/commands/call.js +0 -671
  70. package/dist/commands/list-executables.js +0 -245
  71. package/dist/commands/manifest.js +0 -477
  72. package/dist/commands/run-engine.js +0 -1301
  73. package/dist/commands/run.js +0 -730
  74. package/dist/discovery.js +0 -15
  75. package/dist/metamask-action-validation.js +0 -68
  76. package/dist/network-observation.js +0 -290
@@ -1,177 +1,56 @@
1
- // Read, window and assert the terminal wallet request log the wallet host
2
- // writes: one JSON line per request the app sent to the main wallet (method,
3
- // EIP-712 primary type, domain/active chain, outcome) and, in extension mode,
4
- // one line per confirmation window MetaMask opened. Never holds params,
5
- // messages or signatures.
1
+ // The Terminal's wallet request log: where the wallet host writes it, and what
2
+ // the Terminal forbids in it. Reading, windowing and asserting the log are
3
+ // @farmslot/adapter-web/dapp's; the Hyperliquid rules are passed in as policy:
4
+ // - an L1 (agent-signed, Agent / chainId 1337) payload must never reach the
5
+ // main wallet (max_l1_requests, default 0);
6
+ // - a blocked mainnet venue request or a refused mainnet signature fails the
7
+ // assertion (testnet enforcement).
6
8
 
7
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
8
- import path from 'node:path';
9
+ import { shortPrimaryType } from '@farmslot/adapter-web/dapp';
9
10
 
10
11
  import { terminalRuntimePath } from '../platform/runtime.mjs';
11
12
 
12
13
  export const L1_PRIMARY_TYPE = 'Agent';
13
14
  export const L1_CHAIN_ID = 1337;
14
15
 
15
- export function signatureLogPath(projectRoot) {
16
- return terminalRuntimePath(projectRoot, 'wallet-requests.jsonl');
17
- }
18
-
19
- export function signatureCursorPath(projectRoot) {
20
- return terminalRuntimePath(projectRoot, 'wallet-requests.cursor.json');
21
- }
22
-
23
- export function parseLog(text) {
24
- return text
25
- .split('\n')
26
- .filter((line) => line.trim())
27
- .map((line, index) => {
28
- try {
29
- return { seq: index, ...JSON.parse(line) };
30
- } catch {
31
- return { seq: index, kind: 'unparseable' };
32
- }
33
- });
34
- }
35
-
36
- export async function readLog(projectRoot) {
37
- try {
38
- return parseLog(await readFile(signatureLogPath(projectRoot), 'utf8'));
39
- } catch (error) {
40
- if (error?.code === 'ENOENT') return [];
41
- throw error;
42
- }
43
- }
44
-
45
- export async function readCursor(projectRoot) {
46
- try {
47
- const cursor = JSON.parse(await readFile(signatureCursorPath(projectRoot), 'utf8'));
48
- return Number.isInteger(cursor.seq) ? cursor.seq : 0;
49
- } catch (error) {
50
- if (error?.code === 'ENOENT') return 0;
51
- throw error;
52
- }
53
- }
54
-
55
- export async function writeCursor(projectRoot, seq) {
56
- await writeFile(signatureCursorPath(projectRoot), `${JSON.stringify({ seq, at: new Date().toISOString() })}\n`, { mode: 0o600 });
57
- }
58
-
59
- // "HyperliquidTransaction:ApproveAgent" → "ApproveAgent".
60
- export function shortPrimaryType(primaryType) {
61
- if (primaryType == null) return null;
62
- const raw = String(primaryType);
63
- return raw.includes(':') ? raw.slice(raw.lastIndexOf(':') + 1) : raw;
64
- }
65
-
66
- export function isTypedDataRequest(entry) {
67
- return entry.kind === 'request' && /^eth_signTypedData/u.test(String(entry.method));
16
+ export function signatureLog(projectRoot) {
17
+ return {
18
+ logFile: terminalRuntimePath(projectRoot, 'wallet-requests.jsonl'),
19
+ cursorFile: terminalRuntimePath(projectRoot, 'wallet-requests.cursor.json'),
20
+ };
68
21
  }
69
22
 
70
23
  export function isL1Request(entry) {
71
- return isTypedDataRequest(entry) &&
24
+ return entry.kind === 'request' && /^eth_signTypedData/u.test(String(entry.method)) &&
72
25
  (shortPrimaryType(entry.primaryType) === L1_PRIMARY_TYPE || Number(entry.domainChainId) === L1_CHAIN_ID);
73
26
  }
74
27
 
75
- export function summarize(entries) {
76
- const requests = entries.filter((entry) => entry.kind === 'request');
77
- const typed = requests.filter(isTypedDataRequest);
78
- const byPrimaryType = {};
79
- for (const entry of typed) {
80
- const key = shortPrimaryType(entry.primaryType) ?? '<none>';
81
- byPrimaryType[key] ??= { requested: 0, signed: 0, rejected: 0, chainIds: [] };
82
- byPrimaryType[key].requested += 1;
83
- if (entry.outcome === 'signed') byPrimaryType[key].signed += 1;
84
- if (entry.outcome === 'rejected' || entry.outcome === 'error') byPrimaryType[key].rejected += 1;
85
- if (!byPrimaryType[key].chainIds.includes(entry.domainChainId)) byPrimaryType[key].chainIds.push(entry.domainChainId);
86
- }
87
- const byMethod = {};
88
- for (const entry of requests) byMethod[entry.method] = (byMethod[entry.method] ?? 0) + 1;
28
+ export const TERMINAL_SIGNATURE_POLICY = Object.freeze({
29
+ typedDataClasses: {
30
+ l1Requests: {
31
+ match: isL1Request,
32
+ param: 'max_l1_requests',
33
+ defaultMax: 0,
34
+ failure: (count, max) => `L1 requests reached the main wallet: ${count} > ${max}`,
35
+ },
36
+ },
37
+ forbiddenEntries: {
38
+ blockedMainnet: {
39
+ match: (entry) => entry.kind === 'blocked-mainnet' && !entry.probe,
40
+ failure: (count) => `the browser tried to reach a mainnet venue ${count} time(s) (blocked-mainnet)`,
41
+ },
42
+ refusedMainnet: {
43
+ match: (entry) => entry.kind === 'refused-mainnet',
44
+ failure: (count) => `the app asked the wallet to sign ${count} mainnet action(s) (refused-mainnet)`,
45
+ },
46
+ },
47
+ });
48
+
49
+ // Evidence goes to <artifacts>/terminal-wallet/<node>-signature-log.json.
50
+ export function logArtifactTarget(input) {
89
51
  return {
90
- requests: requests.length,
91
- typedDataRequests: typed.length,
92
- l1Requests: typed.filter(isL1Request).length,
93
- confirmationsShown: entries.filter((entry) => entry.kind === 'confirmation-shown').length,
94
- // Testnet enforcement and evidence gaps: each one fails the assertion.
95
- blockedMainnet: entries.filter((entry) => entry.kind === 'blocked-mainnet' && !entry.probe).length,
96
- refusedMainnet: entries.filter((entry) => entry.kind === 'refused-mainnet').length,
97
- unattributed: entries.filter((entry) => entry.kind === 'unattributed').length,
98
- // Calls from an iframe, a foreign page or a blank document: refused, kept as evidence.
99
- outsideAppFrame: entries.filter((entry) => entry.kind === 'outside-app-frame').length,
100
- byPrimaryType,
101
- byMethod,
52
+ artifactsDir: input.context?.artifactsDir,
53
+ nodeId: input.context?.nodeId ?? input.action,
54
+ folder: 'terminal-wallet',
102
55
  };
103
56
  }
104
-
105
- function exact(label, actual, expected, failures) {
106
- if (actual !== expected) failures.push(`${label}: expected ${expected}, got ${actual}`);
107
- }
108
-
109
- // Every expectation is optional; max_l1_requests defaults to 0 because an L1
110
- // (agent-signed) payload must never reach the main wallet.
111
- export function evaluateSignatureLog(entries, node = {}) {
112
- const summary = summarize(entries);
113
- const typed = entries.filter(isTypedDataRequest);
114
- const failures = [];
115
- if (summary.blockedMainnet > 0) failures.push(`the browser tried to reach a mainnet venue ${summary.blockedMainnet} time(s) (blocked-mainnet)`);
116
- if (summary.refusedMainnet > 0) failures.push(`the app asked the wallet to sign ${summary.refusedMainnet} mainnet action(s) (refused-mainnet)`);
117
- if (summary.unattributed > 0) failures.push(`${summary.unattributed} wallet log entr(y/ies) could not be attributed to the app's top frame (unattributed)`);
118
- const maxL1 = node.max_l1_requests == null ? 0 : Number(node.max_l1_requests);
119
- if (summary.l1Requests > maxL1) {
120
- failures.push(`L1 requests reached the main wallet: ${summary.l1Requests} > ${maxL1}`);
121
- }
122
- if (Array.isArray(node.allowed_primary_types)) {
123
- const allowed = new Set(node.allowed_primary_types.map(shortPrimaryType));
124
- for (const entry of typed) {
125
- const type = shortPrimaryType(entry.primaryType);
126
- if (!allowed.has(type)) failures.push(`primary type ${type} (seq ${entry.seq}) is not allowed`);
127
- }
128
- }
129
- if (node.counts && typeof node.counts === 'object') {
130
- for (const [type, expected] of Object.entries(node.counts)) {
131
- exact(`signed ${shortPrimaryType(type)}`, summary.byPrimaryType[shortPrimaryType(type)]?.signed ?? 0, Number(expected), failures);
132
- }
133
- }
134
- if (node.request_counts && typeof node.request_counts === 'object') {
135
- for (const [type, expected] of Object.entries(node.request_counts)) {
136
- exact(`requested ${shortPrimaryType(type)}`, summary.byPrimaryType[shortPrimaryType(type)]?.requested ?? 0, Number(expected), failures);
137
- }
138
- }
139
- if (node.methods && typeof node.methods === 'object') {
140
- for (const [method, expected] of Object.entries(node.methods)) {
141
- exact(`method ${method}`, summary.byMethod[method] ?? 0, Number(expected), failures);
142
- }
143
- }
144
- if (node.max_typed_data_requests != null && summary.typedDataRequests > Number(node.max_typed_data_requests)) {
145
- failures.push(`typed-data requests ${summary.typedDataRequests} > ${node.max_typed_data_requests}`);
146
- }
147
- if (node.expect_confirmations != null) {
148
- exact('confirmations shown', summary.confirmationsShown, Number(node.expect_confirmations), failures);
149
- }
150
- if (node.require_active_chain === true) {
151
- for (const entry of typed.filter((item) => item.outcome === 'signed')) {
152
- if (Number(entry.domainChainId) !== Number(entry.activeChainId)) {
153
- failures.push(`seq ${entry.seq} ${shortPrimaryType(entry.primaryType)} signed with domain chainId ${entry.domainChainId} while the wallet was on ${entry.activeChainId}`);
154
- }
155
- }
156
- }
157
- if (node.max_rejected != null) {
158
- const rejected = typed.filter((entry) => entry.outcome === 'rejected' || entry.outcome === 'error').length;
159
- if (rejected > Number(node.max_rejected)) failures.push(`rejected typed-data requests ${rejected} > ${node.max_rejected}`);
160
- }
161
- return { ok: failures.length === 0, failures, summary };
162
- }
163
-
164
- export async function windowSinceCursor(projectRoot) {
165
- const [entries, cursor] = await Promise.all([readLog(projectRoot), readCursor(projectRoot)]);
166
- return { cursor, entries: entries.filter((entry) => entry.seq >= cursor), total: entries.length };
167
- }
168
-
169
- export async function writeLogArtifact(input, payload) {
170
- const artifactsDir = input.context?.artifactsDir;
171
- if (!artifactsDir) return [];
172
- const nodeId = String(input.context?.nodeId ?? input.action).replaceAll('/', '__');
173
- const relative = `terminal-wallet/${nodeId}-signature-log.json`;
174
- await mkdir(path.join(artifactsDir, 'terminal-wallet'), { recursive: true });
175
- await writeFile(path.join(artifactsDir, relative), `${JSON.stringify(payload, null, 2)}\n`);
176
- return [{ path: relative, type: 'report', nodeId: String(input.context?.nodeId ?? input.action) }];
177
- }
@@ -3,22 +3,16 @@
3
3
  // payloads, active-chain matching and, in extension mode, confirmations shown.
4
4
  // The full window is retained as evidence whether or not the assertion holds.
5
5
 
6
+ import { awaitSignatureLog, writeLogArtifact } from '@farmslot/adapter-web/dapp';
7
+
6
8
  import { isDirectRun, runAdapter } from '../platform/runtime.mjs';
7
- import { evaluateSignatureLog, windowSinceCursor, writeLogArtifact } from './_signature-log.mjs';
9
+ import { TERMINAL_SIGNATURE_POLICY, logArtifactTarget, signatureLog } from './_signature-log.mjs';
8
10
 
9
11
  export async function assertSignatures(input) {
10
12
  const node = input.node ?? {};
11
- const projectRoot = input.context?.projectRoot;
12
- const deadline = Date.now() + Math.max(0, Number(node.timeout_ms ?? 0));
13
- let window = await windowSinceCursor(projectRoot);
14
- let result = evaluateSignatureLog(window.entries, node);
15
- // Entries arrive asynchronously from the wallet host; allow a short settle.
16
- while (!result.ok && Date.now() < deadline) {
17
- await new Promise((resolve) => setTimeout(resolve, 500));
18
- window = await windowSinceCursor(projectRoot);
19
- result = evaluateSignatureLog(window.entries, node);
20
- }
21
- const artifacts = await writeLogArtifact(input, {
13
+ // Entries arrive asynchronously from the wallet host; timeout_ms allows a short settle.
14
+ const { window, result } = await awaitSignatureLog(signatureLog(input.context?.projectRoot), node, TERMINAL_SIGNATURE_POLICY);
15
+ const artifacts = await writeLogArtifact(logArtifactTarget(input), {
22
16
  cursor: window.cursor,
23
17
  total: window.total,
24
18
  expectations: node,
@@ -2,26 +2,27 @@
2
2
  // evidence. reset=true moves the window start to the end of the log so the next
3
3
  // read covers only what follows (an explicit setup step, recorded in the trace).
4
4
 
5
+ import { resetWindow, summarize, windowSinceCursor, writeLogArtifact } from '@farmslot/adapter-web/dapp';
6
+
5
7
  import { isDirectRun, runAdapter } from '../platform/runtime.mjs';
6
- import { readLog, summarize, windowSinceCursor, writeCursor, writeLogArtifact } from './_signature-log.mjs';
8
+ import { TERMINAL_SIGNATURE_POLICY, logArtifactTarget, signatureLog } from './_signature-log.mjs';
7
9
 
8
10
  export async function readSignatures(input) {
9
- const projectRoot = input.context?.projectRoot;
11
+ const log = signatureLog(input.context?.projectRoot);
10
12
  if (input.node?.reset === true) {
11
- const entries = await readLog(projectRoot);
12
- await writeCursor(projectRoot, entries.length);
13
+ const { cursor, previousEntries } = await resetWindow(log);
13
14
  return {
14
15
  action: input.action,
15
16
  source: 'terminal-wallet-request-log',
16
17
  reset: true,
17
- cursor: entries.length,
18
- previousEntries: entries.length,
18
+ cursor,
19
+ previousEntries,
19
20
  proofPath: 'terminal-wallet-request-log',
20
21
  };
21
22
  }
22
- const { cursor, entries, total } = await windowSinceCursor(projectRoot);
23
- const summary = summarize(entries);
24
- const artifacts = await writeLogArtifact(input, { cursor, total, summary, entries });
23
+ const { cursor, entries, total } = await windowSinceCursor(log);
24
+ const summary = summarize(entries, TERMINAL_SIGNATURE_POLICY);
25
+ const artifacts = await writeLogArtifact(logArtifactTarget(input), { cursor, total, summary, entries });
25
26
  return {
26
27
  action: input.action,
27
28
  source: 'terminal-wallet-request-log',
@@ -819,7 +819,7 @@
819
819
  "execution_capabilities": []
820
820
  },
821
821
  "metamask.perps.quote_fees": {
822
- "description": "core Quote an order's fees through the perps controller's calculateFees (HyperLiquid, testnet by default, read-only): protocol and MetaMask fee rates and amounts (plus the winning fee source on a core that reports it), with an optional rewards discount supplied as RewardsController:getPerpsDiscountForAccount.",
822
+ "description": "core Quote an order's fees through the perps controller's calculateFees (HyperLiquid, testnet by default, read-only): protocol and MetaMask fee rates and amounts (plus the winning fee source on a core that reports it), with the rewards discount the headless RewardsController answers: the rewards API's answer for the selected wallet fixture account, the calls Mobile makes (UAT by default, like Mobile dev builds; CORE_PERPS_REWARDS_API_URL selects another).",
823
823
  "schema": {
824
824
  "type": "object",
825
825
  "properties": {
@@ -852,7 +852,7 @@
852
852
  },
853
853
  "rewards_discount_bips": {
854
854
  "type": "integer",
855
- "description": "Rewards discount the headless RewardsController answers with, an integer in basis points from 0 through 10000 (6500 = 65%). Omitted: no rewards source, the controller's 'not known yet'."
855
+ "description": "Rewards discount the headless RewardsController answers with, an integer in basis points from 0 through 10000 (6500 = 65%). Replaces the rewards API's answer, to prove a discount the fixture accounts do not hold."
856
856
  },
857
857
  "account": {
858
858
  "type": "string",
@@ -1093,6 +1093,10 @@
1093
1093
  "type": "number",
1094
1094
  "default": 3
1095
1095
  },
1096
+ "close_immediately": {
1097
+ "type": "boolean",
1098
+ "description": "Close the opened position again inside this action and prove the market flat (market orders only; the market must start flat). Required on mainnet, with leverage 1 and notional at most $15 from a fixture account."
1099
+ },
1096
1100
  "account": {
1097
1101
  "type": "string",
1098
1102
  "description": "Wallet fixture account name (dev1, dev2, ...) or the 0x address of one; defaults to dev1."
@@ -4371,6 +4371,10 @@
4371
4371
  "type": "number",
4372
4372
  "default": 1
4373
4373
  },
4374
+ "close_immediately": {
4375
+ "type": "boolean",
4376
+ "description": "Close the opened position again inside this action and prove the market flat (market orders only; the market must start flat). Required on mainnet, with leverage 1 and notional at most $15 from a fixture account."
4377
+ },
4374
4378
  "provider": {
4375
4379
  "type": "string",
4376
4380
  "description": "Exact Perps provider required before background dispatch."
@@ -2775,6 +2775,18 @@
2775
2775
  "leverage": {
2776
2776
  "type": "number",
2777
2777
  "default": 3
2778
+ },
2779
+ "network": {
2780
+ "type": "string",
2781
+ "enum": [
2782
+ "mainnet",
2783
+ "testnet"
2784
+ ],
2785
+ "description": "Perps network the order must run on; checked against the live app. A mainnet order must declare mainnet."
2786
+ },
2787
+ "close_immediately": {
2788
+ "type": "boolean",
2789
+ "description": "Close the opened position again inside this action and prove the market flat (market orders only; the market must start flat). Required on mainnet, with leverage 1 and notional at most $15 from a fixture account."
2778
2790
  }
2779
2791
  },
2780
2792
  "additionalProperties": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.80.0",
3
+ "version": "0.81.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -22,12 +22,12 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@farmslot/adapter-rn": "0.17.0",
25
- "@farmslot/adapter-sdk": "0.3.1",
26
- "@farmslot/adapter-web": "0.2.0",
25
+ "@farmslot/adapter-sdk": "0.4.0",
26
+ "@farmslot/adapter-web": "0.3.1",
27
27
  "@farmslot/agent-runtime": "0.17.0",
28
28
  "@farmslot/handoff": "^0.3.1",
29
29
  "@farmslot/protocol": "0.34.0",
30
- "@farmslot/recipe-cli": "0.4.1",
30
+ "@farmslot/recipe-cli": "0.5.0",
31
31
  "@farmslot/recipe-runner": "0.24.0",
32
32
  "agent-device": "0.19.3",
33
33
  "commander": "^12.0.0",
@@ -173,6 +173,7 @@
173
173
  "Graph: no unconditional pass, generic intent, hidden start state, or opaque node that collapses preparation, action, assertion, evidence, and teardown.",
174
174
  "Assertion modes such as require_unchanged must fail before any corrective action. Verify the negative path leaves the inspected state untouched; an assertion must not repair the behavior it is supposed to test.",
175
175
  "Reuse structured reads with assert_output instead of adding a new domain action for every comparison: source names the earlier node, and assert can select $.matchingCount with operator eq and the expected parameter. A status-report action can succeed while reporting missing prerequisites; assert required values before the dependent action. Checking three rows exist does not prove there are exactly three, or that they belong to this task.",
176
+ "Test command nodes: assert_output on the literal pass count. An exit code alone is not proof: jest -t exits 0 when no test matches.",
176
177
  "Evidence: inspect the recipe resolution, summary, trace, manifest, logs, and actual artifacts. Verify the values and state required by the claim.",
177
178
  "For a change claim, compare the actual before/after values for the same subject. Two captures alone prove neither an update nor freshness. Preserve unchanged observations as unproven rather than retrying to obtain a pass.",
178
179
  {
@@ -1,30 +0,0 @@
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
- }
@@ -1,246 +0,0 @@
1
- // Source of the script the wallet host evaluates in every app document before
2
- // page scripts. It reports each wallet request the app makes (method, EIP-712
3
- // primary type, domain/active chain, outcome; no params or signatures) through
4
- // a CDP binding, and in injected mode also provides the strict test wallet
5
- // announced over EIP-6963 as io.metamask. It installs itself only in the top
6
- // frame of a document on the exact app origin: the CDP preload also runs in
7
- // iframes and after the tab navigates elsewhere. Errors are logged as a code
8
- // and a fixed category, never as provider text.
9
-
10
- import { mainnetTypedDataReason } from './mainnet-guard.mjs';
11
-
12
- export const LOG_BINDING = '__mmTerminalWalletLog';
13
- export const REQUEST_BINDING = '__mmTerminalWalletRequest';
14
- export const RESOLVE_FN = '__mmTerminalWalletResolve';
15
-
16
- export const LOGGED_METHODS = Object.freeze([
17
- 'eth_requestAccounts',
18
- 'wallet_requestPermissions',
19
- 'wallet_revokePermissions',
20
- 'wallet_switchEthereumChain',
21
- 'wallet_addEthereumChain',
22
- 'eth_signTypedData',
23
- 'eth_signTypedData_v3',
24
- 'eth_signTypedData_v4',
25
- 'personal_sign',
26
- 'eth_sign',
27
- 'eth_sendTransaction',
28
- ]);
29
-
30
- export const ERROR_CATEGORIES = Object.freeze({
31
- 4001: 'user-rejected',
32
- 4100: 'unauthorized',
33
- 4200: 'unsupported-method',
34
- 4900: 'disconnected',
35
- 4901: 'chain-disconnected',
36
- 4902: 'unrecognized-chain',
37
- [-32602]: 'invalid-params',
38
- [-32603]: 'internal',
39
- });
40
-
41
- function pageMain(config, mainnetTypedDataReason) {
42
- if (window.top !== window || location.origin !== config.appOrigin) return;
43
- if (window.__mmTerminalHarness) return;
44
- // The wallet host's ready check reads this marker on the committed document:
45
- // the preload ran, and the page's provider is wrapped (covers).
46
- const wrapped = new WeakSet();
47
- const harness = {
48
- signer: config.signer,
49
- network: config.network,
50
- wrapped: 0,
51
- wrapFailures: [],
52
- covers: (provider) => Boolean(provider) && wrapped.has(provider),
53
- };
54
- window.__mmTerminalHarness = harness;
55
- const logged = new Set(config.loggedMethods);
56
- // Without the host's binding a wallet request would go unrecorded, so it is
57
- // refused (fail closed) rather than passed through.
58
- const unrecorded = () => Object.assign(
59
- new Error('The harness wallet host is not attached to this document; the request was refused so it cannot go unrecorded.'),
60
- { code: 4100 },
61
- );
62
- const hasBinding = (binding) => typeof window[binding] === 'function';
63
- const report = (entry) => {
64
- try {
65
- window[config.logBinding](JSON.stringify(entry));
66
- } catch {
67
- // Binding unavailable (host detached): the request still proceeds.
68
- }
69
- };
70
- const describe = (method, params) => {
71
- const entry = { kind: 'request', method, t: Date.now(), url: location.pathname };
72
- if (/^eth_signTypedData/.test(method)) {
73
- try {
74
- const raw = params?.[1];
75
- const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
76
- entry.primaryType = data?.primaryType ?? null;
77
- entry.domainName = data?.domain?.name ?? null;
78
- entry.domainChainId = data?.domain?.chainId == null ? null : Number(BigInt(data.domain.chainId));
79
- } catch {
80
- entry.primaryType = null;
81
- }
82
- }
83
- if (method === 'wallet_switchEthereumChain' || method === 'wallet_addEthereumChain') {
84
- try {
85
- entry.toChainId = Number.parseInt(params[0].chainId, 16);
86
- } catch {
87
- entry.toChainId = null;
88
- }
89
- }
90
- return entry;
91
- };
92
- const wrap = (provider, source) => {
93
- if (!provider || typeof provider.request !== 'function' || wrapped.has(provider)) return provider;
94
- const original = provider.request.bind(provider);
95
- const request = async (args) => {
96
- const method = args?.method;
97
- if (!logged.has(method)) return original(args);
98
- if (!hasBinding(config.logBinding)) throw unrecorded();
99
- const entry = { ...describe(method, args?.params), source };
100
- // Testnet runs never let a mainnet Hyperliquid action reach the signer.
101
- if (config.network === 'testnet' && /^eth_signTypedData/.test(method)) {
102
- let reason = null;
103
- try {
104
- const raw = args?.params?.[1];
105
- reason = mainnetTypedDataReason(typeof raw === 'string' ? JSON.parse(raw) : raw);
106
- } catch {
107
- reason = null;
108
- }
109
- if (reason) {
110
- report({ ...entry, kind: 'refused-mainnet', reason, outcome: 'refused', errorCode: 4100 });
111
- throw Object.assign(new Error('Refused: this test run is held to Hyperliquid testnet and the request signs a mainnet action.'), { code: 4100 });
112
- }
113
- }
114
- try {
115
- entry.activeChainId = Number.parseInt(await original({ method: 'eth_chainId' }), 16);
116
- } catch {
117
- entry.activeChainId = null;
118
- }
119
- const started = Date.now();
120
- try {
121
- const result = await original(args);
122
- entry.outcome = /sign/i.test(method) ? 'signed' : 'approved';
123
- entry.durationMs = Date.now() - started;
124
- report(entry);
125
- return result;
126
- } catch (error) {
127
- const code = Number.isInteger(error?.code) ? error.code : null;
128
- entry.outcome = code === 4001 ? 'rejected' : 'error';
129
- entry.errorCode = code;
130
- entry.errorCategory = config.errorCategories[code] ?? 'other';
131
- entry.durationMs = Date.now() - started;
132
- report(entry);
133
- throw error;
134
- }
135
- };
136
- try {
137
- provider.request = request;
138
- } catch {
139
- // Read-only property: define it instead (below).
140
- }
141
- if (provider.request !== request) {
142
- try {
143
- Object.defineProperty(provider, 'request', { value: request, configurable: true, writable: true });
144
- } catch {
145
- // Frozen provider: recorded below.
146
- }
147
- }
148
- // A provider whose request could not be replaced would sign unrecorded.
149
- if (provider.request !== request) {
150
- harness.wrapFailures.push(source);
151
- return provider;
152
- }
153
- wrapped.add(provider);
154
- harness.wrapped += 1;
155
- return provider;
156
- };
157
-
158
- if (config.signer === 'injected') {
159
- const listeners = {};
160
- const emit = (event, ...args) => (listeners[event] ?? []).forEach((fn) => {
161
- try { fn(...args); } catch { /* listener errors stay in the app */ }
162
- });
163
- const pending = new Map();
164
- let nextId = 1;
165
- window[config.resolveFn] = (id, outcome) => {
166
- const entry = pending.get(id);
167
- if (!entry) return;
168
- pending.delete(id);
169
- if (outcome.error) entry.reject(Object.assign(new Error(outcome.error.message), { code: outcome.error.code }));
170
- else entry.resolve(outcome.result);
171
- };
172
- let lastChain = null;
173
- const provider = {
174
- isMetaMask: true,
175
- _metamask: { isUnlocked: async () => true },
176
- request: ({ method, params }) => new Promise((resolve, reject) => {
177
- if (!hasBinding(config.requestBinding)) {
178
- reject(unrecorded());
179
- return;
180
- }
181
- const id = nextId++;
182
- pending.set(id, { resolve, reject });
183
- window[config.requestBinding](JSON.stringify({ id, method, params }));
184
- }).then((result) => {
185
- if (method === 'wallet_switchEthereumChain' && params?.[0]?.chainId !== lastChain) {
186
- lastChain = params[0].chainId;
187
- emit('chainChanged', lastChain);
188
- }
189
- if (method === 'eth_chainId') lastChain = result;
190
- if (method === 'eth_requestAccounts') emit('connect', { chainId: lastChain ?? '0x1' });
191
- return result;
192
- }),
193
- on: (event, fn) => { (listeners[event] ??= []).push(fn); return provider; },
194
- removeListener: (event, fn) => { listeners[event] = (listeners[event] ?? []).filter((f) => f !== fn); return provider; },
195
- enable: () => provider.request({ method: 'eth_requestAccounts' }),
196
- };
197
- wrap(provider, 'injected-strict-wallet');
198
- window.ethereum = provider;
199
- const info = {
200
- uuid: '5d1e5b4e-e2e0-4c3f-9a1d-000000000001',
201
- name: 'MetaMask (strict test wallet)',
202
- icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>",
203
- rdns: 'io.metamask',
204
- };
205
- const announce = () => window.dispatchEvent(new CustomEvent('eip6963:announceProvider', { detail: Object.freeze({ info, provider }) }));
206
- window.addEventListener('eip6963:requestProvider', announce);
207
- announce();
208
- return;
209
- }
210
-
211
- // Extension mode: wrap whatever provider MetaMask injects, however the app
212
- // reaches it (window.ethereum or an EIP-6963 announcement).
213
- let current = window.ethereum ? wrap(window.ethereum, 'window.ethereum') : undefined;
214
- try {
215
- Object.defineProperty(window, 'ethereum', {
216
- configurable: true,
217
- get: () => current,
218
- set: (value) => { current = wrap(value, 'window.ethereum'); },
219
- });
220
- } catch {
221
- // A non-configurable window.ethereum is still wrapped by the poll below.
222
- }
223
- window.addEventListener('eip6963:announceProvider', (event) => {
224
- wrap(event.detail?.provider, `eip6963:${event.detail?.info?.rdns ?? 'unknown'}`);
225
- }, true);
226
- let polls = 0;
227
- const poll = setInterval(() => {
228
- if (window.ethereum) wrap(window.ethereum, 'window.ethereum');
229
- if (++polls > 200) clearInterval(poll);
230
- }, 50);
231
- }
232
-
233
- export function pageScriptSource({ signer, appOrigin, network = 'testnet' }) {
234
- const origin = new URL(appOrigin).origin;
235
- const config = {
236
- signer,
237
- network,
238
- appOrigin: origin,
239
- errorCategories: ERROR_CATEGORIES,
240
- loggedMethods: LOGGED_METHODS,
241
- logBinding: LOG_BINDING,
242
- requestBinding: REQUEST_BINDING,
243
- resolveFn: RESOLVE_FN,
244
- };
245
- return `(${pageMain.toString()})(${JSON.stringify(config)}, ${mainnetTypedDataReason.toString()});`;
246
- }