@deeeed/metamask-harness 0.56.0 → 0.58.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 +20 -0
- package/adapters/manifest.json +3 -3
- package/adapters/mobile/inject.sh +4 -7
- package/dist/adapters/mobile/verify.js +549 -0
- package/dist/commands/checklist.js +6 -16
- package/dist/commands/run-engine.js +2 -2
- package/dist/commands/status-watch.js +13 -2
- package/dist/execution-provenance.js +16 -28
- package/dist/harness.js +36 -8
- package/dist/task-view.js +79 -76
- package/package.json +5 -5
- package/adapters/mobile/verify.sh +0 -557
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.58.0 - 2026-09-18
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Pins `@farmslot/agent-runtime` 0.12.0, whose `mark` records step names through the protocol's `checklistStepName`, so SIGNAL.json events and `status --watch` name a step identically.
|
|
10
|
+
- Mobile `verify` is runner-owned TypeScript: `adapters/mobile/verify.sh` (557 lines, with node heredocs assembling `summary.json`) is now `src/adapters/mobile/verify.ts`, run in-process by `mm-harness verify --adapter mobile`. Same flags, env, exit codes, check names and artifacts (`summary.json`, `artifact-manifest.json`, `logs/`, smoke recipes, `runner-live-smoke/`); a live run against the same checkout produced a byte-identical normalised summary. `install` no longer copies a `scripts/verify.sh` into the checkout and dispatch no longer prefers one, so an older overlay cannot shadow the current runner's verify; the mobile overlay's `scripts/` tree is gone and its manifest advertises `runtimeHelpers: {}`, like Core. Extension verify is unchanged.
|
|
11
|
+
- The execution-provenance record is indexed into `artifact-manifest.json` through the same hardened manifest writer every other artifact uses (`indexArtifactManifest`), instead of a private copy that skipped a missing or unreadable manifest without a word. A manifest the run said it wrote but cannot be read is now the error it is.
|
|
12
|
+
|
|
13
|
+
## 0.57.0 - 2026-09-18
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- The task and checklist rules the harness shares with Farmslot now come from the Farmslot packages instead of private copies, against `@farmslot/protocol` 0.29.0 and `@farmslot/agent-runtime` 0.11.0 (`@farmslot/recipe-harness` 0.17.0, `@farmslot/expo-recipe` 0.10.0 follow the protocol pin). Which signal statuses are terminal: `isTerminalWorkerSignalStatus`, so `mark` now also protects a `failed` or `done` verdict from a later non-terminal mark, and `status --watch` no longer treats an unknown status as finished. Which box is step N: `enumerateChecklistCheckboxes`, so the `check diff` gate reads the row `mark N` ticks even when an upstream checklist sits under `## Rules` or inside `<details>`. Per-row durations: `deriveChecklistStepDurations`, the same numbers Command Center shows (a task without a recorded start now shows its first row as `0:00` rather than blank). Step names: `checklistStepName`. Which task a checkout is on: `latestTaskDir`, the task written to last by signal or by checklist. File names come from the protocol constants.
|
|
18
|
+
- `status --watch` reads the harness identity from the readiness record (`sandbox.json`) `prepare` writes on every surface; the `artifacts/harness-provenance.json` it looked for is the recipe-cook skill's locked-harness receipt and exists on skill runs only, so the `harness x.y.z` line was blank on every dispatched run.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `status --watch` no longer reports `no ongoing task detected` while an agent is busy on a long step. The view judged activity only by task-dir writes (`SIGNAL.json`, row marks) and called the task silent after ten minutes, so an investigation step that ran `mm-harness call` every few seconds without ticking a row read as abandoned. The command journal (`temp/recipe/runtime/last-command.json`) now counts as activity, and the view shows it: `last command call ui.navigate · pass · 0:12 ago`. Only the command name and a leading positional argument reach the view; option values never do, and the journal is read through the same hardened reader the harness uses (symlinks and malformed records mean no command line, never a dead watch). A journal older than the task's start belongs to an earlier task and does not count. Piped output logs `command call ui.navigate pass` when the journal moves. `status` itself is not journaled, so watching a checkout never counts as its own activity.
|
|
23
|
+
- `status --watch` renders a Farm checklist the way the Farmslot run view does. Steps, numbers and phases now come from `enumerateChecklistCheckboxes` in `@farmslot/protocol`, the same enumeration the gateway progress view and the `mark` helper use, instead of a private row pattern that rejected Farm's `- [x] **3. Resolve branch:**` rows and only knew `##` headings. A dispatched run therefore showed no rows at all; now it shows `Setup 5/5`, `Reproduce (BEFORE any code changes) 3/9`, … with the bold title as each step's name.
|
|
24
|
+
|
|
5
25
|
## 0.56.0 - 2026-09-18
|
|
6
26
|
|
|
7
27
|
### Changed
|
package/adapters/manifest.json
CHANGED
|
@@ -140,9 +140,9 @@
|
|
|
140
140
|
},
|
|
141
141
|
{
|
|
142
142
|
"id": "mobile/verify",
|
|
143
|
-
"entry": "adapters/mobile/verify.
|
|
144
|
-
"kind": "
|
|
145
|
-
"purpose": "Verify the mobile harness/runtime is present and healthy (no app launch).",
|
|
143
|
+
"entry": "src/adapters/mobile/verify.ts",
|
|
144
|
+
"kind": "module",
|
|
145
|
+
"purpose": "Verify the mobile harness/runtime is present and healthy (no app launch). Runner-owned: mm-harness verify runs it in-process.",
|
|
146
146
|
"inputs": "--target --artifacts-dir --static-only --platform ios|android --preflight-mode --no-auto-start",
|
|
147
147
|
"outputs": "verify report + evidence; exit 0/1/2"
|
|
148
148
|
},
|
|
@@ -34,7 +34,7 @@ fi
|
|
|
34
34
|
HARNESS_ROOT="$(harness_root)"
|
|
35
35
|
HARNESS_REL="$HARNESS_ROOT/mobile"
|
|
36
36
|
HARNESS_DIR="$(harness_dir "$TARGET" mobile)"
|
|
37
|
-
for rel in "$HARNESS_REL" "$HARNESS_REL/runner"
|
|
37
|
+
for rel in "$HARNESS_REL" "$HARNESS_REL/runner"; do
|
|
38
38
|
current="$TARGET"
|
|
39
39
|
IFS='/' read -r -a parts <<< "$rel"
|
|
40
40
|
for part in "${parts[@]}"; do
|
|
@@ -45,7 +45,7 @@ for rel in "$HARNESS_REL" "$HARNESS_REL/runner" "$HARNESS_REL/scripts"; do
|
|
|
45
45
|
done
|
|
46
46
|
|
|
47
47
|
rm -rf "$HARNESS_DIR/runner" "$HARNESS_DIR/scripts" "$HARNESS_DIR/bridge-runtime"
|
|
48
|
-
mkdir -p "$HARNESS_DIR/runner/bin" "$HARNESS_DIR/runner/manifests" "$HARNESS_DIR/runner/recipes"
|
|
48
|
+
mkdir -p "$HARNESS_DIR/runner/bin" "$HARNESS_DIR/runner/manifests" "$HARNESS_DIR/runner/recipes"
|
|
49
49
|
|
|
50
50
|
RUNNER_EXEC_Q="$(printf '%q' "$RUNNER_DIR/bin/mm-harness")"
|
|
51
51
|
TARGET_Q="$(printf '%q' "$TARGET")"
|
|
@@ -76,10 +76,7 @@ cp "$RUNNER_DIR/library/manifests/extension.action-manifest.json" "$HARNESS_DIR/
|
|
|
76
76
|
rsync -a --delete "$RUNNER_DIR/library/recipes/" "$HARNESS_DIR/runner/recipes/"
|
|
77
77
|
rm -rf "$HARNESS_DIR/runner/flows"
|
|
78
78
|
rm -f "$HARNESS_DIR/runner/library.json"
|
|
79
|
-
cp "$RUNNER_DIR/adapters/mobile/verify.sh" "$HARNESS_DIR/scripts/verify.sh"
|
|
80
79
|
cp -R "$RUNNER_DIR/adapters/mobile/bridge-runtime" "$HARNESS_DIR/bridge-runtime"
|
|
81
|
-
cp "$RUNNER_DIR/adapters/shared/harness-path.sh" "$HARNESS_DIR/scripts/lib/harness-path.sh"
|
|
82
|
-
cp "$RUNNER_DIR/adapters/shared/path-defaults.json" "$HARNESS_DIR/scripts/lib/path-defaults.json"
|
|
83
80
|
chmod -R u+rwX,go+rX "$HARNESS_DIR"
|
|
84
81
|
|
|
85
82
|
add_git_exclude() {
|
|
@@ -114,8 +111,8 @@ fs.writeFileSync(manifestPath, JSON.stringify({
|
|
|
114
111
|
protocolVersion: 'v1',
|
|
115
112
|
actionManifestPath: `${harnessRel}/action-manifest.json`,
|
|
116
113
|
runnerEntrypoint: `${harnessRel}/runner/bin/mm-harness`,
|
|
117
|
-
runtimeHelpers: {
|
|
118
|
-
harnessInstalledPaths: [`${harnessRel}/runner`, `${harnessRel}/
|
|
114
|
+
runtimeHelpers: {},
|
|
115
|
+
harnessInstalledPaths: [`${harnessRel}/runner`, `${harnessRel}/bridge-runtime`, `${harnessRel}/action-manifest.json`],
|
|
119
116
|
productOwnedPaths: ['app/dev-tools/AgenticService', 'app/core/NavigationService/NavigationService.ts'],
|
|
120
117
|
cleanupCommand,
|
|
121
118
|
productDiffExcludes: [`:(exclude)${harnessRoot}`],
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { readRuntimeContextField } from "../../harness.js";
|
|
6
|
+
import { recipeHarnessPath, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "../../paths.js";
|
|
7
|
+
const USAGE = "Usage: mm-harness verify --adapter mobile -- [--target <metamask-mobile>] [--artifacts-dir <dir>] [--static-only] [--platform ios|android] [--preflight-mode fast|auto|default|rebuild-native|clean] [--no-auto-start]";
|
|
8
|
+
const ENV_FILES = [".js.env", ".env", ".env.local"];
|
|
9
|
+
const PREFLIGHT_MODES = ["fast", "auto", "default", "rebuild-native", "clean"];
|
|
10
|
+
const AUTO_START_TRUE = ["1", "true", "TRUE", "True", "yes", "YES", "Yes", "on", "ON", "On"];
|
|
11
|
+
const AUTO_START_FALSE = ["0", "false", "FALSE", "False", "no", "NO", "No", "off", "OFF", "Off", ""];
|
|
12
|
+
const AUTO_START_REFUSAL = "Mobile auto-start is not allowed from product-local scripts. Start or prepare the app through the runner/slot runtime, then rerun verify with --no-auto-start.";
|
|
13
|
+
const EVM_READINESS_EXPRESSION = "(async function(){try{var engine=globalThis.Engine;var controller=engine&&engine.context&&engine.context.NetworkController;if(!controller||typeof controller.getSelectedNetworkClient!=='function')throw new Error('NetworkController selected client is unavailable');var client=controller.getSelectedNetworkClient();if(!client||!client.provider||typeof client.provider.request!=='function')throw new Error('selected EVM provider is unavailable');var result=await client.provider.request({method:'eth_blockNumber'});if(typeof result!=='string'||!/^0x[0-9a-f]+$/i.test(result))throw new Error('eth_blockNumber returned an invalid result');return {ok:true,result:result};}catch(error){return {ok:false,error:error&&(error.message||String(error))};}})()";
|
|
14
|
+
const STATUS_SMOKE_RECIPE = `{
|
|
15
|
+
"$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
|
|
16
|
+
"title": "Mobile runner status smoke",
|
|
17
|
+
"description": "Checks that the installed runner can read Mobile app status before live verification.",
|
|
18
|
+
"workflow": {
|
|
19
|
+
"entry": "status",
|
|
20
|
+
"nodes": {
|
|
21
|
+
"status": { "action": "app.status", "intent": "Read Mobile app status through the runner-owned bridge", "next": "done" },
|
|
22
|
+
"done": { "action": "end", "status": "pass" }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
const LIVE_SMOKE_RECIPE = `{
|
|
28
|
+
"$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
|
|
29
|
+
"title": "Mobile v1 runner live bridge smoke",
|
|
30
|
+
"description": "Verifies the installed MetaMask runner can read the React Native debug bridge without mutating wallet state.",
|
|
31
|
+
"workflow": {
|
|
32
|
+
"entry": "status",
|
|
33
|
+
"nodes": {
|
|
34
|
+
"status": { "action": "app.status", "intent": "Read Mobile app status through the v1 runner", "next": "cdp-probe" },
|
|
35
|
+
"cdp-probe": { "action": "cdp.target", "intent": "Verify the React Native debug bridge target is reachable", "require_reachable": true, "timeout_ms": 60000, "cdp_timeout_ms": 60000, "next": "done" },
|
|
36
|
+
"done": { "action": "end", "status": "pass" }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
`;
|
|
41
|
+
function parseMobileVerifyArgs(argv, env = process.env) {
|
|
42
|
+
let target = process.cwd();
|
|
43
|
+
let artifactsDir;
|
|
44
|
+
let staticOnly = false;
|
|
45
|
+
let platform = env.RECIPE_HARNESS_PLATFORM || env.PLATFORM || "ios";
|
|
46
|
+
let preflightMode = env.RECIPE_HARNESS_MOBILE_PREFLIGHT_MODE || "fast";
|
|
47
|
+
let autoStart = env.RECIPE_HARNESS_MOBILE_AUTO_START ?? "false";
|
|
48
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
49
|
+
const arg = argv[i];
|
|
50
|
+
const needsValue = () => {
|
|
51
|
+
if (i + 1 >= argv.length) return void 0;
|
|
52
|
+
i += 1;
|
|
53
|
+
return argv[i];
|
|
54
|
+
};
|
|
55
|
+
switch (arg) {
|
|
56
|
+
case "--target": {
|
|
57
|
+
const value = needsValue();
|
|
58
|
+
if (value === void 0) return { kind: "error", message: "Missing value for --target", exitCode: 2 };
|
|
59
|
+
target = value;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case "--artifacts-dir": {
|
|
63
|
+
const value = needsValue();
|
|
64
|
+
if (value === void 0) return { kind: "error", message: "Missing value for --artifacts-dir", exitCode: 2 };
|
|
65
|
+
artifactsDir = value;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "--static-only":
|
|
69
|
+
staticOnly = true;
|
|
70
|
+
break;
|
|
71
|
+
case "--platform": {
|
|
72
|
+
const value = needsValue();
|
|
73
|
+
if (value === void 0) return { kind: "error", message: "Missing value for --platform", exitCode: 2 };
|
|
74
|
+
platform = value;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case "--preflight-mode": {
|
|
78
|
+
const value = needsValue();
|
|
79
|
+
if (value === void 0) return { kind: "error", message: "Missing value for --preflight-mode", exitCode: 2 };
|
|
80
|
+
preflightMode = value;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "--auto-start":
|
|
84
|
+
autoStart = "true";
|
|
85
|
+
break;
|
|
86
|
+
case "--no-auto-start":
|
|
87
|
+
autoStart = "false";
|
|
88
|
+
break;
|
|
89
|
+
case "-h":
|
|
90
|
+
case "--help":
|
|
91
|
+
return { kind: "help", usage: USAGE };
|
|
92
|
+
default:
|
|
93
|
+
return { kind: "error", message: `Unknown arg: ${arg}`, exitCode: 2 };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!PREFLIGHT_MODES.includes(preflightMode)) {
|
|
97
|
+
return { kind: "error", message: `Unknown --preflight-mode: ${preflightMode}`, exitCode: 2 };
|
|
98
|
+
}
|
|
99
|
+
if (platform !== "ios" && platform !== "android") {
|
|
100
|
+
return { kind: "error", message: `Unknown --platform: ${platform} (expected ios or android)`, exitCode: 2 };
|
|
101
|
+
}
|
|
102
|
+
if (!AUTO_START_TRUE.includes(autoStart) && !AUTO_START_FALSE.includes(autoStart)) {
|
|
103
|
+
return { kind: "error", message: `Unknown RECIPE_HARNESS_MOBILE_AUTO_START value: ${autoStart}`, exitCode: 2 };
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
kind: "options",
|
|
107
|
+
options: {
|
|
108
|
+
target,
|
|
109
|
+
artifactsDir,
|
|
110
|
+
staticOnly,
|
|
111
|
+
platform,
|
|
112
|
+
preflightMode,
|
|
113
|
+
autoStart: AUTO_START_TRUE.includes(autoStart)
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function isNumericWatcherPort(port) {
|
|
118
|
+
return port.length > 0 && /^[0-9]+$/u.test(port);
|
|
119
|
+
}
|
|
120
|
+
function classifyMobileRuntimeOwner(portHolder, startedByVerify, liveSmokeStatus) {
|
|
121
|
+
if (!portHolder) return "static-only";
|
|
122
|
+
if (startedByVerify) return "harness-owned";
|
|
123
|
+
if (!portHolder.listening) return "none";
|
|
124
|
+
return liveSmokeStatus === "pass" ? "compatible-external-or-harness" : "incompatible-external-or-stale";
|
|
125
|
+
}
|
|
126
|
+
function buildMobileVerifySummary(input) {
|
|
127
|
+
const liveRuntimeCheck = input.checks.find((check) => check.name === "runner v1 live bridge smoke");
|
|
128
|
+
const runtimeOwner = classifyMobileRuntimeOwner(input.portHolder, input.startedByVerify, liveRuntimeCheck?.status);
|
|
129
|
+
return {
|
|
130
|
+
adapter: "mobile",
|
|
131
|
+
status: input.status,
|
|
132
|
+
runtimeClassification: {
|
|
133
|
+
runtimeOwner,
|
|
134
|
+
recipeControllable: liveRuntimeCheck?.status === "pass",
|
|
135
|
+
startedByVerify: input.startedByVerify
|
|
136
|
+
},
|
|
137
|
+
cleanupOwnership: {
|
|
138
|
+
mayStop: input.startedByVerify,
|
|
139
|
+
reason: input.startedByVerify ? "verify launched the runtime through harness preflight" : "verify did not launch this runtime; do not stop human-owned or pre-existing processes automatically"
|
|
140
|
+
},
|
|
141
|
+
gitStatus: input.gitStatus,
|
|
142
|
+
runtimePolicy: {
|
|
143
|
+
preflightMode: input.preflightMode,
|
|
144
|
+
nativeBuildPolicy: input.preflightMode === "fast" ? "verify does not start product-local Mobile scripts; start/reuse a runner-owned or slot-owned runtime before live proof" : "verify did not start product-local Mobile scripts"
|
|
145
|
+
},
|
|
146
|
+
fixtureStatus: input.fixtureStatus,
|
|
147
|
+
portHolder: input.portHolder,
|
|
148
|
+
runtimeNotes: input.runtimeNotes,
|
|
149
|
+
checks: input.checks,
|
|
150
|
+
generatedAt: input.generatedAt
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function resolveWatcherPort(target, env = process.env) {
|
|
154
|
+
const explicit = env.WATCHER_PORT || "";
|
|
155
|
+
if (explicit) return explicit;
|
|
156
|
+
for (const file of ENV_FILES) {
|
|
157
|
+
const full = path.join(target, file);
|
|
158
|
+
if (!fs.existsSync(full)) continue;
|
|
159
|
+
const match = fs.readFileSync(full, "utf8").match(/^\s*(?:export\s+)?WATCHER_PORT=(["']?)([0-9]+)\1/mu);
|
|
160
|
+
if (match) return match[2];
|
|
161
|
+
}
|
|
162
|
+
return "8081";
|
|
163
|
+
}
|
|
164
|
+
function resolveJsEnvValue(target, name, env = process.env) {
|
|
165
|
+
const fromProcess = env[name] || "";
|
|
166
|
+
if (fromProcess) return fromProcess;
|
|
167
|
+
const pattern = new RegExp(`^\\s*(?:export\\s+)?${name}=(["']?)([^"'\\n]+)\\1`, "mu");
|
|
168
|
+
for (const file of ENV_FILES) {
|
|
169
|
+
const full = path.join(target, file);
|
|
170
|
+
if (!fs.existsSync(full)) continue;
|
|
171
|
+
const match = fs.readFileSync(full, "utf8").match(pattern);
|
|
172
|
+
if (match) return match[2];
|
|
173
|
+
}
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
function runtimeContextValue(target, names) {
|
|
177
|
+
const file = path.join(target, recipeRuntimeDir(), "agentic-runtime.json");
|
|
178
|
+
for (const name of names) {
|
|
179
|
+
const value = readRuntimeContextField(file, name);
|
|
180
|
+
if (value !== void 0 && value !== "") return value;
|
|
181
|
+
}
|
|
182
|
+
return "";
|
|
183
|
+
}
|
|
184
|
+
function mobileDeviceTarget(target, platform, env) {
|
|
185
|
+
if (platform === "android") {
|
|
186
|
+
return env.ADB_SERIAL || env.ANDROID_SERIAL || env.ANDROID_DEVICE || runtimeContextValue(target, ["adbSerial", "androidDevice", "device"]);
|
|
187
|
+
}
|
|
188
|
+
return env.SIM_UDID || env.IOS_SIMULATOR || runtimeContextValue(target, ["simulator", "iosSimulator", "device"]);
|
|
189
|
+
}
|
|
190
|
+
function mobileFixtureStatus(target) {
|
|
191
|
+
const runtimeDir = recipeRuntimeDir();
|
|
192
|
+
const fixture = path.join(target, runtimeDir, "wallet-fixture.json");
|
|
193
|
+
if (!fs.existsSync(fixture)) {
|
|
194
|
+
return {
|
|
195
|
+
status: "MISSING_FIXTURES",
|
|
196
|
+
message: `No wallet fixture found. This run may spend time repairing wallet/perps state manually. For a clean isolated sandbox, create ${runtimeDir}/wallet-fixture.json.`,
|
|
197
|
+
setupCommand: `mkdir -p ${runtimeDir} && edit ${runtimeDir}/wallet-fixture.json with password + accounts`
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const fixtureRaw = fs.readFileSync(fixture);
|
|
201
|
+
let valid = false;
|
|
202
|
+
let accountCount = null;
|
|
203
|
+
let hasPassword = false;
|
|
204
|
+
try {
|
|
205
|
+
const parsed = JSON.parse(fixtureRaw.toString("utf8"));
|
|
206
|
+
valid = true;
|
|
207
|
+
accountCount = Array.isArray(parsed.accounts) ? parsed.accounts.length : 0;
|
|
208
|
+
hasPassword = typeof parsed.password === "string" && parsed.password.length > 0;
|
|
209
|
+
} catch {
|
|
210
|
+
valid = false;
|
|
211
|
+
}
|
|
212
|
+
const relative = path.relative(target, fixture);
|
|
213
|
+
const ready = valid && hasPassword && (accountCount ?? 0) > 0;
|
|
214
|
+
return {
|
|
215
|
+
status: ready ? "READY" : "STALE_OR_INVALID",
|
|
216
|
+
path: relative,
|
|
217
|
+
sha256: crypto.createHash("sha256").update(fixtureRaw).digest("hex"),
|
|
218
|
+
modifiedAt: fs.statSync(fixture).mtime.toISOString(),
|
|
219
|
+
accountCount,
|
|
220
|
+
hasPassword,
|
|
221
|
+
message: ready ? `Fixture status: READY (${relative}, accounts=${accountCount}).` : `Fixture status: STALE_OR_INVALID (${relative}). Validate password/accounts before relying on a clean sandbox.`
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function capture(command, args) {
|
|
225
|
+
try {
|
|
226
|
+
return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
227
|
+
} catch {
|
|
228
|
+
return "";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function mobilePortHolder(port) {
|
|
232
|
+
const pid = capture("lsof", [`-iTCP:${port}`, "-sTCP:LISTEN", "-t"]).split("\n")[0] ?? "";
|
|
233
|
+
const command = /^[0-9]+$/u.test(pid) ? capture("ps", ["-p", pid, "-o", "command="]) : "";
|
|
234
|
+
return {
|
|
235
|
+
port,
|
|
236
|
+
listening: Boolean(pid),
|
|
237
|
+
pid: pid || null,
|
|
238
|
+
command: command || null,
|
|
239
|
+
metroStatusReachable: null,
|
|
240
|
+
metroHttpProbeSkipped: true,
|
|
241
|
+
note: "HTTP /status probing is skipped during live verify because the React Native bridge is the authoritative readiness path for Mobile."
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function spawnToLog(logPath, command, args, options = {}) {
|
|
245
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
246
|
+
const fd = fs.openSync(logPath, "w");
|
|
247
|
+
try {
|
|
248
|
+
const result = spawnSync(command, args, {
|
|
249
|
+
cwd: options.cwd,
|
|
250
|
+
env: options.env ?? process.env,
|
|
251
|
+
stdio: ["ignore", fd, fd],
|
|
252
|
+
...options.timeoutMs === void 0 ? {} : { timeout: options.timeoutMs }
|
|
253
|
+
});
|
|
254
|
+
if (result.error) {
|
|
255
|
+
const timedOut = result.error.code === "ETIMEDOUT";
|
|
256
|
+
fs.writeSync(
|
|
257
|
+
fd,
|
|
258
|
+
timedOut ? `Timed out after ${(options.timeoutMs ?? 0) / 1e3}s: ${[command, ...args].join(" ")}
|
|
259
|
+
` : `${result.error.message}
|
|
260
|
+
`
|
|
261
|
+
);
|
|
262
|
+
return timedOut ? 124 : 127;
|
|
263
|
+
}
|
|
264
|
+
return result.status ?? 1;
|
|
265
|
+
} finally {
|
|
266
|
+
fs.closeSync(fd);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function readJsonFile(file) {
|
|
270
|
+
try {
|
|
271
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function gitStatusSummary(target, harnessRootExclude) {
|
|
277
|
+
const runGit = (args) => {
|
|
278
|
+
try {
|
|
279
|
+
return execFileSync("git", ["-C", target, ...args], {
|
|
280
|
+
encoding: "utf8",
|
|
281
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
282
|
+
}).trim();
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
const statusShort = runGit(["status", "--short", "--", ".", `:(exclude)${harnessRootExclude}`]);
|
|
288
|
+
const dirty = statusShort ? statusShort.split("\n").filter(Boolean) : [];
|
|
289
|
+
return {
|
|
290
|
+
branch: runGit(["branch", "--show-current"]),
|
|
291
|
+
head: runGit(["rev-parse", "--short", "HEAD"]),
|
|
292
|
+
dirtyCount: dirty.length,
|
|
293
|
+
dirtyPreview: dirty.slice(0, 25)
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function utcStamp(date) {
|
|
297
|
+
return `${date.toISOString().replace(/[-:]/gu, "").split(".")[0]}Z`;
|
|
298
|
+
}
|
|
299
|
+
function sleep(ms) {
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
setTimeout(resolve, ms);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const defaultIo = {
|
|
305
|
+
out: (line) => process.stdout.write(`${line}
|
|
306
|
+
`),
|
|
307
|
+
err: (line) => process.stderr.write(`${line}
|
|
308
|
+
`)
|
|
309
|
+
};
|
|
310
|
+
async function runMobileVerify(argv, io = defaultIo) {
|
|
311
|
+
const parsed = parseMobileVerifyArgs(argv);
|
|
312
|
+
if (parsed.kind === "help") {
|
|
313
|
+
io.out(parsed.usage);
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
if (parsed.kind === "error") {
|
|
317
|
+
io.err(parsed.message);
|
|
318
|
+
return parsed.exitCode;
|
|
319
|
+
}
|
|
320
|
+
const { staticOnly, platform, preflightMode, autoStart } = parsed.options;
|
|
321
|
+
const target = path.resolve(parsed.options.target);
|
|
322
|
+
if (!fs.existsSync(target)) {
|
|
323
|
+
io.err(`Refusing mobile verify: target checkout does not exist: ${target}`);
|
|
324
|
+
io.err(" Next: pass --target <metamask-mobile checkout>");
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
const harnessRoot = recipeHarnessRoot();
|
|
328
|
+
const harnessRel = `${harnessRoot}/mobile`;
|
|
329
|
+
const harnessDir = recipeHarnessPath(target, "mobile");
|
|
330
|
+
const runnerBin = path.join(harnessDir, "runner/bin/mm-harness");
|
|
331
|
+
const artifacts = parsed.options.artifactsDir ? path.resolve(parsed.options.artifactsDir) : path.join(harnessDir, "verify", utcStamp(/* @__PURE__ */ new Date()));
|
|
332
|
+
fs.mkdirSync(path.join(artifacts, "logs"), { recursive: true });
|
|
333
|
+
const notesPath = path.join(artifacts, "logs/runtime-notes.txt");
|
|
334
|
+
const addNote = (note) => {
|
|
335
|
+
fs.appendFileSync(notesPath, `${note}
|
|
336
|
+
`);
|
|
337
|
+
};
|
|
338
|
+
let status = "pass";
|
|
339
|
+
const checks = [];
|
|
340
|
+
const checkFile = (rel) => {
|
|
341
|
+
const ok = fs.existsSync(path.join(target, rel));
|
|
342
|
+
checks.push({ name: rel, status: ok ? "pass" : "fail" });
|
|
343
|
+
if (!ok) status = "fail";
|
|
344
|
+
};
|
|
345
|
+
const checkContains = (name, file, needle) => {
|
|
346
|
+
let ok = false;
|
|
347
|
+
try {
|
|
348
|
+
ok = fs.readFileSync(path.join(target, file), "utf8").includes(needle);
|
|
349
|
+
} catch {
|
|
350
|
+
ok = false;
|
|
351
|
+
}
|
|
352
|
+
checks.push({ name, status: ok ? "pass" : "fail" });
|
|
353
|
+
if (!ok) status = "fail";
|
|
354
|
+
};
|
|
355
|
+
checkFile(`${harnessRel}/manifest.json`);
|
|
356
|
+
checkFile(`${harnessRel}/action-manifest.json`);
|
|
357
|
+
checkFile(`${harnessRel}/runner/bin/mm-harness`);
|
|
358
|
+
checkFile("package.json");
|
|
359
|
+
checkFile("app/dev-tools/AgenticService/AgenticService.ts");
|
|
360
|
+
checkContains("NavigationService agentic bridge", "app/core/NavigationService/NavigationService.ts", "AgenticService.install");
|
|
361
|
+
checkContains("App AgentStepHud", "app/components/Nav/App/App.tsx", "AgentStepHud");
|
|
362
|
+
let fixtureStatus = null;
|
|
363
|
+
let portHolder = null;
|
|
364
|
+
if (!staticOnly) {
|
|
365
|
+
const port = resolveWatcherPort(target);
|
|
366
|
+
if (!isNumericWatcherPort(port)) {
|
|
367
|
+
io.err(`Refusing mobile verify: resolved watcher port is not numeric: '${port}' (check WATCHER_PORT)`);
|
|
368
|
+
return 2;
|
|
369
|
+
}
|
|
370
|
+
let deviceTarget = mobileDeviceTarget(target, platform, process.env);
|
|
371
|
+
if (!deviceTarget) {
|
|
372
|
+
deviceTarget = platform === "android" ? resolveJsEnvValue(target, "ADB_SERIAL") || resolveJsEnvValue(target, "ANDROID_DEVICE") : resolveJsEnvValue(target, "IOS_SIMULATOR");
|
|
373
|
+
}
|
|
374
|
+
const deviceArgs = deviceTarget ? ["--device", deviceTarget] : [];
|
|
375
|
+
const statusSmokeRecipe = path.join(artifacts, "mobile-status-smoke.recipe.json");
|
|
376
|
+
const precheckLog = path.join(artifacts, "logs/app-status-precheck.log");
|
|
377
|
+
let liveRuntimeOk = false;
|
|
378
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
379
|
+
fs.writeFileSync(statusSmokeRecipe, STATUS_SMOKE_RECIPE);
|
|
380
|
+
const code = spawnToLog(precheckLog, runnerBin, [
|
|
381
|
+
"run",
|
|
382
|
+
statusSmokeRecipe,
|
|
383
|
+
"--adapter",
|
|
384
|
+
"mobile",
|
|
385
|
+
"--target",
|
|
386
|
+
target,
|
|
387
|
+
...deviceArgs,
|
|
388
|
+
"--watcher-port",
|
|
389
|
+
port,
|
|
390
|
+
"--artifacts-dir",
|
|
391
|
+
path.join(artifacts, "runner-status-smoke"),
|
|
392
|
+
"--json"
|
|
393
|
+
], { cwd: target });
|
|
394
|
+
if (code === 0) {
|
|
395
|
+
liveRuntimeOk = true;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
const deviceNotFound = /"code"[ \t]*:[ \t]*"DEVICE_NOT_FOUND"/u.test(
|
|
399
|
+
fs.readFileSync(precheckLog, "utf8")
|
|
400
|
+
);
|
|
401
|
+
if (attempt < 5 && deviceNotFound) {
|
|
402
|
+
io.err(`Waiting for Mobile device discovery (${attempt}/5): ${deviceTarget || "configured target"}`);
|
|
403
|
+
await sleep(1e3);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
if (!liveRuntimeOk && autoStart) io.err(AUTO_START_REFUSAL);
|
|
409
|
+
checks.push(liveRuntimeOk ? { name: "mobile runtime controllable precheck", status: "pass" } : { name: "mobile runtime controllable precheck", status: "fail", detail: "see logs/app-status-precheck.log" });
|
|
410
|
+
if (!liveRuntimeOk) status = "fail";
|
|
411
|
+
fixtureStatus = mobileFixtureStatus(target);
|
|
412
|
+
fs.writeFileSync(path.join(artifacts, "logs/fixture-status.json"), `${JSON.stringify(fixtureStatus)}
|
|
413
|
+
`);
|
|
414
|
+
const fixtureCheck = {
|
|
415
|
+
name: "fixture status",
|
|
416
|
+
status: fixtureStatus.status === "READY" ? "pass" : "warn",
|
|
417
|
+
detail: fixtureStatus.path || fixtureStatus.status || "",
|
|
418
|
+
message: fixtureStatus.message || fixtureStatus.status
|
|
419
|
+
};
|
|
420
|
+
io.err(fixtureCheck.message);
|
|
421
|
+
addNote(fixtureCheck.message);
|
|
422
|
+
checks.push(fixtureCheck);
|
|
423
|
+
portHolder = mobilePortHolder(port);
|
|
424
|
+
fs.writeFileSync(path.join(artifacts, "logs/port-holder.json"), `${JSON.stringify(portHolder)}
|
|
425
|
+
`);
|
|
426
|
+
if (status !== "fail" && portHolder.listening) {
|
|
427
|
+
const waiter = path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh");
|
|
428
|
+
if (!fs.existsSync(waiter)) {
|
|
429
|
+
addNote("Mobile bridge settle skipped: wait-for-bridge.sh not found under the runner adapters; reinstall the runner.");
|
|
430
|
+
} else {
|
|
431
|
+
const waiterCode = spawnToLog(
|
|
432
|
+
path.join(artifacts, "logs/wait-for-bridge.log"),
|
|
433
|
+
"bash",
|
|
434
|
+
[waiter, "--target", target, "--port", port, "--platform", platform],
|
|
435
|
+
{
|
|
436
|
+
env: {
|
|
437
|
+
...process.env,
|
|
438
|
+
MOBILE_BRIDGE_READY_TIMEOUT_MS: process.env.MOBILE_BRIDGE_READY_TIMEOUT_MS || "90000",
|
|
439
|
+
MOBILE_BRIDGE_UNRESPONSIVE_GRACE_MS: process.env.MOBILE_BRIDGE_UNRESPONSIVE_GRACE_MS || "90000"
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
);
|
|
443
|
+
addNote(waiterCode === 0 ? "Mobile bridge settled before the live smoke (logs/wait-for-bridge.log)." : `Mobile bridge settle did not complete (waiter exit ${waiterCode}); running the live smoke anyway (logs/wait-for-bridge.log).`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const liveSmokeRecipe = path.join(artifacts, "mobile-v1-live-smoke.recipe.json");
|
|
447
|
+
fs.writeFileSync(liveSmokeRecipe, LIVE_SMOKE_RECIPE);
|
|
448
|
+
const iosSimulatorResolved = resolveJsEnvValue(target, "IOS_SIMULATOR");
|
|
449
|
+
const adbSerialResolved = resolveJsEnvValue(target, "ADB_SERIAL");
|
|
450
|
+
const liveSmokeCode = spawnToLog(
|
|
451
|
+
path.join(artifacts, "logs/runner-live-smoke.log"),
|
|
452
|
+
runnerBin,
|
|
453
|
+
[
|
|
454
|
+
"run",
|
|
455
|
+
liveSmokeRecipe,
|
|
456
|
+
"--adapter",
|
|
457
|
+
"mobile",
|
|
458
|
+
"--target",
|
|
459
|
+
target,
|
|
460
|
+
...deviceArgs,
|
|
461
|
+
"--watcher-port",
|
|
462
|
+
port,
|
|
463
|
+
"--artifacts-dir",
|
|
464
|
+
path.join(artifacts, "runner-live-smoke"),
|
|
465
|
+
"--json"
|
|
466
|
+
],
|
|
467
|
+
{
|
|
468
|
+
cwd: target,
|
|
469
|
+
env: {
|
|
470
|
+
...process.env,
|
|
471
|
+
METAMASK_RECIPE_AUTO_HUD: "0",
|
|
472
|
+
IOS_SIMULATOR: process.env.IOS_SIMULATOR || iosSimulatorResolved,
|
|
473
|
+
ADB_SERIAL: process.env.ADB_SERIAL || adbSerialResolved,
|
|
474
|
+
ANDROID_SERIAL: process.env.ANDROID_SERIAL || adbSerialResolved
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
);
|
|
478
|
+
if (liveSmokeCode === 0) {
|
|
479
|
+
checks.push({ name: "runner v1 live bridge smoke", status: "pass" });
|
|
480
|
+
} else {
|
|
481
|
+
checks.push({ name: "runner v1 live bridge smoke", status: "fail", detail: "see logs/runner-live-smoke.log" });
|
|
482
|
+
addNote("Runner v1 live bridge smoke failed; inspect logs/runner-live-smoke.log and runner-live-smoke/trace.json.");
|
|
483
|
+
status = "fail";
|
|
484
|
+
}
|
|
485
|
+
const evmLog = path.join(artifacts, "logs/evm-rpc-readiness.log");
|
|
486
|
+
const bridge = path.join(harnessDir, "bridge-runtime/cdp-bridge.cjs");
|
|
487
|
+
let evmOk = false;
|
|
488
|
+
if (!fs.existsSync(bridge)) {
|
|
489
|
+
fs.writeFileSync(evmLog, `Mobile bridge is missing at ${bridge}
|
|
490
|
+
`);
|
|
491
|
+
} else {
|
|
492
|
+
const evmCode = spawnToLog(evmLog, process.execPath, [bridge, "eval-async", EVM_READINESS_EXPRESSION], {
|
|
493
|
+
timeoutMs: 25e3,
|
|
494
|
+
env: {
|
|
495
|
+
...process.env,
|
|
496
|
+
APP_ROOT: target,
|
|
497
|
+
WATCHER_PORT: port,
|
|
498
|
+
IOS_SIMULATOR: process.env.IOS_SIMULATOR || iosSimulatorResolved,
|
|
499
|
+
ADB_SERIAL: process.env.ADB_SERIAL || adbSerialResolved,
|
|
500
|
+
ANDROID_SERIAL: process.env.ANDROID_SERIAL || adbSerialResolved
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
evmOk = evmCode === 0 && readJsonFile(evmLog)?.ok === true;
|
|
504
|
+
}
|
|
505
|
+
if (evmOk) {
|
|
506
|
+
checks.push({ name: "live EVM RPC readiness", status: "pass" });
|
|
507
|
+
} else {
|
|
508
|
+
checks.push({ name: "live EVM RPC readiness", status: "fail", detail: "see logs/evm-rpc-readiness.log" });
|
|
509
|
+
addNote(`EVM RPC readiness probe failed. Infura configured \u2260 RPC reachable. Next: confirm ${target}/.js.env has a real MM_INFURA_PROJECT_ID (not 00000000000), then rerun mm-harness launch ${platform} --verify --target '${target}'.`);
|
|
510
|
+
status = "fail";
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const summary = buildMobileVerifySummary({
|
|
514
|
+
status,
|
|
515
|
+
preflightMode,
|
|
516
|
+
startedByVerify: fs.existsSync(path.join(artifacts, "logs/harness-started-runtime")),
|
|
517
|
+
fixtureStatus: readJsonFile(path.join(artifacts, "logs/fixture-status.json")),
|
|
518
|
+
portHolder: readJsonFile(path.join(artifacts, "logs/port-holder.json")),
|
|
519
|
+
runtimeNotes: readRuntimeNotes(notesPath),
|
|
520
|
+
checks,
|
|
521
|
+
gitStatus: gitStatusSummary(target, harnessRoot),
|
|
522
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
523
|
+
});
|
|
524
|
+
fs.writeFileSync(path.join(artifacts, "summary.json"), `${JSON.stringify(summary, null, 2)}
|
|
525
|
+
`);
|
|
526
|
+
fs.writeFileSync(
|
|
527
|
+
path.join(artifacts, "artifact-manifest.json"),
|
|
528
|
+
`${JSON.stringify({ artifacts: fs.readdirSync(artifacts).map((name) => ({ path: name })) }, null, 2)}
|
|
529
|
+
`
|
|
530
|
+
);
|
|
531
|
+
io.out(`Mobile harness verify ${status}: ${path.join(artifacts, "summary.json")}`);
|
|
532
|
+
return status === "pass" ? 0 : 1;
|
|
533
|
+
}
|
|
534
|
+
function readRuntimeNotes(notesPath) {
|
|
535
|
+
try {
|
|
536
|
+
return fs.readFileSync(notesPath, "utf8").trim().split("\n").filter(Boolean);
|
|
537
|
+
} catch {
|
|
538
|
+
return [];
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
export {
|
|
542
|
+
buildMobileVerifySummary,
|
|
543
|
+
classifyMobileRuntimeOwner,
|
|
544
|
+
isNumericWatcherPort,
|
|
545
|
+
parseMobileVerifyArgs,
|
|
546
|
+
resolveJsEnvValue,
|
|
547
|
+
resolveWatcherPort,
|
|
548
|
+
runMobileVerify
|
|
549
|
+
};
|