rechrome 1.36.0 → 1.36.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/package.json +1 -1
- package/rechrome.js +90 -14
- package/rechrome.ts +90 -14
- package/serve.js +110 -23
- package/serve.ts +110 -23
package/README.md
CHANGED
|
@@ -164,6 +164,11 @@ If no supported daemon manager is available, setup asks before installing `oxmgr
|
|
|
164
164
|
when launched with npx. Pass `--yes` to approve this without prompting,
|
|
165
165
|
for example `bunx rechrome setup --profile Default --yes`.
|
|
166
166
|
|
|
167
|
+
On macOS, a daemon started by launchd or oxmgr cannot read Chrome's profile folder
|
|
168
|
+
until its bun binary has Full Disk Access. `rech status` and the serve log report
|
|
169
|
+
this and name the binary. See [Keeping rech serve running on macOS](docs/macos-daemon.md)
|
|
170
|
+
for the grant, a LaunchAgent, and the terminal keep-alive alternative.
|
|
171
|
+
|
|
167
172
|
Agents setting up Chrome on macOS can use the [rechrome setup skill](skills/rechrome-setup/SKILL.md),
|
|
168
173
|
which covers native extension installation with Computer Use and CLI connection verification.
|
|
169
174
|
`rech setup` detects Codex and Claude Code environment hints and prints agent-specific guidance;
|
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -828,18 +828,53 @@ const CHROME_LOCAL_STATE_PATHS = () => {
|
|
|
828
828
|
|
|
829
829
|
type ChromeProfileInfo = { user_name?: string; name?: string };
|
|
830
830
|
|
|
831
|
+
/**
|
|
832
|
+
* macOS privacy (TCC) blocks a process launched by launchd/oxmgr from Chrome's data folder unless
|
|
833
|
+
* its binary has Full Disk Access; a Terminal or agent session inherits the grant. Turn that EPERM
|
|
834
|
+
* into the fix. Undefined for any other error (or platform).
|
|
835
|
+
*/
|
|
836
|
+
export function chromeDataAccessHint(error: unknown, execPath = process.execPath, platform = process.platform): string | undefined {
|
|
837
|
+
const code = (error as { code?: string } | null)?.code;
|
|
838
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
839
|
+
if (platform !== "darwin" || !(code === "EPERM" || code === "EACCES" || /EPERM|operation not permitted/i.test(message))) return undefined;
|
|
840
|
+
return `macOS privacy settings (TCC) block ${execPath} from reading Chrome's profile folder. ` +
|
|
841
|
+
`Grant it Full Disk Access: System Settings → Privacy & Security → Full Disk Access → +, press ⌘⇧G, paste ${execPath}, ` +
|
|
842
|
+
`then restart rech serve. Or run rech serve from Terminal, which already has access (see docs/macos-daemon.md).`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** Why the last readChromeProfileCache() returned null (undefined when it succeeded). */
|
|
846
|
+
let profileCacheProblem: string | undefined;
|
|
847
|
+
|
|
831
848
|
export async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
|
|
849
|
+
profileCacheProblem = undefined;
|
|
850
|
+
const problems: string[] = [];
|
|
832
851
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
833
852
|
const f = file(statePath);
|
|
834
853
|
if (!(await f.exists())) continue;
|
|
835
854
|
try {
|
|
836
855
|
const data = JSON.parse(await f.text());
|
|
837
|
-
|
|
838
|
-
|
|
856
|
+
if (data?.profile?.info_cache) return data.profile.info_cache;
|
|
857
|
+
problems.push(`${statePath} has no profile list`);
|
|
858
|
+
} catch (error) {
|
|
859
|
+
const hint = chromeDataAccessHint(error);
|
|
860
|
+
problems.push(`${statePath} is unreadable (${error instanceof Error ? error.message : String(error)})${hint ? `. ${hint}` : ""}`);
|
|
861
|
+
}
|
|
839
862
|
}
|
|
863
|
+
profileCacheProblem = problems.join("; ") || "Chrome's Local State file was not found";
|
|
840
864
|
return null;
|
|
841
865
|
}
|
|
842
866
|
|
|
867
|
+
/**
|
|
868
|
+
* Say so (once per process) when profile emails/names are unavailable, so a bare folder name
|
|
869
|
+
* like "Profile 1" is never mistaken for the whole story.
|
|
870
|
+
*/
|
|
871
|
+
let warnedProfileCache = false;
|
|
872
|
+
export function warnIfProfileNamesUnavailable(cache: Record<string, ChromeProfileInfo> | null): void {
|
|
873
|
+
if (cache || warnedProfileCache) return;
|
|
874
|
+
warnedProfileCache = true;
|
|
875
|
+
console.error(`[rech] Chrome profile emails and names are unavailable (${profileCacheProblem ?? "Local State not read"}); profiles are shown by folder name only.`);
|
|
876
|
+
}
|
|
877
|
+
|
|
843
878
|
export function resolveChromeProfileSelector(
|
|
844
879
|
profiles: Array<[string, ChromeProfileInfo]>,
|
|
845
880
|
selector: string,
|
|
@@ -1307,6 +1342,7 @@ async function listRemoteProfiles(url: string): Promise<void> {
|
|
|
1307
1342
|
|
|
1308
1343
|
async function listProfiles(): Promise<void> {
|
|
1309
1344
|
const [cache, registry, root] = await Promise.all([readChromeProfileCache(), readTokenRegistry(), findChromeUserDataDir()]);
|
|
1345
|
+
warnIfProfileNamesUnavailable(cache);
|
|
1310
1346
|
const profiles = buildProfileRows(cache, registry, root);
|
|
1311
1347
|
const listeners = await readListeners();
|
|
1312
1348
|
const url = process.env[ENV_KEY];
|
|
@@ -1406,7 +1442,7 @@ export function registeredProfileChoices(registry: Record<string, TokenEntry>, c
|
|
|
1406
1442
|
const info = !userDataDir || defaultDirs.includes(userDataDir) ? cache?.[dir] : undefined;
|
|
1407
1443
|
const names = [info?.name, info?.user_name, key].filter((n): n is string => !!n && n !== dir);
|
|
1408
1444
|
const shown = [...new Set(names)];
|
|
1409
|
-
return { label: shown.length ? `${shown.join(" · ")} [${dir}]` : dir
|
|
1445
|
+
return { label: shown.length ? `${shown.join(" · ")} [${dir}]` : `${dir} (no email or name known)`, value: key };
|
|
1410
1446
|
});
|
|
1411
1447
|
}
|
|
1412
1448
|
|
|
@@ -1640,6 +1676,7 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1640
1676
|
const interactive = isInteractive();
|
|
1641
1677
|
const registry = await readTokenRegistry();
|
|
1642
1678
|
const cache = await readChromeProfileCache();
|
|
1679
|
+
warnIfProfileNamesUnavailable(cache);
|
|
1643
1680
|
const cancelled = () => new Error("Cancelled; nothing shared.");
|
|
1644
1681
|
let config = await readListeners();
|
|
1645
1682
|
const current = resolveEffectiveProfile(url ? parseUrl(url).profileDirectory : undefined);
|
|
@@ -1999,7 +2036,16 @@ export function unknownCommandHint(output: string, rechCommands: Iterable<string
|
|
|
1999
2036
|
].join("\n");
|
|
2000
2037
|
}
|
|
2001
2038
|
|
|
2002
|
-
|
|
2039
|
+
/**
|
|
2040
|
+
* An `open` whose extension bridge never connected. Typical right after Chrome restarts: the
|
|
2041
|
+
* relay's connect page reaches no live worker, yet a fresh bridge moments later connects at once
|
|
2042
|
+
* (seen 2026-10-17: a timeout, then `rech setup`'s probe connected in 0.1s). Worth one retry.
|
|
2043
|
+
*/
|
|
2044
|
+
export function isBridgeTimeout(args: string[], result: { status: number; stderr?: string }): boolean {
|
|
2045
|
+
return args.find(a => !a.startsWith("-")) === "open" && result.status !== 0 && /Extension connection timeout/i.test(result.stderr ?? "");
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
async function run(url: string, args: string[], overrideEnv?: Record<string, string>, opts: { verbatim?: boolean; bridgeRetried?: boolean } = {}) {
|
|
2003
2049
|
// Match the underlying CLI's command names while accepting the short forms humans
|
|
2004
2050
|
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
2005
2051
|
args = normalizeCommandArgs(args);
|
|
@@ -2020,6 +2066,13 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2020
2066
|
({ args, upload } = await prepareStdinUpload(args, process.stdin));
|
|
2021
2067
|
}
|
|
2022
2068
|
const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity, false, upload);
|
|
2069
|
+
if (!opts.bridgeRetried && isBridgeTimeout(args, { status, stderr })) {
|
|
2070
|
+
// Close the failed session so the retry gets a fresh cliDaemon and relay. callServe re-reads
|
|
2071
|
+
// the extension's live token from the profile, so a re-minted token heals here too.
|
|
2072
|
+
console.error(`[rech] the Chrome extension bridge did not answer (common right after Chrome restarts); retrying once with a fresh connection…`);
|
|
2073
|
+
await callServe(url, [...args.filter(a => /^-s=/.test(a)), "close"], overrideEnv, identity).catch(() => {});
|
|
2074
|
+
return run(url, args, overrideEnv, { ...opts, bridgeRetried: true });
|
|
2075
|
+
}
|
|
2023
2076
|
|
|
2024
2077
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
2025
2078
|
if (existingSession && isOpenWithUrl) {
|
|
@@ -2039,9 +2092,12 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2039
2092
|
// The daemon resolves registered profiles' bridge tokens itself and then asks for a reload;
|
|
2040
2093
|
// only blame a missing install when neither side had credentials.
|
|
2041
2094
|
const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] || /reload the .*extension/i.test(stderr);
|
|
2095
|
+
const profileArg = displayProfile ? ` --profile ${JSON.stringify(displayProfile)}` : "";
|
|
2042
2096
|
const last = hasToken
|
|
2043
|
-
? ` -x: extension did not connect (
|
|
2044
|
-
|
|
2097
|
+
? ` -x: extension did not connect${opts.bridgeRetried ? " (also after an automatic retry)" : ""} -> extension[degraded]\n` +
|
|
2098
|
+
`[rech] reload "Playwright MCP Bridge" at chrome://extensions${displayProfile ? ` in ${displayProfile}'s window` : ""}, then retry; ` +
|
|
2099
|
+
`or run \`rech setup${profileArg}\`, which re-detects the extension and token and verifies the connection.`
|
|
2100
|
+
: ` -> extension[not installed] (run: rech setup${profileArg})`;
|
|
2045
2101
|
console.error(`[rech] rech-client -> rech-server[ok] -> playwright[ok]\n${last}`);
|
|
2046
2102
|
}
|
|
2047
2103
|
if (stderr.includes("Browser '") && stderr.includes("is not open")) {
|
|
@@ -2076,6 +2132,26 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2076
2132
|
process.exit(status);
|
|
2077
2133
|
}
|
|
2078
2134
|
|
|
2135
|
+
/** The daemon's Playwright CLI predates `open --wait` (rechrome <= 1.35.1 bundled one). */
|
|
2136
|
+
export function cliRejectsWaitOption(output: string): boolean {
|
|
2137
|
+
return /Unknown options?:?[^\n]*?['"`]?--wait\b/i.test(output);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
/**
|
|
2141
|
+
* setup's bridge probe: open about:blank without waiting for a load. The daemon owns the CLI and
|
|
2142
|
+
* may be older than this client, so feature-check by behavior: if the CLI rejects `--wait`, probe
|
|
2143
|
+
* again without it rather than reporting a broken bridge.
|
|
2144
|
+
*/
|
|
2145
|
+
export async function probeBridgeOpen<T extends { status: number; stdout: string; stderr: string }>(
|
|
2146
|
+
call: (args: string[]) => Promise<T>,
|
|
2147
|
+
note: (message: string) => void = () => {},
|
|
2148
|
+
): Promise<T> {
|
|
2149
|
+
const probe = await call(["open", "about:blank", "--wait", "none"]);
|
|
2150
|
+
if (probe.status === 0 || !cliRejectsWaitOption(`${probe.stderr}\n${probe.stdout}`)) return probe;
|
|
2151
|
+
note("The daemon's Playwright CLI does not support `open --wait`; probing without it (update rechrome on the daemon host).");
|
|
2152
|
+
return call(["open", "about:blank"]);
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2079
2155
|
export type SetupPhase = "extension" | "token" | "bridge" | "error" | "save" | "ready";
|
|
2080
2156
|
|
|
2081
2157
|
export function createSetupGuide(extDistDir: string, profileDisplay: string) {
|
|
@@ -2536,6 +2612,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
2536
2612
|
[ENV_KEY]: serveUrl,
|
|
2537
2613
|
PWMCP_TEST_CONNECTION_TIMEOUT: process.env.PWMCP_TEST_CONNECTION_TIMEOUT || "30000",
|
|
2538
2614
|
PLAYWRIGHT_CLI: resolvedPlaywrightCli,
|
|
2615
|
+
RECH_SERVE_MANAGED: "1", // the manager restarts it; never idle-self-exit (see serve.js)
|
|
2539
2616
|
};
|
|
2540
2617
|
if (process.env.RECH_HOST) daemonEnv.RECH_HOST = process.env.RECH_HOST;
|
|
2541
2618
|
if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
|
|
@@ -3294,7 +3371,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3294
3371
|
console.log(`✓ [1/5] Local management daemon verified; requested profile access: ${desiredBind}`);
|
|
3295
3372
|
|
|
3296
3373
|
const cache = await readChromeProfileCache();
|
|
3297
|
-
if (!cache) { console.error(
|
|
3374
|
+
if (!cache) { console.error(` Chrome profiles not found: ${profileCacheProblem ?? "Local State not read"}`); rl?.close(); process.exit(1); }
|
|
3298
3375
|
const userDataDir = await findChromeUserDataDir();
|
|
3299
3376
|
|
|
3300
3377
|
async function pickProfile(exclude: Set<string>): Promise<[string, ChromeProfileInfo] | null> {
|
|
@@ -3420,12 +3497,9 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3420
3497
|
guide?.takeRetry();
|
|
3421
3498
|
guide?.update("bridge", "Extension and token detected. Testing the browser connection…");
|
|
3422
3499
|
try {
|
|
3423
|
-
const probe = await
|
|
3424
|
-
rechUrl.toString(),
|
|
3425
|
-
|
|
3426
|
-
probeEnv,
|
|
3427
|
-
probeIdentity,
|
|
3428
|
-
true,
|
|
3500
|
+
const probe = await probeBridgeOpen(
|
|
3501
|
+
args => callServe(rechUrl.toString(), [`-s=${probeSession}`, ...args], probeEnv, probeIdentity, true),
|
|
3502
|
+
note => console.log(` ${note}`),
|
|
3429
3503
|
);
|
|
3430
3504
|
bridgeVerified = probe.status === 0;
|
|
3431
3505
|
if (bridgeVerified) {
|
|
@@ -3581,7 +3655,7 @@ async function status(): Promise<void> {
|
|
|
3581
3655
|
? await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(2000) }).catch(() => null)
|
|
3582
3656
|
: null;
|
|
3583
3657
|
const pingBody = pingResponse?.ok
|
|
3584
|
-
? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
3658
|
+
? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number; chromeAccessProblem?: string } | null
|
|
3585
3659
|
: null;
|
|
3586
3660
|
// Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
|
|
3587
3661
|
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
@@ -3602,6 +3676,8 @@ async function status(): Promise<void> {
|
|
|
3602
3676
|
// No oxmgr/pm2 on PATH must not take down `rech status`: report it instead of throwing.
|
|
3603
3677
|
let mgr: DaemonManager | undefined;
|
|
3604
3678
|
if (isHost) try { mgr = daemonManager(); } catch { /* reported below */ }
|
|
3679
|
+
if (pingBody?.chromeAccessProblem)
|
|
3680
|
+
console.log(`chrome: ⚠ daemon ${pingBody.chromeAccessProblem}`);
|
|
3605
3681
|
if (pingBody?.degraded)
|
|
3606
3682
|
console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; ${isHost ? `force it now with \`${mgr?.id ?? "oxmgr"} restart ${PM_PROCESS_NAME}\`` : "the daemon host can restart it"}`);
|
|
3607
3683
|
if (isHost) {
|
package/rechrome.ts
CHANGED
|
@@ -828,18 +828,53 @@ const CHROME_LOCAL_STATE_PATHS = () => {
|
|
|
828
828
|
|
|
829
829
|
type ChromeProfileInfo = { user_name?: string; name?: string };
|
|
830
830
|
|
|
831
|
+
/**
|
|
832
|
+
* macOS privacy (TCC) blocks a process launched by launchd/oxmgr from Chrome's data folder unless
|
|
833
|
+
* its binary has Full Disk Access; a Terminal or agent session inherits the grant. Turn that EPERM
|
|
834
|
+
* into the fix. Undefined for any other error (or platform).
|
|
835
|
+
*/
|
|
836
|
+
export function chromeDataAccessHint(error: unknown, execPath = process.execPath, platform = process.platform): string | undefined {
|
|
837
|
+
const code = (error as { code?: string } | null)?.code;
|
|
838
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
839
|
+
if (platform !== "darwin" || !(code === "EPERM" || code === "EACCES" || /EPERM|operation not permitted/i.test(message))) return undefined;
|
|
840
|
+
return `macOS privacy settings (TCC) block ${execPath} from reading Chrome's profile folder. ` +
|
|
841
|
+
`Grant it Full Disk Access: System Settings → Privacy & Security → Full Disk Access → +, press ⌘⇧G, paste ${execPath}, ` +
|
|
842
|
+
`then restart rech serve. Or run rech serve from Terminal, which already has access (see docs/macos-daemon.md).`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** Why the last readChromeProfileCache() returned null (undefined when it succeeded). */
|
|
846
|
+
let profileCacheProblem: string | undefined;
|
|
847
|
+
|
|
831
848
|
export async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
|
|
849
|
+
profileCacheProblem = undefined;
|
|
850
|
+
const problems: string[] = [];
|
|
832
851
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
833
852
|
const f = file(statePath);
|
|
834
853
|
if (!(await f.exists())) continue;
|
|
835
854
|
try {
|
|
836
855
|
const data = JSON.parse(await f.text());
|
|
837
|
-
|
|
838
|
-
|
|
856
|
+
if (data?.profile?.info_cache) return data.profile.info_cache;
|
|
857
|
+
problems.push(`${statePath} has no profile list`);
|
|
858
|
+
} catch (error) {
|
|
859
|
+
const hint = chromeDataAccessHint(error);
|
|
860
|
+
problems.push(`${statePath} is unreadable (${error instanceof Error ? error.message : String(error)})${hint ? `. ${hint}` : ""}`);
|
|
861
|
+
}
|
|
839
862
|
}
|
|
863
|
+
profileCacheProblem = problems.join("; ") || "Chrome's Local State file was not found";
|
|
840
864
|
return null;
|
|
841
865
|
}
|
|
842
866
|
|
|
867
|
+
/**
|
|
868
|
+
* Say so (once per process) when profile emails/names are unavailable, so a bare folder name
|
|
869
|
+
* like "Profile 1" is never mistaken for the whole story.
|
|
870
|
+
*/
|
|
871
|
+
let warnedProfileCache = false;
|
|
872
|
+
export function warnIfProfileNamesUnavailable(cache: Record<string, ChromeProfileInfo> | null): void {
|
|
873
|
+
if (cache || warnedProfileCache) return;
|
|
874
|
+
warnedProfileCache = true;
|
|
875
|
+
console.error(`[rech] Chrome profile emails and names are unavailable (${profileCacheProblem ?? "Local State not read"}); profiles are shown by folder name only.`);
|
|
876
|
+
}
|
|
877
|
+
|
|
843
878
|
export function resolveChromeProfileSelector(
|
|
844
879
|
profiles: Array<[string, ChromeProfileInfo]>,
|
|
845
880
|
selector: string,
|
|
@@ -1307,6 +1342,7 @@ async function listRemoteProfiles(url: string): Promise<void> {
|
|
|
1307
1342
|
|
|
1308
1343
|
async function listProfiles(): Promise<void> {
|
|
1309
1344
|
const [cache, registry, root] = await Promise.all([readChromeProfileCache(), readTokenRegistry(), findChromeUserDataDir()]);
|
|
1345
|
+
warnIfProfileNamesUnavailable(cache);
|
|
1310
1346
|
const profiles = buildProfileRows(cache, registry, root);
|
|
1311
1347
|
const listeners = await readListeners();
|
|
1312
1348
|
const url = process.env[ENV_KEY];
|
|
@@ -1406,7 +1442,7 @@ export function registeredProfileChoices(registry: Record<string, TokenEntry>, c
|
|
|
1406
1442
|
const info = !userDataDir || defaultDirs.includes(userDataDir) ? cache?.[dir] : undefined;
|
|
1407
1443
|
const names = [info?.name, info?.user_name, key].filter((n): n is string => !!n && n !== dir);
|
|
1408
1444
|
const shown = [...new Set(names)];
|
|
1409
|
-
return { label: shown.length ? `${shown.join(" · ")} [${dir}]` : dir
|
|
1445
|
+
return { label: shown.length ? `${shown.join(" · ")} [${dir}]` : `${dir} (no email or name known)`, value: key };
|
|
1410
1446
|
});
|
|
1411
1447
|
}
|
|
1412
1448
|
|
|
@@ -1640,6 +1676,7 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
|
|
|
1640
1676
|
const interactive = isInteractive();
|
|
1641
1677
|
const registry = await readTokenRegistry();
|
|
1642
1678
|
const cache = await readChromeProfileCache();
|
|
1679
|
+
warnIfProfileNamesUnavailable(cache);
|
|
1643
1680
|
const cancelled = () => new Error("Cancelled; nothing shared.");
|
|
1644
1681
|
let config = await readListeners();
|
|
1645
1682
|
const current = resolveEffectiveProfile(url ? parseUrl(url).profileDirectory : undefined);
|
|
@@ -1999,7 +2036,16 @@ export function unknownCommandHint(output: string, rechCommands: Iterable<string
|
|
|
1999
2036
|
].join("\n");
|
|
2000
2037
|
}
|
|
2001
2038
|
|
|
2002
|
-
|
|
2039
|
+
/**
|
|
2040
|
+
* An `open` whose extension bridge never connected. Typical right after Chrome restarts: the
|
|
2041
|
+
* relay's connect page reaches no live worker, yet a fresh bridge moments later connects at once
|
|
2042
|
+
* (seen 2026-10-17: a timeout, then `rech setup`'s probe connected in 0.1s). Worth one retry.
|
|
2043
|
+
*/
|
|
2044
|
+
export function isBridgeTimeout(args: string[], result: { status: number; stderr?: string }): boolean {
|
|
2045
|
+
return args.find(a => !a.startsWith("-")) === "open" && result.status !== 0 && /Extension connection timeout/i.test(result.stderr ?? "");
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
async function run(url: string, args: string[], overrideEnv?: Record<string, string>, opts: { verbatim?: boolean; bridgeRetried?: boolean } = {}) {
|
|
2003
2049
|
// Match the underlying CLI's command names while accepting the short forms humans
|
|
2004
2050
|
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
2005
2051
|
args = normalizeCommandArgs(args);
|
|
@@ -2020,6 +2066,13 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2020
2066
|
({ args, upload } = await prepareStdinUpload(args, process.stdin));
|
|
2021
2067
|
}
|
|
2022
2068
|
const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity, false, upload);
|
|
2069
|
+
if (!opts.bridgeRetried && isBridgeTimeout(args, { status, stderr })) {
|
|
2070
|
+
// Close the failed session so the retry gets a fresh cliDaemon and relay. callServe re-reads
|
|
2071
|
+
// the extension's live token from the profile, so a re-minted token heals here too.
|
|
2072
|
+
console.error(`[rech] the Chrome extension bridge did not answer (common right after Chrome restarts); retrying once with a fresh connection…`);
|
|
2073
|
+
await callServe(url, [...args.filter(a => /^-s=/.test(a)), "close"], overrideEnv, identity).catch(() => {});
|
|
2074
|
+
return run(url, args, overrideEnv, { ...opts, bridgeRetried: true });
|
|
2075
|
+
}
|
|
2023
2076
|
|
|
2024
2077
|
const isOpenWithUrl = args[0] === "open" && args.length > 1;
|
|
2025
2078
|
if (existingSession && isOpenWithUrl) {
|
|
@@ -2039,9 +2092,12 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2039
2092
|
// The daemon resolves registered profiles' bridge tokens itself and then asks for a reload;
|
|
2040
2093
|
// only blame a missing install when neither side had credentials.
|
|
2041
2094
|
const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] || /reload the .*extension/i.test(stderr);
|
|
2095
|
+
const profileArg = displayProfile ? ` --profile ${JSON.stringify(displayProfile)}` : "";
|
|
2042
2096
|
const last = hasToken
|
|
2043
|
-
? ` -x: extension did not connect (
|
|
2044
|
-
|
|
2097
|
+
? ` -x: extension did not connect${opts.bridgeRetried ? " (also after an automatic retry)" : ""} -> extension[degraded]\n` +
|
|
2098
|
+
`[rech] reload "Playwright MCP Bridge" at chrome://extensions${displayProfile ? ` in ${displayProfile}'s window` : ""}, then retry; ` +
|
|
2099
|
+
`or run \`rech setup${profileArg}\`, which re-detects the extension and token and verifies the connection.`
|
|
2100
|
+
: ` -> extension[not installed] (run: rech setup${profileArg})`;
|
|
2045
2101
|
console.error(`[rech] rech-client -> rech-server[ok] -> playwright[ok]\n${last}`);
|
|
2046
2102
|
}
|
|
2047
2103
|
if (stderr.includes("Browser '") && stderr.includes("is not open")) {
|
|
@@ -2076,6 +2132,26 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2076
2132
|
process.exit(status);
|
|
2077
2133
|
}
|
|
2078
2134
|
|
|
2135
|
+
/** The daemon's Playwright CLI predates `open --wait` (rechrome <= 1.35.1 bundled one). */
|
|
2136
|
+
export function cliRejectsWaitOption(output: string): boolean {
|
|
2137
|
+
return /Unknown options?:?[^\n]*?['"`]?--wait\b/i.test(output);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
/**
|
|
2141
|
+
* setup's bridge probe: open about:blank without waiting for a load. The daemon owns the CLI and
|
|
2142
|
+
* may be older than this client, so feature-check by behavior: if the CLI rejects `--wait`, probe
|
|
2143
|
+
* again without it rather than reporting a broken bridge.
|
|
2144
|
+
*/
|
|
2145
|
+
export async function probeBridgeOpen<T extends { status: number; stdout: string; stderr: string }>(
|
|
2146
|
+
call: (args: string[]) => Promise<T>,
|
|
2147
|
+
note: (message: string) => void = () => {},
|
|
2148
|
+
): Promise<T> {
|
|
2149
|
+
const probe = await call(["open", "about:blank", "--wait", "none"]);
|
|
2150
|
+
if (probe.status === 0 || !cliRejectsWaitOption(`${probe.stderr}\n${probe.stdout}`)) return probe;
|
|
2151
|
+
note("The daemon's Playwright CLI does not support `open --wait`; probing without it (update rechrome on the daemon host).");
|
|
2152
|
+
return call(["open", "about:blank"]);
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2079
2155
|
export type SetupPhase = "extension" | "token" | "bridge" | "error" | "save" | "ready";
|
|
2080
2156
|
|
|
2081
2157
|
export function createSetupGuide(extDistDir: string, profileDisplay: string) {
|
|
@@ -2536,6 +2612,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
|
|
|
2536
2612
|
[ENV_KEY]: serveUrl,
|
|
2537
2613
|
PWMCP_TEST_CONNECTION_TIMEOUT: process.env.PWMCP_TEST_CONNECTION_TIMEOUT || "30000",
|
|
2538
2614
|
PLAYWRIGHT_CLI: resolvedPlaywrightCli,
|
|
2615
|
+
RECH_SERVE_MANAGED: "1", // the manager restarts it; never idle-self-exit (see serve.ts)
|
|
2539
2616
|
};
|
|
2540
2617
|
if (process.env.RECH_HOST) daemonEnv.RECH_HOST = process.env.RECH_HOST;
|
|
2541
2618
|
if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
|
|
@@ -3294,7 +3371,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3294
3371
|
console.log(`✓ [1/5] Local management daemon verified; requested profile access: ${desiredBind}`);
|
|
3295
3372
|
|
|
3296
3373
|
const cache = await readChromeProfileCache();
|
|
3297
|
-
if (!cache) { console.error(
|
|
3374
|
+
if (!cache) { console.error(` Chrome profiles not found: ${profileCacheProblem ?? "Local State not read"}`); rl?.close(); process.exit(1); }
|
|
3298
3375
|
const userDataDir = await findChromeUserDataDir();
|
|
3299
3376
|
|
|
3300
3377
|
async function pickProfile(exclude: Set<string>): Promise<[string, ChromeProfileInfo] | null> {
|
|
@@ -3420,12 +3497,9 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
|
|
|
3420
3497
|
guide?.takeRetry();
|
|
3421
3498
|
guide?.update("bridge", "Extension and token detected. Testing the browser connection…");
|
|
3422
3499
|
try {
|
|
3423
|
-
const probe = await
|
|
3424
|
-
rechUrl.toString(),
|
|
3425
|
-
|
|
3426
|
-
probeEnv,
|
|
3427
|
-
probeIdentity,
|
|
3428
|
-
true,
|
|
3500
|
+
const probe = await probeBridgeOpen(
|
|
3501
|
+
args => callServe(rechUrl.toString(), [`-s=${probeSession}`, ...args], probeEnv, probeIdentity, true),
|
|
3502
|
+
note => console.log(` ${note}`),
|
|
3429
3503
|
);
|
|
3430
3504
|
bridgeVerified = probe.status === 0;
|
|
3431
3505
|
if (bridgeVerified) {
|
|
@@ -3581,7 +3655,7 @@ async function status(): Promise<void> {
|
|
|
3581
3655
|
? await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(2000) }).catch(() => null)
|
|
3582
3656
|
: null;
|
|
3583
3657
|
const pingBody = pingResponse?.ok
|
|
3584
|
-
? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number } | null
|
|
3658
|
+
? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number; chromeAccessProblem?: string } | null
|
|
3585
3659
|
: null;
|
|
3586
3660
|
// Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
|
|
3587
3661
|
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
@@ -3602,6 +3676,8 @@ async function status(): Promise<void> {
|
|
|
3602
3676
|
// No oxmgr/pm2 on PATH must not take down `rech status`: report it instead of throwing.
|
|
3603
3677
|
let mgr: DaemonManager | undefined;
|
|
3604
3678
|
if (isHost) try { mgr = daemonManager(); } catch { /* reported below */ }
|
|
3679
|
+
if (pingBody?.chromeAccessProblem)
|
|
3680
|
+
console.log(`chrome: ⚠ daemon ${pingBody.chromeAccessProblem}`);
|
|
3605
3681
|
if (pingBody?.degraded)
|
|
3606
3682
|
console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; ${isHost ? `force it now with \`${mgr?.id ?? "oxmgr"} restart ${PM_PROCESS_NAME}\`` : "the daemon host can restart it"}`);
|
|
3607
3683
|
if (isHost) {
|
package/serve.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
readTokenRegistry,
|
|
20
20
|
readChromeProfileCache,
|
|
21
21
|
checkTailscaleServe,
|
|
22
|
+
chromeDataAccessHint,
|
|
22
23
|
} from "./rechrome.js";
|
|
23
24
|
|
|
24
25
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
@@ -122,6 +123,16 @@ export function provesRelayAlive(o: { stdout: string; stderr: string }): boolean
|
|
|
122
123
|
return true;
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The relay checks Chrome's data folder for the installed extension before connecting. Under
|
|
128
|
+
* macOS TCC that read fails and it reports "Playwright Extension not found" although the
|
|
129
|
+
* extension is installed — name the real cause instead of sending the user to reinstall.
|
|
130
|
+
*/
|
|
131
|
+
export function explainExtensionNotFound(output: string, accessProblem: string | undefined): string | undefined {
|
|
132
|
+
if (!accessProblem || !/Playwright Extension not found in/i.test(output)) return undefined;
|
|
133
|
+
return `[rech] the extension is probably installed; this daemon ${accessProblem}\n`;
|
|
134
|
+
}
|
|
135
|
+
|
|
125
136
|
export function inferSilentExtensionFailure(options: {
|
|
126
137
|
status: number;
|
|
127
138
|
stdout: string;
|
|
@@ -256,24 +267,63 @@ export function splitCommand(cmd: string): string[] {
|
|
|
256
267
|
t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t);
|
|
257
268
|
}
|
|
258
269
|
|
|
259
|
-
|
|
270
|
+
/** Why this daemon cannot read Chrome's data folder; reported by /ping so `rech status` shows it. */
|
|
271
|
+
let chromeAccessProblem: string | undefined;
|
|
272
|
+
/** Log (once) why Chrome's data folder is unreadable — under launchd/oxmgr on macOS this is TCC. */
|
|
273
|
+
export function warnChromeDataAccess(path: string, error: unknown): void {
|
|
274
|
+
if (chromeAccessProblem) return;
|
|
275
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
276
|
+
chromeAccessProblem = `cannot read ${path} (${message}). ${chromeDataAccessHint(error) ?? "Profile names and emails cannot be resolved here."}`;
|
|
277
|
+
log(chromeAccessProblem);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** A profile name or email could not be mapped to its Chrome folder because Local State is unreadable. */
|
|
281
|
+
export class ProfileResolutionError extends Error {
|
|
282
|
+
constructor(readonly profile: string, statePath: string, cause: unknown) {
|
|
283
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
284
|
+
const hint = chromeDataAccessHint(cause);
|
|
285
|
+
super(`[rech] cannot find the Chrome folder for profile "${profile}": ${statePath} is unreadable (${message}). Refusing, so Chrome does not create a new empty profile named "${profile}".\n`
|
|
286
|
+
+ ` Register it once (records its folder in ~/.rechrome): rech setup --profile ${JSON.stringify(profile)}\n`
|
|
287
|
+
+ ` Or select the folder directly, e.g. rech --profile "Profile 1" …\n`
|
|
288
|
+
+ (hint ? ` ${hint}\n` : ""));
|
|
289
|
+
this.name = "ProfileResolutionError";
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function resolveProfileDirectory(
|
|
294
|
+
nameOrEmail: string,
|
|
295
|
+
registry?: Record<string, { profileDir?: string }>,
|
|
296
|
+
statePaths: string[] = [
|
|
297
|
+
join(HOME || "~", "Library/Application Support/Google/Chrome/Local State"),
|
|
298
|
+
join(HOME || "~", ".config/google-chrome/Local State"),
|
|
299
|
+
join(HOME || "~", "AppData/Local/Google/Chrome/User Data/Local State"),
|
|
300
|
+
],
|
|
301
|
+
): Promise<string> {
|
|
260
302
|
if (/^(Default|Profile \d+)$/i.test(nameOrEmail)) return nameOrEmail;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
303
|
+
// setup recorded each registered profile's folder in ~/.rechrome, which macOS privacy (TCC)
|
|
304
|
+
// never guards, so a launchd/oxmgr daemon resolves it without touching Chrome's data folder.
|
|
305
|
+
const entry = (registry ?? await readTokenRegistry().catch(() => ({})) as Record<string, { profileDir?: string }>)[nameOrEmail];
|
|
306
|
+
if (entry?.profileDir) return entry.profileDir;
|
|
307
|
+
let unreadable: { path: string; error: unknown } | undefined;
|
|
308
|
+
for (const statePath of statePaths) {
|
|
309
|
+
try {
|
|
310
|
+
// Read without an exists() probe: under TCC the stat fails too, and exists() would report
|
|
311
|
+
// "missing" and hide the denial.
|
|
312
|
+
const data = JSON.parse(await file(statePath).text());
|
|
313
|
+
const cache: Record<string, any> = data?.profile?.info_cache ?? {};
|
|
314
|
+
for (const [dir, info] of Object.entries(cache)) {
|
|
315
|
+
if ([info.name, info.user_name, info.gaia_name].includes(nameOrEmail))
|
|
316
|
+
return dir;
|
|
317
|
+
}
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") continue;
|
|
320
|
+
warnChromeDataAccess(statePath, error);
|
|
321
|
+
unreadable ??= { path: statePath, error };
|
|
275
322
|
}
|
|
276
323
|
}
|
|
324
|
+
// Passing an email or display name through would make Chrome create a NEW empty profile folder
|
|
325
|
+
// literally named after it (--profile-directory=me@example.com) instead of opening the real one.
|
|
326
|
+
if (unreadable) throw new ProfileResolutionError(nameOrEmail, unreadable.path, unreadable.error);
|
|
277
327
|
return nameOrEmail;
|
|
278
328
|
}
|
|
279
329
|
|
|
@@ -397,15 +447,34 @@ async function freeStalePort(port: number, host: string): Promise<void> {
|
|
|
397
447
|
// the leak self-heals. /ping is deliberately NOT activity (the tray polls it every 2s
|
|
398
448
|
// and would otherwise keep an orphan alive forever).
|
|
399
449
|
const ORPHAN_POLL_INTERVAL_MS = 15_000;
|
|
400
|
-
const ORPHAN_IDLE_EXIT_MS =
|
|
450
|
+
const ORPHAN_IDLE_EXIT_MS = parseIdleTimeout(process.env.RECH_SERVE_IDLE_TIMEOUT_MS);
|
|
451
|
+
|
|
452
|
+
// `RECH_SERVE_IDLE_TIMEOUT_MS=0` must disable self-exit; `Number(v) || default` turned 0 back into 5 min.
|
|
453
|
+
export function parseIdleTimeout(raw: string | undefined, fallback = 5 * 60_000): number {
|
|
454
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
455
|
+
const value = Number(raw);
|
|
456
|
+
return Number.isFinite(value) ? value : fallback;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// A supervisor owns this serve's lifetime, so it is never a leaked foreground serve:
|
|
460
|
+
// RECH_SERVE_MANAGED=1 (set by `rech setup`'s oxmgr/pm2 install), or a LaunchAgent — launchd
|
|
461
|
+
// starts it with ppid 1 and its job label in XPC_SERVICE_NAME. ppid 1 alone is not enough:
|
|
462
|
+
// `setsid rech serve` or `(rech serve &)` also starts with ppid 1 and must still self-exit.
|
|
463
|
+
// Terminal apps set XPC_SERVICE_NAME to "0" or "application.<bundle id>…".
|
|
464
|
+
export function isManagedServe(startPpid: number, env: Record<string, string | undefined> = process.env): boolean {
|
|
465
|
+
if (env.RECH_SERVE_MANAGED === "1") return true;
|
|
466
|
+
const label = env.XPC_SERVICE_NAME;
|
|
467
|
+
return startPpid === 1 && !!label && label !== "0" && !label.startsWith("application.");
|
|
468
|
+
}
|
|
401
469
|
|
|
402
470
|
// Pure decision predicate (testable). idleTimeoutMs <= 0 disables orphan self-exit.
|
|
403
471
|
export function shouldExitOrphanedServe(opts: {
|
|
404
472
|
orphaned: boolean;
|
|
405
473
|
idleMs: number;
|
|
406
474
|
idleTimeoutMs: number;
|
|
475
|
+
managed?: boolean;
|
|
407
476
|
}): boolean {
|
|
408
|
-
return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
|
|
477
|
+
return !opts.managed && opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
|
|
409
478
|
}
|
|
410
479
|
|
|
411
480
|
export const LANDING_HEADERS = {
|
|
@@ -533,14 +602,24 @@ export async function serve() {
|
|
|
533
602
|
let orphaned = false;
|
|
534
603
|
let idleSince = Date.now();
|
|
535
604
|
const markActivity = () => { idleSince = Date.now(); };
|
|
605
|
+
const managed = isManagedServe(process.ppid);
|
|
606
|
+
if (managed) log("serve is supervised (launchd, a process manager, or RECH_SERVE_MANAGED=1) — idle self-exit disabled");
|
|
536
607
|
setInterval(() => {
|
|
537
608
|
if (process.ppid === 1) orphaned = true;
|
|
538
|
-
if (shouldExitOrphanedServe({ orphaned, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
|
|
609
|
+
if (shouldExitOrphanedServe({ orphaned, managed, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
|
|
539
610
|
log(`orphaned foreground serve idle ${Math.round((Date.now() - idleSince) / 1000)}s — exiting (spawning agent is gone)`);
|
|
540
611
|
process.exit(0);
|
|
541
612
|
}
|
|
542
613
|
}, ORPHAN_POLL_INTERVAL_MS);
|
|
543
614
|
|
|
615
|
+
// Say at startup, not on the first failed open, when macOS privacy blocks Chrome's data folder.
|
|
616
|
+
if (process.platform === "darwin") {
|
|
617
|
+
const chromeDir = join(HOME, "Library/Application Support/Google/Chrome");
|
|
618
|
+
try { readdirSync(chromeDir); } catch (error) {
|
|
619
|
+
if ((error as { code?: string }).code !== "ENOENT") warnChromeDataAccess(chromeDir, error);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
544
623
|
// Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
|
|
545
624
|
adoptOrphanedIsoSessions();
|
|
546
625
|
setInterval(() => {
|
|
@@ -615,12 +694,12 @@ export async function serve() {
|
|
|
615
694
|
// clients / `rech status` can see trouble without an active probe.
|
|
616
695
|
const degraded = consecutiveTimeouts > 0;
|
|
617
696
|
if (scoped || !reqUrl.searchParams.get("deep"))
|
|
618
|
-
return Response.json({ ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded });
|
|
697
|
+
return Response.json({ ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded, chromeAccessProblem });
|
|
619
698
|
// Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
|
|
620
699
|
// most-recently-used session — never a fresh one, which could spawn a browser window.
|
|
621
700
|
const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
622
701
|
if (!target)
|
|
623
|
-
return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded });
|
|
702
|
+
return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded, chromeAccessProblem });
|
|
624
703
|
const [pbin, ...pbinArgs] = splitCommand(resolvePlaywrightCli());
|
|
625
704
|
const probe = Bun.spawn([pbin, ...pbinArgs, "tab-list", `-s=${target}`], {
|
|
626
705
|
cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
@@ -634,7 +713,7 @@ export async function serve() {
|
|
|
634
713
|
const healthy = probeStatus !== -1;
|
|
635
714
|
return Response.json({
|
|
636
715
|
ok: healthy, bind: listenHost, relay: healthy ? "healthy" : "degraded",
|
|
637
|
-
consecutiveTimeouts, degraded: degraded || !healthy,
|
|
716
|
+
consecutiveTimeouts, degraded: degraded || !healthy, chromeAccessProblem,
|
|
638
717
|
});
|
|
639
718
|
}
|
|
640
719
|
// A browser opening a shared URL gets copyable connect instructions. The key stays in the
|
|
@@ -765,7 +844,14 @@ export async function serve() {
|
|
|
765
844
|
|
|
766
845
|
// Resolve profile name/email → directory name
|
|
767
846
|
if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
768
|
-
|
|
847
|
+
let resolved: string;
|
|
848
|
+
try {
|
|
849
|
+
resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
|
|
850
|
+
} catch (error) {
|
|
851
|
+
if (!(error instanceof ProfileResolutionError)) throw error;
|
|
852
|
+
log(`refused unresolvable profile "${error.profile}" (session=${namespacedSession})`);
|
|
853
|
+
return Response.json({ status: 1, stdout: "", stderr: error.message, files: [] });
|
|
854
|
+
}
|
|
769
855
|
if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
|
|
770
856
|
log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
|
|
771
857
|
passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
|
|
@@ -907,7 +993,8 @@ export async function serve() {
|
|
|
907
993
|
const handshakeTimeoutMs = Number.isFinite(configuredHandshakeTimeout) && configuredHandshakeTimeout > 0
|
|
908
994
|
? configuredHandshakeTimeout
|
|
909
995
|
: 30_000;
|
|
910
|
-
const
|
|
996
|
+
const tccNote = explainExtensionNotFound(`${stdout}\n${rawStderr}`, chromeAccessProblem);
|
|
997
|
+
const stderr = (tccNote ?? "") + inferSilentExtensionFailure({
|
|
911
998
|
status,
|
|
912
999
|
stdout,
|
|
913
1000
|
stderr: rawStderr,
|
package/serve.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
readTokenRegistry,
|
|
20
20
|
readChromeProfileCache,
|
|
21
21
|
checkTailscaleServe,
|
|
22
|
+
chromeDataAccessHint,
|
|
22
23
|
} from "./rechrome.ts";
|
|
23
24
|
|
|
24
25
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
@@ -122,6 +123,16 @@ export function provesRelayAlive(o: { stdout: string; stderr: string }): boolean
|
|
|
122
123
|
return true;
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The relay checks Chrome's data folder for the installed extension before connecting. Under
|
|
128
|
+
* macOS TCC that read fails and it reports "Playwright Extension not found" although the
|
|
129
|
+
* extension is installed — name the real cause instead of sending the user to reinstall.
|
|
130
|
+
*/
|
|
131
|
+
export function explainExtensionNotFound(output: string, accessProblem: string | undefined): string | undefined {
|
|
132
|
+
if (!accessProblem || !/Playwright Extension not found in/i.test(output)) return undefined;
|
|
133
|
+
return `[rech] the extension is probably installed; this daemon ${accessProblem}\n`;
|
|
134
|
+
}
|
|
135
|
+
|
|
125
136
|
export function inferSilentExtensionFailure(options: {
|
|
126
137
|
status: number;
|
|
127
138
|
stdout: string;
|
|
@@ -256,24 +267,63 @@ export function splitCommand(cmd: string): string[] {
|
|
|
256
267
|
t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t);
|
|
257
268
|
}
|
|
258
269
|
|
|
259
|
-
|
|
270
|
+
/** Why this daemon cannot read Chrome's data folder; reported by /ping so `rech status` shows it. */
|
|
271
|
+
let chromeAccessProblem: string | undefined;
|
|
272
|
+
/** Log (once) why Chrome's data folder is unreadable — under launchd/oxmgr on macOS this is TCC. */
|
|
273
|
+
export function warnChromeDataAccess(path: string, error: unknown): void {
|
|
274
|
+
if (chromeAccessProblem) return;
|
|
275
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
276
|
+
chromeAccessProblem = `cannot read ${path} (${message}). ${chromeDataAccessHint(error) ?? "Profile names and emails cannot be resolved here."}`;
|
|
277
|
+
log(chromeAccessProblem);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** A profile name or email could not be mapped to its Chrome folder because Local State is unreadable. */
|
|
281
|
+
export class ProfileResolutionError extends Error {
|
|
282
|
+
constructor(readonly profile: string, statePath: string, cause: unknown) {
|
|
283
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
284
|
+
const hint = chromeDataAccessHint(cause);
|
|
285
|
+
super(`[rech] cannot find the Chrome folder for profile "${profile}": ${statePath} is unreadable (${message}). Refusing, so Chrome does not create a new empty profile named "${profile}".\n`
|
|
286
|
+
+ ` Register it once (records its folder in ~/.rechrome): rech setup --profile ${JSON.stringify(profile)}\n`
|
|
287
|
+
+ ` Or select the folder directly, e.g. rech --profile "Profile 1" …\n`
|
|
288
|
+
+ (hint ? ` ${hint}\n` : ""));
|
|
289
|
+
this.name = "ProfileResolutionError";
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function resolveProfileDirectory(
|
|
294
|
+
nameOrEmail: string,
|
|
295
|
+
registry?: Record<string, { profileDir?: string }>,
|
|
296
|
+
statePaths: string[] = [
|
|
297
|
+
join(HOME || "~", "Library/Application Support/Google/Chrome/Local State"),
|
|
298
|
+
join(HOME || "~", ".config/google-chrome/Local State"),
|
|
299
|
+
join(HOME || "~", "AppData/Local/Google/Chrome/User Data/Local State"),
|
|
300
|
+
],
|
|
301
|
+
): Promise<string> {
|
|
260
302
|
if (/^(Default|Profile \d+)$/i.test(nameOrEmail)) return nameOrEmail;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
303
|
+
// setup recorded each registered profile's folder in ~/.rechrome, which macOS privacy (TCC)
|
|
304
|
+
// never guards, so a launchd/oxmgr daemon resolves it without touching Chrome's data folder.
|
|
305
|
+
const entry = (registry ?? await readTokenRegistry().catch(() => ({})) as Record<string, { profileDir?: string }>)[nameOrEmail];
|
|
306
|
+
if (entry?.profileDir) return entry.profileDir;
|
|
307
|
+
let unreadable: { path: string; error: unknown } | undefined;
|
|
308
|
+
for (const statePath of statePaths) {
|
|
309
|
+
try {
|
|
310
|
+
// Read without an exists() probe: under TCC the stat fails too, and exists() would report
|
|
311
|
+
// "missing" and hide the denial.
|
|
312
|
+
const data = JSON.parse(await file(statePath).text());
|
|
313
|
+
const cache: Record<string, any> = data?.profile?.info_cache ?? {};
|
|
314
|
+
for (const [dir, info] of Object.entries(cache)) {
|
|
315
|
+
if ([info.name, info.user_name, info.gaia_name].includes(nameOrEmail))
|
|
316
|
+
return dir;
|
|
317
|
+
}
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") continue;
|
|
320
|
+
warnChromeDataAccess(statePath, error);
|
|
321
|
+
unreadable ??= { path: statePath, error };
|
|
275
322
|
}
|
|
276
323
|
}
|
|
324
|
+
// Passing an email or display name through would make Chrome create a NEW empty profile folder
|
|
325
|
+
// literally named after it (--profile-directory=me@example.com) instead of opening the real one.
|
|
326
|
+
if (unreadable) throw new ProfileResolutionError(nameOrEmail, unreadable.path, unreadable.error);
|
|
277
327
|
return nameOrEmail;
|
|
278
328
|
}
|
|
279
329
|
|
|
@@ -397,15 +447,34 @@ async function freeStalePort(port: number, host: string): Promise<void> {
|
|
|
397
447
|
// the leak self-heals. /ping is deliberately NOT activity (the tray polls it every 2s
|
|
398
448
|
// and would otherwise keep an orphan alive forever).
|
|
399
449
|
const ORPHAN_POLL_INTERVAL_MS = 15_000;
|
|
400
|
-
const ORPHAN_IDLE_EXIT_MS =
|
|
450
|
+
const ORPHAN_IDLE_EXIT_MS = parseIdleTimeout(process.env.RECH_SERVE_IDLE_TIMEOUT_MS);
|
|
451
|
+
|
|
452
|
+
// `RECH_SERVE_IDLE_TIMEOUT_MS=0` must disable self-exit; `Number(v) || default` turned 0 back into 5 min.
|
|
453
|
+
export function parseIdleTimeout(raw: string | undefined, fallback = 5 * 60_000): number {
|
|
454
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
455
|
+
const value = Number(raw);
|
|
456
|
+
return Number.isFinite(value) ? value : fallback;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// A supervisor owns this serve's lifetime, so it is never a leaked foreground serve:
|
|
460
|
+
// RECH_SERVE_MANAGED=1 (set by `rech setup`'s oxmgr/pm2 install), or a LaunchAgent — launchd
|
|
461
|
+
// starts it with ppid 1 and its job label in XPC_SERVICE_NAME. ppid 1 alone is not enough:
|
|
462
|
+
// `setsid rech serve` or `(rech serve &)` also starts with ppid 1 and must still self-exit.
|
|
463
|
+
// Terminal apps set XPC_SERVICE_NAME to "0" or "application.<bundle id>…".
|
|
464
|
+
export function isManagedServe(startPpid: number, env: Record<string, string | undefined> = process.env): boolean {
|
|
465
|
+
if (env.RECH_SERVE_MANAGED === "1") return true;
|
|
466
|
+
const label = env.XPC_SERVICE_NAME;
|
|
467
|
+
return startPpid === 1 && !!label && label !== "0" && !label.startsWith("application.");
|
|
468
|
+
}
|
|
401
469
|
|
|
402
470
|
// Pure decision predicate (testable). idleTimeoutMs <= 0 disables orphan self-exit.
|
|
403
471
|
export function shouldExitOrphanedServe(opts: {
|
|
404
472
|
orphaned: boolean;
|
|
405
473
|
idleMs: number;
|
|
406
474
|
idleTimeoutMs: number;
|
|
475
|
+
managed?: boolean;
|
|
407
476
|
}): boolean {
|
|
408
|
-
return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
|
|
477
|
+
return !opts.managed && opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
|
|
409
478
|
}
|
|
410
479
|
|
|
411
480
|
export const LANDING_HEADERS = {
|
|
@@ -533,14 +602,24 @@ export async function serve() {
|
|
|
533
602
|
let orphaned = false;
|
|
534
603
|
let idleSince = Date.now();
|
|
535
604
|
const markActivity = () => { idleSince = Date.now(); };
|
|
605
|
+
const managed = isManagedServe(process.ppid);
|
|
606
|
+
if (managed) log("serve is supervised (launchd, a process manager, or RECH_SERVE_MANAGED=1) — idle self-exit disabled");
|
|
536
607
|
setInterval(() => {
|
|
537
608
|
if (process.ppid === 1) orphaned = true;
|
|
538
|
-
if (shouldExitOrphanedServe({ orphaned, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
|
|
609
|
+
if (shouldExitOrphanedServe({ orphaned, managed, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
|
|
539
610
|
log(`orphaned foreground serve idle ${Math.round((Date.now() - idleSince) / 1000)}s — exiting (spawning agent is gone)`);
|
|
540
611
|
process.exit(0);
|
|
541
612
|
}
|
|
542
613
|
}, ORPHAN_POLL_INTERVAL_MS);
|
|
543
614
|
|
|
615
|
+
// Say at startup, not on the first failed open, when macOS privacy blocks Chrome's data folder.
|
|
616
|
+
if (process.platform === "darwin") {
|
|
617
|
+
const chromeDir = join(HOME, "Library/Application Support/Google/Chrome");
|
|
618
|
+
try { readdirSync(chromeDir); } catch (error) {
|
|
619
|
+
if ((error as { code?: string }).code !== "ENOENT") warnChromeDataAccess(chromeDir, error);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
544
623
|
// Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
|
|
545
624
|
adoptOrphanedIsoSessions();
|
|
546
625
|
setInterval(() => {
|
|
@@ -615,12 +694,12 @@ export async function serve() {
|
|
|
615
694
|
// clients / `rech status` can see trouble without an active probe.
|
|
616
695
|
const degraded = consecutiveTimeouts > 0;
|
|
617
696
|
if (scoped || !reqUrl.searchParams.get("deep"))
|
|
618
|
-
return Response.json({ ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded });
|
|
697
|
+
return Response.json({ ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded, chromeAccessProblem });
|
|
619
698
|
// Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
|
|
620
699
|
// most-recently-used session — never a fresh one, which could spawn a browser window.
|
|
621
700
|
const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
622
701
|
if (!target)
|
|
623
|
-
return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded });
|
|
702
|
+
return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded, chromeAccessProblem });
|
|
624
703
|
const [pbin, ...pbinArgs] = splitCommand(resolvePlaywrightCli());
|
|
625
704
|
const probe = Bun.spawn([pbin, ...pbinArgs, "tab-list", `-s=${target}`], {
|
|
626
705
|
cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
@@ -634,7 +713,7 @@ export async function serve() {
|
|
|
634
713
|
const healthy = probeStatus !== -1;
|
|
635
714
|
return Response.json({
|
|
636
715
|
ok: healthy, bind: listenHost, relay: healthy ? "healthy" : "degraded",
|
|
637
|
-
consecutiveTimeouts, degraded: degraded || !healthy,
|
|
716
|
+
consecutiveTimeouts, degraded: degraded || !healthy, chromeAccessProblem,
|
|
638
717
|
});
|
|
639
718
|
}
|
|
640
719
|
// A browser opening a shared URL gets copyable connect instructions. The key stays in the
|
|
@@ -765,7 +844,14 @@ export async function serve() {
|
|
|
765
844
|
|
|
766
845
|
// Resolve profile name/email → directory name
|
|
767
846
|
if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
|
|
768
|
-
|
|
847
|
+
let resolved: string;
|
|
848
|
+
try {
|
|
849
|
+
resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
|
|
850
|
+
} catch (error) {
|
|
851
|
+
if (!(error instanceof ProfileResolutionError)) throw error;
|
|
852
|
+
log(`refused unresolvable profile "${error.profile}" (session=${namespacedSession})`);
|
|
853
|
+
return Response.json({ status: 1, stdout: "", stderr: error.message, files: [] });
|
|
854
|
+
}
|
|
769
855
|
if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
|
|
770
856
|
log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
|
|
771
857
|
passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
|
|
@@ -907,7 +993,8 @@ export async function serve() {
|
|
|
907
993
|
const handshakeTimeoutMs = Number.isFinite(configuredHandshakeTimeout) && configuredHandshakeTimeout > 0
|
|
908
994
|
? configuredHandshakeTimeout
|
|
909
995
|
: 30_000;
|
|
910
|
-
const
|
|
996
|
+
const tccNote = explainExtensionNotFound(`${stdout}\n${rawStderr}`, chromeAccessProblem);
|
|
997
|
+
const stderr = (tccNote ?? "") + inferSilentExtensionFailure({
|
|
911
998
|
status,
|
|
912
999
|
stdout,
|
|
913
1000
|
stderr: rawStderr,
|