From 95ff2a797dd018b6885f94e1344b5fdd3058a4d3 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Wed, 25 Feb 2026 16:52:41 +0100 Subject: [PATCH] fix: scope CDP tab sessions and simplify recording sessionId routing --- extension/CHANGELOG.md | 6 +++ extension/manifest.json | 2 +- extension/package.json | 2 +- extension/src/background.ts | 11 +++++- playwriter/CHANGELOG.md | 7 ++++ playwriter/package.json | 2 +- playwriter/src/cdp-relay.ts | 63 ++++++++++++------------------ playwriter/src/executor.ts | 2 +- playwriter/src/protocol.ts | 4 ++ playwriter/src/screen-recording.ts | 28 ++++++++----- 10 files changed, 74 insertions(+), 53 deletions(-) diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index 3f427f3..4c34ab9 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.0.72 + +### Bug Fixes + +- **Use runtime-scoped root CDP tab session IDs**: Root tab sessions now use `pw-tab--` instead of `pw-tab-`, where scope is a random value generated once per extension runtime. This prevents session ID collisions across multiple connected Chrome profiles. + ## 0.0.71 ### Bug Fixes diff --git a/extension/manifest.json b/extension/manifest.json index 5dd81f0..da15201 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "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.", "permissions": [ "debugger", diff --git a/extension/package.json b/extension/package.json index 7770c3f..108c22d 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "mcp-extension", - "version": "0.0.71", + "version": "0.0.72", "description": "Playwright MCP Browser Extension", "private": true, "repository": { diff --git a/extension/src/background.ts b/extension/src/background.ts index db75a20..b1bfac2 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -66,6 +66,15 @@ async function detectBrowserName(): Promise { } let identityPromise: Promise | 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 { if (identityPromise) { @@ -1070,7 +1079,7 @@ async function attachTab( } const attachOrder = nextSessionId - const sessionId = `pw-tab-${nextSessionId++}` + const sessionId = `pw-tab-${tabSessionScope}-${nextSessionId++}` store.setState((state) => { const newTabs = new Map(state.tabs) diff --git a/playwriter/CHANGELOG.md b/playwriter/CHANGELOG.md index fb8999f..9be2032 100644 --- a/playwriter/CHANGELOG.md +++ b/playwriter/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.0.69 + +### Bug Fixes + +- **Scope CDP tab session IDs by extension runtime**: Switched root tab IDs to `pw-tab--` 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 ### Tests diff --git a/playwriter/package.json b/playwriter/package.json index 05c03d0..31f588d 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -1,7 +1,7 @@ { "name": "playwriter", "description": "", - "version": "0.0.68", + "version": "0.0.69", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index d4befcd..f070d6a 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -429,9 +429,9 @@ export async function startPlayWriterCDPRelayServer({ const recordingRelays = new Map() - // Find which extension connection owns a CDP session ID (pw-tab-*). - // Used by recording routes where sessionId is a CDP tab session, not - // an executor session. Searches all connected extensions' targets. + // Find which extension connection owns a CDP tab session ID (pw-tab-*). + // Used by recording routes where sessionId identifies the target tab. + // Searches all connected extensions' targets. const findExtensionIdByCdpSession = (cdpSessionId: string): string | null => { for (const [connectionId, connection] of extensionConnections.entries()) { if (connection.connectedTargets.has(cdpSessionId)) { @@ -441,29 +441,21 @@ export async function startPlayWriterCDPRelayServer({ return null } - // Resolve extensionId from a recording route's sessionId parameter. - // sessionId can be either: - // - CDP session ID (pw-tab-*) — look up which extension owns that tab - // - Executor session ID (numeric) — look up which extension the executor is bound to - // - null — fall back to default extension - const resolveRecordingExtensionId = async (sessionId: string | null): Promise<{ + // Resolve recording route session ID (CDP tab session) to extension connection. + const resolveRecordingRoute = async ({ + sessionId, + }: { + sessionId: string | null + }): Promise<{ extensionId: string | null - error?: string + sessionId: string | null }> => { if (!sessionId) { - return { extensionId: null } + return { extensionId: null, sessionId: null } } - if (sessionId.startsWith('pw-tab-')) { - const extensionId = findExtensionIdByCdpSession(sessionId) - return { extensionId } - } - // 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 extensionId = findExtensionIdByCdpSession(sessionId) + return { extensionId, sessionId } } const getRecordingRelay = (extensionId?: string | null): RecordingRelay | null => { @@ -1766,15 +1758,14 @@ export async function startPlayWriterCDPRelayServer({ } const sessionId = normalizeSessionId(body.sessionId) const { sessionId: _sessionId, ...recordingOptions } = body - const { extensionId, error } = await resolveRecordingExtensionId(sessionId) - if (error) { - return c.json({ success: false, error }, 404) - } + const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId }) const relay = getRecordingRelay(extensionId) if (!relay) { 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 status = result.success ? 200 : result.error?.includes('required') ? 400 : 500 return c.json(result, status) @@ -1783,15 +1774,12 @@ export async function startPlayWriterCDPRelayServer({ app.post('/recording/stop', async (c) => { const body = (await c.req.json()) as { sessionId?: string | number } const sessionId = normalizeSessionId(body.sessionId) - const { extensionId, error } = await resolveRecordingExtensionId(sessionId) - if (error) { - return c.json({ success: false, error }, 404) - } + const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId }) const relay = getRecordingRelay(extensionId) if (!relay) { 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 status = result.success ? 200 : result.error?.includes('not found') ? 404 : 500 return c.json(result, status) @@ -1799,12 +1787,12 @@ export async function startPlayWriterCDPRelayServer({ app.get('/recording/status', async (c) => { const sessionId = normalizeSessionId(c.req.query('sessionId')) - const { extensionId } = await resolveRecordingExtensionId(sessionId) + const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId }) const relay = getRecordingRelay(extensionId) if (!relay) { return c.json({ isRecording: false }) } - const isRecordingParams: IsRecordingParams = sessionId ? { sessionId } : {} + const isRecordingParams: IsRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {} const result = await relay.isRecording(isRecordingParams) return c.json(result) }) @@ -1812,15 +1800,12 @@ export async function startPlayWriterCDPRelayServer({ app.post('/recording/cancel', async (c) => { const body = (await c.req.json()) as { sessionId?: string | number } const sessionId = normalizeSessionId(body.sessionId) - const { extensionId, error } = await resolveRecordingExtensionId(sessionId) - if (error) { - return c.json({ success: false, error }, 404) - } + const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId }) const relay = getRecordingRelay(extensionId) if (!relay) { 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) return c.json(result) }) diff --git a/playwriter/src/executor.ts b/playwriter/src/executor.ts index 8d79c7f..ce0054a 100644 --- a/playwriter/src/executor.ts +++ b/playwriter/src/executor.ts @@ -954,7 +954,7 @@ export class PlaywrightExecutor { ) => { return async (options: T = {} as T) => { 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 return fn({ page: targetPage, sessionId, relayPort, ...options }) } diff --git a/playwriter/src/protocol.ts b/playwriter/src/protocol.ts index 3b2bf54..0551659 100644 --- a/playwriter/src/protocol.ts +++ b/playwriter/src/protocol.ts @@ -86,6 +86,7 @@ export type ExtensionMessage = // Recording command messages (MCP -> Extension via relay) export type StartRecordingParams = { + /** CDP tab session ID (pw-tab-*) to identify which tab to record. */ sessionId?: string frameRate?: number audio?: boolean @@ -99,14 +100,17 @@ export type StartRecordingBody = StartRecordingParams & { } export type StopRecordingParams = { + /** CDP tab session ID (pw-tab-*) to identify which tab to stop recording. */ sessionId?: string } export type IsRecordingParams = { + /** CDP tab session ID (pw-tab-*) to identify which tab to check. */ sessionId?: string } export type CancelRecordingParams = { + /** CDP tab session ID (pw-tab-*) to identify which tab to cancel. */ sessionId?: string } diff --git a/playwriter/src/screen-recording.ts b/playwriter/src/screen-recording.ts index 90e42de..e5f35c0 100644 --- a/playwriter/src/screen-recording.ts +++ b/playwriter/src/screen-recording.ts @@ -3,13 +3,18 @@ * Recording happens in the extension context, so it survives page navigation. * * 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 path from 'node:path' 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' /** @@ -49,7 +54,7 @@ function isActiveTabPermissionError(error: string): boolean { export interface StartRecordingOptions { /** Target page to record */ 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 /** Frame rate (default: 30) */ frameRate?: number @@ -68,7 +73,7 @@ export interface StartRecordingOptions { export interface StopRecordingOptions { /** Target page that is being recorded */ 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 /** Relay server port (default: 19988) */ relayPort?: number @@ -173,10 +178,11 @@ export async function isRecording(options: { }): Promise { const { sessionId, relayPort = 19988 } = options - const url = sessionId - ? `http://127.0.0.1:${relayPort}/recording/status?sessionId=${encodeURIComponent(sessionId)}` - : `http://127.0.0.1:${relayPort}/recording/status` - const response = await fetch(url) + const url = new URL(`http://127.0.0.1:${relayPort}/recording/status`) + if (sessionId) { + url.searchParams.set('sessionId', sessionId) + } + const response = await fetch(url.toString()) const result = (await response.json()) as IsRecordingResult return { isRecording: result.isRecording, startedAt: result.startedAt, tabId: result.tabId } @@ -185,7 +191,11 @@ export async function isRecording(options: { /** * Cancel recording without saving. */ -export async function cancelRecording(options: { page: Page; sessionId?: string; relayPort?: number }): Promise { +export async function cancelRecording(options: { + page: Page + sessionId?: string + relayPort?: number +}): Promise { const { sessionId, relayPort = 19988 } = options const response = await fetch(`http://127.0.0.1:${relayPort}/recording/cancel`, {