@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.
- package/CHANGELOG.md +44 -0
- package/adapters/extension/live.sh +2 -2
- package/adapters/extension/wallet-fixture-state.cjs +57 -19
- package/adapters/shared/ensure-runner-deps.sh +64 -5
- package/bin/mm-harness +26 -8
- package/dist/adapters/extension/runtime.js +1 -10
- package/dist/adapters.js +5 -1
- package/dist/commands/run-engine.js +18 -13
- package/dist/commands/runtime-launch.js +7 -2
- package/dist/live-adapter-contract.js +4 -2
- package/dist/recipe-security.js +10 -2
- package/dist/run-recording.js +156 -39
- package/library/actions/core/perps/_controller.mjs +138 -5
- package/library/actions/core/perps/assert_orders.mjs +174 -10
- package/library/actions/core/perps/assert_positions.mjs +20 -2
- package/library/actions/core/perps/close_orders.mjs +24 -5
- package/library/actions/core/perps/close_positions.mjs +22 -8
- package/library/actions/core/perps/edit_order.mjs +331 -0
- package/library/actions/core/perps/place_order.mjs +192 -43
- package/library/actions/core/perps/update_position_tpsl.mjs +121 -15
- package/library/actions/extension/analytics/set_consent.mjs +165 -0
- package/library/actions/extension/platform/cdp.mjs +112 -14
- package/library/actions/mobile/analytics/set_consent.mjs +90 -0
- package/library/actions/shared/analytics/_adapter.mjs +24 -0
- package/library/actions/shared/analytics/assert_events.mjs +168 -0
- package/library/actions/shared/analytics/collector.mjs +505 -0
- package/library/actions/shared/analytics/consent.mjs +14 -0
- package/library/actions/shared/analytics/read_events.mjs +22 -0
- package/library/actions/shared/analytics/start_capture.mjs +24 -0
- package/library/manifests/core.action-manifest.json +468 -27
- package/library/manifests/extension.action-manifest.json +188 -1
- package/library/manifests/mobile.action-manifest.json +161 -0
- package/package.json +3 -3
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
configuredSymbols,
|
|
3
|
+
controllerRejection,
|
|
3
4
|
getCoreControllerWithSigner,
|
|
4
5
|
isDirectRun,
|
|
6
|
+
optionalParam,
|
|
5
7
|
redactOrder,
|
|
6
8
|
requireExplicitSelection,
|
|
7
9
|
runAdapter,
|
|
@@ -22,11 +24,6 @@ import {
|
|
|
22
24
|
// whole position. After submitting we re-read live open orders so the resulting
|
|
23
25
|
// trigger orders — including their partial sizes — are visible as evidence.
|
|
24
26
|
|
|
25
|
-
function optionalString(node, snake, camel) {
|
|
26
|
-
const value = node?.[snake] ?? node?.[camel];
|
|
27
|
-
return value === undefined || value === null ? undefined : String(value);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
27
|
function numericValuesMatch(actual, expected) {
|
|
31
28
|
const actualNumber = Number(actual);
|
|
32
29
|
const expectedNumber = Number(expected);
|
|
@@ -94,12 +91,21 @@ export async function updatePositionTpsl(input) {
|
|
|
94
91
|
const symbol = symbols[0];
|
|
95
92
|
|
|
96
93
|
const node = input.node ?? {};
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
let takeProfitPrice = optionalParam(node, 'take_profit_price', 'takeProfitPrice');
|
|
95
|
+
let stopLossPrice = optionalParam(node, 'stop_loss_price', 'stopLossPrice');
|
|
96
|
+
let takeProfitSize = optionalParam(node, 'take_profit_size', 'takeProfitSize');
|
|
97
|
+
let stopLossSize = optionalParam(node, 'stop_loss_size', 'stopLossSize');
|
|
98
|
+
const takeProfitOffsetPct = optionalParam(node, 'take_profit_offset_pct', 'takeProfitOffsetPct');
|
|
99
|
+
const stopLossOffsetPct = optionalParam(node, 'stop_loss_offset_pct', 'stopLossOffsetPct');
|
|
100
|
+
const takeProfitFraction = optionalParam(node, 'take_profit_fraction', 'takeProfitFraction');
|
|
101
|
+
const stopLossFraction = optionalParam(node, 'stop_loss_fraction', 'stopLossFraction');
|
|
101
102
|
|
|
102
|
-
if (
|
|
103
|
+
if (
|
|
104
|
+
takeProfitPrice === undefined &&
|
|
105
|
+
stopLossPrice === undefined &&
|
|
106
|
+
takeProfitOffsetPct === undefined &&
|
|
107
|
+
stopLossOffsetPct === undefined
|
|
108
|
+
) {
|
|
103
109
|
throw new Error(
|
|
104
110
|
'metamask.perps.update_position_tpsl requires take_profit_price and/or stop_loss_price (omit both only to clear, which is not supported here).',
|
|
105
111
|
);
|
|
@@ -120,6 +126,93 @@ export async function updatePositionTpsl(input) {
|
|
|
120
126
|
);
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
// Prices and sizes are resolved from live mid and the actual position, so a
|
|
130
|
+
// recipe never has to name a number that only holds for one market at one
|
|
131
|
+
// moment. An absolute value still wins when the caller gives one.
|
|
132
|
+
// Both derivations below need the market, and each used to fetch it
|
|
133
|
+
// separately. getMarketDataWithPrices reads every market, so doing it twice in
|
|
134
|
+
// one adapter is what pushed this past the node timeout.
|
|
135
|
+
let market;
|
|
136
|
+
const readMarket = async () => {
|
|
137
|
+
if (market === undefined) {
|
|
138
|
+
const markets = await controller.getMarketDataWithPrices({
|
|
139
|
+
standalone: true,
|
|
140
|
+
});
|
|
141
|
+
market =
|
|
142
|
+
(Array.isArray(markets) ? markets : []).find(
|
|
143
|
+
(item) => (item?.symbol ?? '').toUpperCase() === symbol.toUpperCase(),
|
|
144
|
+
) ?? null;
|
|
145
|
+
}
|
|
146
|
+
return market;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (takeProfitOffsetPct !== undefined || stopLossOffsetPct !== undefined) {
|
|
150
|
+
// PerpsMarketData.price is a formatted string like '$103,245.00'.
|
|
151
|
+
const mid = Number(
|
|
152
|
+
String((await readMarket())?.price ?? '').replace(/[$,\s]/gu, ''),
|
|
153
|
+
);
|
|
154
|
+
if (!Number.isFinite(mid) || mid <= 0) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`metamask.perps.update_position_tpsl found no usable mid for ${symbol}.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const fromOffset = (raw, name) => {
|
|
160
|
+
const pct = Number(raw);
|
|
161
|
+
if (!Number.isFinite(pct)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`metamask.perps.update_position_tpsl received invalid ${name}: ${raw}.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const price = mid * (1 + pct / 100);
|
|
167
|
+
if (!Number.isFinite(price) || price <= 0) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`metamask.perps.update_position_tpsl computed a non-positive price from ${name}=${raw}.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return String(price);
|
|
173
|
+
};
|
|
174
|
+
if (takeProfitPrice === undefined && takeProfitOffsetPct !== undefined) {
|
|
175
|
+
takeProfitPrice = fromOffset(takeProfitOffsetPct, 'take_profit_offset_pct');
|
|
176
|
+
}
|
|
177
|
+
if (stopLossPrice === undefined && stopLossOffsetPct !== undefined) {
|
|
178
|
+
stopLossPrice = fromOffset(stopLossOffsetPct, 'stop_loss_offset_pct');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (takeProfitFraction !== undefined || stopLossFraction !== undefined) {
|
|
183
|
+
const absolutePositionSize = Math.abs(Number(position.size ?? position.szi ?? 0));
|
|
184
|
+
// Round to the market's own size precision before submitting. The venue
|
|
185
|
+
// rounds anyway, and the read-back below matches on size — so an unrounded
|
|
186
|
+
// fraction asks for a size that can never come back, and the poll spins
|
|
187
|
+
// until it times out.
|
|
188
|
+
const szDecimals = Number((await readMarket())?.szDecimals);
|
|
189
|
+
const fromFraction = (raw, name) => {
|
|
190
|
+
const fraction = Number(raw);
|
|
191
|
+
if (!Number.isFinite(fraction) || fraction <= 0 || fraction > 1) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`metamask.perps.update_position_tpsl received invalid ${name}: ${raw} (expected a fraction in (0, 1]).`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const exact = absolutePositionSize * fraction;
|
|
197
|
+
if (!Number.isFinite(szDecimals)) {
|
|
198
|
+
return String(exact);
|
|
199
|
+
}
|
|
200
|
+
const rounded = Number(exact.toFixed(szDecimals));
|
|
201
|
+
if (rounded <= 0) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`metamask.perps.update_position_tpsl: ${name}=${raw} of a ${absolutePositionSize} position rounds to zero at ${szDecimals} size decimals; use a larger position or a larger fraction.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return String(rounded);
|
|
207
|
+
};
|
|
208
|
+
if (takeProfitSize === undefined && takeProfitFraction !== undefined) {
|
|
209
|
+
takeProfitSize = fromFraction(takeProfitFraction, 'take_profit_fraction');
|
|
210
|
+
}
|
|
211
|
+
if (stopLossSize === undefined && stopLossFraction !== undefined) {
|
|
212
|
+
stopLossSize = fromFraction(stopLossFraction, 'stop_loss_fraction');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
123
216
|
const params = {
|
|
124
217
|
symbol,
|
|
125
218
|
...(takeProfitPrice === undefined ? {} : { takeProfitPrice }),
|
|
@@ -130,9 +223,11 @@ export async function updatePositionTpsl(input) {
|
|
|
130
223
|
|
|
131
224
|
const result = await controller.updatePositionTPSL(params);
|
|
132
225
|
if (!result || result.success !== true) {
|
|
133
|
-
throw
|
|
134
|
-
|
|
135
|
-
|
|
226
|
+
throw controllerRejection({
|
|
227
|
+
action: 'core updatePositionTPSL',
|
|
228
|
+
detail: symbol,
|
|
229
|
+
code: result?.error ?? 'unknown error',
|
|
230
|
+
});
|
|
136
231
|
}
|
|
137
232
|
|
|
138
233
|
const requests = [
|
|
@@ -156,12 +251,23 @@ export async function updatePositionTpsl(input) {
|
|
|
156
251
|
accountAddress,
|
|
157
252
|
input,
|
|
158
253
|
requests,
|
|
159
|
-
|
|
254
|
+
// Poll for only part of the node's budget. Spending all of it means the
|
|
255
|
+
// runner kills this adapter before the mismatch below can be reported, so a
|
|
256
|
+
// read-back that never matches surfaces as an opaque timeout instead of
|
|
257
|
+
// saying which trigger order was missing.
|
|
258
|
+
Math.min(
|
|
259
|
+
Number(
|
|
260
|
+
optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000,
|
|
261
|
+
) * 0.6,
|
|
262
|
+
60000,
|
|
263
|
+
),
|
|
160
264
|
);
|
|
161
265
|
|
|
162
266
|
if (triggerOrders.length !== requests.length) {
|
|
163
267
|
throw new Error(
|
|
164
|
-
`core updatePositionTPSL for ${symbol} reported success but only ${triggerOrders.length}/${requests.length} requested trigger orders are visible
|
|
268
|
+
`core updatePositionTPSL for ${symbol} reported success but only ${triggerOrders.length}/${requests.length} requested trigger orders are visible. Requested: ${JSON.stringify(
|
|
269
|
+
requests,
|
|
270
|
+
)}.`,
|
|
165
271
|
);
|
|
166
272
|
}
|
|
167
273
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
2
|
+
import { consentParams } from '../../shared/analytics/consent.mjs';
|
|
3
|
+
|
|
4
|
+
runAdapter((input) => withExtensionPage(input, async (page) => {
|
|
5
|
+
const { participate, marketing, timeoutMs } = consentParams(input.node);
|
|
6
|
+
const bridgeSymbol = 'metamask-harness-consent-bridge';
|
|
7
|
+
|
|
8
|
+
async function readConsentState() {
|
|
9
|
+
return page.evaluate(`(async () => {
|
|
10
|
+
const getState = globalThis.stateHooks?.getCleanAppState;
|
|
11
|
+
if (typeof getState !== 'function') throw new Error('stateHooks.getCleanAppState is unavailable.');
|
|
12
|
+
const metamask = (await getState())?.metamask ?? {};
|
|
13
|
+
return {
|
|
14
|
+
optedIn: Boolean(metamask.optedIn),
|
|
15
|
+
dataCollectionForMarketing: Boolean(metamask.dataCollectionForMarketing),
|
|
16
|
+
analyticsId: metamask.analyticsId ? 'set' : null
|
|
17
|
+
};
|
|
18
|
+
})()`, { awaitPromise: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function waitForConsentState(expected) {
|
|
22
|
+
const deadline = Date.now() + timeoutMs;
|
|
23
|
+
let state;
|
|
24
|
+
do {
|
|
25
|
+
state = await readConsentState();
|
|
26
|
+
if (Object.entries(expected).every(([key, value]) => state[key] === value)) {
|
|
27
|
+
return state;
|
|
28
|
+
}
|
|
29
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
30
|
+
} while (Date.now() < deadline);
|
|
31
|
+
throw new Error(`Consent state did not settle to ${JSON.stringify(expected)}; got ${JSON.stringify(state)}.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const hasDebugBridge = await page.evaluate(
|
|
35
|
+
`typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
|
|
36
|
+
);
|
|
37
|
+
let preloadIdentifier;
|
|
38
|
+
if (!hasDebugBridge) {
|
|
39
|
+
const preload = await page.session.call('Page.addScriptToEvaluateOnNewDocument', {
|
|
40
|
+
source: `Object.defineProperty(
|
|
41
|
+
globalThis,
|
|
42
|
+
Symbol.for(${JSON.stringify(bridgeSymbol)}),
|
|
43
|
+
{ value: globalThis.chrome, configurable: true }
|
|
44
|
+
);`,
|
|
45
|
+
});
|
|
46
|
+
preloadIdentifier = preload?.identifier;
|
|
47
|
+
try {
|
|
48
|
+
let notifyLoaded;
|
|
49
|
+
const loaded = new Promise((resolve) => {
|
|
50
|
+
notifyLoaded = resolve;
|
|
51
|
+
});
|
|
52
|
+
const unsubscribe = page.session.on('Page.loadEventFired', () => notifyLoaded?.());
|
|
53
|
+
let reloadTimeout;
|
|
54
|
+
try {
|
|
55
|
+
await page.session.call('Page.reload', { ignoreCache: false });
|
|
56
|
+
await Promise.race([
|
|
57
|
+
loaded,
|
|
58
|
+
new Promise((_, reject) => {
|
|
59
|
+
reloadTimeout = setTimeout(
|
|
60
|
+
() => reject(new Error(`Extension consent reload timed out after ${timeoutMs}ms.`)),
|
|
61
|
+
timeoutMs,
|
|
62
|
+
);
|
|
63
|
+
}),
|
|
64
|
+
]);
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(reloadTimeout);
|
|
67
|
+
unsubscribe();
|
|
68
|
+
}
|
|
69
|
+
await page.waitForExpression(
|
|
70
|
+
`typeof globalThis[Symbol.for(${JSON.stringify(bridgeSymbol)})]?.runtime?.connect === 'function'`,
|
|
71
|
+
{ timeoutMs },
|
|
72
|
+
);
|
|
73
|
+
} finally {
|
|
74
|
+
if (preloadIdentifier) {
|
|
75
|
+
await page.session.call('Page.removeScriptToEvaluateOnNewDocument', {
|
|
76
|
+
identifier: preloadIdentifier,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const controllerResult = await page.evaluate(`(async () => {
|
|
83
|
+
const submit = globalThis.stateHooks?.submitRequestToBackground;
|
|
84
|
+
const bridgeKey = Symbol.for(${JSON.stringify(bridgeSymbol)});
|
|
85
|
+
const capturedChrome = globalThis[bridgeKey];
|
|
86
|
+
let port;
|
|
87
|
+
let nextId = Date.now();
|
|
88
|
+
|
|
89
|
+
const rawSubmit = (method, params) => new Promise((resolve, reject) => {
|
|
90
|
+
if (!port) {
|
|
91
|
+
const connectionName = location.pathname.includes('sidepanel')
|
|
92
|
+
? 'sidepanel'
|
|
93
|
+
: location.pathname.includes('popup')
|
|
94
|
+
? 'popup'
|
|
95
|
+
: 'fullscreen';
|
|
96
|
+
port = capturedChrome.runtime.connect({ name: connectionName });
|
|
97
|
+
}
|
|
98
|
+
const id = nextId++;
|
|
99
|
+
const timer = setTimeout(
|
|
100
|
+
() => reject(new Error(method + ' timed out after ${timeoutMs}ms')),
|
|
101
|
+
${timeoutMs},
|
|
102
|
+
);
|
|
103
|
+
const listener = (message) => {
|
|
104
|
+
const data = message?.name === 'controller' ? message.data : null;
|
|
105
|
+
if (data?.id !== id) return;
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
port.onMessage.removeListener(listener);
|
|
108
|
+
if (data.error) reject(new Error(JSON.stringify(data.error)));
|
|
109
|
+
else resolve(data.result);
|
|
110
|
+
};
|
|
111
|
+
port.onMessage.addListener(listener);
|
|
112
|
+
port.postMessage({
|
|
113
|
+
name: 'controller',
|
|
114
|
+
data: { jsonrpc: '2.0', id, method, params }
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const callController = typeof submit === 'function' ? submit : rawSubmit;
|
|
119
|
+
if (typeof callController !== 'function') {
|
|
120
|
+
throw new Error('Extension consent setup could not reach the background controller.');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const setMarketing = async (value) => {
|
|
124
|
+
await callController('setDataCollectionForMarketing', [value]);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
if (!${JSON.stringify(participate)}) await setMarketing(false);
|
|
129
|
+
const analyticsId = await callController(
|
|
130
|
+
'setParticipateInMetaMetrics',
|
|
131
|
+
[${JSON.stringify(participate)}],
|
|
132
|
+
);
|
|
133
|
+
if (${JSON.stringify(participate)}) {
|
|
134
|
+
await setMarketing(${JSON.stringify(marketing)});
|
|
135
|
+
}
|
|
136
|
+
return { analyticsId: analyticsId ? 'set' : null };
|
|
137
|
+
} finally {
|
|
138
|
+
port?.disconnect();
|
|
139
|
+
if (capturedChrome) delete globalThis[bridgeKey];
|
|
140
|
+
}
|
|
141
|
+
})()`, { awaitPromise: true });
|
|
142
|
+
|
|
143
|
+
const state = await waitForConsentState({
|
|
144
|
+
optedIn: participate,
|
|
145
|
+
dataCollectionForMarketing: marketing,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (state.optedIn !== participate) {
|
|
149
|
+
throw new Error(`Expected optedIn=${participate}, got ${state.optedIn}.`);
|
|
150
|
+
}
|
|
151
|
+
if (state.dataCollectionForMarketing !== marketing) {
|
|
152
|
+
throw new Error(`Expected dataCollectionForMarketing=${marketing}, got ${state.dataCollectionForMarketing}.`);
|
|
153
|
+
}
|
|
154
|
+
if (participate && controllerResult.analyticsId !== 'set') {
|
|
155
|
+
throw new Error('MetaMetrics consent was enabled but the controller did not return an analyticsId.');
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
action: input.action,
|
|
159
|
+
consent: {
|
|
160
|
+
...state,
|
|
161
|
+
analyticsId: controllerResult.analyticsId,
|
|
162
|
+
},
|
|
163
|
+
proofPath: 'extension-background-controller',
|
|
164
|
+
};
|
|
165
|
+
}));
|
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
constants,
|
|
3
|
+
access,
|
|
4
|
+
chmod,
|
|
5
|
+
cp,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
mkdtemp,
|
|
9
|
+
open,
|
|
10
|
+
readFile,
|
|
11
|
+
rename,
|
|
12
|
+
rm,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from 'node:fs/promises';
|
|
2
15
|
import { closeSync, openSync } from 'node:fs';
|
|
3
16
|
import { spawn } from 'node:child_process';
|
|
4
17
|
import { createRequire } from 'node:module';
|
|
@@ -74,6 +87,75 @@ function resolveRelativeArtifactPath(artifactsDir, relPath) {
|
|
|
74
87
|
return { relative: normalized, absolute };
|
|
75
88
|
}
|
|
76
89
|
|
|
90
|
+
async function preparePrivateArtifactStage(artifactsDir, relPath) {
|
|
91
|
+
const destination = resolveRelativeArtifactPath(artifactsDir, relPath);
|
|
92
|
+
const artifactsRoot = path.resolve(artifactsDir);
|
|
93
|
+
await mkdir(artifactsRoot, { recursive: true });
|
|
94
|
+
await ensureArtifactParent(artifactsRoot, path.dirname(destination.absolute));
|
|
95
|
+
await refuseUnsafeDestination(destination.absolute);
|
|
96
|
+
const stagingDir = await mkdtemp(path.join(artifactsRoot, '.extension-evidence-'));
|
|
97
|
+
await chmod(stagingDir, 0o700);
|
|
98
|
+
return {
|
|
99
|
+
...destination,
|
|
100
|
+
stagingDir,
|
|
101
|
+
staged: path.join(stagingDir, 'artifact'),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function ensureArtifactParent(artifactsRoot, parent) {
|
|
106
|
+
const relative = path.relative(artifactsRoot, parent);
|
|
107
|
+
let current = artifactsRoot;
|
|
108
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
109
|
+
current = path.join(current, segment);
|
|
110
|
+
try {
|
|
111
|
+
const info = await lstat(current);
|
|
112
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
113
|
+
throw new Error(`Refusing Extension evidence parent that is not a real directory: ${current}`);
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
117
|
+
await mkdir(current, { mode: 0o700 });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function refuseUnsafeDestination(destination) {
|
|
123
|
+
try {
|
|
124
|
+
const info = await lstat(destination);
|
|
125
|
+
if (info.isSymbolicLink()) {
|
|
126
|
+
throw new Error(`Refusing Extension evidence destination symlink: ${destination}`);
|
|
127
|
+
}
|
|
128
|
+
if (!info.isFile()) {
|
|
129
|
+
throw new Error(`Refusing Extension evidence destination that is not a regular file: ${destination}`);
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function publishPrivateArtifact(stage) {
|
|
137
|
+
const handle = await open(
|
|
138
|
+
stage.staged,
|
|
139
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
140
|
+
);
|
|
141
|
+
try {
|
|
142
|
+
const info = await handle.stat();
|
|
143
|
+
if (!info.isFile()) {
|
|
144
|
+
throw new Error(`Captured Extension evidence is not a safe regular file: ${stage.staged}`);
|
|
145
|
+
}
|
|
146
|
+
await handle.chmod(0o600);
|
|
147
|
+
await refuseUnsafeDestination(stage.absolute);
|
|
148
|
+
await rename(stage.staged, stage.absolute);
|
|
149
|
+
} finally {
|
|
150
|
+
await handle.close();
|
|
151
|
+
}
|
|
152
|
+
await rm(stage.stagingDir, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function cleanupPrivateArtifactStage(stage) {
|
|
156
|
+
await rm(stage.stagingDir, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
|
|
77
159
|
function captureHelperPath() {
|
|
78
160
|
return process.env.CAPTURE_HELPER_PATH || 'capture-helper';
|
|
79
161
|
}
|
|
@@ -121,8 +203,7 @@ async function captureHelperBrowserPid(context, port) {
|
|
|
121
203
|
}
|
|
122
204
|
|
|
123
205
|
async function captureCdpViewportSnapshot(page, context, relPath, metadata, captureHelperError = null) {
|
|
124
|
-
const
|
|
125
|
-
await mkdir(path.dirname(absolute), { recursive: true });
|
|
206
|
+
const stage = await preparePrivateArtifactStage(context.artifactsDir, relPath);
|
|
126
207
|
try {
|
|
127
208
|
const timeoutMs = Number(metadata?.cdpTimeoutMs ?? 5000);
|
|
128
209
|
const result = await Promise.race([
|
|
@@ -136,9 +217,13 @@ async function captureCdpViewportSnapshot(page, context, relPath, metadata, capt
|
|
|
136
217
|
if (typeof result?.data !== 'string' || result.data.length === 0) {
|
|
137
218
|
throw new Error('Chrome Page.captureScreenshot returned no image data.');
|
|
138
219
|
}
|
|
139
|
-
await writeFile(
|
|
220
|
+
await writeFile(stage.staged, Buffer.from(result.data, 'base64'), {
|
|
221
|
+
flag: 'wx',
|
|
222
|
+
mode: 0o600,
|
|
223
|
+
});
|
|
224
|
+
await publishPrivateArtifact(stage);
|
|
140
225
|
return {
|
|
141
|
-
path: relative,
|
|
226
|
+
path: stage.relative,
|
|
142
227
|
type: 'screenshot',
|
|
143
228
|
nodeId: context.nodeId,
|
|
144
229
|
label: metadata?.label ?? `${context.nodeId} screenshot`,
|
|
@@ -151,13 +236,13 @@ async function captureCdpViewportSnapshot(page, context, relPath, metadata, capt
|
|
|
151
236
|
},
|
|
152
237
|
};
|
|
153
238
|
} catch (error) {
|
|
239
|
+
await cleanupPrivateArtifactStage(stage);
|
|
154
240
|
const cdpError = error instanceof Error ? error.message : String(error);
|
|
155
241
|
return captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError);
|
|
156
242
|
}
|
|
157
243
|
}
|
|
158
244
|
|
|
159
245
|
async function captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError) {
|
|
160
|
-
const { relative, absolute } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
|
|
161
246
|
const dataUrl = await page.evaluate(`(async () => {
|
|
162
247
|
const width = Math.max(1, window.innerWidth);
|
|
163
248
|
const height = Math.max(1, window.innerHeight);
|
|
@@ -200,9 +285,20 @@ async function captureDomRasterSnapshot(page, context, relPath, metadata, captur
|
|
|
200
285
|
if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
|
|
201
286
|
throw new Error(`Extension screenshot fallbacks failed: capture-helper=${captureHelperError ?? 'not attempted'}; cdp=${cdpError}; DOM raster returned no PNG.`);
|
|
202
287
|
}
|
|
203
|
-
await
|
|
288
|
+
const stage = await preparePrivateArtifactStage(context.artifactsDir, relPath);
|
|
289
|
+
try {
|
|
290
|
+
await writeFile(
|
|
291
|
+
stage.staged,
|
|
292
|
+
Buffer.from(dataUrl.slice('data:image/png;base64,'.length), 'base64'),
|
|
293
|
+
{ flag: 'wx', mode: 0o600 },
|
|
294
|
+
);
|
|
295
|
+
await publishPrivateArtifact(stage);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
await cleanupPrivateArtifactStage(stage);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
204
300
|
return {
|
|
205
|
-
path: relative,
|
|
301
|
+
path: stage.relative,
|
|
206
302
|
type: 'screenshot',
|
|
207
303
|
nodeId: context.nodeId,
|
|
208
304
|
label: metadata?.label ?? `${context.nodeId} screenshot`,
|
|
@@ -222,16 +318,16 @@ export async function captureHelperSnapshot(page, context, relPath, metadata) {
|
|
|
222
318
|
if (process.platform !== 'darwin') {
|
|
223
319
|
return captureCdpViewportSnapshot(page, context, relPath, metadata);
|
|
224
320
|
}
|
|
225
|
-
const
|
|
226
|
-
await mkdir(path.dirname(absolute), { recursive: true });
|
|
321
|
+
const stage = await preparePrivateArtifactStage(context.artifactsDir, relPath);
|
|
227
322
|
|
|
228
323
|
try {
|
|
229
324
|
const pid = await captureHelperBrowserPid(context, page.port);
|
|
230
325
|
const timeoutMs = Number(metadata?.timeoutMs ?? 30000);
|
|
231
|
-
const sessionSnapshot = await captureActiveRecipeRecordingSnapshot(pid,
|
|
326
|
+
const sessionSnapshot = await captureActiveRecipeRecordingSnapshot(pid, stage.staged, timeoutMs);
|
|
232
327
|
if (sessionSnapshot) {
|
|
328
|
+
await publishPrivateArtifact(stage);
|
|
233
329
|
return {
|
|
234
|
-
path: relative,
|
|
330
|
+
path: stage.relative,
|
|
235
331
|
type: 'screenshot',
|
|
236
332
|
nodeId: context.nodeId,
|
|
237
333
|
label: metadata?.label ?? `${context.nodeId} screenshot`,
|
|
@@ -246,7 +342,7 @@ export async function captureHelperSnapshot(page, context, relPath, metadata) {
|
|
|
246
342
|
};
|
|
247
343
|
}
|
|
248
344
|
|
|
249
|
-
const result = await runProcess(captureHelperPath(), ['snapshot', '--pid', String(pid), '--output',
|
|
345
|
+
const result = await runProcess(captureHelperPath(), ['snapshot', '--pid', String(pid), '--output', stage.staged], {
|
|
250
346
|
cwd: context.projectRoot,
|
|
251
347
|
env: process.env,
|
|
252
348
|
timeoutMs,
|
|
@@ -255,8 +351,9 @@ export async function captureHelperSnapshot(page, context, relPath, metadata) {
|
|
|
255
351
|
throw new Error(`capture-helper snapshot failed for pid ${pid}: ${result.stderr || result.stdout}`);
|
|
256
352
|
}
|
|
257
353
|
const details = parseJsonObject(result.stdout);
|
|
354
|
+
await publishPrivateArtifact(stage);
|
|
258
355
|
return {
|
|
259
|
-
path: relative,
|
|
356
|
+
path: stage.relative,
|
|
260
357
|
type: 'screenshot',
|
|
261
358
|
nodeId: context.nodeId,
|
|
262
359
|
label: metadata?.label ?? `${context.nodeId} screenshot`,
|
|
@@ -270,6 +367,7 @@ export async function captureHelperSnapshot(page, context, relPath, metadata) {
|
|
|
270
367
|
},
|
|
271
368
|
};
|
|
272
369
|
} catch (error) {
|
|
370
|
+
await cleanupPrivateArtifactStage(stage);
|
|
273
371
|
const message = error instanceof Error ? error.message : String(error);
|
|
274
372
|
return captureCdpViewportSnapshot(page, context, relPath, metadata, message);
|
|
275
373
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { evalAsync, navigate, runAdapter } from '../platform/bridge.mjs';
|
|
2
|
+
import { consentParams } from '../../shared/analytics/consent.mjs';
|
|
3
|
+
|
|
4
|
+
const SWITCH_IDS = {
|
|
5
|
+
participate: 'metametrics-switch',
|
|
6
|
+
marketing: 'data-collection-switch',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function toggleExpression(testId, expected, timeoutMs) {
|
|
10
|
+
return `(async () => {
|
|
11
|
+
const deadline = Date.now() + ${JSON.stringify(timeoutMs)};
|
|
12
|
+
let invoked = false;
|
|
13
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
const find = () => {
|
|
15
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
16
|
+
const rootsFor = hook?.getFiberRoots;
|
|
17
|
+
if (!hook?.renderers || typeof rootsFor !== 'function') return null;
|
|
18
|
+
const walk = (fiber) => {
|
|
19
|
+
if (!fiber) return null;
|
|
20
|
+
if (fiber.memoizedProps?.testID === ${JSON.stringify(testId)}) return fiber;
|
|
21
|
+
return walk(fiber.child) || walk(fiber.sibling);
|
|
22
|
+
};
|
|
23
|
+
for (const [id] of hook.renderers) {
|
|
24
|
+
for (const root of rootsFor(id) ?? []) {
|
|
25
|
+
const match = walk(root.current);
|
|
26
|
+
if (match) return match;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
const target = find();
|
|
33
|
+
const props = target?.memoizedProps;
|
|
34
|
+
if (props && Boolean(props.value) === ${JSON.stringify(expected)}) {
|
|
35
|
+
return { ok: true, testId: ${JSON.stringify(testId)}, changed: invoked };
|
|
36
|
+
}
|
|
37
|
+
if (props && !invoked) {
|
|
38
|
+
if (typeof props.onValueChange !== 'function') {
|
|
39
|
+
throw new Error('Consent switch has no onValueChange handler: ${testId}');
|
|
40
|
+
}
|
|
41
|
+
invoked = true;
|
|
42
|
+
await props.onValueChange(${JSON.stringify(expected)});
|
|
43
|
+
}
|
|
44
|
+
await delay(100);
|
|
45
|
+
}
|
|
46
|
+
throw new Error('Timed out setting consent switch ${testId} to ${expected}.');
|
|
47
|
+
})()`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stateExpression(participate, marketing, timeoutMs) {
|
|
51
|
+
return `(async () => {
|
|
52
|
+
const deadline = Date.now() + ${JSON.stringify(timeoutMs)};
|
|
53
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
|
+
while (Date.now() < deadline) {
|
|
55
|
+
const state = globalThis.store?.getState?.();
|
|
56
|
+
const analytics = state?.engine?.backgroundState?.AnalyticsController ?? {};
|
|
57
|
+
const consent = {
|
|
58
|
+
optedIn: Boolean(analytics.optedIn),
|
|
59
|
+
dataCollectionForMarketing: Boolean(state?.security?.dataCollectionForMarketing),
|
|
60
|
+
analyticsId: analytics.analyticsId ? 'set' : null
|
|
61
|
+
};
|
|
62
|
+
if (
|
|
63
|
+
consent.optedIn === ${JSON.stringify(participate)} &&
|
|
64
|
+
consent.dataCollectionForMarketing === ${JSON.stringify(marketing)} &&
|
|
65
|
+
(!${JSON.stringify(participate)} || consent.analyticsId === 'set')
|
|
66
|
+
) return consent;
|
|
67
|
+
await delay(100);
|
|
68
|
+
}
|
|
69
|
+
throw new Error('Timed out reading back Mobile analytics consent.');
|
|
70
|
+
})()`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
runAdapter(async (input) => {
|
|
74
|
+
const { participate, marketing, timeoutMs } = consentParams(input.node);
|
|
75
|
+
|
|
76
|
+
const navigation = await navigate(input, 'SecuritySettings');
|
|
77
|
+
await evalAsync(input, toggleExpression(SWITCH_IDS.participate, participate, timeoutMs));
|
|
78
|
+
await evalAsync(input, toggleExpression(SWITCH_IDS.marketing, marketing, timeoutMs));
|
|
79
|
+
const consent = await evalAsync(
|
|
80
|
+
input,
|
|
81
|
+
stateExpression(participate, marketing, timeoutMs),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
action: input.action,
|
|
86
|
+
consent,
|
|
87
|
+
navigation,
|
|
88
|
+
proofPath: 'mobile-settings-consent',
|
|
89
|
+
};
|
|
90
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Minimal adapter IO for the shared analytics actions.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the live-adapter contract in src/live-adapter-contract.ts. Neither
|
|
4
|
+
// existing runAdapter fits: extension's lives under actions/extension/platform
|
|
5
|
+
// and core's carries PerpsController teardown and expect_error handling. A
|
|
6
|
+
// `shared/` action importing either would couple every platform to one of them,
|
|
7
|
+
// so this is a deliberate 20-line duplicate of the contract itself.
|
|
8
|
+
|
|
9
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
|
|
11
|
+
export async function runAdapter(callback) {
|
|
12
|
+
const inputPath = process.argv[2] || process.env.METAMASK_RECIPE_ADAPTER_INPUT;
|
|
13
|
+
if (!inputPath) throw new Error('Missing live adapter input path.');
|
|
14
|
+
const input = JSON.parse(await readFile(inputPath, 'utf8'));
|
|
15
|
+
try {
|
|
16
|
+
const output = await callback(input);
|
|
17
|
+
await writeFile(input.outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
// No output file on failure — that is how the runner distinguishes a failed
|
|
20
|
+
// assertion from a passing one.
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
process.stderr.write(`[shared/analytics] ${input.action} failed: ${error?.stack ?? error?.message ?? String(error)}\n`);
|
|
23
|
+
}
|
|
24
|
+
}
|