@farmslot/adapter-web 0.5.0 → 0.6.1

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +25 -1
  3. package/dist/web-dapp/cleanup.d.mts +15 -0
  4. package/dist/web-dapp/index.d.mts +124 -0
  5. package/dist/web-dapp/inject.d.mts +12 -0
  6. package/dist/web-dapp/launch.d.mts +57 -0
  7. package/dist/web-dapp/lib/blocked-hosts.d.mts +3 -0
  8. package/dist/web-dapp/lib/cdp-client.d.mts +8 -0
  9. package/dist/web-dapp/lib/display.d.mts +26 -0
  10. package/dist/web-dapp/lib/hud.d.mts +14 -0
  11. package/dist/web-dapp/lib/paths.d.mts +11 -0
  12. package/dist/web-dapp/lib/policy-fence.d.mts +18 -0
  13. package/dist/web-dapp/lib/policy.d.mts +4 -0
  14. package/dist/web-dapp/lib/processes.d.mts +57 -0
  15. package/dist/web-dapp/lib/readiness.d.mts +85 -0
  16. package/dist/web-dapp/lib/runtime.d.mts +29 -0
  17. package/dist/web-dapp/lib/session-lock.d.mts +21 -0
  18. package/dist/web-dapp/lib/signers.d.mts +13 -0
  19. package/dist/web-dapp/lib/viem.d.mts +1 -0
  20. package/dist/web-dapp/stop.d.mts +2 -0
  21. package/dist/web-dapp/verify.d.mts +2 -0
  22. package/dist/web-dapp/wallet-host.d.mts +2 -0
  23. package/package.json +17 -10
  24. package/src/web-dapp/cleanup.mjs +74 -0
  25. package/src/web-dapp/index.mjs +596 -0
  26. package/src/web-dapp/inject.mjs +100 -0
  27. package/src/web-dapp/launch.mjs +818 -0
  28. package/src/web-dapp/lib/blocked-hosts.mjs +24 -0
  29. package/src/web-dapp/lib/cdp-client.mjs +86 -0
  30. package/src/web-dapp/lib/display.mjs +169 -0
  31. package/src/web-dapp/lib/hud.mjs +156 -0
  32. package/src/web-dapp/lib/paths.mjs +55 -0
  33. package/src/web-dapp/lib/policy-fence.mjs +128 -0
  34. package/src/web-dapp/lib/policy.mjs +108 -0
  35. package/src/web-dapp/lib/processes.mjs +215 -0
  36. package/src/web-dapp/lib/readiness.mjs +438 -0
  37. package/src/web-dapp/lib/runtime.mjs +195 -0
  38. package/src/web-dapp/lib/session-lock.mjs +56 -0
  39. package/src/web-dapp/lib/signers.mjs +105 -0
  40. package/src/web-dapp/lib/viem.mjs +26 -0
  41. package/src/web-dapp/stop.mjs +44 -0
  42. package/src/web-dapp/verify.mjs +68 -0
  43. package/src/web-dapp/wallet-host.mjs +728 -0
