@deeeed/metamask-harness 0.23.1 → 0.25.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 (33) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/adapters/extension/live.sh +2 -2
  3. package/adapters/extension/wallet-fixture-state.cjs +57 -19
  4. package/adapters/shared/ensure-runner-deps.sh +64 -5
  5. package/bin/mm-harness +26 -8
  6. package/dist/adapters/extension/runtime.js +1 -10
  7. package/dist/adapters.js +5 -1
  8. package/dist/commands/run-engine.js +18 -13
  9. package/dist/commands/runtime-launch.js +7 -2
  10. package/dist/live-adapter-contract.js +4 -2
  11. package/dist/recipe-security.js +10 -2
  12. package/dist/run-recording.js +156 -39
  13. package/library/actions/core/perps/_controller.mjs +138 -5
  14. package/library/actions/core/perps/assert_orders.mjs +174 -10
  15. package/library/actions/core/perps/assert_positions.mjs +20 -2
  16. package/library/actions/core/perps/close_orders.mjs +24 -5
  17. package/library/actions/core/perps/close_positions.mjs +22 -8
  18. package/library/actions/core/perps/edit_order.mjs +331 -0
  19. package/library/actions/core/perps/place_order.mjs +192 -43
  20. package/library/actions/core/perps/update_position_tpsl.mjs +121 -15
  21. package/library/actions/extension/analytics/set_consent.mjs +165 -0
  22. package/library/actions/extension/platform/cdp.mjs +112 -14
  23. package/library/actions/mobile/analytics/set_consent.mjs +90 -0
  24. package/library/actions/shared/analytics/_adapter.mjs +24 -0
  25. package/library/actions/shared/analytics/assert_events.mjs +168 -0
  26. package/library/actions/shared/analytics/collector.mjs +505 -0
  27. package/library/actions/shared/analytics/consent.mjs +14 -0
  28. package/library/actions/shared/analytics/read_events.mjs +22 -0
  29. package/library/actions/shared/analytics/start_capture.mjs +24 -0
  30. package/library/manifests/core.action-manifest.json +468 -27
  31. package/library/manifests/extension.action-manifest.json +188 -1
  32. package/library/manifests/mobile.action-manifest.json +161 -0
  33. package/package.json +3 -3
@@ -31,23 +31,25 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
31
31
  }
32
32
  const recordArgs = ["record", "--framed", "--pid", String(pid)];
33
33
  const relativePath = "videos/full-run.mp4";
34
- const outputPath = path.join(artifactsDir, relativePath);
35
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
36
- fs.rmSync(outputPath, { force: true });
37
- const child = spawn(captureHelperPath(), [...recordArgs, "--output", outputPath], {
34
+ const stage = preparePrivateRecordingStage(artifactsDir, relativePath);
35
+ const child = spawn(captureHelperPath(), [...recordArgs, "--output", stage.stagedOutputPath], {
38
36
  cwd: projectRoot,
39
37
  env: process.env,
40
38
  stdio: ["pipe", "pipe", "pipe"]
41
39
  });
42
40
  const recording = {
43
41
  child,
44
- outputPath,
42
+ outputPath: stage.outputPath,
43
+ stagedOutputPath: stage.stagedOutputPath,
44
+ stagingDir: stage.stagingDir,
45
45
  relativePath,
46
46
  pid,
47
47
  stdout: "",
48
48
  stderr: "",
49
49
  exited: false,
50
50
  exitCode: null,
51
+ frameReady: false,
52
+ finalized: false,
51
53
  stderrBuffer: "",
52
54
  pendingSnapshots: /* @__PURE__ */ new Map()
53
55
  };
@@ -69,15 +71,25 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
69
71
  activeRecordingsByPid.delete(recording.pid);
70
72
  rejectPendingSnapshots(recording, new Error(`capture-helper recording exited before snapshot completed (code=${exitCode ?? "unknown"})`));
71
73
  });
