@deeeed/metamask-harness 0.43.0 → 0.44.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.
@@ -21,6 +21,7 @@ import {
21
21
  executeWithHealBounds,
22
22
  preflightRecipe,
23
23
  prepareHeal,
24
+ persistRunEffects,
24
25
  recoverRunInfra,
25
26
  runRecipe,
26
27
  validateRunRecipeStatic
@@ -30,11 +31,15 @@ import { applyDeviceTargeting } from "./device-target.js";
30
31
  import { getAdapterSurface } from "../adapters/surface.js";
31
32
  import { coreDependencyBlock } from "./core-readiness.js";
32
33
  import { readMobileReleaseArtifactState } from "../adapters/mobile/release-artifact-state.js";
33
- import { writeRunReport } from "./run-report.js";
34
+ import {
35
+ indexProductProvenanceArtifact,
36
+ writeRunReport
37
+ } from "./run-report.js";
34
38
  import { acquireCheckoutLock } from "../checkout-lock.js";
35
39
  import { JsonStreamWriter } from "../json-stream.js";
36
40
  import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument } from "../run-diagnostics.js";
37
41
  import { recipeTrustFailure } from "../recipe-security.js";
42
+ import { ProvenanceDriftError } from "../execution-provenance.js";
38
43
  import { recordCommandEvidence, redactStructuredValue } from "../command-journal.js";
