rechrome 1.29.0 → 1.30.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/README.md CHANGED
@@ -106,6 +106,18 @@ rech open https://example.com
106
106
  `.rechrome/.env.local`. Remote listeners allow navigation, tabs, snapshots, screenshots and basic
107
107
  interactions, but not `eval`/`run-code` or filesystem commands (see [Remote access](#remote-access)).
108
108
 
109
+ **Share every profile at once.** `rech share --all` gives one link for all Chrome profiles
110
+ registered on the host. It uses its own listener (`share-all`) and key, so one-profile links you
111
+ already handed out never gain access to the others. It is a snapshot: after registering another
112
+ profile, run `rech share --all` again. On the other machine, pick a profile per command, and the
113
+ host resolves the name (exact, the part of an email before `@`, or a unique 3+ letter prefix):
114
+
115
+ ```bash
116
+ rech connect '<url from rech share --all>'
117
+ rech profile # the profiles that link shares
118
+ rech --profile work@example.com open https://example.com
119
+ ```
120
+
109
121
  On a trusted LAN without a proxy, `rech setup --listen lan --profile you@example.com` binds the
110
122
  profile to your LAN address directly (plain HTTP); share the result of `rech share`.
111
123
 
@@ -121,6 +133,9 @@ rech listener remove share
121
133
  ```
122
134
 
123
135
  Changes apply immediately; the daemon reloads its listeners without restarting Chrome.
136
+ A link's key opens **every** profile its listener allows (`?profile=` only picks the default),
137
+ so give a profile its own listener when a link should reach only that one. `rech share ls`
138
+ points out keys that cover several profiles. The local management listener is never shared.
124
139
 
125
140
  ## Setup reference
126
141
 
package/listeners.js CHANGED
@@ -141,11 +141,49 @@ function setPublicUrl(config, name, publicUrl) {
141
141
  listener.publicUrl = normalizePublicUrl(publicUrl);
142
142
  return listener;
143
143
  }
144
+ var profileIdentity = (e) => `${e.userDataDir ?? ""}\x00${e.profileDir}`;
145
+ function canonicalProfileKeys(registry) {
146
+ const byProfile = new Map;
147
+ for (const key of Object.keys(registry).sort()) {
148
+ const id = profileIdentity(registry[key]);
149
+ const kept = byProfile.get(id);
150
+ if (!kept || !kept.includes("@") && key.includes("@"))
151
+ byProfile.set(id, key);
152
+ }
153
+ return [...byProfile.values()];
154
+ }
155
+ function resolveAllowedProfile(selector, allowed, registry, chromeNames = {}) {
156
+ const list = allowed.join(", ");
157
+ if (!selector?.trim())
158
+ throw new Error(`Pick a profile: this link shares ${list}. For example: rech --profile ${JSON.stringify(allowed[0] ?? "<name>")} open https://example.com`);
159
+ const needle = selector.trim().toLowerCase();
160
+ const candidates = allowed.filter((k) => registry[k]).map((key) => {
161
+ const entry = registry[key];
162
+ const aliases = Object.keys(registry).filter((k) => profileIdentity(registry[k]) === profileIdentity(entry));
163
+ const name = entry.userDataDir ? undefined : chromeNames[entry.profileDir];
164
+ const fields = [...new Set([...aliases, entry.profileDir, ...name ? [name] : []])].map((f) => f.toLowerCase());
165
+ return { key, fields, localParts: fields.filter((f) => f.includes("@")).map((f) => f.split("@")[0]) };
166
+ });
167
+ const stages = [
168
+ (c) => c.fields.includes(needle),
169
+ (c) => c.localParts.includes(needle),
170
+ (c) => needle.length >= 3 && c.fields.some((f) => f.startsWith(needle))
171
+ ];
172
+ for (const test of stages) {
173
+ const hits = candidates.filter(test);
174
+ if (hits.length === 1)
175
+ return hits[0].key;
176
+ if (hits.length > 1)
177
+ throw new Error(`"${selector}" matches several shared profiles (${hits.map((h) => h.key).join(", ")}); be more specific.`);
178
+ }
179
+ throw new Error(`"${selector}" is not shared by this link. It shares: ${list}.`);
180
+ }
144
181
  export {
145
182
  LISTENERS_FILE,
146
183
  allowProfiles,
147
184
  authorizeProfileRequest,
148
185
  canReadProfileFile,
186
+ canonicalProfileKeys,
149
187
  denyProfiles,
150
188
  isLoopback,
151
189
  listenerAddress,
@@ -153,6 +191,7 @@ export {
153
191
  normalizePublicUrl,
154
192
  profileOutputPrefix,
155
193
  readListeners,
194
+ resolveAllowedProfile,
156
195
  rotateListenerKey,
157
196
  serviceUrl,
158
197
  setPublicUrl,
package/listeners.ts CHANGED
@@ -135,3 +135,55 @@ export function setPublicUrl(config: ListenerConfig, name: string, publicUrl: st
135
135
  else listener.publicUrl = normalizePublicUrl(publicUrl);
136
136
  return listener;
137
137
  }
138
+
139
+ /** What the resolver needs from a registry entry: which Chrome profile it points at. */
140
+ export type RegisteredProfile = { profileDir: string; userDataDir?: string };
141
+ const profileIdentity = (e: RegisteredProfile) => `${e.userDataDir ?? ""}\0${e.profileDir}`;
142
+
143
+ /**
144
+ * One registry key per Chrome profile (a profile can be registered under several aliases,
145
+ * e.g. "Profile 5" and "taku2"): prefer an email, else the alphabetically first key.
146
+ */
147
+ export function canonicalProfileKeys(registry: Record<string, RegisteredProfile>): string[] {
148
+ const byProfile = new Map<string, string>();
149
+ for (const key of Object.keys(registry).sort()) {
150
+ const id = profileIdentity(registry[key]);
151
+ const kept = byProfile.get(id);
152
+ if (!kept || (!kept.includes("@") && key.includes("@"))) byProfile.set(id, key);
153
+ }
154
+ return [...byProfile.values()];
155
+ }
156
+
157
+ /**
158
+ * Resolve what a remote client typed (`rech --profile work`) to one allowed registry key, on
159
+ * the host, so the client needs no registry of its own. Aliases of an allowed profile map to
160
+ * it, so they share one session. Matching: exact key, alias, folder or Chrome name
161
+ * (case-insensitive), then the email part before "@", then a unique 3+ character prefix.
162
+ * Ambiguity and misses list the allowed profiles; nothing outside the allowlist can match.
163
+ */
164
+ export function resolveAllowedProfile(
165
+ selector: string | undefined, allowed: string[], registry: Record<string, RegisteredProfile>,
166
+ chromeNames: Record<string, string> = {},
167
+ ): string {
168
+ const list = allowed.join(", ");
169
+ if (!selector?.trim()) throw new Error(`Pick a profile: this link shares ${list}. For example: rech --profile ${JSON.stringify(allowed[0] ?? "<name>")} open https://example.com`);
170
+ const needle = selector.trim().toLowerCase();
171
+ const candidates = allowed.filter(k => registry[k]).map(key => {
172
+ const entry = registry[key];
173
+ const aliases = Object.keys(registry).filter(k => profileIdentity(registry[k]) === profileIdentity(entry));
174
+ const name = entry.userDataDir ? undefined : chromeNames[entry.profileDir];
175
+ const fields = [...new Set([...aliases, entry.profileDir, ...(name ? [name] : [])])].map(f => f.toLowerCase());
176
+ return { key, fields, localParts: fields.filter(f => f.includes("@")).map(f => f.split("@")[0]) };
177
+ });
178
+ const stages: Array<(c: typeof candidates[number]) => boolean> = [
179
+ c => c.fields.includes(needle),
180
+ c => c.localParts.includes(needle),
181
+ c => needle.length >= 3 && c.fields.some(f => f.startsWith(needle)),
182
+ ];
183
+ for (const test of stages) {
184
+ const hits = candidates.filter(test);
185
+ if (hits.length === 1) return hits[0].key;
186
+ if (hits.length > 1) throw new Error(`"${selector}" matches several shared profiles (${hits.map(h => h.key).join(", ")}); be more specific.`);
187
+ }
188
+ throw new Error(`"${selector}" is not shared by this link. It shares: ${list}.`);
189
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, type Listener } from "./listeners.js";
2
+ import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, type Listener } from "./listeners.js";
3
3
 
4
4
  import { file } from "bun";
5
5
  import yargs from "yargs";
@@ -624,7 +624,7 @@ const CHROME_LOCAL_STATE_PATHS = () => {
624
624
 
625
625
  type ChromeProfileInfo = { user_name?: string; name?: string };
626
626
 
627
- async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
627
+ export async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
628
628
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
629
629
  const f = file(statePath);
630
630
  if (!(await f.exists())) continue;
@@ -893,6 +893,19 @@ export function buildProfileRows(cache: Record<string, ChromeProfileInfo> | null
893
893
  return rows;
894
894
  }
895
895
 
896
+ /** On a client of a remote host, `rech profile` lists what that host's link shares. */
897
+ async function listRemoteProfiles(url: string): Promise<void> {
898
+ const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(5000) })
899
+ .catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
900
+ if (response.status === 401) throw new Error("The host rejected this link's key; ask for a fresh one (rech share on the host), then rech connect '<url>'.");
901
+ const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
902
+ const current = resolveEffectiveProfile(parseUrl(url).profileDirectory);
903
+ console.log(`Profiles shared by ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}:`);
904
+ if (body.profiles === "*") console.log(" every profile registered on that host");
905
+ else for (const p of body.profiles ?? []) console.log(` ${p}${p === current ? " ← current" : ""}`);
906
+ console.log(`\nUse one: rech --profile <name> open https://example.com`);
907
+ }
908
+
896
909
  async function listProfiles(): Promise<void> {
897
910
  const [cache, registry, root] = await Promise.all([readChromeProfileCache(), readTokenRegistry(), findChromeUserDataDir()]);
898
911
  const profiles = buildProfileRows(cache, registry, root);
@@ -983,15 +996,12 @@ export async function promptChoice<T>(
983
996
  } finally { rl.close(); }
984
997
  }
985
998
 
986
- /** Registered profiles as choices, one per Chrome profile folder (the registry may alias one folder twice). */
999
+ /** Registered profiles as choices, one per Chrome profile (data dir + folder; aliases collapse). */
987
1000
  function registeredProfileChoices(registry: Record<string, TokenEntry>): { label: string; value: string }[] {
988
- const byDir = new Map<string, string>();
989
- for (const key of Object.keys(registry)) {
1001
+ return canonicalProfileKeys(registry).map(key => {
990
1002
  const dir = registry[key].profileDir;
991
- const kept = byDir.get(dir);
992
- if (!kept || (!kept.includes("@") && key.includes("@"))) byDir.set(dir, key);
993
- }
994
- return [...byDir.entries()].map(([dir, key]) => ({ label: key === dir ? key : `${key} [${dir}]`, value: key }));
1003
+ return { label: key === dir ? key : `${key} [${dir}]`, value: key };
1004
+ });
995
1005
  }
996
1006
 
997
1007
  /**
@@ -1013,7 +1023,64 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
1013
1023
  ].filter(Boolean).join("\n"), "none", scoped);
1014
1024
  }
1015
1025
 
1016
- async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean } = {}): Promise<void> {
1026
+ /** A loopback port no listener uses and nothing else is bound to, from the management port + 2. */
1027
+ function freeListenerPort(listeners: Listener[]): number {
1028
+ const used = new Set(listeners.map(l => l.port));
1029
+ for (let port = DEFAULT_PORT + 2; port < 65536; port++) {
1030
+ if (used.has(port)) continue;
1031
+ try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return port; } catch { /* taken */ }
1032
+ }
1033
+ throw new Error("No free port for the share-all listener; pass one with rech listener add");
1034
+ }
1035
+
1036
+ /**
1037
+ * `rech share --all`: one link for every Chrome profile registered now (a snapshot; run it
1038
+ * again after registering more). It uses its own listener and key, so single-profile links
1039
+ * already handed out never gain access to the other profiles.
1040
+ */
1041
+ async function shareAll(opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
1042
+ const config = await requireListeners();
1043
+ const snapshot = canonicalProfileKeys(await readTokenRegistry());
1044
+ if (!snapshot.length) throw new Error("No registered profiles to share. Set one up first: rech setup");
1045
+ const name = opts.listener ?? "share-all";
1046
+ let listener = config.listeners.find(l => l.name === name);
1047
+ if (listener?.profiles === "*")
1048
+ throw new Error(`"${name}" is the local management listener; it is never shared. Omit --listener to use "share-all".`);
1049
+ let changes = "";
1050
+ if (!listener) {
1051
+ if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
1052
+ listener = { name, host: "127.0.0.1", port: freeListenerPort(config.listeners), prefix: "/rechrome-all/", key: randomBytes(24).toString("base64url"), profiles: snapshot };
1053
+ config.listeners.push(listener);
1054
+ changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
1055
+ } else {
1056
+ const before = listener.profiles as string[];
1057
+ const added = snapshot.filter(p => !before.includes(p)), removed = before.filter(p => !snapshot.includes(p));
1058
+ listener.profiles = snapshot;
1059
+ changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
1060
+ }
1061
+ await writeListeners(config);
1062
+ console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
1063
+ console.error(`[rech] this is a snapshot: after registering another profile, run rech share --all again.`);
1064
+ // The daemon reloads listeners.json about every second: confirm this listener answers before handing out its URL.
1065
+ const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
1066
+ let ready = false;
1067
+ for (let i = 0; i < 20 && !ready; i++) {
1068
+ ready = await fetch(serviceUrl(local, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false);
1069
+ if (!ready) await Bun.sleep(250);
1070
+ }
1071
+ if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
1072
+ const uri = listener.publicUrl && !opts.local ? rebaseConnectionUrl(listener.publicUrl, local) : registeredProfileUrl(local);
1073
+ if (!listener.publicUrl) for (const line of listenerNextSteps(listener, undefined, "rech share --all")) console.error(line);
1074
+ console.log(uri);
1075
+ console.error(`[rech] on the other machine: rech connect '<url>', then rech --profile <name> open https://example.com`);
1076
+ if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
1077
+ }
1078
+
1079
+ async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
1080
+ if (opts.all) {
1081
+ if (selector) throw new Error("Pass a profile or --all, not both.");
1082
+ return shareAll({ listener, local: opts.local, save: opts.save });
1083
+ }
1017
1084
  const url = process.env[ENV_KEY];
1018
1085
  const interactive = isInteractive();
1019
1086
  const registry = await readTokenRegistry();
@@ -1073,8 +1140,11 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1073
1140
  }
