@deeeed/metamask-harness 0.87.0 → 0.88.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 (38) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/adapters/extension/ensure-browser.sh +6 -10
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +31 -4
  5. package/adapters/extension/profile-extension-state.cjs +156 -0
  6. package/adapters/extension/readiness.mjs +21 -2
  7. package/adapters/manifest.json +8 -0
  8. package/adapters/mobile/wait-for-bridge.cjs +9 -3
  9. package/dist/adapters/extension/actions.js +2 -1
  10. package/dist/adapters/extension/run.js +6 -0
  11. package/dist/adapters/mobile/android-screenrecord.js +10 -6
  12. package/dist/adapters/mobile/recording-outcome.js +2 -51
  13. package/dist/adapters/mobile/run.js +10 -4
  14. package/dist/adapters/perps-mainnet-cleanup.js +122 -0
  15. package/dist/engine.js +11 -2
  16. package/dist/funding-execution-context.js +13 -8
  17. package/dist/host.js +6 -2
  18. package/dist/index.js +3 -0
  19. package/dist/mm-harness-cli.js +4 -2
  20. package/dist/provider.js +72 -0
  21. package/dist/recipe-security.js +1 -0
  22. package/docs/CONTRIBUTING.md +7 -0
  23. package/docs/RECIPES.md +38 -13
  24. package/docs/RELEASE-QA-CAPABILITY-MAP.md +5 -1
  25. package/docs/cli-contract.md +1 -1
  26. package/library/actions/extension/perps/mutation-receipt.mjs +4 -3
  27. package/library/actions/extension/perps/perps.mjs +200 -9
  28. package/library/actions/extension/perps/set_network.mjs +6 -0
  29. package/library/actions/mobile/perps/perps.mjs +159 -11
  30. package/library/actions/mobile/perps/place_order.mjs +7 -4
  31. package/library/actions/shared/perps/mainnet-order-ledger.mjs +378 -0
  32. package/library/actions/shared/perps/order-policy.mjs +56 -19
  33. package/library/actions/shared/wallet/fixture-accounts.mjs +9 -3
  34. package/library/manifests/extension.action-manifest.json +34 -4
  35. package/library/manifests/mobile.action-manifest.json +18 -5
  36. package/package.json +14 -9
  37. package/scripts/validate-release-report.mjs +14 -2
  38. package/site/assets/help-recipes.json +1 -1
