fix: scope CDP tab sessions and simplify recording sessionId routing

This commit is contained in:
Tommy D. Rossi
2026-02-25 16:52:41 +01:00
parent 40f9c54f74
commit 95ff2a797d
10 changed files with 74 additions and 53 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## 0.0.72
### Bug Fixes
- **Use runtime-scoped root CDP tab session IDs**: Root tab sessions now use `pw-tab-<scope>-<n>` instead of `pw-tab-<n>`, where scope is a random value generated once per extension runtime. This prevents session ID collisions across multiple connected Chrome profiles.
## 0.0.71 ## 0.0.71
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Playwriter", "name": "Playwriter",
"version": "0.0.71", "version": "0.0.72",
"description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.", "description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.",
"permissions": [ "permissions": [
"debugger", "debugger",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mcp-extension", "name": "mcp-extension",
"version": "0.0.71", "version": "0.0.72",
"description": "Playwright MCP Browser Extension", "description": "Playwright MCP Browser Extension",
"private": true, "private": true,
"repository": { "repository": {
+10 -1
View File
@@ -66,6 +66,15 @@ async function detectBrowserName(): Promise<string> {
} }
let identityPromise: Promise<ExtensionIdentity> | null = null let identityPromise: Promise<ExtensionIdentity> | null = null
const tabSessionScope = (() => {
const values = new Uint32Array(2)
crypto.getRandomValues(values)
return Array.from(values)
.map((value) => {
return value.toString(36)
})
.join('')
})()
async function getExtensionIdentity(): Promise<ExtensionIdentity> { async function getExtensionIdentity(): Promise<ExtensionIdentity> {
if (identityPromise) { if (identityPromise) {
@@ -1070,7 +1079,7 @@ async function attachTab(
} }
const attachOrder = nextSessionId const attachOrder = nextSessionId
const sessionId = `pw-tab-${nextSessionId++}` const sessionId = `pw-tab-${tabSessionScope}-${nextSessionId++}`
store.setState((state) => { store.setState((state) => {
const newTabs = new Map(state.tabs) const newTabs = new Map(state.tabs)
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## 0.0.69
### Bug Fixes
- **Scope CDP tab session IDs by extension runtime**: Switched root tab IDs to `pw-tab-<scope>-<n>` so concurrent extension connections do not reuse the same `pw-tab-1`, `pw-tab-2`, etc. The scope is generated once per extension runtime to avoid cross-profile collisions and ambiguous recording-route resolution.
- **Standardize recording routes on CDP `sessionId`**: Recording HTTP routes now treat `sessionId` as a CDP tab session ID (`pw-tab-*`) only, removing executor-target branching from the recording path.
## 0.0.68 ## 0.0.68
### Tests ### Tests
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "playwriter", "name": "playwriter",
"description": "", "description": "",
"version": "0.0.68", "version": "0.0.69",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
+24 -39
View File
@@ -429,9 +429,9 @@ export async function startPlayWriterCDPRelayServer({
const recordingRelays = new Map<string, RecordingRelay>() const recordingRelays = new Map<string, RecordingRelay>()
// Find which extension connection owns a CDP session ID (pw-tab-*). // Find which extension connection owns a CDP tab session ID (pw-tab-*).
// Used by recording routes where sessionId is a CDP tab session, not // Used by recording routes where sessionId identifies the target tab.
// an executor session. Searches all connected extensions' targets. // Searches all connected extensions' targets.
const findExtensionIdByCdpSession = (cdpSessionId: string): string | null => { const findExtensionIdByCdpSession = (cdpSessionId: string): string | null => {
for (const [connectionId, connection] of extensionConnections.entries()) { for (const [connectionId, connection] of extensionConnections.entries()) {
if (connection.connectedTargets.has(cdpSessionId)) { if (connection.connectedTargets.has(cdpSessionId)) {
@@ -441,29 +441,21 @@ export async function startPlayWriterCDPRelayServer({
return null return null
} }
// Resolve extensionId from a recording route's sessionId parameter. // Resolve recording route session ID (CDP tab session) to extension connection.
// sessionId can be either: const resolveRecordingRoute = async ({
// - CDP session ID (pw-tab-*) — look up which extension owns that tab sessionId,
// - Executor session ID (numeric) — look up which extension the executor is bound to }: {
// - null — fall back to default extension sessionId: string | null
const resolveRecordingExtensionId = async (sessionId: string | null): Promise<{ }): Promise<{
extensionId: string | null extensionId: string | null
error?: string sessionId: string | null
}> => { }> => {
if (!sessionId) { if (!sessionId) {
return { extensionId: null } return { extensionId: null, sessionId: null }
} }
if (sessionId.startsWith('pw-tab-')) {
const extensionId = findExtensionIdByCdpSession(sessionId) const extensionId = findExtensionIdByCdpSession(sessionId)
return { extensionId } return { extensionId, sessionId }
}
// Numeric executor session — resolve extension from executor metadata
const manager = await getExecutorManager()
const executor = manager.getSession(sessionId)
if (!executor) {
return { extensionId: null, error: `Session ${sessionId} not found` }
}
return { extensionId: executor.getSessionMetadata().extensionId || null }
} }
const getRecordingRelay = (extensionId?: string | null): RecordingRelay | null => { const getRecordingRelay = (extensionId?: string | null): RecordingRelay | null => {
@@ -1766,15 +1758,14 @@ export async function startPlayWriterCDPRelayServer({
} }
const sessionId = normalizeSessionId(body.sessionId) const sessionId = normalizeSessionId(body.sessionId)
const { sessionId: _sessionId, ...recordingOptions } = body const { sessionId: _sessionId, ...recordingOptions } = body
const { extensionId, error } = await resolveRecordingExtensionId(sessionId) const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
if (error) {
return c.json({ success: false, error }, 404)
}
const relay = getRecordingRelay(extensionId) const relay = getRecordingRelay(extensionId)
if (!relay) { if (!relay) {
return c.json({ success: false, error: 'Extension not connected' }, 500) return c.json({ success: false, error: 'Extension not connected' }, 500)
} }
const recordingParams = (sessionId ? { ...recordingOptions, sessionId } : recordingOptions) as StartRecordingBody const recordingParams = (resolvedSessionId
? { ...recordingOptions, sessionId: resolvedSessionId }
: recordingOptions) as StartRecordingBody
const result = await relay.startRecording(recordingParams) const result = await relay.startRecording(recordingParams)
const status = result.success ? 200 : result.error?.includes('required') ? 400 : 500 const status = result.success ? 200 : result.error?.includes('required') ? 400 : 500
return c.json(result, status) return c.json(result, status)
@@ -1783,15 +1774,12 @@ export async function startPlayWriterCDPRelayServer({
app.post('/recording/stop', async (c) => { app.post('/recording/stop', async (c) => {
const body = (await c.req.json()) as { sessionId?: string | number } const body = (await c.req.json()) as { sessionId?: string | number }
const sessionId = normalizeSessionId(body.sessionId) const sessionId = normalizeSessionId(body.sessionId)
const { extensionId, error } = await resolveRecordingExtensionId(sessionId) const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
if (error) {
return c.json({ success: false, error }, 404)
}
const relay = getRecordingRelay(extensionId) const relay = getRecordingRelay(extensionId)
if (!relay) { if (!relay) {
return c.json({ success: false, error: 'Extension not connected' }, 500) return c.json({ success: false, error: 'Extension not connected' }, 500)
} }
const stopParams: StopRecordingParams = sessionId ? { sessionId } : {} const stopParams: StopRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
const result = await relay.stopRecording(stopParams) const result = await relay.stopRecording(stopParams)
const status = result.success ? 200 : result.error?.includes('not found') ? 404 : 500 const status = result.success ? 200 : result.error?.includes('not found') ? 404 : 500
return c.json(result, status) return c.json(result, status)
@@ -1799,12 +1787,12 @@ export async function startPlayWriterCDPRelayServer({
app.get('/recording/status', async (c) => { app.get('/recording/status', async (c) => {
const sessionId = normalizeSessionId(c.req.query('sessionId')) const sessionId = normalizeSessionId(c.req.query('sessionId'))
const { extensionId } = await resolveRecordingExtensionId(sessionId) const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
const relay = getRecordingRelay(extensionId) const relay = getRecordingRelay(extensionId)
if (!relay) { if (!relay) {
return c.json({ isRecording: false }) return c.json({ isRecording: false })
} }
const isRecordingParams: IsRecordingParams = sessionId ? { sessionId } : {} const isRecordingParams: IsRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
const result = await relay.isRecording(isRecordingParams) const result = await relay.isRecording(isRecordingParams)
return c.json(result) return c.json(result)
}) })
@@ -1812,15 +1800,12 @@ export async function startPlayWriterCDPRelayServer({
app.post('/recording/cancel', async (c) => { app.post('/recording/cancel', async (c) => {
const body = (await c.req.json()) as { sessionId?: string | number } const body = (await c.req.json()) as { sessionId?: string | number }
const sessionId = normalizeSessionId(body.sessionId) const sessionId = normalizeSessionId(body.sessionId)
const { extensionId, error } = await resolveRecordingExtensionId(sessionId) const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
if (error) {
return c.json({ success: false, error }, 404)
}
const relay = getRecordingRelay(extensionId) const relay = getRecordingRelay(extensionId)
if (!relay) { if (!relay) {
return c.json({ success: false, error: 'Extension not connected' }, 500) return c.json({ success: false, error: 'Extension not connected' }, 500)
} }
const cancelParams: CancelRecordingParams = sessionId ? { sessionId } : {} const cancelParams: CancelRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
const result = await relay.cancelRecording(cancelParams) const result = await relay.cancelRecording(cancelParams)
return c.json(result) return c.json(result)
}) })
+1 -1
View File
@@ -954,7 +954,7 @@ export class PlaywrightExecutor {
) => { ) => {
return async (options: T = {} as T) => { return async (options: T = {} as T) => {
const targetPage = options.page || page const targetPage = options.page || page
// Use Playwright's exposed sessionId directly // Use Playwright's exposed tab session ID directly.
const sessionId = options.sessionId || targetPage.sessionId() || undefined const sessionId = options.sessionId || targetPage.sessionId() || undefined
return fn({ page: targetPage, sessionId, relayPort, ...options }) return fn({ page: targetPage, sessionId, relayPort, ...options })
} }
+4
View File
@@ -86,6 +86,7 @@ export type ExtensionMessage =
// Recording command messages (MCP -> Extension via relay) // Recording command messages (MCP -> Extension via relay)
export type StartRecordingParams = { export type StartRecordingParams = {
/** CDP tab session ID (pw-tab-*) to identify which tab to record. */
sessionId?: string sessionId?: string
frameRate?: number frameRate?: number
audio?: boolean audio?: boolean
@@ -99,14 +100,17 @@ export type StartRecordingBody = StartRecordingParams & {
} }
export type StopRecordingParams = { export type StopRecordingParams = {
/** CDP tab session ID (pw-tab-*) to identify which tab to stop recording. */
sessionId?: string sessionId?: string
} }
export type IsRecordingParams = { export type IsRecordingParams = {
/** CDP tab session ID (pw-tab-*) to identify which tab to check. */
sessionId?: string sessionId?: string
} }
export type CancelRecordingParams = { export type CancelRecordingParams = {
/** CDP tab session ID (pw-tab-*) to identify which tab to cancel. */
sessionId?: string sessionId?: string
} }
+19 -9
View File
@@ -3,13 +3,18 @@
* Recording happens in the extension context, so it survives page navigation. * Recording happens in the extension context, so it survives page navigation.
* *
* This module communicates with the relay server which forwards commands to the extension. * This module communicates with the relay server which forwards commands to the extension.
* SessionId (pw-tab-X format) is used to identify which tab to record. * sessionId (pw-tab-* format) is used to identify which tab to record.
*/ */
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import type { Page } from '@xmorse/playwright-core' import type { Page } from '@xmorse/playwright-core'
import type { StartRecordingResult, StopRecordingResult, IsRecordingResult, CancelRecordingResult } from './protocol.js' import type {
StartRecordingResult,
StopRecordingResult,
IsRecordingResult,
CancelRecordingResult,
} from './protocol.js'
import { EXTENSION_IDS } from './utils.js' import { EXTENSION_IDS } from './utils.js'
/** /**
@@ -49,7 +54,7 @@ function isActiveTabPermissionError(error: string): boolean {
export interface StartRecordingOptions { export interface StartRecordingOptions {
/** Target page to record */ /** Target page to record */
page: Page page: Page
/** Session ID (pw-tab-X format) to identify which tab to record */ /** CDP tab session ID (pw-tab-* format) to identify which tab to record */
sessionId?: string sessionId?: string
/** Frame rate (default: 30) */ /** Frame rate (default: 30) */
frameRate?: number frameRate?: number
@@ -68,7 +73,7 @@ export interface StartRecordingOptions {
export interface StopRecordingOptions { export interface StopRecordingOptions {
/** Target page that is being recorded */ /** Target page that is being recorded */
page: Page page: Page
/** Session ID (pw-tab-X format) to identify which tab to stop recording */ /** CDP tab session ID (pw-tab-* format) to identify which tab to stop recording */
sessionId?: string sessionId?: string
/** Relay server port (default: 19988) */ /** Relay server port (default: 19988) */
relayPort?: number relayPort?: number
@@ -173,10 +178,11 @@ export async function isRecording(options: {
}): Promise<RecordingState> { }): Promise<RecordingState> {
const { sessionId, relayPort = 19988 } = options const { sessionId, relayPort = 19988 } = options
const url = sessionId const url = new URL(`http://127.0.0.1:${relayPort}/recording/status`)
? `http://127.0.0.1:${relayPort}/recording/status?sessionId=${encodeURIComponent(sessionId)}` if (sessionId) {
: `http://127.0.0.1:${relayPort}/recording/status` url.searchParams.set('sessionId', sessionId)
const response = await fetch(url) }
const response = await fetch(url.toString())
const result = (await response.json()) as IsRecordingResult const result = (await response.json()) as IsRecordingResult
return { isRecording: result.isRecording, startedAt: result.startedAt, tabId: result.tabId } return { isRecording: result.isRecording, startedAt: result.startedAt, tabId: result.tabId }
@@ -185,7 +191,11 @@ export async function isRecording(options: {
/** /**
* Cancel recording without saving. * Cancel recording without saving.
*/ */
export async function cancelRecording(options: { page: Page; sessionId?: string; relayPort?: number }): Promise<void> { export async function cancelRecording(options: {
page: Page
sessionId?: string
relayPort?: number
}): Promise<void> {
const { sessionId, relayPort = 19988 } = options const { sessionId, relayPort = 19988 } = options
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/cancel`, { const response = await fetch(`http://127.0.0.1:${relayPort}/recording/cancel`, {