@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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { runAdapter } from './_adapter.mjs';
|
|
2
|
+
import { readCollected } from './collector.mjs';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
5
|
+
// Why a settle window exists at all: a duplicate emit arrives *after* the
|
|
6
|
+
// expected one. If this returned the instant a count reached its target it
|
|
7
|
+
// would pass on the first of two events and never see the second — which is
|
|
8
|
+
// precisely the defect class this action was built to catch. So: wait until
|
|
9
|
+
// satisfiable, then keep waiting, then judge.
|
|
10
|
+
const DEFAULT_SETTLE_MS = 1500;
|
|
11
|
+
|
|
12
|
+
function nonNegativeNumber(value, name, fallback) {
|
|
13
|
+
if (value === undefined) return fallback;
|
|
14
|
+
if (value === '') throw new Error(`${name} must not be blank.`);
|
|
15
|
+
const parsed = Number(value);
|
|
16
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
17
|
+
throw new Error(`${name} must be a non-negative number, got ${JSON.stringify(value)}.`);
|
|
18
|
+
}
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nonNegativeInteger(value, name, fallback) {
|
|
23
|
+
const parsed = nonNegativeNumber(value, name, fallback);
|
|
24
|
+
if (!Number.isInteger(parsed)) {
|
|
25
|
+
throw new Error(`${name} must be an integer, got ${JSON.stringify(value)}.`);
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function matchesProperties(actual, expected) {
|
|
31
|
+
return Object.entries(expected).every(([key, want]) => {
|
|
32
|
+
const got = actual?.[key];
|
|
33
|
+
if (want !== null && typeof want === 'object') {
|
|
34
|
+
return JSON.stringify(got) === JSON.stringify(want);
|
|
35
|
+
}
|
|
36
|
+
// Segment serialises numbers inconsistently across clients.
|
|
37
|
+
if (typeof want === 'number' && typeof got === 'string' && got.trim() !== '') {
|
|
38
|
+
return Number(got) === want;
|
|
39
|
+
}
|
|
40
|
+
if (typeof got === 'number' && typeof want === 'string' && want.trim() !== '') {
|
|
41
|
+
return got === Number(want);
|
|
42
|
+
}
|
|
43
|
+
return Object.is(got, want);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function selectFor(expectation, events) {
|
|
48
|
+
return events.filter(
|
|
49
|
+
(entry) =>
|
|
50
|
+
entry.event === expectation.event &&
|
|
51
|
+
(!expectation.properties || matchesProperties(entry.properties, expectation.properties)),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function expectationsFrom(input) {
|
|
56
|
+
const raw = input.node?.expect;
|
|
57
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
58
|
+
throw new Error('metamask.analytics.assert_events requires a non-empty expect array.');
|
|
59
|
+
}
|
|
60
|
+
return raw.map((entry) => {
|
|
61
|
+
if (typeof entry?.event !== 'string' || entry.event.length === 0) {
|
|
62
|
+
throw new Error('Each expect entry requires a non-empty event name.');
|
|
63
|
+
}
|
|
64
|
+
const hasCount = entry.count !== undefined;
|
|
65
|
+
const hasBounds = entry.min !== undefined || entry.max !== undefined;
|
|
66
|
+
if (hasCount && hasBounds) {
|
|
67
|
+
throw new Error(`expect entry for "${entry.event}" sets both count and min/max; pick one.`);
|
|
68
|
+
}
|
|
69
|
+
const expectation = {
|
|
70
|
+
event: entry.event,
|
|
71
|
+
properties: entry.properties ?? null,
|
|
72
|
+
count: hasCount ? nonNegativeInteger(entry.count, `count for "${entry.event}"`) : undefined,
|
|
73
|
+
min: entry.min !== undefined
|
|
74
|
+
? nonNegativeInteger(entry.min, `min for "${entry.event}"`)
|
|
75
|
+
: hasCount
|
|
76
|
+
? nonNegativeInteger(entry.count, `count for "${entry.event}"`)
|
|
77
|
+
: 1,
|
|
78
|
+
max: entry.max !== undefined
|
|
79
|
+
? nonNegativeInteger(entry.max, `max for "${entry.event}"`)
|
|
80
|
+
: hasCount
|
|
81
|
+
? nonNegativeInteger(entry.count, `count for "${entry.event}"`)
|
|
82
|
+
: undefined,
|
|
83
|
+
};
|
|
84
|
+
if (expectation.max !== undefined && expectation.min > expectation.max) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`expect entry for "${entry.event}" has min ${expectation.min} greater than max ${expectation.max}.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return expectation;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function describe(expectation) {
|
|
94
|
+
const bound =
|
|
95
|
+
expectation.count !== undefined
|
|
96
|
+
? `exactly ${expectation.count}`
|
|
97
|
+
: `min ${expectation.min}${expectation.max === undefined ? '' : `, max ${expectation.max}`}`;
|
|
98
|
+
const props = expectation.properties ? ` matching ${JSON.stringify(expectation.properties)}` : '';
|
|
99
|
+
return `"${expectation.event}"${props} (${bound})`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
103
|
+
|
|
104
|
+
runAdapter(async (input) => {
|
|
105
|
+
const since = nonNegativeNumber(input.node?.since, 'since', 0);
|
|
106
|
+
const timeoutMs = nonNegativeNumber(input.node?.timeout_ms, 'timeout_ms', DEFAULT_TIMEOUT_MS);
|
|
107
|
+
const settleMs = nonNegativeNumber(input.node?.settle_ms, 'settle_ms', DEFAULT_SETTLE_MS);
|
|
108
|
+
const exact = Boolean(input.node?.exact);
|
|
109
|
+
const expectations = expectationsFrom(input);
|
|
110
|
+
|
|
111
|
+
// Phase 1 — wait until every lower bound is reachable, or time out. Timing out
|
|
112
|
+
// is not itself a failure; the strict judgement below produces the message.
|
|
113
|
+
const deadline = Date.now() + timeoutMs;
|
|
114
|
+
let events = [];
|
|
115
|
+
for (;;) {
|
|
116
|
+
events = await readCollected(input, { since });
|
|
117
|
+
const allMet = expectations.every((e) => selectFor(e, events).length >= e.min);
|
|
118
|
+
if (allMet || Date.now() >= deadline) break;
|
|
119
|
+
await sleep(250);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Phase 2 — settle, so a late duplicate lands before we judge.
|
|
123
|
+
await sleep(settleMs);
|
|
124
|
+
events = await readCollected(input, { since });
|
|
125
|
+
|
|
126
|
+
const failures = [];
|
|
127
|
+
const observed = [];
|
|
128
|
+
for (const expectation of expectations) {
|
|
129
|
+
const matched = selectFor(expectation, events);
|
|
130
|
+
observed.push({ event: expectation.event, matched: matched.length });
|
|
131
|
+
if (expectation.count !== undefined && matched.length !== expectation.count) {
|
|
132
|
+
failures.push(`expected ${describe(expectation)}, got ${matched.length}`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (matched.length < expectation.min) {
|
|
136
|
+
failures.push(`expected ${describe(expectation)}, got ${matched.length}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (expectation.max !== undefined && matched.length > expectation.max) {
|
|
140
|
+
failures.push(`expected ${describe(expectation)}, got ${matched.length}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (exact) {
|
|
145
|
+
const allowed = new Set(expectations.map((e) => e.event));
|
|
146
|
+
const unexpected = [...new Set(events.map((e) => e.event).filter((name) => name && !allowed.has(name)))];
|
|
147
|
+
if (unexpected.length) {
|
|
148
|
+
failures.push(`exact mode: unexpected event(s) ${unexpected.map((n) => `"${n}"`).join(', ')}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (failures.length) {
|
|
153
|
+
const captured = events.map((e) => e.event).filter(Boolean);
|
|
154
|
+
throw new Error(
|
|
155
|
+
[
|
|
156
|
+
`metamask.analytics.assert_events failed:`,
|
|
157
|
+
...failures.map((line) => ` - ${line}`),
|
|
158
|
+
` captured since ${since}: ${captured.length ? captured.join(', ') : '(none)'}`,
|
|
159
|
+
// A missing-property failure here is most often a consent problem, not a
|
|
160
|
+
// client bug: platform-adapter strips utm_* unless BOTH
|
|
161
|
+
// participateInMetaMetrics and dataCollectionForMarketing are true.
|
|
162
|
+
` if utm_* / marketing properties are missing, check MetaMetrics consent before suspecting the client.`,
|
|
163
|
+
].join('\n'),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { action: input.action, since, exact, observed, capturedCount: events.length };
|
|
168
|
+
});
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
// Segment collector shared by every platform's analytics actions.
|
|
2
|
+
//
|
|
3
|
+
// Both clients already ship the mechanism to divert MetaMetrics to an arbitrary
|
|
4
|
+
// host — the extension via SEGMENT_HOST, mobile via SEGMENT_PROXY_URL — and
|
|
5
|
+
// neither validates the write key. Pointing either at this collector yields the
|
|
6
|
+
// exact payloads Segment would have received.
|
|
7
|
+
//
|
|
8
|
+
// This lives in `shared/` because the payload shape is Segment's, not a
|
|
9
|
+
// client's: `<root>/shared/<family>/<stem>.mjs` is the fallback the adapter
|
|
10
|
+
// contract tries for every platform, so one implementation serves all.
|
|
11
|
+
//
|
|
12
|
+
// The server has to outlive the action process that starts it (each action is a
|
|
13
|
+
// separate short-lived process), so `startCollector` spawns this same file
|
|
14
|
+
// detached in `--serve` mode and records the pid. Events land in a JSONL file
|
|
15
|
+
// that later actions read by cursor.
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
import { createServer } from 'node:http';
|
|
19
|
+
import { execFile, spawn } from 'node:child_process';
|
|
20
|
+
import { constants } from 'node:fs';
|
|
21
|
+
import {
|
|
22
|
+
chmod,
|
|
23
|
+
lstat,
|
|
24
|
+
mkdir,
|
|
25
|
+
open,
|
|
26
|
+
rename,
|
|
27
|
+
unlink,
|
|
28
|
+
} from 'node:fs/promises';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { promisify } from 'node:util';
|
|
31
|
+
|
|
32
|
+
const execFileAsync = promisify(execFile);
|
|
33
|
+
|
|
34
|
+
export function collectorPaths(projectRoot) {
|
|
35
|
+
const configuredRuntime = process.env.RECIPE_RUNTIME_DIR;
|
|
36
|
+
const runtimeRoot = configuredRuntime
|
|
37
|
+
? path.resolve(projectRoot, configuredRuntime)
|
|
38
|
+
: path.join(projectRoot, 'temp/recipe/runtime');
|
|
39
|
+
const dir = path.join(runtimeRoot, 'analytics');
|
|
40
|
+
return {
|
|
41
|
+
dir,
|
|
42
|
+
eventsFile: path.join(dir, 'events.jsonl'),
|
|
43
|
+
stateFile: path.join(dir, 'collector.json'),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function prepareCollectorStorage(projectRoot) {
|
|
48
|
+
const paths = collectorPaths(projectRoot);
|
|
49
|
+
const configuredRuntime = process.env.RECIPE_RUNTIME_DIR;
|
|
50
|
+
const boundary = configuredRuntime && path.isAbsolute(configuredRuntime)
|
|
51
|
+
? path.resolve(configuredRuntime)
|
|
52
|
+
: path.resolve(projectRoot);
|
|
53
|
+
await ensureRealDirectoryChain(
|
|
54
|
+
boundary,
|
|
55
|
+
paths.dir,
|
|
56
|
+
Boolean(configuredRuntime && path.isAbsolute(configuredRuntime)),
|
|
57
|
+
);
|
|
58
|
+
await chmod(paths.dir, 0o700);
|
|
59
|
+
await ensurePrivateAppendFile(paths.eventsFile);
|
|
60
|
+
await secureExistingFile(paths.stateFile);
|
|
61
|
+
return paths;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function ensureRealDirectoryChain(boundary, target, includeBoundary) {
|
|
65
|
+
const relative = path.relative(boundary, target);
|
|
66
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
67
|
+
throw new Error(`Analytics runtime path escapes its selected root: ${target}`);
|
|
68
|
+
}
|
|
69
|
+
if (includeBoundary) {
|
|
70
|
+
await mkdir(boundary, { recursive: true, mode: 0o700 });
|
|
71
|
+
}
|
|
72
|
+
let current = boundary;
|
|
73
|
+
const paths = includeBoundary ? [current] : [];
|
|
74
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
75
|
+
current = path.join(current, segment);
|
|
76
|
+
paths.push(current);
|
|
77
|
+
}
|
|
78
|
+
for (const directory of paths) {
|
|
79
|
+
let info;
|
|
80
|
+
try {
|
|
81
|
+
info = await lstat(directory);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
84
|
+
await mkdir(directory, { mode: 0o700 });
|
|
85
|
+
info = await lstat(directory);
|
|
86
|
+
}
|
|
87
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
88
|
+
throw new Error(`Analytics runtime directory must be a real directory, not a symlink: ${directory}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function secureExistingFile(filePath) {
|
|
94
|
+
try {
|
|
95
|
+
const info = await lstat(filePath);
|
|
96
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
97
|
+
throw new Error(`Analytics runtime file must be a real file, not a symlink: ${filePath}`);
|
|
98
|
+
}
|
|
99
|
+
await chmod(filePath, 0o600);
|
|
100
|
+
return true;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error?.code === 'ENOENT') return false;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function ensurePrivateAppendFile(filePath) {
|
|
108
|
+
await secureExistingFile(filePath);
|
|
109
|
+
const handle = await open(
|
|
110
|
+
filePath,
|
|
111
|
+
constants.O_WRONLY
|
|
112
|
+
| constants.O_APPEND
|
|
113
|
+
| constants.O_CREAT
|
|
114
|
+
| (constants.O_NOFOLLOW ?? 0),
|
|
115
|
+
0o600,
|
|
116
|
+
);
|
|
117
|
+
try {
|
|
118
|
+
const info = await handle.stat();
|
|
119
|
+
if (!info.isFile()) throw new Error(`Analytics events path is not a regular file: ${filePath}`);
|
|
120
|
+
await handle.chmod(0o600);
|
|
121
|
+
} finally {
|
|
122
|
+
await handle.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function appendPrivateFile(filePath, value) {
|
|
127
|
+
const handle = await open(
|
|
128
|
+
filePath,
|
|
129
|
+
constants.O_WRONLY | constants.O_APPEND | (constants.O_NOFOLLOW ?? 0),
|
|
130
|
+
);
|
|
131
|
+
try {
|
|
132
|
+
const info = await handle.stat();
|
|
133
|
+
if (!info.isFile()) throw new Error(`Analytics events path is not a regular file: ${filePath}`);
|
|
134
|
+
await handle.writeFile(value);
|
|
135
|
+
} finally {
|
|
136
|
+
await handle.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readPrivateFile(filePath) {
|
|
141
|
+
const handle = await open(
|
|
142
|
+
filePath,
|
|
143
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
144
|
+
);
|
|
145
|
+
try {
|
|
146
|
+
const info = await handle.stat();
|
|
147
|
+
if (!info.isFile()) throw new Error(`Analytics runtime path is not a regular file: ${filePath}`);
|
|
148
|
+
return await handle.readFile('utf8');
|
|
149
|
+
} finally {
|
|
150
|
+
await handle.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function writePrivateState(stateFile, state) {
|
|
155
|
+
await secureExistingFile(stateFile);
|
|
156
|
+
const temporary = path.join(path.dirname(stateFile), `.collector-${randomUUID()}.json`);
|
|
157
|
+
let handle;
|
|
158
|
+
try {
|
|
159
|
+
handle = await open(
|
|
160
|
+
temporary,
|
|
161
|
+
constants.O_WRONLY
|
|
162
|
+
| constants.O_CREAT
|
|
163
|
+
| constants.O_EXCL
|
|
164
|
+
| (constants.O_NOFOLLOW ?? 0),
|
|
165
|
+
0o600,
|
|
166
|
+
);
|
|
167
|
+
await handle.writeFile(`${JSON.stringify(state)}\n`);
|
|
168
|
+
await handle.chmod(0o600);
|
|
169
|
+
await handle.close();
|
|
170
|
+
handle = undefined;
|
|
171
|
+
await rename(temporary, stateFile);
|
|
172
|
+
await chmod(stateFile, 0o600);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
await handle?.close().catch(() => {});
|
|
175
|
+
await unlink(temporary).catch(() => {});
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Default port derives from the slot's CDP port so co-located slots do not
|
|
182
|
+
* collide. Mirrors farmslot's `SEGMENT_PORT="$(( CDP_PORT + 1000 ))"`.
|
|
183
|
+
*/
|
|
184
|
+
export function collectorPort(input) {
|
|
185
|
+
const explicit = input?.node?.port ?? process.env.SEGMENT_MOCK_PORT;
|
|
186
|
+
if (explicit !== undefined) return validPort(explicit, 'collector port');
|
|
187
|
+
const cdpRaw = input?.node?.cdp_port ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
|
|
188
|
+
if (cdpRaw !== undefined) {
|
|
189
|
+
const cdp = validPort(cdpRaw, 'CDP port');
|
|
190
|
+
return validPort(cdp + 1000, 'collector port derived from CDP port');
|
|
191
|
+
}
|
|
192
|
+
return 9090;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function validPort(value, name) {
|
|
196
|
+
if (value === '') throw new Error(`Analytics ${name} must not be blank.`);
|
|
197
|
+
const port = Number(value);
|
|
198
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
199
|
+
throw new Error(`Analytics ${name} must be an integer from 1 to 65535, got ${JSON.stringify(value)}.`);
|
|
200
|
+
}
|
|
201
|
+
return port;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function projectRootOf(input) {
|
|
205
|
+
const root = input?.context?.projectRoot;
|
|
206
|
+
if (!root) throw new Error('Analytics actions require context.projectRoot.');
|
|
207
|
+
return root;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function readState(stateFile) {
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(await readPrivateFile(stateFile));
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pidAlive(pid) {
|
|
219
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
220
|
+
try {
|
|
221
|
+
// Signal 0 tests for existence without delivering anything.
|
|
222
|
+
process.kill(pid, 0);
|
|
223
|
+
return true;
|
|
224
|
+
} catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function collectorHealth(port) {
|
|
230
|
+
try {
|
|
231
|
+
const response = await fetch(`http://127.0.0.1:${port}/__collector`, {
|
|
232
|
+
signal: AbortSignal.timeout(1000),
|
|
233
|
+
});
|
|
234
|
+
if (!response.ok) return null;
|
|
235
|
+
const body = await response.json();
|
|
236
|
+
return body?.collector === 'metamask-harness' && body?.port === port
|
|
237
|
+
? body
|
|
238
|
+
: null;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function stopCollector(state) {
|
|
245
|
+
if (
|
|
246
|
+
!state?.instanceId ||
|
|
247
|
+
!pidAlive(state.pid) ||
|
|
248
|
+
(await collectorHealth(state.port))?.instanceId !== state.instanceId
|
|
249
|
+
) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
process.kill(state.pid, 'SIGTERM');
|
|
254
|
+
} catch {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
const deadline = Date.now() + 2000;
|
|
258
|
+
while (Date.now() < deadline && pidAlive(state.pid)) {
|
|
259
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
260
|
+
}
|
|
261
|
+
if (pidAlive(state.pid)) {
|
|
262
|
+
try {
|
|
263
|
+
process.kill(state.pid, 'SIGKILL');
|
|
264
|
+
} catch {
|
|
265
|
+
// The process exited after the final liveness check.
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function stopSpawnedCollector(pid) {
|
|
272
|
+
if (!pidAlive(pid)) return;
|
|
273
|
+
try {
|
|
274
|
+
process.kill(pid, 'SIGTERM');
|
|
275
|
+
} catch {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const deadline = Date.now() + 2000;
|
|
279
|
+
while (Date.now() < deadline && pidAlive(pid)) {
|
|
280
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
281
|
+
}
|
|
282
|
+
if (pidAlive(pid)) {
|
|
283
|
+
try {
|
|
284
|
+
process.kill(pid, 'SIGKILL');
|
|
285
|
+
} catch {
|
|
286
|
+
// The process exited after the final liveness check.
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function prepareAndroidAccess(input, port) {
|
|
292
|
+
if (input?.platform !== 'mobile') return null;
|
|
293
|
+
const androidDevice =
|
|
294
|
+
input?.node?.android_device ??
|
|
295
|
+
process.env.ANDROID_DEVICE;
|
|
296
|
+
const adbSerial =
|
|
297
|
+
input?.node?.adb_serial ??
|
|
298
|
+
process.env.ADB_SERIAL ??
|
|
299
|
+
process.env.ANDROID_SERIAL ??
|
|
300
|
+
androidDevice;
|
|
301
|
+
const iosSimulator =
|
|
302
|
+
input?.node?.simulator ??
|
|
303
|
+
input?.node?.ios_simulator ??
|
|
304
|
+
process.env.IOS_SIMULATOR;
|
|
305
|
+
const explicitlyAndroid =
|
|
306
|
+
input?.node?.platform === 'android' ||
|
|
307
|
+
(adbSerial && String(androidDevice ?? '') === String(adbSerial));
|
|
308
|
+
if (!adbSerial || (iosSimulator && !explicitlyAndroid)) return null;
|
|
309
|
+
try {
|
|
310
|
+
await execFileAsync(
|
|
311
|
+
'adb',
|
|
312
|
+
['-s', String(adbSerial), 'reverse', `tcp:${port}`, `tcp:${port}`],
|
|
313
|
+
{ timeout: 5000 },
|
|
314
|
+
);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`Analytics collector could not reverse port ${port} to Android device ${adbSerial}: ${error?.message ?? String(error)}`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return String(adbSerial);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Idempotent: an already-running collector on the same port is reused, so a
|
|
325
|
+
* recipe may call start_capture in several nodes without stacking servers.
|
|
326
|
+
*/
|
|
327
|
+
export async function startCollector(input) {
|
|
328
|
+
const projectRoot = projectRootOf(input);
|
|
329
|
+
const port = collectorPort(input);
|
|
330
|
+
const { eventsFile, stateFile } = await prepareCollectorStorage(projectRoot);
|
|
331
|
+
|
|
332
|
+
const existing = await readState(stateFile);
|
|
333
|
+
const existingHealth = existing?.port ? await collectorHealth(existing.port) : null;
|
|
334
|
+
if (
|
|
335
|
+
existing?.port === port &&
|
|
336
|
+
existing?.instanceId &&
|
|
337
|
+
pidAlive(existing.pid) &&
|
|
338
|
+
existingHealth?.instanceId === existing.instanceId
|
|
339
|
+
) {
|
|
340
|
+
const androidDevice = await prepareAndroidAccess(input, port);
|
|
341
|
+
return { port, pid: existing.pid, eventsFile, reused: true, androidDevice };
|
|
342
|
+
}
|
|
343
|
+
if (existing?.port && existing.port !== port) {
|
|
344
|
+
const stopped = await stopCollector(existing);
|
|
345
|
+
if (!stopped && pidAlive(existing.pid)) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`Refusing to replace unverified analytics collector process ${existing.pid} on port ${existing.port}.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const occupied = await collectorHealth(port);
|
|
353
|
+
if (occupied) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`Analytics collector port ${port} is owned by another instance. Choose another port or stop that collector.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const instanceId = randomUUID();
|
|
360
|
+
const child = spawn(
|
|
361
|
+
process.execPath,
|
|
362
|
+
[
|
|
363
|
+
'--input-type=module',
|
|
364
|
+
'--eval',
|
|
365
|
+
collectorServerSource(),
|
|
366
|
+
String(port),
|
|
367
|
+
eventsFile,
|
|
368
|
+
instanceId,
|
|
369
|
+
],
|
|
370
|
+
{
|
|
371
|
+
detached: true,
|
|
372
|
+
stdio: 'ignore',
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
child.unref();
|
|
376
|
+
|
|
377
|
+
// The server binds asynchronously; fail loudly rather than let a later
|
|
378
|
+
// read_events return an empty list that looks like "no events emitted".
|
|
379
|
+
const deadline = Date.now() + 5000;
|
|
380
|
+
while (Date.now() < deadline) {
|
|
381
|
+
if ((await collectorHealth(port))?.instanceId === instanceId) {
|
|
382
|
+
try {
|
|
383
|
+
await writePrivateState(stateFile, { pid: child.pid, port, eventsFile, instanceId });
|
|
384
|
+
const androidDevice = await prepareAndroidAccess(input, port);
|
|
385
|
+
return {
|
|
386
|
+
port,
|
|
387
|
+
pid: child.pid,
|
|
388
|
+
eventsFile,
|
|
389
|
+
reused: false,
|
|
390
|
+
androidDevice,
|
|
391
|
+
};
|
|
392
|
+
} catch (error) {
|
|
393
|
+
await stopSpawnedCollector(child.pid);
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
398
|
+
}
|
|
399
|
+
await stopSpawnedCollector(child.pid);
|
|
400
|
+
throw new Error(`Segment collector failed to bind port ${port} within 5000ms.`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function readCollected(input, { since = 0, event = null } = {}) {
|
|
404
|
+
const { eventsFile } = await prepareCollectorStorage(projectRootOf(input));
|
|
405
|
+
let raw = '';
|
|
406
|
+
try {
|
|
407
|
+
raw = await readPrivateFile(eventsFile);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (error?.code === 'ENOENT') return [];
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
412
|
+
const events = raw
|
|
413
|
+
.split('\n')
|
|
414
|
+
.filter(Boolean)
|
|
415
|
+
.map((line, index) => {
|
|
416
|
+
try {
|
|
417
|
+
return JSON.parse(line);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`Analytics capture is corrupt at JSONL record ${index + 1}: ${error?.message ?? String(error)}`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
.filter((entry) => entry.ts >= since);
|
|
425
|
+
return event ? events.filter((entry) => entry.event === event) : events;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// --- server mode (spawned detached by startCollector) ---
|
|
429
|
+
|
|
430
|
+
function eventsFromBody(body) {
|
|
431
|
+
// The Segment node SDK posts `{ batch: [...] }` to /v1/batch; the HTTP API
|
|
432
|
+
// also accepts a single event object on /v1/track.
|
|
433
|
+
const items = Array.isArray(body?.batch) ? body.batch : [body];
|
|
434
|
+
return items
|
|
435
|
+
.filter((item) => item && typeof item === 'object')
|
|
436
|
+
.map((item) => ({
|
|
437
|
+
type: item.type ?? null,
|
|
438
|
+
event: item.event ?? null,
|
|
439
|
+
properties: item.properties ?? {},
|
|
440
|
+
userId: item.userId ?? null,
|
|
441
|
+
anonymousId: item.anonymousId ?? null,
|
|
442
|
+
sentAt: item.timestamp ?? item.sentAt ?? null,
|
|
443
|
+
ts: Date.now(),
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function serve(port, eventsFile, instanceId) {
|
|
448
|
+
const server = createServer((request, response) => {
|
|
449
|
+
// The extension's Segment client runs in a page context, so preflight has
|
|
450
|
+
// to pass or nothing is ever delivered.
|
|
451
|
+
const cors = {
|
|
452
|
+
'Access-Control-Allow-Origin': '*',
|
|
453
|
+
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
|
454
|
+
'Access-Control-Allow-Headers': '*',
|
|
455
|
+
};
|
|
456
|
+
if (request.method === 'OPTIONS') {
|
|
457
|
+
response.writeHead(204, cors);
|
|
458
|
+
response.end();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (request.method === 'GET') {
|
|
462
|
+
response.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
463
|
+
response.end(JSON.stringify({
|
|
464
|
+
collector: 'metamask-harness',
|
|
465
|
+
port,
|
|
466
|
+
instanceId,
|
|
467
|
+
}));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const chunks = [];
|
|
472
|
+
request.on('data', (chunk) => chunks.push(chunk));
|
|
473
|
+
request.on('end', async () => {
|
|
474
|
+
try {
|
|
475
|
+
const events = eventsFromBody(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
476
|
+
if (events.length) {
|
|
477
|
+
await appendPrivateFile(
|
|
478
|
+
eventsFile,
|
|
479
|
+
`${events.map((event) => JSON.stringify(event)).join('\n')}\n`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
// Segment clients retry on non-2xx, so acknowledge only after the
|
|
483
|
+
// payload is appended.
|
|
484
|
+
response.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
485
|
+
response.end('{}');
|
|
486
|
+
} catch {
|
|
487
|
+
response.writeHead(500, { ...cors, 'Content-Type': 'application/json' });
|
|
488
|
+
response.end('{"error":"collector_write_failed"}');
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
server.listen(port, '127.0.0.1');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function collectorServerSource() {
|
|
496
|
+
return [
|
|
497
|
+
`import { createServer } from 'node:http';`,
|
|
498
|
+
`import { constants } from 'node:fs';`,
|
|
499
|
+
`import { open } from 'node:fs/promises';`,
|
|
500
|
+
`const appendPrivateFile = ${appendPrivateFile.toString()};`,
|
|
501
|
+
`const eventsFromBody = ${eventsFromBody.toString()};`,
|
|
502
|
+
`const serve = ${serve.toString()};`,
|
|
503
|
+
`serve(Number(process.argv[1]), process.argv[2], process.argv[3]);`,
|
|
504
|
+
].join('\n');
|
|
505
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function consentParams(node) {
|
|
2
|
+
const optional = (value, fallback) =>
|
|
3
|
+
value === undefined || value === null || value === '' ? fallback : value;
|
|
4
|
+
const participate = Boolean(optional(node?.participate, true));
|
|
5
|
+
const marketing = Boolean(optional(node?.marketing, participate));
|
|
6
|
+
const timeoutMs = Number(optional(node?.timeout_ms, 15000));
|
|
7
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
8
|
+
throw new Error('metamask.analytics.set_consent timeout_ms must be a positive number.');
|
|
9
|
+
}
|
|
10
|
+
if (marketing && !participate) {
|
|
11
|
+
throw new Error('metamask.analytics.set_consent cannot enable marketing collection while MetaMetrics participation is disabled.');
|
|
12
|
+
}
|
|
13
|
+
return { participate, marketing, timeoutMs };
|
|
14
|
+
}
|