@deeeed/metamask-harness 0.28.0 → 0.29.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 +19 -0
- package/README.md +41 -0
- package/adapters/extension/build-lavamoat.sh +2 -1
- package/adapters/extension/ensure-browser.sh +82 -9
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/launch-browser.cjs +83 -1
- package/adapters/extension/lib/chrome-args.cjs +325 -1
- package/adapters/extension/lib/playwright-cdp.cjs +34 -0
- package/adapters/extension/lib/slot-title.cjs +2 -4
- package/adapters/extension/lib/validation-launch-supervisor.cjs +292 -0
- package/adapters/extension/lib/validation-process-ownership.cjs +69 -0
- package/adapters/extension/reattach.sh +2 -1
- package/adapters/extension/sidepanel-toggle.sh +14 -96
- package/adapters/extension/wallet-fixture-state.cjs +8 -31
- package/adapters/manifest.json +16 -0
- package/adapters/shared/private-atomic-write.cjs +47 -0
- package/adapters/shared/setup-base.sh +864 -0
- package/dist/adapters/extension/runtime.js +367 -24
- package/dist/adapters/extension/validation-process-ownership.js +10 -0
- package/dist/cli-commands.js +1 -0
- package/dist/command-contract.js +12 -0
- package/dist/commands/launch/extension.js +130 -19
- package/dist/commands/setup-base.js +24 -0
- package/dist/mm-harness-cli.js +28 -2
- package/library/actions/extension/analytics/consent.mjs +203 -0
- package/library/actions/extension/analytics/set_consent.mjs +19 -143
- package/library/actions/extension/perps/perps.mjs +2 -16
- package/library/actions/extension/perps/state.mjs +20 -0
- package/library/actions/extension/wallet/list_accounts.mjs +3 -25
- package/library/actions/extension/wallet/read_state.mjs +3 -23
- package/library/actions/extension/wallet/select_account.mjs +6 -33
- package/library/actions/extension/wallet/setup.mjs +2 -20
- package/library/actions/extension/wallet/state.mjs +111 -0
- package/library/recipes/runner/action-validation.extension.recipe.json +1 -1
- package/library/recipes/runner/action-validation.mobile.recipe.json +1 -1
- package/package.json +7 -4
- package/scripts/site-contrast.mjs +538 -0
- package/site/architecture.html +415 -0
- package/site/assets/progress.mjs +272 -0
- package/site/assets/style.css +808 -0
- package/site/cheatsheet.html +305 -0
- package/site/index.html +643 -0
- package/site/recipes.html +396 -0
- package/site/reviewers.html +374 -0
- package/site/tutorials/index.html +180 -0
- package/site/tutorials/v1.html +211 -0
- package/site/tutorials/v2.html +207 -0
- package/site/tutorials/v3.html +214 -0
- package/site/tutorials/v4.html +195 -0
- package/site/tutorials/v5.html +163 -0
- package/site/tutorials/v6.html +165 -0
- package/site/tutorials/v7.html +184 -0
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, recipeRuntimeDir, resolveLocalProtocolRoot,
|
|
5
|
+
import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, recipeHarnessPath, recipeRuntimeDir, resolveLocalProtocolRoot, runnerDir } from "../../paths.js";
|
|
6
|
+
import { profileProcessPids } from "./validation-process-ownership.js";
|
|
7
|
+
const require2 = createRequire(import.meta.url);
|
|
8
|
+
const { detachedLaunchUnprovenPath, hasDetachedLaunchUnproven, shellQuote } = require2(
|
|
9
|
+
"../../../adapters/extension/lib/chrome-args.cjs"
|
|
10
|
+
);
|
|
5
11
|
const {
|
|
6
12
|
CdpSession,
|
|
7
13
|
jsonGet,
|
|
@@ -24,6 +30,7 @@ async function prepareExtensionRuntime(options) {
|
|
|
24
30
|
projectRoot,
|
|
25
31
|
cdpPort,
|
|
26
32
|
slotId: slot.id,
|
|
33
|
+
launchTimeoutMs: options.validationLaunchTimeoutMs ?? 18e4,
|
|
27
34
|
validationRuntimeDir: options.validationRuntimeDir ?? `${recipeRuntimeDir()}/validation-${cdpPort}`
|
|
28
35
|
});
|
|
29
36
|
}
|
|
@@ -323,27 +330,115 @@ function resolveExtensionSlot(projectRoot, requestedSlot) {
|
|
|
323
330
|
return null;
|
|
324
331
|
}
|
|
325
332
|
async function launchHostValidationBrowser(options) {
|
|
326
|
-
const
|
|
327
|
-
const script = path.join(protocolRoot, "projects/metamask-extension-farm/setup/launch-validation-browser.sh");
|
|
333
|
+
const script = recipeHarnessPath(options.projectRoot, "extension", "scripts", "live.sh");
|
|
328
334
|
if (!fs.existsSync(script)) {
|
|
329
335
|
throw new Error(`Extension validation launcher not found: ${script}`);
|
|
330
336
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
337
|
+
const validationRuntimeBaseDir = path.relative(
|
|
338
|
+
options.projectRoot,
|
|
339
|
+
path.resolve(options.projectRoot, options.validationRuntimeDir)
|
|
340
|
+
);
|
|
341
|
+
const escapesCheckout = !validationRuntimeBaseDir || validationRuntimeBaseDir === ".." || validationRuntimeBaseDir.startsWith(`..${path.sep}`) || path.isAbsolute(validationRuntimeBaseDir);
|
|
342
|
+
if (escapesCheckout) {
|
|
343
|
+
throw new Error(`Extension validation runtime must stay inside the target checkout: ${options.validationRuntimeDir}`);
|
|
344
|
+
}
|
|
345
|
+
const validationRuntimeBasePath = path.join(options.projectRoot, validationRuntimeBaseDir);
|
|
346
|
+
let currentPath = options.projectRoot;
|
|
347
|
+
for (const segment of validationRuntimeBaseDir.split(path.sep)) {
|
|
348
|
+
currentPath = path.join(currentPath, segment);
|
|
349
|
+
const currentStat = fs.lstatSync(currentPath, { throwIfNoEntry: false });
|
|
350
|
+
if (!currentStat) break;
|
|
351
|
+
if (currentStat.isSymbolicLink()) {
|
|
352
|
+
throw new Error(`Extension validation runtime must stay inside the target checkout: ${options.validationRuntimeDir}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
fs.mkdirSync(validationRuntimeBasePath, { recursive: true });
|
|
356
|
+
const realRuntimeBaseDir = path.relative(
|
|
357
|
+
fs.realpathSync(options.projectRoot),
|
|
358
|
+
fs.realpathSync(validationRuntimeBasePath)
|
|
359
|
+
);
|
|
360
|
+
if (!realRuntimeBaseDir || realRuntimeBaseDir === ".." || realRuntimeBaseDir.startsWith(`..${path.sep}`) || path.isAbsolute(realRuntimeBaseDir)) {
|
|
361
|
+
throw new Error(`Extension validation runtime must stay inside the target checkout: ${options.validationRuntimeDir}`);
|
|
362
|
+
}
|
|
363
|
+
const validationRuntimeDir = path.join(validationRuntimeBaseDir, `port-${options.cdpPort}`);
|
|
364
|
+
const validationRuntimePath = path.join(options.projectRoot, validationRuntimeDir);
|
|
365
|
+
if (fs.lstatSync(validationRuntimePath, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
366
|
+
throw new Error(`Extension validation runtime must stay inside the target checkout: ${options.validationRuntimeDir}`);
|
|
367
|
+
}
|
|
368
|
+
fs.mkdirSync(validationRuntimePath, { recursive: true });
|
|
369
|
+
const supervisor = await startValidationLaunchSupervisor(options.cdpPort);
|
|
370
|
+
try {
|
|
371
|
+
return await launchLockedValidationBrowser({
|
|
372
|
+
...options,
|
|
373
|
+
script,
|
|
374
|
+
supervisor,
|
|
375
|
+
validationRuntimeDir,
|
|
376
|
+
validationRuntimePath
|
|
377
|
+
});
|
|
378
|
+
} finally {
|
|
379
|
+
await supervisor.close();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async function launchLockedValidationBrowser(options) {
|
|
383
|
+
const { validationRuntimeDir, validationRuntimePath } = options;
|
|
384
|
+
const walletFixtureDestination = path.join(validationRuntimePath, "wallet-fixture.json");
|
|
385
|
+
const validationProfileRoot = path.join(validationRuntimePath, "validation-profile-pw");
|
|
386
|
+
const validationArtifactsRoot = path.join(validationRuntimePath, "live");
|
|
387
|
+
for (const destination of [walletFixtureDestination, validationProfileRoot, validationArtifactsRoot]) {
|
|
388
|
+
if (fs.lstatSync(destination, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
389
|
+
throw new Error(`Extension validation runtime must stay inside the target checkout: ${options.validationRuntimeDir}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
fs.mkdirSync(validationProfileRoot, { recursive: true });
|
|
393
|
+
fs.mkdirSync(validationArtifactsRoot, { recursive: true });
|
|
394
|
+
assertNoQuarantinedValidationProfiles(validationProfileRoot);
|
|
395
|
+
await stopOwnedCdpListener(options.cdpPort, [
|
|
396
|
+
validationRuntimePath,
|
|
397
|
+
path.join(options.projectRoot, recipeRuntimeDir())
|
|
398
|
+
]);
|
|
399
|
+
assertNoLiveValidationProfileOwners(validationProfileRoot);
|
|
400
|
+
pruneValidationRuns(validationProfileRoot, 0);
|
|
401
|
+
pruneValidationRuns(validationArtifactsRoot, 4);
|
|
402
|
+
const validationProfilePath = fs.mkdtempSync(path.join(validationProfileRoot, "run-"));
|
|
403
|
+
const validationArtifactsPath = fs.mkdtempSync(path.join(validationArtifactsRoot, "run-"));
|
|
404
|
+
if (fs.existsSync(walletFixtureDestination)) fs.unlinkSync(walletFixtureDestination);
|
|
405
|
+
fs.copyFileSync(
|
|
406
|
+
path.join(options.projectRoot, recipeRuntimeDir(), "wallet-fixture.json"),
|
|
407
|
+
walletFixtureDestination,
|
|
408
|
+
fs.constants.COPYFILE_EXCL
|
|
409
|
+
);
|
|
410
|
+
const result = await options.supervisor.run("bash", [
|
|
411
|
+
options.script,
|
|
412
|
+
"--target",
|
|
413
|
+
options.projectRoot,
|
|
335
414
|
"--cdp-port",
|
|
336
415
|
String(options.cdpPort),
|
|
337
|
-
"--
|
|
338
|
-
|
|
339
|
-
|
|
416
|
+
"--launch-existing-dist",
|
|
417
|
+
"--chrome-user-data-dir",
|
|
418
|
+
validationProfilePath,
|
|
419
|
+
"--artifacts-dir",
|
|
420
|
+
validationArtifactsPath
|
|
340
421
|
], {
|
|
341
|
-
cwd:
|
|
342
|
-
env:
|
|
343
|
-
|
|
344
|
-
|
|
422
|
+
cwd: options.projectRoot,
|
|
423
|
+
env: {
|
|
424
|
+
...process.env,
|
|
425
|
+
MM_HARNESS_BIN: path.join(runnerDir, "bin/mm-harness"),
|
|
426
|
+
PLAYWRIGHT_BROWSERS_PATH: path.join(
|
|
427
|
+
options.projectRoot,
|
|
428
|
+
recipeRuntimeDir(),
|
|
429
|
+
"ms-playwright"
|
|
430
|
+
),
|
|
431
|
+
RECIPE_RUNTIME_DIR: validationRuntimeDir
|
|
432
|
+
},
|
|
433
|
+
profile: validationProfilePath
|
|
434
|
+
}, options.launchTimeoutMs);
|
|
435
|
+
if (result.error) {
|
|
436
|
+
throw new Error(`Extension validation launcher supervision failed: ${result.error}
|
|
437
|
+
${result.stdout}
|
|
438
|
+
${result.stderr}`);
|
|
439
|
+
}
|
|
345
440
|
if (result.timedOut) {
|
|
346
|
-
throw new Error(`Extension validation launcher timed out after
|
|
441
|
+
throw new Error(`Extension validation launcher timed out after ${options.launchTimeoutMs}ms.
|
|
347
442
|
${result.stdout}
|
|
348
443
|
${result.stderr}`);
|
|
349
444
|
}
|
|
@@ -352,27 +447,192 @@ ${result.stderr}`);
|
|
|
352
447
|
${result.stdout}
|
|
353
448
|
${result.stderr}`);
|
|
354
449
|
}
|
|
450
|
+
const identity = readValidationRuntimeIdentity(validationRuntimePath, options.cdpPort);
|
|
451
|
+
const listenerPids = await inspectCdpListenerPids(options.cdpPort);
|
|
452
|
+
if (!identity || listenerPids.length !== 1 || listenerPids[0] !== identity.pid || !profileProcessPids(validationProfilePath).includes(identity.pid) || !await browserEchoesRuntimeNonce(options.cdpPort, identity.nonce)) {
|
|
453
|
+
throw new Error("Extension validation launcher exited without a live nonce-owned browser.");
|
|
454
|
+
}
|
|
455
|
+
await options.supervisor.release(true);
|
|
355
456
|
return {
|
|
356
457
|
launched: true,
|
|
357
458
|
slotId: options.slotId,
|
|
358
459
|
cdpPort: options.cdpPort,
|
|
359
|
-
runtimeDir:
|
|
460
|
+
runtimeDir: validationRuntimeDir,
|
|
360
461
|
stdout: result.stdout
|
|
361
462
|
};
|
|
362
463
|
}
|
|
464
|
+
async function startValidationLaunchSupervisor(cdpPort) {
|
|
465
|
+
const supervisorPath = path.join(runnerDir, "adapters/extension/lib/validation-launch-supervisor.cjs");
|
|
466
|
+
const child = spawn(process.execPath, [supervisorPath, String(cdpPort)], {
|
|
467
|
+
cwd: runnerDir,
|
|
468
|
+
env: process.env,
|
|
469
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"]
|
|
470
|
+
});
|
|
471
|
+
let stdout = "";
|
|
472
|
+
let stderr = "";
|
|
473
|
+
let ready = false;
|
|
474
|
+
let runStarted = false;
|
|
475
|
+
let resultSettled = false;
|
|
476
|
+
let released = false;
|
|
477
|
+
let releaseError;
|
|
478
|
+
let resolveResult;
|
|
479
|
+
let rejectResult;
|
|
480
|
+
let resolveReady;
|
|
481
|
+
let rejectReady;
|
|
482
|
+
const readyPromise = new Promise((resolve, reject) => {
|
|
483
|
+
resolveReady = resolve;
|
|
484
|
+
rejectReady = reject;
|
|
485
|
+
});
|
|
486
|
+
const exitPromise = new Promise((resolve) => {
|
|
487
|
+
child.once("exit", (exitCode, signalCode) => resolve({ exitCode, signalCode }));
|
|
488
|
+
});
|
|
489
|
+
child.stdout.on("data", (chunk) => {
|
|
490
|
+
stdout += chunk;
|
|
491
|
+
});
|
|
492
|
+
child.stderr.on("data", (chunk) => {
|
|
493
|
+
stderr += chunk;
|
|
494
|
+
});
|
|
495
|
+
child.on("message", (message) => {
|
|
496
|
+
if (message?.type === "ready") {
|
|
497
|
+
ready = true;
|
|
498
|
+
resolveReady?.();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (message?.type === "error") {
|
|
502
|
+
const error = new Error(String(message.message || "Extension validation supervisor failed."));
|
|
503
|
+
if (!ready) rejectReady?.(error);
|
|
504
|
+
else rejectResult?.(error);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (message?.type === "result") {
|
|
508
|
+
resultSettled = true;
|
|
509
|
+
resolveResult?.({
|
|
510
|
+
exitCode: typeof message.exitCode === "number" ? message.exitCode : null,
|
|
511
|
+
timedOut: message.timedOut === true,
|
|
512
|
+
...typeof message.error === "string" ? { error: message.error } : {}
|
|
513
|
+
});
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (message?.type === "release-error") {
|
|
517
|
+
releaseError = new Error(String(message.message || "Extension validation supervisor cleanup failed."));
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
child.once("error", (error) => {
|
|
521
|
+
if (!ready) rejectReady?.(error);
|
|
522
|
+
else rejectResult?.(error);
|
|
523
|
+
});
|
|
524
|
+
child.once("exit", (exitCode) => {
|
|
525
|
+
const error = new Error(`Extension validation supervisor exited with ${exitCode}.
|
|
526
|
+
${stdout}
|
|
527
|
+
${stderr}`);
|
|
528
|
+
if (!ready) rejectReady?.(error);
|
|
529
|
+
else if (runStarted && !resultSettled) rejectResult?.(error);
|
|
530
|
+
});
|
|
531
|
+
await readyPromise;
|
|
532
|
+
return {
|
|
533
|
+
async run(command, args, options, timeoutMs) {
|
|
534
|
+
if (runStarted) throw new Error("Extension validation supervisor already launched.");
|
|
535
|
+
runStarted = true;
|
|
536
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
537
|
+
resolveResult = resolve;
|
|
538
|
+
rejectResult = reject;
|
|
539
|
+
});
|
|
540
|
+
child.send({
|
|
541
|
+
type: "run",
|
|
542
|
+
command,
|
|
543
|
+
args,
|
|
544
|
+
cwd: options.cwd,
|
|
545
|
+
env: options.env,
|
|
546
|
+
profile: options.profile,
|
|
547
|
+
timeoutMs
|
|
548
|
+
});
|
|
549
|
+
const result = await resultPromise;
|
|
550
|
+
return { ...result, stdout, stderr };
|
|
551
|
+
},
|
|
552
|
+
async release(preserveProfileOwner) {
|
|
553
|
+
if (released) return;
|
|
554
|
+
released = true;
|
|
555
|
+
if (child.connected) child.send({ type: "release", preserveProfileOwner });
|
|
556
|
+
const exit = await exitPromise;
|
|
557
|
+
if (releaseError) throw releaseError;
|
|
558
|
+
if (exit.exitCode !== 0) {
|
|
559
|
+
throw new Error(`Extension validation supervisor exited with ${exit.exitCode ?? exit.signalCode}.
|
|
560
|
+
${stdout}
|
|
561
|
+
${stderr}`);
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
async close() {
|
|
565
|
+
if (released) return;
|
|
566
|
+
released = true;
|
|
567
|
+
if (child.exitCode === null && child.signalCode === null && child.connected) {
|
|
568
|
+
child.send(runStarted ? { type: "release", preserveProfileOwner: false } : { type: "cancel" });
|
|
569
|
+
}
|
|
570
|
+
const exit = await exitPromise;
|
|
571
|
+
if (releaseError) throw releaseError;
|
|
572
|
+
if (exit.exitCode !== 0) {
|
|
573
|
+
throw new Error(`Extension validation supervisor exited with ${exit.exitCode ?? exit.signalCode}.
|
|
574
|
+
${stdout}
|
|
575
|
+
${stderr}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
function processExists(pid) {
|
|
581
|
+
try {
|
|
582
|
+
process.kill(pid, 0);
|
|
583
|
+
return true;
|
|
584
|
+
} catch (error) {
|
|
585
|
+
return error.code !== "ESRCH";
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function assertNoQuarantinedValidationProfiles(profileRoot) {
|
|
589
|
+
for (const name of fs.readdirSync(profileRoot).filter((entry) => entry.startsWith("run-"))) {
|
|
590
|
+
const profile = path.join(profileRoot, name);
|
|
591
|
+
if (!fs.lstatSync(profile, { throwIfNoEntry: false })?.isDirectory()) continue;
|
|
592
|
+
if (!hasDetachedLaunchUnproven(profile)) continue;
|
|
593
|
+
const marker = detachedLaunchUnprovenPath(profile);
|
|
594
|
+
throw new Error(
|
|
595
|
+
`Extension validation will not prune the quarantined profile ${profile}. Next: confirm no process uses that profile, then run: rm -- ${shellQuote(marker)}`
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function assertNoLiveValidationProfileOwners(profileRoot) {
|
|
600
|
+
for (const name of fs.readdirSync(profileRoot).filter((entry) => entry.startsWith("run-"))) {
|
|
601
|
+
const profile = path.join(profileRoot, name);
|
|
602
|
+
if (!fs.lstatSync(profile, { throwIfNoEntry: false })?.isDirectory()) continue;
|
|
603
|
+
const pids = profileProcessPids(profile);
|
|
604
|
+
if (pids.length > 0) {
|
|
605
|
+
throw new Error(
|
|
606
|
+
`Extension validation will not prune the live profile ${profile} (pid ${pids.join(", ")}). Next: stop that browser and rerun the validation.`
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function pruneValidationRuns(root, keep) {
|
|
612
|
+
const runs = fs.readdirSync(root).filter((name) => name.startsWith("run-")).map((name) => {
|
|
613
|
+
const entryPath = path.join(root, name);
|
|
614
|
+
return { entryPath, mtimeMs: fs.lstatSync(entryPath).mtimeMs };
|
|
615
|
+
}).sort((left, right) => right.mtimeMs - left.mtimeMs || right.entryPath.localeCompare(left.entryPath));
|
|
616
|
+
for (const { entryPath } of runs.slice(keep)) {
|
|
617
|
+
const entryStat = fs.lstatSync(entryPath, { throwIfNoEntry: false });
|
|
618
|
+
if (!entryStat) continue;
|
|
619
|
+
if (entryStat.isSymbolicLink() || !entryStat.isDirectory()) fs.unlinkSync(entryPath);
|
|
620
|
+
else fs.rmSync(entryPath, { recursive: true, force: true });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
363
623
|
function runProcess(command, args, options) {
|
|
364
624
|
return new Promise((resolve, reject) => {
|
|
365
625
|
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
366
626
|
let stdout = "";
|
|
367
627
|
let stderr = "";
|
|
368
628
|
let settled = false;
|
|
629
|
+
let didTimeout = false;
|
|
630
|
+
let killTimer;
|
|
369
631
|
const timeout = options.timeoutMs ? setTimeout(() => {
|
|
370
632
|
if (settled) return;
|
|
371
|
-
|
|
633
|
+
didTimeout = true;
|
|
372
634
|
child.kill("SIGTERM");
|
|
373
|
-
|
|
374
|
-
child.once("close", () => clearTimeout(killTimer));
|
|
375
|
-
resolve({ exitCode: null, stdout, stderr, timedOut: true });
|
|
635
|
+
killTimer = setTimeout(() => child.kill("SIGKILL"), 1e3);
|
|
376
636
|
}, options.timeoutMs) : void 0;
|
|
377
637
|
child.stdout.on("data", (chunk) => {
|
|
378
638
|
stdout += chunk;
|
|
@@ -384,23 +644,106 @@ function runProcess(command, args, options) {
|
|
|
384
644
|
if (settled) return;
|
|
385
645
|
settled = true;
|
|
386
646
|
if (timeout) clearTimeout(timeout);
|
|
647
|
+
if (killTimer) clearTimeout(killTimer);
|
|
387
648
|
reject(error);
|
|
388
649
|
});
|
|
389
650
|
child.on("close", (exitCode) => {
|
|
390
651
|
if (settled) return;
|
|
391
652
|
settled = true;
|
|
392
653
|
if (timeout) clearTimeout(timeout);
|
|
393
|
-
|
|
654
|
+
if (killTimer) clearTimeout(killTimer);
|
|
655
|
+
resolve({ exitCode, stdout, stderr, timedOut: didTimeout });
|
|
394
656
|
});
|
|
395
657
|
});
|
|
396
658
|
}
|
|
397
|
-
async function
|
|
659
|
+
async function stopOwnedCdpListener(cdpPort, runtimePaths) {
|
|
398
660
|
if (process.platform !== "darwin" && process.platform !== "linux") return;
|
|
399
|
-
|
|
661
|
+
let listenerPids = await inspectCdpListenerPids(cdpPort);
|
|
662
|
+
if (listenerPids.length === 0) return;
|
|
663
|
+
const identity = runtimePaths.map((runtimePath) => readValidationRuntimeIdentity(runtimePath, cdpPort)).find((candidate) => candidate?.pid === listenerPids[0]);
|
|
664
|
+
if (!identity || listenerPids.length !== 1 || listenerPids[0] !== identity.pid || !await browserEchoesRuntimeNonce(cdpPort, identity.nonce)) {
|
|
665
|
+
throw new Error(`Extension validation will not stop an unowned CDP listener on port ${cdpPort}.`);
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
process.kill(identity.pid, "SIGTERM");
|
|
669
|
+
} catch (error) {
|
|
670
|
+
if (error.code !== "ESRCH") throw error;
|
|
671
|
+
}
|
|
672
|
+
const termDeadline = Date.now() + 1e3;
|
|
673
|
+
do {
|
|
674
|
+
await sleep(50);
|
|
675
|
+
listenerPids = await inspectCdpListenerPids(cdpPort);
|
|
676
|
+
} while ((listenerPids.length > 0 || processExists(identity.pid)) && Date.now() < termDeadline);
|
|
677
|
+
if (listenerPids.length > 0 || processExists(identity.pid)) {
|
|
678
|
+
try {
|
|
679
|
+
process.kill(identity.pid, "SIGKILL");
|
|
680
|
+
} catch (error) {
|
|
681
|
+
if (error.code !== "ESRCH") throw error;
|
|
682
|
+
}
|
|
683
|
+
const killDeadline = Date.now() + 1e3;
|
|
684
|
+
do {
|
|
685
|
+
await sleep(50);
|
|
686
|
+
listenerPids = await inspectCdpListenerPids(cdpPort);
|
|
687
|
+
} while ((listenerPids.length > 0 || processExists(identity.pid)) && Date.now() < killDeadline);
|
|
688
|
+
}
|
|
689
|
+
if (listenerPids.length > 0 || processExists(identity.pid)) {
|
|
690
|
+
throw new Error(`Extension validation could not stop its CDP listener on port ${cdpPort}.`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
async function inspectCdpListenerPids(cdpPort) {
|
|
694
|
+
const result = await runProcess("lsof", ["-ti", `tcp:${cdpPort}`, "-sTCP:LISTEN"], {
|
|
400
695
|
cwd: runnerDir,
|
|
401
696
|
env: process.env,
|
|
402
|
-
timeoutMs:
|
|
697
|
+
timeoutMs: 5e3
|
|
403
698
|
});
|
|
699
|
+
const noListener = result.exitCode === 1 && !result.stdout.trim() && !result.stderr.trim();
|
|
700
|
+
if (result.timedOut || result.exitCode !== 0 && !noListener) {
|
|
701
|
+
throw new Error(`Extension validation could not inspect the CDP listener on port ${cdpPort}.`);
|
|
702
|
+
}
|
|
703
|
+
const pids = result.stdout.trim() ? result.stdout.trim().split(/\s+/u).map(Number) : [];
|
|
704
|
+
if (pids.some((pid) => !Number.isInteger(pid) || pid <= 0)) {
|
|
705
|
+
throw new Error(`Extension validation received invalid CDP listener ownership for port ${cdpPort}.`);
|
|
706
|
+
}
|
|
707
|
+
return [...new Set(pids)];
|
|
708
|
+
}
|
|
709
|
+
function readValidationRuntimeIdentity(validationRuntimePath, cdpPort) {
|
|
710
|
+
const identityPath = path.join(validationRuntimePath, "extension-runtime-identity.json");
|
|
711
|
+
let descriptor;
|
|
712
|
+
try {
|
|
713
|
+
descriptor = fs.openSync(identityPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
714
|
+
const stat = fs.fstatSync(descriptor);
|
|
715
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > 4096 || (stat.mode & 63) !== 0) return null;
|
|
716
|
+
const parsed = JSON.parse(fs.readFileSync(descriptor, "utf8"));
|
|
717
|
+
if (parsed.port !== cdpPort || !Number.isInteger(parsed.pid) || Number(parsed.pid) <= 0 || typeof parsed.nonce !== "string" || !/^[a-f0-9]{64}$/u.test(parsed.nonce)) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
return parsed;
|
|
721
|
+
} catch {
|
|
722
|
+
return null;
|
|
723
|
+
} finally {
|
|
724
|
+
if (descriptor !== void 0) fs.closeSync(descriptor);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
async function browserEchoesRuntimeNonce(cdpPort, nonce) {
|
|
728
|
+
let session;
|
|
729
|
+
try {
|
|
730
|
+
const version = await jsonGet(`http://127.0.0.1:${cdpPort}/json/version`, { timeoutMs: 2e3 });
|
|
731
|
+
if (typeof version.webSocketDebuggerUrl !== "string") return false;
|
|
732
|
+
session = await CdpSession.connect(version.webSocketDebuggerUrl, { timeoutMs: 2e3 });
|
|
733
|
+
const result = await boundedCdpCall(
|
|
734
|
+
session,
|
|
735
|
+
"Browser.getBrowserCommandLine",
|
|
736
|
+
{},
|
|
737
|
+
2e3
|
|
738
|
+
);
|
|
739
|
+
if (!Array.isArray(result.arguments)) return false;
|
|
740
|
+
const expected = `--mm-harness-runtime-nonce=${nonce}`;
|
|
741
|
+
return result.arguments.filter((argument) => argument === expected).length === 1;
|
|
742
|
+
} catch {
|
|
743
|
+
return false;
|
|
744
|
+
} finally {
|
|
745
|
+
session?.close();
|
|
746
|
+
}
|
|
404
747
|
}
|
|
405
748
|
function safeExtensionId(target) {
|
|
406
749
|
try {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
const require2 = createRequire(import.meta.url);
|
|
3
|
+
const processOwnership = require2(
|
|
4
|
+
"../../../adapters/extension/lib/validation-process-ownership.cjs"
|
|
5
|
+
);
|
|
6
|
+
const { profileProcessPids, stopProfileProcesses } = processOwnership;
|
|
7
|
+
export {
|
|
8
|
+
profileProcessPids,
|
|
9
|
+
stopProfileProcesses
|
|
10
|
+
};
|
package/dist/cli-commands.js
CHANGED
|
@@ -8,6 +8,7 @@ const SPEC = {
|
|
|
8
8
|
{ name: "help", aliases: ["-h", "--help"], desc: "Show usage" }
|
|
9
9
|
],
|
|
10
10
|
shared: [
|
|
11
|
+
{ name: "setup-base", desc: "Bootstrap numbered product checkouts", flags: ["--dir", "--counts", "--only", "--dry-run", "--force", "--json", "--show-config", "--reset-config", "--skip-harness-update"] },
|
|
11
12
|
{ name: "status", aliases: ["health", "home"], desc: "Home status + next commands", flags: ["--json", "--fast"] },
|
|
12
13
|
{ name: "ports", desc: "Slot ports and runtime paths", flags: ["--json"] },
|
|
13
14
|
{ name: "up", desc: "Decide + run minimum work to reach ready", flags: ["--json", "--dry-run"] },
|
package/dist/command-contract.js
CHANGED
|
@@ -50,6 +50,18 @@ const REMOVED_OPTION_REPLACEMENTS = {
|
|
|
50
50
|
"--record": "--record-video"
|
|
51
51
|
};
|
|
52
52
|
const PUBLIC_COMMAND_CONTRACTS = {
|
|
53
|
+
"setup-base": {
|
|
54
|
+
options: options(HELP, JSON, {
|
|
55
|
+
"--dir": value(),
|
|
56
|
+
"--counts": value(),
|
|
57
|
+
"--only": value(),
|
|
58
|
+
"--dry-run": bool(),
|
|
59
|
+
"--force": bool(),
|
|
60
|
+
"--show-config": bool(),
|
|
61
|
+
"--reset-config": bool(),
|
|
62
|
+
"--skip-harness-update": bool()
|
|
63
|
+
})
|
|
64
|
+
},
|
|
53
65
|
status: {
|
|
54
66
|
aliases: ["health", "home"],
|
|
55
67
|
options: options(HELP, JSON, TARGET, DEVICE, {
|