@farmslot/adapter-web 0.17.7 → 0.19.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 CHANGED
@@ -6,6 +6,14 @@ All notable changes to `@farmslot/adapter-web` are tracked here.
6
6
 
7
7
  - Active-development baseline; add user-facing changes here before release or package publication.
8
8
 
9
+ ## 0.19.0 - 2026-11-06
10
+
11
+ - Web-dapp wallet accounts: a mnemonic entry yields every account its `count` names (`names[]`, else dev1, dev2, ...), and `--account` selects one by name or address. A host can add named derivations of a fixture secret at a higher HD index through `RECIPE_WALLET_DERIVED_ACCOUNTS`; a declared address that does not match is refused. `browser.json` records the account's fixture secret name and index.
12
+
13
+ ## 0.18.0 - 2026-11-05
14
+
15
+ - Run ordinary websites in an owned extension-free browser with shared CDP actions, screenshots and whole-run recording. Existing extension launches retain their lifecycle.
16
+
9
17
  ## 0.17.7 - 2026-11-04
10
18
 
11
19
  - Check host-resolver blocking in an owned blank document so strict application CSP needs no harness-only source. Keep application HTTP and WebSocket blocking checks unchanged.
package/README.md CHANGED
@@ -108,6 +108,12 @@ const binding = createWalletRequestBinding({
108
108
  });