1074
1141
  }
1075
1142
  }
1076
- if (listeners.find(l => l.name === listener)?.profiles === "*")
1077
- console.error(`[rech] "${listener}" is the local management listener: its key controls every profile. Don't share this URL.`);
1143
+ const chosen = listeners.find(l => l.name === listener);
1144
+ if (chosen?.profiles === "*")
1145
+ throw new Error(`"${listener}" is the local management listener: its key controls every profile and allows eval and file access, so it is never shared. Share through a scoped listener: rech share <profile>, or rech share --all.`);
1146
+ if (chosen && chosen.profiles.length > 1)
1147
+ console.error(`[rech] note: this link's key works for every profile on listener "${chosen.name}" (${chosen.profiles.join(", ")}); ?profile= only picks the default. For a one-profile link, give that profile its own listener.`);
1078
1148
  let uri = profileConnectionUri(profile, url, listeners, listener);
1079
1149
  // Prefer where a proxy exposes the listener, when it has been recorded.
1080
1150
  const publicUrl = listeners.find(l => l.key === parseUrl(uri).key)?.publicUrl;
@@ -1090,6 +1160,29 @@ export function sandboxConnectionWarning(env: Record<string, string | undefined>
1090
1160
  "If it still fails, check that the daemon is running and the host/port are correct.";
1091
1161
  }
1092
1162
 
