@deeeed/metamask-harness 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +9 -1
  3. package/adapters/extension/artifact-runtime-state.cjs +128 -0
  4. package/adapters/extension/check-infura-readiness.cjs +102 -0
  5. package/adapters/extension/inject.mjs +2 -0
  6. package/adapters/extension/launch-browser.cjs +22 -0
  7. package/adapters/extension/live.sh +103 -18
  8. package/adapters/extension/readiness.mjs +77 -36
  9. package/adapters/extension/snapshot-dist.sh +88 -3
  10. package/adapters/extension/start-watch.sh +15 -0
  11. package/adapters/extension/verify.sh +6 -2
  12. package/adapters/extension/wallet-fixture-state.cjs +3 -1
  13. package/adapters/manifest.json +25 -1
  14. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +73 -42
  15. package/adapters/mobile/reset-app-data.sh +154 -0
  16. package/adapters/shared/log-tui.mjs +1 -1
  17. package/dist/adapters/extension/artifact-integrity.js +38 -0
  18. package/dist/adapters/extension/extension-id.js +23 -4
  19. package/dist/adapters/extension/product-config.js +29 -1
  20. package/dist/adapters/extension/release-artifact.js +386 -0
  21. package/dist/adapters/extension/runtime-decision.js +161 -20
  22. package/dist/adapters/extension/runtime.js +127 -0
  23. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  24. package/dist/adapters/mobile/release-artifact.js +295 -0
  25. package/dist/adapters.js +29 -4
  26. package/dist/cli-commands.js +1 -1
  27. package/dist/cli.js +2 -2
  28. package/dist/command-contract.js +17 -1
  29. package/dist/commands/call.js +2 -1
  30. package/dist/commands/device-target.js +5 -0
  31. package/dist/commands/fixtures.js +106 -31
  32. package/dist/commands/launch/extension.js +92 -7
  33. package/dist/commands/launch/index.js +13 -0
  34. package/dist/commands/launch/mobile.js +2 -0
  35. package/dist/commands/provision.js +2 -0
  36. package/dist/commands/run-engine.js +89 -5
  37. package/dist/commands/run.js +3 -1
  38. package/dist/commands/runtime-launch.js +178 -10
  39. package/dist/heal-bounds.js +1 -1
  40. package/dist/live-adapter-contract.js +3 -1
  41. package/dist/metamask-action-validation.js +47 -1
  42. package/dist/mm-harness-cli.js +37 -4
  43. package/dist/recipe-security.js +3 -0
  44. package/dist/run-diagnostics.js +1 -1
  45. package/docs/RECIPES.md +29 -0
  46. package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
  47. package/library/actions/extension/perps/perps.mjs +2 -0
  48. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  49. package/library/actions/extension/platform/cdp.mjs +6 -3
  50. package/library/actions/extension/wallet/import.mjs +201 -0
  51. package/library/actions/extension/wallet/reset.mjs +98 -0
  52. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  53. package/library/actions/extension/wallet/state.mjs +1 -0
  54. package/library/actions/mobile/analytics/consent-settings.mjs +112 -0
  55. package/library/actions/mobile/analytics/set_consent.mjs +4 -112
  56. package/library/actions/mobile/platform/bridge.mjs +8 -0
  57. package/library/actions/mobile/platform/observe-ui.mjs +84 -2
  58. package/library/actions/mobile/ui/native-navigation.mjs +225 -0
  59. package/library/actions/mobile/ui/navigate.mjs +7 -0
  60. package/library/actions/mobile/wallet/import.mjs +328 -0
  61. package/library/actions/mobile/wallet/native-ui.mjs +493 -0
  62. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  63. package/library/actions/mobile/wallet/reset-helper.mjs +99 -0
  64. package/library/actions/mobile/wallet/reset.mjs +20 -0
  65. package/library/actions/shared/ui/locators.mjs +7 -0
  66. package/library/actions/shared/wallet/import-source.mjs +101 -0
  67. package/library/manifests/extension.action-manifest.json +228 -0
  68. package/library/manifests/mobile.action-manifest.json +124 -0
  69. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  70. package/library/recipes/wallet/import.recipe.json +102 -0
  71. package/library/recipes/wallet/reset-import.recipe.json +107 -0
  72. package/package.json +1 -1
  73. package/scripts/completions.sh +2 -2
