playwriter 0.2.0 → 0.3.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.
Files changed (54) hide show
  1. package/dist/bippy.js +1 -1
  2. package/dist/cdp-log.d.ts +4 -1
  3. package/dist/cdp-log.d.ts.map +1 -1
  4. package/dist/cdp-log.js +39 -2
  5. package/dist/cdp-log.js.map +1 -1
  6. package/dist/cdp-log.test.d.ts +2 -0
  7. package/dist/cdp-log.test.d.ts.map +1 -0
  8. package/dist/cdp-log.test.js +109 -0
  9. package/dist/cdp-log.test.js.map +1 -0
  10. package/dist/cdp-relay.d.ts.map +1 -1
  11. package/dist/cdp-relay.js +103 -6
  12. package/dist/cdp-relay.js.map +1 -1
  13. package/dist/cli.js +8 -5
  14. package/dist/cli.js.map +1 -1
  15. package/dist/executor.d.ts +4 -0
  16. package/dist/executor.d.ts.map +1 -1
  17. package/dist/executor.js +102 -32
  18. package/dist/executor.js.map +1 -1
  19. package/dist/extension/background.js +23 -12
  20. package/dist/extension/manifest.json +1 -1
  21. package/dist/prompt.md +32 -13
  22. package/dist/readability.js +1 -1
  23. package/dist/relay-client.d.ts +11 -0
  24. package/dist/relay-client.d.ts.map +1 -1
  25. package/dist/relay-client.js +46 -1
  26. package/dist/relay-client.js.map +1 -1
  27. package/dist/relay-core.test.js +10 -6
  28. package/dist/relay-core.test.js.map +1 -1
  29. package/dist/relay-session.test.js +9 -1
  30. package/dist/relay-session.test.js.map +1 -1
  31. package/dist/relay-state.test.js +57 -1
  32. package/dist/relay-state.test.js.map +1 -1
  33. package/dist/selector-generator.js +1 -1
  34. package/dist/start-relay-server.d.ts +1 -1
  35. package/dist/start-relay-server.d.ts.map +1 -1
  36. package/dist/start-relay-server.js +23 -1
  37. package/dist/start-relay-server.js.map +1 -1
  38. package/dist/utils.d.ts +2 -1
  39. package/dist/utils.d.ts.map +1 -1
  40. package/dist/utils.js +4 -1
  41. package/dist/utils.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/cdp-log.test.ts +131 -0
  44. package/src/cdp-log.ts +44 -2
  45. package/src/cdp-relay.ts +110 -5
  46. package/src/cli.ts +8 -5
  47. package/src/executor.ts +119 -35
  48. package/src/relay-client.ts +62 -5
  49. package/src/relay-core.test.ts +10 -6
  50. package/src/relay-session.test.ts +9 -1
  51. package/src/relay-state.test.ts +67 -1
  52. package/src/skill.md +32 -13
  53. package/src/start-relay-server.ts +22 -1
  54. package/src/utils.ts +5 -0
package/src/executor.ts CHANGED
@@ -66,6 +66,7 @@ const usefulGlobals = {
66
66
  AbortController,
67
67
  AbortSignal,
68
68
  structuredClone,
69
+ process,
69
70
  } as const
70
71
 
71
72
  /**
@@ -226,6 +227,7 @@ export interface CdpConfig {
226
227
  port?: number
227
228
  token?: string
228
229
  extensionId?: string | null
230
+ autoEnable?: boolean
229
231
  /** Direct CDP WebSocket URL — bypasses relay + extension, connects straight to Chrome */
230
232
  directCdpUrl?: string
231
233
  }