1163
+ /**
1164
+ * Is RECHROME_URL this machine's own daemon? True when its key is one of our listeners (or,
1165
+ * before listeners.json existed, when it points at loopback and profiles are registered here). A remote daemon resolves
1166
+ * profiles itself, and must never receive this machine's own extension tokens.
1167
+ */
1168
+ export async function isLocalDaemon(url: string): Promise<boolean> {
1169
+ const { key, host } = parseUrl(url);
1170
+ const config = await readListeners().catch(() => null);
1171
+ // Before listeners.json existed, a local install pointed at loopback and had its own registry;
1172
+ // a fresh client (no registry) talking to a tunnelled loopback port is still remote.
1173
+ if (!config) return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(host) && Object.keys(await readTokenRegistry().catch(() => ({}))).length > 0;
1174
+ return !!key && config.listeners.some(l => l.key === key);
1175
+ }
1176
+
1177
+ /** For a remote daemon: only what the URL itself carries, plus the profile selector (not secret). */
1178
+ export function remoteClientEnv(url: string, profile?: string): Record<string, string> {
1179
+ const { extensionId, extensionToken, userDataDir, loadExtension } = parseUrl(url);
1180
+ return Object.fromEntries(Object.entries({
1181
+ PLAYWRIGHT_MCP_EXTENSION_ID: extensionId, PLAYWRIGHT_MCP_EXTENSION_TOKEN: extensionToken,
1182
+ PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profile, PLAYWRIGHT_MCP_USER_DATA_DIR: userDataDir, PLAYWRIGHT_MCP_LOAD_EXTENSION: loadExtension,
1183
+ }).filter(([, v]) => typeof v === "string" && v)) as Record<string, string>;
1184
+ }
1185
+
1093
1186
  async function callServe(
1094
1187
  url: string,
1095
1188
  args: string[],
@@ -1107,7 +1200,9 @@ async function callServe(
1107
1200
  // reuse the default profile's session (and its browser) instead of opening its own.
1108
1201
  const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
1109
1202
  if (effectiveProfile) identity.profile = effectiveProfile;
1110
- const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension })), ...overrideEnv };
1203
+ const env = await isLocalDaemon(url)
1204
+ ? { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension })), ...overrideEnv }
1205
+ : { ...remoteClientEnv(url, effectiveProfile), ...overrideEnv };
1111
1206
  const res = await fetch(serviceUrl(url, "run"), {
1112
1207
  method: "POST",
1113
1208
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
@@ -2185,7 +2280,7 @@ async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
2185
2280
  }
2186
2281
 
2187
2282
  /** Next steps after exposing a listener: proxy it, record where, share. Plain text, so any shell works. */
2188
- export function listenerNextSteps(listener: Listener, profile?: string): string[] {
2283
+ export function listenerNextSteps(listener: Listener, profile?: string, shareCommand?: string): string[] {
2189
2284
  const prefix = normalizePrefix(listener.prefix);
2190
2285
  const mount = prefix === "/" ? "" : prefix.slice(0, -1);
2191
2286
  return [
@@ -2193,7 +2288,7 @@ export function listenerNextSteps(listener: Listener, profile?: string): string[
2193
2288
  ` tailscale serve --bg${mount ? ` --set-path=${mount}` : ""} ${listener.port}`,
2194
2289
  `Then record where it is reachable and print the URL to share:`,
2195
2290
  ` rech listener set ${listener.name} --public-url https://<your-host>${prefix}`,
2196
- ` rech share ${profile ? JSON.stringify(profile) : "<profile>"}`,
2291
+ ` ${shareCommand ?? `rech share ${profile ? JSON.stringify(profile) : "<profile>"}`}`,
2197
2292
  ];
2198
2293
  }
2199
2294
 
@@ -2265,7 +2360,9 @@ async function urlList(): Promise<void> {
2265
2360
  for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
2266
2361
  }
2267
2362
  printTable(rows);
2268
- console.log(`\nPrint a full URL (contains the secret key): rech share <profile>`);
2363
+ const wide = config.listeners.filter(l => l.profiles !== "*" && l.profiles.length > 1);
2364
+ if (wide.length) console.log(`\nOne key covers several profiles on: ${wide.map(l => `${l.name} (${(l.profiles as string[]).length})`).join(", ")}. Anyone with such a link can use each of them.`);
2365
+ console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
2269
2366
  }
2270
2367
 
2271
2368
  /** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
@@ -2293,6 +2390,8 @@ async function connect(url: string): Promise<void> {
2293
2390
  throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
2294
2391
  const saved = await saveProjectUrl(url);
2295
2392
  console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
2393
+ if (Array.isArray(body.profiles) && body.profiles.length > 1 && !parsed.profileDirectory)
2394
+ console.log(`This link shares ${body.profiles.length} profiles: ${body.profiles.join(", ")}.\nPick one per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
2296
2395
  }
2297
2396
 
2298
2397
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
@@ -2746,7 +2845,7 @@ export type RechHandlers = {
2746
2845
  addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
2747
2846
  removeListener(name: string): Promise<void>;
2748
2847
  listProfiles(): Promise<void>;
2749
- printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean }): Promise<void>;
2848
+ printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean; all?: boolean }): Promise<void>;
2750
2849
  urlList(): Promise<void>;
2751
2850
  connect(url: string): Promise<void>;
2752
2851
  listenerPort(name?: string): Promise<void>;
@@ -2824,10 +2923,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
2824
2923
  .positional("profile", { type: "string", describe: "Profile: email, name, folder, or a unique part of one; ls/list lists everything shared" })
2825
2924
  .option("listener", { type: "string", requiresArg: true, describe: "Listener to share through (default: the one that allows the profile)" })
2826
2925
  .option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
2827
- .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
2828
- a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
2926
+ .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" })
2927
+ .option("all", { type: "boolean", describe: "One link for every registered profile (a snapshot, on its own listener); the other machine picks with --profile" }),
2928
+ a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save && !a.all
2829
2929
  ? handlers.urlList()
2830
- : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
2930
+ : handlers.printProfileUri(a.profile, a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save }))
2831
2931
  .command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
2832
2932
  .positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
2833
2933
  .example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", ""),
@@ -2893,7 +2993,11 @@ if (import.meta.main) {
2893
2993
  const handlers: RechHandlers = {
2894
2994
  serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
2895
2995
  status,
2896
- listListeners, addListener, removeListener, listProfiles, printProfileUri,
2996
+ listListeners, addListener, removeListener, printProfileUri,
2997
+ listProfiles: async () => {
2998
+ const url = process.env[ENV_KEY];
2999
+ return url && !(await isLocalDaemon(url)) ? listRemoteProfiles(url) : listProfiles();
3000
+ },
2897
3001
  urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
2898
3002
  setup: async (opts) => {
2899
3003
  await setup(opts); // setup closes envWatcher itself before printing Done
@@ -2954,7 +3058,11 @@ if (import.meta.main) {
2954
3058
  envWatcher?.close();
2955
3059
  process.exit(1);
2956
3060
  }
2957
- if (profileSelector !== undefined) {
3061
+ if (profileSelector !== undefined && !(await isLocalDaemon(url))) {
3062
+ // A remote host resolves the name among the profiles its link shares; this machine's
3063
+ // registry is irrelevant (and usually empty on a client).
3064
+ overrideEnv = { PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileSelector.trim() };
3065
+ } else if (profileSelector !== undefined) {
2958
3066
  try {
2959
3067
  const registry = await readTokenRegistry();
2960
3068
  const cache = await readChromeProfileCache();
package/rechrome.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, type Listener } from "./listeners.ts";
2
+ import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, type Listener } from "./listeners.ts";
3
3
 
4
4
  import { file } from "bun";
5
5
  import yargs from "yargs";
@@ -624,7 +624,7 @@ const CHROME_LOCAL_STATE_PATHS = () => {
624
624
 
625
625
  type ChromeProfileInfo = { user_name?: string; name?: string };
626
626
 
627
- async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
627
+ export async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
628
628
  for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
629
629
  const f = file(statePath);
630
630
  if (!(await f.exists())) continue;
@@ -893,6 +893,19 @@ export function buildProfileRows(cache: Record<string, ChromeProfileInfo> | null
893
893
  return rows;
894
894
  }
895
895
 
896
+ /** On a client of a remote host, `rech profile` lists what that host's link shares. */
897
+ async function listRemoteProfiles(url: string): Promise<void> {
898
+ const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(5000) })
899
+ .catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
900
+ if (response.status === 401) throw new Error("The host rejected this link's key; ask for a fresh one (rech share on the host), then rech connect '<url>'.");
901
+ const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
902
+ const current = resolveEffectiveProfile(parseUrl(url).profileDirectory);
903
+ console.log(`Profiles shared by ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}:`);
904
+ if (body.profiles === "*") console.log(" every profile registered on that host");
905
+ else for (const p of body.profiles ?? []) console.log(` ${p}${p === current ? " ← current" : ""}`);
906
+ console.log(`\nUse one: rech --profile <name> open https://example.com`);
907
+ }
908
+
896
909
  async function listProfiles(): Promise<void> {
897
910
  const [cache, registry, root] = await Promise.all([readChromeProfileCache(), readTokenRegistry(), findChromeUserDataDir()]);
898
911
  const profiles = buildProfileRows(cache, registry, root);
@@ -983,15 +996,12 @@ export async function promptChoice<T>(
983
996
  } finally { rl.close(); }
984
997
  }
985
998
 