@@ -0,0 +1,438 @@
1
+ // Read-only readiness of a web-dapp slot for recipe runs: checkout shape,
2
+ // dependencies, the slot dev server, forced testnet, wallet fixture, browser,
3
+ // the extension signer's own checks (its `readinessChecks` hook) and the
4
+ // per-slot CDP port. Never launches anything except the optional short-lived
5
+ // browser start probe.
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ import { ownsProcess, pidAlive, portFree } from './processes.mjs';
12
+ import {
13
+ canonicalPath,
14
+ fixtureEntry,
15
+ loadWalletFixture,
16
+ readBrowserState,
17
+ viemAccountFromEntry,
18
+ webDappPolicy,
19
+ webDappRuntimeDir,
20
+ } from './runtime.mjs';
21
+
22
+ /**
23
+ * @param {string} id
24
+ * @param {boolean} ok
25
+ * @param {string} detail
26
+ * @param {{ required?: boolean, status?: 'pass' | 'fail' | 'warn' }} [options]
27
+ */
28
+ export function check(id, ok, detail, { required = true, status } = {}) {
29
+ return { id, status: status ?? (ok ? 'pass' : 'fail'), required, detail };
30
+ }
31
+
32
+ async function httpStatus(url, timeoutMs = 20000) {
33
+ try {
34
+ const response = await fetch(url, {
35
+ signal: AbortSignal.timeout(timeoutMs),
36
+ redirect: 'manual',
37
+ });
38
+ return response.status;
39
+ } catch (error) {
40
+ return error?.name === 'TimeoutError' ? 'timeout' : 'unreachable';
41
+ }
42
+ }
43
+
44
+ // Diagnostic only. Whether the slot's dev server looks configured for testnet:
45
+ // the farm starts `next dev` (temp/farmslot/next-dev.pid) with the policy's
46
+ // testnet variable set to true (the Terminal's
47
+ // NEXT_PUBLIC_HYPERLIQUID_FORCE_TESTNET). Process inspection cannot prove
48
+ // what the browser talks to (macOS reports a process's initial environment,
49
+ // and lsof cannot see other users' sockets), so this only warns; the browser
50
+ // is held to testnet by the network and wallet layers (the venue policy's
51
+ // blocked hosts, the wallet host). Each listener on the app port must descend
52
+ // from the pid-file process and run from the checkout (its cwd); every process on the way from
53
+ // a listener up to the pid-file process is read exactly (argv and environment
54
+ // entries from sysctl KERN_PROCARGS2) and must carry the testnet variable. An
55
+ // unreadable process makes the result unknown.
56
+ export const DEV_SERVER_PID_FILE = 'temp/farmslot/next-dev.pid';
57
+
58
+ function run(command, args) {
59
+ try {
60
+ return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
61
+ } catch (error) {
62
+ return typeof error?.stdout === 'string' ? error.stdout : '';
63
+ }
64
+ }
65
+
66
+ const PROCARGS_HELPER = `
67
+ import ctypes, ctypes.util, json, struct, sys
68
+ libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
69
+ pid = int(sys.argv[1])
70
+ argmax = ctypes.c_int(0)
71
+ size = ctypes.c_size_t(ctypes.sizeof(argmax))
72
+ if libc.sysctl((ctypes.c_int * 2)(1, 8), 2, ctypes.byref(argmax), ctypes.byref(size), None, 0) != 0:
73
+ print("null"); sys.exit(0)
74
+ buf = ctypes.create_string_buffer(argmax.value)
75
+ size = ctypes.c_size_t(argmax.value)
76
+ if libc.sysctl((ctypes.c_int * 3)(1, 49, pid), 3, buf, ctypes.byref(size), None, 0) != 0:
77
+ print("null"); sys.exit(0)
78
+ raw = buf.raw[:size.value]
79
+ argc = struct.unpack("i", raw[:4])[0]
80
+ rest = raw[4:]
81
+ rest = rest[rest.index(b"\\0"):].lstrip(b"\\0")
82
+ parts = rest.split(b"\\0")
83
+ env = []
84
+ for part in parts[argc:]:
85
+ if not part: break
86
+ env.append(part.decode("utf-8", "replace"))
87
+ print(json.dumps({"argv": [p.decode("utf-8", "replace") for p in parts[:argc]], "env": env}))
88
+ `;
89
+
90
+ export const systemProbe = Object.freeze({
91
+ listeners: (port) =>
92
+ run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'])
93
+ .split('\n')
94
+ .map(Number)
95
+ .filter((pid) => pid > 0),
96
+ parents: () =>
97
+ new Map(
98
+ run('ps', ['-A', '-o', 'pid=,ppid='])
99
+ .split('\n')
100
+ .map((line) => line.trim().split(/\s+/u).map(Number))
101
+ .filter(([pid, ppid]) => pid > 0 && ppid >= 0),
102
+ ),
103
+ cwd: (pid) =>
104
+ (
105
+ run('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'])
106
+ .split('\n')
107
+ .find((line) => line.startsWith('n')) ?? ''
108
+ ).slice(1) || null,
109
+ // { argv: string[], env: string[] } with exact element boundaries, or null.
110
+ procArgs: (pid) => {
111
+ const python = usablePython();
112
+ if (!python) return null;
113
+ try {
114
+ return JSON.parse(
115
+ execFileSync(python, ['-c', PROCARGS_HELPER, String(pid)], {
116
+ encoding: 'utf8',
117
+ stdio: ['ignore', 'pipe', 'ignore'],
118
+ timeout: 15000,
119
+ }).trim(),
120
+ );
121
+ } catch {
122
+ return null;
123
+ }
124
+ },
125
+ });
126
+
127
+ // macOS's /usr/bin/python3 is a stub until the Command Line Tools are
128
+ // installed, and running it opens an install dialog that takes keyboard
129
+ // focus. Use it only when xcode-select reports a developer directory; any
130
+ // other python3 on PATH is a real install.
131
+ export function pythonFor({ found, platform = process.platform, developerDir }) {
132
+ if (!found) return null;
133
+ if (platform === 'darwin' && found === '/usr/bin/python3' && !developerDir()) return null;
134
+ return found;
135
+ }
136
+
137
+ let pythonChoice;
138
+ function usablePython() {
139
+ if (pythonChoice === undefined) {
140
+ pythonChoice = pythonFor({
141
+ found: run('/usr/bin/which', ['python3']).trim(),
142
+ developerDir: () => run('/usr/bin/xcode-select', ['-p']).trim(),
143
+ });
144
+ }
145
+ return pythonChoice;
146
+ }
147
+
148
+ export function environmentValue(env, name) {
149
+ const entry = env.find((item) => item.startsWith(`${name}=`));
150
+ return entry === undefined ? undefined : entry.slice(name.length + 1);
151
+ }
152
+
153
+ // `next dev` / `next start`: the argument after the next binary.
154
+ export function nextServerMode(argv) {
155
+ for (let index = 0; index < argv.length - 1; index += 1) {
156
+ const element = argv[index];
157
+ if (
158
+ element === 'next' ||
159
+ element.endsWith('/next') ||
160
+ element.endsWith('/next/dist/bin/next')
161
+ ) {
162
+ if (argv[index + 1] === 'dev' || argv[index + 1] === 'start') return argv[index + 1];
163
+ }
164
+ }
165
+ return null;
166
+ }
167
+
168
+ function chainTo(listener, root, parents) {
169
+ const chain = [];
170
+ let current = listener;
171
+ for (let hops = 0; current && hops < 64; hops += 1) {
172
+ chain.push(current);
173
+ if (current === root) return chain;
174
+ current = parents.get(current);
175
+ }
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * @param {string} target
181
+ * @param {{ appPort?: number, variable?: string, probe?: typeof systemProbe }} [options]
182
+ */
183
+ export function devServerTestnetDiagnostic(
184
+ target,
185
+ { appPort, variable, probe = systemProbe } = {},
186
+ ) {
187
+ const checkout = canonicalPath(target);
188
+ const unknown = (detail) => ({ known: false, forced: false, detail });
189
+ const pidFile = path.join(checkout, DEV_SERVER_PID_FILE);
190
+ if (!existsSync(pidFile)) return unknown(`no ${DEV_SERVER_PID_FILE}`);
191
+ const root = Number(readFileSync(pidFile, 'utf8').trim());
192
+ if (!pidAlive(root)) return unknown(`next dev pid ${root} is not running`);
193
+ if (!appPort) return unknown('no app port to check');
194
+ const listeners = probe.listeners(appPort);
195
+ if (listeners.length === 0)
196
+ return unknown(`nothing (visible to this user) listens on app port ${appPort}`);
197
+ const parents = probe.parents();
198
+ let mode = null;
199
+ for (const listener of listeners) {
200
+ const chain = chainTo(listener, root, parents);
201
+ if (!chain)
202
+ return {
203
+ known: true,
204
+ forced: false,
205
+ detail: `app port ${appPort} is served by pid ${listener}, outside next dev pid ${root}`,
206
+ };
207
+ const cwd = probe.cwd(listener);
208
+ if (!cwd || canonicalPath(cwd) !== checkout) {
209
+ return {
210
+ known: true,
211
+ forced: false,
212
+ detail: `pid ${listener} on app port ${appPort} runs from ${cwd ?? 'an unknown directory'}, not ${checkout}`,
213
+ };
214
+ }
215
+ for (const pid of chain) {
216
+ const args = probe.procArgs(pid);
217
+ if (!args) return unknown(`cannot read the arguments and environment of pid ${pid}`);
218
+ // A process that rewrote its title (next-server) overwrote the memory
219
+ // its arguments and environment are read from.
220
+ if (args.env.length === 0)
221
+ return unknown(
222
+ `pid ${pid} (${args.argv[0] ?? '?'}) rewrote its process title; its environment is not readable`,
223
+ );
224
+ const processMode = nextServerMode(args.argv);
225
+ if (processMode === 'start')
226
+ return {
227
+ known: true,
228
+ forced: false,
229
+ detail: `pid ${pid} runs next start; its environment cannot prove what the build inlined`,
230
+ };
231
+ if (processMode === 'dev') mode = 'dev';
232
+ const value = environmentValue(args.env, variable);
233
+ if (value !== 'true') {
234
+ return {
235
+ known: true,
236
+ forced: false,
237
+ detail: `pid ${pid} (between app port ${appPort} and next dev pid ${root}) has ${variable}=${value ?? '<unset>'}`,
238
+ };
239
+ }
240
+ }
241
+ }
242
+ if (mode !== 'dev')
243
+ return {
244
+ known: true,
245
+ forced: false,
246
+ detail: `no process serving app port ${appPort} runs next dev`,
247
+ };
248
+ return {
249
+ known: true,
250
+ forced: true,
251
+ detail: `every process from app port ${appPort}'s listeners (cwd ${checkout}) up to next dev pid ${root} has ${variable}=true`,
252
+ };
253
+ }
254
+
255
+ export const MAINNET_CONFIRMATION = 'REAL_FUNDS';
256
+
257
+ // The launch only checks the requested network: mainnet needs the real-funds
258
+ // confirmation. Testnet is enforced inside the browser (the venue policy's blocked hosts).
259
+ /**
260
+ * @param {string} _target
261
+ * @param {{ network?: string | null, mainnetConfirmation?: string }} [options]
262
+ * @returns {'testnet' | 'mainnet'}
263
+ */
264
+ export function assertLaunchNetwork(_target, { network, mainnetConfirmation } = {}) {
265
+ const requested = network == null || network === '' ? 'testnet' : String(network);
266
+ if (requested !== 'testnet' && requested !== 'mainnet') {
267
+ throw new Error(`network must be testnet or mainnet, got ${JSON.stringify(network)}.`);
268
+ }
269
+ if (requested === 'mainnet' && mainnetConfirmation !== MAINNET_CONFIRMATION) {
270
+ throw new Error(
271
+ `network=mainnet needs mainnet_confirmation=${MAINNET_CONFIRMATION}; refusing to launch the ${webDappPolicy().adapterId} browser.`,
272
+ );
273
+ }
274
+ return requested;
275
+ }
276
+
277
+ // "a, b and c".
278
+ function listing(names) {
279
+ return names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join('');
280
+ }
281
+
282
+ // `policy`: the venue policy (checkout, dependencies, startPath, testnetVariable);
283
+ // by default the one RECIPE_WEB_DAPP_POLICY names. `signers`: the signer hooks
284
+ // by mode; signer=extension is judged by `signers.extension.readinessChecks`
285
+ // (`{ target, env, required }`, returning checks built with `check`).
286
+ /**
287
+ * @param {{
288
+ * policy?: any,
289
+ * signers?: Record<string, any>,
290
+ * target: string,
291
+ * appPort?: number,
292
+ * cdpPort?: number,
293
+ * signer?: string,
294
+ * account?: string,
295
+ * env?: NodeJS.ProcessEnv,
296
+ * probeBrowser?: (env: NodeJS.ProcessEnv) => Promise<{ bin: string, version: string | null, source: string }>,
297
+ * }} options
298
+ */
299
+ export async function webDappReadiness({
300
+ policy = webDappPolicy(),
301
+ signers = {},
302
+ target,
303
+ appPort,
304
+ cdpPort,
305
+ // As defaultSigner: extension only when the host gave an extension signer.
306
+ signer = signers?.extension ? 'extension' : 'injected',
307
+ account = 'dev1',
308
+ env = process.env,
309
+ probeBrowser = undefined,
310
+ }) {
311
+ const checks = [];
312
+ const shape = policy.checkout.matches(target);
313
+ checks.push(
314
+ check(
315
+ 'checkout',
316
+ shape,
317
+ shape ? policy.checkout.label : `${target} is not a ${policy.checkout.name} checkout`,
318
+ ),
319
+ );
320
+ const deps = policy.dependencies.filter(
321
+ (name) => !existsSync(path.join(target, 'node_modules', name, 'package.json')),
322
+ );
323
+ checks.push(
324
+ check(
325
+ 'dependencies',
326
+ deps.length === 0,
327
+ deps.length === 0
328
+ ? `${listing(policy.dependencies)} installed`
329
+ : `missing: ${deps.join(', ')} (run npm ci on Node 22)`,
330
+ ),
331
+ );
332
+
333
+ if (appPort) {
334
+ const status = await httpStatus(`http://127.0.0.1:${appPort}${policy.startPath}`);
335
+ checks.push(
336
+ check(
337
+ 'app-dev-server',
338
+ status === 200,
339
+ `GET http://localhost:${appPort}${policy.startPath} -> ${status}`,
340
+ ),
341
+ );
342
+ } else {
343
+ checks.push(
344
+ check(
345
+ 'app-dev-server',
346
+ false,
347
+ 'no dev-server port; pass --watcher-port <port> or set TERMINAL_APP_PORT',
348
+ ),
349
+ );
350
+ }
351
+ // Advisory: the browser enforces testnet itself (the venue policy's blocked hosts).
352
+ const testnet = devServerTestnetDiagnostic(target, { appPort, variable: policy.testnetVariable });
353
+ checks.push(
354
+ check('testnet-forced', testnet.known && testnet.forced, testnet.detail, {
355
+ required: false,
356
+ status: testnet.known && testnet.forced ? 'pass' : 'warn',
357
+ }),
358
+ );
359
+
360
+ try {
361
+ const { file, fixture } = await loadWalletFixture(target, env);
362
+ const address = viemAccountFromEntry(fixtureEntry(fixture, account), target).address;
363
+ checks.push(
364
+ check('wallet-fixture', true, `${path.basename(file)}: account ${account} -> ${address}`),
365
+ );
366
+ } catch (error) {
367
+ checks.push(check('wallet-fixture', false, error.message.split('\n')[0]));
368
+ }
369
+
370
+ if (probeBrowser) {
371
+ try {
372
+ const browser = await probeBrowser(env);
373
+ checks.push(check('browser', true, `${browser.version ?? browser.bin} (${browser.source})`));
374
+ } catch (error) {
375
+ checks.push(check('browser', false, error.message.split('\n')[0]));
376
+ }
377
+ }
378
+
379
+ const extensionRequired = signer === 'extension';
380
+ if (signers.extension?.readinessChecks) {
381
+ checks.push(
382
+ ...(await signers.extension.readinessChecks({ target, env, required: extensionRequired })),
383
+ );
384
+ } else if (extensionRequired) {
385
+ checks.push(
386
+ check(
387
+ 'extension-signer',
388
+ false,
389
+ 'no extension signer is configured (signers.extension / RECIPE_WEB_DAPP_SIGNER_MODULE)',
390
+ ),
391
+ );
392
+ }
393
+
394
+ const state = await readBrowserState(target);
395
+ const runtime = webDappRuntimeDir(target);
396
+ const live = Boolean(
397
+ state &&
398
+ ownsProcess('browser', state.browserPid, runtime) &&
399
+ ownsProcess('wallet-host', state.hostPid, runtime),
400
+ );
401
+ if (cdpPort) {
402
+ const free = await portFree(cdpPort);
403
+ const owned = live && state.cdpPort === cdpPort;
404
+ checks.push(
405
+ check(
406
+ 'cdp-port',
407
+ free || owned,
408
+ free
409
+ ? `CDP port ${cdpPort} is free`
410
+ : owned
411
+ ? `CDP port ${cdpPort} held by this slot's browser`
412
+ : `CDP port ${cdpPort} is held by another process`,
413
+ ),
414
+ );
415
+ } else {
416
+ checks.push(check('cdp-port', false, 'no CDP port; pass --cdp-port <port>'));
417
+ }
418
+ checks.push(
419
+ check(
420
+ 'browser-live',
421
+ live,
422
+ live
423
+ ? `browser pid ${state.browserPid} (${state.signer}, ${state.account?.name}) and wallet host pid ${state.hostPid}`
424
+ : 'no slot browser running (the launch recipe node starts it)',
425
+ { required: false },
426
+ ),
427
+ );
428
+
429
+ const failed = checks
430
+ .filter((item) => item.required && item.status === 'fail')
431
+ .map((item) => item.id);
432
+ return {
433
+ status: failed.length === 0 ? 'pass' : 'fail',
434
+ checks,
435
+ failed,
436
+ browser: live ? state : null,
437
+ };
438
+ }
@@ -0,0 +1,195 @@
1
+ // Resolve the web-dapp slot runtime: the venue policy, ports, runtime paths,
2
+ // browser state and wallet-fixture accounts. Key material is only ever
3
+ // returned as a viem account object; callers must never log or persist it.
4
+
5
+ import { realpathSync } from 'node:fs';
6
+ import { readFile, writeFile } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+
9
+ import { recipeRuntimeDir, walletFixturePath } from './paths.mjs';
10
+ import { POLICY_ENV, webDappAdapterId, webDappPolicy } from './policy.mjs';
11
+ import { defaultSigner } from './signers.mjs';
12
+ import { loadViemAccounts } from './viem.mjs';
13
+
14
+ export { POLICY_ENV, webDappAdapterId, webDappPolicy };
15
+
16
+ export const SIGNER_MODES = Object.freeze(['extension', 'injected']);
17
+ export const DEFAULT_ACCOUNT = 'dev1';
18
+
19
+ // The checkout's canonical path: a symlinked or /tmp-vs-/private/tmp spelling
20
+ // of the same slot must name the same runtime dir, profile and pid owners.
21
+ export function canonicalPath(target) {
22
+ const resolved = path.resolve(target);
23
+ try {
24
+ return realpathSync(resolved);
25
+ } catch {
26
+ return resolved;
27
+ }
28
+ }
29
+
30
+ // <checkout>/<runtime>/<adapter id>, e.g. temp/recipe/runtime/terminal.
31
+ export function webDappRuntimeDir(projectRoot) {
32
+ return path.join(canonicalPath(projectRoot), recipeRuntimeDir(), webDappPolicy().adapterId);
33
+ }
34
+
35
+ export function webDappRuntimePath(projectRoot, ...segments) {
36
+ return path.join(webDappRuntimeDir(projectRoot), ...segments);
37
+ }
38
+
39
+ function portFrom(raw, label) {
40
+ if (raw === undefined || raw === null || raw === '') return undefined;
41
+ const port = Number(raw);
42
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
43
+ throw new Error(`${label} must be a TCP port, got ${JSON.stringify(raw)}.`);
44
+ }
45
+ return port;
46
+ }
47
+
48
+ // The slot's Next.js dev server. Farmslot passes it as --watcher-port {{port}};
49
+ // TERMINAL_APP_PORT is the explicit override.
50
+ export function appPort(input = {}, env = process.env) {
51
+ const port =
52
+ portFrom(input.node?.app_port, 'app_port') ??
53
+ portFrom(env.TERMINAL_APP_PORT, 'TERMINAL_APP_PORT') ??
54
+ portFrom(env.RECIPE_WATCHER_PORT, 'RECIPE_WATCHER_PORT') ??
55
+ portFrom(env.WATCHER_PORT, 'WATCHER_PORT');
56
+ if (!port) {
57
+ throw new Error(
58
+ `${webDappPolicy().adapterId} adapter requires the slot dev-server port.\n` +
59
+ 'Next: pass --watcher-port <port> (Farmslot: {{port}}) or set TERMINAL_APP_PORT.',
60
+ );
61
+ }
62
+ return port;
63
+ }
64
+
65
+ export function appOrigin(input = {}, env = process.env) {
66
+ return `http://localhost:${appPort(input, env)}`;
67
+ }
68
+
69
+ export function cdpPort(input = {}, env = process.env) {
70
+ const port =
71
+ portFrom(input.node?.cdp_port, 'cdp_port') ??
72
+ portFrom(env.RECIPE_CDP_PORT, 'RECIPE_CDP_PORT') ??
73
+ portFrom(env.CDP_PORT, 'CDP_PORT');
74
+ if (!port) {
75
+ throw new Error(
76
+ `${webDappPolicy().adapterId} adapter requires a per-slot browser CDP port.\n` +
77
+ 'Next: pass --cdp-port <port> (Farmslot: {{cdp_port}}).',
78
+ );
79
+ }
80
+ return port;
81
+ }
82
+
83
+ export async function readJsonFile(file) {
84
+ try {
85
+ return JSON.parse(await readFile(file, 'utf8'));
86
+ } catch (error) {
87
+ if (error?.code === 'ENOENT') return null;
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ export async function writePrivateJson(file, value) {
93
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
94
+ }
95
+
96
+ export async function readBrowserState(projectRoot) {
97
+ return readJsonFile(webDappRuntimePath(projectRoot, 'browser.json'));
98
+ }
99
+
100
+ export function resolveSigner(raw, fallback = defaultSigner()) {
101
+ const signer = raw == null || raw === '' ? fallback : String(raw);
102
+ if (!SIGNER_MODES.includes(signer)) {
103
+ throw new Error(
104
+ `signer must be one of ${SIGNER_MODES.join(' | ')}, got ${JSON.stringify(raw)}.`,
105
+ );
106
+ }
107
+ return signer;
108
+ }
109
+
110
+ // Account name precedence: node.account_name → node.account (when not an
111
+ // address) → the running browser's account → dev1.
112
+ export function accountName(input = {}, browserState = null) {
113
+ const explicit = input.node?.account_name ?? input.node?.account;
114
+ if (
115
+ typeof explicit === 'string' &&
116
+ explicit.trim() &&
117
+ !/^0x[0-9a-fA-F]{40}$/u.test(explicit.trim())
118
+ ) {
119
+ return explicit.trim();
120
+ }
121
+ return browserState?.account?.name ?? DEFAULT_ACCOUNT;
122
+ }
123
+
124
+ export function fixturePath(projectRoot, env = process.env) {
125
+ return env.RECIPE_WALLET_FIXTURE
126
+ ? path.resolve(env.RECIPE_WALLET_FIXTURE)
127
+ : walletFixturePath(projectRoot);
128
+ }
129
+
130
+ export async function loadWalletFixture(projectRoot, env = process.env) {
131
+ const file = fixturePath(projectRoot, env);
132
+ const fixture = await readJsonFile(file);
133
+ if (!fixture) {
134
+ throw new Error(
135
+ `wallet fixture not found at ${file}.\n` +
136
+ `Next: run the host's install command (--adapter ${webDappPolicy().adapterId} --target <checkout>) with RECIPE_WALLET_FIXTURE set, or copy the fixture there (0600).`,
137
+ );
138
+ }
139
+ if (!Array.isArray(fixture.accounts) || fixture.accounts.length === 0) {
140
+ throw new Error(`wallet fixture at ${file} has no accounts.`);
141
+ }
142
+ return { file, fixture };
143
+ }
144
+
145
+ export function fixtureEntry(fixture, name) {
146
+ const entry = fixture.accounts.find((account) => account?.name === name);
147
+ if (!entry) {
148
+ const names = fixture.accounts
149
+ .map((account) => account?.name)
150
+ .filter(Boolean)
151
+ .join(', ');
152
+ throw new Error(`wallet fixture has no account named "${name}". Available: ${names}.`);
153
+ }
154
+ if (entry.type !== 'mnemonic' && entry.type !== 'privateKey') {
155
+ throw new Error(
156
+ `wallet fixture account "${name}" must be mnemonic or privateKey, got "${entry.type}".`,
157
+ );
158
+ }
159
+ if (typeof entry.value !== 'string' || !entry.value.trim()) {
160
+ throw new Error(`wallet fixture account "${name}" has no value.`);
161
+ }
162
+ return entry;
163
+ }
164
+
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) {
168
+ const { mnemonicToAccount, privateKeyToAccount } = loadViemAccounts(projectRoot);
169
+ if (entry.type === 'mnemonic') return mnemonicToAccount(entry.value.trim(), { addressIndex: 0 });
170
+ const raw = entry.value.trim();
171
+ const key = raw.startsWith('0x') ? raw : `0x${raw}`;
172
+ if (!/^0x[0-9a-fA-F]{64}$/u.test(key)) {
173
+ throw new Error(`wallet fixture privateKey account "${entry.name}" is not a 32-byte hex key.`);
174
+ }
175
+ return privateKeyToAccount(key);
176
+ }
177
+
178
+ export async function fixtureAccount(projectRoot, name, env = process.env) {
179
+ const { fixture } = await loadWalletFixture(projectRoot, env);
180
+ return viemAccountFromEntry(fixtureEntry(fixture, name), projectRoot);
181
+ }
182
+
183
+ // Address-only view of the account a node targets: an explicit address wins
184
+ // (read-only use), otherwise the fixture account is derived.
185
+ export async function resolveAccountAddress(input, env = process.env) {
186
+ const raw = input.node?.account;
187
+ if (typeof raw === 'string' && /^0x[0-9a-fA-F]{40}$/u.test(raw.trim())) {
188
+ return { name: null, address: raw.trim() };
189
+ }
190
+ const projectRoot = input.context?.projectRoot;
191
+ const browserState = projectRoot ? await readBrowserState(projectRoot) : null;
192
+ const name = accountName(input, browserState);
193
+ const account = await fixtureAccount(projectRoot, name, env);
194
+ return { name, address: account.address };
195
+ }
@@ -0,0 +1,56 @@
1
+ // Whether the macOS login session is locked. macOS stops painting visible
2
+ // windows while it is, so a headful browser's screenshots, video and UI actions
3
+ // stall until their own timeouts. The launcher refuses a headful launch up front
4
+ // instead (SESSION_LOCKED).
5
+
6
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
7
+
8
+ export const SESSION_LOCKED_MESSAGE =
9
+ "The macOS login session is locked, so the headful browser can't paint (screenshots, video and UI actions stall).";
10
+ export const SESSION_LOCKED_USER_ACTION =
11
+ 'Unlock the Mac and keep its login session unlocked while the run drives the browser, then rerun. ' +
12
+ 'caffeinate keeps the Mac awake but does not unlock it.';
13
+
14
+ const LOCKED = /<key>CGSSessionScreenIsLocked<\/key>\s*<true\/>/u;
15
+ const ON_CONSOLE = /<key>kCGSSessionOnConsoleKey<\/key>\s*<true\/>/u;
16
+
17
+ /**
18
+ * `ioreg -n Root -d1 -a` output: the lock flag of the on-console session in
19
+ * IOConsoleUsers (any session's when none is on console); false for a plist
20
+ * without it, null for anything else.
21
+ * @param {string} output
22
+ * @returns {boolean | null}
23
+ */
24
+ export function parseSessionLocked(output) {
25
+ if (!/<plist[\s>]/u.test(output)) return null;
26
+ const users = /<key>IOConsoleUsers<\/key>\s*<array>([\s\S]*?)<\/array>/u.exec(output)?.[1] ?? '';
27
+ const onConsole = users
28
+ .match(/<dict>[\s\S]*?<\/dict>/gu)
29
+ ?.find((session) => ON_CONSOLE.test(session));
30
+ return LOCKED.test(onConsole ?? output);
31
+ }
32
+
33
+ /**
34
+ * true or false on macOS; null when unknown (another platform, or ioreg failed
35
+ * or took longer than 3 s). Never throws.
36
+ * @param {{ platform?: string, spawnSync?: typeof nodeSpawnSync }} [deps]
37
+ * @returns {boolean | null}
38
+ */
39
+ export function macosSessionLocked({
40
+ platform = process.platform,
41
+ spawnSync = nodeSpawnSync,
42
+ } = {}) {
43
+ if (platform !== 'darwin') return null;
44
+ try {
45
+ const result = spawnSync('ioreg', ['-n', 'Root', '-d1', '-a'], {
46
+ encoding: 'utf8',
47
+ timeout: 3_000,
48
+ maxBuffer: 8 * 1024 * 1024,
49
+ stdio: ['ignore', 'pipe', 'ignore'],
50
+ });
51
+ if (result.error || result.status !== 0) return null;
52
+ return parseSessionLocked(String(result.stdout));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }