rechrome 1.24.0 → 1.25.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.
@@ -1,12 +1,18 @@
1
1
  #!/usr/bin/env bun
2
+ import { readListeners, writeListeners, listenerAddress, isLoopback, normalizePrefix, serviceUrl, type Listener } from "./listeners.js";
2
3
 
3
4
  import { file } from "bun";
5
+ import yargs from "yargs";
6
+ import { readExtensionTokenFromProfile } from "./extension-token.js";
4
7
  import { randomBytes } from "crypto";
5
8
  import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, constants as fsConstants } from "fs";
6
- import { hostname, homedir } from "os";
9
+ import { hostname, homedir, networkInterfaces } from "os";
10
+ import { isIPv4 } from "net";
7
11
  import { join, basename, dirname } from "path";
12
+ import { pathToFileURL } from "url";
8
13
  import { spawn as cpSpawn } from "child_process";
9
- import { pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
14
+ import { readFile, writeFile, rename, chmod, mkdir } from "node:fs/promises";
15
+ import { oxmgrInstallCommand, pickDaemonManager, type DaemonManager } from "./daemon-manager.js";
10
16
 
11
17
  export const ENV_KEY = "RECHROME_URL";
12
18
  export const DEFAULT_PORT = 13775;
@@ -17,29 +23,74 @@ export const LOG_DIR = join(RECH_DIR, "logs");
17
23
  export const HOME = homedir();
18
24
 
19
25
  const RECH_HOME_DIR = join(HOME, ".rechrome");
20
- const TOKENS_FILE = join(RECH_HOME_DIR, "profiles.json");
26
+ const TOKENS_FILE = join(RECH_HOME_DIR, "profiles.yaml");
21
27
 
22
28
  type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string; loadExtension?: string };
23
29
 
24
- async function readTokenRegistry(): Promise<Record<string, TokenEntry>> {
25
- const raw = await file(TOKENS_FILE).text().catch(() => "{}");
26
- try { return JSON.parse(raw); } catch { return {}; }
30
+ function validateTokenRegistry(value: unknown): Record<string, TokenEntry> {
31
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid profile registry: expected a mapping");
32
+ for (const entry of Object.values(value)) {
33
+ if (!entry || typeof entry !== "object" || Array.isArray(entry) ||
34
+ !["extensionId", "token", "profileDir"].every(k => typeof entry[k] === "string" && entry[k].length > 0) ||
35
+ !["userDataDir", "loadExtension"].every(k => entry[k] === undefined || typeof entry[k] === "string"))
36
+ throw new Error("Invalid profile registry entry");
37
+ }
38
+ return value as Record<string, TokenEntry>;
39
+ }
40
+
41
+ export async function writeTokenRegistry(registry: Record<string, TokenEntry>, directory = RECH_HOME_DIR): Promise<void> {
42
+ validateTokenRegistry(registry);
43
+ await mkdir(directory, { recursive: true });
44
+ const path = join(directory, "profiles.yaml");
45
+ const temporary = `${path}.${randomBytes(6).toString("hex")}.tmp`;
46
+ try {
47
+ await writeFile(temporary, Bun.YAML.stringify(registry, null, 2) + "\n", { mode: 0o600, flag: "wx" });
48
+ await rename(temporary, path);
49
+ } finally {
50
+ try { unlinkSync(temporary); } catch {}
51
+ }
52
+ }
53
+
54
+ export async function readTokenRegistry(directory = RECH_HOME_DIR): Promise<Record<string, TokenEntry>> {
55
+ const path = join(directory, "profiles.yaml");
56
+ let raw: string;
57
+ try { raw = await readFile(path, "utf8"); }
58
+ catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
60
+ const legacyPath = join(directory, "profiles.json");
61
+ let legacy: string;
62
+ try { legacy = await readFile(legacyPath, "utf8"); }
63
+ catch (error) {
64
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
65
+ throw error;
66
+ }
67
+ const registry = validateTokenRegistry(JSON.parse(legacy));
68
+ await writeTokenRegistry(registry, directory);
69
+ // Retain the original as a private migration backup; YAML is authoritative.
70
+ await chmod(legacyPath, 0o600);
71
+ return registry;
72
+ }
73
+ return validateTokenRegistry(Bun.YAML.parse(raw));
27
74
  }
28
75
 
29
76
  async function saveTokenEntry(profileEmail: string, entry: TokenEntry): Promise<void> {
30
77
  mkdirSync(RECH_HOME_DIR, { recursive: true });
31
78
  const registry = await readTokenRegistry();
32
79
  registry[profileEmail] = entry;
33
- await Bun.write(TOKENS_FILE, JSON.stringify(registry, null, 2) + "\n");
80
+ await writeTokenRegistry(registry);
34
81
  }
35
82
 
36
83
  const envFile = join(import.meta.dir, ".env.local");
37
84
  const globalEnvFile = join(HOME || "~", ".env.local");
38
85
 
39
- // Walk CWD→root loading env files nearest-first; per-key: closest file wins, farther files skip.
86
+ // Capture inherited values once so explicit environment overrides survive reloads,
87
+ // while values loaded from files can still change when those files are edited.
88
+ const inheritedEnvKeys = new Set(Object.keys(process.env));
89
+
90
+ // Walk CWD→root loading env files nearest-first; inherited environment wins over files.
40
91
  // At each level .rechrome/.env.local is checked before .env.local (rechrome-specific overrides general).
