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.
- package/README.md +32 -0
- package/package.json +1 -1
- package/rech.js +332 -28
- 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
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
|
-
|
|
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
|
-
|
|
835
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
835
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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();
|