@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.
- package/CHANGELOG.md +25 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +304 -34
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +20 -8
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +102 -0
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +4 -2
- package/adapters/mobile/reload-app.mjs +99 -1
- package/dist/adapters/mobile/prepare.js +12 -0
- package/dist/adapters.js +22 -3
- package/dist/commands/call.js +40 -2
- package/dist/commands/run-engine.js +247 -139
- package/dist/commands/run-report.js +68 -0
- package/dist/commands/run.js +47 -2
- package/dist/execution-provenance.js +342 -0
- package/dist/run-diagnostics.js +36 -11
- package/dist/runner.js +44 -13
- package/library/actions/mobile/perps/performance-capture.mjs +570 -189
- package/library/actions/mobile/perps/perps.mjs +122 -0
- package/library/actions/mobile/platform/bridge.mjs +40 -8
- package/library/actions/mobile/platform/native-session.mjs +1 -1
- package/library/actions/mobile/wallet/lock.mjs +1 -4
- package/library/actions/mobile/wallet/select_account.mjs +129 -17
- package/library/manifests/mobile.action-manifest.json +28 -3
- package/library/recipes/mobile/perps/performance.recipe.json +73 -47
- package/package.json +1 -1
package/dist/commands/run.js
CHANGED
|
@@ -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 {
|
|
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
|
+
};
|
package/dist/run-diagnostics.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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)
|
|
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;
|
|
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
|
|
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)
|
|
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) ||
|
|
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
|
-
|
|
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
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
const
|
|
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 (
|
|
366
|
-
const processAfter =
|
|
367
|
-
const
|
|
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 (
|
|
390
|
+
if (checksRequiredContinuity && !processPreserved) {
|
|
391
|
+
const platform = androidLifecycle ? "Android" : "iOS";
|
|
379
392
|
throw new Error(
|
|
380
|
-
|
|
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;
|