rechrome 1.11.2 → 1.12.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.
@@ -0,0 +1,181 @@
1
+ import { c as clientExports, j as jsxRuntimeExports, r as reactExports, B as Button, A as AuthTokenSection, T as TabItem, g as getOrCreateAuthToken } from "./authToken.js";
2
+ const SUPPORTED_PROTOCOL_VERSION = 2;
3
+ const ConnectApp = () => {
4
+ const [tabs, setTabs] = reactExports.useState([]);
5
+ const [status, setStatus] = reactExports.useState(null);
6
+ const [showButtons, setShowButtons] = reactExports.useState(true);
7
+ const [showTabList, setShowTabList] = reactExports.useState(true);
8
+ const [clientInfo, setClientInfo] = reactExports.useState("unknown");
9
+ const [mcpRelayUrl, setMcpRelayUrl] = reactExports.useState("");
10
+ const [newTab, setNewTab] = reactExports.useState(false);
11
+ reactExports.useEffect(() => {
12
+ const runAsync = async () => {
13
+ const params = new URLSearchParams(window.location.search);
14
+ const relayUrl = params.get("mcpRelayUrl");
15
+ if (!relayUrl) {
16
+ handleReject("Missing mcpRelayUrl parameter in URL.");
17
+ return;
18
+ }
19
+ try {
20
+ const host = new URL(relayUrl).hostname;
21
+ if (host !== "127.0.0.1" && host !== "[::1]") {
22
+ handleReject(`MCP extension only allows loopback connections (127.0.0.1 or [::1]). Received host: ${host}`);
23
+ return;
24
+ }
25
+ } catch (e) {
26
+ handleReject(`Invalid mcpRelayUrl parameter in URL: ${relayUrl}. ${e}`);
27
+ return;
28
+ }
29
+ setMcpRelayUrl(relayUrl);
30
+ let info = "unknown";
31
+ try {
32
+ const client = JSON.parse(params.get("client") || "{}");
33
+ info = `${client.name}/${client.version}`;
34
+ setClientInfo(info);
35
+ setStatus({
36
+ type: "connecting",
37
+ message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
38
+ });
39
+ } catch (e) {
40
+ setStatus({ type: "error", message: "Failed to parse client version." });
41
+ return;
42
+ }
43
+ const parsedVersion = parseInt(params.get("protocolVersion") ?? "", 10);
44
+ const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion;
45
+ if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) {
46
+ const extensionVersion = chrome.runtime.getManifest().version;
47
+ setShowButtons(false);
48
+ setShowTabList(false);
49
+ setStatus({
50
+ type: "error",
51
+ versionMismatch: {
52
+ extensionVersion
53
+ }
54
+ });
55
+ return;
56
+ }
57
+ const expectedToken = getOrCreateAuthToken();
58
+ const token = params.get("token");
59
+ if (token === expectedToken) {
60
+ await connectToMCPRelay(relayUrl);
61
+ await handleConnectToTab(void 0, info);
62
+ return;
63
+ }
64
+ if (token) {
65
+ handleReject("Invalid token provided.");
66
+ return;
67
+ }
68
+ await connectToMCPRelay(relayUrl);
69
+ if (params.get("newTab") === "true") {
70
+ setNewTab(true);
71
+ setShowTabList(false);
72
+ } else {
73
+ await loadTabs();
74
+ }
75
+ };
76
+ void runAsync();
77
+ }, []);
78
+ const handleReject = reactExports.useCallback((message) => {
79
+ setShowButtons(false);
80
+ setShowTabList(false);
81
+ setStatus({ type: "error", message });
82
+ }, []);
83
+ const connectToMCPRelay = reactExports.useCallback(async (mcpRelayUrl2) => {
84
+ const response = await chrome.runtime.sendMessage({ type: "connectToMCPRelay", mcpRelayUrl: mcpRelayUrl2 });
85
+ if (!response.success)
86
+ handleReject(response.error);
87
+ }, [handleReject]);
88
+ const loadTabs = reactExports.useCallback(async () => {
89
+ const response = await chrome.runtime.sendMessage({ type: "getTabs" });
90
+ if (response.success)
91
+ setTabs(response.tabs);
92
+ else
93
+ setStatus({ type: "error", message: "Failed to load tabs: " + response.error });
94
+ }, []);
95
+ const handleConnectToTab = reactExports.useCallback(async (tab, clientInfoOverride) => {
96
+ const displayName = clientInfoOverride || clientInfo;
97
+ setShowButtons(false);
98
+ setShowTabList(false);
99
+ try {
100
+ const response = await chrome.runtime.sendMessage({
101
+ type: "connectToTab",
102
+ mcpRelayUrl,
103
+ tabId: tab?.id,
104
+ windowId: tab?.windowId
105
+ });
106
+ if (response?.success) {
107
+ setStatus({ type: "connected", message: `MCP client "${displayName}" connected. Do not close this tab — it maintains the connection to the browser. Closing it will disconnect the session.` });
108
+ } else {
109
+ setStatus({
110
+ type: "error",
111
+ message: response?.error || `MCP client "${displayName}" failed to connect.`
112
+ });
113
+ }
114
+ } catch (e) {
115
+ setStatus({
116
+ type: "error",
117
+ message: `MCP client "${displayName}" failed to connect: ${e}`
118
+ });
119
+ }
120
+ }, [clientInfo, mcpRelayUrl]);
121
+ reactExports.useEffect(() => {
122
+ const listener = (message) => {
123
+ if (message.type === "connectionTimeout")
124
+ handleReject("Connection timed out.");
125
+ };
126
+ chrome.runtime.onMessage.addListener(listener);
127
+ return () => {
128
+ chrome.runtime.onMessage.removeListener(listener);
129
+ };
130
+ }, [handleReject]);
131
+ return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "app-container", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "content-wrapper", children: [
132
+ status && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "status-container", children: [
133
+ /* @__PURE__ */ jsxRuntimeExports.jsx(StatusBanner, { status }),
134
+ showButtons && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "button-container", children: newTab ? /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
135
+ /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "primary", onClick: () => handleConnectToTab(), children: "Allow" }),
136
+ /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "reject", onClick: () => handleReject("Connection rejected. This tab can be closed."), children: "Reject" })
137
+ ] }) : /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "reject", onClick: () => handleReject("Connection rejected. This tab can be closed."), children: "Reject" }) })
138
+ ] }),
139
+ status?.type === "connecting" && /* @__PURE__ */ jsxRuntimeExports.jsx(AuthTokenSection, {}),
140
+ showTabList && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
141
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tab-section-title", children: "Select page to expose to MCP server:" }),
142
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { children: tabs.map((tab) => /* @__PURE__ */ jsxRuntimeExports.jsx(
143
+ TabItem,
144
+ {
145
+ tab,
146
+ button: /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "primary", onClick: () => handleConnectToTab(tab), children: "Connect" })
147
+ },
148
+ tab.id
149
+ )) })
150
+ ] })
151
+ ] }) });
152
+ };
153
+ const VersionMismatchError = ({ extensionVersion }) => {
154
+ const readmeUrl = "https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md";
155
+ const latestReleaseUrl = "https://github.com/microsoft/playwright-mcp/releases/latest";
156
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
157
+ "Playwright MCP version trying to connect requires newer extension version (current version: ",
158
+ extensionVersion,
159
+ ").",
160
+ " ",
161
+ /* @__PURE__ */ jsxRuntimeExports.jsx("a", { href: latestReleaseUrl, children: "Click here" }),
162
+ " to download latest version of the extension, then drag and drop it into the Chrome Extensions page.",
163
+ " ",
164
+ "See ",
165
+ /* @__PURE__ */ jsxRuntimeExports.jsx("a", { href: readmeUrl, target: "_blank", rel: "noopener noreferrer", children: "installation instructions" }),
166
+ " for more details."
167
+ ] });
168
+ };
169
+ const StatusBanner = ({ status }) => {
170
+ return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: `status-banner ${status.type}`, children: "versionMismatch" in status ? /* @__PURE__ */ jsxRuntimeExports.jsx(
171
+ VersionMismatchError,
172
+ {
173
+ extensionVersion: status.versionMismatch.extensionVersion
174
+ }
175
+ ) : status.message });
176
+ };
177
+ const container = document.getElementById("root");
178
+ if (container) {
179
+ const root = clientExports.createRoot(container);
180
+ root.render(/* @__PURE__ */ jsxRuntimeExports.jsx(ConnectApp, {}));
181
+ }
Binary file
Binary file
@@ -0,0 +1,71 @@
1
+ import { c as clientExports, j as jsxRuntimeExports, r as reactExports, T as TabItem, B as Button, A as AuthTokenSection } from "./authToken.js";
2
+ const StatusApp = () => {
3
+ const [connections, setConnections] = reactExports.useState([]);
4
+ reactExports.useEffect(() => {
5
+ void loadStatus();
6
+ }, []);
7
+ const loadStatus = async () => {
8
+ const { connections: rawConnections = [] } = await chrome.runtime.sendMessage({ type: "getConnectionStatus" });
9
+ const fetchTab = async (id) => {
10
+ try {
11
+ const tab = await chrome.tabs.get(id);
12
+ return { id: tab.id, windowId: tab.windowId, title: tab.title, url: tab.url, favIconUrl: tab.favIconUrl };
13
+ } catch {
14
+ return null;
15
+ }
16
+ };
17
+ const resolved = await Promise.all(
18
+ rawConnections.map(async (c) => {
19
+ const connectedTab = await fetchTab(c.connectedTabId) ?? void 0;
20
+ const playwrightTabs = (await Promise.all(c.playwrightTabIds.map(fetchTab))).filter((t) => t !== null);
21
+ return { ...c, connectedTab, playwrightTabs };
22
+ })
23
+ );
24
+ setConnections(resolved);
25
+ };
26
+ const openTab = async (tabId) => {
27
+ await chrome.tabs.update(tabId, { active: true });
28
+ window.close();
29
+ };
30
+ const disconnect = async (mcpRelayUrl) => {
31
+ await chrome.runtime.sendMessage({ type: "disconnect", mcpRelayUrl });
32
+ void loadStatus();
33
+ };
34
+ return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "app-container", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "content-wrapper", children: [
35
+ connections.length === 0 ? /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "status-banner", children: "No MCP clients are currently connected." }) : connections.map((c, i) => /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
36
+ connections.length > 1 && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "tab-section-title", children: [
37
+ "Instance ",
38
+ i + 1,
39
+ ":"
40
+ ] }),
41
+ c.connectedTab && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
42
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tab-section-title", children: "Page with connected MCP client:" }),
43
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
44
+ TabItem,
45
+ {
46
+ tab: c.connectedTab,
47
+ button: /* @__PURE__ */ jsxRuntimeExports.jsx(Button, { variant: "primary", onClick: () => disconnect(c.mcpRelayUrl), children: "Disconnect" }),
48
+ onClick: () => openTab(c.connectedTabId)
49
+ }
50
+ )
51
+ ] }),
52
+ c.playwrightTabs.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
53
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tab-section-title", children: "Playwright managed tabs:" }),
54
+ c.playwrightTabs.map((tab) => /* @__PURE__ */ jsxRuntimeExports.jsx(
55
+ TabItem,
56
+ {
57
+ tab,
58
+ onClick: () => openTab(tab.id)
59
+ },
60
+ tab.id
61
+ ))
62
+ ] })
63
+ ] }, c.mcpRelayUrl)),
64
+ /* @__PURE__ */ jsxRuntimeExports.jsx(AuthTokenSection, {})
65
+ ] }) });
66
+ };
67
+ const container = document.getElementById("root");
68
+ if (container) {
69
+ const root = clientExports.createRoot(container);
70
+ root.render(/* @__PURE__ */ jsxRuntimeExports.jsx(StatusApp, {}));
71
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyr3kV2bBGDawVu1+vxLx2epYDN18tw/MSHbBunj5XLXho2gYcU2IYjBFZBt65A48IDJJ94D7LEnoFuaJScjEbxmPaPqvE1UrvplkwWiUPoN/3d4bshwx+wybln1oM4jnO/qGcOkyWZ28RrEhewgRznPYisJhVkBZWi+YoSYaQTkURLxi3CZ68xmWVQLyec3v3sxnOYu+ibOBtBgOFI+WYEBQAvI38y4jUdOTyoHyWw32Plr+y/4fnOzxEXVs7emCdQpVeZuNbru4x1UltrhwtXgl37mIGoOQmf6PzafCO9MaQ+mmRSY5e8Ht/Z5/eWY30mwjbc7H3HAtYOUaPZbp8wIDAQAB",
4
+ "name": "Playwright MCP Bridge (multi-tab)",
5
+ "version": "0.0.68.3",
6
+ "description": "Share browser tabs with Playwright MCP server (multi-tab fork: Playwright can open and control multiple tabs)",
7
+ "permissions": [
8
+ "debugger",
9
+ "activeTab",
10
+ "tabs"
11
+ ],
12
+ "host_permissions": [
13
+ "<all_urls>"
14
+ ],
15
+ "background": {
16
+ "service_worker": "lib/background.mjs",
17
+ "type": "module"
18
+ },
19
+ "action": {
20
+ "default_title": "Playwright MCP Bridge",
21
+ "default_icon": {
22
+ "16": "icons/icon-16.png",
23
+ "32": "icons/icon-32.png",
24
+ "48": "icons/icon-48.png",
25
+ "128": "icons/icon-128.png"
26
+ }
27
+ },
28
+ "icons": {
29
+ "16": "icons/icon-16.png",
30
+ "32": "icons/icon-32.png",
31
+ "48": "icons/icon-48.png",
32
+ "128": "icons/icon-128.png"
33
+ }
34
+ }
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Playwright MCP Bridge Status</title>
7
+ <script type="module" crossorigin src="/lib/ui/status.js"></script>
8
+ <link rel="modulepreload" crossorigin href="/lib/ui/authToken.js">
9
+ <link rel="stylesheet" crossorigin href="/lib/ui/authToken.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.11.2",
3
+ "version": "1.12.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
@@ -13,6 +13,7 @@
13
13
  ".env.example",
