rechrome 1.23.0 → 1.23.1
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/extension/lib/background.mjs +16 -3
- package/extension/lib/ui/connect.js +39 -3
- package/extension/manifest.json +2 -1
- package/package.json +1 -1
- package/rech.js +128 -52
- package/rech.ts +128 -52
- package/serve.js +33 -1
- package/serve.ts +33 -1
|
@@ -129,6 +129,7 @@ class RelayConnection {
|
|
|
129
129
|
__publicField(this, "_hasEverAttached", false);
|
|
130
130
|
__publicField(this, "_eventListeners", []);
|
|
131
131
|
__publicField(this, "_closed", false);
|
|
132
|
+
__publicField(this, "_keepAliveInterval");
|
|
132
133
|
__publicField(this, "onclose");
|
|
133
134
|
__publicField(this, "ontabattached");
|
|
134
135
|
__publicField(this, "ontabdetached");
|
|
@@ -143,6 +144,9 @@ class RelayConnection {
|
|
|
143
144
|
this._installEventForwarders();
|
|
144
145
|
this._ws.onmessage = this._onMessage.bind(this);
|
|
145
146
|
this._ws.onclose = () => this._onClose();
|
|
147
|
+
this._keepAliveInterval = setInterval(() => {
|
|
148
|
+
this._sendMessage({ method: "extension.keepalive", params: [] });
|
|
149
|
+
}, 2e4);
|
|
146
150
|
}
|
|
147
151
|
get attachedTabs() {
|
|
148
152
|
return this._attachedTabs;
|
|
@@ -203,6 +207,7 @@ class RelayConnection {
|
|
|
203
207
|
if (this._closed)
|
|
204
208
|
return;
|
|
205
209
|
this._closed = true;
|
|
210
|
+
clearInterval(this._keepAliveInterval);
|
|
206
211
|
for (const l of this._eventListeners)
|
|
207
212
|
l.remove();
|
|
208
213
|
this._eventListeners = [];
|
|
@@ -352,18 +357,23 @@ class PendingConnections {
|
|
|
352
357
|
}
|
|
353
358
|
}
|
|
354
359
|
async function openRelayConnection(mcpRelayUrl, protocolVersion) {
|
|
360
|
+
let socket;
|
|
361
|
+
let timer;
|
|
355
362
|
try {
|
|
356
|
-
|
|
363
|
+
socket = new WebSocket(mcpRelayUrl);
|
|
357
364
|
await new Promise((resolve, reject) => {
|
|
358
365
|
socket.onopen = () => resolve();
|
|
359
366
|
socket.onerror = () => reject(new Error("WebSocket error"));
|
|
360
|
-
setTimeout(() => reject(new Error("Connection timeout")), 5e3);
|
|
367
|
+
timer = setTimeout(() => reject(new Error("Connection timeout")), 5e3);
|
|
361
368
|
});
|
|
362
369
|
return new RelayConnection(socket, protocolVersion);
|
|
363
370
|
} catch (error) {
|
|
371
|
+
socket == null ? void 0 : socket.close();
|
|
364
372
|
const message = `Failed to connect to MCP relay: ${error.message}`;
|
|
365
373
|
debugLog(message);
|
|
366
374
|
throw new Error(message);
|
|
375
|
+
} finally {
|
|
376
|
+
clearTimeout(timer);
|
|
367
377
|
}
|
|
368
378
|
}
|
|
369
379
|
const PLAYWRIGHT_GROUP_TITLE = "pw";
|
|
@@ -595,7 +605,10 @@ class PlaywrightExtension {
|
|
|
595
605
|
}
|
|
596
606
|
async _connectTab(selectorTabId, tab, clientName) {
|
|
597
607
|
try {
|
|
598
|
-
await
|
|
608
|
+
await Promise.race([
|
|
609
|
+
this._cleanupPromise,
|
|
610
|
+
new Promise((resolve) => setTimeout(resolve, 2e3))
|
|
611
|
+
]);
|
|
599
612
|
const connection = await this._pendingConnections.take(selectorTabId);
|
|
600
613
|
if (!connection)
|
|
601
614
|
throw new Error("Pending client connection closed");
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { c as clientExports, j as jsxRuntimeExports, r as reactExports, A as AuthTokenSection, T as TabItem, B as Button, g as getOrCreateAuthToken } from "./authToken.js";
|
|
2
2
|
const SUPPORTED_PROTOCOL_VERSION = 2;
|
|
3
|
+
const BACKGROUND_RESPONSE_TIMEOUT_MS = 1e4;
|
|
4
|
+
async function sendMessageWithTimeout(message, simulateHang = false) {
|
|
5
|
+
let timer;
|
|
6
|
+
try {
|
|
7
|
+
return await Promise.race([
|
|
8
|
+
simulateHang ? new Promise(() => {
|
|
9
|
+
}) : chrome.runtime.sendMessage(message),
|
|
10
|
+
new Promise((_, reject) => {
|
|
11
|
+
timer = setTimeout(() => reject(new Error("Extension service worker did not respond")), BACKGROUND_RESPONSE_TIMEOUT_MS);
|
|
12
|
+
})
|
|
13
|
+
]);
|
|
14
|
+
} finally {
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
3
18
|
const ConnectApp = () => {
|
|
4
19
|
const [tabs, setTabs] = reactExports.useState([]);
|
|
5
20
|
const [status, setStatus] = reactExports.useState(null);
|
|
@@ -13,6 +28,8 @@ const ConnectApp = () => {
|
|
|
13
28
|
const runAsync = async () => {
|
|
14
29
|
const params = new URLSearchParams(window.location.search);
|
|
15
30
|
const relayUrl = params.get("mcpRelayUrl");
|
|
31
|
+
const recoveryAttempt = params.get("recoveryAttempt") === "1";
|
|
32
|
+
const hasAutomationToken = !!params.get("token");
|
|
16
33
|
if (!relayUrl) {
|
|
17
34
|
setError("Missing mcpRelayUrl parameter in URL.");
|
|
18
35
|
return;
|
|
@@ -53,7 +70,20 @@ const ConnectApp = () => {
|
|
|
53
70
|
});
|
|
54
71
|
return;
|
|
55
72
|
}
|
|
56
|
-
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await sendMessageWithTimeout(
|
|
76
|
+
{ type: "connectionRequested", mcpRelayUrl: relayUrl, protocolVersion: requestedVersion },
|
|
77
|
+
params.get("testHangOnce") === "1" && !recoveryAttempt
|
|
78
|
+
);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (hasAutomationToken && !recoveryAttempt) {
|
|
81
|
+
setError("Extension service worker is not responding. Retrying once…");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
setError(`Extension service worker did not recover: ${error.message}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
57
87
|
if (!response.success) {
|
|
58
88
|
setError(response.error);
|
|
59
89
|
return;
|
|
@@ -90,7 +120,7 @@ const ConnectApp = () => {
|
|
|
90
120
|
const handleConnectToTab = reactExports.useCallback(async (tab, clientName = clientInfo) => {
|
|
91
121
|
setShowTabList(false);
|
|
92
122
|
try {
|
|
93
|
-
const response = await
|
|
123
|
+
const response = await sendMessageWithTimeout({
|
|
94
124
|
type: "connectToTab",
|
|
95
125
|
tab,
|
|
96
126
|
clientName
|
|
@@ -104,12 +134,18 @@ const ConnectApp = () => {
|
|
|
104
134
|
});
|
|
105
135
|
}
|
|
106
136
|
} catch (e) {
|
|
137
|
+
const recoveryAttempt = new URLSearchParams(window.location.search).get("recoveryAttempt") === "1";
|
|
138
|
+
const hasAutomationToken = !!new URLSearchParams(window.location.search).get("token");
|
|
139
|
+
if (hasAutomationToken && !recoveryAttempt) {
|
|
140
|
+
setError("Extension service worker stopped during connection. Retrying once…");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
107
143
|
setStatus({
|
|
108
144
|
type: "error",
|
|
109
145
|
message: `"${clientName}" failed to connect: ${e}`
|
|
110
146
|
});
|
|
111
147
|
}
|
|
112
|
-
}, [clientInfo]);
|
|
148
|
+
}, [clientInfo, setError]);
|
|
113
149
|
reactExports.useEffect(() => {
|
|
114
150
|
const listener = (message) => {
|
|
115
151
|
if (message.type === "pendingConnectionClosed") {
|
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Playwright Extension",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.2",
|
|
5
5
|
"description": "Connect your browser to AI agents through Playwright MCP server and CLI. Enables AI-driven web testing, debugging, and automation.",
|
|
6
|
+
"minimum_chrome_version": "116",
|
|
6
7
|
"permissions": [
|
|
7
8
|
"debugger",
|
|
8
9
|
"activeTab",
|
package/package.json
CHANGED
package/rech.js
CHANGED
|
@@ -351,7 +351,9 @@ const CHROME_LOCAL_STATE_PATHS = () => {
|
|
|
351
351
|
];
|
|
352
352
|
};
|
|
353
353
|
|
|
354
|
-
|
|
354
|
+
type ChromeProfileInfo = { user_name?: string; name?: string };
|
|
355
|
+
|
|
356
|
+
async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
|
|
355
357
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
356
358
|
const f = file(statePath);
|
|
357
359
|
if (!(await f.exists())) continue;
|
|
@@ -363,6 +365,41 @@ async function readChromeProfileCache(): Promise<Record<string, { user_name?: st
|
|
|
363
365
|
return null;
|
|
364
366
|
}
|
|
365
367
|
|
|
368
|
+
export function resolveChromeProfileSelector(
|
|
369
|
+
profiles: Array<[string, ChromeProfileInfo]>,
|
|
370
|
+
selector: string,
|
|
371
|
+
): [string, ChromeProfileInfo] | null {
|
|
372
|
+
const value = selector.trim();
|
|
373
|
+
validateChromeProfileSelector(value);
|
|
374
|
+
|
|
375
|
+
const needle = value.toLowerCase();
|
|
376
|
+
const selectors: Array<{ label: string; value: (dir: string, info: ChromeProfileInfo) => string }> = [
|
|
377
|
+
{ label: "email", value: (_dir, info) => info.user_name ?? "" },
|
|
378
|
+
{ label: "Chrome profile name", value: (_dir, info) => info.name ?? "" },
|
|
379
|
+
{ label: "profile folder name", value: (dir) => dir },
|
|
380
|
+
];
|
|
381
|
+
for (const kind of selectors) {
|
|
382
|
+
const matches = profiles.filter(([dir, info]) => kind.value(dir, info).trim().toLowerCase() === needle);
|
|
383
|
+
if (matches.length > 1) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`--profile "${value}" matches multiple profiles by ${kind.label}. ` +
|
|
386
|
+
`Use a unique email or profile folder name from \`rech profiles\`.`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (matches.length === 1) return matches[0];
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function validateChromeProfileSelector(selector: string): void {
|
|
395
|
+
const value = selector.trim();
|
|
396
|
+
if (!/^\d+$/.test(value)) return;
|
|
397
|
+
throw new Error(
|
|
398
|
+
`--profile no longer accepts menu numbers (received "${value}"). ` +
|
|
399
|
+
`Use the profile email, Chrome profile name, or profile folder name from \`rech profiles\`.`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
366
403
|
async function findChromeUserDataDir(): Promise<string | null> {
|
|
367
404
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
368
405
|
if (!(await file(statePath).exists())) continue;
|
|
@@ -480,15 +517,14 @@ async function listProfiles(): Promise<void> {
|
|
|
480
517
|
}
|
|
481
518
|
}
|
|
482
519
|
|
|
483
|
-
//
|
|
484
|
-
// "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
|
|
520
|
+
// Columns mirror selector precedence. Only user_name (email) + name (profile name) are read
|
|
485
521
|
// from Local State — the gaia real name is deliberately never surfaced.
|
|
486
522
|
const rows = [
|
|
487
|
-
["
|
|
523
|
+
["EMAIL", "PROFILE NAME", "FOLDER", ""],
|
|
488
524
|
...Object.entries(cache).map(([dir, info]) => [
|
|
489
|
-
dir,
|
|
490
525
|
info.user_name || "",
|
|
491
526
|
info.name || "",
|
|
527
|
+
dir,
|
|
492
528
|
dir === currentDir ? "← current" : "",
|
|
493
529
|
]),
|
|
494
530
|
];
|
|
@@ -546,7 +582,16 @@ async function callServe(
|
|
|
546
582
|
return res.json();
|
|
547
583
|
}
|
|
548
584
|
|
|
585
|
+
export function normalizeCommandArgs(args: string[]): string[] {
|
|
586
|
+
const normalized = [...args];
|
|
587
|
+
if (normalized[0] === "tabs" || normalized[0] === "list") normalized[0] = "tab-list";
|
|
588
|
+
return normalized;
|
|
589
|
+
}
|
|
590
|
+
|
|
549
591
|
async function run(url: string, args: string[]) {
|
|
592
|
+
// Match the underlying CLI's command names while accepting the short forms humans
|
|
593
|
+
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
594
|
+
args = normalizeCommandArgs(args);
|
|
550
595
|
const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
551
596
|
const effectiveProfile = resolveEffectiveProfile(profileDirectory);
|
|
552
597
|
const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
|
|
@@ -570,10 +615,17 @@ async function run(url: string, args: string[]) {
|
|
|
570
615
|
if (stderr.includes('Extension connection timeout')) {
|
|
571
616
|
const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
|
|
572
617
|
const last = hasToken
|
|
573
|
-
? ` -x: extension token
|
|
618
|
+
? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
|
|
574
619
|
: ` -> extension[not installed] (run: rech setup)`;
|
|
575
620
|
console.error(`[rech] rech-client -> rech-server[ok] -> playwright[ok]\n${last}`);
|
|
576
621
|
}
|
|
622
|
+
if (stderr.includes("Browser '") && stderr.includes("is not open")) {
|
|
623
|
+
console.error(
|
|
624
|
+
`[rech] the session id is derived from this worktree and profile; it is not a persisted stale id. ` +
|
|
625
|
+
`The preceding open did not finish. Retry \`rech open <url>\`; if it reports an extension timeout, ` +
|
|
626
|
+
`reload Playwright MCP Bridge at chrome://extensions and retry.`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
577
629
|
process.stderr.write(stderr);
|
|
578
630
|
}
|
|
579
631
|
if (stdout) process.stdout.write(stdout);
|
|
@@ -1136,6 +1188,15 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
1136
1188
|
}
|
|
1137
1189
|
|
|
1138
1190
|
async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
|
|
1191
|
+
if (opts.profile !== undefined) {
|
|
1192
|
+
try {
|
|
1193
|
+
validateChromeProfileSelector(opts.profile);
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1196
|
+
envWatcher?.close();
|
|
1197
|
+
process.exit(1);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1139
1200
|
const { createInterface } = await import("readline");
|
|
1140
1201
|
const isTTY = process.stdin.isTTY ?? false;
|
|
1141
1202
|
let rl: ReturnType<typeof createInterface> | null = null;
|
|
@@ -1157,8 +1218,8 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1157
1218
|
return new Promise<string>(r => rl!.question("", ans => r(ans || def)));
|
|
1158
1219
|
};
|
|
1159
1220
|
|
|
1160
|
-
// [1/
|
|
1161
|
-
console.log("\n[1/
|
|
1221
|
+
// [1/5] Daemon
|
|
1222
|
+
console.log("\n[1/5] Checking serve daemon...");
|
|
1162
1223
|
|
|
1163
1224
|
// Bind address (persists to ~/.env.local as RECH_HOST).
|
|
1164
1225
|
// Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
|
|
@@ -1262,40 +1323,20 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1262
1323
|
if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
|
|
1263
1324
|
const userDataDir = await findChromeUserDataDir();
|
|
1264
1325
|
|
|
1265
|
-
async function pickProfile(exclude: Set<string>): Promise<[string,
|
|
1326
|
+
async function pickProfile(exclude: Set<string>): Promise<[string, ChromeProfileInfo] | null> {
|
|
1266
1327
|
const available = Object.entries(cache!).filter(([dir]) => !exclude.has(dir));
|
|
1267
1328
|
if (!available.length) return null;
|
|
1268
1329
|
available.forEach(([dir, info], i) =>
|
|
1269
1330
|
console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
|
|
1270
1331
|
);
|
|
1271
1332
|
if (opts.profile !== undefined) {
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
// selection either way. Email is the unambiguous selector — steer toward it.
|
|
1279
|
-
const sel = available[num - 1] ?? null;
|
|
1280
|
-
const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
|
|
1281
|
-
if (dirNamed && dirNamed[0] !== sel?.[0]) {
|
|
1282
|
-
const hint = dirNamed[1].user_name || `"Profile ${num}"`;
|
|
1283
|
-
console.error(
|
|
1284
|
-
` [warn] --profile ${num} = menu index ${num} → ` +
|
|
1285
|
-
`${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
|
|
1286
|
-
`NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
|
|
1287
|
-
`For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
|
|
1288
|
-
);
|
|
1289
|
-
}
|
|
1290
|
-
if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
|
|
1291
|
-
return sel;
|
|
1333
|
+
let match: [string, ChromeProfileInfo] | null;
|
|
1334
|
+
try {
|
|
1335
|
+
match = resolveChromeProfileSelector(available, opts.profile);
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
console.error(` ${error instanceof Error ? error.message : String(error)}`);
|
|
1338
|
+
return null;
|
|
1292
1339
|
}
|
|
1293
|
-
const needle = opts.profile.toLowerCase();
|
|
1294
|
-
const match = available.find(([dir, info]) =>
|
|
1295
|
-
dir.toLowerCase() === needle
|
|
1296
|
-
|| (info.name ?? "").toLowerCase() === needle
|
|
1297
|
-
|| (info.user_name ?? "").toLowerCase().includes(needle)
|
|
1298
|
-
) ?? null;
|
|
1299
1340
|
if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
|
|
1300
1341
|
return match;
|
|
1301
1342
|
}
|
|
@@ -1303,7 +1344,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1303
1344
|
console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
|
|
1304
1345
|
return available[0];
|
|
1305
1346
|
}
|
|
1306
|
-
if (!isTTY) console.log(" [agent] Provide profile number on next stdin line, or rerun with --profile <
|
|
1347
|
+
if (!isTTY) console.log(" [agent] Provide profile number on next stdin line, or rerun with --profile <email|name|folder>");
|
|
1307
1348
|
const answer = await ask("\n Profile number: ");
|
|
1308
1349
|
const idx = parseInt(answer.trim()) - 1;
|
|
1309
1350
|
if (isNaN(idx) || idx < 0 || idx >= available.length) return null;
|
|
@@ -1334,7 +1375,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1334
1375
|
console.error(`\n Non-TTY: load the extension once via chrome://extensions → "Load unpacked":`);
|
|
1335
1376
|
console.error(` ${EXTENSION_DIST_DIR}`);
|
|
1336
1377
|
console.error(` (open chrome://extensions in profile "${profileDisplay}" — see the guide just opened)`);
|
|
1337
|
-
console.error(` Then re-run: rech setup --profile <
|
|
1378
|
+
console.error(` Then re-run: rech setup --profile <email|name|folder> [--token <tok>]`);
|
|
1338
1379
|
return null;
|
|
1339
1380
|
}
|
|
1340
1381
|
await ask("\n Press Enter after loading the extension to retry...");
|
|
@@ -1420,21 +1461,22 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1420
1461
|
return null;
|
|
1421
1462
|
}
|
|
1422
1463
|
|
|
1423
|
-
// [2/
|
|
1424
|
-
console.log("\n[2/
|
|
1464
|
+
// [2/5] Primary profile
|
|
1465
|
+
console.log("\n[2/5] Select Chrome profile:");
|
|
1425
1466
|
const picked = await pickProfile(new Set());
|
|
1426
1467
|
if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
|
|
1427
1468
|
const [profileDir, profileInfoSel] = picked;
|
|
1428
1469
|
const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
|
|
1429
1470
|
|
|
1430
|
-
// [3
|
|
1431
|
-
console.log("\n[3/
|
|
1471
|
+
// [3/5] Extension + token for primary profile
|
|
1472
|
+
console.log("\n[3/5] Checking extension...");
|
|
1432
1473
|
const profileEmail = profileInfoSel.user_name || profileDir;
|
|
1433
1474
|
const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
|
|
1434
1475
|
if (!primary) { rl?.close(); process.exit(1); }
|
|
1435
1476
|
const { extId, token } = primary;
|
|
1436
1477
|
|
|
1437
|
-
// Build RECHROME_URL
|
|
1478
|
+
// Build RECHROME_URL, verify the selected profile can complete a real extension
|
|
1479
|
+
// handshake, then show it before asking where to save.
|
|
1438
1480
|
const rechUrl = new URL(url);
|
|
1439
1481
|
if (!rechUrl.username) rechUrl.username = randomBytes(12).toString("base64url");
|
|
1440
1482
|
rechUrl.searchParams.set("extension_id", extId);
|
|
@@ -1442,7 +1484,39 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1442
1484
|
rechUrl.searchParams.set("profile", profileEmail);
|
|
1443
1485
|
if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
|
|
1444
1486
|
const newLine = `RECHROME_URL=${rechUrl.toString()}`;
|
|
1445
|
-
console.log(`\n[4/
|
|
1487
|
+
console.log(`\n[4/5] Verifying extension bridge for ${profileDisplay}...`);
|
|
1488
|
+
const probeSession = `iso-setup-${randomBytes(4).toString("hex")}`;
|
|
1489
|
+
const probeIdentity = await getClientIdentity();
|
|
1490
|
+
probeIdentity.profile = profileEmail;
|
|
1491
|
+
const probeEnv: Record<string, string> = {
|
|
1492
|
+
PLAYWRIGHT_MCP_EXTENSION_ID: extId,
|
|
1493
|
+
PLAYWRIGHT_MCP_EXTENSION_TOKEN: token,
|
|
1494
|
+
PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileEmail,
|
|
1495
|
+
...(userDataDir ? { PLAYWRIGHT_MCP_USER_DATA_DIR: userDataDir } : {}),
|
|
1496
|
+
};
|
|
1497
|
+
let bridgeVerified = false;
|
|
1498
|
+
try {
|
|
1499
|
+
const probe = await callServe(
|
|
1500
|
+
rechUrl.toString(),
|
|
1501
|
+
[`-s=${probeSession}`, "open", "about:blank", "--wait", "none"],
|
|
1502
|
+
probeEnv,
|
|
1503
|
+
probeIdentity,
|
|
1504
|
+
);
|
|
1505
|
+
bridgeVerified = probe.status === 0;
|
|
1506
|
+
if (bridgeVerified) {
|
|
1507
|
+
console.log(" Extension bridge connected successfully");
|
|
1508
|
+
} else {
|
|
1509
|
+
const diagnostic = probe.stderr.trim() || probe.stdout.trim() || `bridge probe exited ${probe.status}`;
|
|
1510
|
+
console.error(` Extension bridge verification failed: ${diagnostic}`);
|
|
1511
|
+
}
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
console.error(` Extension bridge verification failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1514
|
+
} finally {
|
|
1515
|
+
// The isolated probe must never claim or close an existing worktree session.
|
|
1516
|
+
await callServe(rechUrl.toString(), [`-s=${probeSession}`, "close"], probeEnv, probeIdentity).catch(() => {});
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
console.log(`\n[5/5] Your RECHROME_URL:\n\n ${newLine}\n`);
|
|
1446
1520
|
if (!isTTY) console.log(` [agent] Provide save destination on next stdin line: 1=cwd, 2=cwd rechrome-only, 3=home, 4=skip\n`);
|
|
1447
1521
|
|
|
1448
1522
|
const pwdEnvPath = join(process.cwd(), ".env.local");
|
|
@@ -1493,7 +1567,10 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1493
1567
|
}
|
|
1494
1568
|
rl?.close();
|
|
1495
1569
|
envWatcher?.close();
|
|
1496
|
-
|
|
1570
|
+
if (bridgeVerified)
|
|
1571
|
+
console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
|
|
1572
|
+
else
|
|
1573
|
+
console.error(`\nSetup was saved, but the selected profile did not pass the bridge check. Reload the extension at chrome://extensions and run \`rech setup --profile ${profileEmail}\` again.`);
|
|
1497
1574
|
}
|
|
1498
1575
|
|
|
1499
1576
|
async function status(): Promise<void> {
|
|
@@ -1542,14 +1619,13 @@ function printHelp(): void {
|
|
|
1542
1619
|
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
1543
1620
|
|
|
1544
1621
|
Usage:
|
|
1545
|
-
rech setup [--profile <
|
|
1622
|
+
rech setup [--profile <email|name|folder>] [--token <tok>]
|
|
1546
1623
|
First-time setup: daemon + Chrome extension + config
|
|
1547
1624
|
--profile selects the Chrome profile non-interactively.
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
name ("Profile 1") also matches.
|
|
1625
|
+
Menu numbers are not accepted. Resolution order is exact
|
|
1626
|
+
email (e.g. you@gmail.com), exact Chrome profile name,
|
|
1627
|
+
then exact profile folder name (e.g. "Profile 1"). See
|
|
1628
|
+
available values with \`rech profiles\`.
|
|
1553
1629
|
--token (or RECH_TOKEN) supplies the auth token for
|
|
1554
1630
|
non-TTY/agent runs, skipping the interactive paste
|
|
1555
1631
|
rech provision-profile <name> --experimental [--headed]
|
|
@@ -1578,7 +1654,7 @@ Environment:
|
|
|
1578
1654
|
|
|
1579
1655
|
Examples:
|
|
1580
1656
|
rech setup
|
|
1581
|
-
rech setup --profile
|
|
1657
|
+
rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
|
|
1582
1658
|
rech eval "() => document.title"
|
|
1583
1659
|
rech open https://example.com
|
|
1584
1660
|
rech screenshot`);
|
|
@@ -1624,7 +1700,7 @@ if (import.meta.main) {
|
|
|
1624
1700
|
if (!experimental) {
|
|
1625
1701
|
console.error(`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`);
|
|
1626
1702
|
console.error(`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`);
|
|
1627
|
-
console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <
|
|
1703
|
+
console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`);
|
|
1628
1704
|
console.error(`To proceed anyway, re-run with --experimental.`);
|
|
1629
1705
|
process.exit(1);
|
|
1630
1706
|
}
|
package/rech.ts
CHANGED
|
@@ -351,7 +351,9 @@ const CHROME_LOCAL_STATE_PATHS = () => {
|
|
|
351
351
|
];
|
|
352
352
|
};
|
|
353
353
|
|
|
354
|
-
|
|
354
|
+
type ChromeProfileInfo = { user_name?: string; name?: string };
|
|
355
|
+
|
|
356
|
+
async function readChromeProfileCache(): Promise<Record<string, ChromeProfileInfo> | null> {
|
|
355
357
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
356
358
|
const f = file(statePath);
|
|
357
359
|
if (!(await f.exists())) continue;
|
|
@@ -363,6 +365,41 @@ async function readChromeProfileCache(): Promise<Record<string, { user_name?: st
|
|
|
363
365
|
return null;
|
|
364
366
|
}
|
|
365
367
|
|
|
368
|
+
export function resolveChromeProfileSelector(
|
|
369
|
+
profiles: Array<[string, ChromeProfileInfo]>,
|
|
370
|
+
selector: string,
|
|
371
|
+
): [string, ChromeProfileInfo] | null {
|
|
372
|
+
const value = selector.trim();
|
|
373
|
+
validateChromeProfileSelector(value);
|
|
374
|
+
|
|
375
|
+
const needle = value.toLowerCase();
|
|
376
|
+
const selectors: Array<{ label: string; value: (dir: string, info: ChromeProfileInfo) => string }> = [
|
|
377
|
+
{ label: "email", value: (_dir, info) => info.user_name ?? "" },
|
|
378
|
+
{ label: "Chrome profile name", value: (_dir, info) => info.name ?? "" },
|
|
379
|
+
{ label: "profile folder name", value: (dir) => dir },
|
|
380
|
+
];
|
|
381
|
+
for (const kind of selectors) {
|
|
382
|
+
const matches = profiles.filter(([dir, info]) => kind.value(dir, info).trim().toLowerCase() === needle);
|
|
383
|
+
if (matches.length > 1) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`--profile "${value}" matches multiple profiles by ${kind.label}. ` +
|
|
386
|
+
`Use a unique email or profile folder name from \`rech profiles\`.`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (matches.length === 1) return matches[0];
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function validateChromeProfileSelector(selector: string): void {
|
|
395
|
+
const value = selector.trim();
|
|
396
|
+
if (!/^\d+$/.test(value)) return;
|
|
397
|
+
throw new Error(
|
|
398
|
+
`--profile no longer accepts menu numbers (received "${value}"). ` +
|
|
399
|
+
`Use the profile email, Chrome profile name, or profile folder name from \`rech profiles\`.`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
366
403
|
async function findChromeUserDataDir(): Promise<string | null> {
|
|
367
404
|
for (const statePath of CHROME_LOCAL_STATE_PATHS()) {
|
|
368
405
|
if (!(await file(statePath).exists())) continue;
|
|
@@ -480,15 +517,14 @@ async function listProfiles(): Promise<void> {
|
|
|
480
517
|
}
|
|
481
518
|
}
|
|
482
519
|
|
|
483
|
-
//
|
|
484
|
-
// "Profile N" is never shown alone. Only user_name (email) + name (nickname) are read
|
|
520
|
+
// Columns mirror selector precedence. Only user_name (email) + name (profile name) are read
|
|
485
521
|
// from Local State — the gaia real name is deliberately never surfaced.
|
|
486
522
|
const rows = [
|
|
487
|
-
["
|
|
523
|
+
["EMAIL", "PROFILE NAME", "FOLDER", ""],
|
|
488
524
|
...Object.entries(cache).map(([dir, info]) => [
|
|
489
|
-
dir,
|
|
490
525
|
info.user_name || "",
|
|
491
526
|
info.name || "",
|
|
527
|
+
dir,
|
|
492
528
|
dir === currentDir ? "← current" : "",
|
|
493
529
|
]),
|
|
494
530
|
];
|
|
@@ -546,7 +582,16 @@ async function callServe(
|
|
|
546
582
|
return res.json();
|
|
547
583
|
}
|
|
548
584
|
|
|
585
|
+
export function normalizeCommandArgs(args: string[]): string[] {
|
|
586
|
+
const normalized = [...args];
|
|
587
|
+
if (normalized[0] === "tabs" || normalized[0] === "list") normalized[0] = "tab-list";
|
|
588
|
+
return normalized;
|
|
589
|
+
}
|
|
590
|
+
|
|
549
591
|
async function run(url: string, args: string[]) {
|
|
592
|
+
// Match the underlying CLI's command names while accepting the short forms humans
|
|
593
|
+
// naturally try. Keep this client-side so old and new serve daemons behave alike.
|
|
594
|
+
args = normalizeCommandArgs(args);
|
|
550
595
|
const { host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
|
|
551
596
|
const effectiveProfile = resolveEffectiveProfile(profileDirectory);
|
|
552
597
|
const displayProfile = effectiveProfile ? await resolveProfileEmail(effectiveProfile) : undefined;
|
|
@@ -570,10 +615,17 @@ async function run(url: string, args: string[]) {
|
|
|
570
615
|
if (stderr.includes('Extension connection timeout')) {
|
|
571
616
|
const hasToken = !!resolvedEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
|
|
572
617
|
const last = hasToken
|
|
573
|
-
? ` -x: extension token
|
|
618
|
+
? ` -x: extension did not connect (reload it at chrome://extensions; then verify its token) -> extension[degraded]`
|
|
574
619
|
: ` -> extension[not installed] (run: rech setup)`;
|
|
575
620
|
console.error(`[rech] rech-client -> rech-server[ok] -> playwright[ok]\n${last}`);
|
|
576
621
|
}
|
|
622
|
+
if (stderr.includes("Browser '") && stderr.includes("is not open")) {
|
|
623
|
+
console.error(
|
|
624
|
+
`[rech] the session id is derived from this worktree and profile; it is not a persisted stale id. ` +
|
|
625
|
+
`The preceding open did not finish. Retry \`rech open <url>\`; if it reports an extension timeout, ` +
|
|
626
|
+
`reload Playwright MCP Bridge at chrome://extensions and retry.`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
577
629
|
process.stderr.write(stderr);
|
|
578
630
|
}
|
|
579
631
|
if (stdout) process.stdout.write(stdout);
|
|
@@ -1136,6 +1188,15 @@ async function provisionProfile(name: string, opts: { headed?: boolean } = {}):
|
|
|
1136
1188
|
}
|
|
1137
1189
|
|
|
1138
1190
|
async function setup(opts: { profile?: string; token?: string } = {}): Promise<void> {
|
|
1191
|
+
if (opts.profile !== undefined) {
|
|
1192
|
+
try {
|
|
1193
|
+
validateChromeProfileSelector(opts.profile);
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1196
|
+
envWatcher?.close();
|
|
1197
|
+
process.exit(1);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1139
1200
|
const { createInterface } = await import("readline");
|
|
1140
1201
|
const isTTY = process.stdin.isTTY ?? false;
|
|
1141
1202
|
let rl: ReturnType<typeof createInterface> | null = null;
|
|
@@ -1157,8 +1218,8 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1157
1218
|
return new Promise<string>(r => rl!.question("", ans => r(ans || def)));
|
|
1158
1219
|
};
|
|
1159
1220
|
|
|
1160
|
-
// [1/
|
|
1161
|
-
console.log("\n[1/
|
|
1221
|
+
// [1/5] Daemon
|
|
1222
|
+
console.log("\n[1/5] Checking serve daemon...");
|
|
1162
1223
|
|
|
1163
1224
|
// Bind address (persists to ~/.env.local as RECH_HOST).
|
|
1164
1225
|
// Read the persisted value from ~/.env.local directly — process.env may be shadowed by nearer .env files.
|
|
@@ -1262,40 +1323,20 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1262
1323
|
if (!cache) { console.error(" Chrome profiles not found"); rl?.close(); process.exit(1); }
|
|
1263
1324
|
const userDataDir = await findChromeUserDataDir();
|
|
1264
1325
|
|
|
1265
|
-
async function pickProfile(exclude: Set<string>): Promise<[string,
|
|
1326
|
+
async function pickProfile(exclude: Set<string>): Promise<[string, ChromeProfileInfo] | null> {
|
|
1266
1327
|
const available = Object.entries(cache!).filter(([dir]) => !exclude.has(dir));
|
|
1267
1328
|
if (!available.length) return null;
|
|
1268
1329
|
available.forEach(([dir, info], i) =>
|
|
1269
1330
|
console.log(` ${String(i + 1).padStart(2)}. ${(info.user_name || "(no email)").padEnd(32)} ${(info.name || "").padEnd(20)} [${dir}]`)
|
|
1270
1331
|
);
|
|
1271
1332
|
if (opts.profile !== undefined) {
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
// selection either way. Email is the unambiguous selector — steer toward it.
|
|
1279
|
-
const sel = available[num - 1] ?? null;
|
|
1280
|
-
const dirNamed = available.find(([dir]) => dir.toLowerCase() === `profile ${num}`);
|
|
1281
|
-
if (dirNamed && dirNamed[0] !== sel?.[0]) {
|
|
1282
|
-
const hint = dirNamed[1].user_name || `"Profile ${num}"`;
|
|
1283
|
-
console.error(
|
|
1284
|
-
` [warn] --profile ${num} = menu index ${num} → ` +
|
|
1285
|
-
`${sel ? `${sel[1].user_name || sel[0]} [${sel[0]}]` : "(out of range)"}, ` +
|
|
1286
|
-
`NOT the Chrome directory "Profile ${num}" (${dirNamed[1].user_name || dirNamed[0]}). ` +
|
|
1287
|
-
`For an unambiguous match use the email or exact directory name, e.g. --profile ${hint}.`,
|
|
1288
|
-
);
|
|
1289
|
-
}
|
|
1290
|
-
if (sel) console.log(` selected: ${sel[1].user_name || "(no email)"} [${sel[0]}]`);
|
|
1291
|
-
return sel;
|
|
1333
|
+
let match: [string, ChromeProfileInfo] | null;
|
|
1334
|
+
try {
|
|
1335
|
+
match = resolveChromeProfileSelector(available, opts.profile);
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
console.error(` ${error instanceof Error ? error.message : String(error)}`);
|
|
1338
|
+
return null;
|
|
1292
1339
|
}
|
|
1293
|
-
const needle = opts.profile.toLowerCase();
|
|
1294
|
-
const match = available.find(([dir, info]) =>
|
|
1295
|
-
dir.toLowerCase() === needle
|
|
1296
|
-
|| (info.name ?? "").toLowerCase() === needle
|
|
1297
|
-
|| (info.user_name ?? "").toLowerCase().includes(needle)
|
|
1298
|
-
) ?? null;
|
|
1299
1340
|
if (match) console.log(` selected: ${match[1].user_name || "(no email)"} [${match[0]}]`);
|
|
1300
1341
|
return match;
|
|
1301
1342
|
}
|
|
@@ -1303,7 +1344,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1303
1344
|
console.log(` Only one profile available — selecting: ${available[0][1].user_name || available[0][0]}`);
|
|
1304
1345
|
return available[0];
|
|
1305
1346
|
}
|
|
1306
|
-
if (!isTTY) console.log(" [agent] Provide profile number on next stdin line, or rerun with --profile <
|
|
1347
|
+
if (!isTTY) console.log(" [agent] Provide profile number on next stdin line, or rerun with --profile <email|name|folder>");
|
|
1307
1348
|
const answer = await ask("\n Profile number: ");
|
|
1308
1349
|
const idx = parseInt(answer.trim()) - 1;
|
|
1309
1350
|
if (isNaN(idx) || idx < 0 || idx >= available.length) return null;
|
|
@@ -1334,7 +1375,7 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1334
1375
|
console.error(`\n Non-TTY: load the extension once via chrome://extensions → "Load unpacked":`);
|
|
1335
1376
|
console.error(` ${EXTENSION_DIST_DIR}`);
|
|
1336
1377
|
console.error(` (open chrome://extensions in profile "${profileDisplay}" — see the guide just opened)`);
|
|
1337
|
-
console.error(` Then re-run: rech setup --profile <
|
|
1378
|
+
console.error(` Then re-run: rech setup --profile <email|name|folder> [--token <tok>]`);
|
|
1338
1379
|
return null;
|
|
1339
1380
|
}
|
|
1340
1381
|
await ask("\n Press Enter after loading the extension to retry...");
|
|
@@ -1420,21 +1461,22 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1420
1461
|
return null;
|
|
1421
1462
|
}
|
|
1422
1463
|
|
|
1423
|
-
// [2/
|
|
1424
|
-
console.log("\n[2/
|
|
1464
|
+
// [2/5] Primary profile
|
|
1465
|
+
console.log("\n[2/5] Select Chrome profile:");
|
|
1425
1466
|
const picked = await pickProfile(new Set());
|
|
1426
1467
|
if (!picked) { console.error(" Invalid selection"); rl?.close(); process.exit(1); }
|
|
1427
1468
|
const [profileDir, profileInfoSel] = picked;
|
|
1428
1469
|
const profileDisplay = profileInfoSel.user_name || profileInfoSel.name || profileDir;
|
|
1429
1470
|
|
|
1430
|
-
// [3
|
|
1431
|
-
console.log("\n[3/
|
|
1471
|
+
// [3/5] Extension + token for primary profile
|
|
1472
|
+
console.log("\n[3/5] Checking extension...");
|
|
1432
1473
|
const profileEmail = profileInfoSel.user_name || profileDir;
|
|
1433
1474
|
const primary = await getExtAndToken(profileDir, profileDisplay, profileEmail, opts.token);
|
|
1434
1475
|
if (!primary) { rl?.close(); process.exit(1); }
|
|
1435
1476
|
const { extId, token } = primary;
|
|
1436
1477
|
|
|
1437
|
-
// Build RECHROME_URL
|
|
1478
|
+
// Build RECHROME_URL, verify the selected profile can complete a real extension
|
|
1479
|
+
// handshake, then show it before asking where to save.
|
|
1438
1480
|
const rechUrl = new URL(url);
|
|
1439
1481
|
if (!rechUrl.username) rechUrl.username = randomBytes(12).toString("base64url");
|
|
1440
1482
|
rechUrl.searchParams.set("extension_id", extId);
|
|
@@ -1442,7 +1484,39 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1442
1484
|
rechUrl.searchParams.set("profile", profileEmail);
|
|
1443
1485
|
if (userDataDir) rechUrl.searchParams.set("user_data_dir", userDataDir);
|
|
1444
1486
|
const newLine = `RECHROME_URL=${rechUrl.toString()}`;
|
|
1445
|
-
console.log(`\n[4/
|
|
1487
|
+
console.log(`\n[4/5] Verifying extension bridge for ${profileDisplay}...`);
|
|
1488
|
+
const probeSession = `iso-setup-${randomBytes(4).toString("hex")}`;
|
|
1489
|
+
const probeIdentity = await getClientIdentity();
|
|
1490
|
+
probeIdentity.profile = profileEmail;
|
|
1491
|
+
const probeEnv: Record<string, string> = {
|
|
1492
|
+
PLAYWRIGHT_MCP_EXTENSION_ID: extId,
|
|
1493
|
+
PLAYWRIGHT_MCP_EXTENSION_TOKEN: token,
|
|
1494
|
+
PLAYWRIGHT_MCP_PROFILE_DIRECTORY: profileEmail,
|
|
1495
|
+
...(userDataDir ? { PLAYWRIGHT_MCP_USER_DATA_DIR: userDataDir } : {}),
|
|
1496
|
+
};
|
|
1497
|
+
let bridgeVerified = false;
|
|
1498
|
+
try {
|
|
1499
|
+
const probe = await callServe(
|
|
1500
|
+
rechUrl.toString(),
|
|
1501
|
+
[`-s=${probeSession}`, "open", "about:blank", "--wait", "none"],
|
|
1502
|
+
probeEnv,
|
|
1503
|
+
probeIdentity,
|
|
1504
|
+
);
|
|
1505
|
+
bridgeVerified = probe.status === 0;
|
|
1506
|
+
if (bridgeVerified) {
|
|
1507
|
+
console.log(" Extension bridge connected successfully");
|
|
1508
|
+
} else {
|
|
1509
|
+
const diagnostic = probe.stderr.trim() || probe.stdout.trim() || `bridge probe exited ${probe.status}`;
|
|
1510
|
+
console.error(` Extension bridge verification failed: ${diagnostic}`);
|
|
1511
|
+
}
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
console.error(` Extension bridge verification failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1514
|
+
} finally {
|
|
1515
|
+
// The isolated probe must never claim or close an existing worktree session.
|
|
1516
|
+
await callServe(rechUrl.toString(), [`-s=${probeSession}`, "close"], probeEnv, probeIdentity).catch(() => {});
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
console.log(`\n[5/5] Your RECHROME_URL:\n\n ${newLine}\n`);
|
|
1446
1520
|
if (!isTTY) console.log(` [agent] Provide save destination on next stdin line: 1=cwd, 2=cwd rechrome-only, 3=home, 4=skip\n`);
|
|
1447
1521
|
|
|
1448
1522
|
const pwdEnvPath = join(process.cwd(), ".env.local");
|
|
@@ -1493,7 +1567,10 @@ async function setup(opts: { profile?: string; token?: string } = {}): Promise<v
|
|
|
1493
1567
|
}
|
|
1494
1568
|
rl?.close();
|
|
1495
1569
|
envWatcher?.close();
|
|
1496
|
-
|
|
1570
|
+
if (bridgeVerified)
|
|
1571
|
+
console.log(`\nDone! Test with:\n rech open github.com/snomiao`);
|
|
1572
|
+
else
|
|
1573
|
+
console.error(`\nSetup was saved, but the selected profile did not pass the bridge check. Reload the extension at chrome://extensions and run \`rech setup --profile ${profileEmail}\` again.`);
|
|
1497
1574
|
}
|
|
1498
1575
|
|
|
1499
1576
|
async function status(): Promise<void> {
|
|
@@ -1542,14 +1619,13 @@ function printHelp(): void {
|
|
|
1542
1619
|
console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
|
|
1543
1620
|
|
|
1544
1621
|
Usage:
|
|
1545
|
-
rech setup [--profile <
|
|
1622
|
+
rech setup [--profile <email|name|folder>] [--token <tok>]
|
|
1546
1623
|
First-time setup: daemon + Chrome extension + config
|
|
1547
1624
|
--profile selects the Chrome profile non-interactively.
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
name ("Profile 1") also matches.
|
|
1625
|
+
Menu numbers are not accepted. Resolution order is exact
|
|
1626
|
+
email (e.g. you@gmail.com), exact Chrome profile name,
|
|
1627
|
+
then exact profile folder name (e.g. "Profile 1"). See
|
|
1628
|
+
available values with \`rech profiles\`.
|
|
1553
1629
|
--token (or RECH_TOKEN) supplies the auth token for
|
|
1554
1630
|
non-TTY/agent runs, skipping the interactive paste
|
|
1555
1631
|
rech provision-profile <name> --experimental [--headed]
|
|
@@ -1578,7 +1654,7 @@ Environment:
|
|
|
1578
1654
|
|
|
1579
1655
|
Examples:
|
|
1580
1656
|
rech setup
|
|
1581
|
-
rech setup --profile
|
|
1657
|
+
rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
|
|
1582
1658
|
rech eval "() => document.title"
|
|
1583
1659
|
rech open https://example.com
|
|
1584
1660
|
rech screenshot`);
|
|
@@ -1624,7 +1700,7 @@ if (import.meta.main) {
|
|
|
1624
1700
|
if (!experimental) {
|
|
1625
1701
|
console.error(`provision-profile is experimental and creates a Chrome-for-Testing profile (not your`);
|
|
1626
1702
|
console.error(`real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`);
|
|
1627
|
-
console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <
|
|
1703
|
+
console.error(`can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`);
|
|
1628
1704
|
console.error(`To proceed anyway, re-run with --experimental.`);
|
|
1629
1705
|
process.exit(1);
|
|
1630
1706
|
}
|
package/serve.js
CHANGED
|
@@ -84,6 +84,23 @@ function noteSession(sess: string, now: number): void {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
export function inferSilentExtensionFailure(options: {
|
|
88
|
+
status: number;
|
|
89
|
+
stdout: string;
|
|
90
|
+
stderr: string;
|
|
91
|
+
isOpenCommand: boolean;
|
|
92
|
+
hasExtensionCredentials: boolean;
|
|
93
|
+
elapsedMs: number;
|
|
94
|
+
handshakeTimeoutMs: number;
|
|
95
|
+
}): string {
|
|
96
|
+
const { status, stdout, stderr, isOpenCommand, hasExtensionCredentials, elapsedMs, handshakeTimeoutMs } = options;
|
|
97
|
+
if (stderr || status === 0 || stdout.trim() || !isOpenCommand || !hasExtensionCredentials)
|
|
98
|
+
return stderr;
|
|
99
|
+
if (elapsedMs < Math.max(1_000, handshakeTimeoutMs - 1_000))
|
|
100
|
+
return stderr;
|
|
101
|
+
return `Extension connection timeout after ${handshakeTimeoutMs}ms. Automatic recovery retry failed; reload the Playwright MCP Bridge extension at chrome://extensions and retry.\n`;
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
function tmpSocketRoot(): string {
|
|
88
105
|
return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
|
|
89
106
|
}
|
|
@@ -478,6 +495,7 @@ export async function serve() {
|
|
|
478
495
|
} catch {}
|
|
479
496
|
}
|
|
480
497
|
|
|
498
|
+
const commandStartedAt = Date.now();
|
|
481
499
|
const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
|
|
482
500
|
cwd: workDir,
|
|
483
501
|
stdin: "ignore",
|
|
@@ -497,7 +515,7 @@ export async function serve() {
|
|
|
497
515
|
reject(new Error("timeout"));
|
|
498
516
|
}, TIMEOUT);
|
|
499
517
|
});
|
|
500
|
-
const [status, stdout,
|
|
518
|
+
const [status, stdout, rawStderr] = await Promise.race([
|
|
501
519
|
Promise.all([
|
|
502
520
|
proc.exited,
|
|
503
521
|
new Response(proc.stdout).text(),
|
|
@@ -509,6 +527,20 @@ export async function serve() {
|
|
|
509
527
|
) as [number, string, string];
|
|
510
528
|
clearTimeout(timer);
|
|
511
529
|
|
|
530
|
+
const configuredHandshakeTimeout = Number(childEnv.PWMCP_TEST_CONNECTION_TIMEOUT);
|
|
531
|
+
const handshakeTimeoutMs = Number.isFinite(configuredHandshakeTimeout) && configuredHandshakeTimeout > 0
|
|
532
|
+
? configuredHandshakeTimeout
|
|
533
|
+
: 30_000;
|
|
534
|
+
const stderr = inferSilentExtensionFailure({
|
|
535
|
+
status,
|
|
536
|
+
stdout,
|
|
537
|
+
stderr: rawStderr,
|
|
538
|
+
isOpenCommand: isOpenCmd,
|
|
539
|
+
hasExtensionCredentials: !!(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN),
|
|
540
|
+
elapsedMs: Date.now() - commandStartedAt,
|
|
541
|
+
handshakeTimeoutMs,
|
|
542
|
+
});
|
|
543
|
+
|
|
512
544
|
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
513
545
|
|
|
514
546
|
// Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS): a command that RETURNS — even
|
package/serve.ts
CHANGED
|
@@ -84,6 +84,23 @@ function noteSession(sess: string, now: number): void {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
export function inferSilentExtensionFailure(options: {
|
|
88
|
+
status: number;
|
|
89
|
+
stdout: string;
|
|
90
|
+
stderr: string;
|
|
91
|
+
isOpenCommand: boolean;
|
|
92
|
+
hasExtensionCredentials: boolean;
|
|
93
|
+
elapsedMs: number;
|
|
94
|
+
handshakeTimeoutMs: number;
|
|
95
|
+
}): string {
|
|
96
|
+
const { status, stdout, stderr, isOpenCommand, hasExtensionCredentials, elapsedMs, handshakeTimeoutMs } = options;
|
|
97
|
+
if (stderr || status === 0 || stdout.trim() || !isOpenCommand || !hasExtensionCredentials)
|
|
98
|
+
return stderr;
|
|
99
|
+
if (elapsedMs < Math.max(1_000, handshakeTimeoutMs - 1_000))
|
|
100
|
+
return stderr;
|
|
101
|
+
return `Extension connection timeout after ${handshakeTimeoutMs}ms. Automatic recovery retry failed; reload the Playwright MCP Bridge extension at chrome://extensions and retry.\n`;
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
function tmpSocketRoot(): string {
|
|
88
105
|
return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
|
|
89
106
|
}
|
|
@@ -478,6 +495,7 @@ export async function serve() {
|
|
|
478
495
|
} catch {}
|
|
479
496
|
}
|
|
480
497
|
|
|
498
|
+
const commandStartedAt = Date.now();
|
|
481
499
|
const proc = Bun.spawn([bin, ...binArgs, ...filteredArgs, `-s=${namespacedSession}`], {
|
|
482
500
|
cwd: workDir,
|
|
483
501
|
stdin: "ignore",
|
|
@@ -497,7 +515,7 @@ export async function serve() {
|
|
|
497
515
|
reject(new Error("timeout"));
|
|
498
516
|
}, TIMEOUT);
|
|
499
517
|
});
|
|
500
|
-
const [status, stdout,
|
|
518
|
+
const [status, stdout, rawStderr] = await Promise.race([
|
|
501
519
|
Promise.all([
|
|
502
520
|
proc.exited,
|
|
503
521
|
new Response(proc.stdout).text(),
|
|
@@ -509,6 +527,20 @@ export async function serve() {
|
|
|
509
527
|
) as [number, string, string];
|
|
510
528
|
clearTimeout(timer);
|
|
511
529
|
|
|
530
|
+
const configuredHandshakeTimeout = Number(childEnv.PWMCP_TEST_CONNECTION_TIMEOUT);
|
|
531
|
+
const handshakeTimeoutMs = Number.isFinite(configuredHandshakeTimeout) && configuredHandshakeTimeout > 0
|
|
532
|
+
? configuredHandshakeTimeout
|
|
533
|
+
: 30_000;
|
|
534
|
+
const stderr = inferSilentExtensionFailure({
|
|
535
|
+
status,
|
|
536
|
+
stdout,
|
|
537
|
+
stderr: rawStderr,
|
|
538
|
+
isOpenCommand: isOpenCmd,
|
|
539
|
+
hasExtensionCredentials: !!(passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_ID && passthroughEnv.PLAYWRIGHT_MCP_EXTENSION_TOKEN),
|
|
540
|
+
elapsedMs: Date.now() - commandStartedAt,
|
|
541
|
+
handshakeTimeoutMs,
|
|
542
|
+
});
|
|
543
|
+
|
|
512
544
|
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
513
545
|
|
|
514
546
|
// Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS): a command that RETURNS — even
|