@wong2kim/wmux 1.1.2 → 2.0.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.
@@ -3,32 +3,33 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PlaywrightEngine = void 0;
4
4
  const playwright_core_1 = require("playwright-core");
5
5
  const wmux_client_1 = require("../wmux-client");
6
- const MAX_CONNECT_RETRIES = 3;
7
- const RETRY_DELAY_MS = 1000;
8
- const PAGE_FIND_RETRIES = 5;
9
- const PAGE_FIND_DELAY_MS = 800;
6
+ const MAX_CONNECT_RETRIES = 1;
7
+ const RETRY_DELAY_MS = 500;
8
+ const PAGE_FIND_RETRIES = 1;
9
+ const PAGE_FIND_DELAY_MS = 300;
10
10
  function sleep(ms) {
11
11
  return new Promise((resolve) => setTimeout(resolve, ms));
12
12
  }
13
13
  /**
14
14
  * Returns true if the URL belongs to the Electron main renderer window.
15
- * Navigating these pages would destroy the app — they must never be returned.
16
15
  */
17
16
  function isElectronShellUrl(url) {
18
17
  return (url.startsWith('http://localhost:') ||
18
+ url.startsWith('http://127.0.0.1:') ||
19
19
  url.startsWith('devtools://') ||
20
20
  url.startsWith('chrome://'));
21
21
  }
22
22
  /**
23
23
  * PlaywrightEngine -- singleton wrapper around playwright-core's Chromium CDP connection.
24
24
  *
25
- * Connects to the wmux Electron app via Chrome DevTools Protocol and provides
26
- * access to browser pages for automation.
25
+ * Strategy: Connect to the Electron browser endpoint, then use CDP Target domain
26
+ * to discover and attach to webview targets that aren't visible as regular pages.
27
27
  */
