rechrome 1.36.0 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/serve.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { receiveUpload, readRequestJson } from "./scoped-upload.js";
2
2
  import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, type Listener } from "./listeners.js";
3
+ import { WEBRTC, authorizeCredential, credentialForBearer, readShares, redeemInvite, shareProblem, updateShares, webrtcCredentials, type Credential, type ShareConfig } from "./shares.js";
4
+ import { bearerOf, safeEqual } from "./secret.js";
5
+ import { WebrtcHost } from "./webrtc.js";
3
6
  import { file } from "bun";
4
- import { createHash, X509Certificate } from "crypto";
7
+ import { createHash, randomBytes, X509Certificate } from "crypto";
5
8
  import { mkdirSync, lstatSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
6
9
  import { join, resolve, relative, isAbsolute } from "path";
7
10
  import { tmpdir } from "os";
@@ -19,6 +22,7 @@ import {
19
22
  readTokenRegistry,
20
23
  readChromeProfileCache,
21
24
  checkTailscaleServe,
25
+ chromeDataAccessHint,
22
26
  } from "./rechrome.js";
23
27
 
24
28
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
@@ -122,6 +126,16 @@ export function provesRelayAlive(o: { stdout: string; stderr: string }): boolean
122
126
  return true;
123
127
  }
124
128
 
