rechrome 1.17.1 → 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 (4) hide show
  1. package/README.md +32 -0
  2. package/package.json +1 -1
  3. package/rech.js +332 -28
  4. package/rech.ts +332 -28
package/README.md CHANGED
@@ -37,6 +37,38 @@ Now `rechrome` (or `rech`) is available globally.
37
37
 
38
38
  ## Quick start
39
39
 
40
+ ### 0. One-command setup (recommended)
41
+
42
+ `rech setup` configures the daemon, Chrome extension, and connection URL in one pass:
43
+
44
+ ```bash
45
+ rech setup # interactive: pick a profile, follow the prompts
46
+ rech setup --profile you@email.com # non-interactive profile selection
47
+ ```
48
+
49
+ What it does per Chrome profile:
50
+
51
+ 1. **Daemon** — installs/starts the `serve` daemon (skipped if already healthy).
52
+ 2. **Extension** — if the multi-tab extension isn't installed in the chosen profile, it opens an
53
+ install guide **in that exact profile**; load it once via `chrome://extensions → Load unpacked`
54
+ (a one-time manual click — Chrome only allows unpacked extensions through the GUI).
55
+ 3. **Token** — once the extension is present, the auth token is **read automatically** from the
56
+ profile's `localStorage` (no copy-paste). For headless/agent runs you can also pass it
57
+ explicitly:
58
+
59
+ ```bash
60
+ rech setup --profile 18 --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN> # or RECH_TOKEN=… rech setup …
61
+ ```
62
+
63
+ `setup` is agent-friendly: it never blocks on a TTY, opens the guide in the right profile, and
64
+ auto-reads the token, so a non-interactive run completes end-to-end once the extension is loaded.
65
+
66
+ > **Managed QA profiles (experimental):** `rech provision-profile <name> --experimental` spins up a
67
+ > fully isolated profile on **Chrome for Testing** (run `npx playwright install chromium` first) with
68
+ > the extension auto-loaded and the token auto-seeded — zero GUI, zero TTY. It is *not* your real
69
+ > Chrome (branded Google Chrome 149+ rejects `--load-extension`), so it has no logins/cookies; use it
70
+ > for clean QA fixtures, and `rech setup` for your real, logged-in Chrome.
71
+
40
72
  ### 1. Start the server
41
73
 
42
74
  On the machine with a browser:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.17.1",
3
+ "version": "1.18.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
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
 
@@ -122,6 +125,26 @@ function findChromeBinary(): string | null {
122
125
  return null;
123
126
  }
124
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
+
125
148
  export function log(msg: string) {
126
149
  mkdirSync(LOG_DIR, { recursive: true });
127
150
  const ts = new Date().toISOString();
@@ -145,6 +168,7 @@ export function parseUrl(raw: string) {
145
168
  extensionToken: u.searchParams.get("token") ?? undefined,
146
169
  profileDirectory: u.searchParams.get("profile") ?? undefined,
147
170
  userDataDir: u.searchParams.get("user_data_dir") ?? undefined,
171
+ loadExtension: u.searchParams.get("load_extension") ?? undefined,
148
172
  };
149
173
  }
150
174
 
@@ -213,7 +237,7 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
213
237
  return { hostname: hostname(), cwd };
214
238
  }
215
239
 
