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 +27 -0
- package/listeners.js +50 -0
- package/listeners.ts +76 -0
- package/package.json +1 -1
- package/rechrome.js +256 -29
- package/rechrome.ts +256 -29
- package/serve.js +15 -3
- package/serve.ts +15 -3
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
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
|
|
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
|
-
|
|
989
|
-
for (const key of Object.keys(registry)) {
|
|
1073
|
+
return canonicalProfileKeys(registry).map(key => {
|
|
990
1074
|
const dir = registry[key].profileDir;
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 [
|
|
2824
|
-
.positional("
|
|
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
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
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,
|
|
2897
|
-
|
|
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
|
|
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
|
-
|
|
989
|
-
for (const key of Object.keys(registry)) {
|
|
1073
|
+
return canonicalProfileKeys(registry).map(key => {
|
|
990
1074
|
const dir = registry[key].profileDir;
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 [
|
|
2824
|
-
.positional("
|
|
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
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
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,
|
|
2897
|
-
|
|
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;
|