129
+ /**
130
+ * The relay checks Chrome's data folder for the installed extension before connecting. Under
131
+ * macOS TCC that read fails and it reports "Playwright Extension not found" although the
132
+ * extension is installed — name the real cause instead of sending the user to reinstall.
133
+ */
134
+ export function explainExtensionNotFound(output: string, accessProblem: string | undefined): string | undefined {
135
+ if (!accessProblem || !/Playwright Extension not found in/i.test(output)) return undefined;
136
+ return `[rech] the extension is probably installed; this daemon ${accessProblem}\n`;
137
+ }
138
+
125
139
  export function inferSilentExtensionFailure(options: {
126
140
  status: number;
127
141
  stdout: string;
@@ -256,24 +270,63 @@ export function splitCommand(cmd: string): string[] {
256
270
  t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t);
257
271
  }
258
272
 
259
- async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
273
+ /** Why this daemon cannot read Chrome's data folder; reported by /ping so `rech status` shows it. */
274
+ let chromeAccessProblem: string | undefined;
275
+ /** Log (once) why Chrome's data folder is unreadable — under launchd/oxmgr on macOS this is TCC. */
276
+ export function warnChromeDataAccess(path: string, error: unknown): void {
277
+ if (chromeAccessProblem) return;
278
+ const message = error instanceof Error ? error.message : String(error);
279
+ chromeAccessProblem = `cannot read ${path} (${message}). ${chromeDataAccessHint(error) ?? "Profile names and emails cannot be resolved here."}`;
280
+ log(chromeAccessProblem);
281
+ }
282
+
283
+ /** A profile name or email could not be mapped to its Chrome folder because Local State is unreadable. */
284
+ export class ProfileResolutionError extends Error {
285
+ constructor(readonly profile: string, statePath: string, cause: unknown) {
286
+ const message = cause instanceof Error ? cause.message : String(cause);
287
+ const hint = chromeDataAccessHint(cause);
288
+ 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`
289
+ + ` Register it once (records its folder in ~/.rechrome): rech setup --profile ${JSON.stringify(profile)}\n`
290
+ + ` Or select the folder directly, e.g. rech --profile "Profile 1" …\n`
291
+ + (hint ? ` ${hint}\n` : ""));
292
+ this.name = "ProfileResolutionError";
293
+ }
294
+ }
295
+
296
+ export async function resolveProfileDirectory(
297
+ nameOrEmail: string,
298
+ registry?: Record<string, { profileDir?: string }>,
299
+ statePaths: string[] = [
300
+ join(HOME || "~", "Library/Application Support/Google/Chrome/Local State"),
301
+ join(HOME || "~", ".config/google-chrome/Local State"),
302
+ join(HOME || "~", "AppData/Local/Google/Chrome/User Data/Local State"),
303
+ ],
304
+ ): Promise<string> {
260
305
  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;
306
+ // setup recorded each registered profile's folder in ~/.rechrome, which macOS privacy (TCC)
307
+ // never guards, so a launchd/oxmgr daemon resolves it without touching Chrome's data folder.
308
+ const entry = (registry ?? await readTokenRegistry().catch(() => ({})) as Record<string, { profileDir?: string }>)[nameOrEmail];
309
+ if (entry?.profileDir) return entry.profileDir;
310
+ let unreadable: { path: string; error: unknown } | undefined;
311
+ for (const statePath of statePaths) {
312
+ try {
313
+ // Read without an exists() probe: under TCC the stat fails too, and exists() would report
314
+ // "missing" and hide the denial.
315
+ const data = JSON.parse(await file(statePath).text());
316
+ const cache: Record<string, any> = data?.profile?.info_cache ?? {};
317
+ for (const [dir, info] of Object.entries(cache)) {
318
+ if ([info.name, info.user_name, info.gaia_name].includes(nameOrEmail))
319
+ return dir;
320
+ }
321
+ } catch (error) {
322
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") continue;
323
+ warnChromeDataAccess(statePath, error);
324
+ unreadable ??= { path: statePath, error };
275
325
  }
276
326
  }
327
+ // Passing an email or display name through would make Chrome create a NEW empty profile folder
328
+ // literally named after it (--profile-directory=me@example.com) instead of opening the real one.
329
+ if (unreadable) throw new ProfileResolutionError(nameOrEmail, unreadable.path, unreadable.error);
277
330
  return nameOrEmail;
278
331
  }
279
332
 
@@ -397,15 +450,34 @@ async function freeStalePort(port: number, host: string): Promise<void> {
397
450
  // the leak self-heals. /ping is deliberately NOT activity (the tray polls it every 2s
398
451
  // and would otherwise keep an orphan alive forever).
399
452
  const ORPHAN_POLL_INTERVAL_MS = 15_000;
400
- const ORPHAN_IDLE_EXIT_MS = Number(process.env.RECH_SERVE_IDLE_TIMEOUT_MS) || 5 * 60_000;
453
+ const ORPHAN_IDLE_EXIT_MS = parseIdleTimeout(process.env.RECH_SERVE_IDLE_TIMEOUT_MS);
454
+
455
+ // `RECH_SERVE_IDLE_TIMEOUT_MS=0` must disable self-exit; `Number(v) || default` turned 0 back into 5 min.
456
+ export function parseIdleTimeout(raw: string | undefined, fallback = 5 * 60_000): number {
457
+ if (raw === undefined || raw.trim() === "") return fallback;
458
+ const value = Number(raw);
459
+ return Number.isFinite(value) ? value : fallback;
460
+ }
461
+
462
+ // A supervisor owns this serve's lifetime, so it is never a leaked foreground serve:
463
+ // RECH_SERVE_MANAGED=1 (set by `rech setup`'s oxmgr/pm2 install), or a LaunchAgent — launchd
464
+ // starts it with ppid 1 and its job label in XPC_SERVICE_NAME. ppid 1 alone is not enough:
465
+ // `setsid rech serve` or `(rech serve &)` also starts with ppid 1 and must still self-exit.
466
+ // Terminal apps set XPC_SERVICE_NAME to "0" or "application.<bundle id>…".
467
+ export function isManagedServe(startPpid: number, env: Record<string, string | undefined> = process.env): boolean {
468
+ if (env.RECH_SERVE_MANAGED === "1") return true;
469
+ const label = env.XPC_SERVICE_NAME;
470
+ return startPpid === 1 && !!label && label !== "0" && !label.startsWith("application.");
471
+ }
401
472
 
402
473
  // Pure decision predicate (testable). idleTimeoutMs <= 0 disables orphan self-exit.
403
474
  export function shouldExitOrphanedServe(opts: {
404
475
  orphaned: boolean;
405
476
  idleMs: number;
406
477
  idleTimeoutMs: number;
478
+ managed?: boolean;
407
479
  }): boolean {
408
- return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
480
+ return !opts.managed && opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
409
481
  }
410
482
 
411
483
  export const LANDING_HEADERS = {
@@ -449,7 +521,7 @@ a { color: var(--accent); }
449
521
  <p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
450
522
  On your computer, inside the project folder that should use it, run these two lines
451
523
  (the same in macOS/Linux terminals, PowerShell and cmd):</p>
452
- <p id="nokey" class="warn" hidden>This link is missing its key (the part after #key=). Ask for the full link from <code>rech share</code>.</p>
524
+ <p id="nokey" class="warn" hidden>This link is missing its key (the part after # — #key=… or #rech1_…). Ask for the full link from <code>rech share</code>.</p>
453
525
  <div id="cmds"></div>
454
526
  <div id="profiles" hidden><p>Profiles this link can use (pick one to make it the default in the command above):</p><div id="plist"></div></div>
455
527
  <p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
@@ -459,13 +531,14 @@ This link contains a secret key: anyone with it can use this browser profile, so
459
531
  (() => {
460
532
  let url = location.href;
461
533
  const key = (location.hash.match(/[#&]key=([^&]*)/) || [])[1];
462
- if (!/[#&?]key=/.test(location.hash)) document.getElementById("nokey").hidden = false;
534
+ const invite = /^#rech1_[1-9A-HJ-NP-Za-km-z]{28}$/.test(location.hash);
535
+ if (!/[#&?]key=/.test(location.hash) && !invite) document.getElementById("nokey").hidden = false;
463
536
  // One command for every shell: two lines (no && vs ; split), one double-quoted URL. Characters a
464
537
  // shell expands inside double quotes ($ \` " \\ !) are percent-encoded, which the URL reads the same;
465
538
  // %40 goes back to a readable @ (valid in a query, and one less % for cmd to try to expand).
466
539
  const quote = s => '"' + s.replace(/%40/g, "@").replace(/[$\`"\\\\!]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()) + '"';
467
540
  const build = u => "bun i -g rechrome\\nrechrome connect " + quote(u);
468
- const mask = s => s.replace(/(key=)[^&"\\n]+/, "$1…");
541
+ const mask = s => s.replace(/(key=)[^&"\\n]+/, "$1…").replace(/(#rech1_[1-9A-HJ-NP-Za-km-z]{4})[1-9A-HJ-NP-Za-km-z]+/, "$1…");
469
542
  const root = document.getElementById("cmds");
470
543
  const row = document.createElement("div"); row.className = "row";
471
544
  const line = document.createElement("div"); line.className = "cmd";
@@ -533,14 +606,24 @@ export async function serve() {
533
606
  let orphaned = false;
534
607
  let idleSince = Date.now();
535
608
  const markActivity = () => { idleSince = Date.now(); };
609
+ const managed = isManagedServe(process.ppid);
610
+ if (managed) log("serve is supervised (launchd, a process manager, or RECH_SERVE_MANAGED=1) — idle self-exit disabled");
536
611
  setInterval(() => {
537
612
  if (process.ppid === 1) orphaned = true;
538
- if (shouldExitOrphanedServe({ orphaned, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
613
+ if (shouldExitOrphanedServe({ orphaned, managed, idleMs: Date.now() - idleSince, idleTimeoutMs: ORPHAN_IDLE_EXIT_MS })) {
539
614
  log(`orphaned foreground serve idle ${Math.round((Date.now() - idleSince) / 1000)}s — exiting (spawning agent is gone)`);
540
615
  process.exit(0);
541
616
  }
542
617
  }, ORPHAN_POLL_INTERVAL_MS);
543
618
 
619
+ // Say at startup, not on the first failed open, when macOS privacy blocks Chrome's data folder.
620
+ if (process.platform === "darwin") {
621
+ const chromeDir = join(HOME, "Library/Application Support/Google/Chrome");
622
+ try { readdirSync(chromeDir); } catch (error) {
623
+ if ((error as { code?: string }).code !== "ENOENT") warnChromeDataAccess(chromeDir, error);
624
+ }
625
+ }
626
+
544
627
  // Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
545
628
  adoptOrphanedIsoSessions();
546
629
  setInterval(() => {
@@ -564,24 +647,23 @@ export async function serve() {
564
647
  const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
565
648
  const policies = new Map<string, Listener>();
566
649
  const servers = new Map<string, ReturnType<typeof Bun.serve>>();
567
- const startServer = (initial: Listener, reusePort = false) => Bun.serve({
568
- hostname: initial.host,
569
- port: initial.port,
570
- // reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
571
- // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
572
- // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
573
- reusePort,
574
- tls,
575
- error(err) {
576
- log(`unhandled error: ${err.message}`);
577
- return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
578
- },
579
- async fetch(req, server) {
580
- const listener = policies.get(listenerAddress(initial));
581
- if (!listener) return new Response("Listener removed", { status: 403 });
582
- const key = listener.key;
583
- const listenHost = listener.host;
584
- const scoped = listener.profiles !== "*";
650
+ // Shares (shares.json) are reloaded with the listeners below. sessionOwners remembers which
651
+ // credential drove each session, for `rech session ls` and for closing a revoked share's sessions.
652
+ let shares: ShareConfig = { version: 1, shares: [] };
653
+ const sessionOwners = new Map<string, { owner: string; label: string; profile?: string; name: string; lastUsed: number }>();
654
+ const deviceSeen = new Map<string, number>();
655
+ const noteDeviceUse = (cred: Credential & { kind: "device" }) => {
656
+ const now = Date.now();
657
+ if (now - (deviceSeen.get(cred.device.id) ?? cred.device.lastSeen ?? 0) < 10 * 60_000) return;
658
+ deviceSeen.set(cred.device.id, now);
659
+ void updateShares(config => {
660
+ const device = config.shares.find(s => s.id === cred.share.id)?.devices.find(d => d.id === cred.device.id);
661
+ if (device) device.lastSeen = now;
662
+ }).catch(error => log(`could not record device use: ${error}`));
663
+ };
664
+
665
+ const handle = async (req: Request, initialListener: Listener, remoteAddr: () => string): Promise<Response> => {
666
+ let listener = initialListener;
585
667
  const reqUrl = new URL(req.url);
586
668
  const prefix = normalizePrefix(listener.prefix);
587
669
  // Accept the path with or without the prefix, so a proxy works whether it keeps the
@@ -592,18 +674,70 @@ export async function serve() {
592
674
  else if (reqUrl.pathname.startsWith(prefix)) reqUrl.pathname = "/" + reqUrl.pathname.slice(prefix.length);
593
675
  }
594
676
 
677
+ // Who is asking: the listener's own key (legacy links, the local management key), or a
678
+ // share credential (an invite or a device, see shares.js) offered on this transport. A
679
+ // share narrows the listener to the share's own profiles.
680
+ const presented = req.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
681
+ const cred = presented && !safeEqual(presented, listener.key) ? credentialForBearer(shares, presented) : null;
682
+ if (cred) {
683
+ const route = reqUrl.pathname === "/ping" ? "ping" : reqUrl.pathname === "/redeem" ? "redeem" : "use";
684
+ const denial = authorizeCredential(cred, listener.name, route);
685
+ if (denial) return Response.json({ status: 1, stdout: "", stderr: `[rech] ${denial.message}\n`, error: denial.message, pending: denial.pending }, { status: denial.status });
686
+ if (cred.kind === "device") noteDeviceUse(cred);
687
+ listener = { ...listener, key: presented, profiles: cred.share.profiles };
688
+ }
689
+ const key = listener.key;
690
+ const listenHost = listener.host;
691
+ const scoped = listener.profiles !== "*";
692
+ const owner = cred?.kind === "device" ? `share:${cred.share.id}:${cred.device.id}` : `listener:${listener.name}`;
693
+
694
+ if (reqUrl.pathname === "/redeem" && req.method === "POST") {
695
+ if (cred?.kind !== "invite") return Response.json({ error: "Only an invite link can be redeemed." }, { status: 400 });
696
+ const { name } = await req.json().catch(() => ({})) as { name?: string };
697
+ try {
698
+ const device = await updateShares(config => redeemInvite(config, cred.share.id, String(name ?? "")));
699
+ shares = await readShares();
700
+ log(`share ${cred.share.label}: invite redeemed by device ${device.id} (${device.name})${device.approved ? "" : ", waiting for approval"}`);
701
+ return Response.json({ token: device.token, device: { id: device.id, name: device.name, approved: device.approved }, share: cred.share.label, profiles: cred.share.profiles });
702
+ } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 403 }); }
703
+ }
704
+
705
+ if (reqUrl.pathname === "/profiles") {
706
+ const denied = authCheck(req, key);
707
+ if (denied) return denied;
708
+ const registry = await readTokenRegistry().catch(() => ({} as Awaited<ReturnType<typeof readTokenRegistry>>));
709
+ const cache = await readChromeProfileCache().catch(() => null) ?? {};
710
+ const keys = listener.profiles === "*" ? Object.keys(registry) : listener.profiles;
711
+ return Response.json({ share: cred?.share.label, profiles: keys.map(k => ({ key: k, name: cache[registry[k]?.profileDir ?? ""]?.name ?? "", registered: !!registry[k] })) });
712
+ }
713
+
714
+ if (reqUrl.pathname === "/sessions") {
715
+ const denied = authCheck(req, key);
716
+ if (denied) return denied;
717
+ let live = new Set<string>();
718
+ try { live = new Set(readdirSync(tmpSocketRoot()).filter(f => f.endsWith(".sock")).map(f => f.slice(0, -5))); } catch {}
719
+ const all = listener.profiles === "*" && !cred;
720
+ const rows = [...sessionOwners.entries()]
721
+ .filter(([, o]) => all || o.owner === owner)
722
+ .map(([session, o]) => ({ session: o.name, profile: o.profile, client: o.label, owner: all ? o.owner : undefined, lastUsed: o.lastUsed, live: live.has(session) }))
723
+ .sort((a, b) => b.lastUsed - a.lastUsed);
724
+ return Response.json({ sessions: rows });
725
+ }
726
+
595
727
  // Serve files from output dir
596
728
  if (reqUrl.pathname.startsWith("/files/")) {
597
729
  const denied = authCheck(req, key);
598
730
  if (denied) return denied;
599
731
  const name = decodeURIComponent(reqUrl.pathname.slice(7));
600
732
  if (!isUnderDir(workDir, name)) return new Response("Forbidden", { status: 403 });
601
- if (!canReadProfileFile(listener, name)) return new Response("Forbidden", { status: 403 });
733
+ const readable = (path: string) => canReadProfileFile(listener, path)
734
+ && (cred?.kind !== "device" || cred.share.profiles.some(p => path.startsWith(`${profileOutputPrefix(p)}d-${cred.device.id}/`)));
735
+ if (!readable(name)) return new Response("Forbidden", { status: 403 });
602
736
  const resolved = resolve(workDir, name);
603
737
  const f = file(resolved);
604
738
  if (!(await f.exists())) return new Response("Not found", { status: 404 });
605
739
  const real = realpathSync(resolved);
606
- if (!isUnderDir(realpathSync(workDir), real) || !canReadProfileFile(listener, relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
740
+ if (!isUnderDir(realpathSync(workDir), real) || !readable(relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
607
741
  return new Response(f);
608
742
  }
609
743
 
@@ -615,12 +749,15 @@ export async function serve() {
615
749
  // clients / `rech status` can see trouble without an active probe.
616
750
  const degraded = consecutiveTimeouts > 0;
617
751
  if (scoped || !reqUrl.searchParams.get("deep"))
618
- return Response.json({ ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded });
752
+ return Response.json({
753
+ ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded, chromeAccessProblem,
754
+ ...(cred ? { share: cred.share.label, kind: cred.kind, ...(cred.kind === "device" ? { device: { id: cred.device.id, name: cred.device.name, approved: cred.device.approved } } : {}), expiresAt: cred.share.expiresAt } : {}),
755
+ });
619
756
  // Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
620
757
  // most-recently-used session — never a fresh one, which could spawn a browser window.
621
758
  const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
622
759
  if (!target)
623
- return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded });
760
+ return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded, chromeAccessProblem });
624
761
  const [pbin, ...pbinArgs] = splitCommand(resolvePlaywrightCli());
625
762
  const probe = Bun.spawn([pbin, ...pbinArgs, "tab-list", `-s=${target}`], {
626
763
  cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
@@ -634,7 +771,7 @@ export async function serve() {
634
771
  const healthy = probeStatus !== -1;
635
772
  return Response.json({
636
773
  ok: healthy, bind: listenHost, relay: healthy ? "healthy" : "degraded",
637
- consecutiveTimeouts, degraded: degraded || !healthy,
774
+ consecutiveTimeouts, degraded: degraded || !healthy, chromeAccessProblem,
638
775
  });
639
776
  }
640
777
  // A browser opening a shared URL gets copyable connect instructions. The key stays in the
@@ -675,7 +812,9 @@ export async function serve() {
675
812
  PLAYWRIGHT_MCP_USER_DATA_DIR: entry.userDataDir || "",
676
813
  PLAYWRIGHT_MCP_LOAD_EXTENSION: entry.loadExtension || "",
677
814
  };
678
- body.identity.key = `scoped:${body.identity.key}`;
815
+ // A share's devices never share browser state with each other or with legacy links.
816
+ body.identity.key = cred?.kind === "device" ? `share:${cred.share.id}:${cred.device.id}:${body.identity.key}` : `scoped:${body.identity.key}`;
817
+ if (cred) body.identity.label = cred.share.label;
679
818
  body.env = {}; // Server-owned profile configuration wins over every client override.
680
819
  } catch (error) {
681
820
  if (Array.isArray(body?.args) && body.args.filter((a: unknown) => typeof a !== "string" || !/^-s=[a-zA-Z0-9_-]{1,64}$/.test(a))[0] === "upload")
@@ -683,7 +822,9 @@ export async function serve() {
683
822
  return Response.json({ status: 1, stdout: "", stderr: `${error instanceof Error ? error.message : String(error)}\n` }, { status: 403 });
684
823
  }
685
824
  }
686
- const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) : "";
825
+ // A share device's files live under its own folder, so other devices and links on the same
826
+ // profile can't fetch them (see the /files check above).
827
+ const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) + (cred?.kind === "device" ? `d-${cred.device.id}/` : "") : "";
687
828
  const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;
688
829
  mkdirSync(runWorkDir, { recursive: true });
689
830
  let args: string[];
@@ -692,7 +833,7 @@ export async function serve() {
692
833
  let clientEnv: Record<string, string> = {};
693
834
  if (Array.isArray(body)) {
694
835
  args = body;
695
- const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
836
+ const clientAddr = remoteAddr();
696
837
  sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
697
838
  clientName = clientAddr;
698
839
  log(`session from client IP: ${clientAddr} -> ${sessionId}`);
@@ -713,7 +854,7 @@ export async function serve() {
713
854
  clientName = labelBase || keyBase;
714
855
  log(`session from identity: ${clientName}${id?.profile ? ` profile:${id.profile}` : ""} [${keyBase}] -> ${sessionId}`);
715
856
  } else {
716
- const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
857
+ const clientAddr = remoteAddr();
717
858
  sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
718
859
  clientName = clientAddr;
719
860
  log(`session from client IP fallback: ${clientAddr} -> ${sessionId}`);
@@ -738,6 +879,8 @@ export async function serve() {
738
879
  const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
739
880
  // Track --isolate sessions so the idle-TTL reaper can close them later.
740
881
  const nowMs = Date.now();
882
+ sessionOwners.set(namespacedSession, { owner, label: cred ? `${cred.share.label}/${(cred as any).device?.name ?? ""}` : clientName, profile: scopedProfile ?? (body as any)?.identity?.profile, name: clientSession || "default", lastUsed: nowMs });
883
+ if (sessionOwners.size > 512) sessionOwners.delete(sessionOwners.keys().next().value!);
741
884
  if (isIsoSession(namespacedSession)) isoLastUsed.set(namespacedSession, nowMs);
742
885
  else noteSession(namespacedSession, nowMs); // for the deep health probe to target
743
886
 
@@ -765,7 +908,14 @@ export async function serve() {
765
908
 
766
909
  // Resolve profile name/email → directory name
767
910
  if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
768
- const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
911
+ let resolved: string;
912
+ try {
913
+ resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
914
+ } catch (error) {
915
+ if (!(error instanceof ProfileResolutionError)) throw error;
916
+ log(`refused unresolvable profile "${error.profile}" (session=${namespacedSession})`);
917
+ return Response.json({ status: 1, stdout: "", stderr: error.message, files: [] });
918
+ }
769
919
  if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
770
920
  log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
771
921
  passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolved;
@@ -907,7 +1057,8 @@ export async function serve() {
907
1057
  const handshakeTimeoutMs = Number.isFinite(configuredHandshakeTimeout) && configuredHandshakeTimeout > 0
908
1058
  ? configuredHandshakeTimeout
909
1059
  : 30_000;
910
- const stderr = inferSilentExtensionFailure({
1060
+ const tccNote = explainExtensionNotFound(`${stdout}\n${rawStderr}`, chromeAccessProblem);
1061
+ const stderr = (tccNote ?? "") + inferSilentExtensionFailure({
911
1062
  status,
912
1063
  stdout,
913
1064
  stderr: rawStderr,
@@ -988,6 +1139,33 @@ export async function serve() {
988
1139
  // a cross-OS daemon↔client (e.g. Windows daemon serving a Linux container client).
989
1140
  files: outputFiles.map((p) => (outputPrefix + p).replaceAll("\\", "/")),
990
1141
  });
1142
+ };
1143
+
1144
+ // WebRTC: the same handler, reached over an encrypted DataChannel. Each request carries the
1145
+ // bearer of the credential whose room it came in on, so share checks apply unchanged.
1146
+ const webrtcListener: Listener = { name: WEBRTC, host: "webrtc", port: 0, key: randomBytes(24).toString("base64url"), profiles: [] };
1147
+ const webrtc = new WebrtcHost(async (req, token) => {
1148
+ const headers = new Headers(req.headers);
1149
+ headers.set("Authorization", `Bearer ${bearerOf(token)}`);
1150
+ return handle(new Request(req, { headers }), webrtcListener, () => "webrtc");
1151
+ }, log);
1152
+
1153
+ const startServer = (initial: Listener, reusePort = false) => Bun.serve({
1154
+ hostname: initial.host,
1155
+ port: initial.port,
1156
+ // reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
1157
+ // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
1158
+ // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
1159
+ reusePort,
1160
+ tls,
1161
+ error(err) {
1162
+ log(`unhandled error: ${err.message}`);
1163
+ return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
1164
+ },
1165
+ async fetch(req, server) {
1166
+ const listener = policies.get(listenerAddress(initial));
1167
+ if (!listener) return new Response("Listener removed", { status: 403 });
1168
+ return handle(req, listener, () => `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`);
991
1169
  },
992
1170
  });
993
1171
 
@@ -1056,12 +1234,42 @@ export async function serve() {
1056
1234
  configured ||= !!config;
1057
1235
  applied = fingerprint;
1058
1236
  };
1237
+ // Shares change independently of listeners (redeem, approve, revoke); a bad shares.json keeps
1238
+ // the previous one, like listeners.json.
1239
+ let appliedShares = "";
1240
+ const reloadShares = async () => {
1241
+ const next = await readShares();
1242
+ // Expiry is time-based: fold "what is live right now" into the fingerprint so an expiring
1243
+ // share or invite closes its rooms and sessions without anyone touching the file.
1244
+ const now = Date.now();
1245
+ const fingerprint = JSON.stringify(next) + JSON.stringify(next.shares.map(s => [!!shareProblem(s, now), !!(s.inviteExpiresAt && now >= s.inviteExpiresAt)]));
1246
+ if (fingerprint === appliedShares) return;
1247
+ const allowed = new Set(next.shares.filter(s => !shareProblem(s, now)).flatMap(s => s.devices.map(d => `share:${s.id}:${d.id}`)));
1248
+ const dropped = [...sessionOwners.entries()].filter(([, o]) => o.owner.startsWith("share:") && !allowed.has(o.owner));
1249
+ shares = next;
1250
+ appliedShares = fingerprint;
1251
+ // A revoked share or a removed device loses its browser sessions too.
1252
+ if (dropped.length) {
1253
+ const [bin, ...binArgs] = splitCommand(resolvePlaywrightCli());
1254
+ for (const [session, o] of dropped) {
1255
+ sessionOwners.delete(session);
1256
+ try { Bun.spawn([bin, ...binArgs, "close", `-s=${session}`], { cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore", windowsHide: true, env: playwrightCliEnv() }); } catch {}
1257
+ log(`closed session ${session} of ${o.label}: its share was revoked or its device removed`);
1258
+ }
1259
+ }
1260
+ const creds = webrtcCredentials(next);
1261
+ webrtc.sync(creds.map(c => ({ token: c.token, signal: c.share.signal!, label: c.kind === "device" ? `${c.share.label}/${c.device.name}` : `${c.share.label} (invite)` })));
1262
+ if (creds.length || webrtc.size) log(`webrtc: listening for ${creds.length} share credential(s)`);
1263
+ };
1264
+ await reloadShares().catch(error => log(`shares.json rejected: ${error}`));
1059
1265
  await reload(true);
1060
1266
  let reloading = false;
1061
1267
  let lastError = "";
1062
1268
  setInterval(async () => {
1063
1269
  if (reloading) return;
1064
1270
  reloading = true;
1271
+ // Independent: a broken listeners.json must not freeze revocations (and vice versa).
1272
+ try { await reloadShares(); } catch (error) { log(`shares.json reload rejected; previous shares retained: ${error}`); }
1065
1273
  try { await reload(); lastError = ""; }
1066
1274
  catch (error) {
1067
1275
  const message = String(error);