@@ -40,6 +40,7 @@ const createWebContents = () => {
4040 const history = [ "https://example.test/" , "https://example.test/next" ] ;
4141 let historyIndex = 0 ;
4242 const debuggerEvents = new EventEmitter ( ) ;
43+ const webContentsEvents = new EventEmitter ( ) ;
4344 const emitNavigation = ( nextUrl : string ) => {
4445 const loaderId = `loader-${ crypto . randomUUID ( ) } ` ;
4546 queueMicrotask ( ( ) => {
@@ -110,6 +111,9 @@ const createWebContents = () => {
110111 }
111112 if ( method === "Runtime.evaluate" ) {
112113 const expression = String ( params ?. expression ?? "" ) ;
114+ if ( expression === "globalThis.__synaraWebMcpBridgeV1" ) {
115+ return { result : { objectId : "webmcp-bridge" , type : "object" } } ;
116+ }
113117 if ( expression . includes ( "performance.getEntriesByType" ) ) return { result : { value : 0 } } ;
114118 if (
115119 expression . includes ( 'const key = "__synaraBrowserAutomationV1"' ) &&
@@ -172,6 +176,31 @@ const createWebContents = () => {
172176 if ( method === "Page.createIsolatedWorld" ) return { executionContextId : 12 } ;
173177 if ( method === "Runtime.callFunctionOn" ) {
174178 const declaration = String ( params ?. functionDeclaration ?? "" ) ;
179+ if ( declaration . includes ( "return await this.list()" ) ) {
180+ return {
181+ result : {
182+ value : {
183+ available : true ,
184+ implementation : "compatibility" ,
185+ skippedToolCount : 0 ,
186+ tools : [
187+ {
188+ index : 0 ,
189+ signature : "a" . repeat ( 64 ) ,
190+ name : "search" ,
191+ description : "Search this page." ,
192+ inputSchema : { type : "object" , properties : { } } ,
193+ origin : "https://example.test" ,
194+ annotations : { readOnlyHint : true , untrustedContentHint : true } ,
195+ } ,
196+ ] ,
197+ } ,
198+ } ,
199+ } ;
200+ }
201+ if ( declaration . includes ( "return await this.invoke" ) ) {
202+ return { result : { value : { status : "completed" , result : { ok : true } } } } ;
203+ }
175204 if ( declaration . includes ( "const timeoutMs =" ) && declaration . includes ( "receivesEvents" ) ) {
176205 const actionOptions = (
177206 params ?. arguments as
@@ -240,6 +269,8 @@ const createWebContents = () => {
240269 return { } ;
241270 } ) ;
242271 return {
272+ once : webContentsEvents . once . bind ( webContentsEvents ) ,
273+ removeListener : webContentsEvents . removeListener . bind ( webContentsEvents ) ,
243274 isDestroyed : ( ) => false ,
244275 id : 101 ,
245276 focus : vi . fn ( ) ,
@@ -353,6 +384,210 @@ const createManager = () => {
353384} ;
354385
355386describe ( "DesktopBrowserAutomationHost" , ( ) => {
387+ it ( "releases another session's WebMCP discovery before navigating the shared tab" , async ( ) => {
388+ const { manager, webContents } = createManager ( ) ;
389+ const host = new DesktopBrowserAutomationHost ( manager ) ;
390+
391+ await host . executeTool ( {
392+ sessionId : "session-webmcp-owner" ,
393+ provider : "codex" ,
394+ threadId : THREAD_ID ,
395+ name : "browser_webmcp_tools" ,
396+ arguments : { } ,
397+ } ) ;
398+ await host . executeTool ( {
399+ sessionId : "session-shared-navigation" ,
400+ provider : "claude" ,
401+ threadId : THREAD_ID ,
402+ name : "browser_navigate" ,
403+ arguments : { url : "https://example.test/next" } ,
404+ } ) ;
405+
406+ expect ( webContents . debugger . sendCommand ) . toHaveBeenCalledWith ( "Runtime.releaseObject" , {
407+ objectId : "webmcp-bridge" ,
408+ } ) ;
409+ } ) ;
410+
411+ it ( "invalidates WebMCP discovery when the page navigates outside a browser tool" , async ( ) => {
412+ const { manager, webContents } = createManager ( ) ;
413+ const host = new DesktopBrowserAutomationHost ( manager ) ;
414+ const discovery = ( await host . executeTool ( {
415+ sessionId : "session-webmcp-spontaneous-navigation" ,
416+ provider : "codex" ,
417+ threadId : THREAD_ID ,
418+ name : "browser_webmcp_tools" ,
419+ arguments : { } ,
420+ } ) ) as {
421+ readonly discoveryId : string ;
422+ readonly tools : readonly [ { readonly toolId : string } ] ;
423+ } ;
424+
425+ webContents . emitDebuggerMessage ( "Page.frameNavigated" , {
426+ frame : { id : "main-frame" , url : "https://example.test/delayed" } ,
427+ } ) ;
428+ await Promise . resolve ( ) ;
429+
430+ await expect (
431+ host . executeTool ( {
432+ sessionId : "session-webmcp-spontaneous-navigation" ,
433+ provider : "codex" ,
434+ threadId : THREAD_ID ,
435+ name : "browser_webmcp_call" ,
436+ arguments : {
437+ discoveryId : discovery . discoveryId ,
438+ toolId : discovery . tools [ 0 ] . toolId ,
439+ arguments : { } ,
440+ } ,
441+ } ) ,
442+ ) . rejects . toMatchObject ( { browserError : { code : "BrowserWebMcpDiscoveryStale" } } ) ;
443+ } ) ;
444+
445+ it ( "preserves the current WebMCP discovery after mismatched caller tokens" , async ( ) => {
446+ const { manager, webContents } = createManager ( ) ;
447+ const host = new DesktopBrowserAutomationHost ( manager ) ;
448+ const discovery = ( await host . executeTool ( {
449+ sessionId : "session-webmcp-mismatch" ,
450+ provider : "codex" ,
451+ threadId : THREAD_ID ,
452+ name : "browser_webmcp_tools" ,
453+ arguments : { } ,
454+ } ) ) as {
455+ readonly discoveryId : string ;
456+ readonly tools : readonly [ { readonly toolId : string } ] ;
457+ } ;
458+ const call = ( discoveryId : string , toolId : string ) =>
459+ host . executeTool ( {
460+ sessionId : "session-webmcp-mismatch" ,
461+ provider : "codex" ,
462+ threadId : THREAD_ID ,
463+ name : "browser_webmcp_call" ,
464+ arguments : { discoveryId, toolId, arguments : { } } ,
465+ } ) ;
466+
467+ await expect ( call ( crypto . randomUUID ( ) , discovery . tools [ 0 ] . toolId ) ) . rejects . toMatchObject ( {
468+ browserError : { code : "BrowserWebMcpDiscoveryStale" } ,
469+ } ) ;
470+ await expect ( call ( discovery . discoveryId , "w2" ) ) . rejects . toMatchObject ( {
471+ browserError : { code : "BrowserWebMcpDiscoveryStale" } ,
472+ } ) ;
473+ expect ( webContents . debugger . sendCommand ) . not . toHaveBeenCalledWith ( "Runtime.releaseObject" , {
474+ objectId : "webmcp-bridge" ,
475+ } ) ;
476+ await expect ( call ( discovery . discoveryId , discovery . tools [ 0 ] . toolId ) ) . resolves . toMatchObject ( {
477+ status : "completed" ,
478+ result : { ok : true } ,
479+ } ) ;
480+ } ) ;
481+
482+ it ( "preserves an ambiguous WebMCP invocation error after navigation starts" , async ( ) => {
483+ const { manager, webContents } = createManager ( ) ;
484+ const host = new DesktopBrowserAutomationHost ( manager ) ;
485+ const discovery = ( await host . executeTool ( {
486+ sessionId : "session-webmcp-ambiguous" ,
487+ provider : "codex" ,
488+ threadId : THREAD_ID ,
489+ name : "browser_webmcp_tools" ,
490+ arguments : { } ,
491+ } ) ) as {
492+ readonly discoveryId : string ;
493+ readonly tools : readonly [ { readonly toolId : string } ] ;
494+ } ;
495+ const sendCommand = webContents . debugger . sendCommand as ReturnType < typeof vi . fn > ;
496+ const original = sendCommand . getMockImplementation ( ) as SendCommand ;
497+ sendCommand . mockImplementation ( async ( method : string , params ?: Record < string , unknown > ) => {
498+ const declaration = String ( params ?. functionDeclaration ?? "" ) ;
499+ if ( method === "Runtime.callFunctionOn" && declaration . includes ( "return await this.invoke" ) ) {
500+ webContents . reload ( ) ;
501+ await Promise . resolve ( ) ;
502+ throw new Error ( "execution context was destroyed" ) ;
503+ }
504+ return original ( method , params ) ;
505+ } ) ;
506+
507+ await expect (
508+ host . executeTool ( {
509+ sessionId : "session-webmcp-ambiguous" ,
510+ provider : "codex" ,
511+ threadId : THREAD_ID ,
512+ name : "browser_webmcp_call" ,
513+ arguments : {
514+ discoveryId : discovery . discoveryId ,
515+ toolId : discovery . tools [ 0 ] . toolId ,
516+ arguments : { } ,
517+ } ,
518+ } ) ,
519+ ) . rejects . toMatchObject ( {
520+ browserError : {
521+ code : "BrowserAmbiguousResult" ,
522+ retryable : false ,
523+ effectMayHaveCommitted : true ,
524+ } ,
525+ } ) ;
526+ } ) ;
527+
528+ it ( "waits for draining CDP work before releasing WebMCP discoveries on disposal" , async ( ) => {
529+ const { manager, webContents } = createManager ( ) ;
530+ const host = new DesktopBrowserAutomationHost ( manager ) ;
531+ await host . executeTool ( {
532+ sessionId : "session-webmcp-dispose" ,
533+ provider : "codex" ,
534+ threadId : THREAD_ID ,
535+ name : "browser_webmcp_tools" ,
536+ arguments : { } ,
537+ } ) ;
538+
539+ const sendCommand = webContents . debugger . sendCommand as ReturnType < typeof vi . fn > ;
540+ const original = sendCommand . getMockImplementation ( ) as SendCommand ;
541+ const evaluation = deferred < unknown > ( ) ;
542+ sendCommand . mockImplementation ( ( method : string , params ?: Record < string , unknown > ) => {
543+ if ( method === "Runtime.evaluate" && params ?. expression === "({answer: 42})" ) {
544+ return evaluation . promise ;
545+ }
546+ return original ( method , params ) ;
547+ } ) ;
548+ const controller = new AbortController ( ) ;
549+ const operation = host . executeTool ( {
550+ sessionId : "session-webmcp-dispose" ,
551+ provider : "codex" ,
552+ threadId : THREAD_ID ,
553+ name : "browser_evaluate" ,
554+ arguments : { expression : "({answer: 42})" } ,
555+ signal : controller . signal ,
556+ } ) ;
557+ await vi . waitFor ( ( ) =>
558+ expect ( sendCommand ) . toHaveBeenCalledWith (
559+ "Runtime.evaluate" ,
560+ expect . objectContaining ( { expression : "({answer: 42})" } ) ,
561+ ) ,
562+ ) ;
563+ controller . abort ( ) ;
564+ await expect ( operation ) . rejects . toMatchObject ( {
565+ browserError : { code : "BrowserCancelled" } ,
566+ } ) ;
567+
568+ const disposed = host . dispose ( ) ;
569+ await Promise . resolve ( ) ;
570+ expect ( disposed ) . not . toBe ( undefined ) ;
571+ expect ( sendCommand ) . not . toHaveBeenCalledWith ( "Runtime.releaseObject" , {
572+ objectId : "webmcp-bridge" ,
573+ } ) ;
574+
575+ evaluation . resolve ( { result : { value : { answer : 42 } } } ) ;
576+ await disposed ;
577+ expect ( sendCommand ) . toHaveBeenCalledWith ( "Runtime.releaseObject" , {
578+ objectId : "webmcp-bridge" ,
579+ } ) ;
580+ await expect (
581+ host . executeTool ( {
582+ sessionId : "session-after-dispose" ,
583+ provider : "codex" ,
584+ threadId : THREAD_ID ,
585+ name : "browser_status" ,
586+ arguments : { } ,
587+ } ) ,
588+ ) . rejects . toMatchObject ( { browserError : { code : "BrowserHostUnavailable" } } ) ;
589+ } ) ;
590+
356591 it ( "blocks new DOM tools while a human annotation picker is interactive" , async ( ) => {
357592 const { manager, raw } = createManager ( ) ;
358593 raw . isAnnotationInteractive . mockReturnValue ( true ) ;
0 commit comments