rechrome 1.29.1 → 1.31.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,23 @@ 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 several profiles in one link.** `rech share work@example.com taku2 symval-dev` puts
110
+ exactly those profiles on one listener (its own key; running it again reuses it). Add
111
+ `--listener <name>` to use a listener that already has a proxy route. Its links then reach
112
+ exactly this list, and `rech listener rotate-key <name>` issues a fresh key if old links shouldn't.
113
+
114
+ **Share every profile at once.** `rech share --all` gives one link for all Chrome profiles
115
+ registered on the host. It uses its own listener (`share-all`) and key, so one-profile links you
116
+ already handed out never gain access to the others. It is a snapshot: after registering another
117
+ profile, run `rech share --all` again. On the other machine, pick a profile per command, and the
118
+ host resolves the name (exact, the part of an email before `@`, or a unique 3+ letter prefix):
119
+
120
+ ```bash
121
+ rech connect '<url from rech share --all>'
122
+ rech profile # the profiles that link shares
123
+ rech --profile work@example.com open https://example.com
124
+ ```
125
+
109
126
  On a trusted LAN without a proxy, `rech setup --listen lan --profile you@example.com` binds the
110
127
  profile to your LAN address directly (plain HTTP); share the result of `rech share`.
111
128
 
@@ -118,9 +135,19 @@ rech listener allow share teammate@example.com
118
135
  rech listener deny share teammate@example.com
119
136
  rech listener rotate-key share # revoke: every URL for this listener stops working
120
137
  rech listener remove share