72
- await sleep(750);
74
+ await waitForRecordingReady(recording, 15e3);
73
75
  if (recording.exited) {
74
- console.error(
75
- `WARN: capture-helper record exited before the recipe started (code=${recording.exitCode ?? "unknown"}): ${recording.stderr || recording.stdout}`
76
+ cleanupRecordingStage(recording);
77
+ throw new Error(
78
+ `capture-helper exited before recording its first frame (code=${recording.exitCode ?? "unknown"}): ${recording.stderr || recording.stdout}`
79
+ );
80
+ }
81
+ if (!recording.frameReady) {
82
+ try {
83
+ await stopRecordingProcess(recording);
84
+ } finally {
85
+ cleanupRecordingStage(recording);
86
+ }
87
+ throw new Error(
88
+ `capture-helper did not record a frame within 15000ms: ${recording.stderr || recording.stdout || "no recorder output"}`
76
89
  );
77
- return void 0;
78
90
  }
79
91
  activeRecordingsByPid.set(pid, recording);
80
- console.error(`INFO: recording recipe video with capture-helper pid=${pid} output=${outputPath}`);
92
+ console.error(`INFO: recording recipe video with capture-helper pid=${pid} output=${recording.outputPath}`);
81
93
  return recording;
82
94
  }
83
95
  async function captureActiveRecipeRecordingSnapshot(pid, outputPath, timeoutMs = 3e4) {
@@ -101,8 +113,36 @@ async function captureActiveRecipeRecordingSnapshot(pid, outputPath, timeoutMs =
101
113
  });
102
114
  }