@@ -287,7 +289,12 @@ export class PlaywrightExecutor {
287
289
  private context: BrowserContext | null = null
288
290
 
289
291
  private userState: Record<string, any> = {}
290
- private browserLogs: Map<string, string[]> = new Map()
292
+ private browserLogs: Map<Page, string[]> = new Map()
293
+ // Tracks the index up to which getLatestLogs({ sinceLastCall: true }) has
294
+ // returned logs. 0 means "return everything" (first call gets full buffer).
295
+ // When addBrowserLog shifts old entries (cap at MAX_LOGS_PER_PAGE), cursors
296
+ // are decremented so they stay in sync with the array.
297
+ private pageLogCursor: Map<Page, number> = new Map()
291
298
  private lastSnapshots: WeakMap<Page, Map<string, string>> = new WeakMap()
292
299
  private lastRefToLocator: WeakMap<Page, Map<string, string>> = new WeakMap()
293
300
  private warningEvents: WarningEvent[] = []
@@ -531,41 +538,68 @@ export class PlaywrightExecutor {
531
538
  }
532
539
 
533
540
  private setupPageConsoleListener(page: Page) {
534
- // Use targetId() if available, fallback to internal _guid for CDP connections
535
- const targetId = page.targetId() || ((page as any)._guid as string | undefined)
536
- if (!targetId) {
537
- return
538
- }
539
-
540
- if (!this.browserLogs.has(targetId)) {
541
- this.browserLogs.set(targetId, [])
541
+ if (!this.browserLogs.has(page)) {
542
+ this.browserLogs.set(page, [])
542
543
  }
543
544
 
544
- page.on('framenavigated', (frame) => {
545
- if (frame === page.mainFrame()) {
546
- this.browserLogs.set(targetId, [])
547
- }
548
- })
545
+ // Logs are NOT cleared on navigation so that getLatestLogs({ sinceLastCall: true })
546
+ // can return errors from the previous page load. The MAX_LOGS_PER_PAGE cap (5000)
547
+ // prevents unbounded growth; old entries are shifted out in addBrowserLog.
549
548
 
550
549
  page.on('close', () => {
551
- this.browserLogs.delete(targetId)
550
+ this.browserLogs.delete(page)
551
+ this.pageLogCursor.delete(page)
552
552
  })
553
553
 
554
554
  page.on('console', (msg) => {
555
555
  try {
556
556
  const logEntry = `[${msg.type()}] ${msg.text()}`
557
- if (!this.browserLogs.has(targetId)) {
558
- this.browserLogs.set(targetId, [])
559
- }
560
- const pageLogs = this.browserLogs.get(targetId)!
561
- pageLogs.push(logEntry)
562
- if (pageLogs.length > MAX_LOGS_PER_PAGE) {
563
- pageLogs.shift()
564
- }
557
+ this.addBrowserLog({ page, logEntry })
565
558
  } catch (e) {
566
559
  this.logger.error('[Executor] Failed to get console message text:', e)
567
560
  }
568
561
  })
562
+
563
+ page.on('pageerror', (error) => {
564
+ this.addBrowserLog({ page, logEntry: `[pageerror] ${error.message}` })
565
+ })
566
+ }
567
+
568
+ private addBrowserLog(options: { page: Page; logEntry: string }) {
569
+ if (!this.browserLogs.has(options.page)) {
570
+ this.browserLogs.set(options.page, [])
571
+ }
572
+ const pageLogs = this.browserLogs.get(options.page)!
573
+ pageLogs.push(options.logEntry)
574
+ if (pageLogs.length > MAX_LOGS_PER_PAGE) {
575
+ pageLogs.shift()
576
+ // Decrement cursor so it stays in sync with the shifted array.
577
+ // Clamp to 0 so the cursor never goes negative.
578
+ const cursor = this.pageLogCursor.get(options.page)
579
+ if (cursor !== undefined && cursor > 0) {
580
+ this.pageLogCursor.set(options.page, cursor - 1)
581
+ }
582
+ }
583
+ }
584
+
585
+ private pagesRelatedToPage(page: Page): Page[] {
586
+ const frameUrls = new Set(
587
+ page
588
+ .frames()
589
+ .map((frame) => {
590
+ return frame.url()
591
+ })
592
+ .filter((url) => {
593
+ return url && url !== 'about:blank'
594
+ }),
595
+ )
596
+
597
+ return page
598
+ .context()
599
+ .pages()
600
+ .filter((candidate) => {
601
+ return candidate === page || frameUrls.has(candidate.url())
602
+ })
569
603
  }
570
604
 
571
605
  private async checkExtensionStatus(): Promise<{
@@ -573,17 +607,24 @@ export class PlaywrightExecutor {
573
607
  activeTargets: number
574
608
  playwriterVersion: string | null
575
609
  }> {
576
- const { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig
610
+ const { host = '127.0.0.1', port = 19988, extensionId, token } = this.cdpConfig
577
611
  const { httpBaseUrl } = parseRelayHost(host, port)
578
612
  const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null }
613
+ const headers: Record<string, string> = {}
614
+ const effectiveToken = token || process.env.PLAYWRITER_TOKEN
615
+ if (effectiveToken) {
616
+ headers['Authorization'] = `Bearer ${effectiveToken}`
617
+ }
579
618
  try {
580
619
  if (extensionId) {
581
620
  const response = await fetch(`${httpBaseUrl}/extensions/status`, {
582
621
  signal: AbortSignal.timeout(2000),
622
+ headers,
583
623
  })
584
624
  if (!response.ok) {
585
625
  const fallback = await fetch(`${httpBaseUrl}/extension/status`, {
586
626
  signal: AbortSignal.timeout(2000),
627
+ headers,
587
628
  })
588
629
  if (!fallback.ok) {
589
630
  return notConnected
@@ -617,6 +658,7 @@ export class PlaywrightExecutor {
617
658
 
618
659
  const response = await fetch(`${httpBaseUrl}/extension/status`, {
619
660
  signal: AbortSignal.timeout(2000),
661
+ headers,
620
662
  })
621
663
  if (!response.ok) {
622
664
  return notConnected
@@ -968,21 +1010,50 @@ export class PlaywrightExecutor {
968
1010
  })
969
1011
  }
970
1012
 
971
- const getLatestLogs = async (options?: { page?: Page; count?: number; search?: string | RegExp }) => {
972
- const { page: filterPage, count, search } = options || {}
1013
+ const getLatestLogs = async (options?: {
1014
+ page?: Page
1015
+ count?: number
1016
+ search?: string | RegExp
1017
+ // When true, only return logs added since the last getLatestLogs call
1018
+ // with sinceLastCall: true. First call returns all buffered logs.
1019
+ // Cursors are tracked per page so navigations and new logs are
1020
+ // never missed. Useful for checking page errors after each action.
1021
+ sinceLastCall?: boolean
1022
+ }) => {
1023
+ const { page: filterPage, count, search, sinceLastCall = false } = options || {}
973
1024
  let allLogs: string[] = []
974
1025
 
975
- if (filterPage) {
976
- // Use targetId() if available, fallback to internal _guid for CDP connections
977
- const targetId = filterPage.targetId() || ((filterPage as any)._guid as string | undefined)
978
- if (!targetId) {
979
- throw new Error('Could not get page targetId')
1026
+ // Collect logs, optionally slicing from cursor when sinceLastCall is set
1027
+ const collectLogs = (targetPage: Page): string[] => {
1028
+ const logs = this.browserLogs.get(targetPage) || []
1029
+ if (!sinceLastCall) {
1030
+ return logs
980
1031
  }
981
- const pageLogs = this.browserLogs.get(targetId) || []
982
- allLogs = [...pageLogs]
1032
+ const cursor = this.pageLogCursor.get(targetPage) || 0
1033
+ return logs.slice(cursor)
1034
+ }
1035
+
1036
+ if (filterPage) {
1037
+ const relatedPages = this.pagesRelatedToPage(filterPage)
1038
+ allLogs = relatedPages.flatMap((relatedPage) => {
1039
+ return collectLogs(relatedPage)
1040
+ })
983
1041
  } else {
984
- for (const pageLogs of this.browserLogs.values()) {
985
- allLogs.push(...pageLogs)
1042
+ for (const [p] of this.browserLogs) {
1043
+ allLogs.push(...collectLogs(p))
1044
+ }
1045
+ }
1046
+
1047
+ // Advance cursors after collecting so next sinceLastCall call starts fresh
1048
+ if (sinceLastCall) {
1049
+ const pagesToAdvance = filterPage
1050
+ ? this.pagesRelatedToPage(filterPage)
1051
+ : [...this.browserLogs.keys()]
1052
+ for (const p of pagesToAdvance) {
1053
+ const logs = this.browserLogs.get(p)
1054
+ if (logs) {
1055
+ this.pageLogCursor.set(p, logs.length)
1056
+ }
986
1057
  }
987
1058
  }
988
1059
 
@@ -1021,6 +1092,7 @@ export class PlaywrightExecutor {
1021
1092
 
1022
1093
  const clearAllLogs = () => {
1023
1094
  this.browserLogs.clear()
1095
+ this.pageLogCursor.clear()
1024
1096
  }
1025
1097
 
1026
1098
  const getCDPSession = async (options: { page: Page }) => {
@@ -1221,6 +1293,18 @@ export class PlaywrightExecutor {
1221
1293
  // Ghost Browser API - only works in Ghost Browser, mirrors chrome.ghostPublicAPI etc
1222
1294
  chrome: chromeGhostBrowser,
1223
1295
  ...usefulGlobals,
1296
+ // Expose process with safety overrides:
1297
+ // - cwd() returns the session's cwd instead of the relay server's cwd
1298
+ // - exit() is blocked to prevent killing the relay server
1299
+ // - chdir() is blocked to prevent affecting other sessions
1300
+ process: new Proxy(process, {
1301
+ get(target, prop, receiver) {
1302
+ if (prop === 'cwd') return () => self.sessionCwd || target.cwd()
1303
+ if (prop === 'exit') return () => { throw new Error('process.exit() is not allowed in the sandbox') }
1304
+ if (prop === 'chdir') return () => { throw new Error('process.chdir() is not allowed in the sandbox, use a new session with a different cwd instead') }
1305
+ return Reflect.get(target, prop, receiver)
1306
+ },
1307
+ }),
1224
1308
  }
1225
1309
 
1226
1310
  const vmContext = vm.createContext(vmContextObj)
@@ -39,6 +39,31 @@ export async function getRelayServerVersion(port: number = RELAY_PORT): Promise<
39
39
  }
40
40
  }
41
41
 
42
+ /**
43
+ * Poll /version until a relay responds or timeout expires.
44
+ * Used during startup races where a relay may have bound the port
45
+ * but isn't serving HTTP yet (issue #75).
46
+ */
47
+ export async function waitForRelayVersion({
48
+ port = RELAY_PORT,
49
+ timeoutMs = 2000,
50
+ intervalMs = 200,
51
+ }: {
52
+ port?: number
53
+ timeoutMs?: number
54
+ intervalMs?: number
55
+ } = {}): Promise<string | null> {
56
+ const end = Date.now() + timeoutMs
57
+ while (Date.now() < end) {
58
+ const version = await getRelayServerVersion(port)
59
+ if (version) {
60
+ return version
61
+ }
62
+ await sleep(intervalMs)
63
+ }
64
+ return null
65
+ }
66
+
42
67
  export async function getExtensionStatus(
43
68
  port: number = RELAY_PORT,
44
69
  ): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null } | null> {
@@ -196,11 +221,26 @@ export interface EnsureRelayServerOptions {
196
221
  env?: Record<string, string>
197
222
  }
198
223
 
224
+ // Module-level dedup: if ensureRelayServer is called concurrently within the
225
+ // same process (e.g. two MCP tool handlers at once), only one spawn runs.
226
+ let pendingEnsure: Promise<true | undefined> | null = null
227
+
199
228
  /**
200
229
  * Ensures the relay server is running. Starts it if not running.
201
230
  * Optionally restarts on version mismatch.
231
+ * Concurrent calls within the same process are deduplicated.
202
232
  */
203
233
  export async function ensureRelayServer(options: EnsureRelayServerOptions = {}): Promise<true | undefined> {
234
+ if (pendingEnsure) {
235
+ return pendingEnsure
236
+ }
237
+ pendingEnsure = ensureRelayServerImpl(options).finally(() => {
238
+ pendingEnsure = null
239
+ })
240
+ return pendingEnsure
241
+ }
242
+
243
+ async function ensureRelayServerImpl(options: EnsureRelayServerOptions = {}): Promise<true | undefined> {
204
244
  const { logger, restartOnVersionMismatch = true, env: additionalEnv } = options
205
245
  const serverVersion = await getRelayServerVersion(RELAY_PORT)
206
246
 
@@ -227,11 +267,28 @@ export async function ensureRelayServer(options: EnsureRelayServerOptions = {}):
227
267
  } else {
228
268
  const listeningPids = await getListeningPidsForPort({ port: RELAY_PORT }).catch(() => [])
229
269
  if (listeningPids.length > 0) {
230
- logger?.log(
231
- pc.yellow(
232
- `Port ${RELAY_PORT} is already in use (pid(s): ${listeningPids.join(', ')}). Attempting to stop the existing process...`,
233
- ),
234
- )
270
+ // Something is on the port but /version didn't respond. It might be a
271
+ // relay that's still starting (race with another CLI/MCP instance).
272
+ // Poll /version briefly before deciding to kill it (issue #75).
273
+ const foundVersion = await waitForRelayVersion({ port: RELAY_PORT })
274
+ if (foundVersion) {
275
+ // A relay came up while we waited; use it
276
+ if (foundVersion === VERSION || compareVersions(foundVersion, VERSION) > 0) {
277
+ return
278
+ }
279
+ if (!restartOnVersionMismatch) {
280
+ return
281
+ }
282
+ logger?.log(
283
+ pc.yellow(`CDP relay server version mismatch (server: ${foundVersion}, client: ${VERSION}), restarting...`),
284
+ )
285
+ } else {
286
+ logger?.log(
287
+ pc.yellow(
288
+ `Port ${RELAY_PORT} is already in use (pid(s): ${listeningPids.join(', ')}). Attempting to stop the existing process...`,
289
+ ),
290
+ )
291
+ }
235
292
  await killRelayServer({ port: RELAY_PORT })
236
293
  }
237
294
 
@@ -730,6 +730,7 @@ describe('Relay Core Tests', () => {
730
730
  console.error('Test error 67890');
731
731
  console.warn('Test warning 11111');
732
732
  console.log('Test log 2 with', { data: 'object' });
733
+ setTimeout(() => { throw new Error('Test pageerror 22222'); }, 0);
733
734
  });
734
735
  // Wait for logs to be captured
735
736
  await new Promise(resolve => setTimeout(resolve, 100));
@@ -752,6 +753,7 @@ describe('Relay Core Tests', () => {
752
753
  expect(output).toContain('[log] Test log 12345')
753
754
  expect(output).toContain('[error] Test error 67890')
754
755
  expect(output).toContain('[warning] Test warning 11111')
756
+ expect(output).toContain('[pageerror] Test pageerror 22222')
755
757
 
756
758
  // Test filtering by search string
757
759
  const errorLogsResult = await client.callTool({
@@ -769,7 +771,7 @@ describe('Relay Core Tests', () => {
769
771
  // With context lines (5 above/below), nearby logs are also included
770
772
  expect(errorOutput).toContain('[log] Test log 12345')
771
773
 
772
- // Test that logs are cleared on page reload
774
+ // Test that logs persist across page reload
773
775
  await client.callTool({
774
776
  name: 'execute',
775
777
  arguments: {
@@ -812,7 +814,9 @@ describe('Relay Core Tests', () => {
812
814
  },
813
815
  })
814
816
 
815
- // Check logs after reload - old logs should be gone
817
+ // Check logs after reload - old logs persist (no longer cleared on navigation)
818
+ // so both pre- and post-reload logs are present. Use sinceLastCall to get
819
+ // only new logs if needed.
816
820
  const afterReloadResult = await client.callTool({
817
821
  name: 'execute',
818
822
  arguments: {
@@ -826,7 +830,7 @@ describe('Relay Core Tests', () => {
826
830
 
827
831
  const afterReloadOutput = (afterReloadResult as any).content[0].text
828
832
  expect(afterReloadOutput).toContain('[log] After reload 88888')
829
- expect(afterReloadOutput).not.toContain('[log] Before reload 99999')
833
+ expect(afterReloadOutput).toContain('[log] Before reload 99999')
830
834
 
831
835
  // Clean up
832
836
  await client.callTool({
@@ -943,7 +947,7 @@ describe('Relay Core Tests', () => {
943
947
  expect(allOutput).toContain('[log] PageA log 11111')
944
948
  expect(allOutput).toContain('[log] PageB log 33333')
945
949
 
946
- // Test that reloading page A clears only page A logs
950
+ // Test that reloading page A preserves logs (no longer cleared on navigation)
947
951
  await client.callTool({
948
952
  name: 'execute',
949
953
  arguments: {
@@ -957,7 +961,7 @@ describe('Relay Core Tests', () => {
957
961
  },
958
962
  })
959
963
 
960
- // Check page A logs - should only have new log
964
+ // Check page A logs - logs persist across navigation, so both old and new are present
961
965
  const pageAAfterReloadResult = await client.callTool({
962
966
  name: 'execute',
963
967
  arguments: {
@@ -971,7 +975,7 @@ describe('Relay Core Tests', () => {
971
975
 
972
976
  const pageAAfterReloadOutput = (pageAAfterReloadResult as any).content[0].text
973
977
  expect(pageAAfterReloadOutput).toContain('[log] PageA after reload 55555')
974
- expect(pageAAfterReloadOutput).not.toContain('[log] PageA log 11111')
978
+ expect(pageAAfterReloadOutput).toContain('[log] PageA log 11111')
975
979
 
976
980
  // Check page B logs - should still have original logs
977
981
  const pageBAfterAReloadResult = await client.callTool({
@@ -1277,7 +1277,15 @@ describe('Auto-enable Tests', () => {
1277
1277
  })
1278
1278
  expect(tabCountBefore).toBe(0)
1279
1279
 
1280
- const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
1280
+ const previousAutoEnable = process.env.PLAYWRITER_AUTO_ENABLE
1281
+ delete process.env.PLAYWRITER_AUTO_ENABLE
1282
+ const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT, autoEnable: true })).finally(() => {
1283
+ if (previousAutoEnable === undefined) {
1284
+ delete process.env.PLAYWRITER_AUTO_ENABLE
1285
+ return
1286
+ }
1287
+ process.env.PLAYWRITER_AUTO_ENABLE = previousAutoEnable
1288
+ })
1281
1289
 
1282
1290
  const pages = browser.contexts()[0].pages()
1283
1291
  expect(pages.length).toBeGreaterThan(0)
@@ -2,7 +2,7 @@
2
2
  * Unit tests for relay state transitions.
3
3
  * Data-in / data-out transitions for the unified extension map.
4
4
  */
5
- import { describe, test, expect } from 'vitest'
5
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest'
6
6
  import type { WSContext } from 'hono/ws'
7
7
  import type { Protocol } from './cdp-types.js'
8
8
  import * as relayState from './relay-state.js'
@@ -568,3 +568,69 @@ describe('store.setState with transitions', () => {
568
568
  expect(state.playwrightClients.size).toBe(1)
569
569
  })
570
570
  })
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // DNS rebinding protection — Host header validation
574
+ // Node.js fetch/undici treats Host as a forbidden header, so we use the raw
575
+ // http module to send requests with arbitrary Host values.
576
+ // ---------------------------------------------------------------------------
577
+
578
+ import http from 'node:http'
579
+
580
+ function httpGet({ port, path, host }: { port: number; path: string; host: string }): Promise<{ status: number; body: string }> {
581
+ return new Promise((resolve, reject) => {
582
+ const req = http.request({ hostname: '127.0.0.1', port, path, method: 'GET', headers: { Host: host } }, (res) => {
583
+ let body = ''
584
+ res.on('data', (chunk: Buffer) => {
585
+ body += chunk.toString()
586
+ })
587
+ res.on('end', () => {
588
+ resolve({ status: res.statusCode!, body })
589
+ })
590
+ })
591
+ req.on('error', reject)
592
+ req.end()
593
+ })
594
+ }
595
+
596
+ describe('Host header validation (DNS rebinding protection)', () => {
597
+ let server: { close(): void } | null = null
598
+ const TEST_PORT = 19996
599
+
600
+ beforeAll(async () => {
601
+ const { startPlayWriterCDPRelayServer } = await import('./cdp-relay.js')
602
+ server = await startPlayWriterCDPRelayServer({ port: TEST_PORT })
603
+ })
604
+
605
+ afterAll(async () => {
606
+ server?.close()
607
+ server = null
608
+ })
609
+
610
+ test('rejects requests with non-localhost Host header', async () => {
611
+ // DNS rebinding: evil.com resolves to 127.0.0.1
612
+ const res = await httpGet({ port: TEST_PORT, path: '/', host: 'evil.com' })
613
+ expect(res.status).toBe(403)
614
+ expect(res.body).toContain('Invalid Host header')
615
+
616
+ // With port suffix
617
+ const res2 = await httpGet({ port: TEST_PORT, path: '/version', host: 'evil.com:19996' })
618
+ expect(res2.status).toBe(403)
619
+ })
620
+
621
+ test('allows requests with localhost Host headers', async () => {
622
+ const localhostVariants = [
623
+ `localhost:${TEST_PORT}`,
624
+ `127.0.0.1:${TEST_PORT}`,
625
+ `[::1]:${TEST_PORT}`,
626
+ 'localhost',
627
+ '127.0.0.1',
628
+ 'LOCALHOST',
629
+ ]
630
+
631
+ for (const hostValue of localhostVariants) {
632
+ const res = await httpGet({ port: TEST_PORT, path: '/', host: hostValue })
633
+ expect(res.status, `Expected 200 for Host: ${hostValue}`).toBe(200)
634
+ }
635
+ })
636
+ })
package/src/skill.md CHANGED
@@ -203,10 +203,19 @@ You can collaborate with the user - they can help with captchas, difficult eleme
203
203
  - `page` - a default page (may be shared with other agents). Prefer creating your own page and storing it in `state` (see "working with pages")
204
204
  - `context` - browser context, access all pages via `context.pages()`
205
205
  - `require` - load Node.js modules (e.g., `const fs = require('node:fs')`). ESM `import` is not available in the sandbox
206
- - Node.js globals: `setTimeout`, `setInterval`, `fetch`, `URL`, `Buffer`, `crypto`, etc.
206
+ - Node.js globals: `setTimeout`, `setInterval`, `fetch`, `URL`, `Buffer`, `crypto`, `process`, etc.
207
+
208
+ **Not available in the sandbox:** `__dirname`, `__filename`, `import`.
207
209
 
208
210
  **Important:** `state` is **session-isolated** but pages are **shared** across all sessions. See "working with pages" for how to avoid interference.
209
211
 
212
+ **Sandboxed `fs` write restrictions:** `require('node:fs')` is scoped. Writes (writeFileSync, mkdirSync, etc.) only succeed in:
213
+ - The **directory where `playwriter` CLI was invoked** (the session's cwd)
214
+ - `/tmp`
215
+ - The OS temp directory (`os.tmpdir()`, e.g. `/var/folders/.../T/` on macOS)
216
+
217
+ Writing to any other path (e.g. `~/Downloads`, `~/Desktop`) throws `EPERM: operation not permitted, access outside allowed directories`. To save files elsewhere, write to a temp path first, then move the file using a shell command outside the sandbox.
218
+
210
219
  ## rules
211
220
 
212
221
  - **Initialize state.page first**: see "working with pages" — at the start of a task, assign `state.page` (reuse `about:blank` or create one) and use `state.page` for all automation steps.
@@ -215,6 +224,7 @@ You can collaborate with the user - they can help with captchas, difficult eleme
215
224
  - **No bringToFront**: never call unless user asks - it's disruptive and unnecessary, you can interact with background pages
216
225
  - **Check state after actions**: always verify page state after clicking/submitting (see next section)
217
226
  - **Clean up listeners**: call `state.page.removeAllListeners()` at end of message to prevent leaks
227
+ - **Always print page logs after every action**: call `getLatestLogs({ page: state.page, sinceLastCall: true })` after every goto, click, or submit to catch console errors and warnings. Do not manually collect `page.on('console')` events; manual listeners miss logs emitted before the listener is attached. The first `sinceLastCall` call returns all buffered logs including startup and hydration errors.
218
228
  - **CDP sessions**: use `getCDPSession({ page: state.page })` not `state.page.context().newCDPSession()` - NEVER use `newCDPSession()` method, it doesn't work through playwriter relay
219
229
  - **Wait for load**: use `state.page.waitForLoadState('domcontentloaded')` not `state.page.waitForEvent('load')` - waitForEvent times out if already loaded
220
230
  - **Minimize timeouts**: prefer proper waits (`waitForSelector`, `waitForPageLoad`) over `state.page.waitForTimeout()`. Short timeouts (1-2s) are acceptable for non-deterministic events like animations, tab opens, or async UI updates where no specific selector is available
@@ -227,18 +237,21 @@ You can collaborate with the user - they can help with captchas, difficult eleme
227
237
  Every browser interaction must follow **observe → act → observe**. Never chain multiple actions blindly.
228
238
 
229
239
  1. **Open page** — get or create your page, navigate to URL
230
- 2. **Observe** — print `state.page.url()` + `snapshot()`. Always print URL — pages can redirect unexpectedly.
240
+ 2. **Observe** — print `state.page.url()` + `snapshot()` + `getLatestLogs({ sinceLastCall: true })`. Always print URL — pages can redirect unexpectedly.
231
241
  3. **Check** — if page isn't ready (loading, wrong URL, content missing), wait and observe again
232
242
  4. **Act** — perform one action (click, type, submit)
233
- 5. **Observe again** — print URL + snapshot to verify the action's effect
243
+ 5. **Observe again** — print URL + snapshot + page logs to verify the action's effect
234
244
  6. **Repeat** from step 3 until task is complete
235
245
 
246
+ **Always print page logs after every action** using `getLatestLogs({ sinceLastCall: true })`. This returns only new console messages and errors since the last call, so you catch hydration errors, failed network requests, and runtime exceptions without duplicates. The first call returns all buffered logs from the page, including logs emitted before your script started.
247
+
236
248
  ```js
237
249
  // Each step should be a separate execute call:
238
250
  // Step 1: navigate + observe
239
251
  state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
240
252
  await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
241
253
  console.log('URL:', state.page.url())
254
+ console.log('Page logs:', await getLatestLogs({ page: state.page, sinceLastCall: true }))
242
255
  await snapshot({ page: state.page }).then(console.log)
243
256
  ```
244
257
 
@@ -246,25 +259,26 @@ await snapshot({ page: state.page }).then(console.log)
246
259
  // Step 2: act + observe
247
260
  await state.page.locator('button:has-text("Submit")').click()
248
261
  console.log('URL:', state.page.url())
262
+ console.log('Page logs:', await getLatestLogs({ page: state.page, sinceLastCall: true }))
249
263
  await snapshot({ page: state.page }).then(console.log)
250
264
  ```
251
265
 
252
266
  If nothing changed after an action, try `waitForPageLoad({ page: state.page, timeout: 3000 })` or you may have clicked the wrong element.
253
267
 
254
- **Deeper observation** — when snapshots aren't enough to understand what happened, combine multiple channels:
268
+ **Deeper observation** — when snapshots aren't enough to understand what happened, combine snapshot with filtered logs:
255
269
 
256
270
  ```js
257
- // Check console for errors after an action
271
+ // Search for specific errors in all logs (not just since last call)
258
272
  const errors = await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 })
259
273
 
260
- // Combine snapshot + logs for full picture
274
+ // Combine snapshot + filtered logs for full picture
261
275
  const snap = await snapshot({ page: state.page, search: /dialog|error|message/ })
262
276
  const logs = await getLatestLogs({ page: state.page, search: /error/i, count: 10 })
263
277
  console.log('UI:', snap)
264
278
  console.log('Logs:', logs)
265
279
  ```
266
280
 
267
- Use `getLatestLogs()` for console errors, `state.page.url()` for navigation, screenshots only for visual layout issues.
281
+ Use `getLatestLogs({ sinceLastCall: true })` after every action, `getLatestLogs({ search })` for targeted debugging, `state.page.url()` for navigation, screenshots only for visual layout issues.
268
282
 
269
283
  ## common mistakes to avoid
270
284
 
@@ -680,17 +694,22 @@ For carousels or lazy-loaded galleries, you may need to click navigation arrows
680
694
 
681
695
  ## utility functions
682
696
 
683
- **getLatestLogs** - retrieve captured browser console logs (up to 5000 per page, cleared on navigation):
697
+ **getLatestLogs** - retrieve captured browser console logs and page errors (up to 5000 per page):
698
+
699
+ Always use this helper when inspecting browser logs. Do not attach new `page.on('console')` listeners for debugging because they only see future events and can miss logs emitted during page startup or hydration.
700
+
701
+ Use `sinceLastCall: true` after every action to get only new logs since the previous call. The first call returns all buffered logs including pre-existing ones. Logs persist across navigations so you never miss errors from page transitions.
684
702
 
685
703
  ```js
686
- await getLatestLogs({ page?, count?, search? })
687
- // Examples:
704
+ await getLatestLogs({ page?, count?, search?, sinceLastCall? })
705
+ // After every action: get only new logs
706
+ const newLogs = await getLatestLogs({ page: state.page, sinceLastCall: true })
707
+ // Search all logs (ignores cursor):
688
708
  const errors = await getLatestLogs({ search: /error/i, count: 50 })
689
- const pageLogs = await getLatestLogs({ page: state.page })
709
+ const pageLogs = await getLatestLogs({ page: state.page, count: 100 })
710
+ const hydrationErrors = await getLatestLogs({ page: state.page, search: /hydration|pageerror|React/i })
690
711
  ```
691
712
 
692
- For custom log collection across runs, store in state: `state.logs = []; state.page.on('console', m => state.logs.push(m.text()))`
693
-
694
713
  **getCleanHTML** - get cleaned HTML from a locator or page, with search and diffing:
695
714
 
696
715
  ```js
@@ -1,5 +1,6 @@
1
1
  import { startPlayWriterCDPRelayServer } from './cdp-relay.js'
2
2
  import { createFileLogger } from './create-logger.js'
3
+ import { waitForRelayVersion } from './relay-client.js'
3
4
  import { LOG_CDP_FILE_PATH } from './utils.js'
4
5
 
5
6
  process.title = 'playwriter-ws-server'
@@ -25,7 +26,27 @@ export async function startServer({
25
26
  host = '127.0.0.1',
26
27
  token,
27
28
  }: { port?: number; host?: string; token?: string } = {}) {
28
- const server = await startPlayWriterCDPRelayServer({ port, host, token, logger })
29
+ let server
30
+ try {
31
+ server = await startPlayWriterCDPRelayServer({ port, host, token, logger })
32
+ } catch (err: unknown) {
33
+ // When two relay processes race to start (issue #75), the loser gets
34
+ // EADDRINUSE. Check if the winner is a valid relay and exit cleanly
35
+ // instead of crashing with a scary error in the logs.
36
+ const errWithCode = err as NodeJS.ErrnoException
37
+ if (errWithCode?.code === 'EADDRINUSE') {
38
+ // The winner may have bound the port but not be ready to answer /version
39
+ // yet, so poll for up to 2 seconds before giving up.
40
+ const version = await waitForRelayVersion({ port })
41
+ if (version) {
42
+ await logger.log(`Another relay (v${version}) already bound to port ${port}, exiting gracefully`)
43
+ process.exit(0)
44
+ }
45
+ await logger.error(`Port ${port} is in use by a non-relay process`)
46
+ process.exit(1)
47
+ }
48
+ throw err
49
+ }
29
50
 
30
51
  console.log('CDP Relay Server running. Press Ctrl+C to stop.')
31
52
  console.log('Logs are being written to:', logger.logFilePath)