rechrome 1.24.1 → 1.26.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,6 +1,7 @@
1
+ import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, type Listener } from "./listeners.js";
1
2
  import { file } from "bun";
2
3
  import { createHash, X509Certificate } from "crypto";
3
- import { mkdirSync, unlinkSync, accessSync, readdirSync, constants as fsConstants } from "fs";
4
+ import { mkdirSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
4
5
  import { join, resolve, relative, isAbsolute } from "path";
5
6
  import {
6
7
  log,
@@ -8,10 +9,13 @@ import {
8
9
  getOrCreateUrl,
9
10
  authCheck,
10
11
  RECH_DIR,
12
+ LEGACY_RECH_DIR,
13
+ migrateLegacyDataDir,
11
14
  HOME,
12
15
  PASSTHROUGH_ENV_KEYS,
13
16
  resolvePlaywrightCli,
14
- } from "./rech.js";
17
+ readTokenRegistry,
18
+ } from "./rechrome.js";
15
19
 
16
20
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
17
21
  const CERT_RENEW_THRESHOLD_DAYS = 7;
@@ -239,45 +243,6 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
239
243
  return nameOrEmail;
240
244
  }
241
245
 
242
- // Free the listening port from stale daemon holders before retrying a failed bind.
243
- // On Windows the listening socket (created inheritable by Bun.serve) is swept into the
244
- // detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
245
- // previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
246
- // then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
247
- // only happens when such a stale holder exists; killing orphaned daemon holders here is
248
- // safe because a freshly-starting serve owns no live sessions of its own yet (the user's
249
- // Chrome tabs persist regardless — the cliDaemon only drives them).
250
- async function freeStalePort(port: number): Promise<void> {
251
- try {
252
- if (process.platform === "win32") {
253
- // Two-phase, narrow-first: (1) kill the port's actual listed owner if it's a live
254
- // process; (2) only if the port is STILL held — the inherited-handle case, where the
255
- // socket lives in a child while netstat attributes it to a now-dead owner — fall back
256
- // to killing orphaned cliDaemon holders. The fallback is the only recovery for that
257
- // case (the live holder can't be mapped from the port), but it runs only when the
258
- // precise kill failed, so the broad sweep is a logged last resort, not the default.
259
- const ps = [
260
- "$ErrorActionPreference='SilentlyContinue';",
261
- `$o=(Get-NetTCPConnection -LocalPort ${port} -State Listen).OwningProcess;`,
262
- "if($o -and (Get-Process -Id $o)){ Stop-Process -Id $o -Force; Start-Sleep -Milliseconds 400 };",
263
- `if(Get-NetTCPConnection -LocalPort ${port} -State Listen){`,
264
- " $h=Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*cliDaemon.js*' };",
265
- " Write-Output (\"freeStalePort: port still held; killing cliDaemon holders: \" + ($h.ProcessId -join ','));",
266
- " $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
267
- "}",
268
- ].join(" ");
269
- const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
270
- const out = r.stdout?.toString().trim();
271
- if (out) log(out);
272
- } else {
273
- Bun.spawnSync(["sh", "-c", `fuser -k ${port}/tcp 2>/dev/null || (lsof -ti tcp:${port} | xargs -r kill -9) 2>/dev/null || true`]);
274
- }
275
- } catch {
276
- // best effort — the retry will surface a clear error if the port is still held
277
- }
278
- await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
279
- }
280
-
281
246
  // --- Foreground/orphan self-exit ---------------------------------------------------
282
247
  // A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
283
248
  // process-manager safety net: when the agent that spawned it exits, the OS re-parents
@@ -300,11 +265,14 @@ export function shouldExitOrphanedServe(opts: {
300
265
  }
301
266
 
302
267
  export async function serve() {
268
+ // The daemon owns logs/ and output/, so it migrates them before writing anything.
269
+ const migrated = migrateLegacyDataDir();
303
270
  const url = await getOrCreateUrl();
304
271
  const { key, port } = parseUrl(url);
305
272
 
306
273
  const workDir = join(RECH_DIR, "output");
307
274
  mkdirSync(workDir, { recursive: true });
275
+ if (migrated.length) log(`Moved ${migrated.length} legacy entries from ${LEGACY_RECH_DIR} to ${RECH_DIR}`);
308
276
 
309
277
  // Foreground/orphan self-exit: a serve whose parent has been re-parented to init
310
278
  // (ppid 1) is a leaked foreground serve. Poll for that, track the last real /run,
@@ -341,20 +309,30 @@ export async function serve() {
341
309
  }, 86_400_000);
342
310
  }
