@deeeed/metamask-harness 0.36.0 → 0.38.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 (140) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +138 -0
  3. package/adapters/extension/inject.mjs +2 -0
  4. package/adapters/extension/launch-browser.cjs +24 -0
  5. package/adapters/extension/live.sh +82 -12
  6. package/adapters/extension/readiness.mjs +77 -36
  7. package/adapters/extension/snapshot-dist.sh +88 -3
  8. package/adapters/extension/verify.sh +6 -2
  9. package/adapters/manifest.json +17 -1
  10. package/adapters/shared/log-tui.mjs +1 -1
  11. package/dist/adapters/extension/artifact-integrity.js +38 -0
  12. package/dist/adapters/extension/extension-id.js +23 -4
  13. package/dist/adapters/extension/release-artifact.js +386 -0
  14. package/dist/adapters/extension/runtime-decision.js +164 -20
  15. package/dist/adapters/extension/runtime.js +169 -3
  16. package/dist/adapters/mobile/provision.js +38 -3
  17. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  18. package/dist/adapters/mobile/release-artifact.js +295 -0
  19. package/dist/adapters.js +78 -6
  20. package/dist/app-lifecycle.js +4 -3
  21. package/dist/command-contract.js +16 -0
  22. package/dist/commands/call.js +6 -2
  23. package/dist/commands/launch/extension.js +74 -9
  24. package/dist/commands/launch/index.js +3 -0
  25. package/dist/commands/launch/mobile.js +2 -0
  26. package/dist/commands/provision.js +2 -0
  27. package/dist/commands/run-engine.js +109 -14
  28. package/dist/commands/run.js +4 -2
  29. package/dist/commands/runtime-launch.js +178 -10
  30. package/dist/heal-bounds.js +1 -1
  31. package/dist/live-adapter-contract.js +3 -1
  32. package/dist/metamask-action-validation.js +47 -1
  33. package/dist/mm-harness-cli.js +31 -1
  34. package/dist/recipe-security.js +40 -0
  35. package/dist/run-diagnostics.js +1 -1
  36. package/dist/runner.js +3 -0
  37. package/docs/RELEASE-QA-CAPABILITY-MAP.md +125 -0
  38. package/library/actions/extension/assets/finish_send.mjs +128 -0
  39. package/library/actions/extension/assets/import_custom_token.mjs +227 -0
  40. package/library/actions/extension/assets/prepare_send.mjs +53 -0
  41. package/library/actions/extension/assets/read_details.mjs +57 -0
  42. package/library/actions/extension/assets/read_visible_state.mjs +50 -0
  43. package/library/actions/extension/assets/review_send.mjs +23 -0
  44. package/library/actions/extension/assets/select_network_scope.mjs +30 -0
  45. package/library/actions/extension/assets/send.mjs +104 -0
  46. package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
  47. package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
  48. package/library/actions/extension/assets/verify_sorting.mjs +89 -0
  49. package/library/actions/extension/networks/add_chainlist.mjs +94 -0
  50. package/library/actions/extension/networks/add_custom.mjs +123 -0
  51. package/library/actions/extension/networks/custom_network.mjs +103 -0
  52. package/library/actions/extension/networks/read_visible_state.mjs +50 -0
  53. package/library/actions/extension/networks/remove_custom.mjs +68 -0
  54. package/library/actions/extension/perps/close_orders.mjs +1 -0
  55. package/library/actions/extension/perps/close_visible_position.mjs +133 -0
  56. package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
  57. package/library/actions/extension/perps/edit_margin.mjs +82 -0
  58. package/library/actions/extension/perps/open_balance_action.mjs +58 -0
  59. package/library/actions/extension/perps/open_position_action.mjs +80 -0
  60. package/library/actions/extension/perps/perps.mjs +196 -2
  61. package/library/actions/extension/perps/place_order.mjs +5 -2
  62. package/library/actions/extension/perps/read_eligibility.mjs +34 -0
  63. package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
  64. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  65. package/library/actions/extension/perps/read_visible_state.mjs +98 -0
  66. package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
  67. package/library/actions/extension/perps/select_market_filter.mjs +71 -0
  68. package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
  69. package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
  70. package/library/actions/extension/platform/cdp.mjs +109 -3
  71. package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
  72. package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
  73. package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
  74. package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
  75. package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
  76. package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
  77. package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
  78. package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
  79. package/library/actions/extension/ui/navigate.mjs +264 -8
  80. package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
  81. package/library/actions/extension/wallet/import.mjs +13 -46
  82. package/library/actions/extension/wallet/lock.mjs +35 -0
  83. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  84. package/library/actions/extension/wallet/select_account.mjs +71 -7
  85. package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
  86. package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
  87. package/library/actions/mobile/platform/native-session-name.mjs +5 -0
  88. package/library/actions/mobile/platform/observe-ui.mjs +86 -2
  89. package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
  90. package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
  91. package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
  92. package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
  93. package/library/actions/mobile/ui/native-navigation.mjs +265 -0
  94. package/library/actions/mobile/ui/navigate.mjs +10 -1
  95. package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
  96. package/library/actions/mobile/wallet/import.mjs +71 -2
  97. package/library/actions/mobile/wallet/native-ui.mjs +512 -0
  98. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  99. package/library/actions/mobile/wallet/reset.mjs +17 -4
  100. package/library/actions/shared/assets/visible-state.mjs +176 -0
  101. package/library/actions/shared/networks/visible-state.mjs +70 -0
  102. package/library/actions/shared/perps/visible-state.mjs +354 -0
  103. package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
  104. package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
  105. package/library/actions/shared/ui/locators.mjs +7 -0
  106. package/library/manifests/extension.action-manifest.json +1366 -184
  107. package/library/manifests/mobile.action-manifest.json +235 -1
  108. package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
  109. package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
  110. package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
  111. package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
  112. package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
  113. package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
  114. package/library/recipes/assets/release-token-details.recipe.json +48 -0
  115. package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
  116. package/library/recipes/assets/release-token-list.recipe.json +75 -0
  117. package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
  118. package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
  119. package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
  120. package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
  121. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
  122. package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
  123. package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
  124. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  125. package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
  126. package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
  127. package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
  128. package/library/recipes/perps/release-market-details.recipe.json +83 -0
  129. package/library/recipes/perps/release-market-search.recipe.json +79 -0
  130. package/library/recipes/perps/release-watchlist.recipe.json +99 -0
  131. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
  132. package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
  133. package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
  134. package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
  135. package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
  136. package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
  137. package/library/recipes/wallet/import.recipe.json +20 -1
  138. package/library/recipes/wallet/reset-import.recipe.json +20 -1
  139. package/package.json +6 -5
  140. package/scripts/validate-release-report.mjs +319 -0
