@farmslot/adapter-web 0.6.1 → 0.6.3
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 +12 -0
- package/README.md +1 -1
- package/dist/launch-browser.d.cts +19 -0
- package/dist/web-dapp/lib/policy-fence.d.mts +11 -2
- package/package.json +4 -4
- package/src/launch-browser.cjs +149 -50
- package/src/web-dapp/lib/policy-fence.mjs +63 -4
- package/src/web-dapp/lib/policy.mjs +4 -3
- package/src/web-dapp/wallet-host.mjs +11 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,18 @@ 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.3 - 2026-10-09
|
|
10
|
+
|
|
11
|
+
- Fix: `launch-browser` stops waiting for its CDP listener once the browser it started has exited (`Chrome launched but did not expose an owned CDP listener on 127.0.0.1:<port>: the browser exited`), or once a process it did not launch holds the port (the same `Refusing to launch on CDP port <port>` refusal the pre-launch check gives, naming that pid). Either must hold for about a second, and a browser started through `open` counts as exited only once it has been seen, or after 5 s. The wait is now 30 s of wall-clock time; before, it was 300 polls: 30 s on an idle host, minutes on a loaded one. After the foreign refusal, once the browser this launch saw start is stopped, the launch markers are cleared, so a rerun on a free port is not refused as quarantined; if that browser was never seen, the markers stay and the refusal names the `rm` that clears them. An exit or a timeout keeps them, as before. A `ps` or `lsof` failure mid-wait now stops the browser before it is reported; if that stop fails too, one error names both failures and the pid to stop, and the markers are kept. Wait progress reports `seconds`, not `attempts`.
|
|
12
|
+
- `web-dapp`'s testnet launch probes build their mainnet URLs in node and pass them into the page as JSON strings, so a venue policy's probe `httpPath` or `wsPath` containing a quote reads as blocked or reached instead of failing the launch with a page `SyntaxError`.
|
|
13
|
+
- Fix: every process that loads the `web-dapp` venue policy fences its imports at run time. A `module.registerHooks` resolve hook refuses (`ADAPTER_PLUGIN_INVALID`) any import resolved from one of the policy's digested files (static, dynamic, or a `require` created for it) that lands outside them, at load or later while the process runs. Before, the host's source scan was the only check, so a leaf could load an outside file through an aliased `createRequire` (or another import the scan can't see) while the policy and plugin digests stayed unchanged. Loading the policy now needs Node.js 22.15 or later (`engines` says so). It fences imports, not code.
|
|
14
|
+
- Publish with adapter-sdk 0.7.2 and recipe-runner 0.27.1 so consumers share one adapter-sdk and one recipe-runner copy.
|
|
15
|
+
|
|
16
|
+
## 0.6.2 - 2026-10-08
|
|
17
|
+
|
|
18
|
+
- 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))
|
|
19
|
+
- Publish with adapter-sdk 0.7.1 and recipe-runner 0.27.0 so consumers share one adapter-sdk and one recipe-runner copy.
|
|
20
|
+
|
|
9
21
|
## 0.6.1 - 2026-10-08
|
|
10
22
|
|
|
11
23
|
- `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.
|
package/README.md
CHANGED
|
@@ -112,7 +112,7 @@ const binding = createWalletRequestBinding({
|
|
|
112
112
|
|
|
113
113
|
`@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` `PlatformAdapter`: 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`).
|
|
114
114
|
|
|
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.
|
|
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, and every process that loads the policy refuses, with a `module.registerHooks` resolve hook, any import resolved from one of those files (static, dynamic, or a `require` created for it) that lands outside them (Node.js 22.15 or later).
|
|
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
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.
|
|
118
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`).
|
|
@@ -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[];
|
|
@@ -8,8 +8,17 @@ export function fencePolicy({ file, covered, scope }: {
|
|
|
8
8
|
covered: any;
|
|
9
9
|
scope: any;
|
|
10
10
|
}): string;
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Refuse a policy whose files changed since mm-harness bound it (`digest`).
|
|
13
|
+
* Returns the real paths of the files it digested.
|
|
14
|
+
*/
|
|
15
|
+
export function assertPolicyDigest(file: any, digest: any): any[];
|
|
16
|
+
/**
|
|
17
|
+
* Before a process loads the policy at `file`: check its files against
|
|
18
|
+
* `digest` when the host bound one, and refuse, from then on, any import from
|
|
19
|
+
* those files that resolves outside them.
|
|
20
|
+
*/
|
|
21
|
+
export function fencePolicyImports(file: any, digest: any): void;
|
|
13
22
|
export const POLICY_DIGEST_ENV: "RECIPE_WEB_DAPP_POLICY_DIGEST";
|
|
14
23
|
export class PolicyFenceError extends Error {
|
|
15
24
|
constructor(message: any);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farmslot/adapter-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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": {
|
|
@@ -83,8 +83,8 @@
|
|
|
83
83
|
"prepublishOnly": "node ../../scripts/quality/check-farmslot-package-readiness.mjs --publish --packages @farmslot/adapter-web"
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"@farmslot/adapter-sdk": "0.7.
|
|
87
|
-
"@farmslot/recipe-runner": "0.27.
|
|
86
|
+
"@farmslot/adapter-sdk": "0.7.2",
|
|
87
|
+
"@farmslot/recipe-runner": "0.27.1",
|
|
88
88
|
"ws": "^8.18.0"
|
|
89
89
|
},
|
|
90
90
|
"devDependencies": {
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"agentic-engineering"
|
|
114
114
|
],
|
|
115
115
|
"engines": {
|
|
116
|
-
"node": ">=22.
|
|
116
|
+
"node": ">=22.15.0"
|
|
117
117
|
},
|
|
118
118
|
"files": [
|
|
119
119
|
"README.md",
|
package/src/launch-browser.cjs
CHANGED
|
@@ -33,6 +33,7 @@ const {
|
|
|
33
33
|
clearDetachedLaunchUnproven,
|
|
34
34
|
clearValidationPortQuarantine,
|
|
35
35
|
createRuntimeIdentityNonce,
|
|
36
|
+
detachedLaunchUnprovenPath,
|
|
36
37
|
hasDetachedLaunchUnproven,
|
|
37
38
|
isolatedProfileArgs,
|
|
38
39
|
markDetachedLaunchUnproven,
|
|
@@ -41,7 +42,9 @@ const {
|
|
|
41
42
|
remoteDebuggingArgs,
|
|
42
43
|
removeRuntimeIdentity,
|
|
43
44
|
runtimeIdentityArgs,
|
|
45
|
+
shellQuote,
|
|
44
46
|
validationLaunchQuarantineError,
|
|
47
|
+
validationPortQuarantinePath,
|
|
45
48
|
writeRuntimeIdentity,
|
|
46
49
|
} = require('./chrome-args.cjs');
|
|
47
50
|
const { extensionIdFromExtensionDir } = require('./extension-id.cjs');
|
|
@@ -159,12 +162,7 @@ function runLaunch(opts, acquireLock) {
|
|
|
159
162
|
// Chrome), so we refuse rather than kill it or attach to it.
|
|
160
163
|
const listenerPids = cdpListenerPids(cdpPort);
|
|
161
164
|
const foreignPids = listenerPids.filter((pid) => !ownedBrowser(pid));
|
|
162
|
-
if (foreignPids.length > 0)
|
|
163
|
-
throw new Error(
|
|
164
|
-
`Refusing to launch on CDP port ${cdpPort}: it is held by a browser this harness did not launch (pid ${foreignPids.join(', ')}). ` +
|
|
165
|
-
`Next: pick a free --cdp-port, or stop that browser.`,
|
|
166
|
-
);
|
|
167
|
-
}
|
|
165
|
+
if (foreignPids.length > 0) throw foreignPortError(cdpPort, foreignPids);
|
|
168
166
|
const ownedPids = new Set(listenerPids);
|
|
169
167
|
for (const pid of profileProcessPids(profile)) ownedPids.add(pid);
|
|
170
168
|
const previousPid = readPidFile(opts.chromePid);
|
|
@@ -246,11 +244,57 @@ function runLaunch(opts, acquireLock) {
|
|
|
246
244
|
process.platform === 'darwin' ? macApplicationForExecutable(opts.chromeBin) : null;
|
|
247
245
|
if (application) chromeArgs.push('--no-startup-window');
|
|
248
246
|
else chromeArgs.push(loadsOverCdp ? 'about:blank' : initialUrl);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
247
|
+
// The wait ended without an owned listener: a foreign process took the port,
|
|
248
|
+
// the browser exited, or it never listened in time.
|
|
249
|
+
const ownedListenerMissing = (waited) => {
|
|
250
|
+
if (waited.foreignPids && waited.seen) return foreignPortError(cdpPort, waited.foreignPids);
|
|
251
|
+
if (waited.foreignPids) {
|
|
252
|
+
// This launch's browser was never seen, so its markers stay (see
|
|
253
|
+
// awaitOwnedListener): a free port alone would be refused as quarantined.
|
|
254
|
+
const markers = [detachedLaunchUnprovenPath(profile)];
|
|
255
|
+
if (!activeValidationLease) markers.push(validationPortQuarantinePath(cdpPort));
|
|
256
|
+
return foreignPortError(
|
|
257
|
+
cdpPort,
|
|
258
|
+
waited.foreignPids,
|
|
259
|
+
`this launch's browser never showed up, so its launch state stays quarantined. ` +
|
|
260
|
+
`Confirm no browser uses ${profile}, run: rm -- ${markers.map(shellQuote).join(' ')}, ` +
|
|
261
|
+
`then pick a free --cdp-port, or stop that browser.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const exited = waited.exited ? ': the browser exited' : '';
|
|
265
|
+
return new Error(
|
|
266
|
+
`Chrome launched but did not expose an owned CDP listener on 127.0.0.1:${cdpPort}${exited}. ` +
|
|
252
267
|
`Next: inspect ${opts.chromeLog}, then ${rerun}`,
|
|
253
268
|
);
|
|
269
|
+
};
|
|
270
|
+
// Any outcome but an owned listener stops what this launch started, a ps or
|
|
271
|
+
// lsof failure mid-wait included. The launch markers stay until ownership is
|
|
272
|
+
// proven, except when a foreign process took the port from a browser this
|
|
273
|
+
// launch saw start and has now stopped: then nothing is left to quarantine,
|
|
274
|
+
// and the refusal's hint (a free port) works as given.
|
|
275
|
+
const awaitOwnedListener = (stopOptions, firstSightingMs) => {
|
|
276
|
+
let waited;
|
|
277
|
+
try {
|
|
278
|
+
waited = waitForOwnedCdpPid(cdpPort, ownedBrowser, profile, opts.progress, firstSightingMs);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
try {
|
|
281
|
+
stopProfileProcessesSync(profile, stopOptions);
|
|
282
|
+
} catch (stopError) {
|
|
283
|
+
const pids = stopOptions.extraPids ?? [];
|
|
284
|
+
const target = pids.length > 0 ? `pid ${pids.join(', ')}` : `the browser using ${profile}`;
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Waiting for the CDP listener failed (${error.message}), and stopping the browser failed: ${stopError.message}. ` +
|
|
287
|
+
`The launch markers for port ${cdpPort} and ${profile} are kept. Next: stop ${target}, then ${rerun}`,
|
|
288
|
+
{ cause: error },
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
if (waited.pid !== undefined) return waited.pid;
|
|
294
|
+
stopProfileProcessesSync(profile, stopOptions);
|
|
295
|
+
if (waited.foreignPids && waited.seen) clearLaunchMarkers();
|
|
296
|
+
throw ownedListenerMissing(waited);
|
|
297
|
+
};
|
|
254
298
|
const logFd = fs.openSync(opts.chromeLog, 'a');
|
|
255
299
|
const previousFrontmost = captureMacFrontmost();
|
|
256
300
|
let browserPid;
|
|
@@ -263,11 +307,8 @@ function runLaunch(opts, acquireLock) {
|
|
|
263
307
|
env: sanitizedChildEnv(),
|
|
264
308
|
stdio: ['ignore', logFd, logFd],
|
|
265
309
|
});
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
stopProfileProcessesSync(profile, { waitForAppearanceMs: 400 });
|
|
269
|
-
throw ownedListenerMissing();
|
|
270
|
-
}
|
|
310
|
+
// `open` can return before the browser shows up in ps.
|
|
311
|
+
browserPid = awaitOwnedListener({ waitForAppearanceMs: 400 }, 5000);
|
|
271
312
|
} else {
|
|
272
313
|
const child = spawn(opts.chromeBin, chromeArgs, {
|
|
273
314
|
detached: true,
|
|
@@ -275,11 +316,8 @@ function runLaunch(opts, acquireLock) {
|
|
|
275
316
|
stdio: ['ignore', logFd, logFd],
|
|
276
317
|
});
|
|
277
318
|
child.unref();
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
stopProfileProcessesSync(profile, { extraPids: child.pid ? [child.pid] : [] });
|
|
281
|
-
throw ownedListenerMissing();
|
|
282
|
-
}
|
|
319
|
+
// spawn returns after exec, so the browser is in ps at once.
|
|
320
|
+
browserPid = awaitOwnedListener({ extraPids: child.pid ? [child.pid] : [] }, 0);
|
|
283
321
|
}
|
|
284
322
|
if (loadsOverCdp)
|
|
285
323
|
cdpLoad = loadExtensionOverCdp(opts, browserPid, initialUrl, clearLaunchMarkers);
|
|
@@ -501,23 +539,56 @@ function macApplicationForExecutable(executable) {
|
|
|
501
539
|
return null;
|
|
502
540
|
}
|
|
503
541
|
|
|
504
|
-
function
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
542
|
+
function foreignPortError(port, pids, next = 'pick a free --cdp-port, or stop that browser.') {
|
|
543
|
+
return new Error(
|
|
544
|
+
`Refusing to launch on CDP port ${port}: it is held by a browser this harness did not launch (pid ${pids.join(', ')}). ` +
|
|
545
|
+
`Next: ${next}`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Polls for the launched browser's own CDP listener for up to 30 s: `{ pid }`
|
|
550
|
+
// once it appears, `{}` when the time is up. Stops early with `{ foreignPids }`
|
|
551
|
+
// once only processes it did not launch hold the port (the browser can no
|
|
552
|
+
// longer bind it), or with `{ exited: true }` once no process holds the
|
|
553
|
+
// profile. Either must hold for a second, so a brief ps/lsof gap is not taken
|
|
554
|
+
// for a failure. "Exited" counts only once a profile process has been seen, or
|
|
555
|
+
// after `firstSightingMs` with none. `seen` says whether one was.
|
|
556
|
+
function waitForOwnedCdpPid(port, ownedBrowser, profile, progress, firstSightingMs) {
|
|
557
|
+
const budgetMs = 30_000;
|
|
558
|
+
const confirmMs = 1000;
|
|
559
|
+
const startedAt = Date.now();
|
|
560
|
+
let seen = false;
|
|
561
|
+
let exitedSince = null;
|
|
562
|
+
let foreignSince = null;
|
|
563
|
+
let reportedAt = startedAt;
|
|
564
|
+
while (Date.now() - startedAt < budgetMs) {
|
|
565
|
+
const listeners = cdpListenerPids(port);
|
|
566
|
+
const pid = listeners.find(ownedBrowser);
|
|
567
|
+
if (pid !== undefined) return { pid };
|
|
568
|
+
const now = Date.now();
|
|
569
|
+
if (profileProcessPids(profile).length > 0) {
|
|
570
|
+
seen = true;
|
|
571
|
+
exitedSince = null;
|
|
572
|
+
} else if (seen || now - startedAt >= firstSightingMs) {
|
|
573
|
+
exitedSince ??= now;
|
|
574
|
+
}
|
|
575
|
+
if (exitedSince !== null && now - exitedSince >= confirmMs) return { exited: true, seen };
|
|
576
|
+
foreignSince = listeners.length > 0 ? (foreignSince ?? now) : null;
|
|
577
|
+
if (foreignSince !== null && now - foreignSince >= confirmMs)
|
|
578
|
+
return { foreignPids: listeners, seen };
|
|
579
|
+
// About every 5 s: enough to show the wait is moving.
|
|
580
|
+
if (now - reportedAt >= 5000) {
|
|
581
|
+
reportedAt = now;
|
|
511
582
|
progress({
|
|
512
583
|
waitingFor: `an owned CDP listener on 127.0.0.1:${port}`,
|
|
513
|
-
current:
|
|
514
|
-
total:
|
|
515
|
-
unit: '
|
|
584
|
+
current: Math.round((now - startedAt) / 1000),
|
|
585
|
+
total: budgetMs / 1000,
|
|
586
|
+
unit: 'seconds',
|
|
516
587
|
});
|
|
517
588
|
}
|
|
518
589
|
spawnSync('sleep', ['0.1']);
|
|
519
590
|
}
|
|
520
|
-
return
|
|
591
|
+
return { seen };
|
|
521
592
|
}
|
|
522
593
|
|
|
523
594
|
function assertIsolatedProfile(profile) {
|
|
@@ -643,25 +714,39 @@ function extensionPageUrl(extensionDir, page) {
|
|
|
643
714
|
return id ? `chrome-extension://${id}/${page}` : '';
|
|
644
715
|
}
|
|
645
716
|
|
|
717
|
+
// The CDP HTTP paths the launcher reads: the target list, the version, or one
|
|
718
|
+
// target to close. CDP target ids are hex or UUIDs.
|
|
719
|
+
const CDP_HTTP_PATH = /^\/json\/(?:list|version|close\/[A-Za-z0-9-]+)$/u;
|
|
720
|
+
const CDP_TARGET_ID = /^[A-Za-z0-9-]+$/u;
|
|
721
|
+
// A child node keeps cdpHttp synchronous. The port and path reach it as argv,
|
|
722
|
+
// never as script source, so a path cannot change what it runs.
|
|
723
|
+
const CDP_HTTP_SCRIPT = `
|
|
724
|
+
const http = require('http');
|
|
725
|
+
const [port, pathname] = process.argv.slice(1);
|
|
726
|
+
http.get({ host: '127.0.0.1', port: Number(port), path: pathname }, (res) => {
|
|
727
|
+
let body = '';
|
|
728
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
729
|
+
res.on('end', () => {
|
|
730
|
+
process.stdout.write(body);
|
|
731
|
+
process.exitCode = res.statusCode === 200 ? 0 : 1;
|
|
732
|
+
});
|
|
733
|
+
}).on('error', () => process.exit(1));
|
|
734
|
+
`;
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* GET a CDP HTTP path on 127.0.0.1. Returns the body, or null when the port or
|
|
738
|
+
* path is not one the launcher uses, or the request fails.
|
|
739
|
+
* @param {number} port
|
|
740
|
+
* @param {string} pathname `/json/list`, `/json/version` or `/json/close/<target id>`
|
|
741
|
+
* @returns {string | null}
|
|
742
|
+
*/
|
|
646
743
|
function cdpHttp(port, pathname) {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
);
|
|
744
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
745
|
+
if (typeof pathname !== 'string' || !CDP_HTTP_PATH.test(pathname)) return null;
|
|
746
|
+
const result = spawnSync(process.execPath, ['-e', CDP_HTTP_SCRIPT, String(port), pathname], {
|
|
747
|
+
encoding: 'utf8',
|
|
748
|
+
timeout: 4000,
|
|
749
|
+
});
|
|
665
750
|
if (result.status !== 0) return null;
|
|
666
751
|
return result.stdout;
|
|
667
752
|
}
|
|
@@ -710,6 +795,13 @@ function homeTabsToClose(targets, { extensionId, homePage, defaultTitle }) {
|
|
|
710
795
|
// readiness converges home tabs again, so a pruning failure must not fail the
|
|
711
796
|
// launch. Every failure is returned so the launch log says why a duplicate tab
|
|
712
797
|
// survived instead of hiding it.
|
|
798
|
+
/**
|
|
799
|
+
* Close the extra `homePage` tabs and stray blank tabs the browser on `port` has.
|
|
800
|
+
* @param {number} port CDP port on 127.0.0.1.
|
|
801
|
+
* @param {string} extensionDir The unpacked extension, for its id.
|
|
802
|
+
* @param {{ homePage?: string, defaultTitle?: string }} options
|
|
803
|
+
* @returns {string[]} What could not be done, one line each; empty when all went well.
|
|
804
|
+
*/
|
|
713
805
|
function pruneExtraHomeTabs(port, extensionDir, { homePage, defaultTitle }) {
|
|
714
806
|
if (!homePage) return [];
|
|
715
807
|
const extensionId = extensionIdFromExtensionDir(extensionDir);
|
|
@@ -725,10 +817,17 @@ function pruneExtraHomeTabs(port, extensionDir, { homePage, defaultTitle }) {
|
|
|
725
817
|
if (!Array.isArray(targets)) return ['/json/list did not return a target list'];
|
|
726
818
|
const problems = [];
|
|
727
819
|
for (const target of homeTabsToClose(targets, { extensionId, homePage, defaultTitle })) {
|
|
728
|
-
|
|
820
|
+
// The id comes from the endpoint's own response: close only a CDP-shaped one.
|
|
821
|
+
if (!CDP_TARGET_ID.test(target.id)) {
|
|
822
|
+
problems.push(
|
|
823
|
+
`skipped target with an invalid id: ${JSON.stringify(target.id)} (${target.url})`,
|
|
824
|
+
);
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
if (cdpHttp(port, `/json/close/${encodeURIComponent(target.id)}`) === null)
|
|
729
828
|
problems.push(`could not close ${target.url} (${target.id})`);
|
|
730
829
|
}
|
|
731
830
|
return problems;
|
|
732
831
|
}
|
|
733
832
|
|
|
734
|
-
module.exports = { launchBrowser, homeTabsToClose };
|
|
833
|
+
module.exports = { launchBrowser, homeTabsToClose, cdpHttp, pruneExtraHomeTabs };
|
|
@@ -6,11 +6,23 @@
|
|
|
6
6
|
// paths or node built-ins. The digest of those files goes in
|
|
7
7
|
// RECIPE_WEB_DAPP_POLICY_DIGEST, and a process that loads the policy with it
|
|
8
8
|
// set refuses files that changed since.
|
|
9
|
+
//
|
|
10
|
+
// The source scan only lists the files to digest; it can miss an import (an
|
|
11
|
+
// aliased require, say, in a branch only a leaf takes). So a process that loads
|
|
12
|
+
// the policy also fences its imports at run time, as the plugin loader does: a
|
|
13
|
+
// resolve hook refuses any import resolved from those files that lands outside
|
|
14
|
+
// them, while the process runs. It fences imports, not code: it is no sandbox
|
|
15
|
+
// for code the plugin digest already covers. It goes by the importing file, as
|
|
16
|
+
// the plugin loader does, so a require created for another path, Module
|
|
17
|
+
// internals, or a CommonJS request Node answers from its cache without
|
|
18
|
+
// resolving again pass it; and a process that loads two policies lets a file
|
|
19
|
+
// both digests cover import the files of either.
|
|
9
20
|
|
|
10
21
|
import { createHash } from 'node:crypto';
|
|
11
22
|
import fs from 'node:fs';
|
|
12
|
-
import { isBuiltin } from 'node:module';
|
|
23
|
+
import nodeModule, { isBuiltin } from 'node:module';
|
|
13
24
|
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
14
26
|
|
|
15
27
|
export const POLICY_DIGEST_ENV = 'RECIPE_WEB_DAPP_POLICY_DIGEST';
|
|
16
28
|
|
|
@@ -116,13 +128,60 @@ export function fencePolicy({ file, covered, scope }) {
|
|
|
116
128
|
return digestFiles(policyClosure(file, allowed));
|
|
117
129
|
}
|
|
118
130
|
|
|
119
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Refuse a policy whose files changed since mm-harness bound it (`digest`).
|
|
133
|
+
* Returns the real paths of the files it digested.
|
|
134
|
+
*/
|
|
120
135
|
export function assertPolicyDigest(file, digest) {
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
136
|
+
const files = policyClosure(file, () => {});
|
|
137
|
+
if (digestFiles(files) !== digest) {
|
|
123
138
|
throw new Error(
|
|
124
139
|
`web-dapp venue policy ${file} or a file it imports changed since the host bound it.\n` +
|
|
125
140
|
'Next: rerun the command, so it binds and checks the policy again.',
|
|
126
141
|
);
|
|
127
142
|
}
|
|
143
|
+
return files;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The real paths of the files of each policy this process loaded.
|
|
147
|
+
const fenced = [];
|
|
148
|
+
let hookInstalled = false;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Before a process loads the policy at `file`: check its files against
|
|
152
|
+
* `digest` when the host bound one, and refuse, from then on, any import from
|
|
153
|
+
* those files that resolves outside them.
|
|
154
|
+
*/
|
|
155
|
+
export function fencePolicyImports(file, digest) {
|
|
156
|
+
if (typeof nodeModule.registerHooks !== 'function') {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`web-dapp needs Node.js 22.15 or later (module.registerHooks fences the venue policy's imports); this is ${process.version}.`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
fenced.push(new Set(digest ? assertPolicyDigest(file, digest) : policyClosure(file, () => {})));
|
|
162
|
+
if (hookInstalled) return;
|
|
163
|
+
nodeModule.registerHooks({
|
|
164
|
+
resolve(specifier, context, nextResolve) {
|
|
165
|
+
const parent = realPath(context.parentURL);
|
|
166
|
+
const owners = parent === undefined ? [] : fenced.filter((files) => files.has(parent));
|
|
167
|
+
if (owners.length === 0 || isBuiltin(specifier)) return nextResolve(specifier, context);
|
|
168
|
+
const result = nextResolve(specifier, context);
|
|
169
|
+
const target = realPath(result.url);
|
|
170
|
+
if (target !== undefined && owners.some((files) => files.has(target))) return result;
|
|
171
|
+
throw new PolicyFenceError(
|
|
172
|
+
`web-dapp venue policy file ${parent} imports '${specifier}', which resolves to ${target ?? result.url}: outside the policy files the host digested.`,
|
|
173
|
+
);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
hookInstalled = true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The real path behind a file: URL; undefined for another scheme or a missing file.
|
|
180
|
+
function realPath(url) {
|
|
181
|
+
if (!url?.startsWith('file:')) return undefined;
|
|
182
|
+
try {
|
|
183
|
+
return fs.realpathSync(fileURLToPath(url));
|
|
184
|
+
} catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
128
187
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { fencePolicyImports, POLICY_DIGEST_ENV } from './policy-fence.mjs';
|
|
11
11
|
|
|
12
12
|
export const POLICY_ENV = 'RECIPE_WEB_DAPP_POLICY';
|
|
13
13
|
// The policy contract this web-dapp reads; a policy declares the version it was written for.
|
|
@@ -90,8 +90,9 @@ export function webDappPolicy(env = process.env) {
|
|
|
90
90
|
}
|
|
91
91
|
const digest = env[POLICY_DIGEST_ENV];
|
|
92
92
|
if (loaded?.file !== file || loaded.digest !== digest) {
|
|
93
|
-
// The digest mm-harness took when it bound the policy: refuse files that changed since
|
|
94
|
-
|
|
93
|
+
// The digest mm-harness took when it bound the policy: refuse files that changed since,
|
|
94
|
+
// and imports from them that leave them.
|
|
95
|
+
fencePolicyImports(file, digest);
|
|
95
96
|
// The policy module uses node built-ins only and has no top-level await, so it loads synchronously.
|
|
96
97
|
loaded = {
|
|
97
98
|
file,
|
|
@@ -636,16 +636,24 @@ if (network === 'testnet') {
|
|
|
636
636
|
// resolver rule, WebSocket through the resolver rule).
|
|
637
637
|
const { probe } = policy;
|
|
638
638
|
const host = mainnetHosts.includes(probe.host) ? probe.host : mainnetHosts[0];
|
|
639
|
+
// The probe URLs are built here and spliced in as JSON strings, so the
|
|
640
|
+
// policy's probe paths cannot change the page expression. The host is the
|
|
641
|
+
// policy's probe host only when --mainnet-hosts lists it.
|
|
642
|
+
const httpUrl = JSON.stringify(`https://${host}${probe.httpPath}?${PROBE_MARK}`);
|
|
643
|
+
const resolverUrl = JSON.stringify(
|
|
644
|
+
`https://${host}:${RESOLVER_PROBE_PORT}${probe.httpPath}?${PROBE_MARK}`,
|
|
645
|
+
);
|
|
646
|
+
const wsUrl = JSON.stringify(`wss://${host}${probe.wsPath}?${PROBE_MARK}`);
|
|
639
647
|
const httpProbe = await evaluate(
|
|
640
|
-
`fetch(
|
|
648
|
+
`fetch(${httpUrl}, { method: 'POST', headers: { 'content-type': 'application/json' }, body: ${JSON.stringify(probe.httpBody)} }).then(() => 'reached', () => 'blocked')`,
|
|
641
649
|
20000,
|
|
642
650
|
);
|
|
643
651
|
const resolverProbe = await evaluate(
|
|
644
|
-
`fetch(
|
|
652
|
+
`fetch(${resolverUrl}, { method: 'POST' }).then(() => 'reached', () => 'blocked')`,
|
|
645
653
|
20000,
|
|
646
654
|
);
|
|
647
655
|
const wsProbe = await evaluate(
|
|
648
|
-
`new Promise((resolve) => { const socket = new WebSocket(
|
|
656
|
+
`new Promise((resolve) => { const socket = new WebSocket(${wsUrl}); const done = (value) => { try { socket.close(); } catch {} resolve(value); }; socket.onopen = () => done('reached'); socket.onerror = () => done('blocked'); setTimeout(() => done('timeout'), 10000); })`,
|
|
649
657
|
20000,
|
|
650
658
|
);
|
|
651
659
|
if (httpProbe !== 'blocked' || resolverProbe !== 'blocked' || wsProbe !== 'blocked') {
|