@farmslot/adapter-web 0.6.0 → 0.6.2

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,16 @@ 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.6.2 - 2026-10-08
10
+
11
+ - Fix: `launch-browser` no longer builds its `node -e` CDP request from the port, path or target id. They reach the child as argv, `cdpHttp` takes only an integer port from 1 to 65535 and the paths it uses (`/json/list`, `/json/version`, `/json/close/<id>`), and closing duplicate home tabs skips, with a warning, a target id from `/json/list` that is not a CDP id (letters, digits, `-`). Before, a hostile or substituted local CDP endpoint could run code in that child through a crafted target id. `cdpHttp` and `pruneExtraHomeTabs` are exported for tests. ([#859](https://github.com/deeeed/farmslot/pull/859))
12
+ - Publish with adapter-sdk 0.7.1 and recipe-runner 0.27.0 so consumers share one adapter-sdk and one recipe-runner copy.
13
+
14
+ ## 0.6.1 - 2026-10-08
15
+
16
+ - `web-dapp` refuses a headful launch while the macOS login session is locked (`SESSION_LOCKED`, with the unlock step), before starting, reusing or stopping a browser, as mm-harness does since its #363 (0.6.0 shipped `web-dapp` without it). `macosSessionLocked()`, `parseSessionLocked()`, `SESSION_LOCKED_MESSAGE` and `SESSION_LOCKED_USER_ACTION` are exported.
17
+ - Publish with adapter-sdk 0.7.1 and recipe-runner 0.27.0 so consumers share one adapter-sdk and one recipe-runner copy.
18
+
9
19
  ## 0.6.0 - 2026-10-08
10
20
 
11
21
  - Add `web-dapp` (`@farmslot/adapter-web/web-dapp`, an ESM subpath): `createWebDappAdapter({ id, signerModule, cli, hooks })` returns an `@farmslot/adapter-sdk` `PlatformAdapter` for a web app under test whose dev server the slot owns, moved from mm-harness's Web Terminal adapter. It runs a slot browser and wallet host with the injected strict wallet or a host-supplied extension signer, held to the testnet venue of the adapter that extends it. See the adapter-web reference for the signer module, venue policy and hooks.
package/README.md CHANGED
@@ -114,6 +114,7 @@ const binding = createWalletRequestBinding({
114
114
 
115
115
  - **Venue policy.** The app's venue (the hosts a testnet run blocks and serves, the typed data to refuse, the start page) comes from the adapter that `extends: 'web-dapp'`: its policy module goes to `RECIPE_WEB_DAPP_POLICY` through `bindWebDappPolicy(adapter)`. Bare web-dapp has no policy and refuses to launch. `fencePolicy` and `assertPolicyDigest` hold the module and what it imports to the files the plugin digest covers.
116
116
  - **Served network.** A policy that declares served hosts (`venueHosts().served`) has the wallet host wait for the app to request one of them, which proves the dev server serves testnet. A policy for an app whose page makes no venue request of its own (such as test-dapp-multichain, whose wallet traffic goes through the extension) opts out explicitly: `venueHosts()` returns `served: []` with `servedCheck: 'not-applicable'`. The check is then skipped, and `networkEnforcement.served` in `browser.json` and the `network-enforcement` entry of the wallet request log say `not-applicable`. An empty `served` without that opt-out keeps the check, which fails closed, so a policy that computes its served hosts can't lose the check by finding none. Listing served hosts and opting out together is refused. Mainnet blocking is enforced either way.
117
+ - **Locked session.** A headful launch refuses while the macOS login session is locked (`SESSION_LOCKED`), before starting, reusing or stopping a browser: macOS stops painting visible windows, so screenshots, video and UI actions would stall. `macosSessionLocked()` reads `ioreg` (3 s bound; `null` off macOS or when unknown). A headless launch never probes.
117
118
  - **Signers.** `signer=injected` needs nothing. `signer=extension` belongs to the host: a signer module exports `signers.extension` with `prepareProfile` (load the extension, seed the profile; returns `{ browserArgs, secrets, state }` and may carry an `afterBrowserStart` step; both steps get `trackSecret` for key-material files), `confirm` (required with `prepareProfile`: without it the wallet host would close the wallet's own windows) and `readinessChecks`. `signers.injected.identity` sets the EIP-6963 identity the injected strict wallet presents. The module path (`signerModule`, `--signer-module` or `RECIPE_WEB_DAPP_SIGNER_MODULE`) is the one source: readiness, launch, verify and the wallet host all load it, and `createWebDappAdapter` throws if given an in-process `signers` object. With no signer requested, the signer is `extension` when a module is configured and `injected` otherwise, so a host whose module exports only `injected` sets the signer explicitly (`--signer injected` or `TERMINAL_SIGNER=injected`).
118
119
  - **Hooks.** The action set, console capture, and network and performance observation are the host's; pass them as `hooks`. `diagnostics`, `readiness` and `harness` merge over the generic members.
119
120
  - **Leaves.** `webDappLeafPath('launch' | 'wallet-host' | 'inject' | 'verify' | 'stop' | 'cleanup')` names the CLI scripts.
@@ -116,3 +116,22 @@ export function homeTabsToClose(targets: unknown[], { extensionId, homePage, def
116
116
  id: string;
117
117
  url: string;
118
118
  }[];
119
+ /**
120
+ * GET a CDP HTTP path on 127.0.0.1. Returns the body, or null when the port or
121
+ * path is not one the launcher uses, or the request fails.
122
+ * @param {number} port
123
+ * @param {string} pathname `/json/list`, `/json/version` or `/json/close/<target id>`
124
+ * @returns {string | null}
125
+ */
126
+ export function cdpHttp(port: number, pathname: string): string | null;
127
+ /**
128
+ * Close the extra `homePage` tabs and stray blank tabs the browser on `port` has.
129
+ * @param {number} port CDP port on 127.0.0.1.
130
+ * @param {string} extensionDir The unpacked extension, for its id.
131
+ * @param {{ homePage?: string, defaultTitle?: string }} options
132
+ * @returns {string[]} What could not be done, one line each; empty when all went well.
133
+ */
134
+ export function pruneExtraHomeTabs(port: number, extensionDir: string, { homePage, defaultTitle }: {
135
+ homePage?: string;
136
+ defaultTitle?: string;
137
+ }): string[];
@@ -121,3 +121,4 @@ export { assertPolicyDigest, fencePolicy, POLICY_DIGEST_ENV, PolicyFenceError }
121
121
  export { cdpListenerPids, ownedBrowserPids, OWNER_FLAG, OWNER_KINDS, ownerMarker, ownsProcess, pidAlive, portFree, stopConsoleCollector, stopWebDappBrowser, terminate } from "./lib/processes.mjs";
122
122
  export { assertLaunchNetwork, DEV_SERVER_PID_FILE, devServerTestnetDiagnostic, pythonFor, webDappReadiness } from "./lib/readiness.mjs";
123
123
  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";
124
+ export { macosSessionLocked, parseSessionLocked, SESSION_LOCKED_MESSAGE, SESSION_LOCKED_USER_ACTION } from "./lib/session-lock.mjs";
@@ -40,13 +40,14 @@ export function writePrivateFile(file: any, text: any): void;
40
40
  /**
41
41
  * @param {Record<string, any>} args the parsed launch flags (see `parseLaunchArgs`)
42
42
  * @param {NodeJS.ProcessEnv} [env]
43
- * @param {{ timeouts?: { browserStartMs: number, hostReadyMs: number } }} [options]
43
+ * @param {{ timeouts?: { browserStartMs: number, hostReadyMs: number }, sessionLocked?: () => boolean | null }} [options]
44
44
  */
45
- export function launchWebDappBrowser(args: Record<string, any>, env?: NodeJS.ProcessEnv, { timeouts }?: {
45
+ export function launchWebDappBrowser(args: Record<string, any>, env?: NodeJS.ProcessEnv, { timeouts, sessionLocked }?: {
46
46
  timeouts?: {
47
47
  browserStartMs: number;
48
48
  hostReadyMs: number;
49
49
  };
50
+ sessionLocked?: () => boolean | null;
50
51
  }): Promise<any>;
51
52
  export const SPAWN: "spawn";
52
53
  export const LAUNCH_SERVICES: "launch-services";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `ioreg -n Root -d1 -a` output: the lock flag of the on-console session in
3
+ * IOConsoleUsers (any session's when none is on console); false for a plist
4
+ * without it, null for anything else.
5
+ * @param {string} output
6
+ * @returns {boolean | null}
7
+ */
8
+ export function parseSessionLocked(output: string): boolean | null;
9
+ /**
10
+ * true or false on macOS; null when unknown (another platform, or ioreg failed
11
+ * or took longer than 3 s). Never throws.
12
+ * @param {{ platform?: string, spawnSync?: typeof nodeSpawnSync }} [deps]
13
+ * @returns {boolean | null}
14
+ */
15
+ export function macosSessionLocked({ platform, spawnSync, }?: {
16
+ platform?: string;
17
+ spawnSync?: typeof nodeSpawnSync;
18
+ }): boolean | null;
19
+ export const SESSION_LOCKED_MESSAGE: "The macOS login session is locked, so the headful browser can't paint (screenshots, video and UI actions stall).";
20
+ export const SESSION_LOCKED_USER_ACTION: string;
21
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farmslot/adapter-web",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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": {
@@ -643,25 +643,39 @@ function extensionPageUrl(extensionDir, page) {
643
643
  return id ? `chrome-extension://${id}/${page}` : '';
644
644
  }
645
645
 
646
+ // The CDP HTTP paths the launcher reads: the target list, the version, or one
647
+ // target to close. CDP target ids are hex or UUIDs.
648
+ const CDP_HTTP_PATH = /^\/json\/(?:list|version|close\/[A-Za-z0-9-]+)$/u;
649
+ const CDP_TARGET_ID = /^[A-Za-z0-9-]+$/u;
650
+ // A child node keeps cdpHttp synchronous. The port and path reach it as argv,
651
+ // never as script source, so a path cannot change what it runs.
652
+ const CDP_HTTP_SCRIPT = `
653
+ const http = require('http');
654
+ const [port, pathname] = process.argv.slice(1);
655
+ http.get({ host: '127.0.0.1', port: Number(port), path: pathname }, (res) => {
656
+ let body = '';
657
+ res.on('data', (chunk) => { body += chunk; });
658
+ res.on('end', () => {
659
+ process.stdout.write(body);
660
+ process.exitCode = res.statusCode === 200 ? 0 : 1;
661
+ });
662
+ }).on('error', () => process.exit(1));
663
+ `;
664
+
665
+ /**
666
+ * GET a CDP HTTP path on 127.0.0.1. Returns the body, or null when the port or
667
+ * path is not one the launcher uses, or the request fails.
668
+ * @param {number} port
669
+ * @param {string} pathname `/json/list`, `/json/version` or `/json/close/<target id>`
670
+ * @returns {string | null}
671
+ */
646
672
  function cdpHttp(port, pathname) {
647
- const result = spawnSync(
648
- process.execPath,
649
- [
650
- '-e',
651
- `
652
- const http = require('http');
653
- http.get('http://127.0.0.1:${Number(port)}${pathname}', (res) => {
654
- let body = '';
655
- res.on('data', (chunk) => { body += chunk; });
656
- res.on('end', () => {
657
- process.stdout.write(body);
658
- process.exitCode = res.statusCode === 200 ? 0 : 1;
659
- });
660
- }).on('error', () => process.exit(1));
661
- `,
662
- ],
663
- { encoding: 'utf8', timeout: 4000 },
664
- );
673
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
674
+ if (typeof pathname !== 'string' || !CDP_HTTP_PATH.test(pathname)) return null;
675
+ const result = spawnSync(process.execPath, ['-e', CDP_HTTP_SCRIPT, String(port), pathname], {
676
+ encoding: 'utf8',
677
+ timeout: 4000,
678
+ });
665
679
  if (result.status !== 0) return null;
666
680
  return result.stdout;
667
681
  }
@@ -710,6 +724,13 @@ function homeTabsToClose(targets, { extensionId, homePage, defaultTitle }) {
710
724
  // readiness converges home tabs again, so a pruning failure must not fail the
711
725
  // launch. Every failure is returned so the launch log says why a duplicate tab
712
726
  // survived instead of hiding it.
727
+ /**
728
+ * Close the extra `homePage` tabs and stray blank tabs the browser on `port` has.
729
+ * @param {number} port CDP port on 127.0.0.1.
730
+ * @param {string} extensionDir The unpacked extension, for its id.
731
+ * @param {{ homePage?: string, defaultTitle?: string }} options
732
+ * @returns {string[]} What could not be done, one line each; empty when all went well.
733
+ */
713
734
  function pruneExtraHomeTabs(port, extensionDir, { homePage, defaultTitle }) {
714
735
  if (!homePage) return [];
715
736
  const extensionId = extensionIdFromExtensionDir(extensionDir);
@@ -725,10 +746,17 @@ function pruneExtraHomeTabs(port, extensionDir, { homePage, defaultTitle }) {
725
746
  if (!Array.isArray(targets)) return ['/json/list did not return a target list'];
726
747
  const problems = [];
727
748
  for (const target of homeTabsToClose(targets, { extensionId, homePage, defaultTitle })) {
728
- if (cdpHttp(port, `/json/close/${target.id}`) === null)
749
+ // The id comes from the endpoint's own response: close only a CDP-shaped one.
750
+ if (!CDP_TARGET_ID.test(target.id)) {
751
+ problems.push(
752
+ `skipped target with an invalid id: ${JSON.stringify(target.id)} (${target.url})`,
753
+ );
754
+ continue;
755
+ }
756
+ if (cdpHttp(port, `/json/close/${encodeURIComponent(target.id)}`) === null)
729
757
  problems.push(`could not close ${target.url} (${target.id})`);
730
758
  }
731
759
  return problems;
732
760
  }
733
761
 
734
- module.exports = { launchBrowser, homeTabsToClose };
762
+ module.exports = { launchBrowser, homeTabsToClose, cdpHttp, pruneExtraHomeTabs };
@@ -101,6 +101,12 @@ export {
101
101
  webDappRuntimePath,
102
102
  writePrivateJson,
103
103
  } from './lib/runtime.mjs';
104
+ export {
105
+ macosSessionLocked,
106
+ parseSessionLocked,
107
+ SESSION_LOCKED_MESSAGE,
108
+ SESSION_LOCKED_USER_ACTION,
109
+ } from './lib/session-lock.mjs';
104
110
 
105
111
  const LEAVES = Object.freeze(['launch', 'wallet-host', 'inject', 'verify', 'stop', 'cleanup']);
106
112
 
@@ -73,6 +73,11 @@ import {
73
73
  webDappPolicy,
74
74
  webDappRuntimeDir,
75
75
  } from './lib/runtime.mjs';
76
+ import {
77
+ macosSessionLocked,
78
+ SESSION_LOCKED_MESSAGE,
79
+ SESSION_LOCKED_USER_ACTION,
80
+ } from './lib/session-lock.mjs';
76
81
  import { defaultSigner, loadSigners, SIGNER_MODULE_ENV } from './lib/signers.mjs';
77
82
 
78
83
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -393,12 +398,12 @@ export const LAUNCH_TIMEOUTS = Object.freeze({ browserStartMs: 90000, hostReadyM
393
398
  /**
394
399
  * @param {Record<string, any>} args the parsed launch flags (see `parseLaunchArgs`)
395
400
  * @param {NodeJS.ProcessEnv} [env]
396
- * @param {{ timeouts?: { browserStartMs: number, hostReadyMs: number } }} [options]
401
+ * @param {{ timeouts?: { browserStartMs: number, hostReadyMs: number }, sessionLocked?: () => boolean | null }} [options]
397
402
  */
398
403
  export async function launchWebDappBrowser(
399
404
  args,
400
405
  env = process.env,
401
- { timeouts = LAUNCH_TIMEOUTS } = {},
406
+ { timeouts = LAUNCH_TIMEOUTS, sessionLocked = macosSessionLocked } = {},
402
407
  ) {
403
408
  const policy = webDappPolicy(env);
404
409
  const signerModule = args['signer-module'] ?? env[SIGNER_MODULE_ENV];
@@ -474,6 +479,13 @@ export async function launchWebDappBrowser(
474
479
  const appOrigin = `http://localhost:${appPort}`;
475
480
  const headless = resolveHeadless({ headless: args.headless, headful: args.headful, env });
476
481
  const slowMoMs = resolveSlowMo({ flag: args['slow-mo'], env });
482
+ // A headful browser can't paint while the macOS session is locked: refuse
483
+ // before starting, reusing or stopping one.
484
+ if (!headless && sessionLocked() === true) {
485
+ throw new Error(
486
+ `SESSION_LOCKED: ${SESSION_LOCKED_MESSAGE}\nNext: ${SESSION_LOCKED_USER_ACTION}`,
487
+ );
488
+ }
477
489
 
478
490
  const { fixture } = await loadWalletFixture(target, env);
479
491
  const accountAddress = viemAccountFromEntry(fixtureEntry(fixture, args.account), target).address;
@@ -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
+ }