@@ -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,109 @@ 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 chromeUserDataDir = typeof state.chromeUserDataDir === "string" ? path.resolve(state.chromeUserDataDir) : "";
169
+ const expectedRuntimeDist = path.join(
170
+ runtimeRoot,
171
+ process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist"
172
+ );
173
+ const profileStat = chromeUserDataDir ? fs.lstatSync(chromeUserDataDir, { throwIfNoEntry: false }) : void 0;
174
+ if (state.schemaVersion !== 2 || !sourceDir || runtimeDist !== expectedRuntimeDist || !provenancePath || !chromeUserDataDir.startsWith(`${runtimeRoot}${path.sep}`) || !profileStat?.isDirectory() || profileStat.isSymbolicLink()) {
175
+ return { status: "invalid", reason: "artifact runtime identity has invalid paths or schema" };
176
+ }
177
+ const provenanceStat = fs.lstatSync(provenancePath);
178
+ if (!provenanceStat.isFile() || provenanceStat.isSymbolicLink() || provenanceStat.size <= 0 || provenanceStat.size > 64 * 1024) {
179
+ return { status: "invalid", reason: "artifact provenance is not a regular bounded file" };
180
+ }
181
+ const provenance = JSON.parse(fs.readFileSync(provenancePath, "utf8"));
182
+ const relativeExtensionDir = typeof provenance.extensionDir === "string" ? provenance.extensionDir : "";
183
+ const boundSource = relativeExtensionDir && !path.isAbsolute(relativeExtensionDir) && !relativeExtensionDir.split(/[\\/]+/u).includes("..") ? path.resolve(path.dirname(provenancePath), relativeExtensionDir) : "";
184
+ const sha256 = typeof provenance.sha256 === "string" ? provenance.sha256 : "";
185
+ const expectedVersion = typeof provenance.expectedVersion === "string" ? provenance.expectedVersion : "";
186
+ const manifestVersion = typeof provenance.manifestVersion === "string" ? provenance.manifestVersion : "";
187
+ const treeSha256 = typeof provenance.treeSha256 === "string" ? provenance.treeSha256 : "";
188
+ 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) {
189
+ return { status: "invalid", reason: "artifact runtime identity does not match its provenance" };
190
+ }
191
+ const manifest = JSON.parse(fs.readFileSync(path.join(runtimeDist, "manifest.json"), "utf8"));
192
+ const runtimeVersion = typeof manifest.version === "string" ? manifest.version : "";
193
+ if (normalizeReleaseVersion(runtimeVersion) !== expectedVersion || runtimeVersion !== manifestVersion) {
194
+ return { status: "invalid", reason: "artifact runtime manifest does not match its provenance" };
195
+ }
196
+ if (extensionTreeSha256(sourceDir) !== treeSha256 || extensionTreeSha256(runtimeDist) !== treeSha256) {
197
+ return { status: "invalid", reason: "artifact source or runtime tree does not match its provenance" };
198
+ }
199
+ return {
200
+ status: "valid",
201
+ sourceDir,
202
+ runtimeDist,
203
+ provenancePath,
204
+ chromeUserDataDir,
205
+ sha256,
206
+ treeSha256,
207
+ expectedVersion,
208
+ manifestVersion
209
+ };
210
+ } catch (error) {
211
+ return {
212
+ status: "invalid",
213
+ reason: error instanceof Error ? error.message : String(error)
214
+ };
215
+ }
216
+ }
217
+ function normalizeReleaseVersion(version) {
218
+ if (!/^\d+\.\d+\.\d+(?:\.\d+)?$/u.test(version)) return null;
219
+ const parts = version.split(".");
220
+ if (parts.length === 4 && parts[3] !== "0") return null;
221
+ return parts.slice(0, 3).join(".");
222
+ }
223
+ function releaseArtifactDistCheck(artifact) {
224
+ return {
225
+ status: "fresh",
226
+ source: "release-artifact",
227
+ manifestPath: path.join(artifact.runtimeDist, "manifest.json"),
228
+ manifestVersion: artifact.manifestVersion,
229
+ artifactSha256: artifact.sha256
230
+ };
231
+ }
232
+ function runtimeDistCheck(target, artifact = releaseArtifactState(target)) {
233
+ if (artifact.status === "invalid") {
234
+ return {
235
+ status: "stale",
236
+ source: "release-artifact",
237
+ artifactError: artifact.reason,
238
+ modified: ["<artifact identity invalid>"]
239
+ };
240
+ }
241
+ const dist = artifact.status === "valid" ? artifact.sourceDir : path.join(target, "dist/chrome");
156
242
  const runtimeDist = path.join(
157
243
  target,
158
244
  recipeRuntimeDir(),
159
245
  process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist"
160
246
  );
161
- if (!fs.existsSync(path.join(runtimeDist, "manifest.json"))) return { status: "missing" };
247
+ const identity = artifact.status === "valid" ? {
248
+ source: "release-artifact",
249
+ artifactSha256: artifact.sha256,
250
+ provenancePath: artifact.provenancePath
251
+ } : {};
252
+ if (!fs.existsSync(path.join(runtimeDist, "manifest.json"))) return { status: "missing", ...identity };
162
253
  let output;
163
254
  try {
164
- output = execFileSync("rsync", [
255
+ const rsyncArgs = [
165
256
  "-rnic",
166
257
  "--exclude",
167
- "_metadata",
168
- "--exclude",
169
258
  "home.html",
170
259
  "--exclude",
171
260
  "sidepanel.html",
@@ -174,21 +263,34 @@ function runtimeDistCheck(target) {
174
263
  "--out-format=%n",
175
264
  `${dist}${path.sep}`,
176
265
  `${runtimeDist}${path.sep}`
177
- ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
266
+ ];
267
+ if (artifact.status !== "valid") rsyncArgs.splice(1, 0, "--exclude", "_metadata");
268
+ output = execFileSync("rsync", rsyncArgs, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
178
269
  } catch {
179
- return { status: "stale", modified: ["<snapshot comparison failed>"] };
270
+ return { status: "stale", modified: ["<snapshot comparison failed>"], ...identity };
180
271
  }
181
272
  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");
273
+ if (artifact.status === "valid") {
274
+ for (const name of ["home.html", "sidepanel.html", "manifest.json"]) {
275
+ const source = path.join(dist, name);
276
+ const loaded = path.join(runtimeDist, name);
277
+ if (fileDigest(source) !== fileDigest(loaded)) modified.push(name);
278
+ }
279
+ } else {
280
+ for (const name of ["home.html", "sidepanel.html"]) {
281
+ const source = path.join(dist, name);
282
+ const loaded = path.join(runtimeDist, name);
283
+ if (normalizedRuntimeHtml(source) !== normalizedRuntimeHtml(loaded)) modified.push(name);
284
+ }
285
+ if (normalizedRuntimeManifest(path.join(dist, "manifest.json")) !== normalizedRuntimeManifest(path.join(runtimeDist, "manifest.json"))) {
286
+ modified.push("manifest.json");
287
+ }
189
288
  }
190
289
  const boundedModified = [...new Set(modified)].slice(0, 10);
191
- return boundedModified.length > 0 ? { status: "stale", modified: boundedModified } : { status: "fresh" };
290
+ return boundedModified.length > 0 ? { status: "stale", modified: boundedModified, ...identity } : { status: "fresh", ...identity };
291
+ }
292
+ function fileDigest(file) {
293
+ return fs.existsSync(file) ? crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") : null;
192
294
  }
193
295
  function normalizedRuntimeHtml(file) {
194
296
  if (!fs.existsSync(file)) return null;
@@ -269,10 +371,11 @@ async function decideExtensionReadiness(target, options = {}) {
269
371
  const deps = depsCheck(resolved);
270
372
  const webpackCache = webpackCacheCheck(resolved);
271
373
  const buildLog = buildLogCheck(resolved, options.watchLog);
272
- const dist = distCheck(resolved);
273
- const runtimeDist = runtimeDistCheck(resolved);
374
+ const releaseArtifact = releaseArtifactState(resolved);
375
+ const dist = releaseArtifact.status === "valid" ? releaseArtifactDistCheck(releaseArtifact) : distCheck(resolved);
376
+ const runtimeDist = runtimeDistCheck(resolved, releaseArtifact);
274
377
  const cdp = await cdpCheck(resolved, options.cdpPort, options.pageMode);
275
- const checks = { deps, webpackCache, buildLog, dist, runtimeDist, cdp };
378
+ const checks = { deps, webpackCache, buildLog, dist, runtimeDist, releaseArtifact, cdp };
276
379
  const install = [{ id: "yarn-install", argv: ["yarn", "install", "--immutable"], cwd: resolved }];
277
380
  const relaunch = [{ id: "relaunch-browser" }];
278
381
  const cacheStale = buildLog.reason === "stale-cache";
@@ -355,7 +458,47 @@ async function decideExtensionReadiness(target, options = {}) {
355
458
  reasons: ["Build is fresh; browser liveness unverified (pass --cdp-port to confirm `ready`)."],
356
459
  actions: relaunch
357
460
  };
358
- const core = rules.find((rule) => rule.when) ?? fallback;
461
+ let core;
462
+ if (releaseArtifact.status === "invalid") {
463
+ core = {
464
+ decision: "blocked",
465
+ reasonCode: "release-artifact-identity-invalid",
466
+ reasons: [`The loaded release artifact identity is invalid: ${releaseArtifact.reason ?? "unknown reason"}.`],
467
+ actions: []
468
+ };
469
+ } else if (releaseArtifact.status === "valid") {
470
+ const artifactRules = [
471
+ {
472
+ when: runtimeDist.status !== "fresh",
473
+ decision: "relaunch",
474
+ reasonCode: runtimeDist.status === "missing" ? "release-artifact-runtime-missing" : "release-artifact-runtime-stale",
475
+ reasons: [runtimeDist.status === "missing" ? "The loaded release artifact snapshot is missing." : `The loaded runtime snapshot differs from release artifact ${releaseArtifact.sha256?.slice(0, 12)}.`],
476
+ actions: relaunch
477
+ },
478
+ {
479
+ when: cdp.status === "pass",
480
+ decision: "ready",
481
+ reasonCode: "release-artifact-healthy",
482
+ reasons: [`Release artifact ${releaseArtifact.expectedVersion} is intact and healthy over CDP.`],
483
+ actions: []
484
+ },
485
+ {
486
+ when: cdp.status === "fail",
487
+ decision: "relaunch",
488
+ reasonCode: "runtime-unhealthy",
489
+ reasons: ["The release artifact is intact but the live extension is unhealthy.", ...cdp.findings?.slice(0, 3) ?? []],
490
+ actions: relaunch
491
+ }
492
+ ];
493
+ core = artifactRules.find((rule) => rule.when) ?? {
494
+ decision: "relaunch",
495
+ reasonCode: "cdp-unknown",
496
+ reasons: [`Release artifact ${releaseArtifact.expectedVersion} is intact; browser liveness is unverified.`],
497
+ actions: relaunch
498
+ };
499
+ } else {
500
+ core = rules.find((rule) => rule.when) ?? fallback;
501
+ }
359
502
  return {
360
503
  schemaVersion: 1,
361
504
  adapter: "extension",
@@ -378,5 +521,6 @@ export {
378
521
  decideExtensionReadiness,
379
522
  isExtensionDistStale,
380
523
  recordWebpackBaseline,
524
+ releaseArtifactState,
381
525
  runtimeDistCheck
382
526
  };