41
92
  export async function loadNearestEnv(extraFallbacks: string[] = []) {
42
- const seen = new Set<string>();
93
+ const seen = new Set<string>(inheritedEnvKeys);
43
94
  const applyFile = async (path: string) => {
44
95
  const raw = await file(path).text().catch(() => "");
45
96
  for (const line of raw.split("\n")) {
@@ -69,13 +120,7 @@ export async function loadNearestEnv(extraFallbacks: string[] = []) {
69
120
  async function loadEnv() {
70
121
  await loadNearestEnv();
71
122
  }
72
- // Shell-set passthrough vars survive .env.local loading
73
- const _shellPassthrough: Record<string, string> = {};
74
- for (const k of ["PLAYWRIGHT_MCP_EXTENSION_ID","PLAYWRIGHT_MCP_EXTENSION_TOKEN","PLAYWRIGHT_MCP_PROFILE_DIRECTORY","PLAYWRIGHT_MCP_USER_DATA_DIR","PLAYWRIGHT_MCP_LOAD_EXTENSION"] as const) {
75
- if (process.env[k]) _shellPassthrough[k] = process.env[k]!;
76
- }
77
123
  await loadEnv();
78
- Object.assign(process.env, _shellPassthrough);
79
124
 
80
125
  import { watch } from "node:fs";
81
126
  const envWatcher = existsSync(envFile)
@@ -147,6 +192,46 @@ function openInChromeProfile(profileDir: string, target: string): boolean {
147
192
  }
148
193
  }
149
194
 
195
+ export async function openSetupGuide(profileDir: string, setupHtmlPath: string): Promise<boolean> {
196
+ // A unique URL identifies the window opened in the requested profile, even if
197
+ // other profiles have an older copy of the setup guide open.
198
+ const guideUrl = /^https?:\/\//.test(setupHtmlPath) ? new URL(setupHtmlPath) : pathToFileURL(setupHtmlPath);
199
+ guideUrl.hash = `setup-${randomBytes(6).toString("hex")}`;
200
+ if (!openInChromeProfile(profileDir, guideUrl.toString())) return false;
201
+ if (process.platform !== "darwin") {
202
+ openInChromeProfile(profileDir, "chrome://extensions/");
203
+ return openInChromeProfile(profileDir, guideUrl.toString());
204
+ }
205
+ // Chrome may discard chrome:// URLs passed on its command line. AppleScript
206
+ // can create an internal-page tab in the specific window containing our guide.
207
+ const script = `on run argv
208
+ repeat 30 times
209
+ tell application "Google Chrome"
210
+ repeat with w in windows
211
+ repeat with tabNumber from 1 to count of tabs of w
212
+ set t to tab tabNumber of w
213
+ if URL of t is item 1 of argv then
214
+ make new tab at end of tabs of w with properties {URL:"chrome://extensions/"}
215
+ make new tab at end of tabs of w with properties {URL:item 1 of argv}
216
+ close t
217
+ set active tab index of w to count of tabs of w
218
+ set index of w to 1
219
+ return "opened"
220
+ end if
221
+ end repeat
222
+ end repeat
223
+ end tell
224
+ delay 0.1
225
+ end repeat
226
+ return "guide not found"
227
+ end run`;
228
+ try {
229
+ const proc = Bun.spawn(["osascript", "-e", script, guideUrl.toString()], { stdout: "pipe", stderr: "ignore" });
230
+ const output = await new Response(proc.stdout).text();
231
+ return await proc.exited === 0 && output.trim() === "opened";
232
+ } catch { return false; }
233
+ }
234
+
150
235
  export function log(msg: string) {
151
236
  mkdirSync(LOG_DIR, { recursive: true });
152
237
  const ts = new Date().toISOString();
@@ -158,33 +243,167 @@ export function log(msg: string) {
158
243
 
159
244
  export function parseUrl(raw: string) {
160
245
  const u = new URL(raw);
246
+ const fragment = new URLSearchParams(u.hash.slice(1).replace(/^\?/, ""));
247
+ const param = (name: string) => fragment.get(name) ?? u.searchParams.get(name) ?? undefined;
161
248
  const scheme = u.protocol.replace(":", "");
162
249
  const protocol = scheme === "https" ? "https" : "http";
163
250
  const defaultPort = scheme === "https" ? 443 : scheme === "http" ? 80 : DEFAULT_PORT;
164
251
  return {
165
- key: u.username,
252
+ key: fragment.get("key") ?? u.username,
253
+ prefix: normalizePrefix(u.pathname),
166
254
  host: u.hostname,
167
255
  port: parseInt(u.port) || defaultPort,
168
256
  protocol,
169
- extensionId: u.searchParams.get("extension_id") ?? undefined,
170
- extensionToken: u.searchParams.get("token") ?? undefined,
171
- profileDirectory: u.searchParams.get("profile") ?? undefined,
172
- userDataDir: u.searchParams.get("user_data_dir") ?? undefined,
173
- loadExtension: u.searchParams.get("load_extension") ?? undefined,
257
+ extensionId: param("extension_id"),
258
+ extensionToken: param("token"),
259
+ profileDirectory: param("profile"),
260
+ userDataDir: param("user_data_dir"),
261
+ loadExtension: param("load_extension"),
174
262
  };
175
263
  }
176
264
 
177
- export async function getOrCreateUrl(): Promise<string> {
265
+ // URLs for registered profiles need only the endpoint, profile selector and daemon
266
+ // key. Bridge credentials and local browser paths stay in the server registry.
267
+ export function registeredProfileUrl(raw: string): string {
268
+ const parsed = parseUrl(raw);
269
+ const url = new URL(serviceUrl(raw));
270
+ if (parsed.profileDirectory) url.searchParams.set("profile", parsed.profileDirectory);
271
+ url.hash = new URLSearchParams({ key: parsed.key }).toString();
272
+ return url.toString();
273
+ }
274
+
275
+ // Setup edits parameters while probing before registration. Fold fragments into
276
+ // the legacy fields first so an old fragment cannot override the new selection.
277
+ function editableConnectionUrl(raw: string): URL {
278
+ const url = new URL(raw);
279
+ const parsed = parseUrl(raw);
280
+ url.username = parsed.key;
281
+ for (const [name, value] of Object.entries({ extension_id: parsed.extensionId, token: parsed.extensionToken, profile: parsed.profileDirectory, user_data_dir: parsed.userDataDir, load_extension: parsed.loadExtension })) {
282
+ if (value !== undefined) url.searchParams.set(name, value);
283
+ }
284
+ url.hash = "";
285
+ return url;
286
+ }
287
+
288
+ export type ListenChoice = { kind: "local" | "lan" | "tailscale" | "other"; address: string; label: string };
289
+
290
+ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfaces>, tailscaleIPs: string[] = []): ListenChoice[] {
291
+ const choices: ListenChoice[] = [{ kind: "local", address: "127.0.0.1", label: "Local — this computer only" }];
292
+ const seen = new Set(["127.0.0.1"]);
293
+ for (const [name, entries] of Object.entries(interfaces).sort(([a], [b]) => a.localeCompare(b))) {
294
+ for (const entry of entries ?? []) {
295
+ const address = entry.address;
296
+ if (entry.internal || !isIPv4(address) || address.startsWith("169.254.") || seen.has(address)) continue;
297
+ seen.add(address);
298
+ const tunnel = /^(utun|tun|tap|wg|zt|tailscale)|wireguard|zerotier|vpn/i.test(name);
299
+ if (tailscaleIPs.includes(address) || /^tailscale/i.test(name)) {
300
+ choices.push({ kind: "tailscale", address, label: `Tailscale — ${name}` });
301
+ } else if (tunnel) {
302
+ const provider = /^wg|wireguard/i.test(name) ? "WireGuard" : /^zt|zerotier/i.test(name) ? "ZeroTier" : "VPN / tunnel";
303
+ choices.push({ kind: "other", address, label: `${provider} — ${name}` });
304
+ } else {
305
+ const local = /^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\./.test(address);
306
+ const virtual = /^(docker|veth|virbr|br-|bridge|vmnet|vbox)/i.test(name);
307
+ choices.push({ kind: local && !virtual ? "lan" : "other", address, label: `${virtual ? "Virtual network" : local ? "LAN" : "Network interface"} — ${name}` });
308
+ }
309
+ }
310
+ }
311
+ const rank = { local: 0, lan: 1, tailscale: 2, other: 3 };
312
+ return choices.sort((a, b) => rank[a.kind] - rank[b.kind]);
313
+ }
314
+
315
+ const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
316
+
317
+ /** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
318
+ async function runTailscale(args: string[]): Promise<string | null> {
319
+ const binary = tailscaleBinary();
320
+ if (!binary) return null;
321
+ try {
322
+ const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "ignore" });
323
+ const timeout = setTimeout(() => proc.kill(), 2000);
324
+ try {
325
+ const output = await new Response(proc.stdout).text();
326
+ return await proc.exited === 0 ? output : null;
327
+ } finally { clearTimeout(timeout); }
328
+ } catch { return null; }
329
+ }
330
+
331
+ export async function detectListenChoices(): Promise<ListenChoice[]> {
332
+ const output = await runTailscale(["ip", "-4"]);
333
+ const ips = output ? output.trim().split(/\s+/).filter(isIPv4) : [];
334
+ return buildListenChoices(networkInterfaces(), ips);
335
+ }
336
+
337
+ export type TailscaleServe = { dnsName: string | null; routeUrl: string | null };
338
+
339
+ /**
340
+ * Find the HTTPS Serve route that forwards `prefix` to a loopback listener on `port`,
341
+ * keeping the prefix (a route that strips it would 404 on the prefixed listener).
342
+ * Prefers this node's own DNS name when several hostnames carry the same route.
343
+ */
344
+ export function findTailscaleServeRoute(dnsName: string | null, serveStatus: unknown, port: number, prefix: string): string | null {
345
+ const web = (serveStatus as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null)?.Web ?? {};
346
+ const mount = prefix.replace(/\/+$/, "");
347
+ const matches: string[] = [];
348
+ for (const [hostPort, config] of Object.entries(web)) {
349
+ for (const [path, handler] of Object.entries(config.Handlers ?? {})) {
350
+ if (path.replace(/\/+$/, "") !== mount || !handler.Proxy) continue;
351
+ let target: URL;
352
+ try { target = new URL(/^[a-z]+:\/\//i.test(handler.Proxy) ? handler.Proxy : `http://${handler.Proxy}`); } catch { continue; }
353
+ if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || Number(target.port) !== port) continue;
354
+ if (target.pathname.replace(/\/+$/, "") !== mount) continue;
355
+ const [host, httpsPort] = hostPort.split(/:(?=\d+$)/);
356
+ matches.push(`https://${host}${httpsPort && httpsPort !== "443" ? `:${httpsPort}` : ""}${prefix}`);
357
+ }
358
+ }
359
+ return matches.find(url => dnsName && new URL(url).hostname === dnsName) ?? matches[0] ?? null;
360
+ }
361
+
362
+ export async function detectTailscaleServe(port: number, prefix: string): Promise<TailscaleServe> {
363
+ const [status, serve] = await Promise.all([runTailscale(["status", "--json"]), runTailscale(["serve", "status", "--json"])]);
364
+ let dnsName: string | null = null;
365
+ let serveStatus: unknown = null;
366
+ try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
367
+ try { serveStatus = JSON.parse(serve ?? "null"); } catch { /* no Serve config */ }
368
+ return { dnsName, routeUrl: findTailscaleServeRoute(dnsName, serveStatus, port, prefix) };
369
+ }
370
+
371
+ /** Remote connection URL for a Serve route, carrying the listener key and profile like the local one. */
372
+ export function tailscaleConnectionUrl(routeUrl: string, localUrl: string): string {
373
+ const local = new URL(localUrl);
374
+ const remote = new URL(routeUrl);
375
+ remote.username = parseUrl(localUrl).key;
376
+ remote.search = local.search;
377
+ return registeredProfileUrl(remote.toString());
378
+ }
379
+
380
+ export function chooseListenAddress(choices: ListenChoice[], selector: string): string {
381
+ const matches = choices.filter(choice => choice.address === selector || choice.kind === selector.toLowerCase());
382
+ if (matches.length === 1) return matches[0].address;
383
+ if (matches.length > 1) throw new Error(`Multiple ${selector} addresses detected. Use --listen with one of: ${matches.map(c => c.address).join(", ")}`);
384
+ throw new Error(`Listen network "${selector}" was not detected. Choose local or a detected interface address.`);
385
+ }
386
+
387
+ export function listenUrl(rawUrl: string, address: string): string {
388
+ const url = new URL(rawUrl);
389
+ const connectAddress = address === "0.0.0.0" ? "127.0.0.1" : address === "::" ? "[::1]" : address;
390
+ url.hostname = connectAddress.includes(":") && !connectAddress.startsWith("[") ? `[${connectAddress}]` : connectAddress;
391
+ return url.toString();
392
+ }
393
+
394
+ export async function getOrCreateUrl(persist = true): Promise<string> {
178
395
  // Treat a URL without a bearer key as missing — it cannot authenticate
179
- try { if (process.env[ENV_KEY] && new URL(process.env[ENV_KEY]!).username) return process.env[ENV_KEY]!; } catch {}
396
+ try { if (process.env[ENV_KEY] && parseUrl(process.env[ENV_KEY]!).key) return process.env[ENV_KEY]!; } catch {}
180
397
  const key = randomBytes(12).toString("base64url"); // 16 chars
181
398
  const url = `http://${key}@127.0.0.1:${DEFAULT_PORT}`;
182
- const newLine = `${ENV_KEY}=${url}`;
183
- // Write to ~/.env.local so it's not shadowed by project .env.local
184
- const envRaw = await file(globalEnvFile).text().catch(() => "");
185
- const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
186
- const content = [...lines, newLine, ""].join("\n");
187
- Bun.write(globalEnvFile, content);
399
+ if (persist) {
400
+ const newLine = `${ENV_KEY}=${url}`;
401
+ // Write to ~/.env.local so it's not shadowed by project .env.local
402
+ const envRaw = await file(globalEnvFile).text().catch(() => "");
403
+ const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
404
+ const content = [...lines, newLine, ""].join("\n");
405
+ await Bun.write(globalEnvFile, content);
406
+ }
188
407
  process.env[ENV_KEY] = url;
189
408
  return url;
190
409
  }
@@ -330,6 +549,22 @@ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?:
330
549
  if (!env["PLAYWRIGHT_MCP_EXTENSION_ID"]) env["PLAYWRIGHT_MCP_EXTENSION_ID"] = entry.extensionId;
331
550
  if (!env["PLAYWRIGHT_MCP_USER_DATA_DIR"] && entry.userDataDir) env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = entry.userDataDir;
332
551
  if (!env["PLAYWRIGHT_MCP_LOAD_EXTENSION"] && entry.loadExtension) env["PLAYWRIGHT_MCP_LOAD_EXTENSION"] = entry.loadExtension;
552
+ // The extension mints a fresh `auth-token` whenever its localStorage is cleared
553
+ // (reinstall, "Load unpacked" from a new path, site-data wipe). A registry token
554
+ // that predates that is silently rejected by connect.html ("Invalid token"), which
555
+ // surfaces only as an extension connection timeout. Re-read the live value from the
556
+ // profile and heal the registry instead of failing. Managed (provisioned) profiles are
557
+ // seeded over CDP, but their localStorage can be re-minted the same way, and they use the
558
+ // same <userDataDir>/<profileDir>/Local Storage layout — so they heal here too.
559
+ if (entry.userDataDir) {
560
+ const profileDir = entry.profileDir; // the folder name ("Profile 2"), not the email key
561
+ const live = profileDir ? readExtensionTokenFromProfile(entry.userDataDir, profileDir, entry.extensionId) : null;
562
+ if (live && live !== entry.token) {
563
+ console.error(`[rech] extension token for "${profileKey}" changed — refreshing registry`);
564
+ entry.token = live;
565
+ await saveTokenEntry(profileKey, entry);
566
+ }
567
+ }
333
568
  if (!env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"]) {
334
569
  env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = entry.token;
335
570
  } else if (env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] !== entry.token) {
@@ -383,7 +618,7 @@ export function resolveChromeProfileSelector(
383
618
  if (matches.length > 1) {
384
619
  throw new Error(
385
620
  `--profile "${value}" matches multiple profiles by ${kind.label}. ` +
386
- `Use a unique email or profile folder name from \`rech profiles\`.`,
621
+ `Use a unique email or profile folder name from \`rech profile\`.`,
387
622
  );
388
623
  }
389
624
  if (matches.length === 1) return matches[0];
@@ -396,7 +631,7 @@ export function validateChromeProfileSelector(selector: string): void {
396
631
  if (!/^\d+$/.test(value)) return;
397
632
  throw new Error(
398
633
  `--profile no longer accepts menu numbers (received "${value}"). ` +
399
- `Use the profile email, Chrome profile name, or profile folder name from \`rech profiles\`.`,
634
+ `Use the profile email, Chrome profile name, or profile folder name from \`rech profile\`.`,
400
635
  );
401
636
  }
402
637
 
@@ -429,12 +664,12 @@ export async function resolveGlobalProfile(
429
664
  if (!match) {
430
665
  throw new Error(
431
666
  `--profile "${value}" does not match any Chrome profile. ` +
432
- `See available profiles with \`rech profiles\`.`,
667
+ `See available profiles with \`rech profile\`.`,
433
668
  );
434
669
  }
435
670
 
436
671
  const [dir, info] = match;
437
- const email = info.user_name;
672
+ const email = info.user_name || (registry[dir]?.profileDir === dir && !registry[dir].loadExtension ? dir : undefined);
438
673
  if (!email) {
439
674
  throw new Error(
440
675
  `Chrome profile "${value}" (folder: ${dir}) has no email associated. ` +
@@ -557,34 +792,93 @@ async function resolveProfileEmail(dir: string): Promise<string> {
557
792
  return dir;
558
793
  }
559
794
 
560
- async function listProfiles(): Promise<void> {
561
- const cache = await readChromeProfileCache();
562
- if (!cache) { console.error("Chrome Local State not found"); process.exit(1); }
563
-
564
- const current = process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
565
- // Resolve email/name → dir for current marker
566
- let currentDir = current;
567
- if (current && !/^(Default|Profile \d+)$/i.test(current)) {
568
- for (const [dir, info] of Object.entries(cache)) {
569
- if (info.user_name === current || info.name === current) { currentDir = dir; break; }
570
- }
795
+ export function buildProfileRows(cache: Record<string, ChromeProfileInfo> | null, registry: Record<string, TokenEntry>, chromeRoot: string | null) {
796
+ const entries = Object.entries(registry);
797
+ const used = new Set<string>();
798
+ const rows = Object.entries(cache ?? {}).map(([dir, info]) => {
799
+ const registrations = entries.filter(([, e]) => e.profileDir === dir && !e.loadExtension && (!e.userDataDir || e.userDataDir === chromeRoot));
800
+ registrations.forEach(([key]) => used.add(key));
801
+ return { selector: registrations[0]?.[0] || info.user_name || dir, name: info.name || "", email: info.user_name || "", dir,
802
+ kind: "Chrome", registered: registrations.length > 0 };
803
+ });
804
+ for (const [key, entry] of entries) {
805
+ if (used.has(key)) continue;
806
+ rows.push({ selector: key, name: key, email: "", dir: entry.profileDir,
807
+ kind: entry.loadExtension ? "Managed test" : "Registered", registered: true });
571
808
  }
809
+ return rows;
810
+ }
572
811
 
573
- // Columns mirror selector precedence. Only user_name (email) + name (profile name) are read
574
- // from Local State — the gaia real name is deliberately never surfaced.
575
- const rows = [
576
- ["EMAIL", "PROFILE NAME", "FOLDER", ""],
577
- ...Object.entries(cache).map(([dir, info]) => [
578
- info.user_name || "",
579
- info.name || "",
580
- dir,
581
- dir === currentDir ? "← current" : "",
582
- ]),
583
- ];
812
+ async function listProfiles(): Promise<void> {
813
+ const [cache, registry, root] = await Promise.all([readChromeProfileCache(), readTokenRegistry(), findChromeUserDataDir()]);
814
+ const profiles = buildProfileRows(cache, registry, root);
815
+ const listeners = await readListeners();
816
+ const url = process.env[ENV_KEY];
817
+ const current = url ? resolveEffectiveProfile(parseUrl(url).profileDirectory) : process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
818
+ const identity = await getClientIdentity();
819
+ const states = await Promise.all(profiles.map(async p => {
820
+ if (!p.registered) return "Not set up";
821
+ if (!url) return "Registered / unknown";
822
+ try {
823
+ const { key, protocol, host, port } = parseUrl(url);
824
+ // tab-list only inspects this worktree's existing session; never opens Chrome.
825
+ const response = await fetch(serviceUrl(url, "run"), {
826
+ method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
827
+ body: JSON.stringify({ args: ["tab-list"], identity: { ...identity, profile: p.selector }, env: {} }),
828
+ signal: AbortSignal.timeout(5000),
829
+ });
830
+ if (!response.ok) return "Registered / unknown";
831
+ const result = await response.json() as { status: number; stdout: string; stderr: string };
832
+ if (result.status === 0 && !/### Error|not open|not connected/i.test(result.stdout + result.stderr)) return "Connected";
833
+ if (/not open|not connected/i.test(result.stdout + result.stderr)) return "Registered / idle";
834
+ return "Registered / unknown";
835
+ } catch { return "Registered / unknown"; }
836
+ }));
837
+ const rows = [["TYPE", "EMAIL / SELECTOR", "PROFILE NAME", "FOLDER", "CONNECTION", "ACCESS", ""], ...profiles.map((p, i) => [
838
+ p.kind, p.selector, p.name, p.dir, states[i],
839
+ listeners ? listeners.listeners.filter(l => l.profiles === "*" || l.profiles.includes(p.selector)).map(l => (listenerAddress(l) + normalizePrefix(l.prefix))).join(", ") || "none" : "legacy daemon",
840
+ current && [p.selector, p.dir, p.name, p.email].some(v => v.toLowerCase() === current.toLowerCase()) ? "← current" : "",
841
+ ])];
584
842
  const widths = rows.reduce((w, r) => r.map((c, i) => Math.max(w[i] ?? 0, c.length)), [] as number[]);
585
- for (const row of rows) {
586
- console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
843
+ for (const row of rows) console.log(row.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
844
+ console.log("\nConnection checks this worktree's default session; other sessions may also be open.");
845
+ }
846
+
847
+ export function profileConnectionUri(profile: string, configuredUrl: string | undefined, listeners: Listener[], listenerName?: string): string {
848
+ let url = configuredUrl;
849
+ if (listenerName || !url) {
850
+ const candidates = listeners.filter(l => (l.profiles === "*" || l.profiles.includes(profile)) && (!listenerName || l.name === listenerName));
851
+ if (candidates.length !== 1) throw new Error("Choose a listener with --listener <name>, or set RECHROME_URL to the desired endpoint.");
852
+ const listener = candidates[0];
853
+ url = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}`;
587
854
  }
855
+ const parsed = parseUrl(url);
856
+ if (!parsed.key) throw new Error("The connection URL has no daemon key. Run rech setup or select --listener <name>.");
857
+ const listener = listeners.find(l => l.key === parsed.key);
858
+ if (listener && listener.profiles !== "*" && !listener.profiles.includes(profile)) throw new Error(`This listener does not allow profile "${profile}". Choose a different --listener.`);
859
+ const result = new URL(serviceUrl(url));
860
+ result.searchParams.set("profile", profile);
861
+ result.hash = new URLSearchParams({ key: parsed.key }).toString();
862
+ return result.toString();
863
+ }
864
+
865
+ async function printProfileUri(selector?: string, listener?: string): Promise<void> {
866
+ const url = process.env[ENV_KEY];
867
+ selector ??= url ? parseUrl(url).profileDirectory : undefined;
868
+ if (!selector) throw new Error("Specify a profile: rech profile <name> --print-uri");
869
+ const registry = await readTokenRegistry();
870
+ const cache = await readChromeProfileCache();
871
+ // A configured remote profile may not exist in this machine's local registry.
872
+ const profile = url && parseUrl(url).profileDirectory === selector && !listener
873
+ ? selector : (await resolveGlobalProfile(registry, cache, selector)).email;
874
+ console.log(profileConnectionUri(profile, url, (await readListeners())?.listeners ?? [], listener));
875
+ }
876
+
877
+ export function sandboxConnectionWarning(env: Record<string, string | undefined> = process.env): string | null {
878
+ if (env.CODEX_SANDBOX_NETWORK_DISABLED !== "1" && !env.CODEX_SANDBOX) return null;
879
+ return "[rech] warning: running inside a sandbox; network restrictions may block access to the rechrome daemon, even on localhost. " +
880
+ "Retry this command outside the sandbox (with approval in your coding agent). " +
881
+ "If it still fails, check that the daemon is running and the host/port are correct.";
588
882
  }
589
883
 
590
884
  async function callServe(
@@ -592,6 +886,7 @@ async function callServe(
592
886
  args: string[],
593
887
  overrideEnv?: Record<string, string>,
594
888
  precomputedIdentity?: { key: string; label: string; profile?: string },
889
+ throwOnFailure = false,
595
890
  ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
596
891
  const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
597
892
  // Reuse the caller's identity when provided — computing it shells out to `git` several times,
@@ -603,14 +898,17 @@ async function callServe(
603
898
  // reuse the default profile's session (and its browser) instead of opening its own.
604
899
  const effectiveProfile = overrideEnv?.["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] || resolveEffectiveProfile(profileDirectory);
605
900
  if (effectiveProfile) identity.profile = effectiveProfile;
606
- const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
607
- const res = await fetch(`${protocol}://${host}:${port}/run`, {
901
+ const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension })), ...overrideEnv };
902
+ const res = await fetch(serviceUrl(url, "run"), {
608
903
  method: "POST",
609
904
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
610
905
  body: JSON.stringify({ args, identity, env }),
611
906
  signal: AbortSignal.timeout(70_000),
612
907
  }).catch(async (e) => {
908
+ if (throwOnFailure) throw new Error("Cannot reach the rechrome daemon. Check that it is running.");
613
909
  console.error(`[rech] ${e.message}`);
910
+ const sandboxWarning = sandboxConnectionWarning();
911
+ if (sandboxWarning) console.error(sandboxWarning);
614
912
  const dnsResult = await import("dns/promises").then(m => m.lookup(host)).catch(() => null);
615
913
  if (!dnsResult) {
616
914
  console.error(`[rech] rech-client\n -x: DNS failed -> ${host}[unknown] -> rech-server[unknown]`);
@@ -632,6 +930,7 @@ async function callServe(
632
930
  process.exit(1);
633
931
  });
634
932
  if (res.status === 401) {
933
+ if (throwOnFailure) throw new Error("The daemon rejected its connection key. Run setup again to refresh it.");
635
934
  console.error(`[rech] rech-client -> rech-server[ok]\n -x: bearer key rejected (used: ${key.slice(0, 4)}...) -> playwright[unknown]`);
636
935
  process.exit(1);
637
936
  }
@@ -688,7 +987,7 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
688
987
  `[rech] connecting to ${host}:${port} (identity: ${identity.label}${profileSuffix})`,
689
988
  );
690
989
 
691
- const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
990
+ const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory: effectiveProfile, userDataDir, loadExtension });
692
991
  const effectiveEnv = { ...resolvedEnv, ...overrideEnv };
693
992
  const { status, stdout, stderr, files, existingSession } = await callServe(url, args, overrideEnv, identity);
694
993
 
@@ -724,7 +1023,7 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
724
1023
  const gitignorePath = join(dlDir, ".gitignore");
725
1024
  if (!existsSync(gitignorePath)) await Bun.write(gitignorePath, "*\n");
726
1025
  for (const name of files) {
727
- const fileRes = await fetch(`${protocol}://${host}:${port}/files/${name}`, {
1026
+ const fileRes = await fetch(serviceUrl(url, `files/${name}`), {
728
1027
  headers: { Authorization: `Bearer ${parseUrl(url).key}` },
729
1028
  });
730
1029
  if (!fileRes.ok) continue;
@@ -737,60 +1036,214 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
737
1036
  process.exit(status);
738
1037
  }
739
1038
 
740
- export function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
1039
+ export type SetupPhase = "extension" | "token" | "bridge" | "error" | "save" | "ready";
1040
+
1041
+ export function createSetupGuide(extDistDir: string, profileDisplay: string) {
1042
+ const route = `/setup/${randomBytes(24).toString("hex")}`;
1043
+ let state: { phase: SetupPhase; message: string; statusUrl?: string } = { phase: "extension", message: "Waiting for the extension in this profile…" };
1044
+ let manualToken: string | undefined;
1045
+ let retry = false;
1046
+ let readyDelivered = false;
1047
+ let checks = { extension: false, token: false, bridge: false, registration: false };
1048
+ const server = Bun.serve({
1049
+ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 4096,
1050
+ async fetch(request) {
1051
+ const url = new URL(request.url);
1052
+ const headers = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
1053
+ if (url.host !== `127.0.0.1:${server.port}`) return new Response("Invalid host", { status: 403 });
1054
+ if (url.pathname === route && request.method === "GET")
1055
+ return new Response(buildSetupHtml(extDistDir, profileDisplay, `${route}/status`), { headers: { ...headers, "Content-Type": "text/html; charset=utf-8" } });
1056
+ if (url.pathname !== `${route}/status`) return new Response("Not found", { status: 404 });
1057
+ if (request.method === "POST") {
1058
+ if (request.headers.get("Origin") !== url.origin) return new Response("Invalid origin", { status: 403 });
1059
+ if (Number(request.headers.get("Content-Length")) > 4096) return new Response("Too large", { status: 413 });
1060
+ try {
1061
+ const body = await request.json() as { token?: string; retry?: boolean };
1062
+ if (body.token !== undefined) {
1063
+ const value = body.token.replace(/^PLAYWRIGHT_MCP_EXTENSION_TOKEN=/, "").trim();
1064
+ if (!/^[A-Za-z0-9_-]{20,256}$/.test(value)) return new Response("Invalid token", { status: 400 });
1065
+ manualToken = value;
1066
+ }
1067
+ if (body.retry || body.token) retry = true;
1068
+ } catch { return new Response("Invalid request", { status: 400 }); }
1069
+ } else if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
1070
+ if (state.phase === "ready") readyDelivered = true;
1071
+ return Response.json({ ...state, checks }, { headers });
1072
+ },
1073
+ });
1074
+ return {
1075
+ url: `http://127.0.0.1:${server.port}${route}`,
1076
+ update(phase: SetupPhase, message: string) {
1077
+ state = { ...state, phase, message };
1078
+ if (phase !== "error") {
1079
+ const rank = ["extension", "token", "bridge", "save", "ready"].indexOf(phase);
1080
+ checks = { extension: rank >= 1, token: rank >= 2, bridge: rank >= 3, registration: rank >= 4 };
1081
+ }
1082
+ },
1083
+ setExtensionId(id: string) {
1084
+ if (!/^[a-p]{32}$/.test(id)) throw new Error("Invalid extension ID");
1085
+ state.statusUrl = `chrome-extension://${id}/status.html`;
1086
+ },
1087
+ takeToken() { const value = manualToken; manualToken = undefined; return value; },
1088
+ markRegistered() { checks.registration = true; },
1089
+ takeRetry() { const value = retry; retry = false; return value; },
1090
+ async deliverSuccess() {
1091
+ const deadline = Date.now() + 10_000;
1092
+ while (!readyDelivered && Date.now() < deadline) await Bun.sleep(100);
1093
+ await Bun.sleep(250);
1094
+ },
1095
+ close() { manualToken = undefined; server.stop(); },
1096
+ };
1097
+ }
1098
+
1099
+ export function buildSetupHtml(extDistDir: string, profileDisplay: string, checkUrl?: string): string {
1100
+ const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({
1101
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
1102
+ })[char]!);
741
1103
  return `<!DOCTYPE html>
742
1104
  <html lang="en">
743
1105
  <head>
744
1106
  <meta charset="UTF-8">
1107
+ <meta name="viewport" content="width=device-width, initial-scale=1">
745
1108
  <title>rechrome — Extension Setup</title>
746
1109
  <style>
747
- body { font-family: system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; color: #222; }
748
- h1 { color: #1a73e8; }
749
- .step { background: #f8f9fa; border-left: 4px solid #1a73e8; padding: 12px 16px; margin: 16px 0; border-radius: 0 8px 8px 0; }
750
- .step h3 { margin: 0 0 8px; }
1110
+ body { font-family: system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; color: #222; line-height: 1.6; }
1111
+ h1, a { color: #1a73e8; }
1112
+ .step { background: #f8f9fa; border-left: 4px solid #1a73e8; padding: 16px; margin: 16px 0; border-radius: 0 8px 8px 0; }
1113
+ .step h2 { margin: 0 0 8px; font-size: 1.2rem; }
751
1114
  code { background: #e8eaed; padding: 2px 6px; border-radius: 4px; font-size: 0.95em; word-break: break-all; }
752
- .path { display: flex; align-items: center; gap: 8px; }
753
- button { background: #1a73e8; color: white; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.9em; }
754
- button:active { background: #1558b0; }
1115
+ .path { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
1116
+ button, .button { display: inline-block; background: #1a73e8; color: white; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; font: inherit; text-decoration: none; }
1117
+ button:active, .button:active { background: #1558b0; }
755
1118
  .note { color: #666; font-size: 0.9em; }
1119
+ details.step > summary { cursor: pointer; font-size: 1.2rem; font-weight: 600; }
1120
+ details.step[data-complete="true"] > summary { color: #28743e; }
1121
+ details.step[data-complete="true"] > summary::after { content: " ✓"; }
756
1122
  </style>
757
1123
  </head>
758
1124
  <body>
759
1125
  <h1>rechrome — Extension Setup</h1>
760
- <p>Install the multi-tab extension in Chrome profile: <strong>${profileDisplay}</strong></p>
761
-
762
- <div class="step">
763
- <h3>Step 1 — Open Chrome Extensions</h3>
764
- <p>In the Chrome profile <strong>${profileDisplay}</strong>, navigate to:</p>
765
- <code>chrome://extensions/</code>
766
- <p class="note">Make sure you are in the correct profile (check the avatar in the top-right corner).</p>
767
- </div>
768
-
769
- <div class="step">
770
- <h3>Step 2 — Enable Developer Mode</h3>
771
- <p>Toggle <strong>Developer mode</strong> on (top-right of the extensions page).</p>
772
- </div>
773
-
774
- <div class="step">
775
- <h3>Step 3 — Load the extension</h3>
776
- <p>Click <strong>Load unpacked</strong> and select this directory:</p>
777
- <div class="path">
778
- <code id="extPath">${extDistDir}</code>
779
- <button onclick="navigator.clipboard.writeText(document.getElementById('extPath').textContent).then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy path',1500)})">Copy path</button>
780
- </div>
781
- </div>
782
-
783
- <div class="step">
784
- <h3>Step 4 — Return to terminal</h3>
785
- <p>Press <strong>Enter</strong> in the terminal to continue setup.</p>
786
- </div>
787
-
788
- <div class="step">
789
- <h3>Step 5 — Copy auth token</h3>
790
- <p>Click the extension icon in the Chrome toolbar (or open the URL below):</p>
791
- <code id="statusUrl">chrome-extension://(detected after install)/status.html</code>
792
- <p>The page shows <strong>PLAYWRIGHT_MCP_EXTENSION_TOKEN=...</strong> — paste that into the terminal when prompted.</p>
793
- </div>
1126
+ <p>Chrome profile: <strong>${escapeHtml(profileDisplay)}</strong></p>
1127
+ <p>The previous tab is <strong>Chrome Extensions</strong>. Keep this guide open while you install.</p>
1128
+ ${checkUrl ? '<div class="step"><h2>Connection status</h2><p id="liveStatus" role="status" aria-live="polite">Connecting to setup…</p><ul id="verifiedChecks"><li>○ Extension detected — pending</li><li>○ Token detected — pending</li><li>○ Browser connection verified — pending</li><li>○ Profile registered — pending</li></ul></div>' : ''}
1129
+
1130
+ <main id="start">
1131
+ <details class="step" data-check="extension" open>
1132
+ <summary>Step 1 — Install the extension</summary>
1133
+ <p>Copy the extension path:</p>
1134
+ <div class="path">
1135
+ <code id="extPath">${escapeHtml(extDistDir)}</code>
1136
+ <button type="button" id="copyPath">Copy path</button>
1137
+ </div>
1138
+ <p id="copyStatus" class="note" role="status" aria-live="polite"></p>
1139
+ <ol>
1140
+ <li>Switch to the <strong>Extensions</strong> tab immediately to the left, or press <strong>Ctrl+Shift+Tab</strong>.</li>
1141
+ <li>Enable <strong>Developer mode</strong>, then click <strong>Load unpacked</strong>.</li>
1142
+ <li>Paste the path into the folder picker and select the folder. On macOS, press <strong>⌘⇧G</strong>, paste, press <strong>Enter</strong>, then click <strong>Select</strong>. On Windows or Linux, use the location field.</li>
1143
+ <li>Return to this guide. Installation is detected automatically.</li>
1144
+ </ol>
1145
+ <p class="note">If the Extensions tab did not open, type <code>chrome://extensions/</code> into a new tab’s address bar and press Enter.</p>
1146
+ </details>
1147
+
1148
+ <details class="step" data-check="bridge" open>
1149
+ <summary>Step 2 — Verify the connection</summary>
1150
+ <p>Switch back here. Setup watches for the extension and token automatically, then tests the connection.</p>
1151
+ ${checkUrl ? `<button type="button" id="revalidate" data-url="${escapeHtml(checkUrl)}">Revalidate installation</button>` : '<p>Run your setup command again in the terminal to revalidate the installation.</p>'}
1152
+ <p id="validationStatus" role="status" aria-live="polite"></p>
1153
+ <p class="note">After installation is detected, setup continues in the terminal to read the token and verify the browser connection.</p>
1154
+ <p><a id="extensionStatusLink" href="chrome-extension://${EXTENSION_ID}/status.html" target="_blank" rel="noopener">Open extension status ↗</a></p>
1155
+ <p class="note">If Chrome blocks the link, copy this URL into the address bar of a new tab:</p>
1156
+ <div class="path"><code id="extensionStatusUrl">chrome-extension://${EXTENSION_ID}/status.html</code><button type="button" id="copyStatusUrl">Copy status URL</button></div>
1157
+ ${checkUrl ? `<details><summary>Token not detected? Paste it manually</summary><p>Open the extension status page above, copy its auth token, and paste it here.</p><input id="manualToken" type="password" autocomplete="off" aria-label="Extension auth token"><button type="button" id="submitToken">Use token</button></details>` : ''}
1158
+ </details>
1159
+
1160
+ <details class="step" data-check="registration" open>
1161
+ <summary>Step 3 — Finish setup</summary>
1162
+ <p>Once the browser connection is verified, return to the terminal and choose where to save your connection URL. You can also skip saving the URL and just register the profile.</p>
1163
+ <p>This guide confirms when the profile is registered. You can then close the setup tabs.</p>
1164
+ </details>
1165
+ </main>
1166
+ <script>
1167
+ const checkButton = document.getElementById('revalidate');
1168
+ let finished = false;
1169
+ async function checkSetup(payload) {
1170
+ const status = document.getElementById('validationStatus');
1171
+ try {
1172
+ const response = await fetch(checkButton.dataset.url, payload ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) } : {});
1173
+ if (response.status === 400) {
1174
+ status.textContent = 'That token looks incomplete. Copy the full token from the extension and try again.';
1175
+ return;
1176
+ }
1177
+ if (!response.ok) throw new Error('Check failed');
1178
+ const result = await response.json();
1179
+ document.getElementById('liveStatus').textContent = result.message;
1180
+ const checks = document.getElementById('verifiedChecks');
1181
+ checks.replaceChildren(...Object.entries({ extension: 'Extension detected', token: 'Token detected', bridge: 'Browser connection verified', registration: 'Profile registered' }).map(([key, label]) => {
1182
+ const item = document.createElement('li');
1183
+ item.textContent = (result.checks?.[key] ? '✓ ' : '○ ') + label + (result.checks?.[key] ? ' — verified' : ' — pending');
1184
+ return item;
1185
+ }));
1186
+ for (const step of document.querySelectorAll('details.step[data-check]')) {
1187
+ const complete = !!result.checks?.[step.dataset.check];
1188
+ // Collapse only on completion, preserving manual expansion on later polls.
1189
+ if (complete && step.dataset.complete !== 'true') step.open = false;
1190
+ if (!complete && step.dataset.complete === 'true') step.open = true;
1191
+ step.dataset.complete = String(complete);
1192
+ }
1193
+ if (result.statusUrl) {
1194
+ document.getElementById('extensionStatusLink').href = result.statusUrl;
1195
+ document.getElementById('extensionStatusUrl').textContent = result.statusUrl;
1196
+ }
1197
+ checkButton.textContent = result.phase === 'error' ? 'Retry connection' : 'Revalidate installation';
1198
+ finished = result.phase === 'ready';
1199
+ checkButton.disabled = finished;
1200
+ status.textContent = finished ? 'Setup complete. You can close these setup tabs.' : '';
1201
+ } catch {
1202
+ status.textContent = 'Cannot reach the setup process. This page works while rech setup is running, including with piped input. If setup was stopped or finished, run it again and use the new guide tab. Retrying…';
1203
+ }
1204
+ }
1205
+ checkButton?.addEventListener('click', () => checkSetup({ retry: true }));
1206
+ document.getElementById('submitToken')?.addEventListener('click', async () => {
1207
+ const input = document.getElementById('manualToken');
1208
+ const token = input.value;
1209
+ input.value = '';
1210
+ await checkSetup({ token });
1211
+ });
1212
+ if (checkButton) {
1213
+ const poll = async () => { await checkSetup(); if (!finished) setTimeout(poll, 1000); };
1214
+ poll();
1215
+ }
1216
+ document.getElementById('copyStatusUrl').addEventListener('click', async function () {
1217
+ const url = document.getElementById('extensionStatusUrl');
1218
+ try {
1219
+ await navigator.clipboard.writeText(url.textContent);
1220
+ this.textContent = 'Copied!';
1221
+ } catch {
1222
+ const range = document.createRange();
1223
+ range.selectNodeContents(url);
1224
+ const selection = window.getSelection();
1225
+ selection.removeAllRanges();
1226
+ selection.addRange(range);
1227
+ this.textContent = 'Press ⌘C / Ctrl+C';
1228
+ }
1229
+ });
1230
+ document.getElementById('copyPath').addEventListener('click', async function () {
1231
+ const path = document.getElementById('extPath');
1232
+ const status = document.getElementById('copyStatus');
1233
+ try {
1234
+ await navigator.clipboard.writeText(path.textContent);
1235
+ this.textContent = 'Copied!';
1236
+ status.textContent = 'Path copied. Switch to the Extensions tab.';
1237
+ } catch {
1238
+ const range = document.createRange();
1239
+ range.selectNodeContents(path);
1240
+ const selection = window.getSelection();
1241
+ selection.removeAllRanges();
1242
+ selection.addRange(range);
1243
+ status.textContent = 'Press ⌘C on Mac or Ctrl+C on Windows/Linux to copy the selected path, then switch to the Extensions tab.';
1244
+ }
1245
+ });
1246
+ </script>
794
1247
  </body>
795
1248
  </html>`;
796
1249
  }
@@ -897,14 +1350,44 @@ export function resolvePlaywrightCli(): string {
897
1350
  return "playwright-cli-multi-tab";
898
1351
  }
899
1352
 
1353
+ /**
1354
+ * Make sure a daemon process manager is available, offering to install oxmgr
1355
+ * (default No; --yes approves). An explicit RECH_DAEMON_MANAGER=pm2 is respected,
1356
+ * since installing oxmgr would not satisfy it.
1357
+ */
1358
+ async function ensureDaemonManager(ask: (q: string, def?: string) => Promise<string>, yes = false): Promise<void> {
1359
+ try {
1360
+ daemonManager();
1361
+ return;
1362
+ } catch (error) {
1363
+ if (process.env.RECH_DAEMON_MANAGER?.toLowerCase() === "pm2") throw error;
1364
+ }
1365
+ const command = oxmgrInstallCommand(process.env);
1366
+ const answer = yes ? "yes" : (await ask(` oxmgr is missing. Install globally with \`${command.join(" ")}\`? [y/N]: `)).trim();
1367
+ if (!/^(y|yes)$/i.test(answer)) {
1368
+ throw new Error(`Setup cancelled. To install oxmgr, run \`${command.join(" ")}\`, then rerun setup.`);
1369
+ }
1370
+ console.log(` Installing oxmgr: ${command.join(" ")}`);
1371
+ const installer = Bun.which(command[0]) ?? (command[0] === "bun" ? process.execPath : null);
1372
+ if (!installer) throw new Error(`${command[0]} is not on PATH. Install it or run \`${command.join(" ")}\` in your terminal, then rerun setup.`);
1373
+ const proc = Bun.spawn([installer, ...command.slice(1)], {
1374
+ stdin: "inherit", stdout: "inherit", stderr: "inherit", windowsHide: true,
1375
+ });
1376
+ const code = await proc.exited;
1377
+ if (code !== 0) throw new Error(`\`${command.join(" ")}\` failed (exit code ${code}). Resolve the installation error, then rerun setup.`);
1378
+ if (!Bun.which("oxmgr")) {
1379
+ throw new Error("oxmgr was installed but is not on PATH. Add the package manager's global bin directory to PATH, then rerun setup.");
1380
+ }
1381
+ _daemonMgr = undefined;
1382
+ _oxmgrVersion = undefined;
1383
+ daemonManager();
1384
+ }
1385
+
900
1386
  export async function daemonInstall(serveUrl: string): Promise<void> {
901
- // Persist the URL to ~/.env.local before starting the daemon. The daemon's
902
- // loadEnv() walks CWD→root reading .env.local files and unconditionally
903
- // overwrites process.env.RECHROME_URL from whichever file it finds first.
904
- // Without this write, oxmgr's --env RECHROME_URL=... gets clobbered by a
905
- // stale ~/.env.local entry — the daemon then listens on a different bearer
906
- // key than the one daemonInstall was called with, and every client request
907
- // is rejected with "bearer key rejected".
1387
+ // Resolve the manager first so a missing dependency fails before config is mutated.
1388
+ const mgr = daemonManager();
1389
+ // Persist the URL for future clients without an explicit environment override.
1390
+ // The daemon's explicit environment takes precedence over this saved default.
908
1391
  const envRaw = await file(globalEnvFile).text().catch(() => "");
909
1392
  const filtered = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
910
1393
  await Bun.write(globalEnvFile, [...filtered, `${ENV_KEY}=${serveUrl}`, ""].join("\n"));
@@ -934,8 +1417,6 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
934
1417
  if (isReadable(process.env.RECH_TLS_CERT)) daemonEnv.RECH_TLS_CERT = process.env.RECH_TLS_CERT!;
935
1418
  if (isReadable(process.env.RECH_TLS_KEY)) daemonEnv.RECH_TLS_KEY = process.env.RECH_TLS_KEY!;
936
1419
 
937
- const mgr = daemonManager();
938
-
939
1420
  // Drop any prior registration (current + legacy names) before re-adding.
940
1421
  for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(mgr, ["delete", name]);
941
1422
 
@@ -1106,40 +1587,6 @@ async function trayCommand(sub?: string): Promise<void> {
1106
1587
  }
1107
1588
  }
1108
1589
 
1109
- // Read the extension's auth token straight from a profile's localStorage LevelDB. Read-only
1110
- // (we never take LevelDB's lock), so it's safe while the user's Chrome is running. The token is
1111
- // the value of the `auth-token` key under the extension origin, stored as a 0x01 (Latin-1)
1112
- // encoding byte followed by the 43-char base64url token. LevelDB prefix-compression can split the
1113
- // origin string across block-restart points, so we anchor on the `auth-token` marker + token shape
1114
- // and (when possible) require the extension id to appear in the same file to avoid a collision
1115
- // with another extension's `auth-token`. Returns the newest token found, or null.
1116
- function readExtensionTokenFromProfile(userDataDir: string, profileDir: string): string | null {
1117
- const dir = join(userDataDir, profileDir, "Local Storage", "leveldb");
1118
- let files: string[];
1119
- try { files = readdirSync(dir).filter(f => f.endsWith(".ldb") || f.endsWith(".log")).sort(); }
1120
- catch { return null; }
1121
- const extIdChunk = EXTENSION_ID.slice(0, 20); // contiguous prefix survives the LevelDB split
1122
- const scan = (requireExtId: boolean): string | null => {
1123
- let found: string | null = null;
1124
- for (const f of files) {
1125
- let buf: Buffer;
1126
- try { buf = readFileSync(join(dir, f)); } catch { continue; }
1127
- if (requireExtId && !buf.includes(extIdChunk, 0, "latin1")) continue;
1128
- let idx = 0;
1129
- while (true) {
1130
- const j = buf.indexOf("auth-token", idx, "latin1");
1131
- if (j < 0) break;
1132
- idx = j + 1;
1133
- const win = buf.subarray(j, Math.min(buf.length, j + 200)).toString("latin1");
1134
- const m = win.match(/\x01([A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/);
1135
- if (m) found = m[1]; // newest file / newest occurrence wins
1136
- }
1137
- }
1138
- return found;
1139
- };
1140
- return scan(true) ?? scan(false);
1141
- }
1142
-
1143
1590
  // Resolve a Chromium / Chrome-for-Testing executable from the Playwright browsers cache.
1144
1591
  // Managed (provisioned) profiles must run on Chromium because branded Google Chrome 149+ rejects
1145
1592
  // --load-extension. Returns null if no Chromium is installed (`npx playwright install chromium`).
@@ -1296,19 +1743,21 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
1296
1743
  console.log(`\n[2/3] Building RECHROME_URL`);
1297
1744
  const url = await getOrCreateUrl();
1298
1745
  const { host, port, protocol, key } = parseUrl(url);
1299
- const healthy = await fetch(`${protocol}://${host}:${port}/ping`, {
1746
+ const healthy = await fetch(serviceUrl(url, "ping"), {
1300
1747
  headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(2000),
1301
1748
  }).then(r => r.ok).catch(() => false);
1302
1749
  if (!healthy) console.log(` Note: daemon not reachable at ${host}:${port} — run \`rech setup\` once to start it.`);
1303
1750
 
1304
- const rechUrl = new URL(`${protocol}://${host}:${port}`);
1751
+ const rechUrl = new URL(serviceUrl(url));
1305
1752
  rechUrl.username = key || randomBytes(12).toString("base64url");
1306
1753
  rechUrl.searchParams.set("extension_id", EXTENSION_ID);
1307
1754
  rechUrl.searchParams.set("token", token);
1308
1755
  rechUrl.searchParams.set("profile", name);
1309
1756
  rechUrl.searchParams.set("user_data_dir", userDataDir);
1310
1757
  rechUrl.searchParams.set("load_extension", dist);
1311
- const newLine = `RECHROME_URL=${rechUrl.toString()}`;
1758
+
1759
+
1760
+ const newLine = `RECHROME_URL=${registeredProfileUrl(rechUrl.toString())}`;
1312
1761
 
1313
1762
  // [3/3] Register in the token registry so `rech status` lists it and the daemon can resolve it.
1314
1763
  await saveTokenEntry(name, { extensionId: EXTENSION_ID, token, profileDir: name, userDataDir, loadExtension: dist });
@@ -1319,7 +1768,94 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
1319
1768
  console.log(`Or save it to a project .env.local to make it the default.`);
1320
1769
  }
1321
1770
 
1322
- async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
1771
+ async function exposeProfile(profile: string, host: string, port: number, prefix = "/"): Promise<Listener> {
1772
+ const config = await readListeners();
1773
+ if (!config) throw new Error("Run rech setup once to initialize listeners");
1774
+ // Setup selects this profile's exposure, not the daemon's global bind. Remove
1775
+ // only this profile from other scoped listeners; retain all other profiles.
1776
+ config.listeners = config.listeners.filter(l => {
1777
+ if (l.profiles === "*" || (l.host === host && l.port === port)) return true;
1778
+ l.profiles = l.profiles.filter(p => p !== profile);
1779
+ return l.profiles.length > 0;
1780
+ });
1781
+ let listener = config.listeners.find(l => l.host === host && l.port === port);
1782
+ if (listener && normalizePrefix(listener.prefix) !== prefix) throw new Error("This port already uses a different prefix; choose --port with a separate port");
1783
+ if (prefix !== "/" && listener?.profiles === "*") throw new Error("A prefixed proxy requires a separate scoped listener port");
1784
+ if (listener) {
1785
+ if (listener.profiles !== "*" && !listener.profiles.includes(profile)) listener.profiles.push(profile);
1786
+ } else {
1787
+ listener = { name: `listen-${randomBytes(4).toString("hex")}`, host, port, prefix, key: randomBytes(24).toString("base64url"), profiles: [profile] };
1788
+ config.listeners.push(listener);
1789
+ }
1790
+ await writeListeners(config);
1791
+ return listener;
1792
+ }
1793
+
1794
+ async function requireListeners() {
1795
+ const config = await readListeners();
1796
+ if (!config) throw new Error("No listener configuration yet. Run rech setup to migrate the daemon.");
1797
+ return config;
1798
+ }
1799
+
1800
+ async function listListeners(): Promise<void> {
1801
+ for (const l of (await requireListeners()).listeners) console.log(`${l.name} ${listenerAddress(l)}${normalizePrefix(l.prefix)} ${l.profiles === "*" ? "local management (all profiles)" : l.profiles.join(", ")}`);
1802
+ }
1803
+
1804
+ async function removeListener(name: string): Promise<void> {
1805
+ const config = await requireListeners();
1806
+ const listener = config.listeners.find(l => l.name === name);
1807
+ if (!listener) throw new Error("Unknown listener");
1808
+ if (listener.profiles === "*") throw new Error("Keep the local management listener for setup and recovery");
1809
+ config.listeners = config.listeners.filter(l => l !== listener);
1810
+ await writeListeners(config);
1811
+ console.log("Listener removed from configuration; daemon reloads automatically.");
1812
+ }
1813
+
1814
+ async function addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void> {
1815
+ const config = await requireListeners();
1816
+ const registry = await readTokenRegistry(), cache = await readChromeProfileCache();
1817
+ const profiles: string[] = [];
1818
+ for (const selector of opts.profile) profiles.push((await resolveGlobalProfile(registry, cache, selector)).email);
1819
+ const host = chooseListenAddress(await detectListenChoices(), opts.listen);
1820
+ const port = opts.port ?? DEFAULT_PORT;
1821
+ if (config.listeners.some(l => l.name === name)) throw new Error("Listener name already exists; edit listeners.json to change its allowlist");
1822
+ config.listeners.push({ name, host, port, prefix: normalizePrefix(opts.prefix), key: randomBytes(24).toString("base64url"), profiles: [...new Set(profiles)] });
1823
+ await writeListeners(config);
1824
+ console.log(`Listener saved for ${host}:${port}; daemon reloads automatically. Credentials are in ~/.rechrome/listeners.json (keep private).`);
1825
+ }
1826
+
1827
+ export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
1828
+ // These are hints, not proof of identity or permission to automate the desktop.
1829
+ if (env.RECH_SETUP_AGENT === "none") return null;
1830
+ if (env.RECH_SETUP_AGENT === "codex") return "Codex";
1831
+ if (env.RECH_SETUP_AGENT === "claude") return "Claude Code";
1832
+ const enabled = (value: string | undefined) => !!value && !["0", "false"].includes(value.toLowerCase());
1833
+ if (enabled(env.CODEX_THREAD_ID) || enabled(env.CODEX_CI) || enabled(env.CODEX_SANDBOX)) return "Codex";
1834
+ if (enabled(env.CLAUDECODE) || enabled(env.CLAUDE_CODE_ENTRYPOINT)) return "Claude Code";
1835
+ return null;
1836
+ }
1837
+
1838
+ export function setupAgentGuidance(agent: "Codex" | "Claude Code", platform = process.platform): string {
1839
+ const skillPath = join(import.meta.dir, "skills", "rechrome-setup", "SKILL.md");
1840
+ return [
1841
+ `[rech] ${agent} environment detected — setup guidance:`,
1842
+ ` Read the setup skill: ${skillPath}`,
1843
+ " Keep this setup process alive while installing; retain and poll its terminal session.",
1844
+ " Piped stdin is supported: supply prompt answers and close stdin so setup can start.",
1845
+ " Use the available Computer Use skill for the selected profile's Load unpacked flow; follow its approval rules.",
1846
+ ...(platform === "darwin" ? [" macOS: in the folder picker, use Command+Shift+G and the extension path shown in the guide."] : []),
1847
+ " If desktop control is unavailable, leave setup running and give the user the exact remaining step.",
1848
+ " Never restart the user's Chrome. Let setup detect the token; do not print credentials.",
1849
+ " Wait for bridge verification and registration before reporting success; preserve the default profile unless asked to change it.",
1850
+ " Agent detection does not grant permissions. Set RECH_SETUP_AGENT=none to hide these hints.",
1851
+ ].join("\n");
1852
+ }
1853
+
1854
+ async function setup(opts: SetupOptions = {}): Promise<void> {
1855
+ const prefix = normalizePrefix(opts.prefix);
1856
+ if (opts.port !== undefined && (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535)) throw new Error("--port must be between 1 and 65535");
1857
+ const agent = detectSetupAgent();
1858
+ if (agent) console.error(setupAgentGuidance(agent));
1323
1859
  if (opts.profile !== undefined) {
1324
1860
  try {
1325
1861
  validateChromeProfileSelector(opts.profile);
@@ -1335,6 +1871,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1335
1871
  let stdinQueue: string[] | null = null;
1336
1872
  if (isTTY) {
1337
1873
  rl = createInterface({ input: process.stdin, output: process.stdout });
1874
+ rl.on("SIGINT", () => { rl?.close(); envWatcher?.close(); process.exit(130); });
1338
1875
  } else {
1339
1876
  // Pre-read all piped stdin lines so readline close doesn't block later prompts
1340
1877
  stdinQueue = await new Promise<string[]>(resolve => {
@@ -1351,105 +1888,65 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1351
1888
  };
1352
1889
 
1353
1890
  // [1/5] Daemon
1354
- console.log("\n[1/5] Checking serve daemon...");
1355
-
1356
- // Bind address (persists to ~/.env.local as RECH_HOST).
1357
- // Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
1358
- const globalEnvRaw = await file(globalEnvFile).text().catch(() => "");
1359
- const persistedBindMatch = globalEnvRaw.match(/^\s*RECH_HOST\s*=\s*(.*?)\s*$/m);
1360
- const persistedBind = persistedBindMatch?.[1].replace(/^["']|["']$/g, "") || "127.0.0.1";
1361
-
1362
- // Clear stale hostname-based URL so we always use 127.0.0.1 locally
1363
- if (process.env[ENV_KEY]) {
1364
- try {
1365
- const u = new URL(process.env[ENV_KEY]);
1366
- if (!["127.0.0.1", "localhost"].includes(u.hostname)) delete process.env[ENV_KEY];
1367
- } catch {}
1891
+ console.log("\n○ [1/5] Detecting networks and configuring the daemon...");
1892
+ const listenChoices = await detectListenChoices();
1893
+
1894
+ // Defer persistence until daemon prerequisites have passed; daemonInstall saves the URL.
1895
+ const currentUrl = await getOrCreateUrl(false);
1896
+ const previous = parseUrl(currentUrl);
1897
+ let config = await readListeners();
1898
+ if (!config) {
1899
+ const previousHost = process.env.RECH_HOST || "127.0.0.1";
1900
+ const local: Listener = { name: "local", host: "127.0.0.1", port: previous.port, key: previous.key, profiles: "*" };
1901
+ config = { version: 1, listeners: [local] };
1902
+ const registered = Object.keys(await readTokenRegistry());
1903
+ if (!isLoopback(previousHost) && registered.length && previousHost !== "0.0.0.0" && previousHost !== "::") {
1904
+ config.listeners.push({ name: "existing-remote", host: previousHost, port: previous.port, key: randomBytes(24).toString("base64url"), profiles: registered });
1905
+ console.log(" Existing remote exposure retained for registered profiles with a new scoped listener key.");
1906
+ }
1907
+ await writeListeners(config);
1368
1908
  }
1369
- const url = await getOrCreateUrl();
1370
- const { host, port, protocol } = parseUrl(url);
1371
-
1372
- const { key: serveKey } = parseUrl(url);
1373
- // First check if server is up at all (unauthenticated root), then verify our key matches
1374
- const anonPing = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
1375
- const authPing = anonPing ? await fetch(`${protocol}://${host}:${port}/ping`, {
1376
- headers: { Authorization: `Bearer ${serveKey}` },
1377
- signal: AbortSignal.timeout(2000),
1378
- }).catch(() => null) : null;
1379
- // The daemon's *live* bind (from /ping) is authoritative — persisted RECH_HOST may diverge if the user edited it manually.
1380
- const liveBind = authPing?.ok
1381
- ? await authPing.clone().json().then((b: { bind?: string }) => b?.bind).catch(() => undefined)
1382
- : undefined;
1383
- // Pre-patch daemons return plain "ok" with no bind info — we can't trust persisted/env values to match their live bind, so force reinstall to be safe.
1384
- const liveBindUnknown = !!authPing?.ok && !liveBind;
1385
- const currentBind = liveBind || persistedBind;
1386
-
1387
- // A healthy daemon already answering on our key needs no reinstall — don't re-prompt for it.
1388
- const daemonHealthy = !!(anonPing && authPing?.ok && !liveBindUnknown);
1389
- // An explicit RECH_HOST override that differs from the live bind is a deliberate rebind request.
1390
- const explicitRebind = !!process.env.RECH_HOST && process.env.RECH_HOST !== currentBind;
1391
-
1392
- // Non-TTY honors explicit process.env.RECH_HOST (shell or merged env stack) — matches the documented `RECH_HOST=0.0.0.0 rech setup` flow.
1393
- let desiredBind = process.env.RECH_HOST || currentBind;
1394
- // Only prompt to (re)configure the bind when we actually need to set up the daemon. A running
1395
- // daemon is left alone unless the user explicitly asks for a different bind via RECH_HOST.
1396
- if (isTTY && (!daemonHealthy || explicitRebind)) {
1397
- console.log(`\n Bind address (current: ${currentBind}):`);
1398
- console.log(` 1. 127.0.0.1 (localhost only)`);
1399
- console.log(` 2. 0.0.0.0 (all interfaces — HTTP plaintext, trust your network)`);
1400
- const defaultBindChoice = currentBind === "0.0.0.0" ? "2" : "1";
1401
- const bindAns = (await ask(` Choice [${defaultBindChoice}]: `, defaultBindChoice)).trim();
1402
- desiredBind = bindAns === "2" || bindAns === "0.0.0.0" ? "0.0.0.0" : "127.0.0.1";
1403
- } else if (daemonHealthy) {
1404
- console.log(` Daemon already running at ${protocol}://${host}:${port} (bind: ${currentBind}) — skipping daemon setup`);
1405
- }
1406
- const bindChanged = desiredBind !== currentBind;
1407
- const persistedChanged = desiredBind !== persistedBind;
1408
- if (persistedChanged) {
1409
- const lines = globalEnvRaw.trimEnd().split("\n").filter(l => !/^\s*RECH_HOST\s*=/.test(l));
1410
- await Bun.write(globalEnvFile, [...lines, `RECH_HOST=${desiredBind}`, ""].join("\n"));
1411
- console.log(` Saved RECH_HOST=${desiredBind} to ~/.env.local`);
1412
- }
1413
- // Always align process.env with the desired bind — a nearer .env.local may have shadowed it.
1414
- process.env.RECH_HOST = desiredBind;
1415
-
1416
- const waitForServe = async () => {
1417
- process.stdout.write(" Starting");
1418
- let ping = null;
1419
- for (let i = 0; i < 15; i++) {
1420
- await Bun.sleep(1000);
1421
- ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
1422
- if (ping) break;
1423
- process.stdout.write(".");
1909
+ const management = config.listeners.find(l => l.profiles === "*" && isLoopback(l.host));
1910
+ if (!management) throw new Error("Setup requires a loopback management listener in ~/.rechrome/listeners.json");
1911
+ const scheme = previous.protocol;
1912
+ let url = `${scheme}://${management.key}@${listenerAddress(management)}${normalizePrefix(management.prefix)}`;
1913
+ const { host, port, protocol, key: serveKey } = parseUrl(url);
1914
+ let desiredBind = management.host;
1915
+ if (opts.listen) desiredBind = chooseListenAddress(listenChoices, opts.listen);
1916
+ else if (isTTY) {
1917
+ console.log("\n Where should this profile be accessible? (Other profiles keep their listeners.)");
1918
+ listenChoices.forEach((choice, index) => console.log(` ${index + 1}. ${choice.label} — ${choice.address}`));
1919
+ while (true) {
1920
+ const answer = (await ask(" Choice [1]: ", "1")).trim();
1921
+ const choice = /^\d+$/.test(answer) ? listenChoices[Number(answer) - 1] : undefined;
1922
+ if (choice) { desiredBind = choice.address; break; }
1923
+ console.log(" Enter one of the numbers shown above.");
1424
1924
  }
1425
- process.stdout.write("\n");
1426
- if (!ping) {
1427
- console.error(` Failed to start serve at ${host}:${port}`);
1925
+ }
1926
+ const pingManagement = () => fetch(serviceUrl(url, "ping"), {
1927
+ headers: { Authorization: `Bearer ${serveKey}` }, signal: AbortSignal.timeout(2000),
1928
+ }).then(async r => r.ok && (await r.json()).multiListener === true).catch(() => false);
1929
+ if (!await pingManagement()) {
1930
+ // A one-time source upgrade restarts only the daemon, never Chrome. Subsequent
1931
+ // listener changes are reloaded in place by the running daemon.
1932
+ try {
1933
+ await ensureDaemonManager(ask, opts.yes);
1934
+ } catch (error) {
1935
+ console.error(error instanceof Error ? error.message : String(error));
1428
1936
  rl?.close();
1937
+ envWatcher?.close();
1429
1938
  process.exit(1);
1430
1939
  }
1431
- console.log(` Serve running at ${protocol}://${host}:${port}`);
1432
- };
1433
-
1434
- if (anonPing && authPing?.ok && !bindChanged && !liveBindUnknown) {
1435
- console.log(` Already running at ${protocol}://${host}:${port} — skipping reinstall`);
1436
- } else if (anonPing && authPing?.ok && liveBindUnknown) {
1437
- console.log(` Pre-patch daemon detected (no live bind info) — reinstalling to verify bind`);
1438
- await daemonInstall(url);
1439
- await waitForServe();
1440
- } else if (anonPing && bindChanged) {
1441
- console.log(` Bind changed (${currentBind} → ${desiredBind}) — reinstalling`);
1442
- await daemonInstall(url);
1443
- await waitForServe();
1444
- } else if (anonPing && !authPing?.ok) {
1445
- console.log(` Server running but key mismatch — reinstalling with new key`);
1940
+ process.env.RECH_HOST = management.host;
1446
1941
  await daemonInstall(url);
1447
- await waitForServe();
1448
- } else {
1449
- await daemonInstall(url);
1450
- console.log(` Registered daemon: ${PM_PROCESS_NAME}`);
1451
- await waitForServe();
1942
+ let ready = false;
1943
+ for (let i = 0; i < 15; i++) {
1944
+ await Bun.sleep(1000);
1945
+ if (await pingManagement()) { ready = true; break; }
1946
+ }
1947
+ if (!ready) throw new Error("Local management listener did not start; inspect rech status and daemon logs");
1452
1948
  }
1949
+ console.log(`✓ [1/5] Local management daemon verified; requested profile access: ${desiredBind}`);
1453
1950
 
1454
1951
  const cache = await readChromeProfileCache();
1455
1952
  if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
@@ -1483,141 +1980,86 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1483
1980
  return available[idx];
1484
1981
  }
1485
1982
 
1486
- async function getExtAndToken(profileDir: string, profileDisplay: string, profileKey: string, providedToken?: string): Promise<{ extId: string; token: string } | null> {
1487
- // Extension check
1488
- let extId: string | undefined;
1489
- // Copy bundled dist to a stable per-user location so the install path survives bunx temp-dir cleanup.
1983
+ let guide: ReturnType<typeof createSetupGuide> | undefined;
1984
+ async function getExtAndToken(profileDir: string, profileDisplay: string, _profileKey: string, providedToken?: string): Promise<{ extId: string; token: string } | null> {
1490
1985
  await ensureExtensionDistInstalled();
1491
- while (true) {
1492
- const found = await findInstalledExtension(profileDir);
1493
- if (found) { extId = found.id; break; }
1494
- console.log(`\n Extension not found in profile: ${profileDisplay}`);
1495
- console.log(` Extension dist: ${EXTENSION_DIST_DIR}`);
1496
- const setupHtmlPath = join(RECH_HOME_DIR, "setup.html");
1497
- mkdirSync(RECH_HOME_DIR, { recursive: true });
1498
- await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
1499
- // Open the install guide directly in the *target* profile (resolved from --profile), so
1500
- // "Load unpacked" lands in the right Chrome. This is a new tab, not a restart.
1501
- console.log(`\n Opening install guide in Chrome profile: ${profileDisplay}`);
1502
- openInChromeProfile(profileDir, setupHtmlPath);
1503
- // Non-TTY (agent/pipe) can't block on a paste prompt, and `ask` returns immediately on an
1504
- // exhausted stdin queue — looping would respawn Chrome every iteration. Open the guide once,
1505
- // then stop with clear re-run instructions instead of spinning.
1506
- if (!isTTY) {
1507
- console.error(`\n Non-TTY: load the extension once via chrome://extensions → "Load unpacked":`);
1508
- console.error(` ${EXTENSION_DIST_DIR}`);
1509
- console.error(` (open chrome://extensions in profile "${profileDisplay}" — see the guide just opened)`);
1510
- console.error(` Then re-run: rech setup --profile <email|name|folder> [--token <tok>]`);
1511
- return null;
1512
- }
1513
- await ask("\n Press Enter after loading the extension to retry...");
1514
- }
1515
- console.log(` Extension found: ${extId}`);
1516
-
1517
- // Non-interactive token injection (--token / RECH_TOKEN). An explicitly supplied token wins
1518
- // over both the registry-keep prompt and the paste loop, so a non-TTY agent can register a
1519
- // profile in one shot. Accepts the bare token or a full `PLAYWRIGHT_MCP_EXTENSION_TOKEN=...`.
1520
- if (providedToken) {
1521
- const token = providedToken.replace(/^.*?=/, "").trim();
1522
- if (token.length < 20) {
1523
- console.error(` Provided token too short (${token.length} chars) — pass the full PLAYWRIGHT_MCP_EXTENSION_TOKEN value`);
1524
- return null;
1525
- }
1526
- console.log(` Using provided token: ${token.slice(0, 6)}…`);
1527
- return { extId, token };
1528
- }
1529
-
1530
- // Default automation: read the auth token straight from the profile's localStorage LevelDB,
1531
- // so an installed extension needs no manual paste (works the same in TTY and non-TTY). The
1532
- // token is minted lazily the first time the status/connect page loads, so if it isn't there
1533
- // yet, open status.html in this profile to mint it (a new tab — never a restart) and re-scan.
1534
- if (userDataDir) {
1535
- let auto = readExtensionTokenFromProfile(userDataDir, profileDir);
1536
- if (!auto) {
1537
- console.log(` No token in profile yet — minting via chrome-extension://${extId}/status.html …`);
1538
- openInChromeProfile(profileDir, `chrome-extension://${extId}/status.html`);
1539
- for (let i = 0; i < 10 && !auto; i++) { await Bun.sleep(500); auto = readExtensionTokenFromProfile(userDataDir, profileDir); }
1540
- }
1541
- if (auto) {
1542
- console.log(` Auto-read token from profile localStorage: ${auto.slice(0, 6)}…`);
1543
- return { extId, token: auto };
1544
- }
1545
- console.log(` Could not auto-read token from localStorage — falling back to manual entry`);
1546
- }
1547
-
1548
- // Check for existing token in registry
1549
- const registry = await readTokenRegistry();
1550
- const existing = registry[profileKey];
1551
- if (existing && existing.extensionId === extId && existing.token) {
1552
- console.log(` Existing token found: ${existing.token.slice(0, 6)}…`);
1553
- if (!isTTY) console.log(` [agent] Provide y to keep existing token, n to refresh on next stdin line`);
1554
- const keep = (await ask(" Keep existing token? [Y/n]: ")).trim().toLowerCase();
1555
- if (keep !== "n") {
1556
- console.log(" Keeping existing token");
1557
- return { extId, token: existing.token };
1558
- }
1559
- }
1560
-
1561
- // Token
1562
- const statusUrl = `chrome-extension://${extId}/status.html`;
1563
- console.log(`\n Get auth token from the extension:`);
1564
- console.log(` ${statusUrl}`);
1565
- if (isTTY) {
1566
- openInChromeProfile(profileDir, statusUrl);
1567
- console.log(`\n Or click the extension icon in the Chrome toolbar.`);
1568
- console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
1569
- } else {
1570
- console.log(`\n [agent] Open the URL above in Chrome (profile: ${profileDisplay})`);
1571
- console.log(` [agent] Find PLAYWRIGHT_MCP_EXTENSION_TOKEN=... on that page`);
1572
- console.log(` [agent] Provide the token value on next stdin line:\n`);
1573
- }
1574
- // Retry on empty/too-short paste — a truncated copy or a stale token shouldn't
1575
- // abort the whole setup. Bounded so a non-TTY agent with exhausted stdin can't spin.
1576
- const maxTries = isTTY ? 5 : 3;
1577
- for (let attempt = 1; attempt <= maxTries; attempt++) {
1578
- const tokenInput = (await ask(" Paste token: ")).trim();
1579
- const token = tokenInput.replace(/^.*?=/, "").trim();
1580
- const retriesLeft = maxTries - attempt;
1581
- if (!token) {
1582
- console.error(` No token entered.${retriesLeft ? " Copy the full PLAYWRIGHT_MCP_EXTENSION_TOKEN value and try again." : ""}`);
1583
- } else if (token.length < 20) {
1584
- console.error(` Token too short (${token.length} chars) — likely truncated when copying.${retriesLeft ? " Re-copy the full value and try again." : ""}`);
1585
- } else {
1586
- console.log(" Token accepted");
1587
- return { extId, token };
1986
+ guide?.close();
1987
+ guide = createSetupGuide(EXTENSION_DIST_DIR, profileDisplay);
1988
+ console.log(` Live setup guide: ${guide.url}`);
1989
+ if (!await openSetupGuide(profileDir, guide.url))
1990
+ console.log(" Open chrome://extensions/ in the selected profile if the tab did not open.");
1991
+ const deadline = Date.now() + 15 * 60_000;
1992
+ let statusOpenedFor: string | undefined;
1993
+ let terminalToken: string | undefined;
1994
+ let manualPromptStarted = false;
1995
+ const promptAbort = new AbortController();
1996
+ try {
1997
+ while (Date.now() < deadline) {
1998
+ const found = await findInstalledExtension(profileDir);
1999
+ if (!found) {
2000
+ guide.update("extension", "Waiting for the extension. Load it in the previous tab; this page updates automatically.");
2001
+ await Bun.sleep(1000);
2002
+ continue;
2003
+ }
2004
+ const extId = found.id;
2005
+ guide.setExtensionId(extId);
2006
+ const manual = guide.takeToken() || terminalToken || providedToken;
2007
+ const candidate = manual?.replace(/^PLAYWRIGHT_MCP_EXTENSION_TOKEN=/, "").trim();
2008
+ const automatic = userDataDir ? readExtensionTokenFromProfile(userDataDir, profileDir, extId) : null;
2009
+ const token = candidate && /^[A-Za-z0-9_-]{20,256}$/.test(candidate) ? candidate : automatic;
2010
+ if (token) {
2011
+ guide.update("bridge", "Extension and token detected. Testing the browser connection…");
2012
+ console.log(` Extension ${extId} and auth token detected${candidate ? " (provided token)" : " automatically"}`);
2013
+ return { extId, token };
2014
+ }
2015
+ guide.update("token", "Extension installed. Waiting for its auth token. Open the extension status page using the link below to initialize it, or paste a token manually.");
2016
+ if (statusOpenedFor !== extId) {
2017
+ statusOpenedFor = extId;
2018
+ openInChromeProfile(profileDir, `chrome-extension://${extId}/status.html`);
2019
+ console.log(" Watching for the token automatically. You can also paste it in the live guide.");
2020
+ }
2021
+ if (rl && !manualPromptStarted) {
2022
+ manualPromptStarted = true;
2023
+ rl.question(" Optional fallback — paste token here (automatic detection continues): ", { signal: promptAbort.signal }, answer => { terminalToken = answer.trim(); });
2024
+ }
2025
+ await Bun.sleep(1000);
1588
2026
  }
1589
- // Non-TTY with no input left: ask() won't block, so stop instead of burning retries on empty reads.
1590
- if (!isTTY && !tokenInput) break;
2027
+ guide.update("error", "Setup timed out after 15 minutes. Run setup again to resume.");
2028
+ console.error(" Extension/token detection timed out; run setup again to resume.");
2029
+ return null;
2030
+ } finally {
2031
+ promptAbort.abort();
1591
2032
  }
1592
- console.error(" No valid token provided — aborting");
1593
- return null;
1594
2033
  }
1595
2034
 
1596
2035
  // [2/5] Primary profile
1597
- console.log("\n[2/5] Select Chrome profile:");
2036
+ console.log("\n○ [2/5] Select Chrome profile:");
1598
2037
  const picked = await pickProfile(new Set());
1599
2038
  if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
1600
2039
  const [profileDir, profileInfoSel] = picked;
1601
2040
  const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
2041
+ console.log(`✓ [2/5] Profile resolved: ${profileDisplay} [${profileDir}]`);
1602
2042
 
1603
2043
  // [3/5] Extension + token for primary profile
1604
- console.log("\n[3/5] Checking extension...");
2044
+ console.log("\n○ [3/5] Checking extension...");
1605
2045
  const profileEmail = profileInfoSel.user_name || profileDir;
1606
2046
  const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
1607
- if (!primary) { rl?.close(); process.exit(1); }
1608
- const { extId, token } = primary;
2047
+ if (!primary) { await Bun.sleep(1500); guide?.close(); rl?.close(); process.exit(1); }
2048
+ const { extId } = primary;
2049
+ let token = primary.token;
2050
+ console.log("✓ [3/5] Extension and token detected");
1609
2051
 
1610
2052
  // Build RECHROME_URL, verify the selected profile can complete a real extension
1611
2053
  // handshake, then show it before asking where to save.
1612
- const rechUrl = new URL(url);
2054
+ const rechUrl = editableConnectionUrl(url);
1613
2055
  if (!rechUrl.username) rechUrl.username = randomBytes(12).toString("base64url");
1614
2056
  rechUrl.searchParams.set("extension_id", extId);
1615
2057
  rechUrl.searchParams.set("token", token);
1616
2058
  rechUrl.searchParams.set("profile", profileEmail);
1617
2059
  if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
1618
- const newLine = `RECHROME_URL=${rechUrl.toString()}`;
1619
- console.log(`\n[4/5] Verifying extension bridge for ${profileDisplay}...`);
1620
- const probeSession = `iso-setup-${randomBytes(4).toString("hex")}`;
2060
+
2061
+ console.log(`\n○ [4/5] Verifying extension bridge for ${profileDisplay}...`);
2062
+ const probeSession = `s${randomBytes(3).toString("hex")}`;
1621
2063
  const probeIdentity = await getClientIdentity();
1622
2064
  probeIdentity.profile = profileEmail;
1623
2065
  const probeEnv: Record<string, string> = {
@@ -1627,28 +2069,94 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1627
2069
  ...(userDataDir ? { PLAYWRIGHT_MCP_USER_DATA_DIR: userDataDir } : {}),
1628
2070
  };
1629
2071
  let bridgeVerified = false;
1630
- try {
1631
- const probe = await callServe(
1632
- rechUrl.toString(),
1633
- [`-s=${probeSession}`, "open", "about:blank", "--wait", "none"],
1634
- probeEnv,
1635
- probeIdentity,
1636
- );
1637
- bridgeVerified = probe.status === 0;
1638
- if (bridgeVerified) {
1639
- console.log(" Extension bridge connected successfully");
2072
+ let bridgeError = "";
2073
+ const bridgeDeadline = Date.now() + 15 * 60_000;
2074
+ do {
2075
+ guide?.takeRetry();
2076
+ guide?.update("bridge", "Extension and token detected. Testing the browser connection…");
2077
+ try {
2078
+ const probe = await callServe(
2079
+ rechUrl.toString(),
2080
+ [`-s=${probeSession}`, "open", "about:blank", "--wait", "none"],
2081
+ probeEnv,
2082
+ probeIdentity,
2083
+ true,
2084
+ );
2085
+ bridgeVerified = probe.status === 0;
2086
+ if (bridgeVerified) {
2087
+ console.log("✓ [4/5] Extension bridge connected successfully");
2088
+ } else {
2089
+ const diagnostic = probe.stderr.trim() || probe.stdout.trim() || `bridge probe exited ${probe.status}`;
2090
+ bridgeError = diagnostic;
2091
+ console.error(` Extension bridge verification failed: ${diagnostic}`);
2092
+ }
2093
+ } catch (error) {
2094
+ bridgeError = error instanceof Error ? error.message : String(error);
2095
+ console.error(` Extension bridge verification failed: ${bridgeError}`);
2096
+ } finally {
2097
+ // The isolated probe must never claim or close an existing worktree session.
2098
+ await callServe(rechUrl.toString(), [`-s=${probeSession}`, "close"], probeEnv, probeIdentity, true).catch(() => {});
2099
+ }
2100
+
2101
+ if (bridgeVerified) break;
2102
+ const safeError = bridgeError.replaceAll(token, "[redacted]").replaceAll(serveKey, "[redacted]").slice(0, 700);
2103
+ guide?.update("error", `Connection failed: ${safeError}. Check the daemon and reload the extension if needed, then click Retry connection. A replacement token can also be pasted below.`);
2104
+ while (Date.now() < bridgeDeadline && !guide?.takeRetry()) await Bun.sleep(500);
2105
+ const replacement = guide?.takeToken() || (userDataDir ? readExtensionTokenFromProfile(userDataDir, profileDir, extId) : null);
2106
+ if (replacement) {
2107
+ token = replacement;
2108
+ rechUrl.searchParams.set("token", token);
2109
+ probeEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN = token;
2110
+ }
2111
+ } while (Date.now() < bridgeDeadline);
2112
+ if (!bridgeVerified) {
2113
+ guide?.update("error", "Connection check timed out. Run setup again after reloading the extension.");
2114
+ await Bun.sleep(1500);
2115
+ guide?.close();
2116
+ rl?.close();
2117
+ envWatcher?.close();
2118
+ process.exitCode = 1;
2119
+ return;
2120
+ }
2121
+ guide?.update("save", "Browser connection verified! Finish the configuration prompts in the terminal.");
2122
+ await saveTokenEntry(profileEmail, { extensionId: extId, token, profileDir, userDataDir: userDataDir ?? undefined });
2123
+ const existingPrefix = prefix !== "/" ? (await readListeners())?.listeners.find(l => l.host === desiredBind && l.profiles !== "*" && normalizePrefix(l.prefix) === prefix) : undefined;
2124
+ const exposurePort = opts.port ?? existingPrefix?.port ?? (prefix !== "/" ? port + 1 : port);
2125
+ const exposure = await exposeProfile(profileEmail, desiredBind, exposurePort, prefix);
2126
+ const tailscaleChoice = listenChoices.find(c => c.kind === "tailscale")?.address;
2127
+ rechUrl.hostname = exposure.host;
2128
+ rechUrl.port = String(exposure.port);
2129
+ rechUrl.username = exposure.key;
2130
+ rechUrl.pathname = prefix;
2131
+ // Ensure the newly bound listener is live before offering its connection URL.
2132
+ let exposed = false;
2133
+ for (let i = 0; i < 20; i++) {
2134
+ const response = await fetch(serviceUrl(rechUrl.toString(), "ping"), { headers: { Authorization: `Bearer ${exposure.key}` }, signal: AbortSignal.timeout(1000) }).catch(() => null);
2135
+ if (response?.ok) { exposed = true; break; }
2136
+ await Bun.sleep(250);
2137
+ }
2138
+ if (!exposed) throw new Error("Profile registered, but its listener did not become reachable. Check for an occupied port or an unavailable interface.");
2139
+ if (prefix !== "/" && exposure.host === "127.0.0.1") {
2140
+ // Detect only; never change the Serve config without the user running the command themselves.
2141
+ const serve = await detectTailscaleServe(exposure.port, prefix);
2142
+ if (serve.routeUrl) {
2143
+ console.log(` Tailscale Serve route found: ${serve.routeUrl} → ${protocol}://${listenerAddress(exposure)}${prefix.slice(0, -1)}`);
2144
+ console.log(` Remote RECHROME_URL (secret; for other tailnet machines):\n ${tailscaleConnectionUrl(serve.routeUrl, rechUrl.toString())}`);
1640
2145
  } else {
1641
- const diagnostic = probe.stderr.trim() || probe.stdout.trim() || `bridge probe exited ${probe.status}`;
1642
- console.error(` Extension bridge verification failed: ${diagnostic}`);
2146
+ console.log(` Tailscale Serve command (run separately): tailscale serve --bg --https=443 --set-path=${prefix.slice(0, -1)} ${protocol}://${listenerAddress(exposure)}${prefix.slice(0, -1)}`);
2147
+ if (serve.dnsName) console.log(` Then remote clients can use:\n ${tailscaleConnectionUrl(`https://${serve.dnsName}${prefix}`, rechUrl.toString())}`);
2148
+ else console.log(` For remote clients, use https://<your-machine-tailnet-name>${prefix} with this listener's bearer key and profile query.`);
1643
2149
  }
1644
- } catch (error) {
1645
- console.error(` Extension bridge verification failed: ${error instanceof Error ? error.message : String(error)}`);
1646
- } finally {
1647
- // The isolated probe must never claim or close an existing worktree session.
1648
- await callServe(rechUrl.toString(), [`-s=${probeSession}`, "close"], probeEnv, probeIdentity).catch(() => {});
2150
+ console.log(` The URL saved below is the local loopback one.`);
2151
+ } else if (tailscaleChoice && exposure.host === tailscaleChoice) {
2152
+ console.log(` This binds the raw Tailscale IP over plain HTTP. For an HTTPS tailnet URL instead, run:`);
2153
+ console.log(` rech setup --profile ${JSON.stringify(profileEmail)} --listen local --prefix=rechrome`);
2154
+ console.log(` then expose it with tailscale serve (setup prints the command and the remote URL).`);
1649
2155
  }
2156
+ const newLine = `RECHROME_URL=${registeredProfileUrl(rechUrl.toString())}`;
1650
2157
 
1651
- console.log(`\n[5/5] Your RECHROME_URL:\n\n ${newLine}\n`);
2158
+ console.log("\n○ [5/5] Connection verified. Save connection configuration:");
2159
+ console.log(`\n${newLine}\n`);
1652
2160
  if (!isTTY) console.log(` [agent] Provide save destination on next stdin line: 1=cwd, 2=cwd rechrome-only, 3=home, 4=skip\n`);
1653
2161
 
1654
2162
  const pwdEnvPath = join(process.cwd(), ".env.local");
@@ -1658,7 +2166,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1658
2166
  const tag = async (p: string) => (await file(p).exists()) ? "exists → will update" : "new file";
1659
2167
  const [pwdTag, pwdRechTag, homeTag] = await Promise.all([tag(pwdEnvPath), tag(pwdRechPath), tag(homeEnvPath)]);
1660
2168
  const saveChoice = (await ask(
1661
- `Save to:\n 1. ${pwdEnvPath} (current dir) [${pwdTag}] [default]\n 2. ${pwdRechPath} (current dir, rechrome-only) [${pwdRechTag}]\n 3. ${homeEnvPath} (user home) [${homeTag}]\n 4. Skip (already copied)\n\n Choice [1]: `
2169
+ `Save to:\n 1. ${pwdEnvPath} (current dir) [${pwdTag}] [default]\n 2. ${pwdRechPath} (current dir, rechrome-only) [${pwdRechTag}]\n 3. ${homeEnvPath} (user home) [${homeTag}]\n 4. Skip saving URL (register profile only)\n\n Choice [1]: `
1662
2170
  )).trim();
1663
2171
  if (saveChoice !== "4") {
1664
2172
  const globalEnvPath = saveChoice === "3" ? homeEnvPath : saveChoice === "2" ? pwdRechPath : pwdEnvPath;
@@ -1677,6 +2185,11 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1677
2185
  // Save primary to token registry
1678
2186
  await saveTokenEntry(profileEmail, { extensionId: extId, token, profileDir, userDataDir: userDataDir ?? undefined });
1679
2187
 
2188
+ console.log("✓ [5/5] Profile registration saved");
2189
+ guide?.update("ready", "Success — extension installed, token detected, browser connected, and profile registered.");
2190
+ await guide?.deliverSuccess();
2191
+ guide?.close();
2192
+
1680
2193
  // Additional profiles
1681
2194
  const configured = new Set([profileDir]);
1682
2195
  while (true) {
@@ -1692,9 +2205,13 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
1692
2205
  const extraEmail = extraInfo.user_name || extraDir;
1693
2206
  console.log(`\n Setting up: ${extraDisplay}`);
1694
2207
  const result = await getExtAndToken(extraDir, extraDisplay, extraEmail);
1695
- if (!result) { console.log(" Skipped."); continue; }
2208
+ if (!result) { guide?.close(); console.log(" Skipped."); continue; }
1696
2209
  await saveTokenEntry(extraEmail, { extensionId: result.extId, token: result.token, profileDir: extraDir, userDataDir: userDataDir ?? undefined });
1697
2210
  configured.add(extraDir);
2211
+ guide?.update("bridge", "Token registered. Run setup for this profile to verify its browser connection.");
2212
+ guide?.markRegistered();
2213
+ await Bun.sleep(1500);
2214
+ guide?.close();
1698
2215
  console.log(` Saved token for ${extraDisplay}`);
1699
2216
  }
1700
2217
  rl?.close();
@@ -1713,11 +2230,11 @@ async function status(): Promise<void> {
1713
2230
  }
1714
2231
  const { host, port, protocol } = parseUrl(url);
1715
2232
  const parsed = parseUrl(url);
1716
- const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
2233
+ const ping = await fetch(serviceUrl(url), { signal: AbortSignal.timeout(2000) }).catch(() => null);
1717
2234
  // Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
1718
2235
  // POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
1719
2236
  const pingBody = ping
1720
- ? await fetch(`${protocol}://${host}:${port}/ping`, {
2237
+ ? await fetch(serviceUrl(url, "ping"), {
1721
2238
  headers: { Authorization: `Bearer ${parsed.key}` },
1722
2239
  signal: AbortSignal.timeout(2000),
1723
2240
  }).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
@@ -1763,13 +2280,17 @@ Usage:
1763
2280
  must already be registered (see \`rech setup\`). Place
1764
2281
  --profile before the playwright subcommand. Requires
1765
2282
  ${ENV_KEY}.
1766
- rech setup [--profile <email|name|folder>] [--token <tok>]
2283
+ rech setup [--listen <local|lan|tailscale|IP>] [--profile <email|name|folder>] [--token <tok>] [--prefix <path>] [--port <port>] [--yes]
1767
2284
  First-time setup: daemon + Chrome extension + config
2285
+ --prefix=rechrome mounts at /rechrome/ on a scoped listener.
2286
+ Prefixed setup defaults to the management port + 1; override with --port.
2287
+ Offers to install missing oxmgr globally (y/N).
2288
+ --yes approves installation without prompting.
1768
2289
  --profile selects the Chrome profile non-interactively.
1769
2290
  Menu numbers are not accepted. Resolution order is exact
1770
2291
  email (e.g. you@gmail.com), exact Chrome profile name,
1771
2292
  then exact profile folder name (e.g. "Profile 1"). See
1772
- available values with \`rech profiles\`.
2293
+ available values with \`rech profile\`.
1773
2294
  --token (or RECH_TOKEN) supplies the auth token for
1774
2295
  non-TTY/agent runs, skipping the interactive paste
1775
2296
  rech provision-profile <name> --experimental [--headed]
@@ -1783,7 +2304,12 @@ Usage:
1783
2304
  starts after \`rech setup\`; skipped with no GUI
1784
2305
  rech uninstall Remove the serve daemon and clear config
1785
2306
  rech serve Start the serve server manually (foreground)
1786
- rech profiles List Chrome profiles
2307
+ rech listener [ls|add|remove] Manage daemon listener addresses and allowed profiles
2308
+ rech profile [ls|list]
2309
+ List Chrome + managed test profiles and connection status
2310
+ rech profile [name] --print-uri [--listener <name>]
2311
+ Print a connection URI (includes the daemon key).
2312
+ Uses RECHROME_URL, or an explicitly selected local listener.
1787
2313
  rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
1788
2314
  rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
1789
2315
  fragile single-shot flow (OAuth/login) never shares tabs
@@ -1795,6 +2321,7 @@ Environment:
1795
2321
  RECH_IDENTITY Session bucket mode: worktree (default) | branch | cwd. The session a
1796
2322
  client reuses is keyed on the worktree root path; \`branch\` restores the
1797
2323
  old <remote>/tree/<branch> keying, \`cwd\` keys on the exact directory
2324
+ RECH_SETUP_AGENT Setup hints: codex | claude | none (otherwise auto-detected)
1798
2325
 
1799
2326
  Examples:
1800
2327
  rech setup
@@ -1805,55 +2332,114 @@ Examples:
1805
2332
  rech screenshot`);
1806
2333
  }
1807
2334
 
2335
+ export type SetupOptions = { profile?: string; token?: string; listen?: string; prefix?: string; port?: number; yes?: boolean };
2336
+ export type RechHandlers = {
2337
+ serve(): Promise<void> | void;
2338
+ status(): Promise<void>;
2339
+ listListeners(): Promise<void>;
2340
+ addListener(name: string, opts: { listen: string; profile: string[]; port?: number; prefix?: string }): Promise<void>;
2341
+ removeListener(name: string): Promise<void>;
2342
+ listProfiles(): Promise<void>;
2343
+ printProfileUri(selector?: string, listener?: string): Promise<void>;
2344
+ setup(opts: SetupOptions): Promise<void>;
2345
+ tray(action?: string): Promise<void>;
2346
+ provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
2347
+ uninstall(): Promise<void>;
2348
+ };
2349
+
2350
+ /** Commands rech handles itself; anything else is forwarded verbatim to playwright-cli. */
2351
+ export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners", "profile", "profiles", "setup", "tray", "provision-profile", "uninstall"]);
2352
+
2353
+ const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
2354
+
2355
+ export function rechCli(argv: string[], handlers: RechHandlers) {
2356
+ return yargs(argv)
2357
+ .scriptName("rech")
2358
+ .parserConfiguration({ "parse-numbers": false, "parse-positional-numbers": false })
2359
+ .command("serve", "Run the rechrome daemon in the foreground", {}, () => handlers.serve())
2360
+ .command("status", "Show daemon, relay and profile connection status", {}, () => handlers.status())
2361
+ .command(["listener", "listeners"], "Manage network listeners", y => y
2362
+ .command(["ls", "list", "$0"], "List listeners (credentials hidden)", {}, () => handlers.listListeners())
2363
+ .command("add <name>", "Expose registered profiles on a network", y => y
2364
+ .positional("name", { type: "string", demandOption: true })
2365
+ .option("listen", { type: "string", requiresArg: true, demandOption: true, describe: "local | lan | tailscale | <detected IP>" })
2366
+ .option("profile", { type: "string", array: true, requiresArg: true, demandOption: true, describe: "Allowed profile; repeat for several" })
2367
+ .option("port", portOption)
2368
+ .option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix, e.g. rechrome" }),
2369
+ a => handlers.addListener(a.name, { listen: a.listen, profile: a.profile, port: a.port, prefix: a.prefix }))
2370
+ .command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
2371
+ a => handlers.removeListener(a.name))
2372
+ .demandCommand(1).strict())
2373
+ .command(["profile [name]", "profiles [name]"], "List profiles, or print a profile's connection URI", y => y
2374
+ .positional("name", { type: "string", describe: "Profile (email, name or folder); ls/list lists all" })
2375
+ .option("print-uri", { type: "boolean", describe: "Print the profile's connection URI (contains a secret key)" })
2376
+ .option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URI for" }),
2377
+ a => {
2378
+ if (a.printUri) return handlers.printProfileUri(a.name, a.listener);
2379
+ if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
2380
+ throw new Error("Usage: rech profile [ls|list] | rech profile [name] --print-uri. Create, rename, and delete are not implemented.");
2381
+ })
2382
+ .command("setup", "Install the daemon and connect a Chrome profile", y => y
2383
+ .option("profile", { type: "string", requiresArg: true, describe: "Chrome profile: email, name or folder" })
2384
+ .option("token", { type: "string", requiresArg: true, describe: "Extension token (default: read from the profile, or RECH_TOKEN)" })
2385
+ .option("listen", { type: "string", requiresArg: true, describe: "local | lan | tailscale | <detected IP>" })
2386
+ .option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix for a scoped listener, e.g. rechrome" })
2387
+ .option("port", portOption)
2388
+ .option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
2389
+ 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 }))
2390
+ .command("tray [action]", "Show or hide the tray icon", y => y
2391
+ .positional("action", { type: "string", choices: ["show", "start", "hide", "stop", "quit"] }),
2392
+ a => handlers.tray(a.action))
2393
+ .command("provision-profile <name>", "Create a managed Chrome-for-Testing profile (experimental)", y => y
2394
+ .positional("name", { type: "string", demandOption: true })
2395
+ .option("experimental", { type: "boolean", default: false })
2396
+ .option("headed", { type: "boolean", default: false }),
2397
+ a => handlers.provisionProfile(a.name, { headed: a.headed, experimental: a.experimental }))
2398
+ .command("uninstall", "Stop and remove the rechrome daemon", {}, () => handlers.uninstall())
2399
+ .demandCommand(1)
2400
+ .strict()
2401
+ .help()
2402
+ .version(false)
2403
+ .fail((message, error) => { throw error ?? new Error(message); });
2404
+ }
2405
+
1808
2406
  if (import.meta.main) {
1809
2407
  let args = process.argv.slice(2);
1810
2408
  const cmd = args[0]?.toLowerCase();
1811
2409
 
1812
- if (cmd === "serve") {
1813
- const { serve } = await import("./serve.js");
1814
- serve(); // long-lived; watcher intentionally kept alive
1815
- } else if (cmd === "status") {
1816
- await status();
1817
- envWatcher?.close();
1818
- } else if (cmd === "profiles") {
1819
- await listProfiles();
1820
- envWatcher?.close();
1821
- } else if (cmd === "setup") {
1822
- const profileIdx = args.indexOf("--profile");
1823
- const profile = profileIdx !== -1
1824
- ? args[profileIdx + 1]
1825
- : args.find(a => a.startsWith("--profile="))?.slice("--profile=".length);
1826
- const tokenIdx = args.indexOf("--token");
1827
- const token = (tokenIdx !== -1
1828
- ? args[tokenIdx + 1]
1829
- : args.find(a => a.startsWith("--token="))?.slice("--token=".length))
1830
- ?? process.env.RECH_TOKEN;
1831
- await setup({ profile, token }); // setup closes envWatcher itself before printing Done
1832
- // Auto-start the tray (best-effort, silent on headless / missing binary).
1833
- await startTray({ quiet: true }).catch(() => {});
1834
- } else if (cmd === "tray") {
1835
- await trayCommand(args[1]?.toLowerCase());
1836
- envWatcher?.close();
1837
- } else if (cmd === "provision-profile") {
1838
- const name = args.find((a, i) => i > 0 && !a.startsWith("-"));
1839
- const headed = args.includes("--headed");
1840
- const experimental = args.includes("--experimental");
1841
- if (!name) { console.error("Usage: rech provision-profile <name> --experimental [--headed]"); process.exit(1); }
1842
- // Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
1843
- // (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
1844
- // so it's gated behind --experimental rather than offered as the default setup path.
1845
- if (!experimental) {
1846
- console.error(`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`);
1847
- console.error(`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`);
1848
- console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`);
1849
- console.error(`To proceed anyway, re-run with --experimental.`);
1850
- process.exit(1);
2410
+ if (cmd && RECH_COMMANDS.has(cmd)) {
2411
+ const handlers: RechHandlers = {
2412
+ serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
2413
+ status,
2414
+ listListeners, addListener, removeListener, listProfiles, printProfileUri,
2415
+ setup: async (opts) => {
2416
+ await setup(opts); // setup closes envWatcher itself before printing Done
2417
+ // Auto-start the tray (best-effort, silent on headless / missing binary).
2418
+ await startTray({ quiet: true }).catch(() => {});
2419
+ },
2420
+ tray: trayCommand,
2421
+ provisionProfile: async (name, { headed, experimental }) => {
2422
+ // Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
2423
+ // (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
2424
+ // so it's gated behind --experimental rather than offered as the default setup path.
2425
+ if (!experimental) throw new Error([
2426
+ `provision-profile is experimental and creates a Chrome-for-Testing profile (not your`,
2427
+ `real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`,
2428
+ `can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`,
2429
+ `To proceed anyway, re-run with --experimental.`,
2430
+ ].join("\n"));
2431
+ await provisionProfile(name, { headed });
2432
+ },
2433
+ uninstall: daemonUninstall,
2434
+ };
2435
+ try {
2436
+ await rechCli([cmd, ...args.slice(1)], handlers).parseAsync();
2437
+ } catch (error) {
2438
+ console.error(error instanceof Error ? error.message : String(error));
2439
+ process.exitCode = 1;
2440
+ } finally {
2441
+ if (cmd !== "serve") envWatcher?.close();
1851
2442
  }
1852
- await provisionProfile(name, { headed });
1853
- envWatcher?.close();
1854
- } else if (cmd === "uninstall") {
1855
- await daemonUninstall();
1856
- envWatcher?.close();
1857
2443
  } else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
1858
2444
  printHelp();
1859
2445
  envWatcher?.close();
@@ -1889,7 +2475,6 @@ if (import.meta.main) {
1889
2475
  overrideEnv = {
1890
2476
  PLAYWRIGHT_MCP_PROFILE_DIRECTORY: resolved.email,
1891
2477
  PLAYWRIGHT_MCP_EXTENSION_ID: resolved.entry.extensionId,
1892
- PLAYWRIGHT_MCP_EXTENSION_TOKEN: resolved.entry.token,
1893
2478
  };
1894
2479
  if (resolved.entry.userDataDir) overrideEnv.PLAYWRIGHT_MCP_USER_DATA_DIR = resolved.entry.userDataDir;
1895
2480
  if (resolved.entry.loadExtension) overrideEnv.PLAYWRIGHT_MCP_LOAD_EXTENSION = resolved.entry.loadExtension;