138
+ rech profile rm old-test-profile # unregister a profile everywhere (asks first)
121
139
  ```
122
140
 
141
+ `rech profile rm <name>` shows its plan and asks before changing anything (`--yes` skips the
142
+ question). It removes every alias of the profile from rech's registry and from every listener,
143
+ dropping a listener left with no profiles. A managed test profile's own folder goes to the Trash,
144
+ and its running window is closed only with your consent (`--close` when not in a terminal). A real
145
+ Chrome profile's data is never touched: it is only unregistered from rech.
146
+
123
147
  Changes apply immediately; the daemon reloads its listeners without restarting Chrome.
148
+ A link's key opens **every** profile its listener allows (`?profile=` only picks the default),
149
+ so give a profile its own listener when a link should reach only that one. `rech share ls`
150
+ points out keys that cover several profiles. The local management listener is never shared.
124
151
 
125
152
  ## Setup reference
126
153
 
package/listeners.js CHANGED
@@ -141,18 +141,68 @@ 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
+ }
181
+ function planProfileRemoval(key, registry, listeners, managedRoot) {
182
+ const entry = registry[key];
183
+ if (!entry)
184
+ throw new Error(`"${key}" is not a registered profile.`);
185
+ const keys = Object.keys(registry).filter((k) => profileIdentity(registry[k]) === profileIdentity(entry));
186
+ const edits = listeners.filter((l) => l.profiles !== "*" && l.profiles.some((p) => keys.includes(p))).map((l) => ({ name: l.name, remove: l.profiles.filter((p) => keys.includes(p)), drop: l.profiles.every((p) => keys.includes(p)) }));
187
+ const root = managedRoot.replace(/[\\/]+$/, "");
188
+ const managed = !!entry.loadExtension && !!entry.userDataDir && (entry.userDataDir.startsWith(root + "/") || entry.userDataDir.startsWith(root + "\\"));
189
+ return { keys, listeners: edits, ...managed ? { dataDir: entry.userDataDir } : {} };
190
+ }
144
191
  export {
145
192
  LISTENERS_FILE,
146
193
  allowProfiles,
147
194
  authorizeProfileRequest,
148
195
  canReadProfileFile,
196
+ canonicalProfileKeys,
149
197
  denyProfiles,
150
198
  isLoopback,
151
199
  listenerAddress,
152
200
  normalizePrefix,
153
201
  normalizePublicUrl,
202
+ planProfileRemoval,
154
203
  profileOutputPrefix,
155
204
  readListeners,
205
+ resolveAllowedProfile,
156
206
  rotateListenerKey,
157
207
  serviceUrl,
158
208
  setPublicUrl,
package/listeners.ts CHANGED
@@ -135,3 +135,79 @@ 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
+ }
190
+
191
+ export type ProfileRemovalPlan = {
192
+ keys: string[]; // every registry alias of the profile
193
+ listeners: { name: string; remove: string[]; drop: boolean }[]; // allowlist edits; drop = no profiles left
194
+ dataDir?: string; // a managed profile's own folder (never real Chrome data)
195
+ };
196
+
197
+ /**
198
+ * What `rech profile rm` would change for one registry key: all aliases of that Chrome profile,
199
+ * their listener entries (dropping a listener left empty), and, for a managed test profile
200
+ * whose data lives under `managedRoot`, its folder. Real Chrome profiles only get unregistered.
201
+ */
202
+ export function planProfileRemoval(
203
+ key: string, registry: Record<string, RegisteredProfile & { loadExtension?: string }>, listeners: Listener[], managedRoot: string,
204
+ ): ProfileRemovalPlan {
205
+ const entry = registry[key];
206
+ if (!entry) throw new Error(`"${key}" is not a registered profile.`);
207
+ const keys = Object.keys(registry).filter(k => profileIdentity(registry[k]) === profileIdentity(entry));
208
+ const edits = listeners.filter(l => l.profiles !== "*" && l.profiles.some(p => keys.includes(p)))
209
+ .map(l => ({ name: l.name, remove: (l.profiles as string[]).filter(p => keys.includes(p)), drop: (l.profiles as string[]).every(p => keys.includes(p)) }));
210
+ const root = managedRoot.replace(/[\\/]+$/, "");
211
+ const managed = !!entry.loadExtension && !!entry.userDataDir && (entry.userDataDir.startsWith(root + "/") || entry.userDataDir.startsWith(root + "\\"));
212
+ return { keys, listeners: edits, ...(managed ? { dataDir: entry.userDataDir } : {}) };
213
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.29.1",
3
+ "version": "1.31.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -1,10 +1,10 @@
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, planProfileRemoval, type Listener } from "./listeners.js";
3
3
 
4
4
  import { file } from "bun";
5
5
  import yargs from "yargs";
6
6
  import { readExtensionTokenFromProfile } from "./extension-token.js";
7
- import { randomBytes } from "crypto";
7
+ import { createHash, randomBytes } from "crypto";
8
8
  import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, rmdirSync, constants as fsConstants } from "fs";
9
9
  import { hostname, homedir, networkInterfaces } from "os";
10
10
  import { isIPv4 } from "net";
@@ -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,91 @@ export function buildProfileRows(cache: Record<string, ChromeProfileInfo> | null
893
893
  return rows;
894
894
  }
895
895
 
896
+ /** PIDs of browser processes (not helpers) running on a Chrome user-data dir. POSIX only. */
897
+ function browsersUsing(userDataDir: string): number[] {
898
+ if (process.platform === "win32") return [];
899
+ const ps = Bun.spawnSync(["ps", "ax", "-o", "pid=,command="]).stdout.toString();
900
+ return ps.split("\n").filter(line => line.includes(`--user-data-dir=${userDataDir}`) && !line.includes("--type="))
901
+ .map(line => Number(line.trim().split(/\s+/)[0])).filter(Boolean);
902
+ }
903
+
904
+ /** Move a folder to the user's Trash (recoverable). Returns where it went, or null if unsupported here. */
905
+ function moveToTrash(dir: string): string | null {
906
+ const trash = process.platform === "darwin" ? join(HOME, ".Trash") : process.platform === "linux" ? join(HOME, ".local", "share", "Trash", "files") : null;
907
+ if (!trash) return null;
908
+ mkdirSync(trash, { recursive: true });
909
+ let dest = join(trash, basename(dir));
910
+ if (existsSync(dest)) dest = `${dest} ${new Date().toISOString().replace(/[:.]/g, "-")}`;
911
+ renameSync(dir, dest);
912
+ return dest;
913
+ }
914
+
915
+ /**
916
+ * `rech profile rm <name>`: unregister a profile everywhere (all its aliases, every listener
917
+ * allowlist, dropping listeners left empty) and move a managed test profile's own folder to the
918
+ * Trash. Real Chrome profile data is never touched. Shows the plan and asks first (--yes skips);
919
+ * a browser still running on a managed folder is closed only with consent (--close).
920
+ */
921
+ async function removeProfile(selector: string, opts: { yes?: boolean; close?: boolean } = {}): Promise<void> {
922
+ const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
923
+ const key = (await resolveGlobalProfile(registry, cache, selector)).email;
924
+ const config = await readListeners();
925
+ const plan = planProfileRemoval(key, registry, config?.listeners ?? [], join(RECH_DIR, "profiles"));
926
+ const running = plan.dataDir ? browsersUsing(plan.dataDir) : [];
927
+ console.log(`Remove profile "${key}"${plan.keys.length > 1 ? ` (registered as ${plan.keys.join(", ")})` : ""}:`);
928
+ for (const edit of plan.listeners) console.log(edit.drop ? ` - remove listener "${edit.name}" (it serves only this profile)` : ` - stop sharing it on listener "${edit.name}"`);
929
+ console.log(` - unregister it from rech (~/.rechrome/profiles.yaml)`);
930
+ if (plan.dataDir) console.log(` - move its data folder to the Trash: ${plan.dataDir}`);
931
+ else console.log(` - its Chrome data is left alone (this only unregisters it from rech)`);
932
+ if (running.length) console.log(` - close its browser window, which is running now (pid ${running.join(", ")})`);
933
+ const interactive = isInteractive();
934
+ if (!opts.yes) {
935
+ if (!interactive) throw new Error("Not removed. Re-run with --yes to confirm" + (running.length ? " and --close to close its running window." : "."));
936
+ const ok = await promptChoice("Go ahead?", [{ label: "No, keep it", value: false }, { label: "Yes, remove it", value: true }], 0);
937
+ if (!ok) throw new Error("Cancelled; nothing changed.");
938
+ }
939
+ if (running.length) {
940
+ // In a terminal, confirming the plan above (which lists closing it) is the consent; otherwise --close.
941
+ if (!interactive && !opts.close) throw new Error("Its window is running; re-run with --close to close it, or close it yourself first.");
942
+ for (const pid of running) process.kill(pid, "SIGTERM");
943
+ for (let i = 0; i < 40 && browsersUsing(plan.dataDir!).length; i++) await Bun.sleep(250);
944
+ if (browsersUsing(plan.dataDir!).length) throw new Error("Its window did not close; close it and run this again. Nothing else changed.");
945
+ }
946
+ if (config && plan.listeners.length) {
947
+ for (const edit of plan.listeners) {
948
+ if (edit.drop) config.listeners = config.listeners.filter(l => l.name !== edit.name);
949
+ else { const l = config.listeners.find(x => x.name === edit.name)!; l.profiles = (l.profiles as string[]).filter(p => !plan.keys.includes(p)); }
950
+ }
951
+ await writeListeners(config);
952
+ }
953
+ for (const k of plan.keys) delete registry[k];
954
+ await writeTokenRegistry(registry);
955
+ // The migrated profiles.json backup still holds tokens: drop the same keys there.
956
+ const legacy = join(RECH_DIR, "profiles.json");
957
+ if (existsSync(legacy)) {
958
+ try {
959
+ const old = JSON.parse(readFileSync(legacy, "utf8"));
960
+ if (plan.keys.some(k => k in old)) { for (const k of plan.keys) delete old[k]; await writeFile(legacy, JSON.stringify(old, null, 2) + "\n", { mode: 0o600 }); await chmod(legacy, 0o600); }
961
+ } catch { /* unreadable backup: leave it */ }
962
+ }
963
+ let moved: string | null = null;
964
+ if (plan.dataDir && existsSync(plan.dataDir)) moved = moveToTrash(plan.dataDir);
965
+ console.log(`Removed "${key}".${moved ? ` Its data folder is in the Trash: ${moved}` : plan.dataDir ? ` Its data folder was left at ${plan.dataDir}; delete it yourself if you like.` : ""}`);
966
+ }
967
+
968
+ /** On a client of a remote host, `rech profile` lists what that host's link shares. */
969
+ async function listRemoteProfiles(url: string): Promise<void> {
970
+ const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(5000) })
971
+ .catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
972
+ 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>'.");
973
+ const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
974
+ const current = resolveEffectiveProfile(parseUrl(url).profileDirectory);
975
+ console.log(`Profiles shared by ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}:`);
976
+ if (body.profiles === "*") console.log(" every profile registered on that host");
977
+ else for (const p of body.profiles ?? []) console.log(` ${p}${p === current ? " ← current" : ""}`);
978
+ console.log(`\nUse one: rech --profile <name> open https://example.com`);
979
+ }
980
+
896
981
  async function listProfiles(): Promise<void> {
897
982
  const [cache, registry, root] = await Promise.all([readChromeProfileCache(), readTokenRegistry(), findChromeUserDataDir()]);
898
983
  const profiles = buildProfileRows(cache, registry, root);
@@ -983,15 +1068,12 @@ export async function promptChoice<T>(
983
1068
  } finally { rl.close(); }
984
1069
  }
985
1070
 
986
- /** Registered profiles as choices, one per Chrome profile folder (the registry may alias one folder twice). */
1071
+ /** Registered profiles as choices, one per Chrome profile (data dir + folder; aliases collapse). */
987
1072
  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)) {
1073
+ return canonicalProfileKeys(registry).map(key => {
990
1074
  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 }));
1075
+ return { label: key === dir ? key : `${key} [${dir}]`, value: key };
1076
+ });
995
1077
  }
996
1078
 