986
- /** Registered profiles as choices, one per Chrome profile folder (the registry may alias one folder twice). */
999
+ /** Registered profiles as choices, one per Chrome profile (data dir + folder; aliases collapse). */
987
1000
  function registeredProfileChoices(registry: Record<string, TokenEntry>): { label: string; value: string }[] {
988
- const byDir = new Map<string, string>();
989
- for (const key of Object.keys(registry)) {
1001
+ return canonicalProfileKeys(registry).map(key => {
990
1002
  const dir = registry[key].profileDir;
991
- const kept = byDir.get(dir);
992
- if (!kept || (!kept.includes("@") && key.includes("@"))) byDir.set(dir, key);
993
- }
994
- return [...byDir.entries()].map(([dir, key]) => ({ label: key === dir ? key : `${key} [${dir}]`, value: key }));
1003
+ return { label: key === dir ? key : `${key} [${dir}]`, value: key };
1004
+ });
995
1005
  }
996
1006
 
997
1007
  /**
@@ -1013,7 +1023,64 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
1013
1023
  ].filter(Boolean).join("\n"), "none", scoped);
1014
1024
  }
1015
1025
 
1016
- async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean } = {}): Promise<void> {
1026
+ /** A loopback port no listener uses and nothing else is bound to, from the management port + 2. */
1027
+ function freeListenerPort(listeners: Listener[]): number {
1028
+ const used = new Set(listeners.map(l => l.port));
1029
+ for (let port = DEFAULT_PORT + 2; port < 65536; port++) {
1030
+ if (used.has(port)) continue;
1031
+ try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return port; } catch { /* taken */ }
1032
+ }
1033
+ throw new Error("No free port for the share-all listener; pass one with rech listener add");
1034
+ }
1035
+
1036
+ /**
1037
+ * `rech share --all`: one link for every Chrome profile registered now (a snapshot; run it
1038
+ * again after registering more). It uses its own listener and key, so single-profile links
1039
+ * already handed out never gain access to the other profiles.
1040
+ */
1041
+ async function shareAll(opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
1042
+ const config = await requireListeners();
1043
+ const snapshot = canonicalProfileKeys(await readTokenRegistry());
1044
+ if (!snapshot.length) throw new Error("No registered profiles to share. Set one up first: rech setup");
1045
+ const name = opts.listener ?? "share-all";
1046
+ let listener = config.listeners.find(l => l.name === name);
1047
+ if (listener?.profiles === "*")
1048
+ throw new Error(`"${name}" is the local management listener; it is never shared. Omit --listener to use "share-all".`);
1049
+ let changes = "";
1050
+ if (!listener) {
1051
+ if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
1052
+ listener = { name, host: "127.0.0.1", port: freeListenerPort(config.listeners), prefix: "/rechrome-all/", key: randomBytes(24).toString("base64url"), profiles: snapshot };
1053
+ config.listeners.push(listener);
1054
+ changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
1055
+ } else {
1056
+ const before = listener.profiles as string[];
1057
+ const added = snapshot.filter(p => !before.includes(p)), removed = before.filter(p => !snapshot.includes(p));
1058
+ listener.profiles = snapshot;
1059
+ changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
1060
+ }
1061
+ await writeListeners(config);
1062
+ console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
1063
+ console.error(`[rech] this is a snapshot: after registering another profile, run rech share --all again.`);
1064
+ // The daemon reloads listeners.json about every second: confirm this listener answers before handing out its URL.
1065
+ const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
1066
+ let ready = false;
1067
+ for (let i = 0; i < 20 && !ready; i++) {
1068
+ ready = await fetch(serviceUrl(local, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false);
1069
+ if (!ready) await Bun.sleep(250);
1070
+ }
1071
+ if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
1072
+ const uri = listener.publicUrl && !opts.local ? rebaseConnectionUrl(listener.publicUrl, local) : registeredProfileUrl(local);
1073
+ if (!listener.publicUrl) for (const line of listenerNextSteps(listener, undefined, "rech share --all")) console.error(line);
1074
+ console.log(uri);
1075
+ console.error(`[rech] on the other machine: rech connect '<url>', then rech --profile <name> open https://example.com`);
1076
+ if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
1077
+ }
1078
+
1079
+ async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
1080
+ if (opts.all) {
1081
+ if (selector) throw new Error("Pass a profile or --all, not both.");
1082
+ return shareAll({ listener, local: opts.local, save: opts.save });
1083
+ }
1017
1084
  const url = process.env[ENV_KEY];
1018
1085
  const interactive = isInteractive();
1019
1086
  const registry = await readTokenRegistry();
@@ -1073,8 +1140,11 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1073
1140
  }
1074
1141
  }
1075
1142
  }
1076
- if (listeners.find(l => l.name === listener)?.profiles === "*")
1077
- console.error(`[rech] "${listener}" is the local management listener: its key controls every profile. Don't share this URL.`);
1143
+ const chosen = listeners.find(l => l.name === listener);
1144
+ if (chosen?.profiles === "*")
1145
+ throw new Error(`"${listener}" is the local management listener: its key controls every profile and allows eval and file access, so it is never shared. Share through a scoped listener: rech share <profile>, or rech share --all.`);
1146
+ if (chosen && chosen.profiles.length > 1)
1147
+ console.error(`[rech] note: this link's key works for every profile on listener "${chosen.name}" (${chosen.profiles.join(", ")}); ?profile= only picks the default. For a one-profile link, give that profile its own listener.`);
1078
1148
  let uri = profileConnectionUri(profile, url, listeners, listener);
1079
1149
  // Prefer where a proxy exposes the listener, when it has been recorded.
1080
1150
  const publicUrl = listeners.find(l => l.key === parseUrl(uri).key)?.publicUrl;
@@ -1090,6 +1160,29 @@ export function sandboxConnectionWarning(env: Record<string, string | undefined>
1090
1160
  "If it still fails, check that the daemon is running and the host/port are correct.";
1091
1161
  }
1092
1162
 