@@ -0,0 +1,122 @@
1
+ import {
2
+ VENUE_ORDER_FIELDS,
3
+ cleanupOwns,
4
+ clearMainnetSwitch,
5
+ ledgerOrderTargets,
6
+ mainnetOrderLeftOpenError,
7
+ markRunAborting,
8
+ readHyperliquidVenue,
9
+ readMainnetOrderLedger,
10
+ settleMainnetOrder
11
+ } from "../../library/actions/shared/perps/mainnet-order-ledger.mjs";
12
+ const PENDING_SETTLE_MS = 1e4;
13
+ async function platformPerps(platform) {
14
+ if (platform === "mobile") return import("../../library/actions/mobile/perps/perps.mjs");
15
+ if (platform === "extension") return import("../../library/actions/extension/perps/perps.mjs");
16
+ throw new Error(`no mainnet order cleanup for platform ${platform}`);
17
+ }
18
+ async function currentEntry(projectRoot, id) {
19
+ return (await readMainnetOrderLedger(projectRoot)).entries.find((entry) => entry.id === id);
20
+ }
21
+ async function venueRead(entry) {
22
+ const venue = await readHyperliquidVenue(entry.address, entry.symbol);
23
+ const open = ledgerOrderTargets(entry, venue.openOrders, VENUE_ORDER_FIELDS);
24
+ return { source: venue.source, open: open.map((order) => order.orderId), positionSize: venue.positionSize };
25
+ }
26
+ async function closeOut(projectRoot, env, via, id) {
27
+ const deadline = Date.now() + PENDING_SETTLE_MS;
28
+ for (; ; ) {
29
+ const entry = await currentEntry(projectRoot, id);
30
+ if (!entry) return;
31
+ const cancelInApp = async () => (await platformPerps(entry.platform)).cancelMainnetLedgerOrder(entry, { projectRoot, env });
32
+ let proof = entry.state === "cancelled" ? { canceled: entry.cancelProof.canceled, read: null } : await cancelInApp();
33
+ let venue = await venueRead(entry);
34
+ if (entry.state === "cancelled" && venue.open.length > 0) {
35
+ proof = await cancelInApp();
36
+ venue = await venueRead(entry);
37
+ }
38
+ for (let wait = 0; venue.open.length > 0 && proof.canceled.length > 0 && wait < 5; wait += 1) {
39
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
40
+ venue = await venueRead(entry);
41
+ }
42
+ if (venue.open.length > 0) {
43
+ throw new Error(`the venue still lists order(s) ${venue.open.join(", ")} after the app ${proof.read?.notOpen ? "reported none open" : "cancelled"}`);
44
+ }
45
+ if (proof.read?.notOpen && entry.state === "pending" && Date.now() < deadline) {
46
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
47
+ continue;
48
+ }
49
+ const recorded = { via: entry.cancelProof?.via ?? via, canceled: proof.canceled, app: proof.read, venue };
50
+ const evidencePath = await settleMainnetOrder(projectRoot, entry, recorded);
51
+ process.stderr.write(
52
+ `mainnet ${entry.symbol} order ${entry.orderId ?? "(pending)"}: ${proof.canceled.length > 0 ? "cancelled" : "not open"} at run ${via}, gone at the venue; proof in ${evidencePath}
53
+ `
54
+ );
55
+ if (venue.positionSize !== Number(entry.baselinePositionSize ?? 0)) {
56
+ throw new Error(`the ${entry.symbol} position is ${venue.positionSize}, was ${entry.baselinePositionSize ?? 0} before the order: it filled; close that position now`);
57
+ }
58
+ return;
59
+ }
60
+ }
61
+ async function cleanup(projectRoot, env, via) {
62
+ const ledger = await readMainnetOrderLedger(projectRoot);
63
+ const failures = [];
64
+ for (const entry of ledger.entries.filter((record) => cleanupOwns(record, process.pid))) {
65
+ try {
66
+ await closeOut(projectRoot, env, via, entry.id);
67
+ } catch (error) {
68
+ failures.push({ entry, error });
69
+ }
70
+ }
71
+ if (failures.length > 0) throw mainnetOrderLeftOpenError(failures);
72
+ if (ledger.networkSwitch && cleanupOwns(ledger.networkSwitch, process.pid)) {
73
+ const { restoreTestnetAfterRun } = await import("../../library/actions/extension/perps/perps.mjs");
74
+ await restoreTestnetAfterRun(ledger.networkSwitch, { projectRoot });
75
+ await clearMainnetSwitch(projectRoot);
76
+ process.stderr.write(`Perps network switched back to testnet at run ${via}.
77
+ `);
78
+ }
79
+ }
80
+ let inFlight;
81
+ function cancelRunMainnetOrders(projectRoot, env, via) {
82
+ inFlight ??= cleanup(projectRoot, env, via).finally(() => {
83
+ inFlight = void 0;
84
+ });
85
+ return inFlight;
86
+ }
87
+ const ABORT_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
88
+ async function abortRunMainnetOrders(projectRoot) {
89
+ await markRunAborting(projectRoot, process.pid);
90
+ await cancelRunMainnetOrders(projectRoot, process.env, "abort");
91
+ }
92
+ async function withMainnetOrderAbort(projectRoot, run) {
93
+ let aborting;
94
+ const remove = () => {
95
+ for (const signal of ABORT_SIGNALS) process.off(signal, onSignal);
96
+ };
97
+ function onSignal(signal) {
98
+ if (aborting) return;
99
+ aborting = abortRunMainnetOrders(projectRoot).then(
100
+ () => void 0,
101
+ (error) => {
102
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}
103
+ `);
104
+ }
105
+ ).then(() => {
106
+ remove();
107
+ process.kill(process.pid, signal);
108
+ });
109
+ }
110
+ for (const signal of ABORT_SIGNALS) process.on(signal, onSignal);
111
+ try {
112
+ return await run();
113
+ } finally {
114
+ if (aborting) await aborting;
115
+ else remove();
116
+ }
117
+ }
118
+ export {
119
+ abortRunMainnetOrders,
120
+ cancelRunMainnetOrders,
121
+ withMainnetOrderAbort
122
+ };
package/dist/engine.js CHANGED
@@ -6,11 +6,15 @@ import {
6
6
  fixturePolicyMutationBase,
7
7
  fundingOptionsFromCli,
8
8
  isFixturePolicyBase,
9
- loadTrustedMutationBase
9
+ loadTrustedMutationBase,
10
+ validateTrustedMutationPlan
10
11
  } from "./funding-execution-context.js";
11
12
  import { resolveActionManifest, validateManifest } from "./manifest.js";
12
13
  import { LIBRARY_ACTION_NAMESPACE, runnerDir } from "./paths.js";
13
14
  import { metaMaskActionExecutionCapabilities } from "./recipe-security.js";
15
+ async function loadEngineMutation({ cli, adapter, ...run }) {
16
+ return await loadTrustedMutationBase({ ...fundingOptionsFromCli(cli), ...run }) ?? (getAdapterSurface(adapter).actions.trustedMutation === true ? fixturePolicyMutationBase({ artifactsDir: run.artifactsDir, rootRecipe: run.rootRecipe }) : void 0);
17
+ }
14
18
  const metaMaskEngine = {
15
19
  bundledLibrary: {
16
20
  name: "metamask",
@@ -24,12 +28,17 @@ const metaMaskEngine = {
24
28
  const { createMetaMaskRunner } = await import("./runner.js");
25
29
  return createMetaMaskRunner(adapter, manifest, options);
26
30
  },
31
+ // Preflight checks policy; execution also claims the plan and checks claim conflicts.
27
32
  // Funding files from the run's command line, bound to the run's plan. Without
28
33
  // them, a platform whose actions take a trusted mutation (the Extension) runs a
29
34
  // plan with external mutations in the fixture-policy lane, where each action
30
35
  // applies the shared order policy; any other plan runs without one.
31
36
  trustedMutation: {
32
- load: async ({ cli, adapter, ...run }) => await loadTrustedMutationBase({ ...fundingOptionsFromCli(cli), ...run }) ?? (getAdapterSurface(adapter).actions.trustedMutation === true ? fixturePolicyMutationBase({ artifactsDir: run.artifactsDir, rootRecipe: run.rootRecipe }) : void 0),
37
+ preflight: async (input, plan) => {
38
+ const base = await loadEngineMutation(input);
39
+ if (base) validateTrustedMutationPlan(base, plan);
40
+ },
41
+ load: loadEngineMutation,
33
42
  // Nothing to bind (undefined): recipe-cli keeps the runner it planned with.
34
43
  authorize: async (base, plan) => {
35
44
  if (!base) return void 0;
@@ -181,10 +181,9 @@ function fixturePolicyMutationBase(options) {
181
181
  function isFixturePolicyBase(base) {
182
182
  return base?.proofLane === FIXTURE_POLICY_LANE;
183
183
  }
184
- async function authorizeFixturePolicyPlan(base, plan) {
184
+ async function authorizeFixturePolicyPlan(base, executionPlanSha256) {
185
185
  const source = fixturePolicySources.get(base);
186
186
  if (!source) throw new Error("Fixture-policy plan authorization requires the runner-created context.");
187
- const executionPlanSha256 = normalizeRecipeDigest(plan.digest);
188
187
  await mkdir(source.artifactsDir, { recursive: true, mode: 448 });
189
188
  const artifactsDir = await realpath(source.artifactsDir);
190
189
  const artifactsIdentitySha256 = await directoryIdentity(artifactsDir);
@@ -204,11 +203,9 @@ async function authorizeFixturePolicyPlan(base, plan) {
204
203
  });
205
204
  return authorized;
206
205
  }
207
- async function authorizeTrustedMutationPlan(base, plan, testIo = {}) {
208
- if (isFixturePolicyBase(base)) return authorizeFixturePolicyPlan(base, plan);
209
- const source = fundingSources.get(base);
210
- if (!source) throw new Error("Funding plan authorization requires the original runner-verified context.");
206
+ function validateTrustedMutationPlan(base, plan) {
211
207
  const executionPlanSha256 = normalizeRecipeDigest(plan.digest);
208
+ if (isFixturePolicyBase(base)) return { executionPlanSha256, operationCounts: [] };
212
209
  const nodes = Array.isArray(plan.nodes) ? plan.nodes : [];
213
210
  const actual = /* @__PURE__ */ new Map();
214
211
  for (const node of nodes) {
@@ -235,9 +232,16 @@ async function authorizeTrustedMutationPlan(base, plan, testIo = {}) {
235
232
  }
236
233
  }
237
234
  if (actual.size === 0) throw new Error("Funding documents require a resolved external-mutation plan.");
235
+ const operationCounts = [...expected.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([action, count]) => ({ action, count }));
236
+ return { executionPlanSha256, operationCounts };
237
+ }
238
+ async function authorizeTrustedMutationPlan(base, plan, testIo = {}) {
239
+ const { executionPlanSha256, operationCounts } = validateTrustedMutationPlan(base, plan);
240
+ if (isFixturePolicyBase(base)) return authorizeFixturePolicyPlan(base, executionPlanSha256);
241
+ const source = fundingSources.get(base);
242
+ if (!source) throw new Error("Funding plan authorization requires the original runner-verified context.");
238
243
  await mkdir(source.artifactsDir, { recursive: true, mode: 448 });
239
244
  const artifactsIdentitySha256 = await directoryIdentity(source.artifactsDir);
240
- const operationCounts = [...expected.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([action, count]) => ({ action, count }));
241
245
  const runId = sha256(canonical({
242
246
  requestSha256: base.requestSha256,
243
247
  preflightSha256: base.preflightSha256,
@@ -1529,5 +1533,6 @@ export {
1529
1533
  selectedIdentityFromWalletImport,
1530
1534
  serializeTrustedMutationContext,
1531
1535
  trustedMutationContextExtras,
1532
- trustedMutationForAction
1536
+ trustedMutationForAction,
1537
+ validateTrustedMutationPlan
1533
1538
  };
package/dist/host.js CHANGED
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { configureHarnessAdapters, configureHarnessHost } from "@farmslot/recipe-cli/harness";
4
4
  import { adapterRegistry } from "./adapters/registry.js";
5
5
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
6
- configureHarnessHost({
6
+ const providerHost = {
7
7
  name: "mm-harness",
8
8
  product: "MetaMask",
9
9
  envPrefix: "MM_HARNESS",
@@ -28,5 +28,9 @@ configureHarnessHost({
28
28
  "stop",
29
29
  "verify"
30
30
  ]
31
- });
31
+ };
32
+ configureHarnessHost(providerHost);
32
33
  configureHarnessAdapters(adapterRegistry);
34
+ export {
35
+ providerHost
36
+ };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import "./host.js";
2
+ import { createProvider, providerHost } from "./provider.js";
2
3
  import { createDoctorReport } from "./doctor.js";
3
4
  import { loadActionManifest, validateManifest } from "./manifest.js";
4
5
  import { createMetaMaskExtensionRunner, createMetaMaskMobileRunner, createMetaMaskRunner } from "./runner.js";
@@ -13,10 +14,12 @@ export {
13
14
  createMetaMaskExtensionRunner,
14
15
  createMetaMaskMobileRunner,
15
16
  createMetaMaskRunner,
17
+ createProvider,
16
18
  extensionIdPath,
17
19
  importRecipeHarnessRuntimeBrowserExtension,
18
20
  importRecipeHarnessRuntimeCdp,
19
21
  loadActionManifest,
22
+ providerHost,
20
23
  validateManifest,
21
24
  walletFixturePath
22
25
  };
@@ -22,6 +22,7 @@ import {
22
22
  } from "@farmslot/recipe-cli/harness";
23
23
  import { stopProjectCollector } from "../library/actions/shared/analytics/collector.mjs";
24
24
  import { adoptPluginAdapter, afterAdapterLoad, configuredLibrarySources } from "./adapters/plugin-surface.js";
25
+ import { withMainnetOrderAbort } from "./adapters/perps-mainnet-cleanup.js";
25
26
  import { adapterRegistry } from "./adapters/registry.js";
26
27
  import {
27
28
  explainMissingAdapterLibrary,
@@ -155,8 +156,9 @@ const PUBLIC_RUN = {
155
156
  "execution-template": { run: handleExecutionTemplate },
156
157
  actions: { run: (args) => handleActions(parseArgs(args), { catalog: metaMaskEngine }) },
157
158
  stop: { run: (args) => handleStop(args, stopOptions) },
158
- call: { run: (args) => handleCall(args, callOptions) },
159
- run: { run: (args) => handleRun(args, runOptions) },
159
+ // A signal during run or call still cancels the run's mainnet orders.
160
+ call: { run: (args) => withMainnetOrderAbort(targetPath(parseArgs(args).options), () => handleCall(args, callOptions)) },
161
+ run: { run: (args) => withMainnetOrderAbort(targetPath(parseArgs(args).options), () => handleRun(args, runOptions)) },
160
162
  last: { run: (args) => handleLast(parseArgs(args)) },
161
163
  doctor: { run: (args) => handleDoctor(parseArgs(args)) },
162
164
  check: { run: handleCheck },
@@ -0,0 +1,72 @@
1
+ import "./adapters/surface.js";
2
+ import {
3
+ configureHarnessAdapters,
4
+ adapterForPlatform,
5
+ applyRuntimeDirOption,
6
+ configureHarnessHost,
7
+ ensureAdapterLoaded,
8
+ optionFlag,
9
+ optionStrings,
10
+ usageError
11
+ } from "@farmslot/recipe-cli/harness";
12
+ import { MAINNET_RUN_OWNER_ENV } from "../library/actions/shared/perps/mainnet-order-ledger.mjs";
13
+ import { adoptPluginAdapter, afterAdapterLoad, configuredLibrarySources, operatorLibraryEnv } from "./adapters/plugin-surface.js";
14
+ import { abortRunMainnetOrders, cancelRunMainnetOrders } from "./adapters/perps-mainnet-cleanup.js";
15
+ import { adapterRegistry } from "./adapters/registry.js";
16
+ import { getAdapterSurface } from "./adapters/surface.js";
17
+ import { applyDeviceTargeting } from "./commands/device-target.js";
18
+ import { metaMaskEngine } from "./engine.js";
19
+ import { providerHost } from "./host.js";
20
+ import { providerHost as providerHost2 } from "./host.js";
21
+ async function createProvider(context) {
22
+ configureHarnessHost(context.host ?? providerHost);
23
+ configureHarnessAdapters(adapterRegistry);
24
+ const selection = context.adapter?.value;
25
+ if (!selection) throw usageError("The MetaMask provider requires a resolved adapter.");
26
+ const adapter = adapterForPlatform(selection) ?? selection;
27
+ const options = {
28
+ ...context.options,
29
+ ...!context.options?.platform && (selection === "ios" || selection === "android") ? { platform: selection } : {}
30
+ };
31
+ await ensureAdapterLoaded(adapter, {
32
+ libraries: optionStrings(options, "library"),
33
+ configured: context.libraries ?? configuredLibrarySources(),
34
+ env: operatorLibraryEnv(),
35
+ adopt: adoptPluginAdapter
36
+ });
37
+ await afterAdapterLoad();
38
+ const runtime = getAdapterSurface(adapter);
39
+ const executes = (context.command === "run" || context.command === "call") && !["plan", "list", "describe", "help"].some((flag) => optionFlag(options, flag));
40
+ if (executes) {
41
+ applyRuntimeDirOption(options);
42
+ runtime.resolveSlotPorts(context.target.value);
43
+ const selected = applyDeviceTargeting(context.command, adapter, {
44
+ ...options,
45
+ target: context.target.value
46
+ }, { gate: true, rerun: "" });
47
+ if (selected.ok === false) throw Object.assign(usageError(selected.message), { code: selected.code, userAction: selected.userAction });
48
+ }
49
+ const previousOwner = process.env[MAINNET_RUN_OWNER_ENV];
50
+ if (executes) process.env[MAINNET_RUN_OWNER_ENV] = String(process.pid);
51
+ return {
52
+ runtime,
53
+ engine: metaMaskEngine,
54
+ ...executes ? {
55
+ async cancel() {
56
+ await abortRunMainnetOrders(context.target.value);
57
+ },
58
+ async finalize() {
59
+ try {
60
+ await cancelRunMainnetOrders(context.target.value, process.env, "teardown");
61
+ } finally {
62
+ if (previousOwner === void 0) delete process.env[MAINNET_RUN_OWNER_ENV];
63
+ else process.env[MAINNET_RUN_OWNER_ENV] = previousOwner;
64
+ }
65
+ }
66
+ } : {}
67
+ };
68
+ }
69
+ export {
70
+ createProvider,
71
+ providerHost2 as providerHost
72
+ };
@@ -76,6 +76,7 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
76
76
  "metamask.perps.open_balance_action",
77
77
  "metamask.perps.assert_visible_consistency",
78
78
  "metamask.perps.start_state",
79
+ "metamask.perps.set_network",
79
80
  "metamask.perps.teardown_state",
80
81
  // Consent changes wallet state and is therefore an app mutation.
81
82
  "metamask.analytics.set_consent",
@@ -199,6 +199,13 @@ when packed, so `agent-runtime` and `adapter-rn` can retain older copies until
199
199
  republished. Verify the complete install with
200
200
  `bash tests/contract/packaging-dist-install.test.sh` before releasing the harness.
201
201
 
202
+ `yarn install` hardlinks every `node_modules` file to Yarn's store
203
+ (`~/.yarn/berry/store`), shared by all worktrees. Never edit an installed
204
+ dependency in place: a write into an existing file reaches the store and every
205
+ other worktree, and a reinstall does not undo it. Use `yarn dev:link-farmslot`
206
+ for local Farmslot builds. To repair a package, remove `node_modules/<package>`
207
+ in one worktree and run `yarn install`.
208
+
202
209
  Docs describe only current behavior. History belongs in the changelog and Git;
203
210
  do not add handovers, audits, speculative roadmaps, or duplicate command guides
204
211
  under `docs/`.
package/docs/RECIPES.md CHANGED
@@ -261,12 +261,34 @@ Perps orders follow one rule on Mobile, Extension and Core
261
261
  (`library/actions/shared/perps/order-policy.mjs`), checked before dispatch:
262
262
 
263
263
  - Testnet: allowed with no funding proof.
264
- - Mainnet: only from a synced fixture account (`wallet-fixture.json`), as a 1x
265
- isolated market order with `close_immediately: true`, and the node must
266
- declare `network: mainnet`. The $15 limit bounds the size the controller would
267
- submit: (amount + one size step × price) × (1 + max slippage). Anything else
268
- is refused with `PERPS_MAINNET_ORDER_REFUSED`, naming each broken limit, and
269
- no order is sent.
264
+ - Mainnet: only from a synced fixture account (`wallet-fixture.json`), 1x
265
+ isolated, and the node must declare `network: mainnet`. Either a market order
266
+ with `close_immediately: true`, where the $15 limit bounds the size the
267
+ controller would submit: (amount + one size step × price) × (1 + max
268
+ slippage); or, on Mobile and Extension `place_order`, a `dev1` Hyperliquid
269
+ limit order away from the live market price, sent post-only and bounded at its limit
270
+ price: amount + one size step × limit price. Mainnet orders take no
271
+ `current_price`. Anything else is refused with `PERPS_MAINNET_ORDER_REFUSED`,
272
+ naming each broken limit, and no order is sent: more than 1x or $15, a limit
273
+ that would fill now, another account, Core, and `ensure_orders` or
274
+ `ensure_positions` opening state.
275
+ - A mainnet limit order lasts one run. It is tracked before dispatch in
276
+ `temp/recipe/runtime/perps-mainnet-orders.json`, and the run cancels whatever
277
+ is still open when it ends (success, failure, or abort by signal; after a
278
+ kill, the next run on the checkout). The order counts as gone only when the
279
+ Hyperliquid API, read for the placing address, no longer lists it. Each
280
+ order's ID, cancel result and both reads (app and venue) land in
281
+ `<artifacts>/perps-mainnet-orders.json`. An order
282
+ that cannot be proven cancelled, or that filled (the market's position moved),
283
+ fails the run with `PERPS_MAINNET_ORDER_LEFT_OPEN`; a fill is reported, never
284
+ closed for you. On mainnet, `close_orders` cancels only orders the run placed,
285
+ and leaving mainnet while the run holds one is refused
286
+ (`PERPS_MAINNET_ORDER_OPEN`).
287
+ - Switching to mainnet: Mobile `metamask.perps.start_state network=mainnet`;
288
+ Extension `metamask.perps.set_network network=mainnet`, which the run switches
289
+ back to testnet at its end (the fixture loads testnet). Mobile stays on
290
+ mainnet after the run: switch back with `start_state network=testnet` once
291
+ the run's orders are settled.
270
292
  - `close_immediately: true` needs a market order from a flat market: no
271
293
  position and no resting order in it. Positions are netted per market, so the
272
294
  close takes the market's whole position; don't share a fixture account's
@@ -342,9 +364,11 @@ rather than refuse. Extension `ensure_*` and `teardown_state` only assert, so
342
364
  converge there through an explicitly funded leaf action. Never broaden cleanup
343
365
  onto an account or market the task does not own; obtain an owned one instead
344
366
  of blocking. On mainnet, orders follow the Perps order rule above, Core needs
345
- `CORE_PERPS_ALLOW_MAINNET_WRITES=1` for any write, Mobile cleanup acts on
346
- whatever network the app is on, and Extension refuses mainnet writes other than
347
- orders and `close_visible_position` in the fixture-policy lane.
367
+ `CORE_PERPS_ALLOW_MAINNET_WRITES=1` for any write, Mobile `close_positions`
368
+ acts on whatever network the app is on, `close_orders` there cancels only the
369
+ run's own orders, and Extension refuses mainnet writes other than orders,
370
+ `close_visible_position` and cancelling the run's own orders in the
371
+ fixture-policy lane.
348
372
 
349
373
  ### Mobile advanced-order setup
350
374
 
@@ -445,10 +469,11 @@ A recording that fails fails the run: the `record.video` trace entry starts
445
469
  with `RECORDING_FAILED` or `RECORDER_INTERRUPTED`, names the cause, and says the
446
470
  run has no video. When capture stopped mid-run but a finalized partial file was
447
471
  kept (capture-helper 0.3.1's `stream_interrupted`, or the screenrecord files
448
- finished before a failure), the video is published and its manifest entry's
449
- `recorder.interrupted` gives the code, the cause and the video time up to the
450
- last captured frame (`coveredMs`). `mm-harness doctor` shows the recorder for
451
- the selected device and is not ready while it cannot record.
472
+ finished before a failure), the video is published, its manifest entry's
473
+ `interruption` gives the frames, the video time up to the last captured frame
474
+ (`mediaTimeMs`) and the cause, and the run fails as `CAPTURE_INTERRUPTED`, a
475
+ capture (environment) failure, not a product one. `mm-harness doctor` shows the
476
+ recorder for the selected device and is not ready while it cannot record.
452
477
 
453
478
  ### Mobile setup and command budgets
454
479
 
@@ -63,7 +63,11 @@ outside the declared root, incomplete run directories, and any non-empty
63
63
 
64
64
  Supported verdicts are `PASS`, `FAIL`, `BLOCKED_FUNDS`, `BLOCKED_COST`, and
65
65
  `BLOCKED_ENVIRONMENT`. Product failures must remain failures; infrastructure,
66
- funding, and cost limits must not be reported as passes.
66
+ funding, and cost limits must not be reported as passes. If every run cited by a
67
+ case failed only on `CAPTURE_INTERRUPTED`, the gate requires `BLOCKED_ENVIRONMENT`
68
+ with "evidence incomplete (capture interrupted)" in its summary and the partial
69
+ video in its run directory. Retain interrupted attempts alongside the retry's
70
+ evidence. A passing retry can be `PASS`; a product-failing retry remains `FAIL`.
67
71
 
68
72
  ## Capability matrix
69
73
 
@@ -146,7 +146,7 @@ The goldens need the harness to be a git checkout: `execution-provenance.json` `
146
146
  2. UUIDs become `<UUID>`. Git object ids (40 hex, or a short hex value under a `*head`/`*gitRef`/`*commit` key) become `<GIT_REF>`. ISO-8601 timestamps become `<TIMESTAMP>`, and compact stamps in names `<STAMP>`. Path-derived slot ids `local-<adapter>-<8 hex>` become `local-<adapter>-<HASH>`.
147
147
  3. The package version becomes `<HARNESS_VERSION>`. Any other `x.y.z` under a key ending in `version` becomes `<VERSION>`.
148
148
  4. Numbers under volatile keys: `*pid` becomes `<PID>`, `inode` `<INODE>`, `*port` `<PORT>`, `*At`/`*Time`/`*timestamp`/`mtime*` `<TIMESTAMP>`, and `*Ms`/`duration*`/`elapsed*` `<DURATION>`. Values inside `schema` and `examples` subtrees (static manifest content) are never rewritten.
149
- 5. `invocationDigest` becomes `<DIGEST>`: it hashes `recipe-invocation.json`, which carries sandbox paths and timestamps and is frozen itself in normalised form. `runner.sourceFingerprint` becomes `<FINGERPRINT>`: it hashes the harness source tree, so any harness edit changes it. A `status` holding `git status --porcelain` output (provenance `runner.status` and `libraries[].status`, empty when clean) becomes `<GIT_STATUS>`. A loaded adapter plugin's code digest in doctor's `adapter-plugin:<id>` message becomes `sha256:<PLUGIN_DIGEST>`: it hashes the recipe library's plugin files, so any library edit changes it. Content-only digests (recipe and dependency digests) stay frozen.
149
+ 5. `invocationDigest` becomes `<DIGEST>`: it hashes `recipe-invocation.json`, which carries sandbox paths and timestamps and is frozen itself in normalised form. `runner.sourceFingerprint` becomes `<FINGERPRINT>`: it hashes the harness source tree, so any harness edit changes it. A `status` holding `git status --porcelain` output (provenance `runner.status` and `libraries[].status`, empty when clean) becomes `<GIT_STATUS>`. A loaded adapter plugin's code digest in doctor's `adapter-plugin:<id>` message becomes `sha256:<PLUGIN_DIGEST>`: it hashes the recipe library's plugin files, so any library edit changes it. Within `executionPlan`, SHA-256 `digest` and `executionContextDigest` values become `sha256:<DIGEST>` because plans bind sandbox paths, environment and loaded source bytes. `schema` and `examples` remain exempt. Other content-only digests (recipe and dependency digests) stay frozen.
150
150
  6. Durations in text (`0.3s`, `123ms`) become `<DURATION>`.
151
151
 
152
152
  In tree listings, files whose names differ only by a volatile token collapse into one row: a count and the union of their key shapes, or the sorted list of their values in a value tree.
@@ -228,8 +228,9 @@ export function inFixturePolicyLane(input) {
228
228
  * non-order mutation (allowed on testnet; on mainnet only a close from a fixture account).
229
229
  * @returns {{ input, network, policy }} input with the completed context.
230
230
  */
231
- export async function withFixturePolicyContext(input, page, { operations, order = null, mainnetAllowed = false }) {
232
- // mainnetAllowed: a non-order mutation (a close) a fixture account may run on mainnet.
231
+ export async function withFixturePolicyContext(input, page, { operations, order = null, mainnetAllowed = false, mainnetRefusal = null }) {
232
+ // mainnetAllowed: a non-order mutation (a close, or a cancel of an order this
233
+ // run placed) a fixture account may run on mainnet; mainnetRefusal says why not.
233
234
  if (!inFixturePolicyLane(input)) return { input, network: null, policy: null };
234
235
  const state = await page.evaluate(readPerpsRuntimeStateExpression());
235
236
  if (!state?.available || !state?.networkKnown) {
@@ -246,7 +247,7 @@ export async function withFixturePolicyContext(input, page, { operations, order
246
247
  if (!order && network === 'mainnet'
247
248
  && (!mainnetAllowed || !(await isFixtureAccount(projectRoot, address)))) {
248
249
  throw Object.assign(
249
- new Error(`PERPS_MAINNET_ORDER_REFUSED: ${input.action} on mainnet needs the funding-file release lane${mainnetAllowed ? ' or a synced test account' : ''}; refused before dispatch.`),
250
+ new Error(`PERPS_MAINNET_ORDER_REFUSED: ${input.action} on mainnet ${mainnetRefusal && !mainnetAllowed ? `${mainnetRefusal} without funding files` : `needs the funding-file release lane${mainnetAllowed ? ' or a synced test account' : ''}`}; refused before dispatch.`),
250
251
  { code: 'PERPS_MAINNET_ORDER_REFUSED' },
251
252
  );
252
253
  }