@deeeed/metamask-harness 0.84.0 → 0.86.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 +65 -0
- package/README.md +5 -3
- package/adapters/extension/console-tail.mjs +11 -7
- package/adapters/extension/ensure-browser.sh +17 -18
- package/adapters/extension/refresh-build.sh +1 -1
- package/adapters/extension/start-watch.sh +17 -8
- package/adapters/manifest.json +1 -1
- package/adapters/mobile/reload-app.mjs +2 -1
- package/adapters/mobile/start-console-forwarder.sh +8 -0
- package/adapters/shared/resolve-slot-ports-core.mjs +48 -6
- package/dist/adapters/console-capture.js +3 -1
- package/dist/adapters/extension/actions.js +5 -0
- package/dist/adapters/extension/console-capture.js +18 -0
- package/dist/adapters/extension/fixtures.js +2 -0
- package/dist/adapters/extension/overlay.js +5 -4
- package/dist/adapters/extension/runtime.js +2 -0
- package/dist/adapters/mobile/launch.js +19 -2
- package/dist/adapters/mobile/provision.js +4 -2
- package/dist/adapters/mobile/run.js +3 -2
- package/dist/adapters/mobile/verify.js +15 -7
- package/dist/adapters/web-dapp/actions.js +7 -5
- package/dist/adapters.js +5 -3
- package/dist/command-contract.js +32 -20
- package/dist/commands/check.js +5 -7
- package/dist/commands/debug.js +2 -2
- package/dist/commands/doctor.js +2 -3
- package/dist/commands/domain.js +4 -3
- package/dist/commands/fixtures.js +3 -3
- package/dist/commands/help.js +6 -5
- package/dist/commands/launch/extension.js +9 -7
- package/dist/commands/logs.js +2 -2
- package/dist/commands/prepare.js +4 -4
- package/dist/commands/provision.js +2 -2
- package/dist/commands/review.js +8 -10
- package/dist/commands/runtime-launch.js +2 -0
- package/dist/commands/status-probe.js +8 -1
- package/dist/commands/status.js +4 -2
- package/dist/commands/task-init.js +2 -3
- package/dist/console-classifier.js +3 -2
- package/dist/doctor.js +6 -2
- package/dist/mm-harness-cli.js +19 -6
- package/dist/paths.js +9 -4
- package/dist/recipe-security.js +1 -1
- package/dist/review/knowledge.js +16 -4
- package/dist/runner.js +18 -3
- package/docs/CONTRIBUTING.md +11 -0
- package/docs/QA.md +1 -0
- package/docs/RECIPES.md +18 -2
- package/docs/cli-contract.md +8 -6
- package/library/actions/core/perps/_controller.mjs +2 -2
- package/library/actions/extension/app/assert_no_console_errors.mjs +95 -0
- package/library/actions/extension/platform/cdp.mjs +72 -2
- package/library/actions/extension/ui/navigate.mjs +3 -1
- package/library/actions/extension/wallet/select_account.mjs +17 -17
- package/library/actions/extension/wallet/state.mjs +75 -21
- package/library/actions/mobile/app/assert_no_console_errors.mjs +129 -0
- package/library/actions/mobile/wallet/select_account.mjs +34 -18
- package/library/actions/shared/console-assert.mjs +174 -0
- package/library/actions/shared/console-collector.mjs +10 -3
- package/library/actions/shared/console-findings.mjs +108 -7
- package/library/actions/web-dapp/app/assert_no_console_errors.mjs +10 -54
- package/library/actions/web-dapp/platform/hud.mjs +2 -9
- package/library/actions/web-dapp/platform/page.mjs +62 -16
- package/library/actions/web-dapp/platform/runtime.mjs +1 -1
- package/library/diagnostics/console-allowlist.json +13 -0
- package/library/manifests/core.action-manifest.json +6 -6
- package/library/manifests/extension.action-manifest.json +37 -1
- package/library/manifests/mobile.action-manifest.json +40 -4
- package/library/manifests/web-dapp.action-manifest.json +1 -1
- package/library/recipes/core/health/check.recipe.json +40 -0
- package/library/recipes/extension/health/check.recipe.json +71 -0
- package/library/recipes/mobile/health/check.recipe.json +71 -0
- package/package.json +9 -9
- package/scripts/completions.sh +2 -2
- package/site/assets/help-recipes.json +3 -2
|
@@ -76,6 +76,32 @@ export function extensionTarget(targets, extensionId = null, cdpTargetId = null)
|
|
|
76
76
|
return home ?? sidepanel ?? null;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// The recipe-runner HUD (app.hud's clear) removed from every extension page on
|
|
80
|
+
// the slot's CDP port. Launches nothing; each step is bounded and a missing
|
|
81
|
+
// port or browser clears nothing.
|
|
82
|
+
const CLEAR_HUD_EXPRESSION = "(() => { const hud = document.getElementById('farmslot-recipe-hud'); hud?.remove(); document.getElementById('farmslot-recipe-hud-reserved-space')?.remove(); document.documentElement.style.removeProperty('--farmslot-recipe-hud-height'); document.body?.style.removeProperty('padding-bottom'); return Boolean(hud); })()";
|
|
83
|
+
|
|
84
|
+
export async function clearExtensionHud(env = process.env, timeoutMs = 2000) {
|
|
85
|
+
const port = Number(env.CDP_PORT ?? env.RECIPE_CDP_PORT);
|
|
86
|
+
if (!Number.isInteger(port) || port <= 0) return { cleared: 0 };
|
|
87
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) }).catch(() => null);
|
|
88
|
+
const targets = response?.ok ? await response.json().catch(() => []) : [];
|
|
89
|
+
let cleared = 0;
|
|
90
|
+
for (const target of Array.isArray(targets) ? targets : []) {
|
|
91
|
+
if (target?.type !== 'page' || !String(target.url ?? '').startsWith('chrome-extension://') || !target.webSocketDebuggerUrl) continue;
|
|
92
|
+
let session;
|
|
93
|
+
try {
|
|
94
|
+
session = await CdpSession.connect(target.webSocketDebuggerUrl, { timeoutMs });
|
|
95
|
+
const result = await session.call('Runtime.evaluate', { expression: CLEAR_HUD_EXPRESSION, returnByValue: true }, { timeoutMs });
|
|
96
|
+
if (result?.result?.value === true) cleared += 1;
|
|
97
|
+
} catch {
|
|
98
|
+
// A busy or closing page keeps nothing worth failing the run for.
|
|
99
|
+
} finally {
|
|
100
|
+
session?.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { cleared };
|
|
104
|
+
}
|
|
79
105
|
|
|
80
106
|
function resolveRelativeArtifactPath(artifactsDir, relPath) {
|
|
81
107
|
const relative = relPath || 'screenshots/extension-page.png';
|
|
@@ -955,8 +981,52 @@ export class ExtensionPage extends CdpWebPage {
|
|
|
955
981
|
location.hash = ${JSON.stringify(normalizedHash)};
|
|
956
982
|
return { before, href: location.href };
|
|
957
983
|
})()`);
|
|
958
|
-
await this.waitForDocumentReady({ expectedUrl: navigation.href, timeoutMs });
|
|
959
|
-
return {
|
|
984
|
+
const ready = await this.waitForDocumentReady({ expectedUrl: navigation.href, timeoutMs });
|
|
985
|
+
return {
|
|
986
|
+
before: navigation.before,
|
|
987
|
+
requestedHref: navigation.href,
|
|
988
|
+
href: ready.url,
|
|
989
|
+
redirected: ready.redirected,
|
|
990
|
+
sameDocument: true,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// The Extension router may replace a requested hash with its canonical route
|
|
995
|
+
// in the same document (e.g. Perps symbol casing). The runtime still decides
|
|
996
|
+
// readiness; this accepts the rewritten URL once it holds for two polls. Any
|
|
997
|
+
// other origin, page or query still fails.
|
|
998
|
+
async waitForDocumentReady({ expectedUrl, timeoutMs = 10000 } = {}) {
|
|
999
|
+
const deadline = Date.now() + timeoutMs;
|
|
1000
|
+
let url;
|
|
1001
|
+
let redirectCandidate;
|
|
1002
|
+
while (true) {
|
|
1003
|
+
await super.waitForDocumentReady({ timeoutMs: Math.max(1, deadline - Date.now()) });
|
|
1004
|
+
// A context lost between the two probes counts as an unmatched poll.
|
|
1005
|
+
url = await this.evaluate('location.href').catch(() => undefined);
|
|
1006
|
+
if (!expectedUrl || url === expectedUrl) return { url, redirected: false };
|
|
1007
|
+
const redirect = this.isSameDocumentRedirect(expectedUrl, url) ? url : undefined;
|
|
1008
|
+
if (redirect && redirect === redirectCandidate) return { url, redirected: true };
|
|
1009
|
+
redirectCandidate = redirect;
|
|
1010
|
+
const remainingMs = deadline - Date.now();
|
|
1011
|
+
if (remainingMs <= 0) break;
|
|
1012
|
+
await sleep(Math.min(50, remainingMs));
|
|
1013
|
+
}
|
|
1014
|
+
throw new Error(`Extension page did not reach ${expectedUrl} within ${timeoutMs}ms (last URL ${url ?? 'unavailable'})`);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
isSameDocumentRedirect(expectedUrl, actualUrl) {
|
|
1018
|
+
try {
|
|
1019
|
+
const expected = new URL(expectedUrl);
|
|
1020
|
+
const actual = new URL(actualUrl);
|
|
1021
|
+
return `${expected.protocol}//${expected.host}` === this.origin
|
|
1022
|
+
&& actual.protocol === expected.protocol
|
|
1023
|
+
&& actual.host === expected.host
|
|
1024
|
+
&& actual.pathname === expected.pathname
|
|
1025
|
+
&& actual.search === expected.search
|
|
1026
|
+
&& actual.hash !== expected.hash;
|
|
1027
|
+
} catch {
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
960
1030
|
}
|
|
961
1031
|
|
|
962
1032
|
async readPositions() {
|
|
@@ -591,7 +591,9 @@ export async function navigateExtensionUi(input) {
|
|
|
591
591
|
const url = input.node?.url;
|
|
592
592
|
if (typeof url === 'string' && url.length > 0) {
|
|
593
593
|
const navigation = await page.navigate(url);
|
|
594
|
-
|
|
594
|
+
// The router may canonicalize the requested route; report where it landed.
|
|
595
|
+
const href = await currentHref(page);
|
|
596
|
+
return { action: input.action, url, href, navigation, proofPath: 'ui-navigation' };
|
|
595
597
|
}
|
|
596
598
|
|
|
597
599
|
const hash = input.node?.hash ?? input.node?.path;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// Selects an account by address, ID, or name through the background request hook.
|
|
2
|
-
//
|
|
1
|
+
// Selects an account by address, ID, or account group/internal name through the background request hook.
|
|
2
|
+
// Returns without a write when the account is already selected. Falls back to visible account search
|
|
3
|
+
// when that hook is unavailable and checks the selected address.
|
|
3
4
|
import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
4
5
|
import {
|
|
5
|
-
listWalletAccountsExpression,
|
|
6
6
|
readWalletStateExpression,
|
|
7
|
+
resolveWalletAccountExpression,
|
|
7
8
|
selectWalletAccountExpression,
|
|
8
9
|
} from './state.mjs';
|
|
9
10
|
|
|
@@ -18,25 +19,24 @@ runAdapter((input) => withExtensionPage(input, async (page) => {
|
|
|
18
19
|
if (!requestedAddress && !requestedId && !requestedName) {
|
|
19
20
|
throw new Error('metamask.wallet.select_account extension live adapter requires node.address, node.id, or node.name.');
|
|
20
21
|
}
|
|
22
|
+
const criteria = { address: requestedAddress, id: requestedId, name: requestedName };
|
|
21
23
|
try {
|
|
22
|
-
const selected = await page.evaluate(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
const { alreadySelected, ...selected } = await page.evaluate(
|
|
25
|
+
selectWalletAccountExpression(criteria),
|
|
26
|
+
{ awaitPromise: true },
|
|
27
|
+
);
|
|
28
|
+
return {
|
|
29
|
+
action: input.action,
|
|
30
|
+
selected,
|
|
31
|
+
...(alreadySelected ? { alreadySelected: true } : {}),
|
|
32
|
+
proofPath: 'extension-background-account-selection',
|
|
33
|
+
};
|
|
28
34
|
} catch (error) {
|
|
29
35
|
if (!String(error?.message ?? error).includes('submitRequestToBackground is unavailable')) throw error;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const listed = await page.evaluate(listWalletAccountsExpression('all'), { awaitPromise: true });
|
|
35
|
-
const account = listed.accounts.find((entry) =>
|
|
36
|
-
(requestedId && entry.id === requestedId) ||
|
|
37
|
-
(requestedName && entry.name === requestedName));
|
|
38
|
-
searchAddress = normalize(account?.address);
|
|
39
|
-
}
|
|
38
|
+
const resolved = await page.evaluate(resolveWalletAccountExpression(criteria), { awaitPromise: true });
|
|
39
|
+
const searchAddress = normalize(resolved?.address);
|
|
40
40
|
if (!searchAddress) throw new Error('Requested account does not have a searchable EVM address.');
|
|
41
41
|
|
|
42
42
|
await page.navigateHash('#/account-list');
|
|
@@ -94,40 +94,94 @@ export function listWalletAccountsExpression(scope) {
|
|
|
94
94
|
})()`;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Resolves id, address, or internal metadata name against internalAccounts first. A name
|
|
98
|
+
// with no internal match falls back to AccountTreeController group names (multichain
|
|
99
|
+
// wallets keep fixture names there, e.g. group "dev1" over internal "Account 1") and
|
|
100
|
+
// selects the group's EVM account; a group without one is rejected.
|
|
101
|
+
const resolveAccountPreamble = `
|
|
102
|
+
const resolveRequestedAccount = (state, criteria) => {
|
|
103
|
+
const accounts = state.internal.accounts || {};
|
|
104
|
+
const entries = Object.entries(accounts);
|
|
105
|
+
const isEvm = (account) => /^0x[0-9a-f]{40}$/iu.test(String(account?.address || ''));
|
|
106
|
+
const direct = entries.find(([id, account]) =>
|
|
107
|
+
(criteria.id && id === criteria.id) ||
|
|
108
|
+
(criteria.address && String(account?.address || '').toLowerCase() === criteria.address) ||
|
|
109
|
+
(criteria.name && account?.metadata?.name === criteria.name));
|
|
110
|
+
if (direct) return { id: direct[0], accountGroup: null };
|
|
111
|
+
if (!criteria.name) return null;
|
|
112
|
+
const groups = [];
|
|
113
|
+
for (const wallet of Object.values(state.metamask.accountTree?.wallets || {})) {
|
|
114
|
+
for (const [groupId, group] of Object.entries(wallet?.groups || {})) {
|
|
115
|
+
if (group?.metadata?.name === criteria.name) groups.push({ id: groupId, accounts: group.accounts || [] });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (groups.length > 1) {
|
|
119
|
+
throw new Error('Requested account name ' + JSON.stringify(criteria.name) + ' matches ' + groups.length +
|
|
120
|
+
' account groups; select by address instead.');
|
|
121
|
+
}
|
|
122
|
+
if (!groups.length) return null;
|
|
123
|
+
const id = groups[0].accounts.find((candidate) => isEvm(accounts[candidate]));
|
|
124
|
+
if (!id) {
|
|
125
|
+
throw new Error('Account group ' + JSON.stringify(criteria.name) +
|
|
126
|
+
' has no EVM account; select by id instead.');
|
|
127
|
+
}
|
|
128
|
+
return { id, accountGroup: { id: groups[0].id, name: criteria.name } };
|
|
129
|
+
};
|
|
130
|
+
const describeAccount = (state, resolved) => {
|
|
131
|
+
const account = state.internal.accounts?.[resolved.id];
|
|
132
|
+
return {
|
|
133
|
+
id: resolved.id,
|
|
134
|
+
address: account?.address || null,
|
|
135
|
+
name: account?.metadata?.name || null,
|
|
136
|
+
type: account?.type || null,
|
|
137
|
+
...(resolved.accountGroup ? { accountGroup: resolved.accountGroup } : {}),
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
function criteriaLiteral(criteria) {
|
|
143
|
+
return JSON.stringify({
|
|
144
|
+
address: criteria.address || null,
|
|
145
|
+
id: criteria.id || null,
|
|
146
|
+
name: criteria.name || null,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function resolveWalletAccountExpression(criteria) {
|
|
151
|
+
return `(async () => {
|
|
152
|
+
${walletStatePreamble}
|
|
153
|
+
${resolveAccountPreamble}
|
|
154
|
+
const state = await readWalletState();
|
|
155
|
+
const resolved = resolveRequestedAccount(state, ${criteriaLiteral(criteria)});
|
|
156
|
+
if (!resolved) throw new Error('Requested account was not found in account groups or internalAccounts.');
|
|
157
|
+
return {
|
|
158
|
+
...describeAccount(state, resolved),
|
|
159
|
+
alreadySelected: resolved.id === (state.internal.selectedAccount || null),
|
|
160
|
+
};
|
|
161
|
+
})()`;
|
|
162
|
+
}
|
|
163
|
+
|
|
97
164
|
export function selectWalletAccountExpression(criteria, timeoutMs = 5000) {
|
|
98
165
|
return `(async () => {
|
|
99
166
|
${walletStatePreamble}
|
|
167
|
+
${resolveAccountPreamble}
|
|
100
168
|
const before = await readWalletState();
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return (requestedId && id === requestedId) ||
|
|
108
|
-
(requestedAddress && String(account?.address || '').toLowerCase() === requestedAddress) ||
|
|
109
|
-
(requestedName && metadata.name === requestedName);
|
|
110
|
-
});
|
|
111
|
-
if (!match) throw new Error('Requested account was not found in internalAccounts.');
|
|
169
|
+
const resolved = resolveRequestedAccount(before, ${criteriaLiteral(criteria)});
|
|
170
|
+
if (!resolved) throw new Error('Requested account was not found in account groups or internalAccounts.');
|
|
171
|
+
const id = resolved.id;
|
|
172
|
+
if ((before.internal.selectedAccount || null) === id) {
|
|
173
|
+
return { ...describeAccount(before, resolved), alreadySelected: true };
|
|
174
|
+
}
|
|
112
175
|
if (typeof before.hooks.submitRequestToBackground !== 'function') {
|
|
113
176
|
throw new Error('stateHooks.submitRequestToBackground is unavailable.');
|
|
114
177
|
}
|
|
115
|
-
const [id] = match;
|
|
116
178
|
await before.hooks.submitRequestToBackground('setSelectedInternalAccount', [id]);
|
|
117
179
|
const deadline = Date.now() + ${Number(timeoutMs)};
|
|
118
180
|
let observedId = before.internal.selectedAccount || null;
|
|
119
181
|
while (Date.now() <= deadline) {
|
|
120
182
|
const after = await readWalletState();
|
|
121
183
|
observedId = after.internal.selectedAccount || null;
|
|
122
|
-
if (observedId === id)
|
|
123
|
-
const selected = after.internal.accounts?.[observedId];
|
|
124
|
-
return {
|
|
125
|
-
id: observedId,
|
|
126
|
-
address: selected?.address || null,
|
|
127
|
-
name: selected?.metadata?.name || null,
|
|
128
|
-
type: selected?.type || null,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
184
|
+
if (observedId === id) return describeAccount(after, resolved);
|
|
131
185
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
132
186
|
}
|
|
133
187
|
throw new Error(
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fail the recipe when the app logged an error or uncaught exception that no
|
|
3
|
+
// console allowlist marks as known noise. Reads app-console.log, written by the
|
|
4
|
+
// adapter-rn console forwarder (adapters/mobile/start-console-forwarder.sh).
|
|
5
|
+
// The same classifier and allowlist feed run diagnostics.
|
|
6
|
+
//
|
|
7
|
+
// Window: since "launch" (the default) judges the events since the launch
|
|
8
|
+
// began, by their own timestamps. A launch records its start once its
|
|
9
|
+
// refusals are behind it and before anything can restart the app
|
|
10
|
+
// (app-console.launch.json, recordMobileConsoleLaunch in
|
|
11
|
+
// src/adapters/mobile/launch.ts); a recovery attempt that restarts the app
|
|
12
|
+
// records it again. Stamps come from the device's clock, so the window is
|
|
13
|
+
// moved onto it by the offset the capture control line shows. Every launch
|
|
14
|
+
// restarts the forwarder, and start-console-forwarder.sh empties
|
|
15
|
+
// app-console.log and the forwarder's replay cursor as it does, so a
|
|
16
|
+
// relaunched app's first lines are kept; but on a launch that keeps the app
|
|
17
|
+
// runtime, the new forwarder replays that runtime's console from before the
|
|
18
|
+
// launch. The forwarder stamps each line with the event's own runtime time,
|
|
19
|
+
// so a record stamped before the launch is left out. Without a mark the
|
|
20
|
+
// action fails as unavailable. since "start-of-log" reads the whole file.
|
|
21
|
+
//
|
|
22
|
+
// Device: app-console.log holds every device the forwarder sees; only the
|
|
23
|
+
// device the control line came back from (the one the bridge selects for this
|
|
24
|
+
// run) is judged. When that line names no device, every device is, and the
|
|
25
|
+
// output says so (devices: "all").
|
|
26
|
+
//
|
|
27
|
+
// It never passes on missing evidence: the forwarder named by
|
|
28
|
+
// console-forwarder.pid must be alive and writing this checkout's
|
|
29
|
+
// app-console.log, and a control line logged inside the app through the debug
|
|
30
|
+
// bridge must reach that file; otherwise the action fails as unavailable. The
|
|
31
|
+
// opaque runtime (an installed release build) has no bridge, so it always
|
|
32
|
+
// fails as unavailable.
|
|
33
|
+
|
|
34
|
+
import { randomUUID } from 'node:crypto';
|
|
35
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { pathToFileURL } from 'node:url';
|
|
38
|
+
|
|
39
|
+
import { recipeRuntimeDir } from '@farmslot/recipe-cli/harness';
|
|
40
|
+
|
|
41
|
+
import { captureUnavailable, consoleLevels, inWindow, nearestInstant, judgeConsole, nodeAllowlist, readFrom, readLaunchMark, reportVerdict, throwOnFindings } from '../../shared/console-assert.mjs';
|
|
42
|
+
import { CAPTURE_CONTROL_MARK, controlLineReached, fileSize, processCommand, readPid } from '../../shared/console-collector.mjs';
|
|
43
|
+
import { consoleRecords, recordStamp } from '../../shared/console-findings.mjs';
|
|
44
|
+
import { evalSync, runAdapter } from '../platform/bridge.mjs';
|
|
45
|
+
|
|
46
|
+
const LOG = 'app-console.log';
|
|
47
|
+
const LAUNCH_MARK = 'app-console.launch.json';
|
|
48
|
+
const unavailable = (detail) => captureUnavailable(detail, 'The app console');
|
|
49
|
+
|
|
50
|
+
// The forwarder start-console-forwarder.sh started for this checkout: a live
|
|
51
|
+
// process running console-forwarder.cjs whose --out is this runtime's
|
|
52
|
+
// app-console.log, as the script writes it (the checkout's resolved path).
|
|
53
|
+
export function forwarderStatus({ pidFile, log }) {
|
|
54
|
+
const pid = readPid(pidFile);
|
|
55
|
+
if (!pid) return { ok: false, detail: `no console forwarder pid in ${pidFile}` };
|
|
56
|
+
const command = processCommand(pid);
|
|
57
|
+
if (!command) return { ok: false, detail: `console forwarder pid ${pid} is not running` };
|
|
58
|
+
if (!/\/console-forwarder\.cjs --port \S+ --out /u.test(command) || !command.endsWith(` --out ${log}`)) {
|
|
59
|
+
return { ok: false, detail: `pid ${pid} is not this checkout's console forwarder` };
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, detail: `console forwarder pid ${pid}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Log a control line inside the app; the forwarder writes it at info level,
|
|
65
|
+
// and the classifier never reports a capture control line anyway.
|
|
66
|
+
function logInApp(input, line) {
|
|
67
|
+
return evalSync(input, `(console.info(${JSON.stringify(line)}), true)`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function assertNoConsoleErrors(input, env = process.env, deps = {}) {
|
|
71
|
+
const node = input.node ?? {};
|
|
72
|
+
const levels = consoleLevels(node);
|
|
73
|
+
if (env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === '1') {
|
|
74
|
+
throw unavailable('the opaque runtime has no debug bridge to log a control line through');
|
|
75
|
+
}
|
|
76
|
+
const projectRoot = input.context?.projectRoot;
|
|
77
|
+
if (!projectRoot || !existsSync(projectRoot)) throw unavailable(`no checkout at ${projectRoot}`);
|
|
78
|
+
const runtime = path.join(realpathSync(projectRoot), recipeRuntimeDir());
|
|
79
|
+
const log = path.join(runtime, LOG);
|
|
80
|
+
const forwarder = (deps.forwarderStatus ?? forwarderStatus)({ pidFile: path.join(runtime, 'console-forwarder.pid'), log });
|
|
81
|
+
if (!forwarder.ok) throw unavailable(forwarder.detail);
|
|
82
|
+
const mark = node.since === 'start-of-log' ? null : readLaunchMark(path.join(runtime, LAUNCH_MARK), unavailable);
|
|
83
|
+
const nonce = randomUUID();
|
|
84
|
+
const start = fileSize(log);
|
|
85
|
+
const clock = deps.clock ?? (() => deps.now ?? Date.now());
|
|
86
|
+
const before = clock();
|
|
87
|
+
try {
|
|
88
|
+
await (deps.logInApp ?? logInApp)(input, `${CAPTURE_CONTROL_MARK} ${nonce}`);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// No line logged: the capture is unproven, which is this action's failure.
|
|
91
|
+
throw unavailable(`could not log the capture control line in the app (${error.message})`);
|
|
92
|
+
}
|
|
93
|
+
const after = clock();
|
|
94
|
+
const timeoutMs = Number(node.timeout_ms) > 0 ? Number(node.timeout_ms) : 8000;
|
|
95
|
+
if (!await controlLineReached(log, start, nonce, timeoutMs)) {
|
|
96
|
+
throw unavailable(`the capture control line logged in the app did not reach ${log} within ${timeoutMs}ms`);
|
|
97
|
+
}
|
|
98
|
+
const { text } = readFrom(log, { offset: 0 });
|
|
99
|
+
// The control line names the device, and its stamp, from the device's clock,
|
|
100
|
+
// bounds that clock's offset from this machine's (a physical device's clock
|
|
101
|
+
// can run minutes off; the simulator shares the host's). The line was
|
|
102
|
+
// logged between `before` and `after` on the host, so bridge latency only
|
|
103
|
+
// widens the bound; it never moves the launch past a fresh record.
|
|
104
|
+
const control = consoleRecords(text).find((record) => record.line.includes(nonce));
|
|
105
|
+
const controlStamp = control ? recordStamp(control) : null;
|
|
106
|
+
const device = controlStamp?.device ?? null;
|
|
107
|
+
const controlAt = controlStamp ? nearestInstant(controlStamp.clock, before) : null;
|
|
108
|
+
const offset = controlAt === null ? { min: 0, max: 0 } : { min: controlAt - after, max: controlAt - before };
|
|
109
|
+
const allowlist = nodeAllowlist(node, env);
|
|
110
|
+
const keep = inWindow({ sinceMs: mark?.launchedAtMs ?? null, device, now: clock(), offset });
|
|
111
|
+
const { findings, allowlisted } = judgeConsole([text], { levels, allowlist, keep });
|
|
112
|
+
const output = {
|
|
113
|
+
action: input.action,
|
|
114
|
+
since: node.since ?? 'launch',
|
|
115
|
+
devices: device ?? 'all',
|
|
116
|
+
levels: [...levels],
|
|
117
|
+
findings,
|
|
118
|
+
allowlisted,
|
|
119
|
+
allowlistProblems: allowlist.problems,
|
|
120
|
+
capture: { forwarder: forwarder.detail, control: `control line from the app reached ${LOG}` },
|
|
121
|
+
proofPath: 'mobile-console-capture',
|
|
122
|
+
};
|
|
123
|
+
throwOnFindings(findings, allowlist.problems);
|
|
124
|
+
return output;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
128
|
+
reportVerdict(() => runAdapter((input) => assertNoConsoleErrors(input)));
|
|
129
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Select a wallet account by address, ID, or name and verify the resulting selection.
|
|
2
|
+
// A name with no internal account match falls back to AccountTreeController group names.
|
|
2
3
|
// Observe ambiguous bridge write failures until the deadline without repeating the switch.
|
|
3
4
|
import { pathToFileURL } from 'node:url';
|
|
4
5
|
import {
|
|
@@ -34,13 +35,13 @@ function withRemainingDeadline(input, deadline, attemptTimeoutMs) {
|
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
async function
|
|
38
|
+
async function readBridge(input, deadline, args, timeoutMessage) {
|
|
38
39
|
let lastError = null;
|
|
39
40
|
while (Date.now() < deadline) {
|
|
40
41
|
try {
|
|
41
42
|
return await bridgeCommand(
|
|
42
43
|
withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
|
|
43
|
-
|
|
44
|
+
args,
|
|
44
45
|
);
|
|
45
46
|
} catch (error) {
|
|
46
47
|
if (!TRANSIENT_READ_CODES.has(error?.code)) throw error;
|
|
@@ -48,24 +49,34 @@ async function readSelectedAccount(input, deadline) {
|
|
|
48
49
|
await sleep(Math.min(100, Math.max(0, deadline - Date.now())));
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
|
-
throw lastError ?? new Error(
|
|
52
|
+
throw lastError ?? new Error(timeoutMessage);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
55
|
+
const readSelectedAccount = (input, deadline) =>
|
|
56
|
+
readBridge(input, deadline, ['get-selected-account'], 'Timed out reading the selected Mobile account.');
|
|
57
|
+
|
|
58
|
+
const readAccounts = (input, deadline) =>
|
|
59
|
+
readBridge(input, deadline, ['list-accounts'], 'Timed out reading Mobile accounts.');
|
|
60
|
+
|
|
61
|
+
// Multichain wallets name the account group, not always the internal account.
|
|
62
|
+
async function findAccountByGroupName(input, deadline, accounts, name) {
|
|
63
|
+
const wallets = await readBridge(
|
|
64
|
+
input,
|
|
65
|
+
deadline,
|
|
66
|
+
['get-state', 'engine.backgroundState.AccountTreeController.accountTree.wallets'],
|
|
67
|
+
'Timed out reading Mobile account groups.',
|
|
68
|
+
);
|
|
69
|
+
const groups = [];
|
|
70
|
+
for (const wallet of Object.values(wallets && typeof wallets === 'object' ? wallets : {})) {
|
|
71
|
+
for (const [groupId, group] of Object.entries(wallet?.groups ?? {})) {
|
|
72
|
+
if (group?.metadata?.name === name) groups.push({ id: groupId, accounts: group.accounts ?? [] });
|
|
66
73
|
}
|
|
67
74
|
}
|
|
68
|
-
|
|
75
|
+
if (groups.length > 1) {
|
|
76
|
+
throw new Error(`Requested mobile account name ${JSON.stringify(name)} matches ${groups.length} account groups; select by address instead.`);
|
|
77
|
+
}
|
|
78
|
+
const match = accounts.find((account) => groups[0]?.accounts.includes(account?.id));
|
|
79
|
+
return match ? { ...match, accountGroup: { id: groups[0].id, name } } : null;
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
function ambiguousWriteFailure(writeError, match, finalSelected, readError) {
|
|
@@ -98,7 +109,7 @@ export async function selectAccount(input) {
|
|
|
98
109
|
if (!Array.isArray(accounts)) {
|
|
99
110
|
throw new Error(`metamask.wallet.select_account expected list-accounts to return an array, got ${typeof accounts}.`);
|
|
100
111
|
}
|
|
101
|
-
|
|
112
|
+
let match = accounts.find((account) => {
|
|
102
113
|
const address = String(account?.address ?? '').toLowerCase();
|
|
103
114
|
const id = String(account?.id ?? '');
|
|
104
115
|
const name = String(account?.name ?? '');
|
|
@@ -106,8 +117,11 @@ export async function selectAccount(input) {
|
|
|
106
117
|
(requestedId && id === requestedId) ||
|
|
107
118
|
(requestedName && name === requestedName);
|
|
108
119
|
});
|
|
120
|
+
if (!match && requestedName) {
|
|
121
|
+
match = await findAccountByGroupName(input, deadline, accounts, requestedName);
|
|
122
|
+
}
|
|
109
123
|
if (!match?.address) {
|
|
110
|
-
throw new Error(`Requested mobile account was not found in list-accounts: ${JSON.stringify({ address: requestedAddress, id: requestedId, name: requestedName })}`);
|
|
124
|
+
throw new Error(`Requested mobile account was not found in list-accounts or account groups: ${JSON.stringify({ address: requestedAddress, id: requestedId, name: requestedName })}`);
|
|
111
125
|
}
|
|
112
126
|
const current = await readSelectedAccount(input, deadline);
|
|
113
127
|
const matchAddress = String(match.address).toLowerCase();
|
|
@@ -118,6 +132,7 @@ export async function selectAccount(input) {
|
|
|
118
132
|
address: current?.address ?? match.address,
|
|
119
133
|
id: current?.id ?? match.id,
|
|
120
134
|
name: current?.name ?? match.name,
|
|
135
|
+
...(match.accountGroup ? { accountGroup: match.accountGroup } : {}),
|
|
121
136
|
},
|
|
122
137
|
alreadySelected: true,
|
|
123
138
|
proofPath: 'agentic-account-selection',
|
|
@@ -172,6 +187,7 @@ export async function selectAccount(input) {
|
|
|
172
187
|
address: selected?.address ?? result?.address ?? match.address,
|
|
173
188
|
id: selected?.id ?? result?.id ?? match.id,
|
|
174
189
|
name: selected?.name ?? result?.name ?? match.name,
|
|
190
|
+
...(match.accountGroup ? { accountGroup: match.accountGroup } : {}),
|
|
175
191
|
},
|
|
176
192
|
...(ambiguousWriteError
|
|
177
193
|
? { recoveredAfterAmbiguousWrite: true }
|