playwriter 0.0.62 → 0.0.63

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.
Binary file
package/src/cdp-relay.ts CHANGED
@@ -17,6 +17,13 @@ import type {
17
17
  IsRecordingParams,
18
18
  } from './protocol.js'
19
19
  import pc from 'picocolors'
20
+ import util from 'node:util'
21
+
22
+ // Prevent Buffers from dumping hex bytes in util.inspect output.
23
+ Buffer.prototype[util.inspect.custom] = function () {
24
+ return `<Buffer ${this.length} bytes>`
25
+ }
26
+
20
27
  import { EventEmitter } from 'node:events'
21
28
  import { VERSION, EXTENSION_IDS } from './utils.js'
22
29
  import { createCdpLogger, type CdpLogEntry, type CdpLogger } from './cdp-log.js'
@@ -71,6 +78,8 @@ type ExtensionInfo = {
71
78
  browser?: string
72
79
  email?: string
73
80
  id?: string
81
+ /** playwriter package version the extension was built with (sent as ?v= query param) */
82
+ version?: string
74
83
  }
75
84
 
76
85
  type ExtensionConnection = {
@@ -675,6 +684,7 @@ export async function startPlayWriterCDPRelayServer({
675
684
  activeTargets,
676
685
  browser: info?.browser || null,
677
686
  profile: info ? { email: info.email || '', id: info.id || '' } : null,
687
+ playwriterVersion: info?.version || null,
678
688
  })
679
689
  })
680
690
 
@@ -686,6 +696,7 @@ export async function startPlayWriterCDPRelayServer({
686
696
  browser: extension.info.browser || null,
687
697
  profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null,
688
698
  activeTargets: extension.connectedTargets.size,
699
+ playwriterVersion: extension.info?.version || null,
689
700
  }
690
701
  })
691
702
  return c.json({ extensions })
