@deeeed/metamask-harness 0.24.0 → 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 +25 -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 +3 -6
- package/dist/run-recording.js +156 -39
- 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/extension.action-manifest.json +188 -1
- package/library/manifests/mobile.action-manifest.json +161 -0
- package/package.json +3 -3
package/dist/run-recording.js
CHANGED
|
@@ -31,23 +31,25 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
|
|
|
31
31
|
}
|
|
32
32
|
const recordArgs = ["record", "--framed", "--pid", String(pid)];
|
|
33
33
|
const relativePath = "videos/full-run.mp4";
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
fs.rmSync(outputPath, { force: true });
|
|
37
|
-
const child = spawn(captureHelperPath(), [...recordArgs, "--output", outputPath], {
|
|
34
|
+
const stage = preparePrivateRecordingStage(artifactsDir, relativePath);
|
|
35
|
+
const child = spawn(captureHelperPath(), [...recordArgs, "--output", stage.stagedOutputPath], {
|
|
38
36
|
cwd: projectRoot,
|
|
39
37
|
env: process.env,
|
|
40
38
|
stdio: ["pipe", "pipe", "pipe"]
|
|
41
39
|
});
|
|
42
40
|
const recording = {
|
|
43
41
|
child,
|
|
44
|
-
outputPath,
|
|
42
|
+
outputPath: stage.outputPath,
|
|
43
|
+
stagedOutputPath: stage.stagedOutputPath,
|
|
44
|
+
stagingDir: stage.stagingDir,
|
|
45
45
|
relativePath,
|
|
46
46
|
pid,
|
|
47
47
|
stdout: "",
|
|
48
48
|
stderr: "",
|
|
49
49
|
exited: false,
|
|
50
50
|
exitCode: null,
|
|
51
|
+
frameReady: false,
|
|
52
|
+
finalized: false,
|
|
51
53
|
stderrBuffer: "",
|
|
52
54
|
pendingSnapshots: /* @__PURE__ */ new Map()
|
|
53
55
|
};
|
|
@@ -69,15 +71,25 @@ async function startRecipeRecording(adapter, projectRoot, artifactsDir, options)
|
|
|
69
71
|
activeRecordingsByPid.delete(recording.pid);
|
|
70
72
|
rejectPendingSnapshots(recording, new Error(`capture-helper recording exited before snapshot completed (code=${exitCode ?? "unknown"})`));
|
|
71
73
|
});
|
|
72
|
-
await
|
|
74
|
+
await waitForRecordingReady(recording, 15e3);
|
|
73
75
|
if (recording.exited) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
cleanupRecordingStage(recording);
|
|
77
|
+
throw new Error(
|
|
78
|
+
`capture-helper exited before recording its first frame (code=${recording.exitCode ?? "unknown"}): ${recording.stderr || recording.stdout}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (!recording.frameReady) {
|
|
82
|
+
try {
|
|
83
|
+
await stopRecordingProcess(recording);
|
|
84
|
+
} finally {
|
|
85
|
+
cleanupRecordingStage(recording);
|
|
86
|
+
}
|
|
87
|
+
throw new Error(
|
|
88
|
+
`capture-helper did not record a frame within 15000ms: ${recording.stderr || recording.stdout || "no recorder output"}`
|
|
76
89
|
);
|
|
77
|
-
return void 0;
|
|
78
90
|
}
|
|
79
91
|
activeRecordingsByPid.set(pid, recording);
|
|
80
|
-
console.error(`INFO: recording recipe video with capture-helper pid=${pid} output=${outputPath}`);
|
|
92
|
+
console.error(`INFO: recording recipe video with capture-helper pid=${pid} output=${recording.outputPath}`);
|
|
81
93
|
return recording;
|
|
82
94
|
}
|
|
83
95
|
async function captureActiveRecipeRecordingSnapshot(pid, outputPath, timeoutMs = 3e4) {
|
|
@@ -101,8 +113,36 @@ async function captureActiveRecipeRecordingSnapshot(pid, outputPath, timeoutMs =
|
|
|
101
113
|
});
|
|
102
114
|
}
|
|
103
115
|
async function stopRecipeRecording(recording, result) {
|
|
104
|
-
if (!recording) return;
|
|
105
|
-
|
|
116
|
+
if (!recording || recording.finalized) return;
|
|
117
|
+
recording.finalized = true;
|
|
118
|
+
try {
|
|
119
|
+
await stopRecordingProcess(recording);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
cleanupRecordingStage(recording);
|
|
122
|
+
if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
const validation = validateRecordingArtifact(recording);
|
|
126
|
+
if (validation.ok === false) {
|
|
127
|
+
cleanupRecordingStage(recording);
|
|
128
|
+
if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
|
|
129
|
+
throw new Error(
|
|
130
|
+
`capture-helper recording did not produce a usable video artifact: ${validation.reason}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
publishPrivateRecording(recording);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
cleanupRecordingStage(recording);
|
|
137
|
+
if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
|
|
138
|
+
throw new Error(
|
|
139
|
+
`capture-helper recording could not safely publish ${recording.outputPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (result) addRecordingArtifactToManifest(result, recording);
|
|
143
|
+
}
|
|
144
|
+
async function stopRecordingProcess(recording) {
|
|
145
|
+
if (!recording.exited && !recording.child.stdin.destroyed) {
|
|
106
146
|
recording.child.stdin.end("stop\n");
|
|
107
147
|
await waitForRecordingExit(recording, 15e3);
|
|
108
148
|
}
|
|
@@ -114,33 +154,33 @@ async function stopRecipeRecording(recording, result) {
|
|
|
114
154
|
recording.child.kill("SIGTERM");
|
|
115
155
|
await waitForRecordingExit(recording, 3e3);
|
|
116
156
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
`WARN: could not remove unusable capture-helper video ${recording.outputPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
console.error(
|
|
127
|
-
`WARN: capture-helper recording did not produce a usable video artifact: ${validation.reason}`
|
|
128
|
-
);
|
|
129
|
-
if (result) removeRecordingArtifactFromManifest(result, recording.relativePath);
|
|
130
|
-
return;
|
|
157
|
+
if (!recording.exited) {
|
|
158
|
+
recording.child.kill("SIGKILL");
|
|
159
|
+
await waitForRecordingExit(recording, 2e3);
|
|
160
|
+
}
|
|
161
|
+
if (!recording.exited) {
|
|
162
|
+
throw new Error(`capture-helper recording process did not exit: pid=${recording.child.pid ?? "unknown"}`);
|
|
131
163
|
}
|
|
132
|
-
if (result) addRecordingArtifactToManifest(result, recording);
|
|
133
164
|
}
|
|
134
165
|
async function waitForRecordingExit(recording, timeoutMs) {
|
|
135
166
|
if (recording.exited) return;
|
|
136
167
|
await new Promise((resolve) => {
|
|
137
|
-
const
|
|
138
|
-
recording.child.once("close", () => {
|
|
168
|
+
const finish = () => {
|
|
139
169
|
clearTimeout(timer);
|
|
170
|
+
recording.child.removeListener("close", finish);
|
|
140
171
|
resolve();
|
|
141
|
-
}
|
|
172
|
+
};
|
|
173
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
174
|
+
recording.child.once("close", finish);
|
|
175
|
+
if (recording.exited) finish();
|
|
142
176
|
});
|
|
143
177
|
}
|
|
178
|
+
async function waitForRecordingReady(recording, timeoutMs) {
|
|
179
|
+
const deadline = Date.now() + timeoutMs;
|
|
180
|
+
while (!recording.frameReady && !recording.exited && Date.now() < deadline) {
|
|
181
|
+
await sleep(50);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
144
184
|
function handleRecordingStderr(recording, chunk) {
|
|
145
185
|
recording.stderrBuffer += chunk;
|
|
146
186
|
let newlineIndex = recording.stderrBuffer.indexOf("\n");
|
|
@@ -160,6 +200,9 @@ function handleRecordingEventLine(recording, line) {
|
|
|
160
200
|
} catch {
|
|
161
201
|
return;
|
|
162
202
|
}
|
|
203
|
+
if (event.type === "info" && typeof event.msg === "string" && /^record frames=[1-9]\d*$/.test(event.msg)) {
|
|
204
|
+
recording.frameReady = true;
|
|
205
|
+
}
|
|
163
206
|
const output = typeof event.output === "string" ? event.output : void 0;
|
|
164
207
|
if (!output) return;
|
|
165
208
|
const pending = recording.pendingSnapshots.get(output);
|
|
@@ -184,36 +227,39 @@ function rejectPendingSnapshots(recording, error) {
|
|
|
184
227
|
recording.pendingSnapshots.clear();
|
|
185
228
|
}
|
|
186
229
|
function validateRecordingArtifact(recording) {
|
|
187
|
-
if (!fs.existsSync(recording.
|
|
188
|
-
return { ok: false, reason: `missing output ${recording.
|
|
230
|
+
if (!fs.existsSync(recording.stagedOutputPath)) {
|
|
231
|
+
return { ok: false, reason: `missing output ${recording.stagedOutputPath}` };
|
|
189
232
|
}
|
|
190
|
-
const
|
|
191
|
-
if (
|
|
192
|
-
return { ok: false, reason: `
|
|
233
|
+
const info = fs.lstatSync(recording.stagedOutputPath);
|
|
234
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
235
|
+
return { ok: false, reason: `output is not a safe regular file ${recording.stagedOutputPath}` };
|
|
236
|
+
}
|
|
237
|
+
if (info.size === 0) {
|
|
238
|
+
return { ok: false, reason: `empty output ${recording.stagedOutputPath}` };
|
|
193
239
|
}
|
|
194
240
|
const recorderOutput = `${recording.stdout}
|
|
195
241
|
${recording.stderr}`;
|
|
196
242
|
if (!recorderOutput.includes("record_complete")) {
|
|
197
243
|
return {
|
|
198
244
|
ok: false,
|
|
199
|
-
reason: `capture-helper did not report record_complete for ${recording.
|
|
245
|
+
reason: `capture-helper did not report record_complete for ${recording.stagedOutputPath}: ${recorderOutput.trim() || "no recorder output"}`
|
|
200
246
|
};
|
|
201
247
|
}
|
|
202
248
|
const ffprobe = spawnSync(
|
|
203
249
|
"ffprobe",
|
|
204
|
-
["-hide_banner", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", recording.
|
|
250
|
+
["-hide_banner", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", recording.stagedOutputPath],
|
|
205
251
|
{ encoding: "utf8" }
|
|
206
252
|
);
|
|
207
253
|
if (ffprobe.error && ffprobe.error.code === "ENOENT") {
|
|
208
254
|
return { ok: true };
|
|
209
255
|
}
|
|
210
256
|
if (ffprobe.error) {
|
|
211
|
-
return { ok: false, reason: `ffprobe failed for ${recording.
|
|
257
|
+
return { ok: false, reason: `ffprobe failed for ${recording.stagedOutputPath}: ${ffprobe.error.message}` };
|
|
212
258
|
}
|
|
213
259
|
if (ffprobe.status !== 0) {
|
|
214
260
|
return {
|
|
215
261
|
ok: false,
|
|
216
|
-
reason: `invalid MP4 ${recording.
|
|
262
|
+
reason: `invalid MP4 ${recording.stagedOutputPath}: ${ffprobe.stderr.trim() || ffprobe.stdout.trim() || `ffprobe exited ${ffprobe.status}`}`
|
|
217
263
|
};
|
|
218
264
|
}
|
|
219
265
|
const durationSeconds = Number(ffprobe.stdout.trim());
|
|
@@ -222,6 +268,77 @@ ${recording.stderr}`;
|
|
|
222
268
|
}
|
|
223
269
|
return { ok: true };
|
|
224
270
|
}
|
|
271
|
+
function preparePrivateRecordingStage(artifactsDir, relativePath) {
|
|
272
|
+
const artifactsRoot = path.resolve(artifactsDir);
|
|
273
|
+
const outputPath = path.resolve(artifactsRoot, relativePath);
|
|
274
|
+
if (!outputPath.startsWith(`${artifactsRoot}${path.sep}`)) {
|
|
275
|
+
throw new Error(`Refusing recording artifact outside artifacts dir: ${relativePath}`);
|
|
276
|
+
}
|
|
277
|
+
fs.mkdirSync(artifactsRoot, { recursive: true });
|
|
278
|
+
ensureRecordingParent(artifactsRoot, path.dirname(outputPath));
|
|
279
|
+
refuseUnsafeRecordingDestination(outputPath);
|
|
280
|
+
const stagingDir = fs.mkdtempSync(path.join(artifactsRoot, ".extension-recording-"));
|
|
281
|
+
fs.chmodSync(stagingDir, 448);
|
|
282
|
+
return {
|
|
283
|
+
outputPath,
|
|
284
|
+
stagedOutputPath: path.join(stagingDir, "full-run.mp4"),
|
|
285
|
+
stagingDir
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function ensureRecordingParent(artifactsRoot, parent) {
|
|
289
|
+
const relative = path.relative(artifactsRoot, parent);
|
|
290
|
+
let current = artifactsRoot;
|
|
291
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
292
|
+
current = path.join(current, segment);
|
|
293
|
+
const info = lstatIfPresent(current);
|
|
294
|
+
if (!info) {
|
|
295
|
+
fs.mkdirSync(current, { mode: 448 });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
299
|
+
throw new Error(`Refusing recording artifact parent that is not a real directory: ${current}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function refuseUnsafeRecordingDestination(outputPath) {
|
|
304
|
+
const info = lstatIfPresent(outputPath);
|
|
305
|
+
if (!info) return;
|
|
306
|
+
if (info.isSymbolicLink()) {
|
|
307
|
+
throw new Error(`Refusing recording artifact destination symlink: ${outputPath}`);
|
|
308
|
+
}
|
|
309
|
+
if (!info.isFile()) {
|
|
310
|
+
throw new Error(`Refusing recording artifact destination that is not a regular file: ${outputPath}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function lstatIfPresent(filePath) {
|
|
314
|
+
try {
|
|
315
|
+
return fs.lstatSync(filePath);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error.code === "ENOENT") return void 0;
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function publishPrivateRecording(recording) {
|
|
322
|
+
const descriptor = fs.openSync(
|
|
323
|
+
recording.stagedOutputPath,
|
|
324
|
+
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)
|
|
325
|
+
);
|
|
326
|
+
try {
|
|
327
|
+
const info = fs.fstatSync(descriptor);
|
|
328
|
+
if (!info.isFile()) {
|
|
329
|
+
throw new Error(`Captured recording is not a safe regular file: ${recording.stagedOutputPath}`);
|
|
330
|
+
}
|
|
331
|
+
fs.fchmodSync(descriptor, 384);
|
|
332
|
+
refuseUnsafeRecordingDestination(recording.outputPath);
|
|
333
|
+
fs.renameSync(recording.stagedOutputPath, recording.outputPath);
|
|
334
|
+
} finally {
|
|
335
|
+
fs.closeSync(descriptor);
|
|
336
|
+
}
|
|
337
|
+
cleanupRecordingStage(recording);
|
|
338
|
+
}
|
|
339
|
+
function cleanupRecordingStage(recording) {
|
|
340
|
+
fs.rmSync(recording.stagingDir, { recursive: true, force: true });
|
|
341
|
+
}
|
|
225
342
|
function addRecordingArtifactToManifest(result, recording) {
|
|
226
343
|
const manifestPath = result.artifactManifestPath;
|
|
227
344
|
if (!manifestPath || !fs.existsSync(manifestPath)) {
|
|
@@ -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
|
}
|