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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.36.0",
3
+ "version": "1.36.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
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
- return data?.profile?.info_cache ?? null;
838
- } catch {}
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, value: key };
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
- async function run(url: string, args: string[], overrideEnv?: Record<string, string>, opts: { verbatim?: boolean } = {}) {
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 (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
2044
- : ` -> extension[not installed] (run: rech setup)`;
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(" Chrome profiles not found"); rl?.close(); process.exit(1); }
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 callServe(
3424
- rechUrl.toString(),
3425
- [`-s=${probeSession}`, "open", "about:blank", "--wait", "none"],
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
- return data?.profile?.info_cache ?? null;
838
- } catch {}
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, value: key };
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
- async function run(url: string, args: string[], overrideEnv?: Record<string, string>, opts: { verbatim?: boolean } = {}) {
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 (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
2044
- : ` -> extension[not installed] (run: rech setup)`;
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(" Chrome profiles not found"); rl?.close(); process.exit(1); }
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 callServe(
3424
- rechUrl.toString(),
3425
- [`-s=${probeSession}`, "open", "about:blank", "--wait", "none"],
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
- async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
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
- const home = HOME || "~";
262
- const candidates = [
263
- join(home, "Library/Application Support/Google/Chrome/Local State"),
264
- join(home, ".config/google-chrome/Local State"),
265
- join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
266
- ];
267
- for (const statePath of candidates) {
268
- const f = file(statePath);
269
- if (!(await f.exists())) continue;
270
- const data = JSON.parse(await f.text());
271
- const cache: Record<string, any> = data?.profile?.info_cache ?? {};
272
- for (const [dir, info] of Object.entries(cache)) {
273
- if ([info.name, info.user_name, info.gaia_name].includes(nameOrEmail))
274
- return dir;
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 = Number(process.env.RECH_SERVE_IDLE_TIMEOUT_MS) || 5 * 60_000;
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
- const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
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 stderr = inferSilentExtensionFailure({
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
- async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
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
- const home = HOME || "~";
262
- const candidates = [
263
- join(home, "Library/Application Support/Google/Chrome/Local State"),
264
- join(home, ".config/google-chrome/Local State"),
265
- join(home, "AppData/Local/Google/Chrome/User Data/Local State"),
266
- ];
267
- for (const statePath of candidates) {
268
- const f = file(statePath);
269
- if (!(await f.exists())) continue;
270
- const data = JSON.parse(await f.text());
271
- const cache: Record<string, any> = data?.profile?.info_cache ?? {};
272
- for (const [dir, info] of Object.entries(cache)) {
273
- if ([info.name, info.user_name, info.gaia_name].includes(nameOrEmail))
274
- return dir;
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 = Number(process.env.RECH_SERVE_IDLE_TIMEOUT_MS) || 5 * 60_000;
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
- const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
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 stderr = inferSilentExtensionFailure({
996
+ const tccNote = explainExtensionNotFound(`${stdout}\n${rawStderr}`, chromeAccessProblem);
997
+ const stderr = (tccNote ?? "") + inferSilentExtensionFailure({
911
998
  status,
912
999
  stdout,
913
1000
  stderr: rawStderr,