rechrome 1.29.1 → 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.1",
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";
@@ -591,8 +592,19 @@ export async function serve() {
591
592
  let profileEnv: Record<string, string> = {};
592
593
  if (scoped) {
593
594
  try {
594
- scopedProfile = authorizeProfileRequest(listener, body);
595
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);
596
608
  const entry = registry[scopedProfile];
597
609
  if (!entry?.token || !entry?.extensionId || !entry?.profileDir) throw new Error("Profile is not registered on this server");
598
610
  profileEnv = {
@@ -604,7 +616,7 @@ export async function serve() {
604
616
  };
605
617
  body.identity.key = `scoped:${body.identity.key}`;
606
618
  body.env = {}; // Server-owned profile configuration wins over every client override.
607
- } 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 }); }
608
620
  }
609
621
  const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) : "";
610
622
  const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;
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";
@@ -591,8 +592,19 @@ export async function serve() {
591
592
  let profileEnv: Record<string, string> = {};
592
593
  if (scoped) {
593
594
  try {
594
- scopedProfile = authorizeProfileRequest(listener, body);
595
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);
596
608
  const entry = registry[scopedProfile];
597
609
  if (!entry?.token || !entry?.extensionId || !entry?.profileDir) throw new Error("Profile is not registered on this server");
598
610
  profileEnv = {
@@ -604,7 +616,7 @@ export async function serve() {
604
616
  };
605
617
  body.identity.key = `scoped:${body.identity.key}`;
606
618
  body.env = {}; // Server-owned profile configuration wins over every client override.
607
- } 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 }); }
608
620
  }
609
621
  const outputPrefix = scopedProfile ? profileOutputPrefix(scopedProfile) : "";
610
622
  const runWorkDir = scopedProfile ? join(workDir, outputPrefix) : workDir;