997
1079
  /**
@@ -1013,7 +1095,95 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
1013
1095
  ].filter(Boolean).join("\n"), "none", scoped);
1014
1096
  }
1015
1097
 
1016
- async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean } = {}): Promise<void> {
1098
+ /** A loopback port no listener uses and nothing else is bound to, from the management port + 2. */
1099
+ function freeListenerPort(listeners: Listener[]): number {
1100
+ const used = new Set(listeners.map(l => l.port));
1101
+ for (let port = DEFAULT_PORT + 2; port < 65536; port++) {
1102
+ if (used.has(port)) continue;
1103
+ try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return port; } catch { /* taken */ }
1104
+ }
1105
+ throw new Error("No free port for the share-all listener; pass one with rech listener add");
1106
+ }
1107
+
1108
+ /**
1109
+ * `rech share --all`: one link for every Chrome profile registered now (a snapshot; run it
1110
+ * again after registering more). It uses its own listener and key, so single-profile links
1111
+ * already handed out never gain access to the other profiles.
1112
+ */
1113
+ async function shareAll(opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
1114
+ const snapshot = canonicalProfileKeys(await readTokenRegistry());
1115
+ if (!snapshot.length) throw new Error("No registered profiles to share. Set one up first: rech setup");
1116
+ return shareSet(snapshot, { ...opts, defaultName: "share-all", prefix: "/rechrome-all/", again: "rech share --all" });
1117
+ }
1118
+
1119
+ /** The registry key rech uses for a profile: one per Chrome profile, whatever alias it was named by. */
1120
+ function canonicalKeyFor(registry: Record<string, TokenEntry>, key: string): string {
1121
+ const entry = registry[key];
1122
+ return canonicalProfileKeys(registry).find(k => registry[k].profileDir === entry?.profileDir && (registry[k].userDataDir ?? "") === (entry?.userDataDir ?? "")) ?? key;
1123
+ }
1124
+
1125
+ /**
1126
+ * `rech share a b c`: one link for exactly these profiles, on one listener. With --listener it
1127
+ * sets that listener's profiles to the list (e.g. one that already has a proxy route); otherwise
1128
+ * it reuses a listener with exactly this set, or creates one with its own key.
1129
+ */
1130
+ async function shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
1131
+ const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
1132
+ const keys: string[] = [];
1133
+ for (const selector of selectors) {
1134
+ const key = canonicalKeyFor(registry, (await resolveGlobalProfile(registry, cache, selector)).email);
1135
+ if (!keys.includes(key)) keys.push(key);
1136
+ }
1137
+ if (keys.length === 1) return printProfileUri(keys[0], opts.listener, opts);
1138
+ const config = await requireListeners();
1139
+ const same = config.listeners.find(l => l.profiles !== "*" && l.profiles.length === keys.length && keys.every(k => (l.profiles as string[]).includes(k)));
1140
+ const id = createHash("sha256").update([...keys].sort().join("\0")).digest("hex").slice(0, 6);
1141
+ return shareSet(keys, { ...opts, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
1142
+ }
1143
+
1144
+ /** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
1145
+ async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string }): Promise<void> {
1146
+ const config = await requireListeners();
1147
+ const name = opts.listener ?? opts.defaultName;
1148
+ let listener = config.listeners.find(l => l.name === name);
1149
+ if (listener?.profiles === "*")
1150
+ throw new Error(`"${name}" is the local management listener; it is never shared. Omit --listener, or name a scoped one.`);
1151
+ let changes = "";
1152
+ if (!listener) {
1153
+ if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
1154
+ listener = { name, host: "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1155
+ config.listeners.push(listener);
1156
+ changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
1157
+ } else {
1158
+ const before = listener.profiles as string[];
1159
+ const added = snapshot.filter(p => !before.includes(p)), removed = before.filter(p => !snapshot.includes(p));
1160
+ listener.profiles = snapshot;
1161
+ changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
1162
+ }
1163
+ await writeListeners(config);
1164
+ console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
1165
+ if (opts.defaultName === "share-all") console.error(`[rech] this is a snapshot: after registering another profile, run ${opts.again} again.`);
1166
+ else if (changes !== "no changes" && !changes.startsWith("created")) console.error(`[rech] links already given out for "${name}" now reach exactly these profiles too (same key). For a fresh key: rech listener rotate-key ${name}`);
1167
+ // The daemon reloads listeners.json about every second: confirm this listener answers before handing out its URL.
1168
+ const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
1169
+ let ready = false;
1170
+ for (let i = 0; i < 20 && !ready; i++) {
1171
+ ready = await fetch(serviceUrl(local, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false);
1172
+ if (!ready) await Bun.sleep(250);
1173
+ }
1174
+ if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
1175
+ const uri = listener.publicUrl && !opts.local ? rebaseConnectionUrl(listener.publicUrl, local) : registeredProfileUrl(local);
1176
+ if (!listener.publicUrl) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
1177
+ console.log(uri);
1178
+ console.error(`[rech] on the other machine: rech connect '<url>', then rech --profile <name> open https://example.com`);
1179
+ if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
1180
+ }
1181
+
1182
+ async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
1183
+ if (opts.all) {
1184
+ if (selector) throw new Error("Pass a profile or --all, not both.");
1185
+ return shareAll({ listener, local: opts.local, save: opts.save });
1186
+ }
1017
1187
  const url = process.env[ENV_KEY];
1018
1188
  const interactive = isInteractive();
1019
1189
  const registry = await readTokenRegistry();
@@ -1073,8 +1243,11 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1073
1243
  }
1074
1244
  }
1075
1245
  }
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.`);
1246
+ const chosen = listeners.find(l => l.name === listener);
1247
+ if (chosen?.profiles === "*")
1248
+ 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.`);
1249
+ if (chosen && chosen.profiles.length > 1)
1250
+ 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
1251
  let uri = profileConnectionUri(profile, url, listeners, listener);
1079
1252
  // Prefer where a proxy exposes the listener, when it has been recorded.
1080
1253
  const publicUrl = listeners.find(l => l.key === parseUrl(uri).key)?.publicUrl;
@@ -1090,6 +1263,29 @@ export function sandboxConnectionWarning(env: Record<string, string | undefined>
1090
1263
  "If it still fails, check that the daemon is running and the host/port are correct.";
1091
1264
  }
1092
1265
 
1266
+ /**
1267
+ * Is RECHROME_URL this machine's own daemon? True when its key is one of our listeners (or,
1268
+ * before listeners.json existed, when it points at loopback and profiles are registered here). A remote daemon resolves
1269
+ * profiles itself, and must never receive this machine's own extension tokens.
1270
+ */
1271
+ export async function isLocalDaemon(url: string): Promise<boolean> {
1272
+ const { key, host } = parseUrl(url);
1273
+ const config = await readListeners().catch(() => null);
1274
+ // Before listeners.json existed, a local install pointed at loopback and had its own registry;
1275
+ // a fresh client (no registry) talking to a tunnelled loopback port is still remote.
1276
+ if (!config) return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(host) && Object.keys(await readTokenRegistry().catch(() => ({}))).length > 0;
1277
+ return !!key && config.listeners.some(l => l.key === key);
1278
+ }
1279
+
1280
+ /** For a remote daemon: only what the URL itself carries, plus the profile selector (not secret). */
1281
+ export function remoteClientEnv(url: string, profile?: string): Record<string, string> {
1282
+ const { extensionId, extensionToken, userDataDir, loadExtension } = parseUrl(url);
1283
+ return Object.fromEntries(Object.entries({
1284
+ PLAYWRIGHT_MCP_EXTENSION_ID: extensionId, PLAYWRIGHT_MCP_EXTENSION_TOKEN: extensionToken,
1285
+ PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profile, PLAYWRIGHT_MCP_USER_DATA_DIR: userDataDir, PLAYWRIGHT_MCP_LOAD_EXTENSION: loadExtension,
1286
+ }).filter(([, v]) => typeof v === "string" && v)) as Record<string, string>;
1287
+ }
1288
+
1093
1289
  async function callServe(
1094
1290
  url: string,
1095
1291
  args: string[],
@@ -1107,7 +1303,9 @@ async function callServe(
1107
1303
  // reuse the default profile's session (and its browser) instead of opening its own.
1108
1304
  const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
1109
1305
  if (effectiveProfile) identity.profile = effectiveProfile;
1110
- const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension })), ...overrideEnv };
1306
+ const env = await isLocalDaemon(url)
1307
+ ? { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension })), ...overrideEnv }
1308
+ : { ...remoteClientEnv(url, effectiveProfile), ...overrideEnv };
1111
1309
  const res = await fetch(serviceUrl(url, "run"), {
1112
1310
  method: "POST",
1113
1311
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
@@ -2185,7 +2383,7 @@ async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
2185
2383
  }
2186
2384
 
2187
2385
  /** 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[] {
2386
+ export function listenerNextSteps(listener: Listener, profile?: string, shareCommand?: string): string[] {
2189
2387
  const prefix = normalizePrefix(listener.prefix);
2190
2388
  const mount = prefix === "/" ? "" : prefix.slice(0, -1);
2191
2389
  return [
@@ -2193,7 +2391,7 @@ export function listenerNextSteps(listener: Listener, profile?: string): string[
2193
2391
  ` tailscale serve --bg${mount ? ` --set-path=${mount}` : ""} ${listener.port}`,
2194
2392
  `Then record where it is reachable and print the URL to share:`,
2195
2393
  ` rech listener set ${listener.name} --public-url https://<your-host>${prefix}`,
2196
- ` rech share ${profile ? JSON.stringify(profile) : "<profile>"}`,
2394
+ ` ${shareCommand ?? `rech share ${profile ? JSON.stringify(profile) : "<profile>"}`}`,
2197
2395
  ];
2198
2396
  }
2199
2397
 
@@ -2265,7 +2463,9 @@ async function urlList(): Promise<void> {
2265
2463
  for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
2266
2464
  }
2267
2465
  printTable(rows);
2268
- console.log(`\nPrint a full URL (contains the secret key): rech share <profile>`);
2466
+ const wide = config.listeners.filter(l => l.profiles !== "*" && l.profiles.length > 1);
2467
+ 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.`);
2468
+ console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
2269
2469
  }
2270
2470
 
2271
2471
  /** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
@@ -2293,6 +2493,8 @@ async function connect(url: string): Promise<void> {
2293
2493
  throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
2294
2494
  const saved = await saveProjectUrl(url);
2295
2495
  console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
2496
+ if (Array.isArray(body.profiles) && body.profiles.length > 1 && !parsed.profileDirectory)
2497
+ 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
2498
  }
2297
2499
 
2298
2500
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
@@ -2746,8 +2948,10 @@ export type RechHandlers = {
2746
2948
  addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
2747
2949
  removeListener(name: string): Promise<void>;
2748
2950
  listProfiles(): Promise<void>;
2749
- printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean }): Promise<void>;
2951
+ removeProfile(selector: string, opts: { yes?: boolean; close?: boolean }): Promise<void>;
2952
+ printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean; all?: boolean }): Promise<void>;
2750
2953
  urlList(): Promise<void>;
2954
+ shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void>;
2751
2955
  connect(url: string): Promise<void>;
2752
2956
  listenerPort(name?: string): Promise<void>;
2753
2957
  allowListener(name: string, profiles: string[]): Promise<void>;
@@ -2810,24 +3014,39 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
2810
3014
  .option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
2811
3015
  a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
2812
3016
  .command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
2813
- .command(["profile [name]", "profiles [name]"], "List Chrome profiles and whether each is connected", y => y
2814
- .positional("name", { type: "string", describe: "ls/list lists all (the default)" })
3017
+ .command(["profile [name] [target]", "profiles [name] [target]"], "List Chrome profiles; `profile rm <name>` removes one from rech", y => y
3018
+ .positional("name", { type: "string", describe: "ls/list lists all (the default); rm/remove <name> removes a profile" })
3019
+ .positional("target", { type: "string", describe: "The profile to remove (with rm)" })
3020
+ .option("yes", { alias: "y", type: "boolean", describe: "With rm: don't ask for confirmation" })
3021
+ .option("close", { type: "boolean", describe: "With rm: close the profile's running window (managed profiles)" })
2815
3022
  .option("print-uri", { type: "boolean", describe: "Same as `rech share <name>`" })
2816
3023
  .option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
2817
3024
  a => {
3025
+ if (a.name === "rm" || a.name === "remove") {
3026
+ if (!a.target) throw new Error("Which profile? rech profile rm <name> (see rech profile)");
3027
+ return handlers.removeProfile(a.target, { yes: a.yes, close: a.close });
3028
+ }
3029
+ if (a.target) throw new Error(`Unexpected "${a.target}". To remove a profile: rech profile rm <name>`);
2818
3030
  if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech share`
2819
3031
  if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
2820
3032
  throw new Error(`To share "${a.name}" with another machine: rech share ${JSON.stringify(a.name)}. To list profiles: rech profile`);
2821
3033
  })
2822
3034
  // Share with and connect from other machines
2823
- .command("share [profile]", "Print a URL another machine can connect with (secret); `share ls` lists all", y => y
2824
- .positional("profile", { type: "string", describe: "Profile: email, name, folder, or a unique part of one; ls/list lists everything shared" })
3035
+ .command("share [profiles..]", "Print a URL another machine can connect with (secret); `share ls` lists all", y => y
3036
+ .positional("profiles", { type: "string", array: true, describe: "Profile(s): email, name, folder, or a unique part of one. Several = one link for all of them. ls/list lists everything shared" })
2825
3037
  .option("listener", { type: "string", requiresArg: true, describe: "Listener to share through (default: the one that allows the profile)" })
2826
3038
  .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
2829
- ? handlers.urlList()
2830
- : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
3039
+ .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" })
3040
+ .option("all", { type: "boolean", describe: "One link for every registered profile (a snapshot, on its own listener); the other machine picks with --profile" }),
3041
+ a => {
3042
+ const profiles = (a.profiles ?? []) as string[];
3043
+ if (profiles.length === 1 && ["ls", "list"].includes(profiles[0]) && !a.listener && !a.save && !a.all) return handlers.urlList();
3044
+ if (profiles.length > 1) {
3045
+ if (a.all) throw new Error("Pass profiles or --all, not both.");
3046
+ return handlers.shareProfiles(profiles, { listener: a.listener, local: a.local, save: a.save });
3047
+ }
3048
+ return handlers.printProfileUri(profiles[0], a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save });
3049
+ })
2831
3050
  .command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
2832
3051
  .positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
2833
3052
  .example("rech connect 'https://host.example.js.net/rechrome/?profile=you%40example.com#key=…'", ""),
@@ -2893,8 +3112,12 @@ if (import.meta.main) {
2893
3112
  const handlers: RechHandlers = {
2894
3113
  serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
2895
3114
  status,
2896
- listListeners, addListener, removeListener, listProfiles, printProfileUri,
2897
- urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
3115
+ listListeners, addListener, removeListener, printProfileUri,
3116
+ listProfiles: async () => {
3117
+ const url = process.env[ENV_KEY];
3118
+ return url && !(await isLocalDaemon(url)) ? listRemoteProfiles(url) : listProfiles();
3119
+ },
3120
+ urlList, shareProfiles, removeProfile, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
2898
3121
  setup: async (opts) => {
2899
3122
  await setup(opts); // setup closes envWatcher itself before printing Done
2900
3123
  // Auto-start the tray (best-effort, silent on headless / missing binary).
@@ -2954,7 +3177,11 @@ if (import.meta.main) {
2954
3177
  envWatcher?.close();
2955
3178
  process.exit(1);
2956
3179
  }
2957
- if (profileSelector !== undefined) {
3180
+ if (profileSelector !== undefined && !(await isLocalDaemon(url))) {
3181
+ // A remote host resolves the name among the profiles its link shares; this machine's
3182
+ // registry is irrelevant (and usually empty on a client).
3183
+ overrideEnv = { PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileSelector.trim() };
3184
+ } else if (profileSelector !== undefined) {
2958
3185
  try {
2959
3186
  const registry = await readTokenRegistry();
2960
3187
  const cache = await readChromeProfileCache();
package/rechrome.ts CHANGED
@@ -1,10 +1,10 @@
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, planProfileRemoval, type Listener } from "./listeners.ts";
3
3
 
4
4
  import { file } from "bun";
5
5
  import yargs from "yargs";
6
6
  import { readExtensionTokenFromProfile } from "./extension-token.ts";
7
- import { randomBytes } from "crypto";
7
+ import { createHash, randomBytes } from "crypto";
8
8
  import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, rmdirSync, constants as fsConstants } from "fs";
9
9
  import { hostname, homedir, networkInterfaces } from "os";
10
10
  import { isIPv4 } from "net";
@@ -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,91 @@ export function buildProfileRows(cache: Record<string, ChromeProfileInfo> | null
893
893
  return rows;
894
894
  }
895
895
 
896
+ /** PIDs of browser processes (not helpers) running on a Chrome user-data dir. POSIX only. */
897
+ function browsersUsing(userDataDir: string): number[] {
898
+ if (process.platform === "win32") return [];
899
+ const ps = Bun.spawnSync(["ps", "ax", "-o", "pid=,command="]).stdout.toString();
900
+ return ps.split("\n").filter(line => line.includes(`--user-data-dir=${userDataDir}`) && !line.includes("--type="))
901
+ .map(line => Number(line.trim().split(/\s+/)[0])).filter(Boolean);
902
+ }
903
+
904
+ /** Move a folder to the user's Trash (recoverable). Returns where it went, or null if unsupported here. */
905
+ function moveToTrash(dir: string): string | null {
906
+ const trash = process.platform === "darwin" ? join(HOME, ".Trash") : process.platform === "linux" ? join(HOME, ".local", "share", "Trash", "files") : null;
907
+ if (!trash) return null;
908
+ mkdirSync(trash, { recursive: true });
909
+ let dest = join(trash, basename(dir));
910
+ if (existsSync(dest)) dest = `${dest} ${new Date().toISOString().replace(/[:.]/g, "-")}`;
911
+ renameSync(dir, dest);
912
+ return dest;
913
+ }
914
+
915
+ /**
916
+ * `rech profile rm <name>`: unregister a profile everywhere (all its aliases, every listener
917
+ * allowlist, dropping listeners left empty) and move a managed test profile's own folder to the
918
+ * Trash. Real Chrome profile data is never touched. Shows the plan and asks first (--yes skips);
919
+ * a browser still running on a managed folder is closed only with consent (--close).
920
+ */
921
+ async function removeProfile(selector: string, opts: { yes?: boolean; close?: boolean } = {}): Promise<void> {
922
+ const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
923
+ const key = (await resolveGlobalProfile(registry, cache, selector)).email;
924
+ const config = await readListeners();
925
+ const plan = planProfileRemoval(key, registry, config?.listeners ?? [], join(RECH_DIR, "profiles"));
926
+ const running = plan.dataDir ? browsersUsing(plan.dataDir) : [];
927
+ console.log(`Remove profile "${key}"${plan.keys.length > 1 ? ` (registered as ${plan.keys.join(", ")})` : ""}:`);
928
+ for (const edit of plan.listeners) console.log(edit.drop ? ` - remove listener "${edit.name}" (it serves only this profile)` : ` - stop sharing it on listener "${edit.name}"`);
929
+ console.log(` - unregister it from rech (~/.rechrome/profiles.yaml)`);
930
+ if (plan.dataDir) console.log(` - move its data folder to the Trash: ${plan.dataDir}`);
931
+ else console.log(` - its Chrome data is left alone (this only unregisters it from rech)`);
932
+ if (running.length) console.log(` - close its browser window, which is running now (pid ${running.join(", ")})`);
933
+ const interactive = isInteractive();
934
+ if (!opts.yes) {
935
+ if (!interactive) throw new Error("Not removed. Re-run with --yes to confirm" + (running.length ? " and --close to close its running window." : "."));
936
+ const ok = await promptChoice("Go ahead?", [{ label: "No, keep it", value: false }, { label: "Yes, remove it", value: true }], 0);
937
+ if (!ok) throw new Error("Cancelled; nothing changed.");
938
+ }
939
+ if (running.length) {
940
+ // In a terminal, confirming the plan above (which lists closing it) is the consent; otherwise --close.
941
+ if (!interactive && !opts.close) throw new Error("Its window is running; re-run with --close to close it, or close it yourself first.");
942
+ for (const pid of running) process.kill(pid, "SIGTERM");
943
+ for (let i = 0; i < 40 && browsersUsing(plan.dataDir!).length; i++) await Bun.sleep(250);
944
+ if (browsersUsing(plan.dataDir!).length) throw new Error("Its window did not close; close it and run this again. Nothing else changed.");
945
+ }
946
+ if (config && plan.listeners.length) {
947
+ for (const edit of plan.listeners) {
948
+ if (edit.drop) config.listeners = config.listeners.filter(l => l.name !== edit.name);
949
+ else { const l = config.listeners.find(x => x.name === edit.name)!; l.profiles = (l.profiles as string[]).filter(p => !plan.keys.includes(p)); }
950
+ }
951
+ await writeListeners(config);
952
+ }
953
+ for (const k of plan.keys) delete registry[k];
954
+ await writeTokenRegistry(registry);
955
+ // The migrated profiles.json backup still holds tokens: drop the same keys there.
956
+ const legacy = join(RECH_DIR, "profiles.json");
957
+ if (existsSync(legacy)) {
958
+ try {
959
+ const old = JSON.parse(readFileSync(legacy, "utf8"));
960
+ if (plan.keys.some(k => k in old)) { for (const k of plan.keys) delete old[k]; await writeFile(legacy, JSON.stringify(old, null, 2) + "\n", { mode: 0o600 }); await chmod(legacy, 0o600); }
961
+ } catch { /* unreadable backup: leave it */ }
962
+ }
963
+ let moved: string | null = null;
964
+ if (plan.dataDir && existsSync(plan.dataDir)) moved = moveToTrash(plan.dataDir);
965
+ console.log(`Removed "${key}".${moved ? ` Its data folder is in the Trash: ${moved}` : plan.dataDir ? ` Its data folder was left at ${plan.dataDir}; delete it yourself if you like.` : ""}`);
966
+ }
967
+
968
+ /** On a client of a remote host, `rech profile` lists what that host's link shares. */
969
+ async function listRemoteProfiles(url: string): Promise<void> {
970
+ const response = await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parseUrl(url).key}` }, signal: AbortSignal.timeout(5000) })
971
+ .catch(error => { throw new Error(`Could not reach ${serviceUrl(url)}: ${error instanceof Error ? error.message : error}`); });
972
+ 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>'.");
973
+ const body = await response.json().catch(() => ({})) as { listener?: string; profiles?: string[] | "*" };
974
+ const current = resolveEffectiveProfile(parseUrl(url).profileDirectory);
975
+ console.log(`Profiles shared by ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}:`);
976
+ if (body.profiles === "*") console.log(" every profile registered on that host");
977
+ else for (const p of body.profiles ?? []) console.log(` ${p}${p === current ? " ← current" : ""}`);
978
+ console.log(`\nUse one: rech --profile <name> open https://example.com`);
979
+ }
980
+
896
981
  async function listProfiles(): Promise<void> {
897
982
  const [cache, registry, root] = await Promise.all([readChromeProfileCache(), readTokenRegistry(), findChromeUserDataDir()]);
898
983
  const profiles = buildProfileRows(cache, registry, root);
@@ -983,15 +1068,12 @@ export async function promptChoice<T>(
983
1068
  } finally { rl.close(); }
984
1069
  }