39
44
  import {
40
45
  actionLibraryContextArgs,
@@ -75,6 +80,31 @@ async function handleRun(parsed) {
75
80
  stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode);
76
81
  return exitCode;
77
82
  } catch (error) {
83
+ if (error instanceof ProvenanceDriftError) {
84
+ const failure = {
85
+ code: error.code,
86
+ message: error.message,
87
+ userAction: error.userAction,
88
+ provenancePath: error.provenancePath,
89
+ drift: error.drift
90
+ };
91
+ if (stream.enabled) {
92
+ stream.error(failure);
93
+ stream.complete("fail", error.exitCode);
94
+ } else if (optionFlag(parsed.options, "json")) {
95
+ console.log(JSON.stringify({
96
+ schemaVersion: 1,
97
+ command: "run",
98
+ status: "fail",
99
+ error: failure,
100
+ exitCode: error.exitCode
101
+ }, null, 2));
102
+ } else {
103
+ console.error(`\u2717 mm-harness run: ${error.message}`);
104
+ console.error(` Next: ${error.userAction}`);
105
+ }
106
+ return error.exitCode;
107
+ }
78
108
  const trustFailure = recipeTrustFailure(error);
79
109
  if (trustFailure) {
80
110
  if (stream.enabled) {
@@ -275,6 +305,14 @@ async function handleRunInner({ positional, options }, stream) {
275
305
  return prepared;
276
306
  }
277
307
  const { state, heal } = prepared;
308
+ preflightedExecution = await preflightRecipe(
309
+ adapter,
310
+ validated.recipeFile,
311
+ artifactsDir,
312
+ target,
313
+ optionString(options, "actionManifest"),
314
+ runtimeOptions
315
+ );
278
316
  networkObservation = await startRunNetworkObservation(
279
317
  adapter,
280
318
  target,
@@ -311,7 +349,8 @@ async function handleRunInner({ positional, options }, stream) {
311
349
  target,
312
350
  optionString(options, "actionManifest"),
313
351
  runtimeOptions,
314
- execution
352
+ execution,
353
+ state
315
354
  );
316
355
  },
317
356
  adapter,
@@ -335,6 +374,12 @@ async function handleRunInner({ positional, options }, stream) {
335
374
  performanceObservation = void 0;
336
375
  for (const mutation of state.mutations) stream.mutation(mutation);
337
376
  for (const recovery of state.recovered) stream.recovery(recovery);
377
+ persistRunEffects(result.summaryPath, result.artifactManifestPath, state);
378
+ indexProductProvenanceArtifact(
379
+ result.artifactManifestPath,
380
+ target,
381
+ adapter
382
+ );
338
383
  if (violation !== null) {
339
384
  const userAction = violation.userAction ?? `inspect ${shellQuote(result.summaryPath)} and ${shellQuote(result.tracePath)}; fix the application or recipe failure before retrying`;
340
385
  stream.error({
@@ -0,0 +1,342 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import fs, { constants as fsConstants } from "node:fs";
4
+ import path from "node:path";
5
+ import { mobileSourceFingerprint } from "./adapters/mobile/source-freshness.js";
6
+ import { importRecipeProtocol, runnerDir } from "./paths.js";
7
+ const gitContexts = /* @__PURE__ */ new Map();
8
+ let protocolPromise;
9
+ class ProvenanceDriftError extends Error {
10
+ code = "PROVENANCE_DRIFT";
11
+ exitCode = 5;
12
+ userAction;
13
+ provenancePath;
14
+ drift;
15
+ constructor(provenancePath, drift, cause) {
16
+ const fields = [...new Set(drift.map((entry) => entry.field))].join(", ");
17
+ super(
18
+ `Execution inputs changed after preparation (${fields}); evidence is invalid.`,
19
+ cause === void 0 ? void 0 : { cause }
20
+ );
21
+ this.name = "ProvenanceDriftError";
22
+ this.provenancePath = provenancePath;
23
+ this.drift = drift;
24
+ this.userAction = `restore the prepared recipe, library, and product source, then rerun; inspect ${provenancePath}`;
25
+ }
26
+ }
27
+ async function captureExecutionProvenance(input, phase) {
28
+ const protocol = await (protocolPromise ??= importRecipeProtocol());
29
+ const recipeDigest = input.recipePath ? recipeFileDigest(protocol, input.recipePath) : protocol.digestRecipeDocument(input.recipeDocument);
30
+ const libraries = (input.librarySources ?? []).map((source, index) => ({
31
+ name: source.name ?? `library-${index + 1}`,
32
+ ...sourceSnapshot(source.root)
33
+ }));
34
+ const runnerRoot = input.runnerRoot ?? runnerDir;
35
+ return {
36
+ phase,
37
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
38
+ recipeDigest,
39
+ product: sourceSnapshot(
40
+ input.projectRoot,
41
+ input.adapter,
42
+ input.excludedProductRoots
43
+ ),
44
+ runner: sourceSnapshot(runnerRoot, void 0, [], [
45
+ "src",
46
+ "adapters",
47
+ "library",
48
+ "bin",
49
+ "dist",
50
+ "package.json"
51
+ ]),
52
+ libraries,
53
+ helpers: (input.helperPaths ?? []).map((helperPath) => ({
54
+ path: displayPath(input.projectRoot, helperPath),
55
+ sourceFingerprint: fileFingerprint(helperPath)
56
+ }))
57
+ };
58
+ }
59
+ function executionProvenanceDrift(start, current) {
60
+ const drift = [];
61
+ if (current.phase === "start") return drift;
62
+ const phase = current.phase;
63
+ compare(drift, phase, "recipeDigest", start.recipeDigest, current.recipeDigest);
64
+ compareSource(drift, phase, "product", start.product, current.product);
65
+ compareSource(drift, phase, "runner", start.runner, current.runner);
66
+ if (start.libraries.length !== current.libraries.length) {
67
+ compare(
68
+ drift,
69
+ phase,
70
+ "libraries.length",
71
+ String(start.libraries.length),
72
+ String(current.libraries.length)
73
+ );
74
+ }
75
+ const count = Math.min(start.libraries.length, current.libraries.length);
76
+ for (let index = 0; index < count; index += 1) {
77
+ const expected = start.libraries[index];
78
+ const actual = current.libraries[index];
79
+ const prefix = `libraries[${index}]`;
80
+ compare(drift, phase, `${prefix}.name`, expected.name, actual.name);
81
+ compareSource(drift, phase, prefix, expected, actual);
82
+ }
83
+ if (start.helpers.length !== current.helpers.length) {
84
+ compare(
85
+ drift,
86
+ phase,
87
+ "helpers.length",
88
+ String(start.helpers.length),
89
+ String(current.helpers.length)
90
+ );
91
+ }
92
+ const helperCount = Math.min(start.helpers.length, current.helpers.length);
93
+ for (let index = 0; index < helperCount; index += 1) {
94
+ compare(drift, phase, `helpers[${index}].path`, start.helpers[index].path, current.helpers[index].path);
95
+ compare(
96
+ drift,
97
+ phase,
98
+ `helpers[${index}].sourceFingerprint`,
99
+ start.helpers[index].sourceFingerprint,
100
+ current.helpers[index].sourceFingerprint
101
+ );
102
+ }
103
+ return drift;
104
+ }
105
+ function writeExecutionProvenance(artifactsDir, snapshots, drift, artifactManifestPath) {
106
+ const provenancePath = path.join(path.resolve(artifactsDir), "execution-provenance.json");
107
+ const record = {
108
+ schemaVersion: 1,
109
+ valid: drift.length === 0,
110
+ snapshots,
111
+ drift
112
+ };
113
+ fs.mkdirSync(path.dirname(provenancePath), { recursive: true });
114
+ fs.writeFileSync(provenancePath, `${JSON.stringify(record, null, 2)}
115
+ `);
116
+ if (artifactManifestPath) indexProvenanceArtifact(artifactManifestPath, record);
117
+ return provenancePath;
118
+ }
119
+ function recipeFileDigest(protocol, recipePath) {
120
+ const source = readRegularFileNoFollow(recipePath);
121
+ try {
122
+ return protocol.digestRecipeDocument(JSON.parse(source.toString("utf8")));
123
+ } catch {
124
+ return `invalid:sha256:${createHash("sha256").update(source).digest("hex")}`;
125
+ }
126
+ }
127
+ function sourceSnapshot(root, adapter, excludedRoots = [], includedRoots = []) {
128
+ const git = gitContext(root);
129
+ if (!git) {
130
+ return {
131
+ head: null,
132
+ status: "not-a-git-checkout",
133
+ sourceFingerprint: directoryFingerprint(root, excludedRoots, includedRoots)
134
+ };
135
+ }
136
+ const pathspecs = sourcePathspecs(git, excludedRoots, includedRoots);
137
+ const status = gitText(git.topLevel, [
138
+ "status",
139
+ "--porcelain=v1",
140
+ "--untracked-files=normal",
141
+ "--",
142
+ ...pathspecs
143
+ ]);
144
+ return {
145
+ head: gitText(git.topLevel, ["rev-parse", "HEAD"]).trim() || null,
146
+ status,
147
+ sourceFingerprint: adapter === "mobile" && path.resolve(root) === git.topLevel ? mobileSourceFingerprint(root) : nestedGitSourceFingerprint(root, git, pathspecs, excludedRoots, includedRoots)
148
+ };
149
+ }
150
+ function nestedGitSourceFingerprint(root, git, pathspecs, excludedRoots, includedRoots) {
151
+ const gitFingerprint = gitSourceFingerprint(git.topLevel, pathspecs);
152
+ if (git.pathspec === "." && includedRoots.length === 0) return gitFingerprint;
153
+ return createHash("sha256").update(gitFingerprint).update("\0nested-source\0").update(directoryFingerprint(root, excludedRoots, includedRoots)).digest("hex");
154
+ }
155
+ function gitContext(root) {
156
+ const cacheKey = path.resolve(root);
157
+ if (gitContexts.has(cacheKey)) return gitContexts.get(cacheKey) ?? null;
158
+ try {
159
+ const resolved = cacheKey;
160
+ const topLevel = fs.realpathSync(
161
+ gitText(resolved, ["rev-parse", "--show-toplevel"]).trim()
162
+ );
163
+ const relative = path.relative(topLevel, fs.realpathSync(resolved));
164
+ const context = relative === ".." || relative.startsWith(`..${path.sep}`) ? null : { topLevel, pathspec: relative || "." };
165
+ gitContexts.set(cacheKey, context);
166
+ return context;
167
+ } catch {
168
+ gitContexts.set(cacheKey, null);
169
+ return null;
170
+ }
171
+ }
172
+ function gitSourceFingerprint(topLevel, pathspecs) {
173
+ const hash = createHash("sha256");
174
+ hash.update(gitBuffer(topLevel, ["rev-parse", "HEAD"]));
175
+ hash.update("\0diff\0");
176
+ hash.update(
177
+ gitBuffer(topLevel, [
178
+ "diff",
179
+ "--no-ext-diff",
180
+ "--binary",
181
+ "HEAD",
182
+ "--",
183
+ ...pathspecs
184
+ ])
185
+ );
186
+ const untracked = gitText(topLevel, [
187
+ "ls-files",
188
+ "--others",
189
+ "--exclude-standard",
190
+ "-z",
191
+ "--",
192
+ ...pathspecs
193
+ ]).split("\0").filter(Boolean).sort();
194
+ for (const relative of untracked) {
195
+ hash.update(`\0untracked\0${relative}\0`);
196
+ const absolute = path.join(topLevel, relative);
197
+ hash.update(readPathIdentity(absolute));
198
+ }
199
+ return hash.digest("hex");
200
+ }
201
+ function sourcePathspecs(git, excludedRoots, includedRoots) {
202
+ const exclusions = excludedRoots.flatMap((root) => {
203
+ const relative = path.relative(git.topLevel, path.resolve(root));
204
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) ? [`:(exclude)${relative}`] : [];
205
+ });
206
+ const inclusions = includedRoots.length > 0 ? includedRoots.map((root) => path.join(git.pathspec === "." ? "" : git.pathspec, root)) : [git.pathspec];
207
+ return [...inclusions, ...exclusions];
208
+ }
209
+ function directoryFingerprint(root, excludedRoots, includedRoots) {
210
+ const hash = createHash("sha256");
211
+ const resolvedRoot = path.resolve(root);
212
+ const roots = includedRoots.length > 0 ? includedRoots : [""];
213
+ for (const included of roots) {
214
+ hashDirectory(
215
+ hash,
216
+ resolvedRoot,
217
+ included,
218
+ new Set(excludedRoots.map((entry) => path.resolve(entry)))
219
+ );
220
+ }
221
+ return hash.digest("hex");
222
+ }
223
+ function hashDirectory(hash, root, relative, excludedRoots) {
224
+ const absolute = path.join(root, relative);
225
+ if ([...excludedRoots].some(
226
+ (excluded) => absolute === excluded || absolute.startsWith(`${excluded}${path.sep}`)
227
+ )) return;
228
+ let stat;
229
+ try {
230
+ stat = fs.lstatSync(absolute);
231
+ } catch (error) {
232
+ if (error.code === "ENOENT") {
233
+ hash.update(`absent\0${relative}\0`);
234
+ return;
235
+ }
236
+ throw error;
237
+ }
238
+ if (stat.isDirectory()) {
239
+ hash.update(`directory\0${relative}\0`);
240
+ for (const name of fs.readdirSync(absolute).sort()) {
241
+ if (relative === "" && (name === ".git" || name === "node_modules" || name === "temp")) continue;
242
+ hashDirectory(hash, root, path.join(relative, name), excludedRoots);
243
+ }
244
+ return;
245
+ }
246
+ if (stat.isSymbolicLink()) {
247
+ throw new Error(`Execution provenance source must not be a symbolic link: ${absolute}`);
248
+ }
249
+ hash.update(`file\0${relative}\0`);
250
+ hash.update(readRegularFileNoFollow(absolute));
251
+ hash.update("\0");
252
+ }
253
+ function fileFingerprint(filePath) {
254
+ return createHash("sha256").update(readRegularFileNoFollow(filePath)).digest("hex");
255
+ }
256
+ function readPathIdentity(filePath) {
257
+ const stat = fs.lstatSync(filePath);
258
+ if (stat.isSymbolicLink()) {
259
+ throw new Error(`Execution provenance source must not be a symbolic link: ${filePath}`);
260
+ }
261
+ return readRegularFileNoFollow(filePath);
262
+ }
263
+ function readRegularFileNoFollow(filePath) {
264
+ let fd;
265
+ try {
266
+ fd = fs.openSync(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
267
+ } catch (error) {
268
+ if (error.code === "ELOOP") {
269
+ throw new Error(`Execution provenance source must not be a symbolic link: ${filePath}`);
270
+ }
271
+ throw error;
272
+ }
273
+ try {
274
+ if (!fs.fstatSync(fd).isFile()) {
275
+ throw new Error(`Execution provenance source is not a regular file: ${filePath}`);
276
+ }
277
+ return fs.readFileSync(fd);
278
+ } finally {
279
+ fs.closeSync(fd);
280
+ }
281
+ }
282
+ function displayPath(projectRoot, filePath) {
283
+ const relative = path.relative(path.resolve(projectRoot), path.resolve(filePath));
284
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) ? relative || "." : path.resolve(filePath);
285
+ }
286
+ function gitText(cwd, args) {
287
+ return gitBuffer(cwd, args).toString("utf8");
288
+ }
289
+ function gitBuffer(cwd, args) {
290
+ return execFileSync("git", args, {
291
+ cwd,
292
+ encoding: "buffer",
293
+ maxBuffer: 128 * 1024 * 1024,
294
+ stdio: ["ignore", "pipe", "ignore"]
295
+ });
296
+ }
297
+ function compareSource(drift, phase, prefix, start, current) {
298
+ compare(drift, phase, `${prefix}.head`, start.head, current.head);
299
+ compare(drift, phase, `${prefix}.status`, start.status, current.status);
300
+ compare(
301
+ drift,
302
+ phase,
303
+ `${prefix}.sourceFingerprint`,
304
+ start.sourceFingerprint,
305
+ current.sourceFingerprint
306
+ );
307
+ }
308
+ function compare(drift, phase, field, start, current) {
309
+ if (start !== current) drift.push({ phase, field, start, current });
310
+ }
311
+ function indexProvenanceArtifact(artifactManifestPath, record) {
312
+ let manifest;
313
+ try {
314
+ manifest = JSON.parse(fs.readFileSync(artifactManifestPath, "utf8"));
315
+ } catch {
316
+ return;
317
+ }
318
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return;
319
+ const document = manifest;
320
+ const artifacts = Array.isArray(document.artifacts) ? document.artifacts : [];
321
+ document.artifacts = [
322
+ ...artifacts.filter((artifact) => !artifact || typeof artifact !== "object" || Array.isArray(artifact) || artifact.path !== "execution-provenance.json"),
323
+ {
324
+ path: "execution-provenance.json",
325
+ type: "json",
326
+ label: "Execution provenance",
327
+ category: "system",
328
+ metadata: {
329
+ valid: record.valid,
330
+ errorCode: record.valid ? null : "PROVENANCE_DRIFT"
331
+ }
332
+ }
333
+ ];
334
+ fs.writeFileSync(artifactManifestPath, `${JSON.stringify(document, null, 2)}
335
+ `);
336
+ }
337
+ export {
338
+ ProvenanceDriftError,
339
+ captureExecutionProvenance,
340
+ executionProvenanceDrift,
341
+ writeExecutionProvenance
342
+ };
@@ -8,6 +8,7 @@ import { ensureExtensionConsoleCapture } from "./adapters/extension/console-capt
8
8
  const MAX_CAPTURE_BYTES = 512 * 1024;
9
9
  const MAX_FINDINGS = 20;
10
10
  const MAX_PREVIEW_CHARS = 320;
11
+ const RAW_APP_LOG_ARTIFACT = "diagnostics-app-log.txt";
11
12
  function readRunDiagnosticsDocument(diagnosticsPath) {
12
13
  if (typeof diagnosticsPath !== "string" || diagnosticsPath.length === 0) return null;
13
14
  try {
@@ -53,11 +54,15 @@ function finishRunDiagnostics(baseline, result) {
53
54
  if (!baseline) return result;
54
55
  try {
55
56
  const bufferedIssues = baseline.mobileIssueBuffer ? collectMobileIssueBuffer(baseline.mobileIssueBuffer.projectRoot) : void 0;
56
- const diagnostics = collectRunDiagnostics(baseline, bufferedIssues);
57
+ const { diagnostics, rawAppLog } = collectRunDiagnosticsCapture(baseline, bufferedIssues);
57
58
  const artifactsDir = path.dirname(result.summaryPath);
58
59
  const diagnosticsPath = path.join(artifactsDir, "diagnostics.json");
59
60
  fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}
60
61
  `);
62
+ fs.writeFileSync(
63
+ path.join(artifactsDir, RAW_APP_LOG_ARTIFACT),
64
+ redactDiagnosticText(rawAppLog.toString("utf8"))
65
+ );
61
66
  indexDiagnosticArtifact(result.artifactManifestPath);
62
67
  indexDiagnosticSummary(result.summaryPath, diagnostics);
63
68
  return {
@@ -83,6 +88,9 @@ function finishRunDiagnostics(baseline, result) {
83
88
  }
84
89
  }
85
90
  function collectRunDiagnostics(baseline, bufferedIssues) {
91
+ return collectRunDiagnosticsCapture(baseline, bufferedIssues).diagnostics;
92
+ }
93
+ function collectRunDiagnosticsCapture(baseline, bufferedIssues) {
86
94
  const stat = safeStat(baseline.source.path);
87
95
  const source = {
88
96
  label: baseline.source.label,
@@ -91,9 +99,11 @@ function collectRunDiagnostics(baseline, bufferedIssues) {
91
99
  endOffset: stat?.size ?? baseline.offset,
92
100
  bytesRead: 0,
93
101
  truncated: false,
94
- inAppBuffer: bufferedIssues === void 0 ? "n/a" : bufferedIssues === null ? "unavailable" : "collected"
102
+ inAppBuffer: bufferedIssues === void 0 ? "n/a" : bufferedIssues === null ? "unavailable" : "collected",
103
+ rawArtifact: RAW_APP_LOG_ARTIFACT,
104
+ redacted: true
95
105
  };
96
- let text = "";
106
+ let rawAppLog = Buffer.alloc(0);
97
107
  if (stat) {
98
108
  const sameFile = baseline.inode === void 0 || baseline.inode === stat.ino;
99
109
  const startOffset = sameFile && stat.size >= baseline.offset ? baseline.offset : 0;
@@ -103,8 +113,9 @@ function collectRunDiagnostics(baseline, bufferedIssues) {
103
113
  source.endOffset = stat.size;
104
114
  source.bytesRead = bytesToRead;
105
115
  source.truncated = available > MAX_CAPTURE_BYTES;
106
- if (bytesToRead > 0) text = readSlice(baseline.source.path, startOffset, bytesToRead);
116
+ if (bytesToRead > 0) rawAppLog = readSlice(baseline.source.path, startOffset, bytesToRead);
107
117
  }
118
+ const text = rawAppLog.toString("utf8");
108
119
  const allFindings = dedupeFindings(
109
120
  [
110
121
  ...text.split(/\r?\n/u).map(classifyLine).filter((finding) => finding !== null),
@@ -113,9 +124,10 @@ function collectRunDiagnostics(baseline, bufferedIssues) {
113
124
  );
114
125
  const findings = allFindings.slice(0, MAX_FINDINGS);
115
126
  const counts = countFindings(allFindings);
127
+ const omittedFindingCount = Math.max(0, allFindings.length - findings.length);
116
128
  const status = counts.total > 0 ? "review" : stat || bufferedIssues !== void 0 && bufferedIssues !== null ? "clean" : "unavailable";
117
- const note = counts.total > 0 ? `Observed ${counts.total} distinct application warning/error event(s) during the recipe run; relation to the task is not determined.` : status === "clean" ? "No application warnings or errors were emitted during the recipe run." : "Application diagnostics were unavailable for this run.";
118
- return {
129
+ const note = counts.total > 0 ? `Observed ${counts.total} distinct application warning/error event(s) during the recipe run; showing ${findings.length} preview(s) and omitting ${omittedFindingCount}. Relation to the task is not determined.` : status === "clean" ? "No application warnings or errors were emitted during the recipe run." : "Application diagnostics were unavailable for this run.";
130
+ return { diagnostics: {
119
131
  schemaVersion: 1,
120
132
  scope: "recipe-run-application",
121
133
  status,
@@ -123,8 +135,10 @@ function collectRunDiagnostics(baseline, bufferedIssues) {
123
135
  note,
124
136
  source,
125
137
  counts,
138
+ displayedFindingCount: findings.length,
139
+ omittedFindingCount,
126
140
  findings
127
- };
141
+ }, rawAppLog };
128
142
  }
129
143
  function classifyLine(line) {
130
144
  const trimmed = line.trim();
@@ -180,7 +194,10 @@ function runMobileIssueCommand(projectRoot, command) {
180
194
  }
181
195
  }
182
196
  function redactPreview(value) {
183
- return value.replace(/\b(Bearer)\s+\S+/giu, "$1 [REDACTED]").replace(/\b(api[-_]?key|password|passphrase|mnemonic|seed(?:Phrase)?|privateKey|secret|token|authorization|vault)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/giu, "$1=[REDACTED]").replace(/\b(?:0x)?[a-f0-9]{64,}\b/giu, "[REDACTED_HEX]").replace(/(https?:\/\/[^\s?]+)\?\S+/giu, "$1?[REDACTED_QUERY]").slice(0, MAX_PREVIEW_CHARS);
197
+ return redactDiagnosticText(value).slice(0, MAX_PREVIEW_CHARS);
198
+ }
199
+ function redactDiagnosticText(value) {
200
+ return value.replace(/\b(Bearer)\s+\S+/giu, "$1 [REDACTED]").replace(/(["'])(api[-_]?key|password|passphrase|mnemonic|seed(?:Phrase)?|privateKey|secret|token|authorization|vault)\1\s*:\s*(?:"[^"]*"|'[^']*'|[^,\s}\]]+)/giu, '$1$2$1:"[REDACTED]"').replace(/\b(api[-_]?key|password|passphrase|mnemonic|seed(?:Phrase)?|privateKey|secret|token|authorization|vault)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/giu, "$1=[REDACTED]").replace(/\b(?:0x)?[a-f0-9]{64,}\b/giu, "[REDACTED_HEX]").replace(/(https?:\/\/[^\s?]+)\?\S+/giu, "$1?[REDACTED_QUERY]");
184
201
  }
185
202
  function dedupeFindings(findings) {
186
203
  const byFingerprint = /* @__PURE__ */ new Map();
@@ -201,7 +218,7 @@ function readSlice(filePath, offset, length) {
201
218
  try {
202
219
  const buffer = Buffer.alloc(length);
203
220
  const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
204
- return buffer.subarray(0, bytesRead).toString("utf8");
221
+ return buffer.subarray(0, bytesRead);
205
222
  } finally {
206
223
  fs.closeSync(fd);
207
224
  }
@@ -218,12 +235,18 @@ function indexDiagnosticArtifact(manifestPath) {
218
235
  if (!manifest) return;
219
236
  const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
220
237
  manifest.artifacts = [
221
- ...artifacts.filter((artifact) => !isRecord(artifact) || artifact.path !== "diagnostics.json"),
238
+ ...artifacts.filter((artifact) => !isRecord(artifact) || !["diagnostics.json", RAW_APP_LOG_ARTIFACT].includes(String(artifact.path))),
222
239
  {
223
240
  path: "diagnostics.json",
224
241
  type: "json",
225
242
  label: "Run-scoped application diagnostics",
226
243
  category: "diagnostic"
244
+ },
245
+ {
246
+ path: RAW_APP_LOG_ARTIFACT,
247
+ type: "log",
248
+ label: "Redacted run-scoped application log slice",
249
+ category: "diagnostic"
227
250
  }
228
251
  ];
229
252
  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
@@ -236,7 +259,9 @@ function indexDiagnosticSummary(summaryPath, diagnostics) {
236
259
  status: diagnostics.status,
237
260
  nonBlocking: true,
238
261
  counts: diagnostics.counts,
239
- diagnosticsPath: "diagnostics.json"
262
+ diagnosticsPath: "diagnostics.json",
263
+ appLogPath: RAW_APP_LOG_ARTIFACT,
264
+ appLogRedacted: true
240
265
  };
241
266
  fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}
242
267
  `);
package/dist/runner.js CHANGED
@@ -2,6 +2,7 @@ import { execFileSync, execSync } from "node:child_process";
2
2
  import { OFFICIAL_RECIPE_ACTIONS } from "@farmslot/protocol";
3
3
  import { createMetaMaskAdapters, createMetaMaskUiTransport } from "./adapters.js";
4
4
  import { bridgeCommand } from "../library/actions/mobile/platform/bridge.mjs";
5
+ import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
5
6
  import {
6
7
  mobileSourceFingerprint,
7
8
  recordMobileSourceBaseline
@@ -334,21 +335,29 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
334
335
  fingerprint: mobileSourceFingerprint,
335
336
  record: recordMobileSourceBaseline
336
337
  }, processIdentity = {
337
- readIosPid: readIosAppPid
338
+ readIosPid: readIosAppPid,
339
+ readAndroidPid: readAndroidAppPid
338
340
  }) {
339
341
  if (adapter !== "mobile") return lifecycle;
340
342
  return lifecycle.map((entry) => ({
341
343
  ...entry,
342
344
  async execute(node, context) {
343
- if (context.env?.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1") {
345
+ const command = node.command ?? node.event ?? node.state;
346
+ const opaqueRuntime = context.env?.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1";
347
+ const requiresProcessContinuity = command !== "restart" && node.require_process_continuity === true;
348
+ if (opaqueRuntime && !requiresProcessContinuity) {
344
349
  return entry.execute(node, context);
345
350
  }
346
- const command = node.command ?? node.event ?? node.state;
347
- const reloadsSource = command === "launch" || command === "foreground" || command === "restart";
348
- const checksIosContinuity = command === "foreground" && !isAndroidLifecycle(node, context.env ?? {});
349
- const processBefore = checksIosContinuity ? processIdentity.readIosPid(node, context) : void 0;
350
- const fingerprint = reloadsSource ? sourceFreshness.fingerprint(context.projectRoot) : void 0;
351
- const androidRestart = command === "restart" && isAndroidLifecycle(node, context.env ?? {});
351
+ const reloadsSource = !opaqueRuntime && (command === "launch" || command === "foreground" || command === "restart");
352
+ const recordsSource = !opaqueRuntime && command === "restart";
353
+ const androidLifecycle = isAndroidLifecycle(node, context.env ?? {});
354
+ const checksIosContinuity = command === "foreground" && !androidLifecycle;
355
+ const checksRequiredContinuity = requiresProcessContinuity;
356
+ const recordsProcessIdentity = checksIosContinuity || checksRequiredContinuity;
357
+ const readProcessId = androidLifecycle ? processIdentity.readAndroidPid ?? readAndroidAppPid : processIdentity.readIosPid;
358
+ const processBefore = recordsProcessIdentity ? readProcessId(node, context) : void 0;
359
+ const fingerprint = recordsSource ? sourceFreshness.fingerprint(context.projectRoot) : void 0;
360
+ const androidRestart = command === "restart" && androidLifecycle;
352
361
  const initialNode = androidRestart ? { ...node, settle_ms: 0 } : node;
353
362
  const initialResult = await entry.execute(initialNode, context);
354
363
  let result = initialResult;
@@ -362,22 +371,26 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
362
371
  if (reloadsSource) {
363
372
  await readinessProbe(node, context);
364
373
  }
365
- if (checksIosContinuity) {
366
- const processAfter = processIdentity.readIosPid(node, context);
367
- const actualTransition = processBefore !== null && processBefore === processAfter ? "foreground_resume" : processAfter !== null ? "cold_relaunch" : "unknown";
374
+ if (recordsProcessIdentity) {
375
+ const processAfter = readProcessId(node, context);
376
+ const processPreserved = processBefore !== null && processBefore === processAfter;
377
+ const actualTransition = processPreserved ? command === "background" ? "background_same_process" : "foreground_resume" : processAfter !== null ? "cold_relaunch" : "unknown";
378
+ const processContinuity = processPreserved ? "preserved" : processAfter === null ? "unknown" : "changed";
368
379
  const resultRecord = asRecord(result);
369
380
  result = {
370
381
  ...resultRecord,
371
382
  output: {
372
383
  ...asRecord(resultRecord.output),
373
384
  actualTransition,
385
+ processContinuity,
374
386
  processBefore,
375
387
  processAfter
376
388
  }
377
389
  };
378
- if (node.require_process_continuity === true && actualTransition !== "foreground_resume") {
390
+ if (checksRequiredContinuity && !processPreserved) {
391
+ const platform = androidLifecycle ? "Android" : "iOS";
379
392
  throw new Error(
380
- `iOS foreground did not preserve the app process (actualTransition=${actualTransition}, before=${String(processBefore)}, after=${String(processAfter)}); this lifecycle sample is a cold relaunch, not a reconnect.`
393
+ `${platform} ${String(command)} did not prove app-process continuity (actualTransition=${actualTransition}, before=${String(processBefore)}, after=${String(processAfter)}); this lifecycle sample cannot be classified as a same-process reconnect.`
381
394
  );
382
395
  }
383
396
  }
@@ -390,6 +403,24 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
390
403
  }
391
404
  }));
392
405
  }
406
+ function readAndroidAppPid(node, context) {
407
+ const env = context.env ?? {};
408
+ const device = node.adb_serial ?? node.android_device ?? node.device ?? env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL;
409
+ const packageId = node.package_id ?? node.packageName ?? node.app_id ?? env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID ?? "io.metamask";
410
+ if (typeof device !== "string" || typeof packageId !== "string") return null;
411
+ try {
412
+ const adb = resolveMobileToolPath("adb", { required: true });
413
+ const output = execFileSync(
414
+ adb,
415
+ ["-s", device, "shell", "pidof", packageId],
416
+ { encoding: "utf8", timeout: 5e3, stdio: ["ignore", "pipe", "ignore"] }
417
+ );
418
+ const pid = Number.parseInt(output.trim().split(/\s+/u)[0] ?? "", 10);
419
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
420
+ } catch {
421
+ return null;
422
+ }
423
+ }
393
424
  function readIosAppPid(node, context) {
394
425
  const env = context.env ?? {};
395
426
  const device = node.simulator ?? node.ios_simulator ?? env.SIM_UDID ?? env.IOS_SIMULATOR ?? process.env.SIM_UDID ?? process.env.IOS_SIMULATOR;