14
14
  "LICENSE",
15
15
  "README.md",
16
+ "extension",
16
17
  "rech.js",
17
18
  "rech.ts",
18
19
  "serve.js",
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, constants as fsConstants } from "fs";
5
+ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, constants as fsConstants } from "fs";
6
6
  import { hostname } from "os";
7
7
  import { join, basename, dirname } from "path";
8
8
 
@@ -242,15 +242,35 @@ async function findChromeUserDataDir(): Promise<string | null> {
242
242
  return null;
243
243
  }
244
244
 
245
- export const EXTENSION_DIST_DIR = join(
246
- import.meta.dir,
247
- "lib/playwright-multi-tab/lib/playwright-mcp/packages/extension/dist",
248
- );
245
+ // Bundled extension dist (shipped via package.json `files`). `import.meta.dir` resolves to the install
246
+ // location at runtime — under local dev that's the repo root, under bunx/npm it's the package dir.
247
+ const BUNDLED_EXTENSION_DIST_DIR = join(import.meta.dir, "extension");
248
+ // The legacy submodule path (pre-1.12). Kept for backwards-compat with users who installed from there.
249
+ const LEGACY_EXTENSION_DIST_DIR = join(import.meta.dir, "lib/playwright-multi-tab/lib/playwright-mcp/packages/extension/dist");
250
+
251
+ // Stable per-user location: we copy the bundled dist here so Chrome's recorded install path survives
252
+ // the ephemeral bunx temp dir being cleaned up between invocations.
253
+ export const EXTENSION_DIST_DIR = join(process.env.HOME!, ".rechrome", "extension");
254
+
255
+ // With the manifest `key` field set, Chrome derives this ID deterministically from the key (not the path),
256
+ // so we can locate the extension by ID even when the on-disk path differs from what Chrome stored.
257
+ export const EXTENSION_ID = "fokngfbogklgiffokdnekajodmhgfnhk";
258
+
259
+ async function ensureExtensionDistInstalled(): Promise<string> {
260
+ const source = existsSync(BUNDLED_EXTENSION_DIST_DIR)
261
+ ? BUNDLED_EXTENSION_DIST_DIR
262
+ : existsSync(LEGACY_EXTENSION_DIST_DIR)
263
+ ? LEGACY_EXTENSION_DIST_DIR
264
+ : null;
265
+ if (!source) return EXTENSION_DIST_DIR;
266
+ const sourceManifest = await file(join(source, "manifest.json")).text().catch(() => "");
267
+ const destManifest = await file(join(EXTENSION_DIST_DIR, "manifest.json")).text().catch(() => "");
268
+ if (sourceManifest && sourceManifest === destManifest) return EXTENSION_DIST_DIR;
269
+ mkdirSync(EXTENSION_DIST_DIR, { recursive: true });
270
+ cpSync(source, EXTENSION_DIST_DIR, { recursive: true, force: true });
271
+ return EXTENSION_DIST_DIR;
272
+ }
249
273
 