985
1070
 
986
- /** Registered profiles as choices, one per Chrome profile folder (the registry may alias one folder twice). */
1071
+ /** Registered profiles as choices, one per Chrome profile (data dir + folder; aliases collapse). */
987
1072
  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)) {
1073
+ return canonicalProfileKeys(registry).map(key => {
990
1074
  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 }));
1075
+ return { label: key === dir ? key : `${key} [${dir}]`, value: key };
1076
+ });
995
1077
  }
996
1078
 
997
1079
  /**
@@ -1013,7 +1095,95 @@ export function chooseShareListener(profile: string, listeners: Listener[]): str
1013
1095
  ].filter(Boolean).join("\n"), "none", scoped);
1014
1096
  }
1015
1097
 
1016
- async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean } = {}): Promise<void> {
1098
+ /** A loopback port no listener uses and nothing else is bound to, from the management port + 2. */
1099
+ function freeListenerPort(listeners: Listener[]): number {
1100
+ const used = new Set(listeners.map(l => l.port));
1101
+ for (let port = DEFAULT_PORT + 2; port < 65536; port++) {
1102
+ if (used.has(port)) continue;
1103
+ try { Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() }).stop(true); return port; } catch { /* taken */ }
1104
+ }
1105
+ throw new Error("No free port for the share-all listener; pass one with rech listener add");
1106
+ }
1107
+
1108
+ /**
1109
+ * `rech share --all`: one link for every Chrome profile registered now (a snapshot; run it
1110
+ * again after registering more). It uses its own listener and key, so single-profile links
1111
+ * already handed out never gain access to the other profiles.
1112
+ */
1113
+ async function shareAll(opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
1114
+ const snapshot = canonicalProfileKeys(await readTokenRegistry());
1115
+ if (!snapshot.length) throw new Error("No registered profiles to share. Set one up first: rech setup");
1116
+ return shareSet(snapshot, { ...opts, defaultName: "share-all", prefix: "/rechrome-all/", again: "rech share --all" });
1117
+ }
1118
+
1119
+ /** The registry key rech uses for a profile: one per Chrome profile, whatever alias it was named by. */
1120
+ function canonicalKeyFor(registry: Record<string, TokenEntry>, key: string): string {
1121
+ const entry = registry[key];
1122
+ return canonicalProfileKeys(registry).find(k => registry[k].profileDir === entry?.profileDir && (registry[k].userDataDir ?? "") === (entry?.userDataDir ?? "")) ?? key;
1123
+ }
1124
+
1125
+ /**
1126
+ * `rech share a b c`: one link for exactly these profiles, on one listener. With --listener it
1127
+ * sets that listener's profiles to the list (e.g. one that already has a proxy route); otherwise
1128
+ * it reuses a listener with exactly this set, or creates one with its own key.
1129
+ */
1130
+ async function shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void> {
1131
+ const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
1132
+ const keys: string[] = [];
1133
+ for (const selector of selectors) {
1134
+ const key = canonicalKeyFor(registry, (await resolveGlobalProfile(registry, cache, selector)).email);
1135
+ if (!keys.includes(key)) keys.push(key);
1136
+ }
1137
+ if (keys.length === 1) return printProfileUri(keys[0], opts.listener, opts);
1138
+ const config = await requireListeners();
1139
+ const same = config.listeners.find(l => l.profiles !== "*" && l.profiles.length === keys.length && keys.every(k => (l.profiles as string[]).includes(k)));
1140
+ const id = createHash("sha256").update([...keys].sort().join("\0")).digest("hex").slice(0, 6);
1141
+ return shareSet(keys, { ...opts, listener: opts.listener ?? same?.name, defaultName: `share-${id}`, prefix: `/rechrome-${id}/`, again: `rech share ${selectors.map(s => JSON.stringify(s)).join(" ")}` });
1142
+ }
1143
+
1144
+ /** Put exactly `snapshot` on one scoped listener (created if missing) and print its link. */
1145
+ async function shareSet(snapshot: string[], opts: { listener?: string; local?: boolean; save?: boolean; defaultName: string; prefix: string; again: string }): Promise<void> {
1146
+ const config = await requireListeners();
1147
+ const name = opts.listener ?? opts.defaultName;
1148
+ let listener = config.listeners.find(l => l.name === name);
1149
+ if (listener?.profiles === "*")
1150
+ throw new Error(`"${name}" is the local management listener; it is never shared. Omit --listener, or name a scoped one.`);
1151
+ let changes = "";
1152
+ if (!listener) {
1153
+ if (opts.listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
1154
+ listener = { name, host: "127.0.0.1", port: freeListenerPort(config.listeners), prefix: opts.prefix, key: randomBytes(24).toString("base64url"), profiles: snapshot };
1155
+ config.listeners.push(listener);
1156
+ changes = `created listener "${name}" on ${listenerAddress(listener)}${listener.prefix}`;
1157
+ } else {
1158
+ const before = listener.profiles as string[];
1159
+ const added = snapshot.filter(p => !before.includes(p)), removed = before.filter(p => !snapshot.includes(p));
1160
+ listener.profiles = snapshot;
1161
+ changes = added.length || removed.length ? [added.length && `added ${added.join(", ")}`, removed.length && `removed ${removed.join(", ")}`].filter(Boolean).join("; ") : "no changes";
1162
+ }
1163
+ await writeListeners(config);
1164
+ console.error(`[rech] sharing ${snapshot.length} profiles through "${name}" (${changes}): ${snapshot.join(", ")}`);
1165
+ if (opts.defaultName === "share-all") console.error(`[rech] this is a snapshot: after registering another profile, run ${opts.again} again.`);
1166
+ else if (changes !== "no changes" && !changes.startsWith("created")) console.error(`[rech] links already given out for "${name}" now reach exactly these profiles too (same key). For a fresh key: rech listener rotate-key ${name}`);
1167
+ // The daemon reloads listeners.json about every second: confirm this listener answers before handing out its URL.
1168
+ const local = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
1169
+ let ready = false;
1170
+ for (let i = 0; i < 20 && !ready; i++) {
1171
+ ready = await fetch(serviceUrl(local, "ping"), { headers: { Authorization: `Bearer ${listener.key}` }, signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false);
1172
+ if (!ready) await Bun.sleep(250);
1173
+ }
1174
+ if (!ready) console.error(`[rech] warning: listener "${name}" is not answering yet; check the daemon with rech status.`);
1175
+ const uri = listener.publicUrl && !opts.local ? rebaseConnectionUrl(listener.publicUrl, local) : registeredProfileUrl(local);
1176
+ if (!listener.publicUrl) for (const line of listenerNextSteps(listener, undefined, opts.again)) console.error(line);
1177
+ console.log(uri);
1178
+ console.error(`[rech] on the other machine: rech connect '<url>', then rech --profile <name> open https://example.com`);
1179
+ if (opts.save) console.error(`Saved RECHROME_URL to ${await saveProjectUrl(uri)}`);
1180
+ }
1181
+
1182
+ async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
1183
+ if (opts.all) {
1184
+ if (selector) throw new Error("Pass a profile or --all, not both.");
1185
+ return shareAll({ listener, local: opts.local, save: opts.save });
1186
+ }
1017
1187
  const url = process.env[ENV_KEY];
1018
1188
  const interactive = isInteractive();
1019
1189
  const registry = await readTokenRegistry();
@@ -1073,8 +1243,11 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1073
1243
  }
1074
1244
  }
