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 +15 -0
- package/listeners.js +39 -0
- package/listeners.ts +52 -0
- package/package.json +1 -1
- package/rechrome.js +130 -22
- package/rechrome.ts +130 -22
- package/serve.js +15 -3
- package/serve.ts +15 -3
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
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
|
|
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
|
-
|
|
989
|
-
for (const key of Object.keys(registry)) {
|
|
1001
|
+
return canonicalProfileKeys(registry).map(key => {
|
|
990
1002
|
const dir = registry[key].profileDir;
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
989
|
-
for (const key of Object.keys(registry)) {
|
|
1001
|
+
return canonicalProfileKeys(registry).map(key => {
|
|
990
1002
|
const dir = registry[key].profileDir;
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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;
|