@@ -0,0 +1,386 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { promises as fsp } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { inflateRawSync } from "node:zlib";
7
+ import { extensionTreeSha256 } from "./artifact-integrity.js";
8
+ const MAX_ARCHIVE_BYTES = 768 * 1024 * 1024;
9
+ const MAX_ENTRY_BYTES = 512 * 1024 * 1024;
10
+ const MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024;
11
+ const MAX_ENTRIES = 1e5;
12
+ async function acquireExtensionReleaseArtifact(options) {
13
+ const source = localSource(options.file);
14
+ const expectedVersion = expectedArtifactVersion(options.expectedVersion);
15
+ const expectedSha256 = expectedArtifactSha256(options.expectedSha256);
16
+ const requestedCacheRoot = path.resolve(
17
+ options.cacheRoot ?? path.join(os.homedir(), ".cache", "mm-harness", "extension-artifacts")
18
+ );
19
+ await fsp.mkdir(requestedCacheRoot, { recursive: true, mode: 448 });
20
+ const cacheRoot = await fsp.realpath(requestedCacheRoot);
21
+ const acquired = await inspectLocalArchive(source.path, cacheRoot);
22
+ if (acquired.sha256 !== expectedSha256) {
23
+ if (acquired.temporary) await fsp.rm(path.dirname(acquired.archivePath), { recursive: true });
24
+ throw new Error(`Extension artifact SHA-256 ${acquired.sha256} does not match expected ${expectedSha256}.`);
25
+ }
26
+ const artifactRoot = path.join(cacheRoot, expectedVersion, acquired.sha256);
27
+ const provenancePath = path.join(artifactRoot, "provenance.json");
28
+ const cached = await readCachedArtifact(artifactRoot, provenancePath, acquired.sha256, expectedVersion);
29
+ if (cached) {
30
+ if (acquired.temporary) {
31
+ await fsp.rm(path.dirname(acquired.archivePath), { recursive: true });
32
+ }
33
+ return {
34
+ ...cached,
35
+ source,
36
+ archiveBytes: acquired.bytes,
37
+ cache: "hit"
38
+ };
39
+ }
40
+ if (await pathExists(artifactRoot)) {
41
+ await assertRegularTree(artifactRoot);
42
+ await assertContainedDirectory(artifactRoot, cacheRoot);
43
+ await fsp.rm(artifactRoot, { recursive: true });
44
+ }
45
+ const versionRoot = path.dirname(artifactRoot);
46
+ await fsp.mkdir(versionRoot, { recursive: true, mode: 448 });
47
+ await assertContainedDirectory(versionRoot, cacheRoot);
48
+ const temporaryRoot = await fsp.mkdtemp(path.join(versionRoot, ".extract-"));
49
+ try {
50
+ const contentRoot = path.join(temporaryRoot, "content");
51
+ await fsp.mkdir(contentRoot, { mode: 448 });
52
+ const extensionDir = await extractExtensionArchive(acquired.archivePath, contentRoot);
53
+ const manifestVersion = await validateManifestVersion(extensionDir, expectedVersion);
54
+ const treeSha256 = extensionTreeSha256(extensionDir);
55
+ const relativeExtensionDir = path.relative(temporaryRoot, extensionDir) || ".";
56
+ const provenance = {
57
+ schemaVersion: 1,
58
+ source,
59
+ expectedVersion,
60
+ manifestVersion,
61
+ sha256: acquired.sha256,
62
+ treeSha256,
63
+ archiveBytes: acquired.bytes,
64
+ extensionDir: relativeExtensionDir,
65
+ acquiredAt: (/* @__PURE__ */ new Date()).toISOString()
66
+ };
67
+ await fsp.writeFile(
68
+ path.join(temporaryRoot, "provenance.json"),
69
+ `${JSON.stringify(provenance, null, 2)}
70
+ `,
71
+ { mode: 384 }
72
+ );
73
+ try {
74
+ await fsp.rename(temporaryRoot, artifactRoot);
75
+ } catch (error) {
76
+ if (!isAlreadyExists(error)) throw error;
77
+ await fsp.rm(temporaryRoot, { recursive: true });
78
+ }
79
+ const ready = await readCachedArtifact(artifactRoot, provenancePath, acquired.sha256, expectedVersion);
80
+ if (!ready) throw new Error(`Extension artifact cache could not be validated at ${artifactRoot}.`);
81
+ return {
82
+ ...ready,
83
+ source,
84
+ archiveBytes: acquired.bytes,
85
+ cache: "miss"
86
+ };
87
+ } catch (error) {
88
+ if (await pathExists(temporaryRoot)) await fsp.rm(temporaryRoot, { recursive: true });
89
+ throw error;
90
+ } finally {
91
+ if (acquired.temporary) {
92
+ await fsp.rm(path.dirname(acquired.archivePath), { recursive: true });
93
+ }
94
+ }
95
+ }
96
+ function expectedArtifactSha256(requested) {
97
+ if (!/^[a-f0-9]{64}$/u.test(requested)) {
98
+ throw new Error("--artifact-sha256 must be 64 lowercase hexadecimal characters.");
99
+ }
100
+ return requested;
101
+ }
102
+ async function inspectLocalArchive(archive, cacheRoot) {
103
+ const sourcePath = path.resolve(archive);
104
+ const downloadRoot = path.join(cacheRoot, ".downloads");
105
+ await fsp.mkdir(downloadRoot, { recursive: true, mode: 448 });
106
+ const temporaryRoot = await fsp.mkdtemp(path.join(downloadRoot, "artifact-"));
107
+ const archivePath = path.join(temporaryRoot, "artifact.zip");
108
+ let source;
109
+ try {
110
+ source = await fsp.open(sourcePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
111
+ const stat = await source.stat();
112
+ if (!stat.isFile()) throw new Error(`Extension artifact archive must be a regular file: ${sourcePath}`);
113
+ if (stat.size > MAX_ARCHIVE_BYTES) {
114
+ throw new Error(`Extension artifact exceeds the ${MAX_ARCHIVE_BYTES}-byte archive limit.`);
115
+ }
116
+ const destination = await fsp.open(archivePath, "wx", 384);
117
+ const digest = createHash("sha256");
118
+ let bytes = 0;
119
+ try {
120
+ const stream = source.createReadStream({ autoClose: false });
121
+ for await (const chunk of stream) {
122
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
123
+ bytes += buffer.length;
124
+ if (bytes > MAX_ARCHIVE_BYTES) {
125
+ throw new Error(`Extension artifact exceeds the ${MAX_ARCHIVE_BYTES}-byte archive limit.`);
126
+ }
127
+ digest.update(buffer);
128
+ await destination.write(buffer);
129
+ }
130
+ } finally {
131
+ await destination.close();
132
+ }
133
+ return { archivePath, sha256: digest.digest("hex"), bytes, temporary: true };
134
+ } catch (error) {
135
+ await fsp.rm(temporaryRoot, { recursive: true });
136
+ throw error;
137
+ } finally {
138
+ await source?.close();
139
+ }
140
+ }
141
+ function localSource(archive) {
142
+ return { kind: "local", path: path.resolve(archive) };
143
+ }
144
+ function expectedArtifactVersion(requested) {
145
+ if (!/^\d+\.\d+\.\d+$/u.test(requested)) {
146
+ throw new Error("--artifact-version must use X.Y.Z.");
147
+ }
148
+ return requested;
149
+ }
150
+ async function extractExtensionArchive(archivePath, outputRoot) {
151
+ const archive = await fsp.readFile(archivePath);
152
+ const entries = readZipEntries(archive);
153
+ const manifestEntries = entries.filter((entry) => !entry.directory && path.posix.basename(entry.name) === "manifest.json");
154
+ if (manifestEntries.length !== 1) {
155
+ throw new Error(`Extension artifact must contain exactly one manifest.json; found ${manifestEntries.length}.`);
156
+ }
157
+ const extensionPrefix = path.posix.dirname(manifestEntries[0].name);
158
+ if (extensionPrefix !== ".") {
159
+ const prefix = `${extensionPrefix}/`;
160
+ const outside = entries.find((entry) => entry.name !== extensionPrefix && !entry.name.startsWith(prefix));
161
+ if (outside) {
162
+ throw new Error(`Extension artifact has content outside its manifest root: ${outside.name}.`);
163
+ }
164
+ }
165
+ for (const entry of entries) {
166
+ const destination = path.join(outputRoot, ...entry.name.split("/"));
167
+ if (entry.directory) {
168
+ await fsp.mkdir(destination, { recursive: true, mode: 493 });
169
+ continue;
170
+ }
171
+ await fsp.mkdir(path.dirname(destination), { recursive: true, mode: 493 });
172
+ const compressedStart = localEntryDataOffset(archive, entry);
173
+ const compressed = archive.subarray(compressedStart, compressedStart + entry.compressedSize);
174
+ const content = entry.method === 0 ? compressed : inflateRawSync(compressed, { maxOutputLength: entry.uncompressedSize });
175
+ if (content.length !== entry.uncompressedSize || crc32(content) !== entry.crc32) {
176
+ throw new Error(`Extension artifact entry failed integrity validation: ${entry.name}.`);
177
+ }
178
+ await fsp.writeFile(destination, content, { flag: "wx", mode: 420 });
179
+ }
180
+ return extensionPrefix === "." ? outputRoot : path.join(outputRoot, ...extensionPrefix.split("/"));
181
+ }
182
+ function readZipEntries(archive) {
183
+ const eocdOffset = findEndOfCentralDirectory(archive);
184
+ const disk = archive.readUInt16LE(eocdOffset + 4);
185
+ const centralDisk = archive.readUInt16LE(eocdOffset + 6);
186
+ const entriesOnDisk = archive.readUInt16LE(eocdOffset + 8);
187
+ const entryCount = archive.readUInt16LE(eocdOffset + 10);
188
+ const centralSize = archive.readUInt32LE(eocdOffset + 12);
189
+ const centralOffset = archive.readUInt32LE(eocdOffset + 16);
190
+ if (disk !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount) {
191
+ throw new Error("Multi-disk ZIP archives are not supported.");
192
+ }
193
+ if (entryCount === 65535 || centralSize === 4294967295 || centralOffset === 4294967295) {
194
+ throw new Error("ZIP64 extension artifacts are not supported.");
195
+ }
196
+ if (entryCount === 0 || entryCount > MAX_ENTRIES || centralOffset + centralSize > eocdOffset) {
197
+ throw new Error("Extension artifact has an invalid central directory.");
198
+ }
199
+ const entries = [];
200
+ const names = /* @__PURE__ */ new Set();
201
+ const offsets = /* @__PURE__ */ new Set();
202
+ let expandedBytes = 0;
203
+ let offset = centralOffset;
204
+ for (let index = 0; index < entryCount; index += 1) {
205
+ if (offset + 46 > archive.length || archive.readUInt32LE(offset) !== 33639248) {
206
+ throw new Error("Extension artifact has a malformed central directory entry.");
207
+ }
208
+ const flags = archive.readUInt16LE(offset + 8);
209
+ const method = archive.readUInt16LE(offset + 10);
210
+ const crc = archive.readUInt32LE(offset + 16);
211
+ const compressedSize = archive.readUInt32LE(offset + 20);
212
+ const uncompressedSize = archive.readUInt32LE(offset + 24);
213
+ const nameLength = archive.readUInt16LE(offset + 28);
214
+ const extraLength = archive.readUInt16LE(offset + 30);
215
+ const commentLength = archive.readUInt16LE(offset + 32);
216
+ const madeBy = archive.readUInt16LE(offset + 4) >>> 8;
217
+ const externalAttributes = archive.readUInt32LE(offset + 38);
218
+ const localOffset = archive.readUInt32LE(offset + 42);
219
+ const nextOffset = offset + 46 + nameLength + extraLength + commentLength;
220
+ if (nextOffset > archive.length) throw new Error("Extension artifact has a truncated central directory entry.");
221
+ const name = archive.subarray(offset + 46, offset + 46 + nameLength).toString("utf8");
222
+ validateEntryName(name);
223
+ const directory = name.endsWith("/");
224
+ const unixMode = madeBy === 3 ? externalAttributes >>> 16 : 0;
225
+ if ((unixMode & 61440) === 40960) {
226
+ throw new Error(`Extension artifact contains a symbolic link: ${name}.`);
227
+ }
228
+ if ((flags & 1) !== 0 || method !== 0 && method !== 8) {
229
+ throw new Error(`Extension artifact entry uses unsupported ZIP features: ${name}.`);
230
+ }
231
+ if (names.has(name) || offsets.has(localOffset)) {
232
+ throw new Error(`Extension artifact contains a duplicate entry: ${name}.`);
233
+ }
234
+ if (uncompressedSize > MAX_ENTRY_BYTES || compressedSize > MAX_ENTRY_BYTES) {
235
+ throw new Error(`Extension artifact entry is too large: ${name}.`);
236
+ }
237
+ if (compressedSize > 0 && uncompressedSize / compressedSize > 1e3) {
238
+ throw new Error(`Extension artifact entry has an unsafe compression ratio: ${name}.`);
239
+ }
240
+ expandedBytes += uncompressedSize;
241
+ if (expandedBytes > MAX_EXPANDED_BYTES) {
242
+ throw new Error(`Extension artifact exceeds the ${MAX_EXPANDED_BYTES}-byte expansion limit.`);
243
+ }
244
+ names.add(name);
245
+ offsets.add(localOffset);
246
+ entries.push({ name, directory, method, compressedSize, uncompressedSize, crc32: crc, localOffset });
247
+ offset = nextOffset;
248
+ }
249
+ if (offset !== centralOffset + centralSize) throw new Error("Extension artifact central directory size does not match.");
250
+ return entries;
251
+ }
252
+ function validateEntryName(name) {
253
+ const withoutSlash = name.endsWith("/") ? name.slice(0, -1) : name;
254
+ const segments = withoutSlash.split("/");
255
+ if (!withoutSlash || name.includes("\\") || name.includes("\0") || name.includes("\uFFFD") || name.startsWith("/") || /^[A-Za-z]:/u.test(name) || segments.some((segment) => !segment || segment === "." || segment === "..") || path.posix.normalize(withoutSlash) !== withoutSlash) {
256
+ throw new Error(`Extension artifact contains an unsafe path: ${JSON.stringify(name)}.`);
257
+ }
258
+ }
259
+ function localEntryDataOffset(archive, entry) {
260
+ const offset = entry.localOffset;
261
+ if (offset + 30 > archive.length || archive.readUInt32LE(offset) !== 67324752) {
262
+ throw new Error(`Extension artifact has a malformed local entry: ${entry.name}.`);
263
+ }
264
+ const flags = archive.readUInt16LE(offset + 6);
265
+ const method = archive.readUInt16LE(offset + 8);
266
+ const nameLength = archive.readUInt16LE(offset + 26);
267
+ const extraLength = archive.readUInt16LE(offset + 28);
268
+ const localName = archive.subarray(offset + 30, offset + 30 + nameLength).toString("utf8");
269
+ const dataOffset = offset + 30 + nameLength + extraLength;
270
+ if (localName !== entry.name || method !== entry.method || (flags & 1) !== 0 || dataOffset + entry.compressedSize > archive.length) {
271
+ throw new Error(`Extension artifact local entry does not match its directory record: ${entry.name}.`);
272
+ }
273
+ return dataOffset;
274
+ }
275
+ function findEndOfCentralDirectory(archive) {
276
+ const start = Math.max(0, archive.length - 65557);
277
+ for (let offset = archive.length - 22; offset >= start; offset -= 1) {
278
+ if (archive.readUInt32LE(offset) === 101010256) {
279
+ const commentLength = archive.readUInt16LE(offset + 20);
280
+ if (offset + 22 + commentLength === archive.length) return offset;
281
+ }
282
+ }
283
+ throw new Error("Extension artifact is not a valid ZIP archive.");
284
+ }
285
+ async function validateManifestVersion(extensionDir, expectedVersion) {
286
+ await assertRegularTree(extensionDir);
287
+ const manifestPath = path.join(extensionDir, "manifest.json");
288
+ const manifestStat = await fsp.lstat(manifestPath).catch(() => null);
289
+ if (!manifestStat?.isFile() || manifestStat.isSymbolicLink() || manifestStat.size > 2 * 1024 * 1024) {
290
+ throw new Error("Extension artifact manifest.json must be a regular file smaller than 2 MiB.");
291
+ }
292
+ let manifest;
293
+ try {
294
+ manifest = JSON.parse(await fsp.readFile(manifestPath, "utf8"));
295
+ } catch {
296
+ throw new Error("Extension artifact manifest.json is not valid JSON.");
297
+ }
298
+ const version = typeof manifest === "object" && manifest !== null && "version" in manifest ? manifest.version : void 0;
299
+ if (typeof version !== "string" || normalizeManifestVersion(version) !== expectedVersion) {
300
+ throw new Error(`Extension artifact manifest version ${String(version ?? "missing")} does not match expected ${expectedVersion}.`);
301
+ }
302
+ return version;
303
+ }
304
+ function normalizeManifestVersion(version) {
305
+ if (!/^\d+\.\d+\.\d+(?:\.\d+)?$/u.test(version)) return null;
306
+ const parts = version.split(".");
307
+ if (parts.length === 4 && parts[3] !== "0") return null;
308
+ return parts.slice(0, 3).join(".");
309
+ }
310
+ async function assertRegularTree(root) {
311
+ const rootStat = await fsp.lstat(root).catch(() => null);
312
+ if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
313
+ throw new Error(`Extension artifact root must be a regular directory: ${root}`);
314
+ }
315
+ const entries = await fsp.readdir(root, { withFileTypes: true });
316
+ for (const entry of entries) {
317
+ const child = path.join(root, entry.name);
318
+ const stat = await fsp.lstat(child);
319
+ if (stat.isSymbolicLink() || !stat.isDirectory() && !stat.isFile()) {
320
+ throw new Error(`Extension artifact cache contains an unsupported entry: ${child}`);
321
+ }
322
+ if (stat.isDirectory()) await assertRegularTree(child);
323
+ }
324
+ }
325
+ async function assertContainedDirectory(directory, root) {
326
+ const resolvedRoot = await fsp.realpath(root);
327
+ const resolvedDirectory = await fsp.realpath(directory);
328
+ if (resolvedDirectory !== resolvedRoot && !resolvedDirectory.startsWith(`${resolvedRoot}${path.sep}`)) {
329
+ throw new Error(`Extension artifact cache path escapes its root: ${directory}`);
330
+ }
331
+ let current = resolvedDirectory;
332
+ while (current !== resolvedRoot) {
333
+ const stat = await fsp.lstat(current);
334
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
335
+ throw new Error(`Extension artifact cache path is not a regular directory: ${current}`);
336
+ }
337
+ const parent = path.dirname(current);
338
+ if (parent === current) throw new Error(`Extension artifact cache path escapes its root: ${directory}`);
339
+ current = parent;
340
+ }
341
+ }
342
+ async function readCachedArtifact(artifactRoot, provenancePath, sha256, expectedVersion) {
343
+ try {
344
+ const provenanceStat = await fsp.lstat(provenancePath);
345
+ if (!provenanceStat.isFile() || provenanceStat.isSymbolicLink() || provenanceStat.size > 64 * 1024) return null;
346
+ const provenance = JSON.parse(await fsp.readFile(provenancePath, "utf8"));
347
+ if (provenance.schemaVersion !== 1 || provenance.sha256 !== sha256 || provenance.expectedVersion !== expectedVersion || typeof provenance.treeSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(provenance.treeSha256) || typeof provenance.extensionDir !== "string" || path.isAbsolute(provenance.extensionDir) || provenance.extensionDir.split(path.sep).includes("..")) return null;
348
+ const extensionDir = path.resolve(artifactRoot, provenance.extensionDir);
349
+ if (extensionDir !== artifactRoot && !extensionDir.startsWith(`${artifactRoot}${path.sep}`)) return null;
350
+ const manifestVersion = await validateManifestVersion(extensionDir, expectedVersion);
351
+ const treeSha256 = extensionTreeSha256(extensionDir);
352
+ if (treeSha256 !== provenance.treeSha256) return null;
353
+ return {
354
+ schemaVersion: 1,
355
+ expectedVersion,
356
+ manifestVersion,
357
+ sha256,
358
+ treeSha256,
359
+ extensionDir,
360
+ provenancePath
361
+ };
362
+ } catch {
363
+ return null;
364
+ }
365
+ }
366
+ function isAlreadyExists(error) {
367
+ return Boolean(
368
+ error && typeof error === "object" && "code" in error && ["EEXIST", "ENOTEMPTY"].includes(String(error.code))
369
+ );
370
+ }
371
+ async function pathExists(file) {
372
+ return fsp.access(file).then(() => true, () => false);
373
+ }
374
+ const CRC_TABLE = Array.from({ length: 256 }, (_, initial) => {
375
+ let value = initial;
376
+ for (let bit = 0; bit < 8; bit += 1) value = (value & 1) !== 0 ? 3988292384 ^ value >>> 1 : value >>> 1;
377
+ return value >>> 0;
378
+ });
379
+ function crc32(buffer) {
380
+ let value = 4294967295;
381
+ for (const byte of buffer) value = CRC_TABLE[(value ^ byte) & 255] ^ value >>> 8;
382
+ return (value ^ 4294967295) >>> 0;
383
+ }
384
+ export {
385
+ acquireExtensionReleaseArtifact
386
+ };
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { extensionTreeSha256 } from "./artifact-integrity.js";
5
6
  import {
6
7
  depsCheck,
7
8
  INSTALL_MARKERS
@@ -151,21 +152,106 @@ function latestDistMtime(target) {
151
152
  walk2(distDir);
152
153
  return latest;
153
154
  }
154
- function runtimeDistCheck(target) {
155
- const dist = path.join(target, "dist/chrome");
155
+ function releaseArtifactState(target) {
156
+ const runtimeRoot = path.join(target, recipeRuntimeDir());
157
+ const statePath = path.join(runtimeRoot, "extension-release-artifact.json");
158
+ if (!fs.existsSync(statePath)) return { status: "none" };
159
+ try {
160
+ const stateStat = fs.lstatSync(statePath);
161
+ if (!stateStat.isFile() || stateStat.isSymbolicLink() || stateStat.size <= 0 || stateStat.size > 64 * 1024) {
162
+ return { status: "invalid", reason: "artifact runtime identity is not a regular bounded file" };
163
+ }
164
+ const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
165
+ const sourceDir = typeof state.sourceDir === "string" ? path.resolve(state.sourceDir) : "";
166
+ const runtimeDist = typeof state.runtimeDist === "string" ? path.resolve(state.runtimeDist) : "";
167
+ const provenancePath = typeof state.provenancePath === "string" ? path.resolve(state.provenancePath) : "";
168
+ const expectedRuntimeDist = path.join(
169
+ runtimeRoot,
170
+ process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist"
171
+ );
172
+ if (state.schemaVersion !== 1 || !sourceDir || runtimeDist !== expectedRuntimeDist || !provenancePath) {
173
+ return { status: "invalid", reason: "artifact runtime identity has invalid paths or schema" };
174
+ }
175
+ const provenanceStat = fs.lstatSync(provenancePath);
176
+ if (!provenanceStat.isFile() || provenanceStat.isSymbolicLink() || provenanceStat.size <= 0 || provenanceStat.size > 64 * 1024) {
177
+ return { status: "invalid", reason: "artifact provenance is not a regular bounded file" };
178
+ }
179
+ const provenance = JSON.parse(fs.readFileSync(provenancePath, "utf8"));
180
+ const relativeExtensionDir = typeof provenance.extensionDir === "string" ? provenance.extensionDir : "";
181
+ const boundSource = relativeExtensionDir && !path.isAbsolute(relativeExtensionDir) && !relativeExtensionDir.split(/[\\/]+/u).includes("..") ? path.resolve(path.dirname(provenancePath), relativeExtensionDir) : "";
182
+ const sha256 = typeof provenance.sha256 === "string" ? provenance.sha256 : "";
183
+ const expectedVersion = typeof provenance.expectedVersion === "string" ? provenance.expectedVersion : "";
184
+ const manifestVersion = typeof provenance.manifestVersion === "string" ? provenance.manifestVersion : "";
185
+ const treeSha256 = typeof provenance.treeSha256 === "string" ? provenance.treeSha256 : "";
186
+ if (provenance.schemaVersion !== 1 || boundSource !== sourceDir || !/^[a-f0-9]{64}$/u.test(sha256) || !/^[a-f0-9]{64}$/u.test(treeSha256) || !/^\d+\.\d+\.\d+$/u.test(expectedVersion) || state.sha256 !== sha256 || state.treeSha256 !== treeSha256 || state.expectedVersion !== expectedVersion || state.manifestVersion !== manifestVersion) {
187
+ return { status: "invalid", reason: "artifact runtime identity does not match its provenance" };
188
+ }
189
+ const manifest = JSON.parse(fs.readFileSync(path.join(runtimeDist, "manifest.json"), "utf8"));
190
+ const runtimeVersion = typeof manifest.version === "string" ? manifest.version : "";
191
+ if (normalizeReleaseVersion(runtimeVersion) !== expectedVersion || runtimeVersion !== manifestVersion) {
192
+ return { status: "invalid", reason: "artifact runtime manifest does not match its provenance" };
193
+ }
194
+ if (extensionTreeSha256(sourceDir) !== treeSha256 || extensionTreeSha256(runtimeDist) !== treeSha256) {
195
+ return { status: "invalid", reason: "artifact source or runtime tree does not match its provenance" };
196
+ }
197
+ return {
198
+ status: "valid",
199
+ sourceDir,
200
+ runtimeDist,
201
+ provenancePath,
202
+ sha256,
203
+ treeSha256,
204
+ expectedVersion,
205
+ manifestVersion
206
+ };
207
+ } catch (error) {
208
+ return {
209
+ status: "invalid",
210
+ reason: error instanceof Error ? error.message : String(error)
211
+ };
212
+ }
213
+ }
214
+ function normalizeReleaseVersion(version) {
215
+ if (!/^\d+\.\d+\.\d+(?:\.\d+)?$/u.test(version)) return null;
216
+ const parts = version.split(".");
217
+ if (parts.length === 4 && parts[3] !== "0") return null;
218
+ return parts.slice(0, 3).join(".");
219
+ }
220
+ function releaseArtifactDistCheck(artifact) {
221
+ return {
222
+ status: "fresh",
223
+ source: "release-artifact",
224
+ manifestPath: path.join(artifact.runtimeDist, "manifest.json"),
225
+ manifestVersion: artifact.manifestVersion,
226
+ artifactSha256: artifact.sha256
227
+ };
228
+ }
229
+ function runtimeDistCheck(target, artifact = releaseArtifactState(target)) {
230
+ if (artifact.status === "invalid") {
231
+ return {
232
+ status: "stale",
233
+ source: "release-artifact",
234
+ artifactError: artifact.reason,
235
+ modified: ["<artifact identity invalid>"]
236
+ };
237
+ }
238
+ const dist = artifact.status === "valid" ? artifact.sourceDir : path.join(target, "dist/chrome");
156
239
  const runtimeDist = path.join(
157
240
  target,
158
241
  recipeRuntimeDir(),
159
242
  process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist"
160
243
  );
161
- if (!fs.existsSync(path.join(runtimeDist, "manifest.json"))) return { status: "missing" };
244
+ const identity = artifact.status === "valid" ? {
245
+ source: "release-artifact",
246
+ artifactSha256: artifact.sha256,
247
+ provenancePath: artifact.provenancePath
248
+ } : {};
249
+ if (!fs.existsSync(path.join(runtimeDist, "manifest.json"))) return { status: "missing", ...identity };
162
250
  let output;
163
251
  try {
164
- output = execFileSync("rsync", [
252
+ const rsyncArgs = [
165
253
  "-rnic",
166
254
  "--exclude",
167
- "_metadata",
168
- "--exclude",
169
255
  "home.html",
170
256
  "--exclude",
171
257
  "sidepanel.html",
@@ -174,21 +260,34 @@ function runtimeDistCheck(target) {
174
260
  "--out-format=%n",
175
261
  `${dist}${path.sep}`,
176
262
  `${runtimeDist}${path.sep}`
177
- ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
263
+ ];
264
+ if (artifact.status !== "valid") rsyncArgs.splice(1, 0, "--exclude", "_metadata");
265
+ output = execFileSync("rsync", rsyncArgs, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
178
266
  } catch {
179
- return { status: "stale", modified: ["<snapshot comparison failed>"] };
267
+ return { status: "stale", modified: ["<snapshot comparison failed>"], ...identity };
180
268
  }
181
269
  const modified = output.split("\n").map((line) => line.trim()).filter(Boolean);
182
- for (const name of ["home.html", "sidepanel.html"]) {
183
- const source = path.join(dist, name);
184
- const loaded = path.join(runtimeDist, name);
185
- if (normalizedRuntimeHtml(source) !== normalizedRuntimeHtml(loaded)) modified.push(name);
186
- }
187
- if (normalizedRuntimeManifest(path.join(dist, "manifest.json")) !== normalizedRuntimeManifest(path.join(runtimeDist, "manifest.json"))) {
188
- modified.push("manifest.json");
270
+ if (artifact.status === "valid") {
271
+ for (const name of ["home.html", "sidepanel.html", "manifest.json"]) {
272
+ const source = path.join(dist, name);
273
+ const loaded = path.join(runtimeDist, name);
274
+ if (fileDigest(source) !== fileDigest(loaded)) modified.push(name);
275
+ }
276
+ } else {
277
+ for (const name of ["home.html", "sidepanel.html"]) {
278
+ const source = path.join(dist, name);
279
+ const loaded = path.join(runtimeDist, name);
280
+ if (normalizedRuntimeHtml(source) !== normalizedRuntimeHtml(loaded)) modified.push(name);
281
+ }
282
+ if (normalizedRuntimeManifest(path.join(dist, "manifest.json")) !== normalizedRuntimeManifest(path.join(runtimeDist, "manifest.json"))) {
283
+ modified.push("manifest.json");
284
+ }
189
285
  }
190
286
  const boundedModified = [...new Set(modified)].slice(0, 10);
191
- return boundedModified.length > 0 ? { status: "stale", modified: boundedModified } : { status: "fresh" };
287
+ return boundedModified.length > 0 ? { status: "stale", modified: boundedModified, ...identity } : { status: "fresh", ...identity };
288
+ }
289
+ function fileDigest(file) {
290
+ return fs.existsSync(file) ? crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") : null;
192
291
  }
193
292
  function normalizedRuntimeHtml(file) {
194
293
  if (!fs.existsSync(file)) return null;
@@ -269,10 +368,11 @@ async function decideExtensionReadiness(target, options = {}) {
269
368
  const deps = depsCheck(resolved);
270
369
  const webpackCache = webpackCacheCheck(resolved);
271
370
  const buildLog = buildLogCheck(resolved, options.watchLog);
272
- const dist = distCheck(resolved);
273
- const runtimeDist = runtimeDistCheck(resolved);
371
+ const releaseArtifact = releaseArtifactState(resolved);
372
+ const dist = releaseArtifact.status === "valid" ? releaseArtifactDistCheck(releaseArtifact) : distCheck(resolved);
373
+ const runtimeDist = runtimeDistCheck(resolved, releaseArtifact);
274
374
  const cdp = await cdpCheck(resolved, options.cdpPort, options.pageMode);
275
- const checks = { deps, webpackCache, buildLog, dist, runtimeDist, cdp };
375
+ const checks = { deps, webpackCache, buildLog, dist, runtimeDist, releaseArtifact, cdp };
276
376
  const install = [{ id: "yarn-install", argv: ["yarn", "install", "--immutable"], cwd: resolved }];
277
377
  const relaunch = [{ id: "relaunch-browser" }];
278
378
  const cacheStale = buildLog.reason === "stale-cache";
@@ -355,7 +455,47 @@ async function decideExtensionReadiness(target, options = {}) {
355
455
  reasons: ["Build is fresh; browser liveness unverified (pass --cdp-port to confirm `ready`)."],
356
456
  actions: relaunch
357
457
  };
358
- const core = rules.find((rule) => rule.when) ?? fallback;
458
+ let core;
459
+ if (releaseArtifact.status === "invalid") {
460
+ core = {
461
+ decision: "blocked",
462
+ reasonCode: "release-artifact-identity-invalid",
463
+ reasons: [`The loaded release artifact identity is invalid: ${releaseArtifact.reason ?? "unknown reason"}.`],
464
+ actions: []
465
+ };
466
+ } else if (releaseArtifact.status === "valid") {
467
+ const artifactRules = [
468
+ {
469
+ when: runtimeDist.status !== "fresh",
470
+ decision: "relaunch",
471
+ reasonCode: runtimeDist.status === "missing" ? "release-artifact-runtime-missing" : "release-artifact-runtime-stale",
472
+ reasons: [runtimeDist.status === "missing" ? "The loaded release artifact snapshot is missing." : `The loaded runtime snapshot differs from release artifact ${releaseArtifact.sha256?.slice(0, 12)}.`],
473
+ actions: relaunch
474
+ },
475
+ {
476
+ when: cdp.status === "pass",
477
+ decision: "ready",
478
+ reasonCode: "release-artifact-healthy",
479
+ reasons: [`Release artifact ${releaseArtifact.expectedVersion} is intact and healthy over CDP.`],
480
+ actions: []
481
+ },
482
+ {
483
+ when: cdp.status === "fail",
484
+ decision: "relaunch",
485
+ reasonCode: "runtime-unhealthy",
486
+ reasons: ["The release artifact is intact but the live extension is unhealthy.", ...cdp.findings?.slice(0, 3) ?? []],
487
+ actions: relaunch
488
+ }
489
+ ];
490
+ core = artifactRules.find((rule) => rule.when) ?? {
491
+ decision: "relaunch",
492
+ reasonCode: "cdp-unknown",
493
+ reasons: [`Release artifact ${releaseArtifact.expectedVersion} is intact; browser liveness is unverified.`],
494
+ actions: relaunch
495
+ };
496
+ } else {
497
+ core = rules.find((rule) => rule.when) ?? fallback;
498
+ }
359
499
  return {
360
500
  schemaVersion: 1,
361
501
  adapter: "extension",
@@ -378,5 +518,6 @@ export {
378
518
  decideExtensionReadiness,
379
519
  isExtensionDistStale,
380
520
  recordWebpackBaseline,
521
+ releaseArtifactState,
381
522
  runtimeDistCheck
382
523
  };