1075
1245
  }
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.`);
1246
+ const chosen = listeners.find(l => l.name === listener);
1247
+ if (chosen?.profiles === "*")
1248
+ 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.`);
1249
+ if (chosen && chosen.profiles.length > 1)
1250
+ 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
1251
  let uri = profileConnectionUri(profile, url, listeners, listener);
1079
1252
  // Prefer where a proxy exposes the listener, when it has been recorded.
1080
1253
  const publicUrl = listeners.find(l => l.key === parseUrl(uri).key)?.publicUrl;
@@ -1090,6 +1263,29 @@ export function sandboxConnectionWarning(env: Record<string, string | undefined>
1090
1263
  "If it still fails, check that the daemon is running and the host/port are correct.";
1091
1264
  }
1092
1265
 
1266
+ /**
1267
+ * Is RECHROME_URL this machine's own daemon? True when its key is one of our listeners (or,
1268
+ * before listeners.json existed, when it points at loopback and profiles are registered here). A remote daemon resolves
1269
+ * profiles itself, and must never receive this machine's own extension tokens.
1270
+ */
1271
+ export async function isLocalDaemon(url: string): Promise<boolean> {
1272
+ const { key, host } = parseUrl(url);
1273
+ const config = await readListeners().catch(() => null);
1274
+ // Before listeners.json existed, a local install pointed at loopback and had its own registry;
1275
+ // a fresh client (no registry) talking to a tunnelled loopback port is still remote.
1276
+ if (!config) return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(host) && Object.keys(await readTokenRegistry().catch(() => ({}))).length > 0;
1277
+ return !!key && config.listeners.some(l => l.key === key);
1278
+ }
1279
+
1280
+ /** For a remote daemon: only what the URL itself carries, plus the profile selector (not secret). */
1281
+ export function remoteClientEnv(url: string, profile?: string): Record<string, string> {
1282
+ const { extensionId, extensionToken, userDataDir, loadExtension } = parseUrl(url);
1283
+ return Object.fromEntries(Object.entries({
1284
+ PLAYWRIGHT_MCP_EXTENSION_ID: extensionId, PLAYWRIGHT_MCP_EXTENSION_TOKEN: extensionToken,
1285
+ PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profile, PLAYWRIGHT_MCP_USER_DATA_DIR: userDataDir, PLAYWRIGHT_MCP_LOAD_EXTENSION: loadExtension,
1286
+ }).filter(([, v]) => typeof v === "string" && v)) as Record<string, string>;
1287
+ }
1288
+
1093
1289
  async function callServe(
1094
1290
  url: string,
1095
1291
  args: string[],
@@ -1107,7 +1303,9 @@ async function callServe(
1107
1303
  // reuse the default profile's session (and its browser) instead of opening its own.
1108
1304
  const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
1109
1305
  if (effectiveProfile) identity.profile = effectiveProfile;
1110
- const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension })), ...overrideEnv };
1306
+ const env = await isLocalDaemon(url)
1307
+ ? { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension })), ...overrideEnv }
1308
+ : { ...remoteClientEnv(url, effectiveProfile), ...overrideEnv };
1111
1309
  const res = await fetch(serviceUrl(url, "run"), {
1112
1310
  method: "POST",
1113
1311
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
@@ -2185,7 +2383,7 @@ async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
2185
2383
  }
2186
2384
 
2187
2385
  /** 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[] {
2386
+ export function listenerNextSteps(listener: Listener, profile?: string, shareCommand?: string): string[] {
2189
2387
  const prefix = normalizePrefix(listener.prefix);
2190
2388
  const mount = prefix === "/" ? "" : prefix.slice(0, -1);
2191
2389
  return [
@@ -2193,7 +2391,7 @@ export function listenerNextSteps(listener: Listener, profile?: string): string[
2193
2391
  ` tailscale serve --bg${mount ? ` --set-path=${mount}` : ""} ${listener.port}`,
2194
2392
  `Then record where it is reachable and print the URL to share:`,
2195
2393
  ` rech listener set ${listener.name} --public-url https://<your-host>${prefix}`,
2196
- ` rech share ${profile ? JSON.stringify(profile) : "<profile>"}`,
2394
+ ` ${shareCommand ?? `rech share ${profile ? JSON.stringify(profile) : "<profile>"}`}`,
2197
2395
  ];
2198
2396
  }
2199
2397
 
@@ -2265,7 +2463,9 @@ async function urlList(): Promise<void> {
2265
2463
  for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
2266
2464
  }
2267
2465
  printTable(rows);
2268
- console.log(`\nPrint a full URL (contains the secret key): rech share <profile>`);
2466
+ const wide = config.listeners.filter(l => l.profiles !== "*" && l.profiles.length > 1);
2467
+ 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.`);
2468
+ console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
2269
2469
  }
2270
2470
 
2271
2471
  /** Write RECHROME_URL to this project's .rechrome/.env.local (the folder git-ignores itself). */
@@ -2293,6 +2493,8 @@ async function connect(url: string): Promise<void> {
2293
2493
  throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
2294
2494
  const saved = await saveProjectUrl(url);
2295
2495
  console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
2496
+ if (Array.isArray(body.profiles) && body.profiles.length > 1 && !parsed.profileDirectory)
2497
+ 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
2498
  }
2297
2499
 
2298
2500
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
@@ -2746,8 +2948,10 @@ export type RechHandlers = {
2746
2948
  addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
2747
2949
  removeListener(name: string): Promise<void>;
2748
2950
  listProfiles(): Promise<void>;
2749
- printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean }): Promise<void>;
2951
+ removeProfile(selector: string, opts: { yes?: boolean; close?: boolean }): Promise<void>;
2952
+ printProfileUri(selector?: string, listener?: string, opts?: { local?: boolean; save?: boolean; all?: boolean }): Promise<void>;
2750
2953
  urlList(): Promise<void>;