216
- 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>> {
217
241
  const env: Record<string, string> = {};
218
242
  for (const key of PASSTHROUGH_ENV_KEYS) {
219
243
  if (process.env[key]) env[key] = process.env[key];
@@ -224,6 +248,8 @@ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?:
224
248
  env["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] = urlExtras.profileDirectory;
225
249
  if (urlExtras?.userDataDir)
226
250
  env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = urlExtras.userDataDir;
251
+ if (urlExtras?.loadExtension)
252
+ env["PLAYWRIGHT_MCP_LOAD_EXTENSION"] = urlExtras.loadExtension;
227
253
  // Token: shell env wins (explicit override), registry is fallback, URL param is last resort
228
254
  const profileKey = urlExtras?.profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
229
255
  if (profileKey) {
@@ -232,6 +258,7 @@ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?:
232
258
  if (entry) {
233
259
  if (!env["PLAYWRIGHT_MCP_EXTENSION_ID"]) env["PLAYWRIGHT_MCP_EXTENSION_ID"] = entry.extensionId;
234
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;
235
262
  if (!env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"]) {
236
263
  env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = entry.token;
237
264
  } else if (env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] !== entry.token) {
@@ -399,11 +426,11 @@ async function callServe(
399
426
  args: string[],
400
427
  overrideEnv?: Record<string, string>,
401
428
  ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
402
- const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
429
+ const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
403
430
  const identity = await getClientIdentity();
404
431
  const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
405
432
  if (effectiveProfile) (identity as any).profile = effectiveProfile;
406
- const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir })), ...overrideEnv };
433
+ const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
407
434
  const res = await fetch(`${protocol}://${host}:${port}/run`, {
408
435
  method: "POST",
409
436
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
@@ -439,7 +466,7 @@ async function callServe(
439
466
  }
440
467
 
441
468
  async function run(url: string, args: string[]) {
442
- const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
469
+ const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
443
470
  const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
444
471
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
445
472
  const identity = await getClientIdentity();
@@ -448,7 +475,7 @@ async function run(url: string, args: string[]) {
448
475
  `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
449
476
  );
450
477
 
451
- const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir });
478
+ const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
452
479
  const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
453
480
 
454
481
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
@@ -658,7 +685,220 @@ async function daemonUninstall(): Promise<void> {
658
685
  console.log(`Removed ${PM_BIN} process: ${PM_PROCESS_NAME}`);
659
686
  }
660
687
 
661
- 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> {
662
902
  const { createInterface } = await import("readline");
663
903
  const isTTY = process.stdin.isTTY ?? false;
664
904
  let rl: ReturnType<typeof createInterface> | null = null;
@@ -812,7 +1052,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
812
1052
  return available[idx];
813
1053
  }
814
1054
 
815
- 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> {
816
1056
  // Extension check
817
1057
  let extId: string | undefined;
818
1058
  // Copy bundled dist to a stable per-user location so the install path survives bunx temp-dir cleanup.
@@ -822,21 +1062,58 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
822
1062
  if (found) { extId = found.id; break; }
823
1063
  console.log(`\n Extension not found in profile: ${profileDisplay}`);
824
1064
  console.log(` Extension dist: ${EXTENSION_DIST_DIR}`);
825
- // Non-TTY (agent/pipe) can't install an extension interactively, and `ask` doesn't block on an exhausted stdin queue —
826
- // looping here would spawn `open` per iteration until the OS runs out of resources. Fail fast instead.
827
- if (!isTTY) {
828
- console.error(` Non-TTY: cannot install extension interactively — aborting`);
829
- return null;
830
- }
831
1065
  const setupHtmlPath = join(RECH_HOME_DIR, "setup.html");
832
1066
  mkdirSync(RECH_HOME_DIR, { recursive: true });
833
1067
  await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
834
- console.log(`\n Opening install guide in your browser...`);
835
- openInDefaultApp(setupHtmlPath);
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
+ }
836
1082
  await ask("\n Press Enter after loading the extension to retry...");
837
1083
  }
838
1084
  console.log(` Extension found: ${extId}`);
839
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
+
840
1117
  // Check for existing token in registry
841
1118
  const registry = await readTokenRegistry();
842
1119
  const existing = registry[profileKey];
@@ -855,13 +1132,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
855
1132
  console.log(`\n Get auth token from the extension:`);
856
1133
  console.log(` ${statusUrl}`);
857
1134
  if (isTTY) {
858
- const chromeBin = findChromeBinary();
859
- if (chromeBin) {
860
- Bun.spawn(
861
- [chromeBin, `--profile-directory=${profileDir}`, statusUrl],
862
- { stdout: "ignore", stderr: "ignore", detached: true },
863
- );
864
- }
1135
+ openInChromeProfile(profileDir, statusUrl);
865
1136
  console.log(`\n Or click the extension icon in the Chrome toolbar.`);
866
1137
  console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
867
1138
  } else {
@@ -901,7 +1172,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
901
1172
  // [3+4/4] Extension + token for primary profile
902
1173
  console.log("\n[3/4] Checking extension...");
903
1174
  const profileEmail = profileInfoSel.user_name || profileDir;
904
- const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail);
1175
+ const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
905
1176
  if (!primary) { rl?.close(); process.exit(1); }
906
1177
  const { extId, token } = primary;
907
1178
 
@@ -1010,7 +1281,16 @@ function printHelp(): void {
1010
1281
  console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
1011
1282
 
1012
1283
  Usage:
1013
- 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\`
1014
1294
  rech status Show current configuration and serve health
1015
1295
  rech uninstall Remove the serve daemon and clear config
1016
1296
  rech serve Start the serve server manually (foreground)
@@ -1019,9 +1299,11 @@ Usage:
1019
1299
 
1020
1300
  Environment:
1021
1301
  ${ENV_KEY} Server URL set by \`rech setup\`
1302
+ RECH_TOKEN Auth token for \`rech setup\` (same as --token)
1022
1303
 
1023
1304
  Examples:
1024
1305
  rech setup
1306
+ rech setup --profile 18 --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1025
1307
  rech eval "() => document.title"
1026
1308
  rech open https://example.com
1027
1309
  rech screenshot`);
@@ -1045,7 +1327,29 @@ if (import.meta.main) {
1045
1327
  const profile = profileIdx !== -1
1046
1328
  ? args[profileIdx + 1]
1047
1329
  : args.find(a => a.startsWith("--profile="))?.slice("--profile=".length);
1048
- 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();
1049
1353
  } else if (cmd === "uninstall") {
1050
1354
  await daemonUninstall();
1051
1355
  envWatcher?.close();
package/rech.ts 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
 
@@ -122,6 +125,26 @@ function findChromeBinary(): string | null {
122
125
  return null;
123
126
  }
124
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
+
125
148
  export function log(msg: string) {
126
149
  mkdirSync(LOG_DIR, { recursive: true });
127
150
  const ts = new Date().toISOString();
@@ -145,6 +168,7 @@ export function parseUrl(raw: string) {
145
168
  extensionToken: u.searchParams.get("token") ?? undefined,
146
169
  profileDirectory: u.searchParams.get("profile") ?? undefined,
147
170
  userDataDir: u.searchParams.get("user_data_dir") ?? undefined,
171
+ loadExtension: u.searchParams.get("load_extension") ?? undefined,
148
172
  };
149
173
  }
150
174
 
@@ -213,7 +237,7 @@ async function getClientIdentity(): Promise<{ gitUrl?: string; hostname?: string
213
237
  return { hostname: hostname(), cwd };
214
238
  }
215
239
 
216
- 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>> {
217
241
  const env: Record<string, string> = {};
218
242
  for (const key of PASSTHROUGH_ENV_KEYS) {
219
243
  if (process.env[key]) env[key] = process.env[key];
@@ -224,6 +248,8 @@ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?:
224
248
  env["PLAYWRIGHT_MCP_PROFILE_DIRECTORY"] = urlExtras.profileDirectory;
225
249
  if (urlExtras?.userDataDir)
226
250
  env["PLAYWRIGHT_MCP_USER_DATA_DIR"] = urlExtras.userDataDir;
251
+ if (urlExtras?.loadExtension)
252
+ env["PLAYWRIGHT_MCP_LOAD_EXTENSION"] = urlExtras.loadExtension;
227
253
  // Token: shell env wins (explicit override), registry is fallback, URL param is last resort
228
254
  const profileKey = urlExtras?.profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
229
255
  if (profileKey) {
@@ -232,6 +258,7 @@ async function getClientEnv(urlExtras?: { extensionId?: string; extensionToken?:
232
258
  if (entry) {
233
259
  if (!env["PLAYWRIGHT_MCP_EXTENSION_ID"]) env["PLAYWRIGHT_MCP_EXTENSION_ID"] = entry.extensionId;
234
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;
235
262
  if (!env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"]) {
236
263
  env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] = entry.token;
237
264
  } else if (env["PLAYWRIGHT_MCP_EXTENSION_TOKEN"] !== entry.token) {
@@ -399,11 +426,11 @@ async function callServe(
399
426
  args: string[],
400
427
  overrideEnv?: Record<string, string>,
401
428
  ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
402
- const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
429
+ const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
403
430
  const identity = await getClientIdentity();
404
431
  const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
405
432
  if (effectiveProfile) (identity as any).profile = effectiveProfile;
406
- const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir })), ...overrideEnv };
433
+ const env = { ...(await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension })), ...overrideEnv };
407
434
  const res = await fetch(`${protocol}://${host}:${port}/run`, {
408
435
  method: "POST",
409
436
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
@@ -439,7 +466,7 @@ async function callServe(
439
466
  }
440
467
 
441
468
  async function run(url: string, args: string[]) {
442
- const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir } = parseUrl(url);
469
+ const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
443
470
  const effectiveProfile = profileDirectory || process.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY;
444
471
  const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
445
472
  const identity = await getClientIdentity();
@@ -448,7 +475,7 @@ async function run(url: string, args: string[]) {
448
475
  `[rech] connecting to ${host}:${port} (identity: ${identity.gitUrl || `${identity.hostname}:${identity.cwd}`}${profileSuffix})`,
449
476
  );
450
477
 
451
- const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir });
478
+ const resolvedEnv = await getClientEnv({ extensionId, extensionToken, profileDirectory, userDataDir, loadExtension });
452
479
  const { status, stdout, stderr, files, existingSession } = await callServe(url, args);
453
480
 
454
481
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
@@ -658,7 +685,220 @@ async function daemonUninstall(): Promise<void> {
658
685
  console.log(`Removed ${PM_BIN} process: ${PM_PROCESS_NAME}`);
659
686
  }
660
687
 
661
- 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> {
662
902
  const { createInterface } = await import("readline");
663
903
  const isTTY = process.stdin.isTTY ?? false;
664
904
  let rl: ReturnType<typeof createInterface> | null = null;
@@ -812,7 +1052,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
812
1052
  return available[idx];
813
1053
  }
814
1054
 
815
- 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> {
816
1056
  // Extension check
817
1057
  let extId: string | undefined;
818
1058
  // Copy bundled dist to a stable per-user location so the install path survives bunx temp-dir cleanup.
@@ -822,21 +1062,58 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
822
1062
  if (found) { extId = found.id; break; }
823
1063
  console.log(`\n Extension not found in profile: ${profileDisplay}`);
824
1064
  console.log(` Extension dist: ${EXTENSION_DIST_DIR}`);
825
- // Non-TTY (agent/pipe) can't install an extension interactively, and `ask` doesn't block on an exhausted stdin queue —
826
- // looping here would spawn `open` per iteration until the OS runs out of resources. Fail fast instead.
827
- if (!isTTY) {
828
- console.error(` Non-TTY: cannot install extension interactively — aborting`);
829
- return null;
830
- }
831
1065
  const setupHtmlPath = join(RECH_HOME_DIR, "setup.html");
832
1066
  mkdirSync(RECH_HOME_DIR, { recursive: true });
833
1067
  await Bun.write(setupHtmlPath, buildSetupHtml(EXTENSION_DIST_DIR, profileDisplay));
834
- console.log(`\n Opening install guide in your browser...`);
835
- openInDefaultApp(setupHtmlPath);
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
+ }
836
1082
  await ask("\n Press Enter after loading the extension to retry...");
837
1083
  }
838
1084
  console.log(` Extension found: ${extId}`);
839
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
+
840
1117
  // Check for existing token in registry
841
1118
  const registry = await readTokenRegistry();
842
1119
  const existing = registry[profileKey];
@@ -855,13 +1132,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
855
1132
  console.log(`\n Get auth token from the extension:`);
856
1133
  console.log(` ${statusUrl}`);
857
1134
  if (isTTY) {
858
- const chromeBin = findChromeBinary();
859
- if (chromeBin) {
860
- Bun.spawn(
861
- [chromeBin, `--profile-directory=${profileDir}`, statusUrl],
862
- { stdout: "ignore", stderr: "ignore", detached: true },
863
- );
864
- }
1135
+ openInChromeProfile(profileDir, statusUrl);
865
1136
  console.log(`\n Or click the extension icon in the Chrome toolbar.`);
866
1137
  console.log(` Copy the token shown on the page (PLAYWRIGHT_MCP_EXTENSION_TOKEN=...).\n`);
867
1138
  } else {
@@ -901,7 +1172,7 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
901
1172
  // [3+4/4] Extension + token for primary profile
902
1173
  console.log("\n[3/4] Checking extension...");
903
1174
  const profileEmail = profileInfoSel.user_name || profileDir;
904
- const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail);
1175
+ const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
905
1176
  if (!primary) { rl?.close(); process.exit(1); }
906
1177
  const { extId, token } = primary;
907
1178
 
@@ -1010,7 +1281,16 @@ function printHelp(): void {
1010
1281
  console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
1011
1282
 
1012
1283
  Usage:
1013
- 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\`
1014
1294
  rech status Show current configuration and serve health
1015
1295
  rech uninstall Remove the serve daemon and clear config
1016
1296
  rech serve Start the serve server manually (foreground)
@@ -1019,9 +1299,11 @@ Usage:
1019
1299
 
1020
1300
  Environment:
1021
1301
  ${ENV_KEY} Server URL set by \`rech setup\`
1302
+ RECH_TOKEN Auth token for \`rech setup\` (same as --token)
1022
1303
 
1023
1304
  Examples:
1024
1305
  rech setup
1306
+ rech setup --profile 18 --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
1025
1307
  rech eval "() => document.title"
1026
1308
  rech open https://example.com
1027
1309
  rech screenshot`);
@@ -1045,7 +1327,29 @@ if (import.meta.main) {
1045
1327
  const profile = profileIdx !== -1
1046
1328
  ? args[profileIdx + 1]
1047
1329
  : args.find(a => a.startsWith("--profile="))?.slice("--profile=".length);
1048
- 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();
1049
1353
  } else if (cmd === "uninstall") {
1050
1354
  await daemonUninstall();
1051
1355
  envWatcher?.close();