@@ -1002,10 +1013,12 @@ export async function startPlayWriterCDPRelayServer({
1002
1013
  const browser = c.req.query('browser')
1003
1014
  const email = c.req.query('email')
1004
1015
  const id = c.req.query('id')
1016
+ const version = c.req.query('v')
1005
1017
  return {
1006
1018
  browser: browser || undefined,
1007
1019
  email: email || undefined,
1008
1020
  id: id || undefined,
1021
+ version: version || undefined,
1009
1022
  }
1010
1023
  }
1011
1024
 
@@ -1428,6 +1441,60 @@ export async function startPlayWriterCDPRelayServer({
1428
1441
  return executorManager
1429
1442
  }
1430
1443
 
1444
+ // ============================================================================
1445
+ // Security middleware for privileged HTTP routes (/cli/*, /recording/*)
1446
+ //
1447
+ // CORS alone does NOT prevent cross-origin POST attacks. Browsers skip the
1448
+ // preflight for "simple" requests (POST + Content-Type: text/plain), so a
1449
+ // malicious website can fire-and-forget a POST to localhost:19988/cli/execute
1450
+ // and the code executes before CORS even enters the picture.
1451
+ //
1452
+ // Two layers of defense:
1453
+ // 1. Sec-Fetch-Site: browsers set this forbidden header on every request.
1454
+ // If present and not "same-origin"/"none", it's a cross-origin browser
1455
+ // request → reject. Node.js clients don't send it → unaffected.
1456
+ // 2. Content-Type must be application/json on POST. This forces a CORS
1457
+ // preflight as a fallback, which our CORS policy already blocks.
1458
+ // 3. When token mode is enabled (remote access), require the token.
1459
+ // ============================================================================
1460
+ const privilegedRouteMiddleware = async (c: Parameters<Parameters<typeof app.use>[1]>[0], next: () => Promise<void>) => {
1461
+ // Block cross-origin browser requests via Sec-Fetch-Site header.
1462
+ // Browsers always set this forbidden header; it cannot be spoofed.
1463
+ // Non-browser clients (Node.js, curl, MCP) don't send it.
1464
+ const secFetchSite = c.req.header('sec-fetch-site')
1465
+ if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
1466
+ logger?.log(pc.red(`Rejecting ${c.req.path}: cross-origin browser request (Sec-Fetch-Site: ${secFetchSite})`))
1467
+ return c.text('Forbidden - Cross-origin requests not allowed', 403)
1468
+ }
1469
+
1470
+ // Require application/json on POST to force CORS preflight as backup defense.
1471
+ // A text/plain POST is a "simple request" that skips preflight entirely.
1472
+ if (c.req.method === 'POST') {
1473
+ const contentType = c.req.header('content-type') || ''
1474
+ if (!contentType.includes('application/json')) {
1475
+ logger?.log(pc.red(`Rejecting ${c.req.path}: Content-Type must be application/json, got: ${contentType}`))
1476
+ return c.text('Content-Type must be application/json', 415)
1477
+ }
1478
+ }
1479
+
1480
+ // When token mode is enabled (remote/serve mode), require authentication.
1481
+ if (token) {
1482
+ const authHeader = c.req.header('authorization') || ''
1483
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null
1484
+ const url = new URL(c.req.url, 'http://localhost')
1485
+ const queryToken = url.searchParams.get('token')
1486
+ if (bearerToken !== token && queryToken !== token) {
1487
+ logger?.log(pc.red(`Rejecting ${c.req.path}: invalid or missing token`))
1488
+ return c.text('Unauthorized', 401)
1489
+ }
1490
+ }
1491
+
1492
+ return next()
1493
+ }
1494
+
1495
+ app.use('/cli/*', privilegedRouteMiddleware)
1496
+ app.use('/recording/*', privilegedRouteMiddleware)
1497
+
1431
1498
  app.post('/cli/execute', async (c) => {
1432
1499
  try {
1433
1500
  const body = await c.req.json() as { sessionId: string | number; code: string; timeout?: number }
package/src/cli.ts CHANGED
@@ -2,11 +2,17 @@
2
2
 
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
5
+ import util from 'node:util'
5
6
  import { fileURLToPath } from 'node:url'
6
7
  import { cac } from '@xmorse/cac'
8
+
9
+ // Prevent Buffers from dumping hex bytes in util.inspect output.
10
+ Buffer.prototype[util.inspect.custom] = function () {
11
+ return `<Buffer ${this.length} bytes>`
12
+ }
7
13
  import { killPortProcess } from './kill-port.js'
8
14
  import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.js'
9
- import { ensureRelayServer, RELAY_PORT, waitForExtension } from './relay-client.js'
15
+ import { ensureRelayServer, RELAY_PORT, waitForExtension, getExtensionOutdatedWarning, getExtensionStatus } from './relay-client.js'
10
16
 
11
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
18
 
@@ -20,6 +26,7 @@ type ExtensionStatus = {
20
26
  browser: string | null
21
27
  profile: { email: string; id: string } | null
22
28
  activeTargets: number
29
+ playwriterVersion?: string | null
23
30
  }
24
31
 
25
32
  cli
@@ -74,22 +81,24 @@ async function fetchExtensionsStatus(host?: string): Promise<ExtensionStatus[]>
74
81
  activeTargets: number
75
82
  browser: string | null
76
83
  profile: { email: string; id: string } | null
84
+ playwriterVersion?: string | null
77
85
  }
78
- if (!fallbackData.connected) {
86
+ if (!fallbackData?.connected) {
79
87
  return []
80
88
  }
81
89
  return [{
82
90
  extensionId: 'default',
83
91
  stableKey: undefined,
84
- browser: fallbackData.browser,
85
- profile: fallbackData.profile,
86
- activeTargets: fallbackData.activeTargets,
92
+ browser: fallbackData?.browser,
93
+ profile: fallbackData?.profile,
94
+ activeTargets: fallbackData?.activeTargets,
95
+ playwriterVersion: fallbackData?.playwriterVersion || null,
87
96
  }]
88
97
  }
89
98
  const data = await response.json() as {
90
99
  extensions: ExtensionStatus[]
91
100
  }
92
- return data.extensions
101
+ return data?.extensions || []
93
102
  } catch {
94
103
  return []
95
104
  }
@@ -126,6 +135,13 @@ async function executeCode(options: {
126
135
  }
127
136
  }
128
137
 
138
+ // Warn once if extension is outdated
139
+ const extensionStatus = await getExtensionStatus()
140
+ const outdatedWarning = getExtensionOutdatedWarning(extensionStatus?.playwriterVersion)
141
+ if (outdatedWarning) {
142
+ console.error(outdatedWarning)
143
+ }
144
+
129
145
  // Build request URL with token if provided
130
146
  const executeUrl = `${serverUrl}/cli/execute`
131
147
 
@@ -197,6 +213,15 @@ cli
197
213
  process.exit(1)
198
214
  }
199
215
 
216
+ // Warn if any connected extension was built with an older playwriter version
217
+ for (const ext of extensions) {
218
+ const warning = getExtensionOutdatedWarning(ext.playwriterVersion)
219
+ if (warning) {
220
+ console.error(warning)
221
+ break
222
+ }
223
+ }
224
+
200
225
  let selectedExtension: ExtensionStatus | null = null
201
226
 
202
227
  if (extensions.length === 1) {
@@ -22,7 +22,7 @@ export function createFileLogger({ logFilePath }: { logFilePath?: string } = {})
22
22
 
23
23
  const log = (...args: unknown[]): Promise<void> => {
24
24
  const message = args.map(arg =>
25
- typeof arg === 'string' ? arg : util.inspect(arg, { depth: null, colors: false })
25
+ typeof arg === 'string' ? arg : util.inspect(arg, { depth: null, colors: false, maxStringLength: 1000 })
26
26
  ).join(' ')
27
27
  queue = queue.then(() => fs.promises.appendFile(resolvedLogFilePath, stripAnsi(message) + '\n'))
28
28
  return queue
package/src/executor.ts CHANGED
@@ -15,6 +15,7 @@ import vm from 'node:vm'
15
15
  import * as acorn from 'acorn'
16
16
  import { createSmartDiff } from './diff-utils.js'
17
17
  import { getCdpUrl, parseRelayHost } from './utils.js'
18
+ import { getExtensionOutdatedWarning } from './relay-client.js'
18
19
  import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js'
19
20
  import { ICDPSession, getCDPSessionForPage } from './cdp-session.js'
20
21
  import { Debugger } from './debugger.js'
@@ -225,6 +226,7 @@ export class PlaywrightExecutor {
225
226
  private cdpConfig: CdpConfig
226
227
  private logger: ExecutorLogger
227
228
  private sessionMetadata: SessionMetadata
229
+ private hasWarnedExtensionOutdated = false
228
230
 
229
231
  constructor(options: ExecutorOptions) {
230
232
  this.cdpConfig = options.cdpConfig
@@ -292,6 +294,17 @@ export class PlaywrightExecutor {
292
294
  this.context = null
293
295
  }
294
296
 
297
+ private warnIfExtensionOutdated(playwriterVersion: string | null) {
298
+ if (this.hasWarnedExtensionOutdated) {
299
+ return
300
+ }
301
+ const warning = getExtensionOutdatedWarning(playwriterVersion)
302
+ if (warning) {
303
+ this.logger.log(warning)
304
+ this.hasWarnedExtensionOutdated = true
305
+ }
306
+ }
307
+
295
308
  private setupPageConsoleListener(page: Page) {
296
309
  // Use targetId() if available, fallback to internal _guid for CDP connections
297
310
  const targetId = page.targetId() || (page as any)._guid as string | undefined
@@ -330,9 +343,10 @@ export class PlaywrightExecutor {
330
343
  })
331
344
  }
332
345
 
333
- private async checkExtensionStatus(): Promise<{ connected: boolean; activeTargets: number }> {
346
+ private async checkExtensionStatus(): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null }> {
334
347
  const { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig
335
348
  const { httpBaseUrl } = parseRelayHost(host, port)
349
+ const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null }
336
350
  try {
337
351
  if (extensionId) {
338
352
  const response = await fetch(`${httpBaseUrl}/extensions/status`, {
@@ -343,31 +357,31 @@ export class PlaywrightExecutor {
343
357
  signal: AbortSignal.timeout(2000),
344
358
  })
345
359
  if (!fallback.ok) {
346
- return { connected: false, activeTargets: 0 }
360
+ return notConnected
347
361
  }
348
- return (await fallback.json()) as { connected: boolean; activeTargets: number }
362
+ return (await fallback.json()) as { connected: boolean; activeTargets: number; playwriterVersion: string | null }
349
363
  }
350
364
  const data = await response.json() as {
351
- extensions: Array<{ extensionId: string; stableKey?: string; activeTargets: number }>
365
+ extensions: Array<{ extensionId: string; stableKey?: string; activeTargets: number; playwriterVersion?: string | null }>
352
366
  }
353
367
  const extension = data.extensions.find((item) => {
354
368
  return item.extensionId === extensionId || item.stableKey === extensionId
355
369
  })
356
370
  if (!extension) {
357
- return { connected: false, activeTargets: 0 }
371
+ return notConnected
358
372
  }
359
- return { connected: true, activeTargets: extension.activeTargets }
373
+ return { connected: true, activeTargets: extension.activeTargets, playwriterVersion: extension?.playwriterVersion || null }
360
374
  }
361
375
 
362
376
  const response = await fetch(`${httpBaseUrl}/extension/status`, {
363
377
  signal: AbortSignal.timeout(2000),
364
378
  })
365
379
  if (!response.ok) {
366
- return { connected: false, activeTargets: 0 }
380
+ return notConnected
367
381
  }
368
- return (await response.json()) as { connected: boolean; activeTargets: number }
382
+ return (await response.json()) as { connected: boolean; activeTargets: number; playwriterVersion: string | null }
369
383
  } catch {
370
- return { connected: false, activeTargets: 0 }
384
+ return notConnected
371
385
  }
372
386
  }
373
387
 
@@ -381,6 +395,7 @@ export class PlaywrightExecutor {
381
395
  if (!extensionStatus.connected) {
382
396
  throw new Error(EXTENSION_NOT_CONNECTED_ERROR)
383
397
  }
398
+ this.warnIfExtensionOutdated(extensionStatus.playwriterVersion)
384
399
 
385
400
  // Generate a fresh unique URL for each Playwright connection
386
401
  const cdpUrl = getCdpUrl(this.cdpConfig)
@@ -455,6 +470,7 @@ export class PlaywrightExecutor {
455
470
  if (!extensionStatus.connected) {
456
471
  throw new Error(EXTENSION_NOT_CONNECTED_ERROR)
457
472
  }
473
+ this.warnIfExtensionOutdated(extensionStatus.playwriterVersion)
458
474
 
459
475
  // Generate a fresh unique URL for each Playwright connection
460
476
  const cdpUrl = getCdpUrl(this.cdpConfig)
@@ -498,7 +514,7 @@ export class PlaywrightExecutor {
498
514
  const formattedArgs = args
499
515
  .map((arg) => {
500
516
  if (typeof arg === 'string') return arg
501
- return util.inspect(arg, { depth: 4, colors: false, maxArrayLength: 100, breakLength: 80 })
517
+ return util.inspect(arg, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 })
502
518
  })
503
519
  .join(' ')
504
520
  text += `[${method}] ${formattedArgs}\n`
@@ -837,7 +853,7 @@ export class PlaywrightExecutor {
837
853
  const formatted =
838
854
  typeof resolvedResult === 'string'
839
855
  ? resolvedResult
840
- : util.inspect(resolvedResult, { depth: 4, colors: false, maxArrayLength: 100, breakLength: 80 })
856
+ : util.inspect(resolvedResult, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 })
841
857
  if (formatted.trim()) {
842
858
  responseText += `[return value] ${formatted}\n`
843
859
  }
package/src/mcp.ts CHANGED
@@ -3,8 +3,16 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { z } from 'zod'
4
4
  import fs from 'node:fs'
5
5
  import path from 'node:path'
6
+ import util from 'node:util'
6
7
  import { fileURLToPath } from 'node:url'
7
8
  import { createRequire } from 'node:module'
9
+
10
+ // Prevent Buffers from dumping hex bytes in util.inspect output.
11
+ // Without this, returning a screenshot Buffer would log ~400+ chars of useless hex.
12
+ Buffer.prototype[util.inspect.custom] = function () {
13
+ return `<Buffer ${this.length} bytes>`
14
+ }
15
+
8
16
  import dedent from 'string-dedent'
9
17
  import { LOG_FILE_PATH, VERSION, parseRelayHost } from './utils.js'
10
18
  import { ensureRelayServer, RELAY_PORT } from './relay-client.js'
@@ -30,7 +30,7 @@ export async function getRelayServerVersion(port: number = RELAY_PORT): Promise<
30
30
  }
31
31
  }
32
32
 
33
- export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ connected: boolean; activeTargets: number } | null> {
33
+ export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null } | null> {
34
34
  try {
35
35
  const response = await fetch(`http://127.0.0.1:${port}/extension/status`, {
36
36
  signal: AbortSignal.timeout(500),
@@ -38,7 +38,7 @@ export async function getExtensionStatus(port: number = RELAY_PORT): Promise<{ c
38
38
  if (!response.ok) {
39
39
  return null
40
40
  }
41
- return await response.json() as { connected: boolean; activeTargets: number }
41
+ return await response.json() as { connected: boolean; activeTargets: number; playwriterVersion: string | null }
42
42
  } catch {
43
43
  return null
44
44
  }
@@ -96,7 +96,7 @@ async function killRelayServer(options: { port: number; waitForFreeMs?: number }
96
96
  * - 0 if v1 === v2
97
97
  * - positive if v1 > v2
98
98
  */
99
- function compareVersions(v1: string, v2: string): number {
99
+ export function compareVersions(v1: string, v2: string): number {
100
100
  const parts1 = v1.split('.').map(Number)
101
101
  const parts2 = v2.split('.').map(Number)
102
102
  const len = Math.max(parts1.length, parts2.length)
@@ -111,6 +111,22 @@ function compareVersions(v1: string, v2: string): number {
111
111
  return 0
112
112
  }
113
113
 
114
+ /**
115
+ * Check if the running playwriter package is older than the version the extension was built with.
116
+ * The extension bundles the playwriter version at build time. If the extension reports a newer
117
+ * version, it means the user's CLI/MCP needs updating.
118
+ * Returns a warning message if outdated, null otherwise.
119
+ */
120
+ export function getExtensionOutdatedWarning(extensionPlaywriterVersion: string | null | undefined): string | null {
121
+ if (!extensionPlaywriterVersion) {
122
+ return null
123
+ }
124
+ if (compareVersions(extensionPlaywriterVersion, VERSION) > 0) {
125
+ return `Playwriter ${VERSION} is outdated (extension requires ${extensionPlaywriterVersion}). Run \`npm install -g playwriter@latest\` or update the playwriter package in your project.`
126
+ }
127
+ return null
128
+ }
129
+
114
130
  export interface EnsureRelayServerOptions {
115
131
  logger?: { log: (...args: any[]) => void }
116
132
  /** If true, will kill and restart server on version mismatch. Default: true */