2954
+ shareProfiles(selectors: string[], opts: { listener?: string; local?: boolean; save?: boolean }): Promise<void>;
2751
2955
  connect(url: string): Promise<void>;
2752
2956
  listenerPort(name?: string): Promise<void>;
2753
2957
  allowListener(name: string, profiles: string[]): Promise<void>;
@@ -2810,24 +3014,39 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
2810
3014
  .option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
2811
3015
  a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
2812
3016
  .command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
2813
- .command(["profile [name]", "profiles [name]"], "List Chrome profiles and whether each is connected", y => y
2814
- .positional("name", { type: "string", describe: "ls/list lists all (the default)" })
3017
+ .command(["profile [name] [target]", "profiles [name] [target]"], "List Chrome profiles; `profile rm <name>` removes one from rech", y => y
3018
+ .positional("name", { type: "string", describe: "ls/list lists all (the default); rm/remove <name> removes a profile" })
3019
+ .positional("target", { type: "string", describe: "The profile to remove (with rm)" })
3020
+ .option("yes", { alias: "y", type: "boolean", describe: "With rm: don't ask for confirmation" })
3021
+ .option("close", { type: "boolean", describe: "With rm: close the profile's running window (managed profiles)" })
2815
3022
  .option("print-uri", { type: "boolean", describe: "Same as `rech share <name>`" })
2816
3023
  .option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
2817
3024
  a => {
3025
+ if (a.name === "rm" || a.name === "remove") {
3026
+ if (!a.target) throw new Error("Which profile? rech profile rm <name> (see rech profile)");
3027
+ return handlers.removeProfile(a.target, { yes: a.yes, close: a.close });
3028
+ }
3029
+ if (a.target) throw new Error(`Unexpected "${a.target}". To remove a profile: rech profile rm <name>`);
2818
3030
  if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech share`
2819
3031
  if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
2820
3032
  throw new Error(`To share "${a.name}" with another machine: rech share ${JSON.stringify(a.name)}. To list profiles: rech profile`);
2821
3033
  })
2822
3034
  // Share with and connect from other machines
2823
- .command("share [profile]", "Print a URL another machine can connect with (secret); `share ls` lists all", y => y
2824
- .positional("profile", { type: "string", describe: "Profile: email, name, folder, or a unique part of one; ls/list lists everything shared" })
3035
+ .command("share [profiles..]", "Print a URL another machine can connect with (secret); `share ls` lists all", y => y
3036
+ .positional("profiles", { type: "string", array: true, describe: "Profile(s): email, name, folder, or a unique part of one. Several = one link for all of them. ls/list lists everything shared" })
2825
3037
  .option("listener", { type: "string", requiresArg: true, describe: "Listener to share through (default: the one that allows the profile)" })
2826
3038
  .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
2829
- ? handlers.urlList()
2830
- : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
3039
+ .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" })
3040
+ .option("all", { type: "boolean", describe: "One link for every registered profile (a snapshot, on its own listener); the other machine picks with --profile" }),
3041
+ a => {
3042
+ const profiles = (a.profiles ?? []) as string[];
3043
+ if (profiles.length === 1 && ["ls", "list"].includes(profiles[0]) && !a.listener && !a.save && !a.all) return handlers.urlList();
3044
+ if (profiles.length > 1) {
3045
+ if (a.all) throw new Error("Pass profiles or --all, not both.");
3046
+ return handlers.shareProfiles(profiles, { listener: a.listener, local: a.local, save: a.save });
3047
+ }
3048
+ return handlers.printProfileUri(profiles[0], a.listener, a.all ? { local: a.local, save: a.save, all: true } : { local: a.local, save: a.save });
3049
+ })
2831
3050
  .command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
2832
3051
  .positional("url", { type: "string", demandOption: true, describe: "The URL printed by `rech share <profile>` on the machine with Chrome. Quote it: it contains #" })
2833
3052
  .example("rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'", ""),
@@ -2893,8 +3112,12 @@ if (import.meta.main) {
2893
3112
  const handlers: RechHandlers = {
2894
3113
  serve: async () => { const { serve } = await import("./serve.ts"); serve(); }, // long-lived; watcher intentionally kept alive
2895
3114
  status,
2896
- listListeners, addListener, removeListener, listProfiles, printProfileUri,
2897
- urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
3115
+ listListeners, addListener, removeListener, printProfileUri,
3116
+ listProfiles: async () => {
3117
+ const url = process.env[ENV_KEY];
3118
+ return url && !(await isLocalDaemon(url)) ? listRemoteProfiles(url) : listProfiles();
3119
+ },
3120
+ urlList, shareProfiles, removeProfile, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
2898
3121
  setup: async (opts) => {
2899
3122
  await setup(opts); // setup closes envWatcher itself before printing Done
2900
3123
  // Auto-start the tray (best-effort, silent on headless / missing binary).
@@ -2954,7 +3177,11 @@ if (import.meta.main) {
2954
3177
  envWatcher?.close();
2955
3178
  process.exit(1);
2956
3179
  }
2957
- if (profileSelector !== undefined) {
3180
+ if (profileSelector !== undefined && !(await isLocalDaemon(url))) {
3181
+ // A remote host resolves the name among the profiles its link shares; this machine's
3182
+ // registry is irrelevant (and usually empty on a client).
3183
+ overrideEnv = { PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileSelector.trim() };
3184
+ } else if (profileSelector !== undefined) {
2958
3185
  try {
2959
3186
  const registry = await readTokenRegistry();
2960
3187
  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;