250
- // Walk all Chrome profiles' Secure Preferences and find an extension
251
- // whose loaded `path` matches our dist directory. The extension ID Chrome
252
- // generates for an unpacked extension is path-dependent, so we cannot rely
253
- // on a hardcoded ID across machines.
254
274
  async function findInstalledExtension(
255
275
  profileDir?: string,
256
276
  ): Promise<{ id: string; profile: string } | null> {
@@ -258,6 +278,11 @@ async function findInstalledExtension(
258
278
  if (!userDataDir) return null;
259
279
  const cache = await readChromeProfileCache();
260
280
  const profiles = profileDir ? [profileDir] : (cache ? Object.keys(cache) : []);
281
+ // Resolve our known-good install paths up front for path-based fallback matching.
282
+ const knownPaths = new Set<string>();
283
+ for (const p of [EXTENSION_DIST_DIR, BUNDLED_EXTENSION_DIST_DIR, LEGACY_EXTENSION_DIST_DIR]) {
284
+ try { knownPaths.add(realpathSync(p)); } catch {}
285
+ }
261
286
  for (const prof of profiles) {
262
287
  const prefsPath = join(userDataDir, prof, "Secure Preferences");
263
288
  const f = file(prefsPath);
@@ -267,11 +292,12 @@ async function findInstalledExtension(
267
292
  const settings = data?.extensions?.settings ?? {};
268
293
  for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
269
294
  if (!info?.path || info.state === 0) continue; // state 0 = explicitly disabled
295
+ // Primary: stable ID match (works when manifest `key` is set, regardless of path).
296
+ if (extId === EXTENSION_ID) return { id: extId, profile: prof };
297
+ // Fallback: path equality for legacy installs without a stable key.
270
298
  let storedPath = info.path as string;
271
299
  try { storedPath = realpathSync(storedPath); } catch {}
272
- let distPath = EXTENSION_DIST_DIR;
273
- try { distPath = realpathSync(distPath); } catch {}
274
- if (storedPath === distPath) return { id: extId, profile: prof };
300
+ if (knownPaths.has(storedPath)) return { id: extId, profile: prof };
275
301
  }
276
302
  } catch {}
277
303
  }
@@ -501,13 +527,18 @@ async function daemonInstall(serveUrl: string): Promise<void> {
501
527
  const bunBin = Bun.which("bun") ?? process.execPath;
502
528
  const rechScript = import.meta.filename;
503
529
 
530
+ // Resolve PLAYWRIGHT_CLI: env override > bundled fork (development checkout) > "playwright-cli-multi-tab"
531
+ const bundledForkCli = join(import.meta.dir, "lib/playwright-cli/playwright-cli.js");
532
+ const resolvedPlaywrightCli = process.env.PLAYWRIGHT_CLI
533
+ || (existsSync(bundledForkCli) ? bundledForkCli : "playwright-cli-multi-tab");
534
+
504
535
  const envArgs: string[] = [
505
536
  "--env", `HOME=${home}`,
506
537
  "--env", `PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}`,
507
538
  "--env", `${ENV_KEY}=${serveUrl}`,
508
539
  "--env", `PWMCP_TEST_CONNECTION_TIMEOUT=${process.env.PWMCP_TEST_CONNECTION_TIMEOUT || "30000"}`,
540
+ "--env", `PLAYWRIGHT_CLI=${resolvedPlaywrightCli}`,
509
541
  ];
510
- if (process.env.PLAYWRIGHT_CLI) envArgs.push("--env", `PLAYWRIGHT_CLI=${process.env.PLAYWRIGHT_CLI}`);
511
542
  if (process.env.RECH_HOST) envArgs.push("--env", `RECH_HOST=${process.env.RECH_HOST}`);
512
543
  if (isReadable(process.env.RECH_TLS_CERT)) envArgs.push("--env", `RECH_TLS_CERT=${process.env.RECH_TLS_CERT}`);
513
544
  if (isReadable(process.env.RECH_TLS_KEY)) envArgs.push("--env", `RECH_TLS_KEY=${process.env.RECH_TLS_KEY}`);
@@ -675,6 +706,8 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
675
706
  async function getExtAndToken(profileDir: string, profileDisplay: string, profileKey: string): Promise<{ extId: string; token: string } | null> {
676
707
  // Extension check
677
708
  let extId: string | undefined;
709
+ // Copy bundled dist to a stable per-user location so the install path survives bunx temp-dir cleanup.
710
+ await ensureExtensionDistInstalled();
678
711
  while (true) {
679
712
  const found = await findInstalledExtension(profileDir);
680
713
  if (found) { extId = found.id; break; }
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, constants as fsConstants } from "fs";
5
+ import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, constants as fsConstants } from "fs";
6
6
  import { hostname } from "os";
7
7
  import { join, basename, dirname } from "path";
8
8
 
@@ -242,15 +242,35 @@ async function findChromeUserDataDir(): Promise<string | null> {
242
242
  return null;
243
243
  }
244
244
 
245
- export const EXTENSION_DIST_DIR = join(
246
- import.meta.dir,
247
- "lib/playwright-multi-tab/lib/playwright-mcp/packages/extension/dist",
248
- );
245
+ // Bundled extension dist (shipped via package.json `files`). `import.meta.dir` resolves to the install
246
+ // location at runtime — under local dev that's the repo root, under bunx/npm it's the package dir.
247
+ const BUNDLED_EXTENSION_DIST_DIR = join(import.meta.dir, "extension");
248
+ // The legacy submodule path (pre-1.12). Kept for backwards-compat with users who installed from there.
249
+ const LEGACY_EXTENSION_DIST_DIR = join(import.meta.dir, "lib/playwright-multi-tab/lib/playwright-mcp/packages/extension/dist");
250
+
251
+ // Stable per-user location: we copy the bundled dist here so Chrome's recorded install path survives
252
+ // the ephemeral bunx temp dir being cleaned up between invocations.
253
+ export const EXTENSION_DIST_DIR = join(process.env.HOME!, ".rechrome", "extension");
254
+
255
+ // With the manifest `key` field set, Chrome derives this ID deterministically from the key (not the path),
256
+ // so we can locate the extension by ID even when the on-disk path differs from what Chrome stored.
257
+ export const EXTENSION_ID = "fokngfbogklgiffokdnekajodmhgfnhk";
258
+
259
+ async function ensureExtensionDistInstalled(): Promise<string> {
260
+ const source = existsSync(BUNDLED_EXTENSION_DIST_DIR)
261
+ ? BUNDLED_EXTENSION_DIST_DIR
262
+ : existsSync(LEGACY_EXTENSION_DIST_DIR)
263
+ ? LEGACY_EXTENSION_DIST_DIR
264
+ : null;
265
+ if (!source) return EXTENSION_DIST_DIR;
266
+ const sourceManifest = await file(join(source, "manifest.json")).text().catch(() => "");
267
+ const destManifest = await file(join(EXTENSION_DIST_DIR, "manifest.json")).text().catch(() => "");
268
+ if (sourceManifest && sourceManifest === destManifest) return EXTENSION_DIST_DIR;
269
+ mkdirSync(EXTENSION_DIST_DIR, { recursive: true });
270
+ cpSync(source, EXTENSION_DIST_DIR, { recursive: true, force: true });
271
+ return EXTENSION_DIST_DIR;
272
+ }
249
273
 
250
- // Walk all Chrome profiles' Secure Preferences and find an extension
251
- // whose loaded `path` matches our dist directory. The extension ID Chrome
252
- // generates for an unpacked extension is path-dependent, so we cannot rely
253
- // on a hardcoded ID across machines.
254
274
  async function findInstalledExtension(
255
275
  profileDir?: string,
256
276
  ): Promise<{ id: string; profile: string } | null> {
@@ -258,6 +278,11 @@ async function findInstalledExtension(
258
278
  if (!userDataDir) return null;
259
279
  const cache = await readChromeProfileCache();
260
280
  const profiles = profileDir ? [profileDir] : (cache ? Object.keys(cache) : []);
281
+ // Resolve our known-good install paths up front for path-based fallback matching.
282
+ const knownPaths = new Set<string>();
283
+ for (const p of [EXTENSION_DIST_DIR, BUNDLED_EXTENSION_DIST_DIR, LEGACY_EXTENSION_DIST_DIR]) {
284
+ try { knownPaths.add(realpathSync(p)); } catch {}
285
+ }
261
286
  for (const prof of profiles) {
262
287
  const prefsPath = join(userDataDir, prof, "Secure Preferences");
263
288
  const f = file(prefsPath);
@@ -267,11 +292,12 @@ async function findInstalledExtension(
267
292
  const settings = data?.extensions?.settings ?? {};
268
293
  for (const [extId, info] of Object.entries(settings as Record<string, any>)) {
269
294
  if (!info?.path || info.state === 0) continue; // state 0 = explicitly disabled
295
+ // Primary: stable ID match (works when manifest `key` is set, regardless of path).
296
+ if (extId === EXTENSION_ID) return { id: extId, profile: prof };
297
+ // Fallback: path equality for legacy installs without a stable key.
270
298
  let storedPath = info.path as string;
271
299
  try { storedPath = realpathSync(storedPath); } catch {}
272
- let distPath = EXTENSION_DIST_DIR;
273
- try { distPath = realpathSync(distPath); } catch {}
274
- if (storedPath === distPath) return { id: extId, profile: prof };
300
+ if (knownPaths.has(storedPath)) return { id: extId, profile: prof };
275
301
  }
276
302
  } catch {}
277
303
  }
@@ -501,13 +527,18 @@ async function daemonInstall(serveUrl: string): Promise<void> {
501
527
  const bunBin = Bun.which("bun") ?? process.execPath;
502
528
  const rechScript = import.meta.filename;
503
529
 
530
+ // Resolve PLAYWRIGHT_CLI: env override > bundled fork (development checkout) > "playwright-cli-multi-tab"
531
+ const bundledForkCli = join(import.meta.dir, "lib/playwright-cli/playwright-cli.js");
532
+ const resolvedPlaywrightCli = process.env.PLAYWRIGHT_CLI
533
+ || (existsSync(bundledForkCli) ? bundledForkCli : "playwright-cli-multi-tab");
534
+
504
535
  const envArgs: string[] = [
505
536
  "--env", `HOME=${home}`,
506
537
  "--env", `PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}`,
507
538
  "--env", `${ENV_KEY}=${serveUrl}`,
508
539
  "--env", `PWMCP_TEST_CONNECTION_TIMEOUT=${process.env.PWMCP_TEST_CONNECTION_TIMEOUT || "30000"}`,
540
+ "--env", `PLAYWRIGHT_CLI=${resolvedPlaywrightCli}`,
509
541
  ];
510
- if (process.env.PLAYWRIGHT_CLI) envArgs.push("--env", `PLAYWRIGHT_CLI=${process.env.PLAYWRIGHT_CLI}`);
511
542
  if (process.env.RECH_HOST) envArgs.push("--env", `RECH_HOST=${process.env.RECH_HOST}`);
512
543
  if (isReadable(process.env.RECH_TLS_CERT)) envArgs.push("--env", `RECH_TLS_CERT=${process.env.RECH_TLS_CERT}`);
513
544
  if (isReadable(process.env.RECH_TLS_KEY)) envArgs.push("--env", `RECH_TLS_KEY=${process.env.RECH_TLS_KEY}`);
@@ -675,6 +706,8 @@ async function setup(opts: { profile?: string } = {}): Promise<void> {
675
706
  async function getExtAndToken(profileDir: string, profileDisplay: string, profileKey: string): Promise<{ extId: string; token: string } | null> {
676
707
  // Extension check
677
708
  let extId: string | undefined;
709
+ // Copy bundled dist to a stable per-user location so the install path survives bunx temp-dir cleanup.
710
+ await ensureExtensionDistInstalled();
678
711
  while (true) {
679
712
  const found = await findInstalledExtension(profileDir);
680
713
  if (found) { extId = found.id; break; }
package/serve.js CHANGED
@@ -163,7 +163,7 @@ export async function serve() {
163
163
  });
164
164
  const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
165
165
 
166
- const [bin, ...binArgs] = (process.env.PLAYWRIGHT_CLI || "playwright-cli").split(" ");
166
+ const [bin, ...binArgs] = (process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab").split(" ");
167
167
 
168
168
  if (filteredArgs.length === 0) {
169
169
  filteredArgs.push("--help");
package/serve.ts CHANGED
@@ -163,7 +163,7 @@ export async function serve() {
163
163
  });
164
164
  const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
165
165
 
166
- const [bin, ...binArgs] = (process.env.PLAYWRIGHT_CLI || "playwright-cli").split(" ");
166
+ const [bin, ...binArgs] = (process.env.PLAYWRIGHT_CLI || "playwright-cli-multi-tab").split(" ");
167
167
 
168
168
  if (filteredArgs.length === 0) {
169
169
  filteredArgs.push("--help");