109
109
  ```
110
110
 
111
+ ## Plain websites
112
+
113
+ `@farmslot/adapter-web/plain-web` exports `createPlainWebAdapter({ manifestPath, url, serverLabel })`. The project supplies its action catalog and a URL function using its selected ports. The runtime owns an isolated browser and reuses shared CDP actions, screenshots and whole-run recording. The project owns its application server.
114
+
115
+ Browser stop is scoped to the selected checkout's profile and retains the application server. This path needs no extension, signer or wallet policy and makes no assumptions about the application framework or page selectors.
116
+
111
117
  ## Web-dapp adapter
112
118
 
113
119
  `@farmslot/adapter-web/web-dapp` (ESM) is the lifecycle of a web app under test whose dev server the slot owns. `createWebDappAdapter({ id, signerModule, cli, hooks })` returns an `@farmslot/adapter-sdk` `ProjectRuntime`: launch, stop, runtime status, doctor checks, logs and the wallet request-log findings. It runs one slot browser per slot, and a wallet host attached to it over CDP that records the wallet requests (and, with `signer=injected`, answers them with the strict wallet from `dapp`).
@@ -3,9 +3,9 @@ export type LaunchBrowserOptions = {
3
3
  chromeBin: string;
4
4
  profile: string;
5
5
  /**
6
- * Unpacked extension to load.
6
+ * Unpacked extension to load; omitted for a plain web page.
7
7
  */
8
- extensionDir: string;
8
+ extensionDir?: string | undefined;
9
9
  /**
10
10
  * Holds browser-resolution.json and the runtime identity.
11
11
  */
@@ -72,7 +72,7 @@ export type LaunchBrowserOptions = {
72
72
  * @property {number} cdpPort
73
73
  * @property {string} chromeBin
74
74
  * @property {string} profile
75
- * @property {string} extensionDir Unpacked extension to load.
75
+ * @property {string} [extensionDir] Unpacked extension to load; omitted for a plain web page.
76
76
  * @property {string} runtimeDir Holds browser-resolution.json and the runtime identity.
77
77
  * @property {string} chromeLog
78
78
  * @property {string} chromePid
@@ -0,0 +1,17 @@
1
+ /** Runtime files and the browser profile always belong to the selected checkout. */
2
+ export function plainWebRuntimeDir(target: any): string;
3
+ /**
4
+ * Use existing browser ownership, CDP actions and whole-run recording for a plain website.
5
+ * @param {PlainWebAdapterConfig} config
6
+ * @returns {import('@farmslot/adapter-sdk').ProjectRuntime}
7
+ */
8
+ export function createPlainWebAdapter(config: PlainWebAdapterConfig): import("@farmslot/adapter-sdk").ProjectRuntime;
9
+ /** Releases only the selected checkout's owned profile. The application server is retained. */
10
+ export function stopPlainWebBrowser(target: any): {
11
+ stopped: boolean;
12
+ };
13
+ export type PlainWebAdapterConfig = {
14
+ manifestPath: string;
15
+ url: (target: string) => string;
16
+ serverLabel?: string;
17
+ };
@@ -125,5 +125,5 @@ export { recipeHarnessPath, recipeRuntimeDir, recipeRuntimePath, shellQuote, wal
125
125
  export { assertPolicyDigest, fencePolicy, POLICY_DIGEST_ENV, PolicyFenceError } from "./lib/policy-fence.mjs";
126
126
  export { cdpListenerPids, ownedBrowserPids, OWNER_FLAG, OWNER_KINDS, ownerMarker, ownsProcess, pidAlive, portFree, stopConsoleCollector, stopWebDappBrowser, terminate } from "./lib/processes.mjs";
127
127
  export { assertLaunchNetwork, DEV_SERVER_PID_FILE, devServerTestnetDiagnostic, pythonFor, webDappReadiness } from "./lib/readiness.mjs";
128
- export { accountName, appOrigin, appPort, canonicalPath, cdpPort, DEFAULT_ACCOUNT, fixtureAccount, fixtureEntry, fixturePath, loadWalletFixture, readBrowserState, readJsonFile, resolveAccountAddress, resolveSigner, SIGNER_MODES, viemAccountFromEntry, webDappRuntimeDir, webDappRuntimePath, writePrivateJson } from "./lib/runtime.mjs";
128
+ export { accountName, appOrigin, appPort, canonicalPath, cdpPort, DEFAULT_ACCOUNT, DERIVED_ACCOUNTS_ENV, fixtureAccount, fixtureEntry, fixturePath, loadWalletFixture, readBrowserState, readJsonFile, resolveAccountAddress, resolveSigner, SIGNER_MODES, viemAccountFromEntry, walletAccount, walletAccounts, webDappRuntimeDir, webDappRuntimePath, writePrivateJson } from "./lib/runtime.mjs";
129
129
  export { macosSessionLocked, parseSessionLocked, SESSION_LOCKED_MESSAGE, SESSION_LOCKED_USER_ACTION } from "./lib/session-lock.mjs";
@@ -15,7 +15,28 @@ export function loadWalletFixture(projectRoot: any, env?: NodeJS.ProcessEnv): Pr
15
15
  fixture: any;
16
16
  }>;
17
17
  export function fixtureEntry(fixture: any, name: any): any;
18
- export function viemAccountFromEntry(entry: any, projectRoot: any): any;
18
+ export function viemAccountFromEntry(entry: any, projectRoot: any, addressIndex?: number): any;
19
+ export function derivedAccounts(env?: NodeJS.ProcessEnv): any[];
20
+ /**
21
+ * Every account the fixture offers: each mnemonic entry expanded to its count,
22
+ * each private key, then the host's named derivations (DERIVED_ACCOUNTS_ENV)
23
+ * of the secrets this fixture holds. `account` is the viem account holding the
24
+ * key: sign with it, never log or persist it.
25
+ */
26
+ export function walletAccounts(fixture: any, projectRoot: any, env?: NodeJS.ProcessEnv): {
27
+ name: any;
28
+ address: any;
29
+ secret: any;
30
+ index: any;
31
+ account: any;
32
+ }[];
33
+ export function walletAccount(fixture: any, selector: any, projectRoot: any, env?: NodeJS.ProcessEnv): {
34
+ name: any;
35
+ address: any;
36
+ secret: any;
37
+ index: any;
38
+ account: any;
39
+ };
19
40
  export function fixtureAccount(projectRoot: any, name: any, env?: NodeJS.ProcessEnv): Promise<any>;
20
41
  export function resolveAccountAddress(input: any, env?: NodeJS.ProcessEnv): Promise<{
21
42
  name: any;
@@ -23,6 +44,7 @@ export function resolveAccountAddress(input: any, env?: NodeJS.ProcessEnv): Prom
23
44
  }>;
24
45
  export const SIGNER_MODES: readonly string[];
25
46
  export const DEFAULT_ACCOUNT: "dev1";
47
+ export const DERIVED_ACCOUNTS_ENV: "RECIPE_WALLET_DERIVED_ACCOUNTS";
26
48
  import { POLICY_ENV } from './policy.mjs';
27
49
  import { webDappAdapterId } from './policy.mjs';
28
50
  import { webDappPolicy } from './policy.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farmslot/adapter-web",
3
- "version": "0.17.7",
3
+ "version": "0.19.0",
4
4
  "description": "Web platform adapter for Farmslot recipes: resolve and launch an isolated Chromium, own its CDP port, load unpacked extensions, and keep macOS focus.",
5
5
  "type": "commonjs",
6
6
  "exports": {
@@ -68,6 +68,10 @@
68
68
  "./web-dapp": {
69
69
  "types": "./dist/web-dapp/index.d.mts",
70
70
  "default": "./src/web-dapp/index.mjs"
71
+ },
72
+ "./plain-web": {
73
+ "types": "./dist/plain-web.d.mts",
74
+ "default": "./src/plain-web.mjs"
71
75
  }
72
76
  },
73
77
  "scripts": {
@@ -67,7 +67,7 @@ const BROWSER_RESOLVER_CLI = require.resolve('./browser-resolver.cjs');
67
67
  * @property {number} cdpPort
68
68
  * @property {string} chromeBin
69
69
  * @property {string} profile
70
- * @property {string} extensionDir Unpacked extension to load.
70
+ * @property {string} [extensionDir] Unpacked extension to load; omitted for a plain web page.
71
71
  * @property {string} runtimeDir Holds browser-resolution.json and the runtime identity.
72
72
  * @property {string} chromeLog
73
73
  * @property {string} chromePid
@@ -114,7 +114,6 @@ function normalizeOptions(options) {
114
114
  const flags = {
115
115
  chromeBin: 'chrome-bin',
116
116
  profile: 'profile',
117
- extensionDir: 'extension-dir',
118
117
  runtimeDir: 'runtime-dir',
119
118
  chromeLog: 'chrome-log',
120
119
  chromePid: 'chrome-pid',
@@ -122,6 +121,10 @@ function normalizeOptions(options) {
122
121
  for (const [key, flag] of Object.entries(flags)) {
123
122
  if (!opts[key]) throw new Error(`Missing --${flag}`);
124
123
  }
124
+ if (!opts.extensionDir && !opts.stopOnly) {
125
+ if (!opts.startUrl || !/^https?:$/u.test(new URL(opts.startUrl).protocol))
126
+ throw new Error('Plain web launch requires an explicit HTTP(S) --start-url');
127
+ }
125
128
  opts.log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
126
129
  opts.progress = opts.progress ?? (() => {});
127
130
  return opts;
@@ -135,7 +138,7 @@ function runLaunch(opts, acquireLock) {
135
138
  if (!opts.stopOnly) {
136
139
  if (!fs.existsSync(opts.chromeBin))
137
140
  throw new Error(`Chrome binary not found: ${opts.chromeBin}`);
138
- if (!fs.existsSync(path.join(extensionDir, 'manifest.json'))) {
141
+ if (extensionDir && !fs.existsSync(path.join(extensionDir, 'manifest.json'))) {
139
142
  throw new Error(
140
143
  `Extension dist manifest not found: ${path.join(extensionDir, 'manifest.json')}`,
141
144
  );
@@ -189,7 +192,7 @@ function runLaunch(opts, acquireLock) {
189
192
  // run's Chrome would hold the lock and the write fails.
190
193
  if (opts.stopOnly) return { stopped: true };
191
194
  try {
192
- removeExtensionWorkerRegistrations(profile);
195
+ if (extensionDir) removeExtensionWorkerRegistrations(profile);
193
196
  } catch (error) {
194
197
  const detail = error instanceof Error ? error.message : String(error);
195
198
  throw new Error(
@@ -221,7 +224,7 @@ function runLaunch(opts, acquireLock) {
221
224
  const initialUrl =
222
225
  opts.startUrl || extensionPageUrl(extensionDir, opts.homePage) || 'chrome://extensions/';
223
226
  const browser = launchBrowserResolution(opts.browserResolution, opts.chromeBin);
224
- const loadsOverCdp = browser.extensionLoading === CDP_LOAD_UNPACKED;
227
+ const loadsOverCdp = Boolean(extensionDir) && browser.extensionLoading === CDP_LOAD_UNPACKED;
225
228
  let cdpLoad = null;
226
229
  let owner = null;
227
230
  const runtimeNonce = createRuntimeIdentityNonce();
@@ -238,10 +241,14 @@ function runLaunch(opts, acquireLock) {
238
241
  ...isolatedProfileArgs(),
239
242
  '--disable-default-apps',
240
243
  '--disable-popup-blocking',
241
- '--disable-extensions-file-access-check',
242
- '--disable-extensions-content-verification',
243
- '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
244
- ...extensionLaunchArgs(extensionDir, browser.extensionLoading),
244
+ ...(extensionDir
245
+ ? [
246
+ '--disable-extensions-file-access-check',
247
+ '--disable-extensions-content-verification',
248
+ '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
249
+ ...extensionLaunchArgs(extensionDir, browser.extensionLoading),
250
+ ]
251
+ : []),
245
252
  ]);
246
253
  // Initial tab: the caller's URL when provided, else the extension home. The
247
254
  // extension home avoids leaving disposable chrome://newtab/ or extensions
@@ -333,7 +340,7 @@ function runLaunch(opts, acquireLock) {
333
340
  cdpLoad = loadExtensionOverCdp(opts, browserPid, initialUrl, clearLaunchMarkers);
334
341
  else if (application) openBackgroundWindow(opts, browserPid, initialUrl, clearLaunchMarkers);
335
342
  owner = cdpLoad?.owner ?? ownerIdentity(cdpPort, profile, browserPid, log);
336
- for (const problem of pruneExtraHomeTabs(cdpPort, extensionDir, opts)) {
343
+ for (const problem of extensionDir ? pruneExtraHomeTabs(cdpPort, extensionDir, opts) : []) {
337
344
  log(`[launch] home-tab pruning: ${problem}`);
338
345
  }
339
346
  } finally {
@@ -658,7 +665,8 @@ function processIsOwnedBrowser(pid, profile, extensionDir, extensionOwnerRoot) {
658
665
  const command = processCommand(pid);
659
666
  if (!command) return false;
660
667
  if (command.includes(`--user-data-dir=${profile}`)) return true;
661
- const ownerRoot = extensionOwnerRoot ? extensionOwnerRoot(path.resolve(extensionDir)) : null;
668
+ const ownerRoot =
669
+ extensionDir && extensionOwnerRoot ? extensionOwnerRoot(path.resolve(extensionDir)) : null;
662
670
  return Boolean(ownerRoot && command.includes(`--load-extension=${ownerRoot}`));
663
671
  }
664
672
 
@@ -0,0 +1,219 @@
1
+ // A regular website in a slot-owned browser. The project owns its application server.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+
6
+ import { CdpWebPage, listCdpTargets } from '@farmslot/recipe-runner/runtime/cdp';
7
+
8
+ import { assertSameOwner, cdpOwner } from './browser-cdp.cjs';
9
+ import { resolveBrowser } from './browser-resolver.cjs';
10
+ import { removeRuntimeIdentity } from './chrome-args.cjs';
11
+ import { launchBrowser } from './launch-browser.cjs';
12
+ import { stopProfileProcessesSync } from './validation-process-ownership.cjs';
13
+
14
+ /** @typedef {{
15
+ * manifestPath: string,
16
+ * url: (target: string) => string,
17
+ * serverLabel?: string
18
+ * }} PlainWebAdapterConfig
19
+ */
20
+
21
+ function port(env = process.env) {
22
+ const value = Number(env.RECIPE_CDP_PORT ?? env.CDP_PORT);
23
+ if (!Number.isInteger(value) || value < 1 || value > 65535)
24
+ throw new Error('The plain web runtime requires the assigned --cdp-port.');
25
+ return value;
26
+ }
27
+
28
+ /** Runtime files and the browser profile always belong to the selected checkout. */
29
+ export function plainWebRuntimeDir(target) {
30
+ return path.join(fs.realpathSync(target), '.agent', 'web');
31
+ }
32
+
33
+ function browserOptions(target, env = process.env) {
34
+ const runtimeDir = plainWebRuntimeDir(target);
35
+ return {
36
+ cdpPort: port(env),
37
+ runtimeDir,
38
+ profile: path.join(runtimeDir, 'profile'),
39
+ chromeLog: path.join(runtimeDir, 'browser.log'),
40
+ chromePid: path.join(runtimeDir, 'browser.pid'),
41
+ };
42
+ }
43
+
44
+ function owned(target, env = process.env) {
45
+ const options = browserOptions(target, env);
46
+ return cdpOwner(options.cdpPort, options.profile);
47
+ }
48
+
49
+ /**
50
+ * Use existing browser ownership, CDP actions and whole-run recording for a plain website.
51
+ * @param {PlainWebAdapterConfig} config
52
+ * @returns {import('@farmslot/adapter-sdk').ProjectRuntime}
53
+ */
54
+ export function createPlainWebAdapter(config) {
55
+ const hint = 'Prepare the selected slot, then launch its plain web browser.';
56
+ async function page(target, env = process.env) {
57
+ const owner = owned(target, env);
58
+ const file = path.join(plainWebRuntimeDir(target), 'page.json');
59
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
60
+ const selected = (await listCdpTargets('127.0.0.1', port(env))).find(
61
+ (entry) => entry.id === state.pageId && entry.type === 'page',
62
+ );
63
+ assertSameOwner(owner);
64
+ if (!selected) throw new Error(`The owned application page is unavailable. ${hint}`);
65
+ return selected;
66
+ }
67
+ /** @type {import('@farmslot/adapter-sdk').ProjectRuntime} */
68
+ const runtime = {
69
+ id: 'web',
70
+ sdkVersion: 1,
71
+ headless: false,
72
+ targets: ['web', 'cli'],
73
+ resolveSlotPorts() {},
74
+ async runtimeStatus(target) {
75
+ try {
76
+ await page(target);
77
+ const response = await fetch(config.url(target), { signal: AbortSignal.timeout(5000) });
78
+ await response.body?.cancel();
79
+ if (!response.ok) throw new Error(`Application returned HTTP ${response.status}.`);
80
+ return {
81
+ decision: 'ready',
82
+ reasons: [],
83
+ devServer: { label: config.serverLabel ?? 'website', status: 'up' },
84
+ };
85
+ } catch (error) {
86
+ return {
87
+ decision: 'blocked',
88
+ reasons: [error instanceof Error ? error.message : String(error)],
89
+ nextAction: hint,
90
+ };
91
+ }
92
+ },
93
+ devServer: {
94
+ label: 'browser',
95
+ describe: () => 'The owned website browser; the application server belongs to the project',
96
+ stop(target) {
97
+ stopPlainWebBrowser(target);
98
+ return {
99
+ kind: 'stopped',
100
+ status: 0,
101
+ summary: 'Website browser stopped; application server retained.',
102
+ };
103
+ },
104
+ },
105
+ logSources: (target) => [
106
+ { label: 'Browser', path: path.join(plainWebRuntimeDir(target), 'browser.log') },
107
+ ],
108
+ appLogSource: () => null,
109
+ hints: { launch: hint, relaunch: hint, runtimeProbeRecovery: () => hint },
110
+ harness: {
111
+ kind: 'built-in',
112
+ verify(target) {
113
+ return {
114
+ inProcess: async () => async (_args, io) => {
115
+ const status = await runtime.runtimeStatus(target);
116
+ if (status.decision !== 'ready') {
117
+ io.err(status.reasons.join('; '));
118
+ return 1;
119
+ }
120
+ io.out('ready');
121
+ return 0;
122
+ },
123
+ };
124
+ },
125
+ },
126
+ runtimeContext: { forbiddenFields: [] },
127
+ readiness: { captureProviders: ['cdp'], readyIndicator: () => 'ready' },
128
+ recording: {
129
+ async target(context) {
130
+ const owner = owned(context.projectRoot, context.env);
131
+ await page(context.projectRoot, context.env);
132
+ assertSameOwner(owner);
133
+ return { kind: 'pid', pid: owner.pid };
134
+ },
135
+ },
136
+ async launch({ target, jsonOutput }) {
137
+ const url = config.url(target);
138
+ const parsed = new URL(url);
139
+ if (!['http:', 'https:'].includes(parsed.protocol))
140
+ throw new Error('Expected an HTTP(S) application URL.');
141
+ const options = browserOptions(target);
142
+ const resolution = await resolveBrowser({
143
+ env: {
144
+ ...process.env,
145
+ RECIPE_HARNESS_BROWSER: process.env.RECIPE_HARNESS_BROWSER ?? 'chrome',
146
+ },
147
+ cft: () => ({
148
+ error:
149
+ 'Select installed Chrome or set RECIPE_HARNESS_CHROME_BIN to an installed Chromium executable.',
150
+ }),
151
+ cachePath: path.join(options.runtimeDir, 'browser-probe.json'),
152
+ launchMethod: process.platform === 'darwin' ? 'launch-services' : 'spawn',
153
+ display: 'headful',
154
+ });
155
+ launchBrowser({ ...options, chromeBin: resolution.bin, startUrl: url });
156
+ try {
157
+ const owner = owned(target);
158
+ const deadline = Date.now() + 15_000;
159
+ let candidates = [];
160
+ do {
161
+ candidates = (await listCdpTargets('127.0.0.1', port())).filter(
162
+ (entry) => entry.type === 'page' && entry.url.startsWith(`${parsed.origin}/`),
163
+ );
164
+ if (candidates.length) break;
165
+ await delay(100);
166
+ } while (Date.now() < deadline);
167
+ if (candidates.length !== 1)
168
+ throw new Error('Expected one application page in the owned browser.');
169
+ assertSameOwner(owner);
170
+ fs.writeFileSync(
171
+ path.join(options.runtimeDir, 'page.json'),
172
+ JSON.stringify({ pageId: candidates[0].id }),
173
+ { mode: 0o600 },
174
+ );
175
+ const status = await runtime.runtimeStatus(target);
176
+ if (status.decision !== 'ready') throw new Error(status.reasons.join('; '));
177
+ } catch (error) {
178
+ stopPlainWebBrowser(target);
179
+ throw error;
180
+ }
181
+ if (jsonOutput)
182
+ console.log(
183
+ JSON.stringify({
184
+ schemaVersion: 1,
185
+ command: 'launch',
186
+ status: 'pass',
187
+ adapter: 'web',
188
+ target,
189
+ url,
190
+ cdpPort: options.cdpPort,
191
+ }),
192
+ );
193
+ return 0;
194
+ },
195
+ actions: {
196
+ manifestPath: () => config.manifestPath,
197
+ semantic: [],
198
+ cdpTarget: { transport: 'cdp', probePath: '/json/version' },
199
+ ui: ({ createCdpWebUiTransport }) => ({
200
+ base: createCdpWebUiTransport({
201
+ getPage: async ({ context }) =>
202
+ CdpWebPage.connectToTarget(await page(context.projectRoot, context.env)),
203
+ }),
204
+ }),
205
+ },
206
+ };
207
+ return runtime;
208
+ }
209
+
210
+ /** Releases only the selected checkout's owned profile. The application server is retained. */
211
+ export function stopPlainWebBrowser(target) {
212
+ const runtimeDir = plainWebRuntimeDir(target);
213
+ // Stop also works without active port options, including a repeated closeout.
214
+ stopProfileProcessesSync(path.join(runtimeDir, 'profile'));
215
+ removeRuntimeIdentity(runtimeDir);
216
+ fs.rmSync(path.join(runtimeDir, 'browser.pid'), { force: true });
217
+ fs.rmSync(path.join(runtimeDir, 'page.json'), { force: true });
218
+ return { stopped: true };
219
+ }
@@ -90,6 +90,7 @@ export {
90
90
  canonicalPath,
91
91
  cdpPort,
92
92
  DEFAULT_ACCOUNT,
93
+ DERIVED_ACCOUNTS_ENV,
93
94
  fixtureAccount,
94
95
  fixtureEntry,
95
96
  fixturePath,
@@ -100,6 +101,8 @@ export {
100
101
  resolveSigner,
101
102
  SIGNER_MODES,
102
103
  viemAccountFromEntry,
104
+ walletAccount,
105
+ walletAccounts,
103
106
  webDappRuntimeDir,
104
107
  webDappRuntimePath,
105
108
  writePrivateJson,
@@ -11,11 +11,12 @@
11
11
  // wallet requests.
12
12
  //
13
13
  // Inputs (flags): --target <checkout> --cdp-port <port> --app-port <port>
14
- // [--signer extension|injected] [--signer-module <path>] [--account <fixture name>]
14
+ // [--signer extension|injected] [--signer-module <path>] [--account <name|address>]
15
15
  // [--fresh-profile] [--headless | --headful] [--slow-mo <ms>] [--start-chain <id>]
16
16
  // [--json] [--network mainnet --mainnet-confirmation REAL_FUNDS] (testnet
17
17
  // otherwise, enforced in the browser from the venue policy)
18
18
  // env: TERMINAL_CHROME_BIN, RECIPE_WEB_DAPP_SIGNER_MODULE, RECIPE_WALLET_FIXTURE,
19
+ // RECIPE_WALLET_DERIVED_ACCOUNTS,
19
20
  // TERMINAL_HEADLESS, TERMINAL_SLOW_MO, TERMINAL_WINDOW, TERMINAL_SCREEN
20
21
  // (plus whatever the signer module reads)
21
22
  // The browser is headful by default, in a window placed per slot (see
@@ -68,10 +69,9 @@ import {
68
69
  } from './lib/processes.mjs';
69
70
  import { assertLaunchNetwork, devServerTestnetDiagnostic } from './lib/readiness.mjs';
70
71
  import {
71
- fixtureEntry,
72
72
  loadWalletFixture,
73
73
  resolveSigner,
74
- viemAccountFromEntry,
74
+ walletAccount,
75
75
  webDappPolicy,
76
76
  webDappRuntimeDir,
77
77
  } from './lib/runtime.mjs';
@@ -86,7 +86,7 @@ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
86
86
  const macosFocus = createRequire(import.meta.url)('../macos-focus.cjs');
87
87
 
88
88
  function usage() {
89
- return 'Usage: launch.mjs --target <checkout> --cdp-port <port> --app-port <port> [--signer extension|injected] [--signer-module <path>] [--account <name>] [--fresh-profile] [--headless | --headful] [--slow-mo <ms>] [--network testnet|mainnet --mainnet-confirmation REAL_FUNDS] [--start-chain <id>] [--json]';
89
+ return 'Usage: launch.mjs --target <checkout> --cdp-port <port> --app-port <port> [--signer extension|injected] [--signer-module <path>] [--account <name|address>] [--fresh-profile] [--headless | --headful] [--slow-mo <ms>] [--network testnet|mainnet --mainnet-confirmation REAL_FUNDS] [--start-chain <id>] [--json]';
90
90
  }
91
91
 
92
92
  export function parseLaunchArgs(argv, env = process.env) {
@@ -490,7 +490,14 @@ export async function launchWebDappBrowser(
490
490
  }
491
491
 
492
492
  const { fixture } = await loadWalletFixture(target, env);
493
- const accountAddress = viemAccountFromEntry(fixtureEntry(fixture, args.account), target).address;
493
+ // --account is an account name or address; the browser runs under its name.
494
+ const selected = walletAccount(fixture, args.account, target, env);
495
+ const account = {
496
+ name: selected.name,
497
+ address: selected.address,
498
+ secret: selected.secret,
499
+ index: selected.index,
500
+ };
494
501
 
495
502
  const previous = readStateFile(stateFile);
496
503
  const reusable =
@@ -499,7 +506,8 @@ export async function launchWebDappBrowser(
499
506
  previous.signer === signer &&
500
507
  previous.cdpPort === cdpPort &&
501
508
  previous.appOrigin === appOrigin &&
502
- previous.account?.name === args.account &&
509
+ previous.account?.name === account.name &&
510
+ previous.account?.address?.toLowerCase() === account.address.toLowerCase() &&
503
511
  previous.browser?.headless === headless &&
504
512
  (previous.network ?? 'testnet') === network &&
505
513
  previous.networkEnforcement?.fingerprint === enforcementFingerprint &&
@@ -507,7 +515,7 @@ export async function launchWebDappBrowser(
507
515
  ownsProcess('wallet-host', previous.hostPid, runtime);
508
516
  if (reusable) {
509
517
  await browserWebSocketUrl(cdpPort, 5000);
510
- log(`reusing browser pid ${previous.browserPid} (${signer}, ${args.account})`);
518
+ log(`reusing browser pid ${previous.browserPid} (${signer}, ${account.name})`);
511
519
  const state = { ...previous, slowMoMs };
512
520
  if (previous.slowMoMs !== slowMoMs)
513
521
  writePrivateFile(stateFile, `${JSON.stringify(state, null, 2)}\n`);
@@ -518,7 +526,7 @@ export async function launchWebDappBrowser(
518
526
  const browser = await resolveBrowser(env);
519
527
  const profile = path.join(
520
528
  runtime,
521
- `profile-${signer}-${args.account.replace(/[^A-Za-z0-9_-]/gu, '_')}`,
529
+ `profile-${signer}-${account.name.replace(/[^A-Za-z0-9_-]/gu, '_')}`,
522
530
  );
523
531
  if (args['fresh-profile'] && existsSync(profile)) {
524
532
  const retired = `${profile}.retired-${Date.now()}`;
@@ -571,7 +579,7 @@ export async function launchWebDappBrowser(
571
579
  target,
572
580
  runtime,
573
581
  profile,
574
- account: args.account,
582
+ account: account.name,
575
583
  fixture,
576
584
  env,
577
585
  freshProfile,
@@ -683,7 +691,7 @@ export async function launchWebDappBrowser(
683
691
  '--target',
684
692
  target,
685
693
  '--account',
686
- args.account,
694
+ account.name,
687
695
  '--start-chain',
688
696
  String(args['start-chain'] ?? policy.startChain),
689
697
  '--network',
@@ -758,7 +766,7 @@ export async function launchWebDappBrowser(
758
766
  appListeners: cdpListenerPids(appPort),
759
767
  cdpPort,
760
768
  appOrigin,
761
- account: { name: args.account, address: accountAddress },
769
+ account,
762
770
  browserPid,
763
771
  hostPid,
764
772
  browser: {
@@ -11,10 +11,9 @@ import path from 'node:path';
11
11
  import { ownsProcess, pidAlive, portFree } from './processes.mjs';
12
12
  import {
13
13
  canonicalPath,
14
- fixtureEntry,
15
14
  loadWalletFixture,
16
15
  readBrowserState,
17
- viemAccountFromEntry,
16
+ walletAccount,
18
17
  webDappPolicy,
19
18
  webDappRuntimeDir,
20
19
  } from './runtime.mjs';
@@ -359,7 +358,7 @@ export async function webDappReadiness({
359
358
 
360
359
  try {
361
360
  const { file, fixture } = await loadWalletFixture(target, env);
362
- const address = viemAccountFromEntry(fixtureEntry(fixture, account), target).address;
361
+ const { address } = walletAccount(fixture, account, target, env);
363
362
  checks.push(
364
363
  check('wallet-fixture', true, `${path.basename(file)}: account ${account} -> ${address}`),
365
364
  );
@@ -151,22 +151,26 @@ export function fixtureEntry(fixture, name) {
151
151
  .join(', ');
152
152
  throw new Error(`wallet fixture has no account named "${name}". Available: ${names}.`);
153
153
  }
154
+ return checkedEntry(entry);
155
+ }
156
+
157
+ function checkedEntry(entry) {
154
158
  if (entry.type !== 'mnemonic' && entry.type !== 'privateKey') {
155
159
  throw new Error(
156
- `wallet fixture account "${name}" must be mnemonic or privateKey, got "${entry.type}".`,
160
+ `wallet fixture account "${entry.name}" must be mnemonic or privateKey, got "${entry.type}".`,
157
161
  );
158
162
  }
159
163
  if (typeof entry.value !== 'string' || !entry.value.trim()) {
160
- throw new Error(`wallet fixture account "${name}" has no value.`);
164
+ throw new Error(`wallet fixture account "${entry.name}" has no value.`);
161
165
  }
162
166
  return entry;
163
167
  }
164
168
 
165
- // Mnemonics use BIP-44 index 0, the MetaMask default and the derivation the
166
- // Extension fixture flow imports.
167
- export function viemAccountFromEntry(entry, projectRoot) {
169
+ // Mnemonics use BIP-44 m/44'/60'/0'/0/<addressIndex>, the MetaMask derivation
170
+ // the Extension fixture flow imports; index 0 is the wallet's first account.
171
+ export function viemAccountFromEntry(entry, projectRoot, addressIndex = 0) {
168
172
  const { mnemonicToAccount, privateKeyToAccount } = loadViemAccounts(projectRoot);
169
- if (entry.type === 'mnemonic') return mnemonicToAccount(entry.value.trim(), { addressIndex: 0 });
173
+ if (entry.type === 'mnemonic') return mnemonicToAccount(entry.value.trim(), { addressIndex });
170
174
  const raw = entry.value.trim();
171
175
  const key = raw.startsWith('0x') ? raw : `0x${raw}`;
172
176
  if (!/^0x[0-9a-fA-F]{64}$/u.test(key)) {
@@ -175,9 +179,123 @@ export function viemAccountFromEntry(entry, projectRoot) {
175
179
  return privateKeyToAccount(key);
176
180
  }
177
181
 
182
+ // Named HD derivations of the fixture's mnemonic entries that the host adds,
183
+ // e.g. a team's shared test account at a higher index: JSON
184
+ // [{ "name", "secret": <mnemonic entry name>, "index", "address"? }]. Names and
185
+ // indexes only; the key stays in the fixture.
186
+ export const DERIVED_ACCOUNTS_ENV = 'RECIPE_WALLET_DERIVED_ACCOUNTS';
187
+
188
+ export function derivedAccounts(env = process.env) {
189
+ const raw = env[DERIVED_ACCOUNTS_ENV];
190
+ if (!raw) return [];
191
+ let value;
192
+ try {
193
+ value = JSON.parse(raw);
194
+ } catch (error) {
195
+ throw new Error(`${DERIVED_ACCOUNTS_ENV} is not JSON: ${error.message}`);
196
+ }
197
+ const valid = (item) =>
198
+ item &&
199
+ typeof item.name === 'string' &&
200
+ item.name.trim() &&
201
+ typeof item.secret === 'string' &&
202
+ item.secret.trim() &&
203
+ Number.isInteger(item.index) &&
204
+ item.index >= 0 &&
205
+ (item.address === undefined || /^0x[0-9a-fA-F]{40}$/u.test(item.address));
206
+ if (!Array.isArray(value) || !value.every(valid)) {
207
+ throw new Error(
208
+ `${DERIVED_ACCOUNTS_ENV} must be an array of { name, secret, index, address? } (index a non-negative integer).`,
209
+ );
210
+ }
211
+ return value;
212
+ }
213
+
214
+ function mnemonicCount(entry) {
215
+ const count = Number(entry.count ?? entry.numberOfAccounts ?? 1);
216
+ if (!Number.isInteger(count) || count < 1 || count > 100) {
217
+ throw new Error(
218
+ `wallet fixture account "${entry.name}" count must be an integer from 1 through 100.`,
219
+ );
220
+ }
221
+ return count;
222
+ }
223
+
224
+ // names[index] when the entry gives one; a single account keeps the entry's
225
+ // name; otherwise dev1 with count 3 is dev1, dev2, dev3 (a name without a
226
+ // trailing number gets one appended).
227
+ function derivedName(entry, index, count) {
228
+ const explicit = Array.isArray(entry.names) ? entry.names[index] : undefined;
229
+ if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
230
+ if (count === 1) return entry.name;
231
+ const match = /^(.*?)(\d+)$/u.exec(entry.name);
232
+ return match ? `${match[1]}${Number(match[2]) + index}` : `${entry.name}${index + 1}`;
233
+ }
234
+
235
+ /**
236
+ * Every account the fixture offers: each mnemonic entry expanded to its count,
237
+ * each private key, then the host's named derivations (DERIVED_ACCOUNTS_ENV)
238
+ * of the secrets this fixture holds. `account` is the viem account holding the
239
+ * key: sign with it, never log or persist it.
240
+ */
241
+ export function walletAccounts(fixture, projectRoot, env = process.env) {
242
+ const accounts = [];
243
+ for (const entry of fixture.accounts) {
244
+ if (entry?.type !== 'mnemonic' && entry?.type !== 'privateKey') continue;
245
+ checkedEntry(entry);
246
+ const count = entry.type === 'mnemonic' ? mnemonicCount(entry) : 1;
247
+ for (let index = 0; index < count; index += 1) {
248
+ const account = viemAccountFromEntry(entry, projectRoot, index);
249
+ accounts.push({
250
+ name: derivedName(entry, index, count),
251
+ address: account.address,
252
+ secret: entry.name,
253
+ index,
254
+ account,
255
+ });
256
+ }
257
+ }
258
+ for (const item of derivedAccounts(env)) {
259
+ const entry = fixture.accounts.find(
260
+ (candidate) => candidate?.type === 'mnemonic' && candidate.name === item.secret,
261
+ );
262
+ if (!entry) continue;
263
+ const account = viemAccountFromEntry(checkedEntry(entry), projectRoot, item.index);
264
+ if (item.address && item.address.toLowerCase() !== account.address.toLowerCase()) {
265
+ throw new Error(
266
+ `wallet account "${item.name}" derives to ${account.address} from "${item.secret}" index ${item.index}, not the declared ${item.address}.`,
267
+ );
268
+ }
269
+ accounts.push({
270
+ name: item.name,
271
+ address: account.address,
272
+ secret: item.secret,
273
+ index: item.index,
274
+ account,
275
+ });
276
+ }
277
+ return accounts;
278
+ }
279
+
280
+ // The fixture account a selector names: an account name, else an address.
281
+ export function walletAccount(fixture, selector, projectRoot, env = process.env) {
282
+ const accounts = walletAccounts(fixture, projectRoot, env);
283
+ const wanted = String(selector ?? '').trim();
284
+ const found =
285
+ accounts.find((account) => account.name === wanted) ??
286
+ (/^0x[0-9a-fA-F]{40}$/u.test(wanted)
287
+ ? accounts.find((account) => account.address.toLowerCase() === wanted.toLowerCase())
288
+ : undefined);
289
+ if (!found) {
290
+ const names = accounts.map((account) => account.name).join(', ');
291
+ throw new Error(`wallet fixture has no account named "${wanted}". Available: ${names}.`);
292
+ }
293
+ return found;
294
+ }
295
+
178
296
  export async function fixtureAccount(projectRoot, name, env = process.env) {
179
297
  const { fixture } = await loadWalletFixture(projectRoot, env);
180
- return viemAccountFromEntry(fixtureEntry(fixture, name), projectRoot);
298
+ return walletAccount(fixture, name, projectRoot, env).account;
181
299
  }
182
300
 
183
301
  // Address-only view of the account a node targets: an explicit address wins