rechrome 1.23.3 → 1.24.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/package.json +1 -1
- package/rech.js +186 -10
- package/rech.ts +186 -10
- package/serve.js +77 -3
- package/serve.ts +77 -3
package/package.json
CHANGED
package/rech.js
CHANGED
|
@@ -400,6 +400,59 @@ export function validateChromeProfileSelector(selector: string): void {
|
|
|
400
400
|
);
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
+
export async function resolveGlobalProfile(
|
|
404
|
+
registry: Record<string, TokenEntry>,
|
|
405
|
+
chromeProfiles: Record<string, ChromeProfileInfo> | null,
|
|
406
|
+
selector: string,
|
|
407
|
+
): Promise<{ email: string; entry: TokenEntry }> {
|
|
408
|
+
const value = selector.trim();
|
|
409
|
+
if (!value) throw new Error("--profile requires a non-empty value");
|
|
410
|
+
|
|
411
|
+
const registryKey = Object.keys(registry).find(k => k.toLowerCase() === value.toLowerCase());
|
|
412
|
+
if (registryKey) return { email: registryKey, entry: registry[registryKey] };
|
|
413
|
+
|
|
414
|
+
if (!chromeProfiles) {
|
|
415
|
+
throw new Error(
|
|
416
|
+
`--profile "${value}" does not match any registered email, and Chrome profiles are not accessible. ` +
|
|
417
|
+
`Run \`rech setup --profile "${value}"\` to register this profile.`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const profiles = Object.entries(chromeProfiles);
|
|
422
|
+
let match: [string, ChromeProfileInfo] | null;
|
|
423
|
+
try {
|
|
424
|
+
match = resolveChromeProfileSelector(profiles, value);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
throw err;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!match) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`--profile "${value}" does not match any Chrome profile. ` +
|
|
432
|
+
`See available profiles with \`rech profiles\`.`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const [dir, info] = match;
|
|
437
|
+
const email = info.user_name;
|
|
438
|
+
if (!email) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`Chrome profile "${value}" (folder: ${dir}) has no email associated. ` +
|
|
441
|
+
`Run \`rech setup --profile "${value}"\` to register it.`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const entry = registry[email];
|
|
446
|
+
if (!entry) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
`Profile "${email}" (${dir}) is not registered. ` +
|
|
449
|
+
`Run \`rech setup --profile "${value}"\` to register it.`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return { email, entry };
|
|
454
|
+
}
|
|
455
|
+
|
|
403
456
|
async function findChromeUserDataDir(): Promise<string | null> {
|
|
404
457
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
405
458
|
if (!(await file(statePath).exists())) continue;
|
|
@@ -545,7 +598,10 @@ async function callServe(
|
|
|
545
598
|
// and run() has already done so for its log line. Recomputing here would double those git
|
|
546
599
|
// spawns (and, on Windows, the console-window flashes) on every `rech open`.
|
|
547
600
|
const identity = precomputedIdentity ?? await getClientIdentity();
|
|
548
|
-
|
|
601
|
+
// A global `--profile` override must win for the session key too: the daemon hashes
|
|
602
|
+
// identity.profile into the session id, so without this, `rech --profile other open` would
|
|
603
|
+
// reuse the default profile's session (and its browser) instead of opening its own.
|
|
604
|
+
const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
|
|
549
605
|
if (effectiveProfile) identity.profile = effectiveProfile;
|
|
550
606
|
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
|
|
551
607
|
const res = await fetch(`${protocol}://${host}:${port}/run`, {
|
|
@@ -588,12 +644,43 @@ export function normalizeCommandArgs(args: string[]): string[] {
|
|
|
588
644
|
return normalized;
|
|
589
645
|
}
|
|
590
646
|
|
|
591
|
-
|
|
647
|
+
// Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
|
|
648
|
+
// Only flags before the first positional (the playwright subcommand) are rech globals — a
|
|
649
|
+
// --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
|
|
650
|
+
// `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
|
|
651
|
+
// missing value; accepts multiple occurrences (last one wins).
|
|
652
|
+
export function extractGlobalProfileArg(args: string[]): { args: string[]; selector?: string } {
|
|
653
|
+
const rest = [...args];
|
|
654
|
+
let selector: string | undefined;
|
|
655
|
+
for (let i = 0; i < rest.length && rest[i].startsWith("-"); i++) {
|
|
656
|
+
const a = rest[i];
|
|
657
|
+
if (a === "--profile") {
|
|
658
|
+
const value = rest[i + 1];
|
|
659
|
+
if (!value || value.startsWith("--"))
|
|
660
|
+
throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
|
|
661
|
+
selector = value;
|
|
662
|
+
rest.splice(i, 2);
|
|
663
|
+
i--;
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (a.startsWith("--profile=")) {
|
|
667
|
+
const value = a.slice("--profile=".length);
|
|
668
|
+
if (!value) throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
|
|
669
|
+
selector = value;
|
|
670
|
+
rest.splice(i, 1);
|
|
671
|
+
i--;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return { args: rest, selector };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function run(url: string, args: string[], overrideEnv?: Record<string, string>) {
|
|
592
679
|
// Match the underlying CLI's command names while accepting the short forms humans
|
|
593
680
|
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
594
681
|
args = normalizeCommandArgs(args);
|
|
595
682
|
const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
596
|
-
const effectiveProfile = resolveEffectiveProfile(profileDirectory);
|
|
683
|
+
const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
|
|
597
684
|
const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
|
|
598
685
|
const identity = await getClientIdentity();
|
|
599
686
|
const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
|
|
@@ -602,18 +689,19 @@ async function run(url: string, args: string[]) {
|
|
|
602
689
|
);
|
|
603
690
|
|
|
604
691
|
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
|
|
605
|
-
const
|
|
692
|
+
const effectiveEnv = { ...resolvedEnv, ...overrideEnv };
|
|
693
|
+
const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity);
|
|
606
694
|
|
|
607
695
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
608
696
|
if (existingSession && isOpenWithUrl) {
|
|
609
|
-
return run(url, ["goto", ...args.slice(1)]);
|
|
697
|
+
return run(url, ["goto", ...args.slice(1)], overrideEnv);
|
|
610
698
|
}
|
|
611
699
|
|
|
612
700
|
if (existingSession)
|
|
613
701
|
console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
|
|
614
702
|
if (stderr) {
|
|
615
703
|
if (stderr.includes('Extension connection timeout')) {
|
|
616
|
-
const hasToken = !!
|
|
704
|
+
const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
|
|
617
705
|
const last = hasToken
|
|
618
706
|
? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
|
|
619
707
|
: ` -> extension[not installed] (run: rech setup)`;
|
|
@@ -923,12 +1011,51 @@ function findTrayBinary(): string | undefined {
|
|
|
923
1011
|
return Bun.which(`rechrome-tray${ext}`) ?? undefined;
|
|
924
1012
|
}
|
|
925
1013
|
|
|
1014
|
+
// Verify a PID is actually a rechrome-tray process, not an unrelated process that
|
|
1015
|
+
// happened to reuse the pid after the real tray died. POSIX only (`ps` isn't on stock
|
|
1016
|
+
// Windows); there we fall back to trusting the liveness probe alone (best-effort).
|
|
1017
|
+
function isPidRechromeTray(pid: number): boolean {
|
|
1018
|
+
if (IS_WINDOWS) return true;
|
|
1019
|
+
try {
|
|
1020
|
+
const p = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(pid)], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
1021
|
+
return (p.stdout?.toString() ?? "").trim().includes("rechrome-tray");
|
|
1022
|
+
} catch {
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// PIDs of live rechrome-tray processes (POSIX). Catches untracked trays — ones launched
|
|
1028
|
+
// outside `rech tray show` that never touch the pidfile — so a singleton guard can't
|
|
1029
|
+
// spawn a second icon on top of them. Empty on Windows (no `ps`), where we rely on the
|
|
1030
|
+
// pidfile liveness check alone.
|
|
1031
|
+
function listTrayPids(): number[] {
|
|
1032
|
+
if (IS_WINDOWS) return [];
|
|
1033
|
+
try {
|
|
1034
|
+
const p = Bun.spawnSync(["ps", "-axo", "pid=,comm="], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
1035
|
+
const out = p.stdout?.toString() ?? "";
|
|
1036
|
+
const pids: number[] = [];
|
|
1037
|
+
for (const line of out.split("\n")) {
|
|
1038
|
+
const m = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
1039
|
+
// macOS reports the full launch path in `comm`, so match the basename via a
|
|
1040
|
+
// trailing "/rechrome-tray" or an exact bare name (Linux truncates to the name).
|
|
1041
|
+
if (m && Number(m[1]) !== process.pid && m[2].includes("rechrome-tray")) pids.push(Number(m[1]));
|
|
1042
|
+
}
|
|
1043
|
+
return pids;
|
|
1044
|
+
} catch {
|
|
1045
|
+
return [];
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
926
1049
|
function isTrayRunning(): boolean {
|
|
1050
|
+
// Any live tray (tracked by the pidfile or not) means "already running" — the pidfile
|
|
1051
|
+
// is a single slot that only ever records the most-recent spawn, so it cannot see
|
|
1052
|
+
// earlier/untracked instances on its own.
|
|
1053
|
+
if (listTrayPids().length > 0) return true;
|
|
927
1054
|
try {
|
|
928
1055
|
const pid = parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10);
|
|
929
1056
|
if (!Number.isFinite(pid)) return false;
|
|
930
1057
|
process.kill(pid, 0); // signal 0 = liveness probe, doesn't actually signal
|
|
931
|
-
return
|
|
1058
|
+
return isPidRechromeTray(pid);
|
|
932
1059
|
} catch {
|
|
933
1060
|
return false;
|
|
934
1061
|
}
|
|
@@ -958,7 +1085,12 @@ async function startTray({ quiet = false }: { quiet?: boolean } = {}): Promise<v
|
|
|
958
1085
|
}
|
|
959
1086
|
|
|
960
1087
|
function stopTray(): void {
|
|
961
|
-
|
|
1088
|
+
// Kill every live tray (tracked or untracked), then drop the pidfile. A singleton
|
|
1089
|
+
// tray should never have more than one instance, but a crashed/overwritten pidfile
|
|
1090
|
+
// can leave orphans the single-slot pidfile no longer points at — reap them too.
|
|
1091
|
+
const pids = listTrayPids();
|
|
1092
|
+
if (pids.length === 0 && !isTrayRunning()) { console.log("tray: not running."); return; }
|
|
1093
|
+
for (const pid of pids) { try { process.kill(pid); } catch {} }
|
|
962
1094
|
try { process.kill(parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10)); } catch {}
|
|
963
1095
|
try { unlinkSync(TRAY_PID_FILE); } catch {}
|
|
964
1096
|
console.log("tray: stopped. Run `rech tray show` to restore.");
|
|
@@ -1623,6 +1755,14 @@ function printHelp(): void {
|
|
|
1623
1755
|
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
1624
1756
|
|
|
1625
1757
|
Usage:
|
|
1758
|
+
rech [--profile <email|name|folder>] <playwright-args...>
|
|
1759
|
+
Run Playwright CLI command with the given registered
|
|
1760
|
+
Chrome profile. --profile selects the profile by exact
|
|
1761
|
+
registered email (e.g. you@gmail.com), exact Chrome
|
|
1762
|
+
profile name, or exact profile folder name. The profile
|
|
1763
|
+
must already be registered (see \`rech setup\`). Place
|
|
1764
|
+
--profile before the playwright subcommand. Requires
|
|
1765
|
+
${ENV_KEY}.
|
|
1626
1766
|
rech setup [--profile <email|name|folder>] [--token <tok>]
|
|
1627
1767
|
First-time setup: daemon + Chrome extension + config
|
|
1628
1768
|
--profile selects the Chrome profile non-interactively.
|
|
@@ -1659,13 +1799,14 @@ Environment:
|
|
|
1659
1799
|
Examples:
|
|
1660
1800
|
rech setup
|
|
1661
1801
|
rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
|
|
1802
|
+
rech --profile you@gmail.com open https://example.com
|
|
1662
1803
|
rech eval "() => document.title"
|
|
1663
1804
|
rech open https://example.com
|
|
1664
1805
|
rech screenshot`);
|
|
1665
1806
|
}
|
|
1666
1807
|
|
|
1667
1808
|
if (import.meta.main) {
|
|
1668
|
-
|
|
1809
|
+
let args = process.argv.slice(2);
|
|
1669
1810
|
const cmd = args[0]?.toLowerCase();
|
|
1670
1811
|
|
|
1671
1812
|
if (cmd === "serve") {
|
|
@@ -1723,6 +1864,41 @@ if (import.meta.main) {
|
|
|
1723
1864
|
printHelp();
|
|
1724
1865
|
process.exit(1);
|
|
1725
1866
|
}
|
|
1867
|
+
// --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
|
|
1868
|
+
// the leading-flags-only rule that protects the forwarded CLI's own --profile).
|
|
1869
|
+
let profileSelector: string | undefined;
|
|
1870
|
+
let overrideEnv: Record<string, string> | undefined;
|
|
1871
|
+
try {
|
|
1872
|
+
const extracted = extractGlobalProfileArg(args);
|
|
1873
|
+
profileSelector = extracted.selector;
|
|
1874
|
+
args = extracted.args;
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1877
|
+
envWatcher?.close();
|
|
1878
|
+
process.exit(1);
|
|
1879
|
+
}
|
|
1880
|
+
if (profileSelector !== undefined) {
|
|
1881
|
+
try {
|
|
1882
|
+
const registry = await readTokenRegistry();
|
|
1883
|
+
const cache = await readChromeProfileCache();
|
|
1884
|
+
const resolved = await resolveGlobalProfile(registry, cache, profileSelector);
|
|
1885
|
+
// Use the registry key (email, or the managed profile name) as the profile identity:
|
|
1886
|
+
// the daemon already resolves email/name → profile dir for the default URL-param path,
|
|
1887
|
+
// so this keeps `--profile <email>` on the SAME session as the default path and only
|
|
1888
|
+
// opens a separate session when the profile really differs.
|
|
1889
|
+
overrideEnv = {
|
|
1890
|
+
PLAYWRIGHT_MCP_PROFILE_DIRECTORY: resolved.email,
|
|
1891
|
+
PLAYWRIGHT_MCP_EXTENSION_ID: resolved.entry.extensionId,
|
|
1892
|
+
PLAYWRIGHT_MCP_EXTENSION_TOKEN: resolved.entry.token,
|
|
1893
|
+
};
|
|
1894
|
+
if (resolved.entry.userDataDir) overrideEnv.PLAYWRIGHT_MCP_USER_DATA_DIR = resolved.entry.userDataDir;
|
|
1895
|
+
if (resolved.entry.loadExtension) overrideEnv.PLAYWRIGHT_MCP_LOAD_EXTENSION = resolved.entry.loadExtension;
|
|
1896
|
+
} catch (err) {
|
|
1897
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1898
|
+
envWatcher?.close();
|
|
1899
|
+
process.exit(1);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1726
1902
|
// --isolate: ephemeral session isolation, sugar for -s=iso-<random>. For fragile single-shot
|
|
1727
1903
|
// flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
|
|
1728
1904
|
// marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.js), so an
|
|
@@ -1732,7 +1908,7 @@ if (import.meta.main) {
|
|
|
1732
1908
|
args.splice(isolateIdx, 1);
|
|
1733
1909
|
args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
|
|
1734
1910
|
}
|
|
1735
|
-
await run(url, args);
|
|
1911
|
+
await run(url, args, overrideEnv);
|
|
1736
1912
|
envWatcher?.close();
|
|
1737
1913
|
}
|
|
1738
1914
|
}
|
package/rech.ts
CHANGED
|
@@ -400,6 +400,59 @@ export function validateChromeProfileSelector(selector: string): void {
|
|
|
400
400
|
);
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
+
export async function resolveGlobalProfile(
|
|
404
|
+
registry: Record<string, TokenEntry>,
|
|
405
|
+
chromeProfiles: Record<string, ChromeProfileInfo> | null,
|
|
406
|
+
selector: string,
|
|
407
|
+
): Promise<{ email: string; entry: TokenEntry }> {
|
|
408
|
+
const value = selector.trim();
|
|
409
|
+
if (!value) throw new Error("--profile requires a non-empty value");
|
|
410
|
+
|
|
411
|
+
const registryKey = Object.keys(registry).find(k => k.toLowerCase() === value.toLowerCase());
|
|
412
|
+
if (registryKey) return { email: registryKey, entry: registry[registryKey] };
|
|
413
|
+
|
|
414
|
+
if (!chromeProfiles) {
|
|
415
|
+
throw new Error(
|
|
416
|
+
`--profile "${value}" does not match any registered email, and Chrome profiles are not accessible. ` +
|
|
417
|
+
`Run \`rech setup --profile "${value}"\` to register this profile.`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const profiles = Object.entries(chromeProfiles);
|
|
422
|
+
let match: [string, ChromeProfileInfo] | null;
|
|
423
|
+
try {
|
|
424
|
+
match = resolveChromeProfileSelector(profiles, value);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
throw err;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!match) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`--profile "${value}" does not match any Chrome profile. ` +
|
|
432
|
+
`See available profiles with \`rech profiles\`.`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const [dir, info] = match;
|
|
437
|
+
const email = info.user_name;
|
|
438
|
+
if (!email) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`Chrome profile "${value}" (folder: ${dir}) has no email associated. ` +
|
|
441
|
+
`Run \`rech setup --profile "${value}"\` to register it.`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const entry = registry[email];
|
|
446
|
+
if (!entry) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
`Profile "${email}" (${dir}) is not registered. ` +
|
|
449
|
+
`Run \`rech setup --profile "${value}"\` to register it.`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return { email, entry };
|
|
454
|
+
}
|
|
455
|
+
|
|
403
456
|
async function findChromeUserDataDir(): Promise<string | null> {
|
|
404
457
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
405
458
|
if (!(await file(statePath).exists())) continue;
|
|
@@ -545,7 +598,10 @@ async function callServe(
|
|
|
545
598
|
// and run() has already done so for its log line. Recomputing here would double those git
|
|
546
599
|
// spawns (and, on Windows, the console-window flashes) on every `rech open`.
|
|
547
600
|
const identity = precomputedIdentity ?? await getClientIdentity();
|
|
548
|
-
|
|
601
|
+
// A global `--profile` override must win for the session key too: the daemon hashes
|
|
602
|
+
// identity.profile into the session id, so without this, `rech --profile other open` would
|
|
603
|
+
// reuse the default profile's session (and its browser) instead of opening its own.
|
|
604
|
+
const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
|
|
549
605
|
if (effectiveProfile) identity.profile = effectiveProfile;
|
|
550
606
|
const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
|
|
551
607
|
const res = await fetch(`${protocol}://${host}:${port}/run`, {
|
|
@@ -588,12 +644,43 @@ export function normalizeCommandArgs(args: string[]): string[] {
|
|
|
588
644
|
return normalized;
|
|
589
645
|
}
|
|
590
646
|
|
|
591
|
-
|
|
647
|
+
// Pull a global `--profile <val>` / `--profile=<val>` out of the leading flags of an argv.
|
|
648
|
+
// Only flags before the first positional (the playwright subcommand) are rech globals — a
|
|
649
|
+
// --profile at/after the subcommand belongs to the forwarded CLI (e.g. playwright-cli's own
|
|
650
|
+
// `open --profile <dir>`, a user-data-dir path) and must pass through untouched. Throws on a
|
|
651
|
+
// missing value; accepts multiple occurrences (last one wins).
|
|
652
|
+
export function extractGlobalProfileArg(args: string[]): { args: string[]; selector?: string } {
|
|
653
|
+
const rest = [...args];
|
|
654
|
+
let selector: string | undefined;
|
|
655
|
+
for (let i = 0; i < rest.length && rest[i].startsWith("-"); i++) {
|
|
656
|
+
const a = rest[i];
|
|
657
|
+
if (a === "--profile") {
|
|
658
|
+
const value = rest[i + 1];
|
|
659
|
+
if (!value || value.startsWith("--"))
|
|
660
|
+
throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
|
|
661
|
+
selector = value;
|
|
662
|
+
rest.splice(i, 2);
|
|
663
|
+
i--;
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (a.startsWith("--profile=")) {
|
|
667
|
+
const value = a.slice("--profile=".length);
|
|
668
|
+
if (!value) throw new Error("--profile requires a value (e.g. --profile you@gmail.com)");
|
|
669
|
+
selector = value;
|
|
670
|
+
rest.splice(i, 1);
|
|
671
|
+
i--;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return { args: rest, selector };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function run(url: string, args: string[], overrideEnv?: Record<string, string>) {
|
|
592
679
|
// Match the underlying CLI's command names while accepting the short forms humans
|
|
593
680
|
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
594
681
|
args = normalizeCommandArgs(args);
|
|
595
682
|
const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
596
|
-
const effectiveProfile = resolveEffectiveProfile(profileDirectory);
|
|
683
|
+
const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
|
|
597
684
|
const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
|
|
598
685
|
const identity = await getClientIdentity();
|
|
599
686
|
const profileSuffix = displayProfile ? ` profile:${displayProfile}` : "";
|
|
@@ -602,18 +689,19 @@ async function run(url: string, args: string[]) {
|
|
|
602
689
|
);
|
|
603
690
|
|
|
604
691
|
const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
|
|
605
|
-
const
|
|
692
|
+
const effectiveEnv = { ...resolvedEnv, ...overrideEnv };
|
|
693
|
+
const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity);
|
|
606
694
|
|
|
607
695
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
608
696
|
if (existingSession && isOpenWithUrl) {
|
|
609
|
-
return run(url, ["goto", ...args.slice(1)]);
|
|
697
|
+
return run(url, ["goto", ...args.slice(1)], overrideEnv);
|
|
610
698
|
}
|
|
611
699
|
|
|
612
700
|
if (existingSession)
|
|
613
701
|
console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
|
|
614
702
|
if (stderr) {
|
|
615
703
|
if (stderr.includes('Extension connection timeout')) {
|
|
616
|
-
const hasToken = !!
|
|
704
|
+
const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
|
|
617
705
|
const last = hasToken
|
|
618
706
|
? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
|
|
619
707
|
: ` -> extension[not installed] (run: rech setup)`;
|
|
@@ -923,12 +1011,51 @@ function findTrayBinary(): string | undefined {
|
|
|
923
1011
|
return Bun.which(`rechrome-tray${ext}`) ?? undefined;
|
|
924
1012
|
}
|
|
925
1013
|
|
|
1014
|
+
// Verify a PID is actually a rechrome-tray process, not an unrelated process that
|
|
1015
|
+
// happened to reuse the pid after the real tray died. POSIX only (`ps` isn't on stock
|
|
1016
|
+
// Windows); there we fall back to trusting the liveness probe alone (best-effort).
|
|
1017
|
+
function isPidRechromeTray(pid: number): boolean {
|
|
1018
|
+
if (IS_WINDOWS) return true;
|
|
1019
|
+
try {
|
|
1020
|
+
const p = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(pid)], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
1021
|
+
return (p.stdout?.toString() ?? "").trim().includes("rechrome-tray");
|
|
1022
|
+
} catch {
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// PIDs of live rechrome-tray processes (POSIX). Catches untracked trays — ones launched
|
|
1028
|
+
// outside `rech tray show` that never touch the pidfile — so a singleton guard can't
|
|
1029
|
+
// spawn a second icon on top of them. Empty on Windows (no `ps`), where we rely on the
|
|
1030
|
+
// pidfile liveness check alone.
|
|
1031
|
+
function listTrayPids(): number[] {
|
|
1032
|
+
if (IS_WINDOWS) return [];
|
|
1033
|
+
try {
|
|
1034
|
+
const p = Bun.spawnSync(["ps", "-axo", "pid=,comm="], { stdout: "pipe", stderr: "ignore", windowsHide: true });
|
|
1035
|
+
const out = p.stdout?.toString() ?? "";
|
|
1036
|
+
const pids: number[] = [];
|
|
1037
|
+
for (const line of out.split("\n")) {
|
|
1038
|
+
const m = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
1039
|
+
// macOS reports the full launch path in `comm`, so match the basename via a
|
|
1040
|
+
// trailing "/rechrome-tray" or an exact bare name (Linux truncates to the name).
|
|
1041
|
+
if (m && Number(m[1]) !== process.pid && m[2].includes("rechrome-tray")) pids.push(Number(m[1]));
|
|
1042
|
+
}
|
|
1043
|
+
return pids;
|
|
1044
|
+
} catch {
|
|
1045
|
+
return [];
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
926
1049
|
function isTrayRunning(): boolean {
|
|
1050
|
+
// Any live tray (tracked by the pidfile or not) means "already running" — the pidfile
|
|
1051
|
+
// is a single slot that only ever records the most-recent spawn, so it cannot see
|
|
1052
|
+
// earlier/untracked instances on its own.
|
|
1053
|
+
if (listTrayPids().length > 0) return true;
|
|
927
1054
|
try {
|
|
928
1055
|
const pid = parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10);
|
|
929
1056
|
if (!Number.isFinite(pid)) return false;
|
|
930
1057
|
process.kill(pid, 0); // signal 0 = liveness probe, doesn't actually signal
|
|
931
|
-
return
|
|
1058
|
+
return isPidRechromeTray(pid);
|
|
932
1059
|
} catch {
|
|
933
1060
|
return false;
|
|
934
1061
|
}
|
|
@@ -958,7 +1085,12 @@ async function startTray({ quiet = false }: { quiet?: boolean } = {}): Promise<v
|
|
|
958
1085
|
}
|
|
959
1086
|
|
|
960
1087
|
function stopTray(): void {
|
|
961
|
-
|
|
1088
|
+
// Kill every live tray (tracked or untracked), then drop the pidfile. A singleton
|
|
1089
|
+
// tray should never have more than one instance, but a crashed/overwritten pidfile
|
|
1090
|
+
// can leave orphans the single-slot pidfile no longer points at — reap them too.
|
|
1091
|
+
const pids = listTrayPids();
|
|
1092
|
+
if (pids.length === 0 && !isTrayRunning()) { console.log("tray: not running."); return; }
|
|
1093
|
+
for (const pid of pids) { try { process.kill(pid); } catch {} }
|
|
962
1094
|
try { process.kill(parseInt(readFileSync(TRAY_PID_FILE, "utf8"), 10)); } catch {}
|
|
963
1095
|
try { unlinkSync(TRAY_PID_FILE); } catch {}
|
|
964
1096
|
console.log("tray: stopped. Run `rech tray show` to restore.");
|
|
@@ -1623,6 +1755,14 @@ function printHelp(): void {
|
|
|
1623
1755
|
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
1624
1756
|
|
|
1625
1757
|
Usage:
|
|
1758
|
+
rech [--profile <email|name|folder>] <playwright-args...>
|
|
1759
|
+
Run Playwright CLI command with the given registered
|
|
1760
|
+
Chrome profile. --profile selects the profile by exact
|
|
1761
|
+
registered email (e.g. you@gmail.com), exact Chrome
|
|
1762
|
+
profile name, or exact profile folder name. The profile
|
|
1763
|
+
must already be registered (see \`rech setup\`). Place
|
|
1764
|
+
--profile before the playwright subcommand. Requires
|
|
1765
|
+
${ENV_KEY}.
|
|
1626
1766
|
rech setup [--profile <email|name|folder>] [--token <tok>]
|
|
1627
1767
|
First-time setup: daemon + Chrome extension + config
|
|
1628
1768
|
--profile selects the Chrome profile non-interactively.
|
|
@@ -1659,13 +1799,14 @@ Environment:
|
|
|
1659
1799
|
Examples:
|
|
1660
1800
|
rech setup
|
|
1661
1801
|
rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
|
|
1802
|
+
rech --profile you@gmail.com open https://example.com
|
|
1662
1803
|
rech eval "() => document.title"
|
|
1663
1804
|
rech open https://example.com
|
|
1664
1805
|
rech screenshot`);
|
|
1665
1806
|
}
|
|
1666
1807
|
|
|
1667
1808
|
if (import.meta.main) {
|
|
1668
|
-
|
|
1809
|
+
let args = process.argv.slice(2);
|
|
1669
1810
|
const cmd = args[0]?.toLowerCase();
|
|
1670
1811
|
|
|
1671
1812
|
if (cmd === "serve") {
|
|
@@ -1723,6 +1864,41 @@ if (import.meta.main) {
|
|
|
1723
1864
|
printHelp();
|
|
1724
1865
|
process.exit(1);
|
|
1725
1866
|
}
|
|
1867
|
+
// --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
|
|
1868
|
+
// the leading-flags-only rule that protects the forwarded CLI's own --profile).
|
|
1869
|
+
let profileSelector: string | undefined;
|
|
1870
|
+
let overrideEnv: Record<string, string> | undefined;
|
|
1871
|
+
try {
|
|
1872
|
+
const extracted = extractGlobalProfileArg(args);
|
|
1873
|
+
profileSelector = extracted.selector;
|
|
1874
|
+
args = extracted.args;
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1877
|
+
envWatcher?.close();
|
|
1878
|
+
process.exit(1);
|
|
1879
|
+
}
|
|
1880
|
+
if (profileSelector !== undefined) {
|
|
1881
|
+
try {
|
|
1882
|
+
const registry = await readTokenRegistry();
|
|
1883
|
+
const cache = await readChromeProfileCache();
|
|
1884
|
+
const resolved = await resolveGlobalProfile(registry, cache, profileSelector);
|
|
1885
|
+
// Use the registry key (email, or the managed profile name) as the profile identity:
|
|
1886
|
+
// the daemon already resolves email/name → profile dir for the default URL-param path,
|
|
1887
|
+
// so this keeps `--profile <email>` on the SAME session as the default path and only
|
|
1888
|
+
// opens a separate session when the profile really differs.
|
|
1889
|
+
overrideEnv = {
|
|
1890
|
+
PLAYWRIGHT_MCP_PROFILE_DIRECTORY: resolved.email,
|
|
1891
|
+
PLAYWRIGHT_MCP_EXTENSION_ID: resolved.entry.extensionId,
|
|
1892
|
+
PLAYWRIGHT_MCP_EXTENSION_TOKEN: resolved.entry.token,
|
|
1893
|
+
};
|
|
1894
|
+
if (resolved.entry.userDataDir) overrideEnv.PLAYWRIGHT_MCP_USER_DATA_DIR = resolved.entry.userDataDir;
|
|
1895
|
+
if (resolved.entry.loadExtension) overrideEnv.PLAYWRIGHT_MCP_LOAD_EXTENSION = resolved.entry.loadExtension;
|
|
1896
|
+
} catch (err) {
|
|
1897
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1898
|
+
envWatcher?.close();
|
|
1899
|
+
process.exit(1);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1726
1902
|
// --isolate: ephemeral session isolation, sugar for -s=iso-<random>. For fragile single-shot
|
|
1727
1903
|
// flows (OAuth/login) that must not share tabs with the worktree's default session. The `iso-`
|
|
1728
1904
|
// marker lets the daemon reap these throwaway sessions on an idle TTL (see serve.ts), so an
|
|
@@ -1732,7 +1908,7 @@ if (import.meta.main) {
|
|
|
1732
1908
|
args.splice(isolateIdx, 1);
|
|
1733
1909
|
args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
|
|
1734
1910
|
}
|
|
1735
|
-
await run(url, args);
|
|
1911
|
+
await run(url, args, overrideEnv);
|
|
1736
1912
|
envWatcher?.close();
|
|
1737
1913
|
}
|
|
1738
1914
|
}
|
package/serve.js
CHANGED
|
@@ -84,6 +84,36 @@ function noteSession(sess: string, now: number): void {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Did this returned command actually prove the relay is answering?
|
|
89
|
+
*
|
|
90
|
+
* The watchdog above clears its streaks whenever a command returns instead of timing out,
|
|
91
|
+
* on the reasoning that a reply — even a failing one — means the relay is alive. That holds
|
|
92
|
+
* for errors produced BY the browser, but not for ones the CLI raises locally before it ever
|
|
93
|
+
* opens a connection. `Browser '<id>' is not open` is the important case: it is emitted the
|
|
94
|
+
* instant a session is missing, which is precisely the state the per-session heal CREATES by
|
|
95
|
+
* closing a wedged session. So the sequence was:
|
|
96
|
+
*
|
|
97
|
+
* timeout, timeout → per-session heal closes the session
|
|
98
|
+
* "browser is not open" → returned fast, counted as success, globalStreak reset to 0
|
|
99
|
+
* timeout, timeout → heal again … forever
|
|
100
|
+
*
|
|
101
|
+
* WATCHDOG_TIMEOUTS (3) could therefore never be reached on a genuinely dead relay, and the
|
|
102
|
+
* daemon-level restart that exists to fix exactly that never fired — leaving `oxmgr restart
|
|
103
|
+
* rechrome` by hand as the only cure (observed 2026-08-05: three manual restarts in one
|
|
104
|
+
* session, each buying only a handful of commands).
|
|
105
|
+
*
|
|
106
|
+
* Returning false here does NOT count against the relay; it just refuses to forgive the
|
|
107
|
+
* timeouts already recorded.
|
|
108
|
+
*/
|
|
109
|
+
export function provesRelayAlive(o: { stdout: string; stderr: string }): boolean {
|
|
110
|
+
const out = `${o.stdout ?? ""}\n${o.stderr ?? ""}`;
|
|
111
|
+
// Both spellings the CLI uses for "no such session" (cli-client/output.js).
|
|
112
|
+
if (/Browser '[^']*' is not open/i.test(out)) return false;
|
|
113
|
+
if (/is not open, please run open first/i.test(out)) return false;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
87
117
|
export function inferSilentExtensionFailure(options: {
|
|
88
118
|
status: number;
|
|
89
119
|
stdout: string;
|
|
@@ -248,6 +278,27 @@ async function freeStalePort(port: number): Promise<void> {
|
|
|
248
278
|
await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
|
|
249
279
|
}
|
|
250
280
|
|
|
281
|
+
// --- Foreground/orphan self-exit ---------------------------------------------------
|
|
282
|
+
// A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
|
|
283
|
+
// process-manager safety net: when the agent that spawned it exits, the OS re-parents
|
|
284
|
+
// the orphan to init (ppid 1) and it lives forever — the resource leak behind
|
|
285
|
+
// PERFORMANCE-EVENT.md. The managed daemon (oxmgr `--restart always`) keeps the
|
|
286
|
+
// process as its own child, so its ppid is stable and non-1 and it is never flagged.
|
|
287
|
+
// Once a serve is orphaned AND has had no real /run for the idle timeout, it exits so
|
|
288
|
+
// the leak self-heals. /ping is deliberately NOT activity (the tray polls it every 2s
|
|
289
|
+
// and would otherwise keep an orphan alive forever).
|
|
290
|
+
const ORPHAN_POLL_INTERVAL_MS = 15_000;
|
|
291
|
+
const ORPHAN_IDLE_EXIT_MS = Number(process.env.RECH_SERVE_IDLE_TIMEOUT_MS) || 5 * 60_000;
|
|
292
|
+
|
|
293
|
+
// Pure decision predicate (testable). idleTimeoutMs <= 0 disables orphan self-exit.
|
|
294
|
+
export function shouldExitOrphanedServe(opts: {
|
|
295
|
+
orphaned: boolean;
|
|
296
|
+
idleMs: number;
|
|
297
|
+
idleTimeoutMs: number;
|
|
298
|
+
}): boolean {
|
|
299
|
+
return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
|
|
300
|
+
}
|
|
301
|
+
|
|
251
302
|
export async function serve() {
|
|
252
303
|
const url = await getOrCreateUrl();
|
|
253
304
|
const { key, port } = parseUrl(url);
|
|
@@ -255,6 +306,21 @@ export async function serve() {
|
|
|
255
306
|
const workDir = join(RECH_DIR, "output");
|
|
256
307
|
mkdirSync(workDir, { recursive: true });
|
|
257
308
|
|
|
309
|
+
// Foreground/orphan self-exit: a serve whose parent has been re-parented to init
|
|
310
|
+
// (ppid 1) is a leaked foreground serve. Poll for that, track the last real /run,
|
|
311
|
+
// and exit once orphaned + idle so an agent that died without shutting us down
|
|
312
|
+
// doesn't leave a daemon behind.
|
|
313
|
+
let orphaned = false;
|
|
314
|
+
let idleSince = Date.now();
|
|
315
|
+
const markActivity = () => { idleSince = Date.now(); };
|
|
316
|
+
setInterval(() => {
|
|
317
|
+
if (process.ppid === 1) orphaned = true;
|
|
318
|
+
if (shouldExitOrphanedServe({ orphaned, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
|
|
319
|
+
log(`orphaned foreground serve idle ${Math.round((Date.now() - idleSince) / 1000)}s — exiting (spawning agent is gone)`);
|
|
320
|
+
process.exit(0);
|
|
321
|
+
}
|
|
322
|
+
}, ORPHAN_POLL_INTERVAL_MS);
|
|
323
|
+
|
|
258
324
|
// Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
|
|
259
325
|
adoptOrphanedIsoSessions();
|
|
260
326
|
setInterval(() => {
|
|
@@ -335,6 +401,7 @@ export async function serve() {
|
|
|
335
401
|
if (reqUrl.pathname !== "/run") return new Response("rech server\n");
|
|
336
402
|
const denied = authCheck(req, key);
|
|
337
403
|
if (denied) return denied;
|
|
404
|
+
markActivity(); // a real command: this serve is not idle
|
|
338
405
|
|
|
339
406
|
const body = await req.json();
|
|
340
407
|
let args: string[];
|
|
@@ -543,8 +610,10 @@ export async function serve() {
|
|
|
543
610
|
|
|
544
611
|
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
545
612
|
|
|
546
|
-
// Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS)
|
|
547
|
-
//
|
|
613
|
+
// Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS). A command that RETURNS usually
|
|
614
|
+
// proves the relay answered — but NOT when the CLI short-circuited locally without ever
|
|
615
|
+
// reaching it (provesRelayAlive). Treating those as success reset the global streak on
|
|
616
|
+
// every per-session heal, so WATCHDOG_TIMEOUTS could never be reached. See that helper.
|
|
548
617
|
if (timedOut) {
|
|
549
618
|
consecutiveTimeouts++;
|
|
550
619
|
const streak = (sessionTimeouts.get(namespacedSession) ?? 0) + 1;
|
|
@@ -565,9 +634,14 @@ export async function serve() {
|
|
|
565
634
|
log(`relay wedged: ${consecutiveTimeouts} consecutive timeouts, no success between — exiting for oxmgr (--restart always) to respawn clean; extension WS will reconnect (Chrome untouched)`);
|
|
566
635
|
setTimeout(() => process.exit(1), 100); // brief delay to flush this response
|
|
567
636
|
}
|
|
568
|
-
} else {
|
|
637
|
+
} else if (provesRelayAlive({ stdout, stderr })) {
|
|
569
638
|
consecutiveTimeouts = 0;
|
|
570
639
|
sessionTimeouts.delete(namespacedSession);
|
|
640
|
+
} else {
|
|
641
|
+
// Returned, but proved nothing about the relay (local short-circuit). Leave BOTH
|
|
642
|
+
// streaks untouched: not a timeout, so don't punish it — but don't let it forgive
|
|
643
|
+
// the timeouts that came before, which is the bug this branch exists to fix.
|
|
644
|
+
log(`inconclusive: session=${namespacedSession} did not reach the relay — streaks kept (globalStreak=${consecutiveTimeouts})`);
|
|
571
645
|
}
|
|
572
646
|
|
|
573
647
|
// Detect files mentioned in output
|
package/serve.ts
CHANGED
|
@@ -84,6 +84,36 @@ function noteSession(sess: string, now: number): void {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Did this returned command actually prove the relay is answering?
|
|
89
|
+
*
|
|
90
|
+
* The watchdog above clears its streaks whenever a command returns instead of timing out,
|
|
91
|
+
* on the reasoning that a reply — even a failing one — means the relay is alive. That holds
|
|
92
|
+
* for errors produced BY the browser, but not for ones the CLI raises locally before it ever
|
|
93
|
+
* opens a connection. `Browser '<id>' is not open` is the important case: it is emitted the
|
|
94
|
+
* instant a session is missing, which is precisely the state the per-session heal CREATES by
|
|
95
|
+
* closing a wedged session. So the sequence was:
|
|
96
|
+
*
|
|
97
|
+
* timeout, timeout → per-session heal closes the session
|
|
98
|
+
* "browser is not open" → returned fast, counted as success, globalStreak reset to 0
|
|
99
|
+
* timeout, timeout → heal again … forever
|
|
100
|
+
*
|
|
101
|
+
* WATCHDOG_TIMEOUTS (3) could therefore never be reached on a genuinely dead relay, and the
|
|
102
|
+
* daemon-level restart that exists to fix exactly that never fired — leaving `oxmgr restart
|
|
103
|
+
* rechrome` by hand as the only cure (observed 2026-08-05: three manual restarts in one
|
|
104
|
+
* session, each buying only a handful of commands).
|
|
105
|
+
*
|
|
106
|
+
* Returning false here does NOT count against the relay; it just refuses to forgive the
|
|
107
|
+
* timeouts already recorded.
|
|
108
|
+
*/
|
|
109
|
+
export function provesRelayAlive(o: { stdout: string; stderr: string }): boolean {
|
|
110
|
+
const out = `${o.stdout ?? ""}\n${o.stderr ?? ""}`;
|
|
111
|
+
// Both spellings the CLI uses for "no such session" (cli-client/output.ts).
|
|
112
|
+
if (/Browser '[^']*' is not open/i.test(out)) return false;
|
|
113
|
+
if (/is not open, please run open first/i.test(out)) return false;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
87
117
|
export function inferSilentExtensionFailure(options: {
|
|
88
118
|
status: number;
|
|
89
119
|
stdout: string;
|
|
@@ -248,6 +278,27 @@ async function freeStalePort(port: number): Promise<void> {
|
|
|
248
278
|
await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
|
|
249
279
|
}
|
|
250
280
|
|
|
281
|
+
// --- Foreground/orphan self-exit ---------------------------------------------------
|
|
282
|
+
// A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
|
|
283
|
+
// process-manager safety net: when the agent that spawned it exits, the OS re-parents
|
|
284
|
+
// the orphan to init (ppid 1) and it lives forever — the resource leak behind
|
|
285
|
+
// PERFORMANCE-EVENT.md. The managed daemon (oxmgr `--restart always`) keeps the
|
|
286
|
+
// process as its own child, so its ppid is stable and non-1 and it is never flagged.
|
|
287
|
+
// Once a serve is orphaned AND has had no real /run for the idle timeout, it exits so
|
|
288
|
+
// the leak self-heals. /ping is deliberately NOT activity (the tray polls it every 2s
|
|
289
|
+
// and would otherwise keep an orphan alive forever).
|
|
290
|
+
const ORPHAN_POLL_INTERVAL_MS = 15_000;
|
|
291
|
+
const ORPHAN_IDLE_EXIT_MS = Number(process.env.RECH_SERVE_IDLE_TIMEOUT_MS) || 5 * 60_000;
|
|
292
|
+
|
|
293
|
+
// Pure decision predicate (testable). idleTimeoutMs <= 0 disables orphan self-exit.
|
|
294
|
+
export function shouldExitOrphanedServe(opts: {
|
|
295
|
+
orphaned: boolean;
|
|
296
|
+
idleMs: number;
|
|
297
|
+
idleTimeoutMs: number;
|
|
298
|
+
}): boolean {
|
|
299
|
+
return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
|
|
300
|
+
}
|
|
301
|
+
|
|
251
302
|
export async function serve() {
|
|
252
303
|
const url = await getOrCreateUrl();
|
|
253
304
|
const { key, port } = parseUrl(url);
|
|
@@ -255,6 +306,21 @@ export async function serve() {
|
|
|
255
306
|
const workDir = join(RECH_DIR, "output");
|
|
256
307
|
mkdirSync(workDir, { recursive: true });
|
|
257
308
|
|
|
309
|
+
// Foreground/orphan self-exit: a serve whose parent has been re-parented to init
|
|
310
|
+
// (ppid 1) is a leaked foreground serve. Poll for that, track the last real /run,
|
|
311
|
+
// and exit once orphaned + idle so an agent that died without shutting us down
|
|
312
|
+
// doesn't leave a daemon behind.
|
|
313
|
+
let orphaned = false;
|
|
314
|
+
let idleSince = Date.now();
|
|
315
|
+
const markActivity = () => { idleSince = Date.now(); };
|
|
316
|
+
setInterval(() => {
|
|
317
|
+
if (process.ppid === 1) orphaned = true;
|
|
318
|
+
if (shouldExitOrphanedServe({ orphaned, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
|
|
319
|
+
log(`orphaned foreground serve idle ${Math.round((Date.now() - idleSince) / 1000)}s — exiting (spawning agent is gone)`);
|
|
320
|
+
process.exit(0);
|
|
321
|
+
}
|
|
322
|
+
}, ORPHAN_POLL_INTERVAL_MS);
|
|
323
|
+
|
|
258
324
|
// Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
|
|
259
325
|
adoptOrphanedIsoSessions();
|
|
260
326
|
setInterval(() => {
|
|
@@ -335,6 +401,7 @@ export async function serve() {
|
|
|
335
401
|
if (reqUrl.pathname !== "/run") return new Response("rech server\n");
|
|
336
402
|
const denied = authCheck(req, key);
|
|
337
403
|
if (denied) return denied;
|
|
404
|
+
markActivity(); // a real command: this serve is not idle
|
|
338
405
|
|
|
339
406
|
const body = await req.json();
|
|
340
407
|
let args: string[];
|
|
@@ -543,8 +610,10 @@ export async function serve() {
|
|
|
543
610
|
|
|
544
611
|
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
545
612
|
|
|
546
|
-
// Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS)
|
|
547
|
-
//
|
|
613
|
+
// Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS). A command that RETURNS usually
|
|
614
|
+
// proves the relay answered — but NOT when the CLI short-circuited locally without ever
|
|
615
|
+
// reaching it (provesRelayAlive). Treating those as success reset the global streak on
|
|
616
|
+
// every per-session heal, so WATCHDOG_TIMEOUTS could never be reached. See that helper.
|
|
548
617
|
if (timedOut) {
|
|
549
618
|
consecutiveTimeouts++;
|
|
550
619
|
const streak = (sessionTimeouts.get(namespacedSession) ?? 0) + 1;
|
|
@@ -565,9 +634,14 @@ export async function serve() {
|
|
|
565
634
|
log(`relay wedged: ${consecutiveTimeouts} consecutive timeouts, no success between — exiting for oxmgr (--restart always) to respawn clean; extension WS will reconnect (Chrome untouched)`);
|
|
566
635
|
setTimeout(() => process.exit(1), 100); // brief delay to flush this response
|
|
567
636
|
}
|
|
568
|
-
} else {
|
|
637
|
+
} else if (provesRelayAlive({ stdout, stderr })) {
|
|
569
638
|
consecutiveTimeouts = 0;
|
|
570
639
|
sessionTimeouts.delete(namespacedSession);
|
|
640
|
+
} else {
|
|
641
|
+
// Returned, but proved nothing about the relay (local short-circuit). Leave BOTH
|
|
642
|
+
// streaks untouched: not a timeout, so don't punish it — but don't let it forgive
|
|
643
|
+
// the timeouts that came before, which is the bug this branch exists to fix.
|
|
644
|
+
log(`inconclusive: session=${namespacedSession} did not reach the relay — streaks kept (globalStreak=${consecutiveTimeouts})`);
|
|
571
645
|
}
|
|
572
646
|
|
|
573
647
|
// Detect files mentioned in output
|