1163
+ /**
1164
+ * Is RECHROME_URL this machine's own daemon? True when its key is one of our listeners (or,
1165
+ * before listeners.json existed, when it points at loopback and profiles are registered here). A remote daemon resolves
1166
+ * profiles itself, and must never receive this machine's own extension tokens.
1167
+ */
1168
+ export async function isLocalDaemon(url: string): Promise<boolean> {
1169
+ const { key, host } = parseUrl(url);
1170
+ const config = await readListeners().catch(() => null);
1171
+ // Before listeners.json existed, a local install pointed at loopback and had its own registry;
1172
+ // a fresh client (no registry) talking to a tunnelled loopback port is still remote.
1173
+ if (!config) return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(host) && Object.keys(await readTokenRegistry().catch(() => ({}))).length > 0;
1174
+ return !!key && config.listeners.some(l => l.key === key);
1175
+ }
1176
+
1177
+ /** For a remote daemon: only what the URL itself carries, plus the profile selector (not secret). */
1178
+ export function remoteClientEnv(url: string, profile?: string): Record<string, string> {
1179
+ const { extensionId, extensionToken, userDataDir, loadExtension } = parseUrl(url);
1180
+ return Object.fromEntries(Object.entries({
1181
+ PLAYWRIGHT_MCP_EXTENSION_ID: extensionId, PLAYWRIGHT_MCP_EXTENSION_TOKEN: extensionToken,
1182
+ PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profile, PLAYWRIGHT_MCP_USER_DATA_DIR: userDataDir, PLAYWRIGHT_MCP_LOAD_EXTENSION: loadExtension,
1183
+ }).filter(([, v]) => typeof v === "string" && v)) as Record<string, string>;
1184
+ }
1185
+
1093
1186
  async function callServe(
1094
1187
  url: string,
1095
1188
  args: string[],
@@ -1107,7 +1200,9 @@ async function callServe(
1107
1200
  // reuse the default profile's session (and its browser) instead of opening its own.
1108
1201
  const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
1109
1202
  if (effectiveProfile) identity.profile = effectiveProfile;
1110
- const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension })), ...overrideEnv };
1203
+ const env = await isLocalDaemon(url)
1204
+ ? { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension })), ...overrideEnv }
1205
+ : { ...remoteClientEnv(url, effectiveProfile), ...overrideEnv };
1111
1206
  const res = await fetch(serviceUrl(url, "run"), {
1112
1207
  method: "POST",
1113
1208
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
@@ -2185,7 +2280,7 @@ async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
2185
2280
  }
2186
2281
 
2187
2282
  /** Next steps after exposing a listener: proxy it, record where, share. Plain text, so any shell works. */
2188
- export function listenerNextSteps(listener: Listener, profile?: string): string[] {
2283
+ export function listenerNextSteps(listener: Listener, profile?: string, shareCommand?: string): string[] {
2189
2284
  const prefix = normalizePrefix(listener.prefix);
2190
2285
  const mount = prefix === "/" ? "" : prefix.slice(0, -1);
2191
2286
  return [
@@ -2193,7 +2288,7 @@ export function listenerNextSteps(listener: Listener, profile?: string): string[
2193
2288
  ` tailscale serve --bg${mount ? ` --set-path=${mount}` : ""} ${listener.port}`,
2194
2289
  `Then record where it is reachable and print the URL to share:`,
2195
2290
  ` rech listener set ${listener.name} --public-url https://<your-host>${prefix}`,
2196
- ` rech share ${profile ? JSON.stringify(profile) : "<profile>"}`,
2291
+ ` ${shareCommand ?? `rech share ${profile ? JSON.stringify(profile) : "<profile>"}`}`,
2197
2292
  ];
2198
2293
  }
2199
2294
 
@@ -2265,7 +2360,9 @@ async function urlList(): Promise<void> {
2265
2360
  for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
2266
2361
  }
2267
2362
  printTable(rows);
2268
- console.log(`\nPrint a full URL (contains the secret key): rech share <profile>`);
2363
+ const wide = config.listeners.filter(l => l.profiles !== "*" && l.profiles.length > 1);
2364
+ if (wide.length) console.log(`\nOne key covers several profiles on: ${wide.map(l => `${l.name} (${(l.profiles as string[]).length})`).join(", ")}. Anyone with such a link can use each of them.`);
2365
+ console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
2269
2366
  }
2270
2367
 
2271
2368
  /** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
@@ -2293,6 +2390,8 @@ async function connect(url: string): Promise<void> {
2293
2390
  throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
2294
2391
  const saved = await saveProjectUrl(url);
2295
2392
  console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
2393
+ if (Array.isArray(body.profiles) && body.profiles.length > 1 && !parsed.profileDirectory)
2394
+ console.log(`This link shares ${body.profiles.length} profiles: ${body.profiles.join(", ")}.\nPick one per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
2296
2395
  }
2297
2396
 
2298
2397
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
@@ -2746,7 +2845,7 @@ export type RechHandlers = {
2746
2845
  addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
2747
2846
  removeListener(name: string): Promise<void>;
2748
2847
  listProfiles(): Promise<void>;
2749
- printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean }): Promise<void>;
2848
+ printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean; all?: boolean }): Promise<void>;
2750
2849
  urlList(): Promise<void>;
2751
2850
  connect(url: string): Promise<void>;
2752
2851
  listenerPort(name?: string): Promise<void>;
@@ -2824,10 +2923,11 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
2824
2923
  .positional("profile", { type: "string", describe: "Profile: email, name, folder, or a unique part of one; ls/list lists everything shared" })
2825
2924
  .option("listener", { type: "string", requiresArg: true, describe: "Listener to share through (default: the one that allows the profile)" })
2826
2925
  .option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
2827
- .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
2828
- a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
2926
+ .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" })
2927
+ .option("all", { type: "boolean", describe: "One link for every registered profile (a snapshot, on its own listener); the other machine picks with --profile" }),
2928
+ a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save && !a.all
2829
2929
  ? handlers.urlList()
2830
- : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
2930
+ : handlers.printProfileUri(a.profile, a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save }))
2831
2931
  .command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
2832
2932
  .positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
2833
2933
  .example("rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'", ""),
@@ -2893,7 +2993,11 @@ if (import.meta.main) {
2893
2993
  const handlers: RechHandlers = {
2894
2994
  serve: async () => { const { serve } = await import("./serve.ts"); serve(); }, // long-lived; watcher intentionally kept alive
2895
2995
  status,
2896
- listListeners, addListener, removeListener, listProfiles, printProfileUri,
2996
+ listListeners, addListener, removeListener, printProfileUri,
2997
+ listProfiles: async () => {
2998
+ const url = process.env[ENV_KEY];
2999
+ return url && !(await isLocalDaemon(url)) ? listRemoteProfiles(url) : listProfiles();
3000
+ },
2897
3001
  urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
2898
3002
  setup: async (opts) => {
2899
3003
  await setup(opts); // setup closes envWatcher itself before printing Done
@@ -2954,7 +3058,11 @@ if (import.meta.main) {
2954
3058
  envWatcher?.close();
2955
3059
  process.exit(1);
2956
3060
  }
2957
- if (profileSelector !== undefined) {
3061
+ if (profileSelector !== undefined && !(await isLocalDaemon(url))) {
3062
+ // A remote host resolves the name among the profiles its link shares; this machine's
3063
+ // registry is irrelevant (and usually empty on a client).
3064
+ overrideEnv = { PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileSelector.trim() };
3065
+ } else if (profileSelector !== undefined) {
2958
3066
  try {
2959
3067
  const registry = await readTokenRegistry();
2960
3068
  const cache = await readChromeProfileCache();
package/serve.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, type Listener } from "./listeners.js";
1
+ import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, type Listener } from "./listeners.js";
2
2
  import { file } from "bun";
3
3
  import { createHash, X509Certificate } from "crypto";
4
4
  import { mkdirSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
@@ -15,6 +15,7 @@ import {
15
15
  PASSTHROUGH_ENV_KEYS,
16
16
  resolvePlaywrightCli,
17
17
  readTokenRegistry,
18
+ readChromeProfileCache,
18
19
  } from "./rechrome.js";
19
20
 
20
21
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
@@ -243,6 +244,116 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
243
244
  return nameOrEmail;
244
245
  }
245
246
 
247
+ // Free the listening port from stale daemon holders before retrying a failed bind.
248
+ // On Windows the listening socket (created inheritable by Bun.serve) is swept into the
249
+ // detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
250
+ // previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
251
+ // then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
252
+ // only happens when such a stale holder exists; killing orphaned daemon holders here is
253
+ // safe because a freshly-starting serve owns no live sessions of its own yet (the user's
254
+ // Chrome tabs persist regardless — the cliDaemon only drives them).
255
+ // Command lines freeStalePort may kill: a previous rech serve, or a cliDaemon started from
256
+ // THIS install's playwright (lib/ or vendor/ under this directory) — never another app's
257
+ // Playwright daemon, which runs from its own node_modules.
258
+ const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
259
+ const OWN_CLI_DAEMON = import.meta.dir.split(/[\\/]/).map(escapeRegex).join(String.raw`[\\/]`) + String.raw`[\\/].*cliDaemon\.js`;
260
+ const RECH_SERVE = String.raw`rech(rome)?(\.js)?"?\s+serve`;
261
+ const STALE_HOLDER_PATTERN = `${OWN_CLI_DAEMON}|${RECH_SERVE}`;
262
+ // A leaked socket (or a wedged serve) accepts connections but never answers. Anything that
263
+ // responds at an address is a live server there — possibly a healthy rech serve — so it is
264
+ // never killed. Both schemes: the holder's TLS setting may differ from this serve's.
265
+ async function answersAt(address: string, port: number): Promise<boolean> {
266
+ const host = address === "0.0.0.0" || address === "*" ? "127.0.0.1"
267
+ : address === "::" ? "[::1]"
268
+ : address.includes(":") ? `[${address}]` : address;
269
+ const probe = (scheme: string) => fetch(`${scheme}://${host}:${port}/`, {
270
+ signal: AbortSignal.timeout(1500), tls: { rejectUnauthorized: false },
271
+ } as RequestInit).then(() => true, () => false);
272
+ return (await Promise.all([probe("http"), probe("https")])).includes(true);
273
+ }
274
+
275
+ type PortHolder = { address: string; pid: number; alive: boolean; command: string };
276
+
277
+ // Listeners on `port` that conflict with binding `host`: the same address, or a wildcard of
278
+ // its family (a wildcard bind conflicts with every address of the family). Never this serve's
279
+ // own sockets, which may already hold other addresses on the same port from this startup.
280
+ function conflictingHolders(port: number, host: string): PortHolder[] {
281
+ let holders: PortHolder[] = [];
282
+ if (process.platform === "win32") {
283
+ const ps = [
284
+ "$ErrorActionPreference='SilentlyContinue';",
285
+ `$r=@(Get-NetTCPConnection -LocalPort ${port} -State Listen | ForEach-Object {`,
286
+ " $p=Get-CimInstance Win32_Process -Filter \"ProcessId=$($_.OwningProcess)\";",
287
+ " [pscustomobject]@{ address=[string]$_.LocalAddress; pid=[int]$_.OwningProcess; alive=[bool]$p; command=[string]$p.CommandLine } });",
288
+ "ConvertTo-Json -Compress -InputObject $r",
289
+ ].join(" ");
290
+ const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
291
+ try { holders = out ? [JSON.parse(out)].flat() : []; } catch { holders = []; }
292
+ } else {
293
+ const out = Bun.spawnSync(["sh", "-c", `lsof -nP -iTCP:${port} -sTCP:LISTEN -Fpn 2>/dev/null`]).stdout?.toString() ?? "";
294
+ let pid = 0;
295
+ for (const line of out.split("\n")) {
296
+ if (line.startsWith("p")) pid = Number(line.slice(1));
297
+ else if (line.startsWith("n") && pid) {
298
+ const address = line.slice(1).replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
299
+ const command = Bun.spawnSync(["ps", "-o", "command=", "-p", String(pid)]).stdout?.toString().trim() ?? "";
300
+ holders.push({ address, pid, alive: true, command });
301
+ }
302
+ }
303
+ }
304
+ const v6 = (a: string) => a.includes(":");
305
+ const wildcard = (a: string) => a === "0.0.0.0" || a === "::" || a === "*";
306
+ // A `::` listener is dual-stack on Windows and most Linux setups: it conflicts with IPv4 binds too.
307
+ return holders.filter(h => h.pid !== process.pid && (
308
+ wildcard(host) ? (h.address === "*" || h.address === "::" || host === "::" || !v6(h.address))
309
+ : h.address === host || h.address === "*" || h.address === "::" || (h.address === "0.0.0.0" && !v6(host))));
310
+ }
311
+
312
+ // Free the conflicting address from stale rech holders before retrying a failed bind.
313
+ // Narrow-first: (1) kill each conflicting owner that is a live rech serve / this install's
314
+ // cliDaemon AND doesn't answer at its own address — never an unrelated app, never a healthy
315
+ // server; (2) only if the address is STILL held by owners that are all dead — the
316
+ // inherited-handle case, where the socket lives in a child while netstat attributes it to a
317
+ // now-dead owner — sweep orphaned cliDaemons (Windows). That sweep is the only recovery for
318
+ // the case (the live holder can't be mapped from the port), so it is a logged last resort.
319
+ async function freeStalePort(port: number, host: string): Promise<void> {
320
+ try {
321
+ const stale = new RegExp(STALE_HOLDER_PATTERN, process.platform === "win32" ? "i" : "");
322
+ for (const h of conflictingHolders(port, host)) {
323
+ if (!h.alive) continue;
324
+ if (!stale.test(h.command)) { log(`freeStalePort: port ${port} is held by an unrelated process (${h.pid}); not killing it`); continue; }
325
+ if (await answersAt(h.address, port)) { log(`freeStalePort: ${h.address}:${port} is served by a live process (${h.pid}); not killing it`); continue; }
326
+ log(`freeStalePort: killing stale rech holder ${h.pid} of ${h.address}:${port}`);
327
+ try { process.kill(h.pid, "SIGKILL"); } catch {}
328
+ }
329
+ await new Promise(r => setTimeout(r, 400));
330
+ const remaining = conflictingHolders(port, host);
331
+ if (process.platform === "win32" && remaining.length && remaining.every(h => !h.alive)) {
332
+ const q = (text: string) => text.replaceAll("'", "''");
333
+ const ps = [
334
+ "$ErrorActionPreference='SilentlyContinue';",
335
+ // A cliDaemon's parent (playwright-cli) exits right after spawning it, so a live one
336
+ // can't be told from an orphan by ancestry. They are only provably orphaned when no
337
+ // other rech serve is running: exclude this serve and its own wrappers (oxmgr, cmd).
338
+ `$self=@(); $x=${process.pid}; while($x){ $self+=$x; $x=(Get-CimInstance Win32_Process -Filter \"ProcessId=$x\").ParentProcessId; if($self -contains $x){ break } };`,
339
+ `$live=@(Get-CimInstance Win32_Process | Where-Object { $self -notcontains $_.ProcessId -and $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(RECH_SERVE)}' });`,
340
+ "if($live.Count){ Write-Output (\"freeStalePort: port still held, but another rech serve is running (\" + ($live.ProcessId -join ',') + \"); not sweeping cliDaemons\") }",
341
+ "else {",
342
+ // Exclude this PowerShell itself: its own command line contains the pattern.
343
+ ` $d=Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(OWN_CLI_DAEMON)}' };`,
344
+ " Write-Output (\"freeStalePort: port still held; killing orphaned cliDaemon holders: \" + ($d.ProcessId -join ','));",
345
+ " $d | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
346
+ "}",
347
+ ].join(" ");
348
+ const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
349
+ if (out) log(out);
350
+ }
351
+ } catch {
352
+ // best effort — the retry will surface a clear error if the port is still held
353
+ }
354
+ await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
355
+ }
356
+
246
357
  // --- Foreground/orphan self-exit ---------------------------------------------------
247
358
  // A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
248
359
  // process-manager safety net: when the agent that spawned it exits, the OS re-parents
@@ -394,9 +505,13 @@ export async function serve() {
394
505
  const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
395
506
  const policies = new Map<string, Listener>();
396
507
  const servers = new Map<string, ReturnType<typeof Bun.serve>>();
397
- const startServer = (initial: Listener) => Bun.serve({
508
+ const startServer = (initial: Listener, reusePort = false) => Bun.serve({
398
509
  hostname: initial.host,
399
510
  port: initial.port,
511
+ // reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
512
+ // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
513
+ // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
514
+ reusePort,
400
515
  tls,
401
516
  error(err) {
402
517
  log(`unhandled error: ${err.message}`);
@@ -477,8 +592,19 @@ export async function serve() {
477
592
  let profileEnv: Record<string, string> = {};
478
593
  if (scoped) {
479
594
  try {
480
- scopedProfile = authorizeProfileRequest(listener, body);
481
595
  const registry = await readTokenRegistry();
596
+ // The client sends whatever the user typed (`rech --profile work`); resolve it here,
597
+ // among this listener's profiles only, to one canonical key BEFORE authorization and
598
+ // session hashing, so aliases of one profile share a session and a remote client needs
599
+ // no registry of its own.
600
+ if (body && !Array.isArray(body) && typeof body.identity === "object" && body.identity) {
601
+ const chromeNames = Object.fromEntries(Object.entries(await readChromeProfileCache().catch(() => null) ?? {}).map(([dir, info]) => [dir, info.name ?? ""]).filter(([, name]) => name));
602
+ const allowed = listener.profiles as string[];
603
+ body.identity.profile = resolveAllowedProfile(body.identity.profile ?? body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY, allowed, registry, chromeNames);
604
+ if (typeof body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY === "string")
605
+ body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolveAllowedProfile(body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY, allowed, registry, chromeNames);
606
+ }
607
+ scopedProfile = authorizeProfileRequest(listener, body);
482
608
  const entry = registry[scopedProfile];
483
609
  if (!entry?.token || !entry?.extensionId || !entry?.profileDir) throw new Error("Profile is not registered on this server");
484
610
  profileEnv = {
@@ -490,7 +616,7 @@ export async function serve() {
490
616
  };
491
617
  body.identity.key = `scoped:${body.identity.key}`;
492
618
  body.env = {}; // Server-owned profile configuration wins over every client override.
493
- } catch (error) { return Response.json({ status: 1, stdout: "", stderr: String(error) }, { status: 403 }); }
619
+ } catch (error) { return Response.json({ status: 1, stdout: "", stderr: `${error instanceof Error ? error.message : String(error)}\n` }, { status: 403 }); }
494
620
  }
495
621
  const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) : "";
496
622
  const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;
@@ -773,14 +899,47 @@ export async function serve() {
773
899
  },
774
900
  });
775
901
 
902
+ // A leaked listening-socket handle in an orphaned cliDaemon can keep a port in LISTEN after a
903
+ // prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
904
+ // detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
905
+ // netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
906
+ // map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
907
+ // Startup only: a freshly-starting serve owns no live sessions, so clearing stale holders is
908
+ // safe. Hot reloads never kill anything (see reconcile). As an absolute last resort, bind with
909
+ // reusePort so a holder we genuinely can't kill degrades to "up but sharing the port" rather
910
+ // than a permanent EADDRINUSE crash-loop.
911
+ const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
912
+ const MAX_BIND_ATTEMPTS = 4;
913
+ const bindAtStartup = async (listener: Listener) => {
914
+ for (let attempt = 1; ; attempt++) {
915
+ try {
916
+ return startServer(listener);
917
+ } catch (e: any) {
918
+ if (!isEaddrInUse(e)) throw e;
919
+ // Something answers at this exact address: a live server (never killed, never port-shared).
920
+ if (await answersAt(listener.host, listener.port)) {
921
+ log(`port ${listener.port} is held by a live server that answers requests — not touching it`);
922
+ throw e;
923
+ }
924
+ if (attempt === MAX_BIND_ATTEMPTS) {
925
+ log(`port ${listener.port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
926
+ return startServer(listener, true);
927
+ }
928
+ log(`port ${listener.port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
929
+ await freeStalePort(listener.port, listener.host);
930
+ }
931
+ }
932
+ };
933
+
776
934
  // Stage all new sockets before changing policy. Bind failure leaves existing
777
- // listeners and browser sessions intact; never kill a process holding another port.
778
- const reconcile = async (listeners: Listener[]) => {
935
+ // listeners and browser sessions intact; never kill a process holding another port
936
+ // once serving (stale-holder recovery runs only at startup).
937
+ const reconcile = async (listeners: Listener[], startup = false) => {
779
938
  const staged = new Map<string, ReturnType<typeof Bun.serve>>();
780
939
  try {
781
940
  for (const listener of listeners) {
782
941
  const address = listenerAddress(listener);
783
- if (!servers.has(address)) staged.set(address, startServer(listener));
942
+ if (!servers.has(address)) staged.set(address, startup ? await bindAtStartup(listener) : startServer(listener));
784
943
  }
785
944
  } catch (error) {
786
945
  for (const server of staged.values()) server.stop(true);
@@ -795,17 +954,17 @@ export async function serve() {
795
954
  };
796
955
  let applied = "";
797
956
  let configured = false;
798
- const reload = async () => {
957
+ const reload = async (startup = false) => {
799
958
  const config = await readListeners();
800
959
  if (!config && configured) throw new Error("listeners.json disappeared; refusing to restore unrestricted legacy access");
801
960
  const listeners = config?.listeners ?? [legacy];
802
961
  const fingerprint = JSON.stringify(listeners);
803
962
  if (applied === fingerprint) return;
804
- await reconcile(listeners);
963
+ await reconcile(listeners, startup);
805
964
  configured ||= !!config;
806
965
  applied = fingerprint;
807
966
  };
808
- await reload();
967
+ await reload(true);
809
968
  let reloading = false;
810
969
  let lastError = "";
811
970
  setInterval(async () => {
package/serve.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, type Listener } from "./listeners.ts";
1
+ import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, type Listener } from "./listeners.ts";
2
2
  import { file } from "bun";
3
3
  import { createHash, X509Certificate } from "crypto";
4
4
  import { mkdirSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
@@ -15,6 +15,7 @@ import {
15
15
  PASSTHROUGH_ENV_KEYS,
16
16
  resolvePlaywrightCli,
17
17
  readTokenRegistry,
18
+ readChromeProfileCache,
18
19
  } from "./rechrome.ts";
19
20
 
20
21
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
@@ -243,6 +244,116 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
243
244
  return nameOrEmail;
244
245
  }
245
246
 
247
+ // Free the listening port from stale daemon holders before retrying a failed bind.
248
+ // On Windows the listening socket (created inheritable by Bun.serve) is swept into the
249
+ // detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
250
+ // previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
251
+ // then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
252
+ // only happens when such a stale holder exists; killing orphaned daemon holders here is
253
+ // safe because a freshly-starting serve owns no live sessions of its own yet (the user's
254
+ // Chrome tabs persist regardless — the cliDaemon only drives them).
255
+ // Command lines freeStalePort may kill: a previous rech serve, or a cliDaemon started from
256
+ // THIS install's playwright (lib/ or vendor/ under this directory) — never another app's
257
+ // Playwright daemon, which runs from its own node_modules.
258
+ const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
259
+ const OWN_CLI_DAEMON = import.meta.dir.split(/[\\/]/).map(escapeRegex).join(String.raw`[\\/]`) + String.raw`[\\/].*cliDaemon\.js`;
260
+ const RECH_SERVE = String.raw`rech(rome)?(\.ts)?"?\s+serve`;
261
+ const STALE_HOLDER_PATTERN = `${OWN_CLI_DAEMON}|${RECH_SERVE}`;
262
+ // A leaked socket (or a wedged serve) accepts connections but never answers. Anything that
263
+ // responds at an address is a live server there — possibly a healthy rech serve — so it is
264
+ // never killed. Both schemes: the holder's TLS setting may differ from this serve's.
265
+ async function answersAt(address: string, port: number): Promise<boolean> {
266
+ const host = address === "0.0.0.0" || address === "*" ? "127.0.0.1"
267
+ : address === "::" ? "[::1]"
268
+ : address.includes(":") ? `[${address}]` : address;
269
+ const probe = (scheme: string) => fetch(`${scheme}://${host}:${port}/`, {
270
+ signal: AbortSignal.timeout(1500), tls: { rejectUnauthorized: false },
271
+ } as RequestInit).then(() => true, () => false);
272
+ return (await Promise.all([probe("http"), probe("https")])).includes(true);
273
+ }
274
+
275
+ type PortHolder = { address: string; pid: number; alive: boolean; command: string };
276
+
277
+ // Listeners on `port` that conflict with binding `host`: the same address, or a wildcard of
278
+ // its family (a wildcard bind conflicts with every address of the family). Never this serve's
279
+ // own sockets, which may already hold other addresses on the same port from this startup.
280
+ function conflictingHolders(port: number, host: string): PortHolder[] {
281
+ let holders: PortHolder[] = [];
282
+ if (process.platform === "win32") {
283
+ const ps = [
284
+ "$ErrorActionPreference='SilentlyContinue';",
285
+ `$r=@(Get-NetTCPConnection -LocalPort ${port} -State Listen | ForEach-Object {`,
286
+ " $p=Get-CimInstance Win32_Process -Filter \"ProcessId=$($_.OwningProcess)\";",
287
+ " [pscustomobject]@{ address=[string]$_.LocalAddress; pid=[int]$_.OwningProcess; alive=[bool]$p; command=[string]$p.CommandLine } });",
288
+ "ConvertTo-Json -Compress -InputObject $r",
289
+ ].join(" ");
290
+ const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
291
+ try { holders = out ? [JSON.parse(out)].flat() : []; } catch { holders = []; }
292
+ } else {
293
+ const out = Bun.spawnSync(["sh", "-c", `lsof -nP -iTCP:${port} -sTCP:LISTEN -Fpn 2>/dev/null`]).stdout?.toString() ?? "";
294
+ let pid = 0;
295
+ for (const line of out.split("\n")) {
296
+ if (line.startsWith("p")) pid = Number(line.slice(1));
297
+ else if (line.startsWith("n") && pid) {
298
+ const address = line.slice(1).replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
299
+ const command = Bun.spawnSync(["ps", "-o", "command=", "-p", String(pid)]).stdout?.toString().trim() ?? "";
300
+ holders.push({ address, pid, alive: true, command });
301
+ }
302
+ }
303
+ }
304
+ const v6 = (a: string) => a.includes(":");
305
+ const wildcard = (a: string) => a === "0.0.0.0" || a === "::" || a === "*";
306
+ // A `::` listener is dual-stack on Windows and most Linux setups: it conflicts with IPv4 binds too.
307
+ return holders.filter(h => h.pid !== process.pid && (
308
+ wildcard(host) ? (h.address === "*" || h.address === "::" || host === "::" || !v6(h.address))
309
+ : h.address === host || h.address === "*" || h.address === "::" || (h.address === "0.0.0.0" && !v6(host))));
310
+ }
311
+
312
+ // Free the conflicting address from stale rech holders before retrying a failed bind.
313
+ // Narrow-first: (1) kill each conflicting owner that is a live rech serve / this install's
314
+ // cliDaemon AND doesn't answer at its own address — never an unrelated app, never a healthy
315
+ // server; (2) only if the address is STILL held by owners that are all dead — the
316
+ // inherited-handle case, where the socket lives in a child while netstat attributes it to a
317
+ // now-dead owner — sweep orphaned cliDaemons (Windows). That sweep is the only recovery for
318
+ // the case (the live holder can't be mapped from the port), so it is a logged last resort.
319
+ async function freeStalePort(port: number, host: string): Promise<void> {
320
+ try {
321
+ const stale = new RegExp(STALE_HOLDER_PATTERN, process.platform === "win32" ? "i" : "");
322
+ for (const h of conflictingHolders(port, host)) {
323
+ if (!h.alive) continue;
324
+ if (!stale.test(h.command)) { log(`freeStalePort: port ${port} is held by an unrelated process (${h.pid}); not killing it`); continue; }
325
+ if (await answersAt(h.address, port)) { log(`freeStalePort: ${h.address}:${port} is served by a live process (${h.pid}); not killing it`); continue; }
326
+ log(`freeStalePort: killing stale rech holder ${h.pid} of ${h.address}:${port}`);
327
+ try { process.kill(h.pid, "SIGKILL"); } catch {}
328
+ }
329
+ await new Promise(r => setTimeout(r, 400));
330
+ const remaining = conflictingHolders(port, host);
331
+ if (process.platform === "win32" && remaining.length && remaining.every(h => !h.alive)) {
332
+ const q = (text: string) => text.replaceAll("'", "''");
333
+ const ps = [
334
+ "$ErrorActionPreference='SilentlyContinue';",
335
+ // A cliDaemon's parent (playwright-cli) exits right after spawning it, so a live one
336
+ // can't be told from an orphan by ancestry. They are only provably orphaned when no
337
+ // other rech serve is running: exclude this serve and its own wrappers (oxmgr, cmd).
338
+ `$self=@(); $x=${process.pid}; while($x){ $self+=$x; $x=(Get-CimInstance Win32_Process -Filter \"ProcessId=$x\").ParentProcessId; if($self -contains $x){ break } };`,
339
+ `$live=@(Get-CimInstance Win32_Process | Where-Object { $self -notcontains $_.ProcessId -and $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(RECH_SERVE)}' });`,
340
+ "if($live.Count){ Write-Output (\"freeStalePort: port still held, but another rech serve is running (\" + ($live.ProcessId -join ',') + \"); not sweeping cliDaemons\") }",
341
+ "else {",
342
+ // Exclude this PowerShell itself: its own command line contains the pattern.
343
+ ` $d=Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(OWN_CLI_DAEMON)}' };`,
344
+ " Write-Output (\"freeStalePort: port still held; killing orphaned cliDaemon holders: \" + ($d.ProcessId -join ','));",
345
+ " $d | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
346
+ "}",
347
+ ].join(" ");
348
+ const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
349
+ if (out) log(out);
350
+ }
351
+ } catch {
352
+ // best effort — the retry will surface a clear error if the port is still held
353
+ }
354
+ await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
355
+ }
356
+
246
357
  // --- Foreground/orphan self-exit ---------------------------------------------------
247
358
  // A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
248
359
  // process-manager safety net: when the agent that spawned it exits, the OS re-parents
@@ -394,9 +505,13 @@ export async function serve() {
394
505
  const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
395
506
  const policies = new Map<string, Listener>();
396
507
  const servers = new Map<string, ReturnType<typeof Bun.serve>>();
397
- const startServer = (initial: Listener) => Bun.serve({
508
+ const startServer = (initial: Listener, reusePort = false) => Bun.serve({
398
509
  hostname: initial.host,
399
510
  port: initial.port,
511
+ // reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
512
+ // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
513
+ // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
514
+ reusePort,
400
515
  tls,
401
516
  error(err) {
402
517
  log(`unhandled error: ${err.message}`);
@@ -477,8 +592,19 @@ export async function serve() {
477
592
  let profileEnv: Record<string, string> = {};
478
593
  if (scoped) {
479
594
  try {
480
- scopedProfile = authorizeProfileRequest(listener, body);
481
595
  const registry = await readTokenRegistry();
596
+ // The client sends whatever the user typed (`rech --profile work`); resolve it here,
597
+ // among this listener's profiles only, to one canonical key BEFORE authorization and
598
+ // session hashing, so aliases of one profile share a session and a remote client needs
599
+ // no registry of its own.
600
+ if (body && !Array.isArray(body) && typeof body.identity === "object" && body.identity) {
601
+ const chromeNames = Object.fromEntries(Object.entries(await readChromeProfileCache().catch(() => null) ?? {}).map(([dir, info]) => [dir, info.name ?? ""]).filter(([, name]) => name));
602
+ const allowed = listener.profiles as string[];
603
+ body.identity.profile = resolveAllowedProfile(body.identity.profile ?? body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY, allowed, registry, chromeNames);
604
+ if (typeof body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY === "string")
605
+ body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY = resolveAllowedProfile(body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY, allowed, registry, chromeNames);
606
+ }
607
+ scopedProfile = authorizeProfileRequest(listener, body);
482
608
  const entry = registry[scopedProfile];
483
609
  if (!entry?.token || !entry?.extensionId || !entry?.profileDir) throw new Error("Profile is not registered on this server");
484
610
  profileEnv = {
@@ -490,7 +616,7 @@ export async function serve() {
490
616
  };
491
617
  body.identity.key = `scoped:${body.identity.key}`;
492
618
  body.env = {}; // Server-owned profile configuration wins over every client override.
493
- } catch (error) { return Response.json({ status: 1, stdout: "", stderr: String(error) }, { status: 403 }); }
619
+ } catch (error) { return Response.json({ status: 1, stdout: "", stderr: `${error instanceof Error ? error.message : String(error)}\n` }, { status: 403 }); }
494
620
  }
495
621
  const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) : "";
496
622
  const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;
@@ -773,14 +899,47 @@ export async function serve() {
773
899
  },
774
900
  });
775
901
 
902
+ // A leaked listening-socket handle in an orphaned cliDaemon can keep a port in LISTEN after a
903
+ // prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
904
+ // detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
905
+ // netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
906
+ // map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
907
+ // Startup only: a freshly-starting serve owns no live sessions, so clearing stale holders is
908
+ // safe. Hot reloads never kill anything (see reconcile). As an absolute last resort, bind with
909
+ // reusePort so a holder we genuinely can't kill degrades to "up but sharing the port" rather
910
+ // than a permanent EADDRINUSE crash-loop.
911
+ const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
912
+ const MAX_BIND_ATTEMPTS = 4;
913
+ const bindAtStartup = async (listener: Listener) => {
914
+ for (let attempt = 1; ; attempt++) {
915
+ try {
916
+ return startServer(listener);
917
+ } catch (e: any) {
918
+ if (!isEaddrInUse(e)) throw e;
919
+ // Something answers at this exact address: a live server (never killed, never port-shared).
920
+ if (await answersAt(listener.host, listener.port)) {
921
+ log(`port ${listener.port} is held by a live server that answers requests — not touching it`);
922
+ throw e;
923
+ }
924
+ if (attempt === MAX_BIND_ATTEMPTS) {
925
+ log(`port ${listener.port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
926
+ return startServer(listener, true);
927
+ }
928
+ log(`port ${listener.port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
929
+ await freeStalePort(listener.port, listener.host);
930
+ }
931
+ }
932
+ };
933
+
776
934
  // Stage all new sockets before changing policy. Bind failure leaves existing
777
- // listeners and browser sessions intact; never kill a process holding another port.
778
- const reconcile = async (listeners: Listener[]) => {
935
+ // listeners and browser sessions intact; never kill a process holding another port
936
+ // once serving (stale-holder recovery runs only at startup).
937
+ const reconcile = async (listeners: Listener[], startup = false) => {
779
938
  const staged = new Map<string, ReturnType<typeof Bun.serve>>();
780
939
  try {
781
940
  for (const listener of listeners) {
782
941
  const address = listenerAddress(listener);
783
- if (!servers.has(address)) staged.set(address, startServer(listener));
942
+ if (!servers.has(address)) staged.set(address, startup ? await bindAtStartup(listener) : startServer(listener));
784
943
  }
785
944
  } catch (error) {
786
945
  for (const server of staged.values()) server.stop(true);
@@ -795,17 +954,17 @@ export async function serve() {
795
954
  };
796
955
  let applied = "";
797
956
  let configured = false;
798
- const reload = async () => {
957
+ const reload = async (startup = false) => {
799
958
  const config = await readListeners();
800
959
  if (!config && configured) throw new Error("listeners.json disappeared; refusing to restore unrestricted legacy access");
801
960
  const listeners = config?.listeners ?? [legacy];
802
961
  const fingerprint = JSON.stringify(listeners);
803
962
  if (applied === fingerprint) return;
804
- await reconcile(listeners);
963
+ await reconcile(listeners, startup);
805
964
  configured ||= !!config;
806
965
  applied = fingerprint;
807
966
  };
808
- await reload();
967
+ await reload(true);
809
968
  let reloading = false;
810
969
  let lastError = "";
811
970
  setInterval(async () => {