343
311
  const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
344
- const startServer = (reusePort = false) => Bun.serve({
345
- hostname: listenHost,
346
- port,
347
- // reusePort is used only as a last-resort fallback (see the bind loop below): if an orphaned
348
- // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
349
- // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
350
- reusePort,
312
+ const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
313
+ const policies = new Map<string, Listener>();
314
+ const servers = new Map<string, ReturnType<typeof Bun.serve>>();
315
+ const startServer = (initial: Listener) => Bun.serve({
316
+ hostname: initial.host,
317
+ port: initial.port,
351
318
  tls,
352
319
  error(err) {
353
320
  log(`unhandled error: ${err.message}`);
354
321
  return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
355
322
  },
356
- async fetch(req) {
323
+ async fetch(req, server) {
324
+ const listener = policies.get(listenerAddress(initial));
325
+ if (!listener) return new Response("Listener removed", { status: 403 });
326
+ const key = listener.key;
327
+ const listenHost = listener.host;
328
+ const scoped = listener.profiles !== "*";
357
329
  const reqUrl = new URL(req.url);
330
+ const prefix = normalizePrefix(listener.prefix);
331
+ if (prefix !== "/") {
332
+ if (reqUrl.pathname === prefix.slice(0, -1)) reqUrl.pathname = "/";
333
+ else if (reqUrl.pathname.startsWith(prefix)) reqUrl.pathname = "/" + reqUrl.pathname.slice(prefix.length);
334
+ else return new Response("Not found", { status: 404 });
335
+ }
358
336
 
359
337
  // Serve files from output dir
360
338
  if (reqUrl.pathname.startsWith("/files/")) {
@@ -362,9 +340,12 @@ export async function serve() {
362
340
  if (denied) return denied;
363
341
  const name = decodeURIComponent(reqUrl.pathname.slice(7));
364
342
  if (!isUnderDir(workDir, name)) return new Response("Forbidden", { status: 403 });
343
+ if (!canReadProfileFile(listener, name)) return new Response("Forbidden", { status: 403 });
365
344
  const resolved = resolve(workDir, name);
366
345
  const f = file(resolved);
367
346
  if (!(await f.exists())) return new Response("Not found", { status: 404 });
347
+ const real = realpathSync(resolved);
348
+ if (!isUnderDir(realpathSync(workDir), real) || !canReadProfileFile(listener, relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
368
349
  return new Response(f);
369
350
  }
370
351
 
@@ -375,8 +356,8 @@ export async function serve() {
375
356
  // relay wedges). `degraded` surfaces the passive timeout streak from real traffic so
376
357
  // clients / `rech status` can see trouble without an active probe.
377
358
  const degraded = consecutiveTimeouts > 0;
378
- if (!reqUrl.searchParams.get("deep"))
379
- return Response.json({ ok: true, bind: listenHost, consecutiveTimeouts, degraded });
359
+ if (scoped || !reqUrl.searchParams.get("deep"))
360
+ return Response.json({ ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded });
380
361
  // Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
381
362
  // most-recently-used session — never a fresh one, which could spawn a browser window.
382
363
  const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
@@ -404,6 +385,28 @@ export async function serve() {
404
385
  markActivity(); // a real command: this serve is not idle
405
386
 
406
387
  const body = await req.json();
388
+ let scopedProfile: string | undefined;
389
+ let profileEnv: Record<string, string> = {};
390
+ if (scoped) {
391
+ try {
392
+ scopedProfile = authorizeProfileRequest(listener, body);
393
+ const registry = await readTokenRegistry();
394
+ const entry = registry[scopedProfile];
395
+ if (!entry?.token || !entry?.extensionId || !entry?.profileDir) throw new Error("Profile is not registered on this server");
396
+ profileEnv = {
397
+ PLAYWRIGHT_MCP_PROFILE_DIRECTORY: entry.profileDir,
398
+ PLAYWRIGHT_MCP_EXTENSION_ID: entry.extensionId,
399
+ PLAYWRIGHT_MCP_EXTENSION_TOKEN: entry.token,
400
+ PLAYWRIGHT_MCP_USER_DATA_DIR: entry.userDataDir || "",
401
+ PLAYWRIGHT_MCP_LOAD_EXTENSION: entry.loadExtension || "",
402
+ };
403
+ body.identity.key = `scoped:${body.identity.key}`;
404
+ body.env = {}; // Server-owned profile configuration wins over every client override.
405
+ } catch (error) { return Response.json({ status: 1, stdout: "", stderr: String(error) }, { status: 403 }); }
406
+ }
407
+ const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) : "";
408
+ const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;
409
+ mkdirSync(runWorkDir, { recursive: true });
407
410
  let args: string[];
408
411
  let sessionId: string;
409
412
  let clientName = "";
@@ -480,7 +483,7 @@ export async function serve() {
480
483
  if (isOpenCmd) {
481
484
  try {
482
485
  const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
483
- cwd: workDir,
486
+ cwd: runWorkDir,
484
487
  stdin: "ignore",
485
488
  stdout: "pipe",
486
489
  stderr: "pipe",
@@ -518,10 +521,10 @@ export async function serve() {
518
521
  for (const key of PASSTHROUGH_ENV_KEYS) {
519
522
  if (process.env[key]) passthroughEnv[key] = process.env[key];
520
523
  }
521
- Object.assign(passthroughEnv, clientEnv);
524
+ Object.assign(passthroughEnv, clientEnv, profileEnv);
522
525
 
523
526
  // Resolve profile name/email → directory name
524
- if (passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
527
+ if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
525
528
  const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
526
529
  if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
527
530
  log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
@@ -564,7 +567,7 @@ export async function serve() {
564
567
 
565
568
  const commandStartedAt = Date.now();
566
569
  const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
567
- cwd: workDir,
570
+ cwd: runWorkDir,
568
571
  stdin: "ignore",
569
572
  stdout: "pipe",
570
573
  stderr: "pipe",
@@ -623,7 +626,7 @@ export async function serve() {
623
626
  log(`session ${namespacedSession} wedged (${streak} consecutive timeouts) — closing so the next command respawns a clean cliDaemon`);
624
627
  try {
625
628
  Bun.spawn([bin, ...binArgs, "close", `-s=${namespacedSession}`], {
626
- cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
629
+ cwd: runWorkDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
627
630
  windowsHide: true,
628
631
  env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
629
632
  });
@@ -653,16 +656,16 @@ export async function serve() {
653
656
  ];
654
657
  const outputFiles: string[] = [];
655
658
  for (const f of mentionedFiles) {
656
- if (!isUnderDir(workDir, f)) continue;
657
- if (await file(join(workDir, f)).exists()) {
659
+ if (!isUnderDir(runWorkDir, f)) continue;
660
+ if (await file(join(runWorkDir, f)).exists()) {
658
661
  outputFiles.push(f);
659
662
  } else {
660
663
  const basename = f.split("/").pop()!;
661
- for (const subdir of [".playwright-cli", ".rech-multi-tab"]) {
664
+ for (const subdir of [".playwright-cli", ".playwright-cli-multi-tab"]) {
662
665
  // Forward-slash for the wire: join() would use "\" on the Windows daemon, which
663
666
  // a POSIX client can't treat as a separator (it builds a literal-backslash path).
664
667
  const subpath = `${subdir}/${basename}`;
665
- if (await file(join(workDir, subpath)).exists()) {
668
+ if (await file(join(runWorkDir, subpath)).exists()) {
666
669
  outputFiles.push(subpath);
667
670
  break;
668
671
  }
@@ -677,40 +680,55 @@ export async function serve() {
677
680
  stderr: rebrand(stderr),
678
681
  // Normalize any platform separators to "/" so relative paths are portable across
679
682
  // a cross-OS daemon↔client (e.g. Windows daemon serving a Linux container client).
680
- files: outputFiles.map((p) => p.replaceAll("\\", "/")),
683
+ files: outputFiles.map((p) => (outputPrefix + p).replaceAll("\\", "/")),
681
684
  });
682
685
  },
683
686
  });
684
687
 
685
- // A leaked listening-socket handle in an orphaned cliDaemon can keep the port in LISTEN after a
686
- // prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
687
- // detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
688
- // netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
689
- // map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
690
- // Retry a bounded number of times: the old single retry crash-looped whenever the OS hadn't yet
691
- // released the socket after the kill (pm2 then restarts serve into the same race). As an absolute
692
- // last resort, bind with reusePort so a holder we genuinely can't kill degrades to "up but sharing
693
- // the port" rather than a permanent EADDRINUSE crash-loop.
694
- const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
695
- const MAX_BIND_ATTEMPTS = 4;
696
- let server: ReturnType<typeof startServer> | undefined;
697
- for (let attempt = 1; attempt <= MAX_BIND_ATTEMPTS; attempt++) {
688
+ // Stage all new sockets before changing policy. Bind failure leaves existing
689
+ // listeners and browser sessions intact; never kill a process holding another port.
690
+ const reconcile = async (listeners: Listener[]) => {
691
+ const staged = new Map<string, ReturnType<typeof Bun.serve>>();
698
692
  try {
699
- server = startServer();
700
- break;
701
- } catch (e: any) {
702
- if (!isEaddrInUse(e)) throw e;
703
- if (attempt === MAX_BIND_ATTEMPTS) {
704
- log(`port ${port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
705
- server = startServer(true);
706
- break;
693
+ for (const listener of listeners) {
694
+ const address = listenerAddress(listener);
695
+ if (!servers.has(address)) staged.set(address, startServer(listener));
707
696
  }
708
- log(`port ${port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
709
- await freeStalePort(port);
697
+ } catch (error) {
698
+ for (const server of staged.values()) server.stop(true);
699
+ throw error;
710
700
  }
711
- }
712
- if (!server) throw new Error(`failed to bind port ${port}`);
713
-
714
- log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
715
- log(`Connection URL set (use .env.local to view)`);
701
+ policies.clear();
702
+ for (const listener of listeners) policies.set(listenerAddress(listener), listener);
703
+ for (const [address, server] of staged) { servers.set(address, server); log(`listening on ${address}`); }
704
+ for (const [address, server] of servers) {
705
+ if (!policies.has(address)) { server.stop(false); servers.delete(address); log(`listener removed: ${address}`); }
706
+ }
707
+ };
708
+ let applied = "";
709
+ let configured = false;
710
+ const reload = async () => {
711
+ const config = await readListeners();
712
+ if (!config && configured) throw new Error("listeners.json disappeared; refusing to restore unrestricted legacy access");
713
+ const listeners = config?.listeners ?? [legacy];
714
+ const fingerprint = JSON.stringify(listeners);
715
+ if (applied === fingerprint) return;
716
+ await reconcile(listeners);
717
+ configured ||= !!config;
718
+ applied = fingerprint;
719
+ };
720
+ await reload();
721
+ let reloading = false;
722
+ let lastError = "";
723
+ setInterval(async () => {
724
+ if (reloading) return;
725
+ reloading = true;
726
+ try { await reload(); lastError = ""; }
727
+ catch (error) {
728
+ const message = String(error);
729
+ if (message !== lastError) log(`listener reload rejected; previous configuration retained: ${message}`);
730
+ lastError = message;
731
+ } finally { reloading = false; }
732
+ }, 1000);
733
+ log("Connection credentials remain in local configuration; listener changes reload automatically");
716
734
  }
package/serve.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, type Listener } from "./listeners.ts";
1
2
  import { file } from "bun";
2
3
  import { createHash, X509Certificate } from "crypto";
3
- import { mkdirSync, unlinkSync, accessSync, readdirSync, constants as fsConstants } from "fs";
4
+ import { mkdirSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
4
5
  import { join, resolve, relative, isAbsolute } from "path";
5
6
  import {
6
7
  log,
@@ -8,10 +9,13 @@ import {
8
9
  getOrCreateUrl,
9
10
  authCheck,
10
11
  RECH_DIR,
12
+ LEGACY_RECH_DIR,
13
+ migrateLegacyDataDir,
11
14
  HOME,
12
15
  PASSTHROUGH_ENV_KEYS,
13
16
  resolvePlaywrightCli,
14
- } from "./rech.ts";
17
+ readTokenRegistry,
18
+ } from "./rechrome.ts";
15
19
 
16
20
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
17
21
  const CERT_RENEW_THRESHOLD_DAYS = 7;
@@ -239,45 +243,6 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
239
243
  return nameOrEmail;
240
244
  }
241
245
 
242
- // Free the listening port from stale daemon holders before retrying a failed bind.
243
- // On Windows the listening socket (created inheritable by Bun.serve) is swept into the
244
- // detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
245
- // previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
246
- // then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
247
- // only happens when such a stale holder exists; killing orphaned daemon holders here is
248
- // safe because a freshly-starting serve owns no live sessions of its own yet (the user's
249
- // Chrome tabs persist regardless — the cliDaemon only drives them).
250
- async function freeStalePort(port: number): Promise<void> {
251
- try {
252
- if (process.platform === "win32") {
253
- // Two-phase, narrow-first: (1) kill the port's actual listed owner if it's a live
254
- // process; (2) only if the port is STILL held — the inherited-handle case, where the
255
- // socket lives in a child while netstat attributes it to a now-dead owner — fall back
256
- // to killing orphaned cliDaemon holders. The fallback is the only recovery for that
257
- // case (the live holder can't be mapped from the port), but it runs only when the
258
- // precise kill failed, so the broad sweep is a logged last resort, not the default.
259
- const ps = [
260
- "$ErrorActionPreference='SilentlyContinue';",
261
- `$o=(Get-NetTCPConnection -LocalPort ${port} -State Listen).OwningProcess;`,
262
- "if($o -and (Get-Process -Id $o)){ Stop-Process -Id $o -Force; Start-Sleep -Milliseconds 400 };",
263
- `if(Get-NetTCPConnection -LocalPort ${port} -State Listen){`,
264
- " $h=Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*cliDaemon.js*' };",
265
- " Write-Output (\"freeStalePort: port still held; killing cliDaemon holders: \" + ($h.ProcessId -join ','));",
266
- " $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
267
- "}",
268
- ].join(" ");
269
- const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
270
- const out = r.stdout?.toString().trim();
271
- if (out) log(out);
272
- } else {
273
- Bun.spawnSync(["sh", "-c", `fuser -k ${port}/tcp 2>/dev/null || (lsof -ti tcp:${port} | xargs -r kill -9) 2>/dev/null || true`]);
274
- }
275
- } catch {
276
- // best effort — the retry will surface a clear error if the port is still held
277
- }
278
- await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
279
- }
280
-
281
246
  // --- Foreground/orphan self-exit ---------------------------------------------------
282
247
  // A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
283
248
  // process-manager safety net: when the agent that spawned it exits, the OS re-parents
@@ -300,11 +265,14 @@ export function shouldExitOrphanedServe(opts: {
300
265
  }
301
266
 
302
267
  export async function serve() {
268
+ // The daemon owns logs/ and output/, so it migrates them before writing anything.
269
+ const migrated = migrateLegacyDataDir();
303
270
  const url = await getOrCreateUrl();
304
271
  const { key, port } = parseUrl(url);
305
272
 
306
273
  const workDir = join(RECH_DIR, "output");
307
274
  mkdirSync(workDir, { recursive: true });
275
+ if (migrated.length) log(`Moved ${migrated.length} legacy entries from ${LEGACY_RECH_DIR} to ${RECH_DIR}`);
308
276
 
309
277
  // Foreground/orphan self-exit: a serve whose parent has been re-parented to init
310
278
  // (ppid 1) is a leaked foreground serve. Poll for that, track the last real /run,
@@ -341,20 +309,30 @@ export async function serve() {
341
309
  }, 86_400_000);
342
310
  }
343
311
  const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
344
- const startServer = (reusePort = false) => Bun.serve({
345
- hostname: listenHost,
346
- port,
347
- // reusePort is used only as a last-resort fallback (see the bind loop below): if an orphaned
348
- // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
349
- // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
350
- reusePort,
312
+ const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
313
+ const policies = new Map<string, Listener>();
314
+ const servers = new Map<string, ReturnType<typeof Bun.serve>>();
315
+ const startServer = (initial: Listener) => Bun.serve({
316
+ hostname: initial.host,
317
+ port: initial.port,
351
318
  tls,
352
319
  error(err) {
353
320
  log(`unhandled error: ${err.message}`);
354
321
  return Response.json({ status: 1, stdout: "", stderr: err.message }, { status: 500 });
355
322
  },
356
- async fetch(req) {
323
+ async fetch(req, server) {
324
+ const listener = policies.get(listenerAddress(initial));
325
+ if (!listener) return new Response("Listener removed", { status: 403 });
326
+ const key = listener.key;
327
+ const listenHost = listener.host;
328
+ const scoped = listener.profiles !== "*";
357
329
  const reqUrl = new URL(req.url);
330
+ const prefix = normalizePrefix(listener.prefix);
331
+ if (prefix !== "/") {
332
+ if (reqUrl.pathname === prefix.slice(0, -1)) reqUrl.pathname = "/";
333
+ else if (reqUrl.pathname.startsWith(prefix)) reqUrl.pathname = "/" + reqUrl.pathname.slice(prefix.length);
334
+ else return new Response("Not found", { status: 404 });
335
+ }
358
336
 
359
337
  // Serve files from output dir
360
338
  if (reqUrl.pathname.startsWith("/files/")) {
@@ -362,9 +340,12 @@ export async function serve() {
362
340
  if (denied) return denied;
363
341
  const name = decodeURIComponent(reqUrl.pathname.slice(7));
364
342
  if (!isUnderDir(workDir, name)) return new Response("Forbidden", { status: 403 });
343
+ if (!canReadProfileFile(listener, name)) return new Response("Forbidden", { status: 403 });
365
344
  const resolved = resolve(workDir, name);
366
345
  const f = file(resolved);
367
346
  if (!(await f.exists())) return new Response("Not found", { status: 404 });
347
+ const real = realpathSync(resolved);
348
+ if (!isUnderDir(realpathSync(workDir), real) || !canReadProfileFile(listener, relative(realpathSync(workDir), real).replaceAll("\\", "/"))) return new Response("Forbidden", { status: 403 });
368
349
  return new Response(f);
369
350
  }
370
351
 
@@ -375,8 +356,8 @@ export async function serve() {
375
356
  // relay wedges). `degraded` surfaces the passive timeout streak from real traffic so
376
357
  // clients / `rech status` can see trouble without an active probe.
377
358
  const degraded = consecutiveTimeouts > 0;
378
- if (!reqUrl.searchParams.get("deep"))
379
- return Response.json({ ok: true, bind: listenHost, consecutiveTimeouts, degraded });
359
+ if (scoped || !reqUrl.searchParams.get("deep"))
360
+ return Response.json({ ok: true, bind: listenHost, listener: listener.name, profiles: listener.profiles, multiListener: true, consecutiveTimeouts, degraded });
380
361
  // Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
381
362
  // most-recently-used session — never a fresh one, which could spawn a browser window.
382
363
  const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
@@ -404,6 +385,28 @@ export async function serve() {
404
385
  markActivity(); // a real command: this serve is not idle
405
386
 
406
387
  const body = await req.json();
388
+ let scopedProfile: string | undefined;
389
+ let profileEnv: Record<string, string> = {};
390
+ if (scoped) {
391
+ try {
392
+ scopedProfile = authorizeProfileRequest(listener, body);
393
+ const registry = await readTokenRegistry();
394
+ const entry = registry[scopedProfile];
395
+ if (!entry?.token || !entry?.extensionId || !entry?.profileDir) throw new Error("Profile is not registered on this server");
396
+ profileEnv = {
397
+ PLAYWRIGHT_MCP_PROFILE_DIRECTORY: entry.profileDir,
398
+ PLAYWRIGHT_MCP_EXTENSION_ID: entry.extensionId,
399
+ PLAYWRIGHT_MCP_EXTENSION_TOKEN: entry.token,
400
+ PLAYWRIGHT_MCP_USER_DATA_DIR: entry.userDataDir || "",
401
+ PLAYWRIGHT_MCP_LOAD_EXTENSION: entry.loadExtension || "",
402
+ };
403
+ body.identity.key = `scoped:${body.identity.key}`;
404
+ body.env = {}; // Server-owned profile configuration wins over every client override.
405
+ } catch (error) { return Response.json({ status: 1, stdout: "", stderr: String(error) }, { status: 403 }); }
406
+ }
407
+ const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) : "";
408
+ const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;
409
+ mkdirSync(runWorkDir, { recursive: true });
407
410
  let args: string[];
408
411
  let sessionId: string;
409
412
  let clientName = "";
@@ -480,7 +483,7 @@ export async function serve() {
480
483
  if (isOpenCmd) {
481
484
  try {
482
485
  const listProc = Bun.spawn([bin, ...binArgs, "tab-list", `-s=${namespacedSession}`], {
483
- cwd: workDir,
486
+ cwd: runWorkDir,
484
487
  stdin: "ignore",
485
488
  stdout: "pipe",
486
489
  stderr: "pipe",
@@ -518,10 +521,10 @@ export async function serve() {
518
521
  for (const key of PASSTHROUGH_ENV_KEYS) {
519
522
  if (process.env[key]) passthroughEnv[key] = process.env[key];
520
523
  }
521
- Object.assign(passthroughEnv, clientEnv);
524
+ Object.assign(passthroughEnv, clientEnv, profileEnv);
522
525
 
523
526
  // Resolve profile name/email → directory name
524
- if (passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
527
+ if (!scoped && passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY) {
525
528
  const resolved = await resolveProfileDirectory(passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY);
526
529
  if (resolved !== passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY)
527
530
  log(`profile resolved: "${passthroughEnv.PLAYWRIGHT_MCP_PROFILE_DIRECTORY}" → "${resolved}"`);
@@ -564,7 +567,7 @@ export async function serve() {
564
567
 
565
568
  const commandStartedAt = Date.now();
566
569
  const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
567
- cwd: workDir,
570
+ cwd: runWorkDir,
568
571
  stdin: "ignore",
569
572
  stdout: "pipe",
570
573
  stderr: "pipe",
@@ -623,7 +626,7 @@ export async function serve() {
623
626
  log(`session ${namespacedSession} wedged (${streak} consecutive timeouts) — closing so the next command respawns a clean cliDaemon`);
624
627
  try {
625
628
  Bun.spawn([bin, ...binArgs, "close", `-s=${namespacedSession}`], {
626
- cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
629
+ cwd: runWorkDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
627
630
  windowsHide: true,
628
631
  env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
629
632
  });
@@ -653,16 +656,16 @@ export async function serve() {
653
656
  ];
654
657
  const outputFiles: string[] = [];
655
658
  for (const f of mentionedFiles) {
656
- if (!isUnderDir(workDir, f)) continue;
657
- if (await file(join(workDir, f)).exists()) {
659
+ if (!isUnderDir(runWorkDir, f)) continue;
660
+ if (await file(join(runWorkDir, f)).exists()) {
658
661
  outputFiles.push(f);
659
662
  } else {
660
663
  const basename = f.split("/").pop()!;
661
- for (const subdir of [".playwright-cli", ".rech-multi-tab"]) {
664
+ for (const subdir of [".playwright-cli", ".playwright-cli-multi-tab"]) {
662
665
  // Forward-slash for the wire: join() would use "\" on the Windows daemon, which
663
666
  // a POSIX client can't treat as a separator (it builds a literal-backslash path).
664
667
  const subpath = `${subdir}/${basename}`;
665
- if (await file(join(workDir, subpath)).exists()) {
668
+ if (await file(join(runWorkDir, subpath)).exists()) {
666
669
  outputFiles.push(subpath);
667
670
  break;
668
671
  }
@@ -677,40 +680,55 @@ export async function serve() {
677
680
  stderr: rebrand(stderr),
678
681
  // Normalize any platform separators to "/" so relative paths are portable across
679
682
  // a cross-OS daemon↔client (e.g. Windows daemon serving a Linux container client).
680
- files: outputFiles.map((p) => p.replaceAll("\\", "/")),
683
+ files: outputFiles.map((p) => (outputPrefix + p).replaceAll("\\", "/")),
681
684
  });
682
685
  },
683
686
  });
684
687
 
685
- // A leaked listening-socket handle in an orphaned cliDaemon can keep the port in LISTEN after a
686
- // prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
687
- // detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
688
- // netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
689
- // map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
690
- // Retry a bounded number of times: the old single retry crash-looped whenever the OS hadn't yet
691
- // released the socket after the kill (pm2 then restarts serve into the same race). As an absolute
692
- // last resort, bind with reusePort so a holder we genuinely can't kill degrades to "up but sharing
693
- // the port" rather than a permanent EADDRINUSE crash-loop.
694
- const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
695
- const MAX_BIND_ATTEMPTS = 4;
696
- let server: ReturnType<typeof startServer> | undefined;
697
- for (let attempt = 1; attempt <= MAX_BIND_ATTEMPTS; attempt++) {
688
+ // Stage all new sockets before changing policy. Bind failure leaves existing
689
+ // listeners and browser sessions intact; never kill a process holding another port.
690
+ const reconcile = async (listeners: Listener[]) => {
691
+ const staged = new Map<string, ReturnType<typeof Bun.serve>>();
698
692
  try {
699
- server = startServer();
700
- break;
701
- } catch (e: any) {
702
- if (!isEaddrInUse(e)) throw e;
703
- if (attempt === MAX_BIND_ATTEMPTS) {
704
- log(`port ${port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
705
- server = startServer(true);
706
- break;
693
+ for (const listener of listeners) {
694
+ const address = listenerAddress(listener);
695
+ if (!servers.has(address)) staged.set(address, startServer(listener));
707
696
  }
708
- log(`port ${port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
709
- await freeStalePort(port);
697
+ } catch (error) {
698
+ for (const server of staged.values()) server.stop(true);
699
+ throw error;
710
700
  }
711
- }
712
- if (!server) throw new Error(`failed to bind port ${port}`);
713
-
714
- log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
715
- log(`Connection URL set (use .env.local to view)`);
701
+ policies.clear();
702
+ for (const listener of listeners) policies.set(listenerAddress(listener), listener);
703
+ for (const [address, server] of staged) { servers.set(address, server); log(`listening on ${address}`); }
704
+ for (const [address, server] of servers) {
705
+ if (!policies.has(address)) { server.stop(false); servers.delete(address); log(`listener removed: ${address}`); }
706
+ }
707
+ };
708
+ let applied = "";
709
+ let configured = false;
710
+ const reload = async () => {
711
+ const config = await readListeners();
712
+ if (!config && configured) throw new Error("listeners.json disappeared; refusing to restore unrestricted legacy access");
713
+ const listeners = config?.listeners ?? [legacy];
714
+ const fingerprint = JSON.stringify(listeners);
715
+ if (applied === fingerprint) return;
716
+ await reconcile(listeners);
717
+ configured ||= !!config;
718
+ applied = fingerprint;
719
+ };
720
+ await reload();
721
+ let reloading = false;
722
+ let lastError = "";
723
+ setInterval(async () => {
724
+ if (reloading) return;
725
+ reloading = true;
726
+ try { await reload(); lastError = ""; }
727
+ catch (error) {
728
+ const message = String(error);
729
+ if (message !== lastError) log(`listener reload rejected; previous configuration retained: ${message}`);
730
+ lastError = message;
731
+ } finally { reloading = false; }
732
+ }, 1000);
733
+ log("Connection credentials remain in local configuration; listener changes reload automatically");
716
734
  }