rechrome 1.17.0 → 1.18.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.
Files changed (6) hide show
  1. package/README.md +32 -0
  2. package/package.json +1 -1
  3. package/rech.js +389 -37
  4. package/rech.ts +389 -37
  5. package/serve.js +73 -7
  6. package/serve.ts +73 -7
package/rech.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { file } from "bun";
4
4
  import { randomBytes } from "crypto";
5
- import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, constants as fsConstants } from "fs";
5
+ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, constants as fsConstants } from "fs";
6
6
  import { hostname, homedir } from "os";
7
7
  import { join, basename, dirname } from "path";
8
8
 
@@ -17,7 +17,7 @@ export const HOME = homedir();
17
17
  const RECH_HOME_DIR = join(HOME, ".rechrome");
18
18
  const TOKENS_FILE = join(RECH_HOME_DIR, "profiles.json");
19
19
 
20
- type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string };
20
+ type TokenEntry = { extensionId: string; token: string; profileDir: string; userDataDir?: string; loadExtension?: string };
21
21
 
22
22
  async function readTokenRegistry(): Promise<Record<string, TokenEntry>> {
23
23
  const raw = await file(TOKENS_FILE).text().catch(() => "{}");
@@ -69,7 +69,7 @@ async function loadEnv() {
69
69
  }
70
70
  // Shell-set passthrough vars survive .env.local loading
71
71
  const _shellPassthrough: Record<string, string> = {};
72
- for (const k of ["PLAYWRIGHT_MCP_EXTENSION_ID","PLAYWRIGHT_MCP_EXTENSION_TOKEN","PLAYWRIGHT_MCP_PROFILE_DIRECTORY","PLAYWRIGHT_MCP_USER_DATA_DIR"] as const) {
72
+ 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) {
73
73
  if (process.env[k]) _shellPassthrough[k] = process.env[k]!;
74
74
  }
75
75
  await loadEnv();
@@ -86,6 +86,9 @@ export const PASSTHROUGH_ENV_KEYS = [
86
86
  "PLAYWRIGHT_MCP_EXTENSION_TOKEN",
87
87
  "PLAYWRIGHT_MCP_PROFILE_DIRECTORY",
88
88
  "PLAYWRIGHT_MCP_USER_DATA_DIR",
89
+ // Managed (provisioned) profiles aren't persistently installed in Secure Preferences,
90
+ // so the relay must re-load the unpacked extension on every launch via --load-extension.
91
+ "PLAYWRIGHT_MCP_LOAD_EXTENSION",
89
92
  "PWMCP_TEST_CONNECTION_TIMEOUT",
90
93
  ] as const;
91
94
 
@@ -94,6 +97,54 @@ function isReadable(p?: string): boolean {
94
97
  try { accessSync(p, fsConstants.R_OK); return true; } catch { return false; }
95
98
  }
96
99
 
100
+ // Open a file/URL in the OS default app/browser. `open` is macOS-only — Windows needs
101
+ // `cmd /c start`, Linux needs `xdg-open`.
102
+ function openInDefaultApp(target: string): void {
103
+ const cmd = process.platform === "darwin" ? ["open", target]
104
+ : process.platform === "win32" ? ["cmd", "/c", "start", "", target]
105
+ : ["xdg-open", target];
106
+ try { Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); } catch {}
107
+ }
108
+
109
+ // Best-effort path to the Chrome executable for the current platform (used to open a
110
+ // specific profile at a chrome-extension:// URL). Returns null if not found.
111
+ function findChromeBinary(): string | null {
112
+ const candidates = process.platform === "darwin"
113
+ ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
114
+ : process.platform === "win32"
115
+ ? [
116
+ join(process.env.PROGRAMFILES || "C:\\Program Files", "Google/Chrome/Application/chrome.exe"),
117
+ join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google/Chrome/Application/chrome.exe"),
118
+ join(process.env.LOCALAPPDATA || join(HOME, "AppData/Local"), "Google/Chrome/Application/chrome.exe"),
119
+ ]
120
+ : ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"];
121
+ for (const p of candidates) {
122
+ if (p.includes("/") || p.includes("\\")) { if (existsSync(p)) return p; }
123
+ else { const w = Bun.which(p); if (w) return w; }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ // Open a target (URL or local file) in a specific Chrome profile. This opens a new tab in
129
+ // the user's running Chrome for that profile (or launches Chrome if it's not running) — it
130
+ // does NOT restart Chrome or touch the live session. Note: `--profile-directory` only opens
131
+ // a tab; flags like `--load-extension` are ignored when Chrome is already running for that
132
+ // user-data-dir. Returns true if Chrome was spawned, false if it fell back to the OS default.
133
+ function openInChromeProfile(profileDir: string, target: string): boolean {
134
+ const chromeBin = findChromeBinary();
135
+ if (!chromeBin) { openInDefaultApp(target); return false; }
136
+ try {
137
+ Bun.spawn(
138
+ [chromeBin, `--profile-directory=${profileDir}`, target],
139
+ { stdout: "ignore", stderr: "ignore", detached: true },
140
+ );
141
+ return true;
142
+ } catch {
143
+ openInDefaultApp(target);
144
+ return false;
145
+ }
146
+ }
147
+
97
148
  export function log(msg: string) {
98
149
  mkdirSync(LOG_DIR, { recursive: true });
99
150
  const ts = new Date().toISOString();
@@ -117,6 +168,7 @@ export function parseUrl(raw: string) {
117
168
  extensionToken: u.searchParams.get("token") ?? undefined,
118
169
  profileDirectory: u.searchParams.get("profile") ?? undefined,
119
170
  userDataDir: u.searchParams.get("user_data_dir") ?? undefined,
171
+ loadExtension: u.searchParams.get("load_extension") ?? undefined,
120
172
  };
121
173
  }
122
174
 
@@ -185,7 +237,7 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
185
237
  return { hostname: hostname(), cwd };
186
238
  }
187
239
 
188
- async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string }): Promise<Record<string, string>> {
240
+ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?: string; profileDirectory?: string; userDataDir?: string; loadExtension?: string }): Promise<Record<string, string>> {
189
241
  const env: Record<string, string> = {};
190
242
  for (const key of PASSTHROUGH_ENV_KEYS) {
191
243
  if (process.env[key]) env[key] = process.env[key];
@@ -196,6 +248,8 @@ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?:
196
248
  env["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] = urlExtras.profileDirectory;
197
249
  if (urlExtras?.userDataDir)
198
250
  env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = urlExtras.userDataDir;
251
+ if (urlExtras?.loadExtension)
252
+ env["PLAYWRIGHT_MCP_LOAD_EXTENSION"] = urlExtras.loadExtension;
199
253
  // Token: shell env wins (explicit override), registry is fallback, URL param is last resort
200
254
  const profileKey = urlExtras?.profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
201
255
  if (profileKey) {
@@ -204,6 +258,7 @@ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?:
204
258
  if (entry) {
205
259
  if (!env["PLAYWRIGHT_MCP_EXTENSION_ID"]) env["PLAYWRIGHT_MCP_EXTENSION_ID"] = entry.extensionId;
206
260
  if (!env["PLAYWRIGHT_MCP_USER_DATA_DIR"] && entry.userDataDir) env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = entry.userDataDir;
261
+ if (!env["PLAYWRIGHT_MCP_LOAD_EXTENSION"] && entry.loadExtension) env["PLAYWRIGHT_MCP_LOAD_EXTENSION"] = entry.loadExtension;
207
262
  if (!env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"]) {
208
263
  env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = entry.token;
209
264
  } else if (env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] !== entry.token) {
@@ -371,11 +426,11 @@ async function callServe(
371
426
  args: string[],
372
427
  overrideEnv?: Record<string, string>,
373
428
  ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
374
- const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
429
+ const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
375
430
  const identity = await getClientIdentity();
376
431
  const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
377
432
  if (effectiveProfile) (identity as any).profile = effectiveProfile;
378
- const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir })), ...overrideEnv };
433
+ const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
379
434
  const res = await fetch(`${protocol}://${host}:${port}/run`, {
380
435
  method: "POST",
381
436
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
@@ -411,7 +466,7 @@ async function callServe(
411
466
  }
412
467
 
413
468
  async function run(url: string, args: string[]) {
414
- const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
469
+ const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
415
470
  const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
416
471
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
417
472
  const identity = await getClientIdentity();
@@ -420,7 +475,7 @@ async function run(url: string, args: string[]) {
420
475
  `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
421
476
  );
422
477
 
423
- const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir });
478
+ const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
424
479
  const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
425
480
 
426
481
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
@@ -461,7 +516,7 @@ async function run(url: string, args: string[]) {
461
516
  process.exit(status);
462
517
  }
463
518
 
464
- function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
519
+ export function buildSetupHtml(extDistDir: string, profileDisplay: string): string {
465
520
  return `<!DOCTYPE html>
466
521
  <html lang="en">
467
522
  <head>
@@ -563,10 +618,20 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
563
618
  const bunBin = Bun.which("bun") ?? process.execPath;
564
619
  const rechScript = import.meta.filename;
565
620
 
566
- // Resolve PLAYWRIGHT_CLI: env override > bundled fork (development checkout) > "playwright-cli-multi-tab"
621
+ // Resolve PLAYWRIGHT_CLI: env override > bundled fork (development checkout) > "playwright-cli-multi-tab".
622
+ // The fork is a .js script: POSIX execs it via its shebang (`#!/usr/bin/env node`), but Windows
623
+ // can't exec a .js directly, so it must be invoked through an interpreter. It MUST be node, not
624
+ // bun: the cliDaemon inherits its parent's runtime (spawned via process.execPath), and the
625
+ // extension-bridge relay's WebSocket handshake hangs under Bun (the extension WS connects but
626
+ // `extension.initialized` never completes) — under node it completes, matching the POSIX shebang.
627
+ // serve splits PLAYWRIGHT_CLI on spaces into argv, so we use bare `node` (the node path lives
628
+ // under "Program Files" and contains a space); node must be on the daemon's PATH, same as the
629
+ // shebang's `env node` assumption. The repo path contains no spaces.
567
630
  const bundledForkCli = join(import.meta.dir, "lib/playwright-cli/playwright-cli.js");
568
631
  const resolvedPlaywrightCli = process.env.PLAYWRIGHT_CLI
569
- || (existsSync(bundledForkCli) ? bundledForkCli : "playwright-cli-multi-tab");
632
+ || (existsSync(bundledForkCli)
633
+ ? (IS_WINDOWS ? `node ${bundledForkCli}` : bundledForkCli)
634
+ : "playwright-cli-multi-tab");
570
635
 
571
636
  // Environment the managed `serve` process must run with.
572
637
  const daemonEnv: Record<string, string> = {
@@ -583,11 +648,12 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
583
648
  // Drop any prior registration (current + legacy names) before re-adding.
584
649
  for (const name of [PM_PROCESS_NAME, ...LEGACY_PROCESS_NAMES]) await runPm(["delete", name]);
585
650
 
651
+ let startCode: number;
586
652
  if (IS_WINDOWS) {
587
653
  // pm2 captures the CLI env (passed via runPm's env) for the managed process,
588
654
  // autorestarts by default, and runs the bun binary directly with
589
655
  // `--interpreter none` (so it isn't fed to node).
590
- await runPm([
656
+ startCode = await runPm([
591
657
  "start", bunBin,
592
658
  "--name", PM_PROCESS_NAME,
593
659
  "--interpreter", "none",
@@ -597,7 +663,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
597
663
  await runPm(["save"]); // persist process list for `pm2 resurrect` on reboot
598
664
  } else {
599
665
  const envArgs = Object.entries(daemonEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
600
- await runPm([
666
+ startCode = await runPm([
601
667
  "start",
602
668
  "--name", PM_PROCESS_NAME,
603
669
  "--restart", "always",
@@ -607,6 +673,9 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
607
673
  ]);
608
674
  await runPm(["service", "install"]);
609
675
  }
676
+ // Surface a failed start instead of reporting a daemon that was never registered.
677
+ if (startCode !== 0)
678
+ throw new Error(`${PM_BIN} failed to start "${PM_PROCESS_NAME}" (exit ${startCode}). Check that ${PM_BIN} is installed and on PATH.`);
610
679
  }
611
680
 
612
681
  async function daemonUninstall(): Promise<void> {
@@ -616,7 +685,220 @@ async function daemonUninstall(): Promise<void> {
616
685
  console.log(`Removed ${PM_BIN} process: ${PM_PROCESS_NAME}`);
617
686
  }
618
687
 
619
- async function setup(opts: { profile?: string } = {}): Promise<void> {
688
+ // Read the extension's auth token straight from a profile's localStorage LevelDB. Read-only
689
+ // (we never take LevelDB's lock), so it's safe while the user's Chrome is running. The token is
690
+ // the value of the `auth-token` key under the extension origin, stored as a 0x01 (Latin-1)
691
+ // encoding byte followed by the 43-char base64url token. LevelDB prefix-compression can split the
692
+ // origin string across block-restart points, so we anchor on the `auth-token` marker + token shape
693
+ // and (when possible) require the extension id to appear in the same file to avoid a collision
694
+ // with another extension's `auth-token`. Returns the newest token found, or null.
695
+ function readExtensionTokenFromProfile(userDataDir: string, profileDir: string): string | null {
696
+ const dir = join(userDataDir, profileDir, "Local Storage", "leveldb");
697
+ let files: string[];
698
+ try { files = readdirSync(dir).filter(f => f.endsWith(".ldb") || f.endsWith(".log")).sort(); }
699
+ catch { return null; }
700
+ const extIdChunk = EXTENSION_ID.slice(0, 20); // contiguous prefix survives the LevelDB split
701
+ const scan = (requireExtId: boolean): string | null => {
702
+ let found: string | null = null;
703
+ for (const f of files) {
704
+ let buf: Buffer;
705
+ try { buf = readFileSync(join(dir, f)); } catch { continue; }
706
+ if (requireExtId && !buf.includes(extIdChunk, 0, "latin1")) continue;
707
+ let idx = 0;
708
+ while (true) {
709
+ const j = buf.indexOf("auth-token", idx, "latin1");
710
+ if (j < 0) break;
711
+ idx = j + 1;
712
+ const win = buf.subarray(j, Math.min(buf.length, j + 200)).toString("latin1");
713
+ const m = win.match(/\x01([A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/);
714
+ if (m) found = m[1]; // newest file / newest occurrence wins
715
+ }
716
+ }
717
+ return found;
718
+ };
719
+ return scan(true) ?? scan(false);
720
+ }
721
+
722
+ // Resolve a Chromium / Chrome-for-Testing executable from the Playwright browsers cache.
723
+ // Managed (provisioned) profiles must run on Chromium because branded Google Chrome 149+ rejects
724
+ // --load-extension. Returns null if no Chromium is installed (`npx playwright install chromium`).
725
+ function findChromiumForTesting(): string | null {
726
+ // Honor PLAYWRIGHT_BROWSERS_PATH (the user's convention) first, then the platform default —
727
+ // `playwright install` doesn't always write to the env path, so check both.
728
+ const bases = [
729
+ process.env.PLAYWRIGHT_BROWSERS_PATH,
730
+ process.platform === "win32" ? join(HOME, "AppData/Local/ms-playwright")
731
+ : process.platform === "darwin" ? join(HOME, "Library/Caches/ms-playwright")
732
+ : join(HOME, ".cache/ms-playwright"),
733
+ ].filter((b): b is string => !!b);
734
+ for (const base of bases) {
735
+ let revs: string[];
736
+ try { revs = readdirSync(base).filter(d => /^chromium-\d+$/.test(d)).sort((a, b) => parseInt(b.slice(9)) - parseInt(a.slice(9))); }
737
+ catch { continue; }
738
+ for (const rev of revs) {
739
+ const root = join(base, rev);
740
+ const candidates = process.platform === "darwin"
741
+ ? readdirSync(root).filter(d => d.startsWith("chrome-mac")).flatMap(d => {
742
+ const appsDir = join(root, d);
743
+ let apps: string[] = [];
744
+ try { apps = readdirSync(appsDir).filter(a => a.endsWith(".app")); } catch {}
745
+ return apps.map(a => join(appsDir, a, "Contents/MacOS", a.replace(/\.app$/, "")));
746
+ })
747
+ : process.platform === "win32"
748
+ ? [join(root, "chrome-win", "chrome.exe")]
749
+ : [join(root, "chrome-linux", "chrome")];
750
+ for (const c of candidates) if (existsSync(c)) return c;
751
+ }
752
+ }
753
+ return null;
754
+ }
755
+
756
+ // Minimal Chrome DevTools Protocol client over a WebSocket — just enough to create a
757
+ // target, attach to it, and evaluate JS. Used to seed the auth token into a managed
758
+ // profile's extension localStorage without pulling in the full Playwright dependency.
759
+ class CDPClient {
760
+ private ws: WebSocket;
761
+ private nextId = 0;
762
+ private pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
763
+ private opened: Promise<void>;
764
+ constructor(url: string) {
765
+ this.ws = new WebSocket(url);
766
+ this.opened = new Promise<void>((resolve, reject) => {
767
+ this.ws.addEventListener("open", () => resolve(), { once: true });
768
+ this.ws.addEventListener("error", () => reject(new Error("CDP WebSocket error")), { once: true });
769
+ });
770
+ this.ws.addEventListener("message", (ev: MessageEvent) => {
771
+ let msg: any;
772
+ try { msg = JSON.parse(typeof ev.data === "string" ? ev.data : ""); } catch { return; }
773
+ const p = msg.id != null ? this.pending.get(msg.id) : undefined;
774
+ if (!p) return;
775
+ this.pending.delete(msg.id);
776
+ if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
777
+ else p.resolve(msg.result);
778
+ });
779
+ }
780
+ async open(): Promise<void> { await this.opened; }
781
+ send(method: string, params: Record<string, any> = {}, sessionId?: string): Promise<any> {
782
+ const id = ++this.nextId;
783
+ const payload: any = { id, method, params };
784
+ if (sessionId) payload.sessionId = sessionId;
785
+ return new Promise((resolve, reject) => {
786
+ this.pending.set(id, { resolve, reject });
787
+ this.ws.send(JSON.stringify(payload));
788
+ setTimeout(() => { if (this.pending.delete(id)) reject(new Error(`CDP ${method} timed out`)); }, 15_000);
789
+ });
790
+ }
791
+ close(): void { try { this.ws.close(); } catch {} }
792
+ }
793
+
794
+ // Launch a throwaway Chrome against a dedicated user-data-dir with the unpacked extension
795
+ // loaded, then seed `token` into the extension's localStorage (the value `connect.html` checks
796
+ // for token-bypass). Headless by default; never touches the user's real Chrome/profiles.
797
+ async function provisionExtensionToken(opts: {
798
+ userDataDir: string; profileDir: string; dist: string; token: string; headed?: boolean;
799
+ }): Promise<void> {
800
+ // Branded Google Chrome 149+ rejects --load-extension ("not allowed in Google Chrome"), so a
801
+ // managed profile must be seeded on Chromium / Chrome for Testing, which still honors the flag.
802
+ const chromeBin = findChromiumForTesting();
803
+ if (!chromeBin) throw new Error("Chromium / Chrome for Testing not found — run `npx playwright install chromium`");
804
+ const { userDataDir, profileDir, dist, token } = opts;
805
+ mkdirSync(userDataDir, { recursive: true });
806
+ const portFile = join(userDataDir, "DevToolsActivePort");
807
+ try { unlinkSync(portFile); } catch {}
808
+ const args = [
809
+ `--user-data-dir=${userDataDir}`,
810
+ `--profile-directory=${profileDir}`,
811
+ `--load-extension=${dist}`,
812
+ `--disable-extensions-except=${dist}`,
813
+ "--remote-debugging-port=0",
814
+ "--no-first-run",
815
+ "--no-default-browser-check",
816
+ "--disable-background-timer-throttling",
817
+ ];
818
+ if (!opts.headed) args.push("--headless=new");
819
+ if (process.platform === "linux") args.push("--no-sandbox");
820
+ args.push("about:blank");
821
+ const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore" });
822
+ let cdp: CDPClient | null = null;
823
+ try {
824
+ // Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
825
+ let port: number | null = null;
826
+ for (let i = 0; i < 100; i++) {
827
+ await Bun.sleep(100);
828
+ const line = (await file(portFile).text().catch(() => "")).split("\n")[0]?.trim();
829
+ if (line && /^\d+$/.test(line)) { port = parseInt(line); break; }
830
+ if (proc.exitCode !== null) throw new Error("Chrome exited before opening the DevTools port");
831
+ }
832
+ if (!port) throw new Error("Chrome DevTools port not found (extension may have failed to load)");
833
+ const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
834
+ cdp = new CDPClient(ver.webSocketDebuggerUrl as string);
835
+ await cdp.open();
836
+ const { targetId } = await cdp.send("Target.createTarget", { url: `chrome-extension://${EXTENSION_ID}/status.html` });
837
+ const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
838
+ // The extension page may still be loading; retry the write until localStorage reflects it.
839
+ let ok = false;
840
+ const expr = `(()=>{try{localStorage.setItem('auth-token',${JSON.stringify(token)});return localStorage.getItem('auth-token');}catch(e){return 'ERR:'+e.message}})()`;
841
+ for (let i = 0; i < 50; i++) {
842
+ const r = await cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId).catch(() => null);
843
+ if (r?.result?.value === token) { ok = true; break; }
844
+ await Bun.sleep(100);
845
+ }
846
+ if (!ok) throw new Error(`Could not seed auth token into chrome-extension://${EXTENSION_ID}/ (is the extension loading?)`);
847
+ // Graceful close flushes localStorage to the profile's leveldb before we kill Chrome.
848
+ await cdp.send("Browser.close").catch(() => {});
849
+ } finally {
850
+ cdp?.close();
851
+ try { proc.kill(); } catch {}
852
+ await proc.exited.catch(() => {});
853
+ }
854
+ }
855
+
856
+ async function provisionProfile(name: string, opts: { headed?: boolean } = {}): Promise<void> {
857
+ // The name doubles as the on-disk profile directory and the registry/URL key, so keep it a
858
+ // simple token and disallow the reserved real-Chrome names to avoid any cross-talk.
859
+ if (!name || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || /^(Default|Profile \d+)$/i.test(name)) {
860
+ console.error(`Invalid profile name: "${name ?? ""}". Use letters/digits/._- (not "Default"/"Profile N").`);
861
+ process.exit(1);
862
+ }
863
+ const dist = await ensureExtensionDistInstalled();
864
+ const userDataDir = join(RECH_HOME_DIR, "profiles", name);
865
+ const token = randomBytes(32).toString("base64url");
866
+
867
+ console.log(`\n[1/3] Provisioning managed profile "${name}"`);
868
+ console.log(` user-data-dir: ${userDataDir}`);
869
+ console.log(` extension: ${dist}`);
870
+ console.log(` Launching ${opts.headed ? "headed" : "headless"} Chrome to seed the auth token...`);
871
+ await provisionExtensionToken({ userDataDir, profileDir: name, dist, token, headed: opts.headed });
872
+ console.log(` Token seeded (${token.slice(0, 6)}…)`);
873
+
874
+ // [2/3] Daemon URL — reuse the running daemon's key; warn (don't fail) if it isn't up yet.
875
+ console.log(`\n[2/3] Building RECHROME_URL`);
876
+ const url = await getOrCreateUrl();
877
+ const { host, port, protocol, key } = parseUrl(url);
878
+ const healthy = await fetch(`${protocol}://${host}:${port}/ping`, {
879
+ headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(2000),
880
+ }).then(r => r.ok).catch(() => false);
881
+ if (!healthy) console.log(` Note: daemon not reachable at ${host}:${port} — run \`rech setup\` once to start it.`);
882
+
883
+ const rechUrl = new URL(`${protocol}://${host}:${port}`);
884
+ rechUrl.username = key || randomBytes(12).toString("base64url");
885
+ rechUrl.searchParams.set("extension_id", EXTENSION_ID);
886
+ rechUrl.searchParams.set("token", token);
887
+ rechUrl.searchParams.set("profile", name);
888
+ rechUrl.searchParams.set("user_data_dir", userDataDir);
889
+ rechUrl.searchParams.set("load_extension", dist);
890
+ const newLine = `RECHROME_URL=${rechUrl.toString()}`;
891
+
892
+ // [3/3] Register in the token registry so `rech status` lists it and the daemon can resolve it.
893
+ await saveTokenEntry(name, { extensionId: EXTENSION_ID, token, profileDir: name, userDataDir, loadExtension: dist });
894
+ console.log(`\n[3/3] Registered "${name}" in ${TOKENS_FILE}`);
895
+
896
+ console.log(`\nDone! RECHROME_URL for "${name}":\n\n ${newLine}\n`);
897
+ console.log(`Use it per-call:\n ${newLine.replace("RECHROME_URL=", "RECHROME_URL='")}' rech open https://example.com\n`);
898
+ console.log(`Or save it to a project .env.local to make it the default.`);
899
+ }
900
+
901
+ async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
620
902
  const { createInterface } = await import("readline");
621
903
  const isTTY = process.stdin.isTTY ?? false;
622
904
  let rl: ReturnType<typeof createInterface> | null = null;
@@ -770,7 +1052,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
770
1052
  return available[idx];
771
1053
  }
772
1054
 
773
- async function getExtAndToken(profileDir: string, profileDisplay: string, profileKey: string): Promise<{ extId: string; token: string } | null> {
1055
+ async function getExtAndToken(profileDir: string, profileDisplay: string, profileKey: string, providedToken?: string): Promise<{ extId: string; token: string } | null> {
774
1056
  // Extension check
775
1057
  let extId: string | undefined;
776
1058
  // Copy bundled dist to a stable per-user location so the install path survives bunx temp-dir cleanup.
@@ -780,21 +1062,58 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
780
1062
  if (found) { extId = found.id; break; }
781
1063
  console.log(`\n Extension not found in profile: ${profileDisplay}`);
782
1064
  console.log(` Extension dist: ${EXTENSION_DIST_DIR}`);
783
- // Non-TTY (agent/pipe) can't install an extension interactively, and `ask` doesn't block on an exhausted stdin queue —
784
- // looping here would spawn `open` per iteration until the OS runs out of resources. Fail fast instead.
785
- if (!isTTY) {
786
- console.error(` Non-TTY: cannot install extension interactively — aborting`);
787
- return null;
788
- }
789
1065
  const setupHtmlPath = join(RECH_HOME_DIR, "setup.html");
790
1066
  mkdirSync(RECH_HOME_DIR, { recursive: true });
791
1067
  await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
792
- console.log(`\n Opening install guide in your browser...`);
793
- Bun.spawn(["open", setupHtmlPath], { stdout: "ignore", stderr: "ignore" });
1068
+ // Open the install guide directly in the *target* profile (resolved from --profile), so
1069
+ // "Load unpacked" lands in the right Chrome. This is a new tab, not a restart.
1070
+ console.log(`\n Opening install guide in Chrome profile: ${profileDisplay}`);
1071
+ openInChromeProfile(profileDir, setupHtmlPath);
1072
+ // Non-TTY (agent/pipe) can't block on a paste prompt, and `ask` returns immediately on an
1073
+ // exhausted stdin queue — looping would respawn Chrome every iteration. Open the guide once,
1074
+ // then stop with clear re-run instructions instead of spinning.
1075
+ if (!isTTY) {
1076
+ console.error(`\n Non-TTY: load the extension once via chrome://extensions → "Load unpacked":`);
1077
+ console.error(` ${EXTENSION_DIST_DIR}`);
1078
+ console.error(` (open chrome://extensions in profile "${profileDisplay}" — see the guide just opened)`);
1079
+ console.error(` Then re-run: rech setup --profile <num|email> [--token <tok>]`);
1080
+ return null;
1081
+ }
794
1082
  await ask("\n Press Enter after loading the extension to retry...");
795
1083
  }
796
1084
  console.log(` Extension found: ${extId}`);
797
1085
 
1086
+ // Non-interactive token injection (--token / RECH_TOKEN). An explicitly supplied token wins
1087
+ // over both the registry-keep prompt and the paste loop, so a non-TTY agent can register a
1088
+ // profile in one shot. Accepts the bare token or a full `PLAYWRIGHT_MCP_EXTENSION_TOKEN=...`.
1089
+ if (providedToken) {
1090
+ const token = providedToken.replace(/^.*?=/, "").trim();
1091
+ if (token.length < 20) {
1092
+ console.error(` Provided token too short (${token.length} chars) — pass the full PLAYWRIGHT_MCP_EXTENSION_TOKEN value`);
1093
+ return null;
1094
+ }
1095
+ console.log(` Using provided token: ${token.slice(0, 6)}…`);
1096
+ return { extId, token };
1097
+ }
1098
+
1099
+ // Default automation: read the auth token straight from the profile's localStorage LevelDB,
1100
+ // so an installed extension needs no manual paste (works the same in TTY and non-TTY). The
1101
+ // token is minted lazily the first time the status/connect page loads, so if it isn't there
1102
+ // yet, open status.html in this profile to mint it (a new tab — never a restart) and re-scan.
1103
+ if (userDataDir) {
1104
+ let auto = readExtensionTokenFromProfile(userDataDir, profileDir);
1105
+ if (!auto) {
1106
+ console.log(` No token in profile yet — minting via chrome-extension://${extId}/status.html …`);
1107
+ openInChromeProfile(profileDir, `chrome-extension://${extId}/status.html`);
1108
+ for (let i = 0; i < 10 && !auto; i++) { await Bun.sleep(500); auto = readExtensionTokenFromProfile(userDataDir, profileDir); }
1109
+ }
1110
+ if (auto) {
1111
+ console.log(` Auto-read token from profile localStorage: ${auto.slice(0, 6)}…`);
1112
+ return { extId, token: auto };
1113
+ }
1114
+ console.log(` Could not auto-read token from localStorage — falling back to manual entry`);
1115
+ }
1116
+
798
1117
  // Check for existing token in registry
799
1118
  const registry = await readTokenRegistry();
800
1119
  const existing = registry[profileKey];
@@ -813,11 +1132,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
813
1132
  console.log(`\n Get auth token from the extension:`);
814
1133
  console.log(` ${statusUrl}`);
815
1134
  if (isTTY) {
816
- Bun.spawn(
817
- ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
818
- `--profile-directory=${profileDir}`, statusUrl],
819
- { stdout: "ignore", stderr: "ignore", detached: true },
820
- );
1135
+ openInChromeProfile(profileDir, statusUrl);
821
1136
  console.log(`\n Or click the extension icon in the Chrome toolbar.`);
822
1137
  console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
823
1138
  } else {
@@ -857,7 +1172,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
857
1172
  // [3+4/4] Extension + token for primary profile
858
1173
  console.log("\n[3/4] Checking extension...");
859
1174
  const profileEmail = profileInfoSel.user_name || profileDir;
860
- const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail);
1175
+ const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
861
1176
  if (!primary) { rl?.close(); process.exit(1); }
862
1177
  const { extId, token } = primary;
863
1178
 
@@ -932,12 +1247,16 @@ async function status(): Promise<void> {
932
1247
  const { host, port, protocol } = parseUrl(url);
933
1248
  const parsed = parseUrl(url);
934
1249
  const ping = await fetch(`${protocol}://${host}:${port}/`, { signal: AbortSignal.timeout(2000) }).catch(() => null);
935
- // Check actual socket binding via lsof (shows * for 0.0.0.0, or exact IP for loopback-only)
936
- const lsofProc = Bun.spawn(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], { stdout: "pipe", stderr: "ignore" });
937
- const lsofOut = await new Response(lsofProc.stdout).text();
938
- const listenLine = lsofOut.split("\n").find(l => l.includes(`:${port}`));
939
- const listenAddr = listenLine?.match(/TCP\s+(\S+:\d+)/)?.[1] ?? (ping ? `${host}:${port}` : null);
940
- console.log(`serve: ${ping ? `running ${protocol}://${listenAddr ?? `${host}:${port}`}` : "not running"}`);
1250
+ // Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
1251
+ // POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
1252
+ const bind = ping
1253
+ ? await fetch(`${protocol}://${host}:${port}/ping`, {
1254
+ headers: { Authorization: `Bearer ${parsed.key}` },
1255
+ signal: AbortSignal.timeout(2000),
1256
+ }).then(r => (r.ok ? r.json() : null)).then((b: { bind?: string } | null) => b?.bind).catch(() => undefined)
1257
+ : undefined;
1258
+ const listenAddr = bind ? `${bind}:${port}` : `${host}:${port}`;
1259
+ console.log(`serve: ${ping ? `running ${protocol}://${listenAddr}` : "not running"}`);
941
1260
  const pmOut = await pmList();
942
1261
  const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
943
1262
  console.log(`daemon: ${daemonRegistered ? `${PM_BIN} (${PM_PROCESS_NAME})` : "not installed"}`);
@@ -962,7 +1281,16 @@ function printHelp(): void {
962
1281
  console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
963
1282
 
964
1283
  Usage:
965
- rech setup First-time setup: daemon + Chrome extension + config
1284
+ rech setup [--profile <num|email>] [--token <tok>]
1285
+ First-time setup: daemon + Chrome extension + config
1286
+ --profile selects the Chrome profile non-interactively
1287
+ --token (or RECH_TOKEN) supplies the auth token for
1288
+ non-TTY/agent runs, skipping the interactive paste
1289
+ rech provision-profile <name> --experimental [--headed]
1290
+ (experimental) Auto-provision a managed QA profile on
1291
+ Chrome for Testing — branded Chrome 149+ rejects
1292
+ --load-extension, so this is a clean browser, not your
1293
+ real Chrome. For your real Chrome, use \`rech setup\`
966
1294
  rech status Show current configuration and serve health
967
1295
  rech uninstall Remove the serve daemon and clear config
968
1296
  rech serve Start the serve server manually (foreground)
@@ -971,9 +1299,11 @@ Usage:
971
1299
 
972
1300
  Environment:
973
1301
  ${ENV_KEY} Server URL set by \`rech setup\`
1302
+ RECH_TOKEN Auth token for \`rech setup\` (same as --token)
974
1303
 
975
1304
  Examples:
976
1305
  rech setup
1306
+ rech setup --profile 18 --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
977
1307
  rech eval "() => document.title"
978
1308
  rech open https://example.com
979
1309
  rech screenshot`);
@@ -997,7 +1327,29 @@ if (import.meta.main) {
997
1327
  const profile = profileIdx !== -1
998
1328
  ? args[profileIdx + 1]
999
1329
  : args.find(a => a.startsWith("--profile="))?.slice("--profile=".length);
1000
- await setup({ profile }); // setup closes envWatcher itself before printing Done
1330
+ const tokenIdx = args.indexOf("--token");
1331
+ const token = (tokenIdx !== -1
1332
+ ? args[tokenIdx + 1]
1333
+ : args.find(a => a.startsWith("--token="))?.slice("--token=".length))
1334
+ ?? process.env.RECH_TOKEN;
1335
+ await setup({ profile, token }); // setup closes envWatcher itself before printing Done
1336
+ } else if (cmd === "provision-profile") {
1337
+ const name = args.find((a, i) => i > 0 && !a.startsWith("-"));
1338
+ const headed = args.includes("--headed");
1339
+ const experimental = args.includes("--experimental");
1340
+ if (!name) { console.error("Usage: rech provision-profile <name> --experimental [--headed]"); process.exit(1); }
1341
+ // Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
1342
+ // (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
1343
+ // so it's gated behind --experimental rather than offered as the default setup path.
1344
+ if (!experimental) {
1345
+ console.error(`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`);
1346
+ console.error(`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`);
1347
+ console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <N>`);
1348
+ console.error(`To proceed anyway, re-run with --experimental.`);
1349
+ process.exit(1);
1350
+ }
1351
+ await provisionProfile(name, { headed });
1352
+ envWatcher?.close();
1001
1353
  } else if (cmd === "uninstall") {
1002
1354
  await daemonUninstall();
1003
1355
  envWatcher?.close();