rechrome 1.28.2 → 1.29.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/serve.js CHANGED
@@ -243,6 +243,116 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
243
243
  return nameOrEmail;
244
244
  }
245
245
 
246
+ // Free the listening port from stale daemon holders before retrying a failed bind.
247
+ // On Windows the listening socket (created inheritable by Bun.serve) is swept into the
248
+ // detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
249
+ // previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
250
+ // then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
251
+ // only happens when such a stale holder exists; killing orphaned daemon holders here is
252
+ // safe because a freshly-starting serve owns no live sessions of its own yet (the user's
253
+ // Chrome tabs persist regardless — the cliDaemon only drives them).
254
+ // Command lines freeStalePort may kill: a previous rech serve, or a cliDaemon started from
255
+ // THIS install's playwright (lib/ or vendor/ under this directory) — never another app's
256
+ // Playwright daemon, which runs from its own node_modules.
257
+ const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
258
+ const OWN_CLI_DAEMON = import.meta.dir.split(/[\\/]/).map(escapeRegex).join(String.raw`[\\/]`) + String.raw`[\\/].*cliDaemon\.js`;
259
+ const RECH_SERVE = String.raw`rech(rome)?(\.js)?"?\s+serve`;
260
+ const STALE_HOLDER_PATTERN = `${OWN_CLI_DAEMON}|${RECH_SERVE}`;
261
+ // A leaked socket (or a wedged serve) accepts connections but never answers. Anything that
262
+ // responds at an address is a live server there — possibly a healthy rech serve — so it is
263
+ // never killed. Both schemes: the holder's TLS setting may differ from this serve's.
264
+ async function answersAt(address: string, port: number): Promise<boolean> {
265
+ const host = address === "0.0.0.0" || address === "*" ? "127.0.0.1"
266
+ : address === "::" ? "[::1]"
267
+ : address.includes(":") ? `[${address}]` : address;
268
+ const probe = (scheme: string) => fetch(`${scheme}://${host}:${port}/`, {
269
+ signal: AbortSignal.timeout(1500), tls: { rejectUnauthorized: false },
270
+ } as RequestInit).then(() => true, () => false);
271
+ return (await Promise.all([probe("http"), probe("https")])).includes(true);
272
+ }
273
+
274
+ type PortHolder = { address: string; pid: number; alive: boolean; command: string };
275
+
276
+ // Listeners on `port` that conflict with binding `host`: the same address, or a wildcard of
277
+ // its family (a wildcard bind conflicts with every address of the family). Never this serve's
278
+ // own sockets, which may already hold other addresses on the same port from this startup.
279
+ function conflictingHolders(port: number, host: string): PortHolder[] {
280
+ let holders: PortHolder[] = [];
281
+ if (process.platform === "win32") {
282
+ const ps = [
283
+ "$ErrorActionPreference='SilentlyContinue';",
284
+ `$r=@(Get-NetTCPConnection -LocalPort ${port} -State Listen | ForEach-Object {`,
285
+ " $p=Get-CimInstance Win32_Process -Filter \"ProcessId=$($_.OwningProcess)\";",
286
+ " [pscustomobject]@{ address=[string]$_.LocalAddress; pid=[int]$_.OwningProcess; alive=[bool]$p; command=[string]$p.CommandLine } });",
287
+ "ConvertTo-Json -Compress -InputObject $r",
288
+ ].join(" ");
289
+ const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
290
+ try { holders = out ? [JSON.parse(out)].flat() : []; } catch { holders = []; }
291
+ } else {
292
+ const out = Bun.spawnSync(["sh", "-c", `lsof -nP -iTCP:${port} -sTCP:LISTEN -Fpn 2>/dev/null`]).stdout?.toString() ?? "";
293
+ let pid = 0;
294
+ for (const line of out.split("\n")) {
295
+ if (line.startsWith("p")) pid = Number(line.slice(1));
296
+ else if (line.startsWith("n") && pid) {
297
+ const address = line.slice(1).replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
298
+ const command = Bun.spawnSync(["ps", "-o", "command=", "-p", String(pid)]).stdout?.toString().trim() ?? "";
299
+ holders.push({ address, pid, alive: true, command });
300
+ }
301
+ }
302
+ }
303
+ const v6 = (a: string) => a.includes(":");
304
+ const wildcard = (a: string) => a === "0.0.0.0" || a === "::" || a === "*";
305
+ // A `::` listener is dual-stack on Windows and most Linux setups: it conflicts with IPv4 binds too.
306
+ return holders.filter(h => h.pid !== process.pid && (
307
+ wildcard(host) ? (h.address === "*" || h.address === "::" || host === "::" || !v6(h.address))
308
+ : h.address === host || h.address === "*" || h.address === "::" || (h.address === "0.0.0.0" && !v6(host))));
309
+ }
310
+
311
+ // Free the conflicting address from stale rech holders before retrying a failed bind.
312
+ // Narrow-first: (1) kill each conflicting owner that is a live rech serve / this install's
313
+ // cliDaemon AND doesn't answer at its own address — never an unrelated app, never a healthy
314
+ // server; (2) only if the address is STILL held by owners that are all dead — the
315
+ // inherited-handle case, where the socket lives in a child while netstat attributes it to a
316
+ // now-dead owner — sweep orphaned cliDaemons (Windows). That sweep is the only recovery for
317
+ // the case (the live holder can't be mapped from the port), so it is a logged last resort.
318
+ async function freeStalePort(port: number, host: string): Promise<void> {
319
+ try {
320
+ const stale = new RegExp(STALE_HOLDER_PATTERN, process.platform === "win32" ? "i" : "");
321
+ for (const h of conflictingHolders(port, host)) {
322
+ if (!h.alive) continue;
323
+ if (!stale.test(h.command)) { log(`freeStalePort: port ${port} is held by an unrelated process (${h.pid}); not killing it`); continue; }
324
+ if (await answersAt(h.address, port)) { log(`freeStalePort: ${h.address}:${port} is served by a live process (${h.pid}); not killing it`); continue; }
325
+ log(`freeStalePort: killing stale rech holder ${h.pid} of ${h.address}:${port}`);
326
+ try { process.kill(h.pid, "SIGKILL"); } catch {}
327
+ }
328
+ await new Promise(r => setTimeout(r, 400));
329
+ const remaining = conflictingHolders(port, host);
330
+ if (process.platform === "win32" && remaining.length && remaining.every(h => !h.alive)) {
331
+ const q = (text: string) => text.replaceAll("'", "''");
332
+ const ps = [
333
+ "$ErrorActionPreference='SilentlyContinue';",
334
+ // A cliDaemon's parent (playwright-cli) exits right after spawning it, so a live one
335
+ // can't be told from an orphan by ancestry. They are only provably orphaned when no
336
+ // other rech serve is running: exclude this serve and its own wrappers (oxmgr, cmd).
337
+ `$self=@(); $x=${process.pid}; while($x){ $self+=$x; $x=(Get-CimInstance Win32_Process -Filter \"ProcessId=$x\").ParentProcessId; if($self -contains $x){ break } };`,
338
+ `$live=@(Get-CimInstance Win32_Process | Where-Object { $self -notcontains $_.ProcessId -and $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(RECH_SERVE)}' });`,
339
+ "if($live.Count){ Write-Output (\"freeStalePort: port still held, but another rech serve is running (\" + ($live.ProcessId -join ',') + \"); not sweeping cliDaemons\") }",
340
+ "else {",
341
+ // Exclude this PowerShell itself: its own command line contains the pattern.
342
+ ` $d=Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(OWN_CLI_DAEMON)}' };`,
343
+ " Write-Output (\"freeStalePort: port still held; killing orphaned cliDaemon holders: \" + ($d.ProcessId -join ','));",
344
+ " $d | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
345
+ "}",
346
+ ].join(" ");
347
+ const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
348
+ if (out) log(out);
349
+ }
350
+ } catch {
351
+ // best effort — the retry will surface a clear error if the port is still held
352
+ }
353
+ await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
354
+ }
355
+
246
356
  // --- Foreground/orphan self-exit ---------------------------------------------------