28
28
  class PlaywrightEngine {
29
29
  constructor() {
30
30
  this.browser = null;
31
31
  this.cdpPort = null;
32
+ this.playwrightFailed = false;
32
33
  }
33
34
  static getInstance() {
34
35
  if (!PlaywrightEngine.instance) {
@@ -43,25 +44,30 @@ class PlaywrightEngine {
43
44
  await this.disconnect();
44
45
  this.browser = await playwright_core_1.chromium.connectOverCDP(`http://localhost:${cdpPort}`);
45
46
  this.cdpPort = cdpPort;
46
- console.log(`[PlaywrightEngine] Connected to CDP on port ${cdpPort}`);
47
+ console.error(`[PlaywrightEngine] Connected to CDP on port ${cdpPort}`);
48
+ // Enable auto-attach so Electron webview targets become discoverable as Playwright pages.
49
+ // Without this, <webview> tags in Electron are separate renderer processes that
50
+ // don't appear in browser.contexts().pages().
51
+ try {
52
+ const session = await this.browser.newBrowserCDPSession();
53
+ await session.send('Target.setAutoAttach', {
54
+ autoAttach: true,
55
+ waitForDebuggerOnStart: false,
56
+ flatten: true,
57
+ });
58
+ console.error(`[PlaywrightEngine] Auto-attach enabled`);
59
+ }
60
+ catch (err) {
61
+ console.error('[PlaywrightEngine] setAutoAttach warning:', err instanceof Error ? err.message : String(err));
62
+ }
47
63
  }
48
64
  async disconnect() {
49
65
  if (this.browser) {
50
66
  this.browser = null;
51
67
  this.cdpPort = null;
52
- console.log('[PlaywrightEngine] Disconnected');
68
+ console.error('[PlaywrightEngine] Disconnected');
53
69
  }
54
70
  }
55
- /**
56
- * Force reconnect — drops existing connection and creates a fresh one.
57
- * Needed when new webviews are created after the initial connection,
58
- * because connectOverCDP only discovers targets at connection time.
59
- */
60
- async reconnect() {
61
- const info = (await (0, wmux_client_1.sendRpc)('browser.cdp.info'));
62
- await this.disconnect();
63
- await this.connect(info.cdpPort);
64
- }
65
71
  async ensureConnected() {
66
72
  if (this.browser?.isConnected())
67
73
  return;
@@ -72,7 +78,7 @@ class PlaywrightEngine {
72
78
  return;
73
79
  }
74
80
  catch (err) {
75
- console.warn(`[PlaywrightEngine] Connection attempt ${attempt}/${MAX_CONNECT_RETRIES} failed:`, err instanceof Error ? err.message : String(err));
81
+ console.error(`[PlaywrightEngine] Connection attempt ${attempt}/${MAX_CONNECT_RETRIES} failed:`, err instanceof Error ? err.message : String(err));
76
82
  if (attempt < MAX_CONNECT_RETRIES) {
77
83
  await sleep(RETRY_DELAY_MS);
78
84
  }
@@ -93,89 +99,190 @@ class PlaywrightEngine {
93
99
  return pages;
94
100
  }
95
101
  /**
96
- * Fetch the CDP /json target list.
102
+ * Find a webview page using multiple strategies:
103
+ * 1. Check existing Playwright pages (works if webview is in a discoverable context)
104
+ * 2. Use CDP Target domain to find and attach to webview targets directly
105
+ * 3. Fetch /json endpoint for target discovery
97
106
  */
98
- async fetchJsonTargets() {
99
- if (!this.cdpPort)
100
- return [];
101
- const resp = await fetch(`http://127.0.0.1:${this.cdpPort}/json`);
102
- return (await resp.json());
103
- }
104
- /**
105
- * Try to find a Playwright Page that corresponds to a registered webview target.
106
- * Returns null if no safe page can be found.
107
- */
108
- async findWebviewPage(allPages, target) {
109
- // Strategy 1: Match by targetId → URL from /json endpoint
110
- if (target) {
107
+ async getPage(surfaceId) {
108
+ // Fast-fail if Playwright has already failed to find webview pages.
109
+ // MCP tools with RPC fallbacks will skip directly to the fast RPC path.
110
+ if (this.playwrightFailed)
111
+ return null;
112
+ await this.ensureConnected();
113
+ for (let attempt = 1; attempt <= PAGE_FIND_RETRIES; attempt++) {
111
114
  try {
112
- const jsonTargets = await this.fetchJsonTargets();
113
- const jsonTarget = jsonTargets.find((t) => t.id === target.targetId);
114
- if (jsonTarget && !isElectronShellUrl(jsonTarget.url)) {
115
- // Find Playwright page with matching URL
116
- const matched = allPages.find((p) => p.url() === jsonTarget.url);
117
- if (matched)
118
- return matched;
119
- // URL might differ slightly (trailing slash, redirect) — try loose match
120
- const normalizedTarget = jsonTarget.url.replace(/\/+$/, '');
121
- const looseMatch = allPages.find((p) => p.url().replace(/\/+$/, '') === normalizedTarget);
122
- if (looseMatch)
123
- return looseMatch;
115
+ // Strategy 1: Check existing pages
116
+ const allPages = this.getAllPages();
117
+ console.error(`[PlaywrightEngine] Attempt ${attempt}: ${allPages.length} pages in ${this.browser?.contexts().length ?? 0} contexts`);
118
+ const safePage = allPages.find((p) => !isElectronShellUrl(p.url()));
119
+ if (safePage) {
120
+ console.error(`[PlaywrightEngine] Found page via contexts: ${safePage.url()}`);
121
+ return safePage;
122
+ }
123
+ // Strategy 2: Use CDP Target.getTargets to find webview targets
124
+ if (this.browser) {
125
+ const page = await this.findViaTargetDomain(surfaceId);
126
+ if (page)
127
+ return page;
128
+ }
129
+ // Strategy 3: Use /json endpoint + match registered targets
130
+ if (this.cdpPort) {
131
+ const page = await this.findViaJsonEndpoint(surfaceId);
132
+ if (page)
133
+ return page;
134
+ }
135
+ if (attempt < PAGE_FIND_RETRIES) {
136
+ console.error(`[PlaywrightEngine] No page found, reconnecting... (${attempt}/${PAGE_FIND_RETRIES})`);
137
+ await sleep(PAGE_FIND_DELAY_MS);
138
+ await this.disconnect();
139
+ await this.ensureConnected();
124
140
  }
125
141
  }
126
- catch {
127
- // /json fetch failed
142
+ catch (err) {
143
+ console.error(`[PlaywrightEngine] getPage attempt ${attempt} failed:`, err instanceof Error ? err.message : String(err));
144
+ if (attempt < PAGE_FIND_RETRIES) {
145
+ await sleep(PAGE_FIND_DELAY_MS);
146
+ await this.disconnect();
147
+ await this.ensureConnected();
148
+ }
128
149
  }
129
150
  }
130
- // Strategy 2: Any page that isn't the Electron shell
131
- // about:blank is allowed — webviews start there before navigating
132
- const candidates = allPages.filter((p) => !isElectronShellUrl(p.url()));
133
- if (candidates.length > 0) {
134
- return candidates[0];
135
- }
151
+ console.error('[PlaywrightEngine] No webview page found after all retries — marking as failed');
152
+ this.playwrightFailed = true;
136
153
  return null;
137
154
  }
138
155
  /**
139
- * Get a Page matching the given surfaceId.
140
- *
141
- * Includes retry logic: if no webview page is found on the first attempt,
142
- * reconnects to CDP (to discover newly created webview targets) and retries.
156
+ * Use CDP Target domain to discover webview targets and create a page for them.
143
157
  */
144
- async getPage(surfaceId) {
145
- await this.ensureConnected();
146
- for (let attempt = 1; attempt <= PAGE_FIND_RETRIES; attempt++) {
147
- const allPages = this.getAllPages();
148
- if (allPages.length === 0 && attempt < PAGE_FIND_RETRIES) {
149
- // No pages yet — reconnect to discover new targets
150
- await sleep(PAGE_FIND_DELAY_MS);
151
- await this.reconnect();
152
- continue;
158
+ async findViaTargetDomain(surfaceId) {
159
+ if (!this.browser)
160
+ return null;
161
+ try {
162
+ // Get the default context's first page to create a CDP session
163
+ const defaultContext = this.browser.contexts()[0];
164
+ if (!defaultContext) {
165
+ console.error('[PlaywrightEngine] No default context available');
166
+ return null;
167
+ }
168
+ let cdpSession;
169
+ const existingPages = defaultContext.pages();
170
+ if (existingPages.length > 0) {
171
+ cdpSession = await existingPages[0].context().newCDPSession(existingPages[0]);
172
+ }
173
+ else {
174
+ cdpSession = await this.browser.newBrowserCDPSession();
153
175
  }
154
- // Get registered webview targets
176
+ // Get all targets
177
+ const { targetInfos } = await cdpSession.send('Target.getTargets');
178
+ console.error(`[PlaywrightEngine] CDP targets: ${targetInfos.map(t => `${t.type}:${t.url.substring(0, 40)}`).join(', ')}`);
179
+ // Get registered wmux targets for matching
155
180
  const info = (await (0, wmux_client_1.sendRpc)('browser.cdp.info'));
156
- const target = surfaceId
181
+ const wmuxTarget = surfaceId
157
182
  ? info.targets.find((t) => t.surfaceId === surfaceId)
158
183
  : info.targets[0];
159
- // If no targets registered yet, wait for webview to initialize
160
- if (!target && attempt < PAGE_FIND_RETRIES) {
161
- console.log(`[PlaywrightEngine] No CDP targets registered yet, retry ${attempt}/${PAGE_FIND_RETRIES}...`);
162
- await sleep(PAGE_FIND_DELAY_MS);
163
- // Reconnect to pick up newly created webview targets
164
- await this.reconnect();
165
- continue;
184
+ // Find the webview target match by targetId from WebviewCdpManager
185
+ let webviewTarget = wmuxTarget
186
+ ? targetInfos.find((t) => t.targetId === wmuxTarget.targetId)
187
+ : undefined;
188
+ // Fallback: find any page target that isn't the Electron shell
189
+ if (!webviewTarget) {
190
+ webviewTarget = targetInfos.find((t) => t.type === 'page' && !isElectronShellUrl(t.url) && t.url !== 'about:blank');
166
191
  }
167
- const page = await this.findWebviewPage(allPages, target);
168
- if (page)
169
- return page;
170
- // Page not found — reconnect and retry (new webview might not be visible yet)
171
- if (attempt < PAGE_FIND_RETRIES) {
172
- console.log(`[PlaywrightEngine] Webview page not found, reconnecting... (${attempt}/${PAGE_FIND_RETRIES})`);
173
- await sleep(PAGE_FIND_DELAY_MS);
174
- await this.reconnect();
192
+ if (!webviewTarget) {
193
+ console.error('[PlaywrightEngine] No webview target found in Target.getTargets');
194
+ return null;
175
195
  }
196
+ console.error(`[PlaywrightEngine] Found webview target: ${webviewTarget.targetId} url=${webviewTarget.url}`);
197
+ // Try to attach to the target and get a page
198
+ // Attach with flatten:true creates a session in the current connection
199
+ if (!webviewTarget.attached) {
200
+ await cdpSession.send('Target.attachToTarget', {
201
+ targetId: webviewTarget.targetId,
202
+ flatten: true,
203
+ });
204
+ console.error(`[PlaywrightEngine] Attached to target ${webviewTarget.targetId}`);
205
+ }
206
+ // After attaching, check if new pages appeared
207
+ await sleep(500);
208
+ const newPages = this.getAllPages();
209
+ console.error(`[PlaywrightEngine] After attach: ${newPages.length} pages`);
210
+ const matchedPage = newPages.find((p) => !isElectronShellUrl(p.url()));
211
+ if (matchedPage) {
212
+ console.error(`[PlaywrightEngine] Found page after attach: ${matchedPage.url()}`);
213
+ return matchedPage;
214
+ }
215
+ // If pages still empty, try creating a new CDP connection specifically to the webview
216
+ // by reconnecting — this forces Playwright to re-discover all targets
217
+ console.error('[PlaywrightEngine] Attach did not create a page, will retry with reconnect');
218
+ return null;
219
+ }
220
+ catch (err) {
221
+ console.error('[PlaywrightEngine] findViaTargetDomain error:', err instanceof Error ? err.message : String(err));
222
+ return null;
223
+ }
224
+ }
225
+ /**
226
+ * Use the /json HTTP endpoint to find webview targets and attach via CDP.
227
+ */
228
+ async findViaJsonEndpoint(surfaceId) {
229
+ if (!this.cdpPort || !this.browser)
230
+ return null;
231
+ try {
232
+ const resp = await fetch(`http://127.0.0.1:${this.cdpPort}/json`);
233
+ const targets = (await resp.json());
234
+ console.error(`[PlaywrightEngine] /json targets: ${targets.map(t => `${t.type}:${t.url.substring(0, 40)}`).join(', ')}`);
235
+ // Get registered wmux targets
236
+ const info = (await (0, wmux_client_1.sendRpc)('browser.cdp.info'));
237
+ const wmuxTarget = surfaceId
238
+ ? info.targets.find((t) => t.surfaceId === surfaceId)
239
+ : info.targets[0];
240
+ // Find the webview in /json
241
+ let jsonTarget = wmuxTarget
242
+ ? targets.find((t) => t.id === wmuxTarget.targetId)
243
+ : undefined;
244
+ if (!jsonTarget) {
245
+ jsonTarget = targets.find((t) => t.type === 'page' && !isElectronShellUrl(t.url) && t.url !== 'about:blank');
246
+ }
247
+ if (!jsonTarget) {
248
+ console.error('[PlaywrightEngine] No webview found in /json');
249
+ return null;
250
+ }
251
+ console.error(`[PlaywrightEngine] Found target in /json: ${jsonTarget.id} url=${jsonTarget.url}`);
252
+ // Attach to the target via browser-level CDP session (don't disconnect!)
253
+ try {
254
+ const session = await this.browser.newBrowserCDPSession();
255
+ // Re-enable auto-attach to pick up the webview target
256
+ await session.send('Target.setAutoAttach', {
257
+ autoAttach: true,
258
+ waitForDebuggerOnStart: false,
259
+ flatten: true,
260
+ });
261
+ // Also explicitly attach to the discovered target
262
+ await session.send('Target.attachToTarget', {
263
+ targetId: jsonTarget.id,
264
+ flatten: true,
265
+ });
266
+ console.error(`[PlaywrightEngine] Attached to target ${jsonTarget.id} via /json`);
267
+ // Brief wait for Playwright to process the attached target
268
+ await sleep(200);
269
+ const pages = this.getAllPages();
270
+ console.error(`[PlaywrightEngine] After /json attach: ${pages.length} pages`);
271
+ const matchedPage = pages.find((p) => !isElectronShellUrl(p.url()));
272
+ if (matchedPage) {
273
+ console.error(`[PlaywrightEngine] Found page via /json attach: ${matchedPage.url()}`);
274
+ return matchedPage;
275
+ }
276
+ }
277
+ catch (attachErr) {
278
+ console.error(`[PlaywrightEngine] /json attach failed: ${attachErr instanceof Error ? attachErr.message : String(attachErr)}`);
279
+ }
280
+ return null;
281
+ }
282
+ catch (err) {
283
+ console.error('[PlaywrightEngine] findViaJsonEndpoint error:', err instanceof Error ? err.message : String(err));
284
+ return null;
176
285
  }
177
- console.warn('[PlaywrightEngine] No webview page found after all retries');
178
- return null;
179
286
  }
180
287
  async getBrowser() {
181
288
  await this.ensureConnected();
@@ -24,21 +24,26 @@ async function applyAntiDetection(page) {
24
24
  // CDP-powered evaluate with user gesture
25
25
  // ---------------------------------------------------------------------------
26
26
  /**
27
- * Evaluate a JavaScript expression in the page context with the
28
- * `userGesture` flag set to `true`.
27
+ * Evaluate a JavaScript expression in the page context via CDP
28
+ * `Runtime.evaluate`.
29
29
  *
30
- * This is useful for actions that require a transient user activation
31
- * (e.g. opening a popup, triggering downloads) without an actual mouse /
32
- * keyboard event.
30
+ * @param page - The Playwright page to evaluate in.
31
+ * @param expression - The JavaScript expression to evaluate.
32
+ * @param options - Optional settings.
33
+ * @param options.userGesture - When `true`, the evaluation is treated as if
34
+ * triggered by a user gesture (transient activation). Defaults to `false`
35
+ * to follow the principle of least privilege. Callers that genuinely need
36
+ * user activation (e.g. opening popups, triggering downloads) should
37
+ * explicitly pass `true`.
33
38
  *
34
39
  * Internally opens a CDP session and calls `Runtime.evaluate`.
35
40
  */
36
- async function evaluateWithGesture(page, expression) {
41
+ async function evaluateWithGesture(page, expression, options) {
37
42
  const client = await page.context().newCDPSession(page);
38
43
  try {
39
44
  const result = await client.send('Runtime.evaluate', {
40
45
  expression,
41
- userGesture: true,
46
+ userGesture: options?.userGesture ?? false,
42
47
  returnByValue: true,
43
48
  awaitPromise: true,
44
49
  });
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ // ---------------------------------------------------------------------------
3
+ // Dangerous pattern detection for browser code execution
4
+ // ---------------------------------------------------------------------------
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.detectDangerousPatterns = detectDangerousPatterns;
7
+ const DANGEROUS_PATTERNS = [
8
+ { pattern: /\bfetch\s*\(/, label: 'fetch()' },
9
+ { pattern: /\bXMLHttpRequest\b/, label: 'XMLHttpRequest' },
10
+ { pattern: /\bWebSocket\b/, label: 'WebSocket' },
11
+ { pattern: /\bnavigator\.sendBeacon\b/, label: 'sendBeacon' },
12
+ { pattern: /\brequire\s*\(/, label: 'require()' },
13
+ { pattern: /\bimport\s*\(/, label: 'dynamic import()' },
14
+ { pattern: /\beval\s*\(/, label: 'eval()' },
15
+ { pattern: /\bnew\s+Function\b/, label: 'new Function()' },
16
+ { pattern: /\bdocument\.cookie\b/, label: 'document.cookie access' },
17
+ { pattern: /\blocalStorage\b/, label: 'localStorage access' },
18
+ { pattern: /\bsessionStorage\b/, label: 'sessionStorage access' },
19
+ { pattern: /\bindexedDB\b/, label: 'indexedDB access' },
20
+ ];
21
+ /**
22
+ * Detect dangerous patterns in a JavaScript code string.
23
+ * Returns an array of human-readable labels for each matched pattern.
24
+ */
25
+ function detectDangerousPatterns(code) {
26
+ return DANGEROUS_PATTERNS
27
+ .filter(({ pattern }) => pattern.test(code))
28
+ .map(({ label }) => label);
29
+ }
@@ -33,7 +33,7 @@ function registerExtractionTools(server) {
33
33
  try {
34
34
  const page = await engine.getPage(surfaceId);
35
35
  if (!page) {
36
- throw new Error('No browser page available. Call browser_open first.');
36
+ throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
37
37
  }
38
38
  const snapshot = await (0, dom_intelligence_1.getSmartSnapshot)(page, {
39
39
  maxContentLength: maxContentLength ?? 3000,
@@ -86,7 +86,7 @@ function registerExtractionTools(server) {
86
86
  try {
87
87
  const page = await engine.getPage(surfaceId);
88
88
  if (!page) {
89
- throw new Error('No browser page available. Call browser_open first.');
89
+ throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
90
90
  }
91
91
  const markdown = await (0, markdown_extractor_1.extractMarkdown)(page, {
92
92
  selector,
@@ -120,7 +120,7 @@ function registerExtractionTools(server) {
120
120
  try {
121
121
  const page = await engine.getPage(surfaceId);
122
122
  if (!page) {
123
- throw new Error('No browser page available. Call browser_open first.');
123
+ throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
124
124
  }
125
125
  const records = await (0, markdown_extractor_1.extractStructuredData)(page, goal, fields);
126
126
  return {
@@ -69,7 +69,7 @@ function registerFileTools(server) {
69
69
  try {
70
70
  const page = await engine.getPage(surfaceId);
71
71
  if (!page) {
72
- throw new Error('No browser page available. Call browser_open first.');
72
+ throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
73
73
  }
74
74
  if (ref) {
75
75
  const el = await (0, snapshot_1.resolveRef)(page, ref);
@@ -119,7 +119,7 @@ function registerFileTools(server) {
119
119
  try {
120
120
  const page = await engine.getPage(surfaceId);
121
121
  if (!page) {
122
- throw new Error('No browser page available. Call browser_open first.');
122
+ throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
123
123
  }
124
124
  const el = await (0, snapshot_1.resolveRef)(page, ref);
125
125
  if (!el) {
@@ -177,7 +177,7 @@ function registerFileTools(server) {
177
177
  try {
178
178
  const page = await engine.getPage(surfaceId);
179
179
  if (!page) {
180
- throw new Error('No browser page available. Call browser_open first.');
180
+ throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
181
181
  }
182
182
  const download = await page.waitForEvent('download', {
183
183
  timeout: resolvedTimeout,
@@ -243,7 +243,7 @@ function registerFileTools(server) {
243
243
  try {
244
244
  const page = await engine.getPage(surfaceId);
245
245
  if (!page) {
246
- throw new Error('No browser page available. Call browser_open first.');
246
+ throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
247
247
  }
248
248
  page.once('dialog', async (dialog) => {
249
249
  if (accept) {