103
115
  async function stopRecipeRecording(recording, result) {
104
- if (!recording) return;
105
- if (!recording.exited) {
116
+ if (!recording || recording.finalized) return;
117
+ recording.finalized = true;
118
+ try {
119
+ await stopRecordingProcess(recording);
120
+ } catch (error) {
121
+ cleanupRecordingStage(recording);
122
+ if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
123
+ throw error;
124
+ }
125
+ const validation = validateRecordingArtifact(recording);
126
+ if (validation.ok === false) {
127
+ cleanupRecordingStage(recording);
128
+ if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
129
+ throw new Error(
130
+ `capture-helper recording did not produce a usable video artifact: ${validation.reason}`
131
+ );
132
+ }
133
+ try {
134
+ publishPrivateRecording(recording);
135
+ } catch (error) {
136
+ cleanupRecordingStage(recording);
137
+ if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
138
+ throw new Error(
139
+ `capture-helper recording could not safely publish ${recording.outputPath}: ${error instanceof Error ? error.message : String(error)}`
140
+ );
141
+ }
142
+ if (result) addRecordingArtifactToManifest(result, recording);
143
+ }
144
+ async function stopRecordingProcess(recording) {
145
+ if (!recording.exited && !recording.child.stdin.destroyed) {
106
146
  recording.child.stdin.end("stop\n");
107
147
  await waitForRecordingExit(recording, 15e3);
108
148
  }
@@ -114,33 +154,33 @@ async function stopRecipeRecording(recording, result) {
114
154
  recording.child.kill("SIGTERM");
115
155
  await waitForRecordingExit(recording, 3e3);
116
156
  }
117
- const validation = validateRecordingArtifact(recording);
118
- if (validation.ok === false) {
119
- try {
120
- fs.rmSync(recording.outputPath, { force: true });
121
- } catch (error) {
122
- console.error(
123
- `WARN: could not remove unusable capture-helper video ${recording.outputPath}: ${error instanceof Error ? error.message : String(error)}`
124
- );
125
- }
126
- console.error(
127
- `WARN: capture-helper recording did not produce a usable video artifact: ${validation.reason}`
128
- );
129
- if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
130
- return;
157
+ if (!recording.exited) {
158
+ recording.child.kill("SIGKILL");
159
+ await waitForRecordingExit(recording, 2e3);
160
+ }
161
+ if (!recording.exited) {
162
+ throw new Error(`capture-helper recording process did not exit: pid=${recording.child.pid ?? "unknown"}`);
131
163
  }
132
- if (result) addRecordingArtifactToManifest(result, recording);
133
164
  }
134
165
  async function waitForRecordingExit(recording, timeoutMs) {
135
166
  if (recording.exited) return;
136
167
  await new Promise((resolve) => {
137
- const timer = setTimeout(resolve, timeoutMs);
138
- recording.child.once("close", () => {
168
+ const finish = () => {
139
169
  clearTimeout(timer);
170
+ recording.child.removeListener("close", finish);
140
171
  resolve();
141
- });
172
+ };
173
+ const timer = setTimeout(finish, timeoutMs);
174
+ recording.child.once("close", finish);
175
+ if (recording.exited) finish();
142
176
  });
143
177
  }
178
+ async function waitForRecordingReady(recording, timeoutMs) {
179
+ const deadline = Date.now() + timeoutMs;
180
+ while (!recording.frameReady && !recording.exited && Date.now() < deadline) {
181
+ await sleep(50);
182
+ }
183
+ }
144
184
  function handleRecordingStderr(recording, chunk) {
145
185
  recording.stderrBuffer += chunk;
146
186
  let newlineIndex = recording.stderrBuffer.indexOf("\n");
@@ -160,6 +200,9 @@ function handleRecordingEventLine(recording, line) {
160
200
  } catch {
161
201
  return;
162
202
  }
203
+ if (event.type === "info" && typeof event.msg === "string" && /^record frames=[1-9]\d*$/.test(event.msg)) {
204
+ recording.frameReady = true;
205
+ }
163
206
  const output = typeof event.output === "string" ? event.output : void 0;
164
207
  if (!output) return;
165
208
  const pending = recording.pendingSnapshots.get(output);
@@ -184,36 +227,39 @@ function rejectPendingSnapshots(recording, error) {
184
227
  recording.pendingSnapshots.clear();
185
228
  }
186
229
  function validateRecordingArtifact(recording) {
187
- if (!fs.existsSync(recording.outputPath)) {
188
- return { ok: false, reason: `missing output ${recording.outputPath}` };
230
+ if (!fs.existsSync(recording.stagedOutputPath)) {
231
+ return { ok: false, reason: `missing output ${recording.stagedOutputPath}` };
189
232
  }
190
- const size = fs.statSync(recording.outputPath).size;
191
- if (size === 0) {
192
- return { ok: false, reason: `empty output ${recording.outputPath}` };
233
+ const info = fs.lstatSync(recording.stagedOutputPath);
234
+ if (info.isSymbolicLink() || !info.isFile()) {
235
+ return { ok: false, reason: `output is not a safe regular file ${recording.stagedOutputPath}` };
236
+ }
237
+ if (info.size === 0) {
238
+ return { ok: false, reason: `empty output ${recording.stagedOutputPath}` };
193
239
  }
194
240
  const recorderOutput = `${recording.stdout}
195
241
  ${recording.stderr}`;
196
242
  if (!recorderOutput.includes("record_complete")) {
197
243
  return {
198
244
  ok: false,
199
- reason: `capture-helper did not report record_complete for ${recording.outputPath}: ${recorderOutput.trim() || "no recorder output"}`
245
+ reason: `capture-helper did not report record_complete for ${recording.stagedOutputPath}: ${recorderOutput.trim() || "no recorder output"}`
200
246
  };
201
247
  }
202
248
  const ffprobe = spawnSync(
203
249
  "ffprobe",
204
- ["-hide_banner", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", recording.outputPath],
250
+ ["-hide_banner", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", recording.stagedOutputPath],
205
251
  { encoding: "utf8" }
206
252
  );
207
253
  if (ffprobe.error && ffprobe.error.code === "ENOENT") {
208
254
  return { ok: true };
209
255
  }
210
256
  if (ffprobe.error) {
211
- return { ok: false, reason: `ffprobe failed for ${recording.outputPath}: ${ffprobe.error.message}` };
257
+ return { ok: false, reason: `ffprobe failed for ${recording.stagedOutputPath}: ${ffprobe.error.message}` };
212
258
  }
213
259
  if (ffprobe.status !== 0) {
214
260
  return {
215
261
  ok: false,
216
- reason: `invalid MP4 ${recording.outputPath}: ${ffprobe.stderr.trim() || ffprobe.stdout.trim() || `ffprobe exited ${ffprobe.status}`}`
262
+ reason: `invalid MP4 ${recording.stagedOutputPath}: ${ffprobe.stderr.trim() || ffprobe.stdout.trim() || `ffprobe exited ${ffprobe.status}`}`
217
263
  };
218
264
  }
219
265
  const durationSeconds = Number(ffprobe.stdout.trim());
@@ -222,6 +268,77 @@ ${recording.stderr}`;
222
268
  }
223
269
  return { ok: true };
224
270
  }
271
+ function preparePrivateRecordingStage(artifactsDir, relativePath) {
272
+ const artifactsRoot = path.resolve(artifactsDir);
273
+ const outputPath = path.resolve(artifactsRoot, relativePath);
274
+ if (!outputPath.startsWith(`${artifactsRoot}${path.sep}`)) {
275
+ throw new Error(`Refusing recording artifact outside artifacts dir: ${relativePath}`);
276
+ }
277
+ fs.mkdirSync(artifactsRoot, { recursive: true });
278
+ ensureRecordingParent(artifactsRoot, path.dirname(outputPath));
279
+ refuseUnsafeRecordingDestination(outputPath);
280
+ const stagingDir = fs.mkdtempSync(path.join(artifactsRoot, ".extension-recording-"));
281
+ fs.chmodSync(stagingDir, 448);
282
+ return {
283
+ outputPath,
284
+ stagedOutputPath: path.join(stagingDir, "full-run.mp4"),
285
+ stagingDir
286
+ };
287
+ }
288
+ function ensureRecordingParent(artifactsRoot, parent) {
289
+ const relative = path.relative(artifactsRoot, parent);
290
+ let current = artifactsRoot;
291
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
292
+ current = path.join(current, segment);
293
+ const info = lstatIfPresent(current);
294
+ if (!info) {
295
+ fs.mkdirSync(current, { mode: 448 });
296
+ continue;
297
+ }
298
+ if (info.isSymbolicLink() || !info.isDirectory()) {
299
+ throw new Error(`Refusing recording artifact parent that is not a real directory: ${current}`);
300
+ }
301
+ }
302
+ }
303
+ function refuseUnsafeRecordingDestination(outputPath) {
304
+ const info = lstatIfPresent(outputPath);
305
+ if (!info) return;
306
+ if (info.isSymbolicLink()) {
307
+ throw new Error(`Refusing recording artifact destination symlink: ${outputPath}`);
308
+ }
309
+ if (!info.isFile()) {
310
+ throw new Error(`Refusing recording artifact destination that is not a regular file: ${outputPath}`);
311
+ }
312
+ }
313
+ function lstatIfPresent(filePath) {
314
+ try {
315
+ return fs.lstatSync(filePath);
316
+ } catch (error) {
317
+ if (error.code === "ENOENT") return void 0;
318
+ throw error;
319
+ }
320
+ }
321
+ function publishPrivateRecording(recording) {
322
+ const descriptor = fs.openSync(
323
+ recording.stagedOutputPath,
324
+ fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)
325
+ );
326
+ try {
327
+ const info = fs.fstatSync(descriptor);
328
+ if (!info.isFile()) {
329
+ throw new Error(`Captured recording is not a safe regular file: ${recording.stagedOutputPath}`);
330
+ }
331
+ fs.fchmodSync(descriptor, 384);
332
+ refuseUnsafeRecordingDestination(recording.outputPath);
333
+ fs.renameSync(recording.stagedOutputPath, recording.outputPath);
334
+ } finally {
335
+ fs.closeSync(descriptor);
336
+ }
337
+ cleanupRecordingStage(recording);
338
+ }
339
+ function cleanupRecordingStage(recording) {
340
+ fs.rmSync(recording.stagingDir, { recursive: true, force: true });
341
+ }
225
342
  function addRecordingArtifactToManifest(result, recording) {
226
343
  const manifestPath = result.artifactManifestPath;
227
344
  if (!manifestPath || !fs.existsSync(manifestPath)) {
@@ -618,6 +618,60 @@ export function redactPosition(position) {
618
618
  };
619
619
  }
620
620
 
621
+ /**
622
+ * Wrap a controller rejection so its typed code survives as data.
623
+ *
624
+ * `expect_error` used to match by substring against the message, which meant a
625
+ * message that merely mentioned a code — or contained a longer code with the
626
+ * expected one as a prefix — matched wrongly. Carrying the code on the error
627
+ * lets the comparison be exact.
628
+ *
629
+ * @param params - Rejection details.
630
+ * @param params.action - Action name, for the message.
631
+ * @param params.detail - Human context for the message.
632
+ * @param params.code - The controller's typed error code.
633
+ * @returns An error carrying `perpsErrorCode`.
634
+ */
635
+ export function controllerRejection(params) {
636
+ const { action, detail, code } = params;
637
+ const error = new Error(`${action} was rejected: ${code}${detail ? ` (${detail})` : ''}`);
638
+ error.perpsErrorCode = code;
639
+ return error;
640
+ }
641
+
642
+ /**
643
+ * Read an optional node param, treating an unset template as absent.
644
+ *
645
+ * Recipes are static JSON, so an optional parameter that a caller leaves at its
646
+ * default arrives as an empty string rather than being omitted. Passing that
647
+ * through as a set-but-empty value is how a plain order ends up carrying an
648
+ * empty TP/SL size, which the controller then rejects as invalid. Whitespace is
649
+ * trimmed for the same reason.
650
+ *
651
+ * @param node - The recipe node.
652
+ * @param snake - snake_case param name.
653
+ * @param camel - camelCase alias.
654
+ * @returns The value as a string, or undefined when the node did not set it.
655
+ */
656
+ export function optionalParam(node, snake, camel) {
657
+ const normalize = (value) => {
658
+ if (value === undefined || value === null) {
659
+ return undefined;
660
+ }
661
+ const text = String(value).trim();
662
+ return text.length === 0 ? undefined : text;
663
+ };
664
+ return normalize(node?.[snake]) ?? normalize(node?.[camel]);
665
+ }
666
+
667
+ export function optionalBooleanParam(node, snake, camel) {
668
+ const value = optionalParam(node, snake, camel);
669
+ if (value === undefined) return undefined;
670
+ if (value.toLowerCase() === 'true') return true;
671
+ if (value.toLowerCase() === 'false') return false;
672
+ throw new Error(`${snake} must be true or false; got ${value}.`);
673
+ }
674
+
621
675
  export function redactOrder(order) {
622
676
  return {
623
677
  coin: order.coin ?? order.symbol ?? null,
@@ -691,16 +745,95 @@ async function disconnectController(exitCode) {
691
745
  }
692
746
  }
693
747
 
748
+ /**
749
+ * Read the rejection a node expects, if any.
750
+ *
751
+ * @param input - Adapter input (node.expect_error / node.expectError).
752
+ * @returns The expected error token, or undefined when the node expects success.
753
+ */
754
+ function expectedErrorFor(input) {
755
+ const node = input?.node ?? {};
756
+ for (const key of ['expect_error', 'expectError']) {
757
+ const value = node[key];
758
+ if (typeof value === 'string' && value.trim().length > 0) {
759
+ return value.trim();
760
+ }
761
+ }
762
+ return undefined;
763
+ }
764
+
765
+ /**
766
+ * Run a Core perps adapter, honouring an expected rejection.
767
+ *
768
+ * Most nodes assert that an operation succeeds. A controller's refusals are part
769
+ * of its contract too — that a partial size below the asset's precision, or a
770
+ * TP/SL linkage the venue cannot express, is rejected with a typed error and
771
+ * before any side effect. Without `expect_error` those can only be proven by a
772
+ * bespoke script, because every action here throws on failure.
773
+ *
774
+ * With `expect_error` set, a throw whose message carries the expected token is
775
+ * the passing outcome: the rejection is written out as evidence and the process
776
+ * exits 0. Anything else fails — a different error, or the operation succeeding
777
+ * when it should have been refused. The success case is checked explicitly
778
+ * rather than left to fall through, so a contract that silently stops rejecting
779
+ * is caught rather than reported as a pass.
780
+ *
781
+ * Actions surface a controller's `{ success: false, error }` result by throwing
782
+ * with the code in the message (see place_order.mjs), so a substring match
783
+ * covers both that shape and a genuine throw.
784
+ *
785
+ * When the param is absent this is byte-identical to the previous behaviour.
786
+ *
787
+ * @param callback - The adapter body, receiving the loaded input.
788
+ */
694
789
  export async function runAdapter(callback) {
695
790
  const input = await loadInput();
791
+ const expectedError = expectedErrorFor(input);
696
792
  let exitCode = 0;
697
793
  try {
698
- await writeOutput(input, await callback(input));
794
+ const output = await callback(input);
795
+ if (expectedError) {
796
+ // Not thrown: a rejection asserted here must not be satisfied by the
797
+ // message this branch would itself produce.
798
+ exitCode = 1;
799
+ process.stderr.write(
800
+ `[core/perps] ${input.action} expected rejection ${expectedError}, but it succeeded.\n`,
801
+ );
802
+ } else {
803
+ await writeOutput(input, output);
804
+ }
699
805
  } catch (error) {
700
- exitCode = 1;
701
- // Surface the failure; still tear down so a half-open WebSocket from a
702
- // failed write doesn't leave the process wedged open.
703
- process.stderr.write(`[core/perps] adapter failed: ${fmtError(error)}\n`);
806
+ const message = error?.message ?? String(error);
807
+ // A typed proof requires the code as data. Matching on the message was a
808
+ // weaker check wearing the same name: an adapter's own error, or any text
809
+ // that merely mentioned a code, could satisfy it. Only a rejection the
810
+ // action tagged with the controller's code counts, compared exactly — so a
811
+ // proof cannot pass on a failure the controller never issued.
812
+ const code = error?.perpsErrorCode;
813
+ if (expectedError && code === expectedError) {
814
+ await writeOutput(input, {
815
+ action: input.action,
816
+ source: 'expect_error',
817
+ rejected: true,
818
+ matched: true,
819
+ matchedOn: 'code',
820
+ expectedError,
821
+ actualErrorCode: code,
822
+ actualError: message,
823
+ });
824
+ } else {
825
+ exitCode = 1;
826
+ if (expectedError) {
827
+ process.stderr.write(
828
+ code === undefined
829
+ ? `[core/perps] ${input.action} expected rejection ${expectedError}, but the failure carries no controller error code — it did not come from the controller.\n`
830
+ : `[core/perps] ${input.action} expected rejection ${expectedError}, but the controller rejected with ${code}.\n`,
831
+ );
832
+ }
833
+ // Surface the failure; still tear down so a half-open WebSocket from a
834
+ // failed write doesn't leave the process wedged open.
835
+ process.stderr.write(`[core/perps] adapter failed: ${fmtError(error)}\n`);
836
+ }
704
837
  } finally {
705
838
  await disconnectController(exitCode);
706
839
  }