247
357
  // A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
248
358
  // process-manager safety net: when the agent that spawned it exits, the OS re-parents
@@ -264,6 +374,88 @@ export function shouldExitOrphanedServe(opts: {
264
374
  return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
265
375
  }
266
376
 
377
+ export const LANDING_HEADERS = {
378
+ "Content-Type": "text/html; charset=utf-8",
379
+ "Cache-Control": "no-store",
380
+ "Referrer-Policy": "no-referrer",
381
+ "X-Content-Type-Options": "nosniff",
382
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
383
+ };
384
+
385
+ /**
386
+ * The page a shared connection URL shows in a browser: install-and-connect commands to copy,
387
+ * one per shell. It is static; the script reads the full URL (with its #key) from the address
388
+ * bar, shows it with the key masked, and copies it whole.
389
+ */
390
+ export function landingPage(): string {
391
+ return `<!doctype html>
392
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
393
+ <title>Connect to a shared Chrome · rechrome</title>
394
+ <style>
395
+ :root { --bg:#fff; --fg:#1d1d1f; --muted:#6e6e73; --card:#f5f5f7; --line:#d2d2d7; --accent:#0a66c2; --warn:#b25000; }
396
+ @media (prefers-color-scheme: dark) { :root { --bg:#161617; --fg:#f5f5f7; --muted:#a1a1a6; --card:#232325; --line:#3a3a3c; --accent:#4c9bff; --warn:#ffb86b; } }
397
+ * { box-sizing: border-box; }
398
+ body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
399
+ main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
400
+ h1 { font-size: 1.6rem; margin: 0 0 8px; }
401
+ p { margin: 0 0 16px; color: var(--muted); }
402
+ .row { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0 0 12px; }
403
+ .label { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 6px; }
404
+ .cmd { display: flex; gap: 10px; align-items: flex-start; }
405
+ code { flex: 1; font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; }
406
+ button { flex: none; font: inherit; font-size: .9rem; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
407
+ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
408
+ .warn { color: var(--warn); font-weight: 600; }
409
+ .small { font-size: .85rem; }
410
+ a { color: var(--accent); }
411
+ </style></head>
412
+ <body><main>
413
+ <h1>Connect to a shared Chrome</h1>
414
+ <p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
415
+ On your computer, inside the project folder that should use it, run:</p>
416
+ <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>
417
+ <div id="cmds"></div>
418
+ <p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
419
+ This link contains a secret key: anyone with it can use this browser profile, so share it privately.</p>
420
+ </main>
421
+ <script>
422
+ (() => {
423
+ const url = location.href;
424
+ if (!/[#&?]key=/.test(location.hash)) document.getElementById("nokey").hidden = false;
425
+ const posix = s => "'" + s.replace(/'/g, "'\\\\''") + "'";
426
+ const pwsh = s => '"' + s.replace(/[\`"$]/g, c => "\`" + c) + '"';
427
+ const cmd = s => '"' + s.replace(/"/g, "%22") + '"';
428
+ const shells = [
429
+ ["macOS / Linux", "bun i -g rechrome && rechrome connect " + posix(url)],
430
+ ["Windows PowerShell", "bun i -g rechrome; rechrome connect " + pwsh(url)],
431
+ ["Windows cmd", "bun i -g rechrome && rechrome connect " + cmd(url)],
432
+ ];
433
+ const mask = s => s.replace(/(key=)[^&"'\`]+/, "$1…");
434
+ const root = document.getElementById("cmds");
435
+ for (const [label, text] of shells) {
436
+ const row = document.createElement("div"); row.className = "row";
437
+ const name = document.createElement("div"); name.className = "label"; name.textContent = label;
438
+ const line = document.createElement("div"); line.className = "cmd";
439
+ const code = document.createElement("code"); code.textContent = mask(text);
440
+ const button = document.createElement("button"); button.type = "button"; button.textContent = "Copy";
441
+ button.setAttribute("aria-label", "Copy the " + label + " command");
442
+ button.addEventListener("click", async () => {
443
+ try { await navigator.clipboard.writeText(text); }
444
+ catch {
445
+ // Plain-HTTP pages (e.g. a LAN address) have no clipboard API: copy via a hidden textarea.
446
+ const area = document.createElement("textarea"); area.value = text; document.body.append(area);
447
+ area.select(); document.execCommand("copy"); area.remove();
448
+ }
449
+ button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
450
+ });
451
+ line.append(code, button); row.append(name, line); root.append(row);
452
+ }
453
+ })();
454
+ </script>
455
+ </body></html>
456
+ `;
457
+ }
458
+
267
459
  export async function serve() {
268
460
  // The daemon owns logs/ and output/, so it migrates them before writing anything.
269
461
  const migrated = migrateLegacyDataDir();
@@ -312,9 +504,13 @@ export async function serve() {
312
504
  const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
313
505
  const policies = new Map<string, Listener>();
314
506
  const servers = new Map<string, ReturnType<typeof Bun.serve>>();
315
- const startServer = (initial: Listener) => Bun.serve({
507
+ const startServer = (initial: Listener, reusePort = false) => Bun.serve({
316
508
  hostname: initial.host,
317
509
  port: initial.port,
510
+ // reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
511
+ // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
512
+ // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
513
+ reusePort,
318
514
  tls,
319
515
  error(err) {
320
516
  log(`unhandled error: ${err.message}`);
@@ -381,6 +577,10 @@ export async function serve() {
381
577
  consecutiveTimeouts, degraded: degraded || !healthy,
382
578
  });
383
579
  }
580
+ // A browser opening a shared URL gets copyable connect instructions. The key stays in the
581
+ // #fragment, which browsers never send, so the page is static and the key never reaches here.
582
+ if (reqUrl.pathname === "/" && req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html"))
583
+ return new Response(landingPage(), { headers: LANDING_HEADERS });
384
584
  if (reqUrl.pathname !== "/run") return new Response("rech server\n");
385
585
  const denied = authCheck(req, key);
386
586
  if (denied) return denied;
@@ -687,14 +887,47 @@ export async function serve() {
687
887
  },
688
888
  });
689
889
 
890
+ // A leaked listening-socket handle in an orphaned cliDaemon can keep a port in LISTEN after a
891
+ // prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
892
+ // detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
893
+ // netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
894
+ // map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
895
+ // Startup only: a freshly-starting serve owns no live sessions, so clearing stale holders is
896
+ // safe. Hot reloads never kill anything (see reconcile). As an absolute last resort, bind with
897
+ // reusePort so a holder we genuinely can't kill degrades to "up but sharing the port" rather
898
+ // than a permanent EADDRINUSE crash-loop.
899
+ const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
900
+ const MAX_BIND_ATTEMPTS = 4;
901
+ const bindAtStartup = async (listener: Listener) => {
902
+ for (let attempt = 1; ; attempt++) {
903
+ try {
904
+ return startServer(listener);
905
+ } catch (e: any) {
906
+ if (!isEaddrInUse(e)) throw e;
907
+ // Something answers at this exact address: a live server (never killed, never port-shared).
908
+ if (await answersAt(listener.host, listener.port)) {
909
+ log(`port ${listener.port} is held by a live server that answers requests — not touching it`);
910
+ throw e;
911
+ }
912
+ if (attempt === MAX_BIND_ATTEMPTS) {
913
+ log(`port ${listener.port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
914
+ return startServer(listener, true);
915
+ }
916
+ log(`port ${listener.port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
917
+ await freeStalePort(listener.port, listener.host);
918
+ }
919
+ }
920
+ };
921
+
690
922
  // Stage all new sockets before changing policy. Bind failure leaves existing
691
- // listeners and browser sessions intact; never kill a process holding another port.
692
- const reconcile = async (listeners: Listener[]) => {
923
+ // listeners and browser sessions intact; never kill a process holding another port
924
+ // once serving (stale-holder recovery runs only at startup).
925
+ const reconcile = async (listeners: Listener[], startup = false) => {
693
926
  const staged = new Map<string, ReturnType<typeof Bun.serve>>();
694
927
  try {
695
928
  for (const listener of listeners) {
696
929
  const address = listenerAddress(listener);
697
- if (!servers.has(address)) staged.set(address, startServer(listener));
930
+ if (!servers.has(address)) staged.set(address, startup ? await bindAtStartup(listener) : startServer(listener));
698
931
  }
699
932
  } catch (error) {
700
933
  for (const server of staged.values()) server.stop(true);
@@ -709,17 +942,17 @@ export async function serve() {
709
942
  };
710
943
  let applied = "";
711
944
  let configured = false;
712
- const reload = async () => {
945
+ const reload = async (startup = false) => {
713
946
  const config = await readListeners();
714
947
  if (!config && configured) throw new Error("listeners.json disappeared; refusing to restore unrestricted legacy access");
715
948
  const listeners = config?.listeners ?? [legacy];
716
949
  const fingerprint = JSON.stringify(listeners);
717
950
  if (applied === fingerprint) return;
718
- await reconcile(listeners);
951
+ await reconcile(listeners, startup);
719
952
  configured ||= !!config;
720
953
  applied = fingerprint;
721
954
  };
722
- await reload();
955
+ await reload(true);
723
956
  let reloading = false;
724
957
  let lastError = "";
725
958
  setInterval(async () => {
package/serve.ts CHANGED
@@ -243,6 +243,116 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
243
243
  return nameOrEmail;
244
244
  }
245
245
 
246
+ // Free the listening port from stale daemon holders before retrying a failed bind.
247
+ // On Windows the listening socket (created inheritable by Bun.serve) is swept into the
248
+ // detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
249
+ // previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
250
+ // then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
251
+ // only happens when such a stale holder exists; killing orphaned daemon holders here is
252
+ // safe because a freshly-starting serve owns no live sessions of its own yet (the user's
253
+ // Chrome tabs persist regardless — the cliDaemon only drives them).
254
+ // Command lines freeStalePort may kill: a previous rech serve, or a cliDaemon started from
255
+ // THIS install's playwright (lib/ or vendor/ under this directory) — never another app's
256
+ // Playwright daemon, which runs from its own node_modules.
257
+ const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
258
+ const OWN_CLI_DAEMON = import.meta.dir.split(/[\\/]/).map(escapeRegex).join(String.raw`[\\/]`) + String.raw`[\\/].*cliDaemon\.js`;
259
+ const RECH_SERVE = String.raw`rech(rome)?(\.ts)?"?\s+serve`;
260
+ const STALE_HOLDER_PATTERN = `${OWN_CLI_DAEMON}|${RECH_SERVE}`;
261
+ // A leaked socket (or a wedged serve) accepts connections but never answers. Anything that
262
+ // responds at an address is a live server there — possibly a healthy rech serve — so it is
263
+ // never killed. Both schemes: the holder's TLS setting may differ from this serve's.
264
+ async function answersAt(address: string, port: number): Promise<boolean> {
265
+ const host = address === "0.0.0.0" || address === "*" ? "127.0.0.1"
266
+ : address === "::" ? "[::1]"
267
+ : address.includes(":") ? `[${address}]` : address;
268
+ const probe = (scheme: string) => fetch(`${scheme}://${host}:${port}/`, {
269
+ signal: AbortSignal.timeout(1500), tls: { rejectUnauthorized: false },
270
+ } as RequestInit).then(() => true, () => false);
271
+ return (await Promise.all([probe("http"), probe("https")])).includes(true);
272
+ }
273
+
274
+ type PortHolder = { address: string; pid: number; alive: boolean; command: string };
275
+
276
+ // Listeners on `port` that conflict with binding `host`: the same address, or a wildcard of
277
+ // its family (a wildcard bind conflicts with every address of the family). Never this serve's
278
+ // own sockets, which may already hold other addresses on the same port from this startup.
279
+ function conflictingHolders(port: number, host: string): PortHolder[] {
280
+ let holders: PortHolder[] = [];
281
+ if (process.platform === "win32") {
282
+ const ps = [
283
+ "$ErrorActionPreference='SilentlyContinue';",
284
+ `$r=@(Get-NetTCPConnection -LocalPort ${port} -State Listen | ForEach-Object {`,
285
+ " $p=Get-CimInstance Win32_Process -Filter \"ProcessId=$($_.OwningProcess)\";",
286
+ " [pscustomobject]@{ address=[string]$_.LocalAddress; pid=[int]$_.OwningProcess; alive=[bool]$p; command=[string]$p.CommandLine } });",
287
+ "ConvertTo-Json -Compress -InputObject $r",
288
+ ].join(" ");
289
+ const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
290
+ try { holders = out ? [JSON.parse(out)].flat() : []; } catch { holders = []; }
291
+ } else {
292
+ const out = Bun.spawnSync(["sh", "-c", `lsof -nP -iTCP:${port} -sTCP:LISTEN -Fpn 2>/dev/null`]).stdout?.toString() ?? "";
293
+ let pid = 0;
294
+ for (const line of out.split("\n")) {
295
+ if (line.startsWith("p")) pid = Number(line.slice(1));
296
+ else if (line.startsWith("n") && pid) {
297
+ const address = line.slice(1).replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
298
+ const command = Bun.spawnSync(["ps", "-o", "command=", "-p", String(pid)]).stdout?.toString().trim() ?? "";
299
+ holders.push({ address, pid, alive: true, command });
300
+ }
301
+ }
302
+ }
303
+ const v6 = (a: string) => a.includes(":");
304
+ const wildcard = (a: string) => a === "0.0.0.0" || a === "::" || a === "*";
305
+ // A `::` listener is dual-stack on Windows and most Linux setups: it conflicts with IPv4 binds too.
306
+ return holders.filter(h => h.pid !== process.pid && (
307
+ wildcard(host) ? (h.address === "*" || h.address === "::" || host === "::" || !v6(h.address))
308
+ : h.address === host || h.address === "*" || h.address === "::" || (h.address === "0.0.0.0" && !v6(host))));
309
+ }
310
+
311
+ // Free the conflicting address from stale rech holders before retrying a failed bind.
312
+ // Narrow-first: (1) kill each conflicting owner that is a live rech serve / this install's
313
+ // cliDaemon AND doesn't answer at its own address — never an unrelated app, never a healthy
314
+ // server; (2) only if the address is STILL held by owners that are all dead — the
315
+ // inherited-handle case, where the socket lives in a child while netstat attributes it to a
316
+ // now-dead owner — sweep orphaned cliDaemons (Windows). That sweep is the only recovery for
317
+ // the case (the live holder can't be mapped from the port), so it is a logged last resort.
318
+ async function freeStalePort(port: number, host: string): Promise<void> {
319
+ try {
320
+ const stale = new RegExp(STALE_HOLDER_PATTERN, process.platform === "win32" ? "i" : "");
321
+ for (const h of conflictingHolders(port, host)) {
322
+ if (!h.alive) continue;
323
+ if (!stale.test(h.command)) { log(`freeStalePort: port ${port} is held by an unrelated process (${h.pid}); not killing it`); continue; }
324
+ if (await answersAt(h.address, port)) { log(`freeStalePort: ${h.address}:${port} is served by a live process (${h.pid}); not killing it`); continue; }
325
+ log(`freeStalePort: killing stale rech holder ${h.pid} of ${h.address}:${port}`);
326
+ try { process.kill(h.pid, "SIGKILL"); } catch {}
327
+ }
328
+ await new Promise(r => setTimeout(r, 400));
329
+ const remaining = conflictingHolders(port, host);
330
+ if (process.platform === "win32" && remaining.length && remaining.every(h => !h.alive)) {
331
+ const q = (text: string) => text.replaceAll("'", "''");
332
+ const ps = [
333
+ "$ErrorActionPreference='SilentlyContinue';",
334
+ // A cliDaemon's parent (playwright-cli) exits right after spawning it, so a live one
335
+ // can't be told from an orphan by ancestry. They are only provably orphaned when no
336
+ // other rech serve is running: exclude this serve and its own wrappers (oxmgr, cmd).
337
+ `$self=@(); $x=${process.pid}; while($x){ $self+=$x; $x=(Get-CimInstance Win32_Process -Filter \"ProcessId=$x\").ParentProcessId; if($self -contains $x){ break } };`,
338
+ `$live=@(Get-CimInstance Win32_Process | Where-Object { $self -notcontains $_.ProcessId -and $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(RECH_SERVE)}' });`,
339
+ "if($live.Count){ Write-Output (\"freeStalePort: port still held, but another rech serve is running (\" + ($live.ProcessId -join ',') + \"); not sweeping cliDaemons\") }",
340
+ "else {",
341
+ // Exclude this PowerShell itself: its own command line contains the pattern.
342
+ ` $d=Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(OWN_CLI_DAEMON)}' };`,
343
+ " Write-Output (\"freeStalePort: port still held; killing orphaned cliDaemon holders: \" + ($d.ProcessId -join ','));",
344
+ " $d | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
345
+ "}",
346
+ ].join(" ");
347
+ const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
348
+ if (out) log(out);
349
+ }
350
+ } catch {
351
+ // best effort — the retry will surface a clear error if the port is still held
352
+ }
353
+ await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
354
+ }
355
+
246
356
  // --- Foreground/orphan self-exit ---------------------------------------------------
247
357
  // A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
248
358
  // process-manager safety net: when the agent that spawned it exits, the OS re-parents
@@ -264,6 +374,88 @@ export function shouldExitOrphanedServe(opts: {
264
374
  return opts.idleTimeoutMs > 0 && opts.orphaned && opts.idleMs >= opts.idleTimeoutMs;
265
375
  }
266
376
 
377
+ export const LANDING_HEADERS = {
378
+ "Content-Type": "text/html; charset=utf-8",
379
+ "Cache-Control": "no-store",
380
+ "Referrer-Policy": "no-referrer",
381
+ "X-Content-Type-Options": "nosniff",
382
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
383
+ };
384
+
385
+ /**
386
+ * The page a shared connection URL shows in a browser: install-and-connect commands to copy,
387
+ * one per shell. It is static; the script reads the full URL (with its #key) from the address
388
+ * bar, shows it with the key masked, and copies it whole.
389
+ */
390
+ export function landingPage(): string {
391
+ return `<!doctype html>
392
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
393
+ <title>Connect to a shared Chrome · rechrome</title>
394
+ <style>
395
+ :root { --bg:#fff; --fg:#1d1d1f; --muted:#6e6e73; --card:#f5f5f7; --line:#d2d2d7; --accent:#0a66c2; --warn:#b25000; }
396
+ @media (prefers-color-scheme: dark) { :root { --bg:#161617; --fg:#f5f5f7; --muted:#a1a1a6; --card:#232325; --line:#3a3a3c; --accent:#4c9bff; --warn:#ffb86b; } }
397
+ * { box-sizing: border-box; }
398
+ body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
399
+ main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
400
+ h1 { font-size: 1.6rem; margin: 0 0 8px; }
401
+ p { margin: 0 0 16px; color: var(--muted); }
402
+ .row { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0 0 12px; }
403
+ .label { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 6px; }
404
+ .cmd { display: flex; gap: 10px; align-items: flex-start; }
405
+ code { flex: 1; font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; }
406
+ button { flex: none; font: inherit; font-size: .9rem; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
407
+ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
408
+ .warn { color: var(--warn); font-weight: 600; }
409
+ .small { font-size: .85rem; }
410
+ a { color: var(--accent); }
411
+ </style></head>
412
+ <body><main>
413
+ <h1>Connect to a shared Chrome</h1>
414
+ <p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
415
+ On your computer, inside the project folder that should use it, run:</p>
416
+ <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>
417
+ <div id="cmds"></div>
418
+ <p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
419
+ This link contains a secret key: anyone with it can use this browser profile, so share it privately.</p>
420
+ </main>
421
+ <script>
422
+ (() => {
423
+ const url = location.href;
424
+ if (!/[#&?]key=/.test(location.hash)) document.getElementById("nokey").hidden = false;
425
+ const posix = s => "'" + s.replace(/'/g, "'\\\\''") + "'";
426
+ const pwsh = s => '"' + s.replace(/[\`"$]/g, c => "\`" + c) + '"';
427
+ const cmd = s => '"' + s.replace(/"/g, "%22") + '"';
428
+ const shells = [
429
+ ["macOS / Linux", "bun i -g rechrome && rechrome connect " + posix(url)],
430
+ ["Windows PowerShell", "bun i -g rechrome; rechrome connect " + pwsh(url)],
431
+ ["Windows cmd", "bun i -g rechrome && rechrome connect " + cmd(url)],
432
+ ];
433
+ const mask = s => s.replace(/(key=)[^&"'\`]+/, "$1…");
434
+ const root = document.getElementById("cmds");
435
+ for (const [label, text] of shells) {
436
+ const row = document.createElement("div"); row.className = "row";
437
+ const name = document.createElement("div"); name.className = "label"; name.textContent = label;
438
+ const line = document.createElement("div"); line.className = "cmd";
439
+ const code = document.createElement("code"); code.textContent = mask(text);
440
+ const button = document.createElement("button"); button.type = "button"; button.textContent = "Copy";
441
+ button.setAttribute("aria-label", "Copy the " + label + " command");
442
+ button.addEventListener("click", async () => {
443
+ try { await navigator.clipboard.writeText(text); }
444
+ catch {
445
+ // Plain-HTTP pages (e.g. a LAN address) have no clipboard API: copy via a hidden textarea.
446
+ const area = document.createElement("textarea"); area.value = text; document.body.append(area);
447
+ area.select(); document.execCommand("copy"); area.remove();
448
+ }
449
+ button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
450
+ });
451
+ line.append(code, button); row.append(name, line); root.append(row);
452
+ }
453
+ })();
454
+ </script>
455
+ </body></html>
456
+ `;
457
+ }
458
+
267
459
  export async function serve() {
268
460
  // The daemon owns logs/ and output/, so it migrates them before writing anything.
269
461
  const migrated = migrateLegacyDataDir();
@@ -312,9 +504,13 @@ export async function serve() {
312
504
  const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
313
505
  const policies = new Map<string, Listener>();
314
506
  const servers = new Map<string, ReturnType<typeof Bun.serve>>();
315
- const startServer = (initial: Listener) => Bun.serve({
507
+ const startServer = (initial: Listener, reusePort = false) => Bun.serve({
316
508
  hostname: initial.host,
317
509
  port: initial.port,
510
+ // reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
511
+ // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
512
+ // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
513
+ reusePort,
318
514
  tls,
319
515
  error(err) {
320
516
  log(`unhandled error: ${err.message}`);
@@ -381,6 +577,10 @@ export async function serve() {
381
577
  consecutiveTimeouts, degraded: degraded || !healthy,
382
578
  });
383
579
  }
580
+ // A browser opening a shared URL gets copyable connect instructions. The key stays in the
581
+ // #fragment, which browsers never send, so the page is static and the key never reaches here.
582
+ if (reqUrl.pathname === "/" && req.method === "GET" && (req.headers.get("accept") ?? "").includes("text/html"))
583
+ return new Response(landingPage(), { headers: LANDING_HEADERS });
384
584
  if (reqUrl.pathname !== "/run") return new Response("rech server\n");
385
585
  const denied = authCheck(req, key);
386
586
  if (denied) return denied;
@@ -687,14 +887,47 @@ export async function serve() {
687
887
  },
688
888
  });
689
889
 
890
+ // A leaked listening-socket handle in an orphaned cliDaemon can keep a port in LISTEN after a
891
+ // prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
892
+ // detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
893
+ // netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
894
+ // map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
895
+ // Startup only: a freshly-starting serve owns no live sessions, so clearing stale holders is
896
+ // safe. Hot reloads never kill anything (see reconcile). As an absolute last resort, bind with
897
+ // reusePort so a holder we genuinely can't kill degrades to "up but sharing the port" rather
898
+ // than a permanent EADDRINUSE crash-loop.
899
+ const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
900
+ const MAX_BIND_ATTEMPTS = 4;
901
+ const bindAtStartup = async (listener: Listener) => {
902
+ for (let attempt = 1; ; attempt++) {
903
+ try {
904
+ return startServer(listener);
905
+ } catch (e: any) {
906
+ if (!isEaddrInUse(e)) throw e;
907
+ // Something answers at this exact address: a live server (never killed, never port-shared).
908
+ if (await answersAt(listener.host, listener.port)) {
909
+ log(`port ${listener.port} is held by a live server that answers requests — not touching it`);
910
+ throw e;
911
+ }
912
+ if (attempt === MAX_BIND_ATTEMPTS) {
913
+ log(`port ${listener.port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
914
+ return startServer(listener, true);
915
+ }
916
+ log(`port ${listener.port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
917
+ await freeStalePort(listener.port, listener.host);
918
+ }
919
+ }
920
+ };
921
+
690
922
  // Stage all new sockets before changing policy. Bind failure leaves existing
691
- // listeners and browser sessions intact; never kill a process holding another port.
692
- const reconcile = async (listeners: Listener[]) => {
923
+ // listeners and browser sessions intact; never kill a process holding another port
924
+ // once serving (stale-holder recovery runs only at startup).
925
+ const reconcile = async (listeners: Listener[], startup = false) => {
693
926
  const staged = new Map<string, ReturnType<typeof Bun.serve>>();
694
927
  try {
695
928
  for (const listener of listeners) {
696
929
  const address = listenerAddress(listener);
697
- if (!servers.has(address)) staged.set(address, startServer(listener));
930
+ if (!servers.has(address)) staged.set(address, startup ? await bindAtStartup(listener) : startServer(listener));
698
931
  }
699
932
  } catch (error) {
700
933
  for (const server of staged.values()) server.stop(true);
@@ -709,17 +942,17 @@ export async function serve() {
709
942
  };
710
943
  let applied = "";
711
944
  let configured = false;
712
- const reload = async () => {
945
+ const reload = async (startup = false) => {
713
946
  const config = await readListeners();
714
947
  if (!config && configured) throw new Error("listeners.json disappeared; refusing to restore unrestricted legacy access");
715
948
  const listeners = config?.listeners ?? [legacy];
716
949
  const fingerprint = JSON.stringify(listeners);
717
950
  if (applied === fingerprint) return;
718
- await reconcile(listeners);
951
+ await reconcile(listeners, startup);
719
952
  configured ||= !!config;
720
953
  applied = fingerprint;
721
954
  };
722
- await reload();
955
+ await reload(true);
723
956
  let reloading = false;
724
957
  let lastError = "";
725
958
  setInterval(async () => {