1+ import { BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS } from '@sim/browser-protocol'
12import type { MenuItemConstructorOptions } from 'electron'
23import { beforeEach , describe , expect , it , vi } from 'vitest'
34
@@ -33,6 +34,29 @@ function freshDriver(): DriverModule {
3334 return driverModule
3435}
3536
37+ type BrowserToolQueueBoundary = NonNullable <
38+ ReturnType < DriverModule [ 'captureBrowserToolQueueBoundary' ] >
39+ >
40+
41+ function capturePendingAuthorizations (
42+ driver : DriverModule ,
43+ scopeId : string ,
44+ count : number = driver . BROWSER_TOOL_ADMISSION_LIMITS . perScope
45+ ) : BrowserToolQueueBoundary [ ] {
46+ const boundaries = Array . from ( { length : count } , ( ) =>
47+ driver . captureBrowserToolQueueBoundary ( scopeId )
48+ )
49+ expect ( boundaries . every ( ( boundary ) => boundary !== null ) ) . toBe ( true )
50+ return boundaries . filter ( ( boundary ) : boundary is BrowserToolQueueBoundary => boundary !== null )
51+ }
52+
53+ function releasePendingAuthorizations (
54+ driver : DriverModule ,
55+ boundaries : readonly BrowserToolQueueBoundary [ ]
56+ ) : void {
57+ for ( const boundary of boundaries ) driver . releaseBrowserToolQueueBoundary ( boundary )
58+ }
59+
3660/** Match the serialized function invocation, not comments or helper names in its body. */
3761function isPageCall ( expression : string , fnName : string ) : boolean {
3862 return expression . includes ( `function ${ fnName } (` )
@@ -53,13 +77,15 @@ describe('executeTool', () => {
5377 } )
5478
5579 it ( 'validates navigation URLs before touching the session' , async ( ) => {
80+ const grant = vi . spyOn ( session , 'grantSiteOriginForAgentNavigation' )
5681 const result = await driver . executeTool ( 'chat-test' , 'browser_navigate' , {
5782 url : 'file:///etc/passwd' ,
5883 } )
5984 expect ( result ) . toEqual ( {
6085 ok : false ,
6186 error : 'URL must be absolute and start with http:// or https://' ,
6287 } )
88+ expect ( grant ) . not . toHaveBeenCalled ( )
6389 } )
6490
6591 it ( 'reports missing required parameters by name' , async ( ) => {
@@ -68,6 +94,23 @@ describe('executeTool', () => {
6894 expect ( result . error ) . toMatch ( / M i s s i n g r e q u i r e d p a r a m e t e r " u r l " / )
6995 } )
7096
97+ it ( 'grants only SSRF-checked agent navigation destinations before loading them' , async ( ) => {
98+ const grant = vi . spyOn ( session , 'grantSiteOriginForAgentNavigation' )
99+ const navigations = [
100+ [ 'browser_navigate' , 'http://127.0.0.1:4011/navigate' ] ,
101+ [ 'browser_open_url' , 'http://127.0.0.1:4012/open' ] ,
102+ [ 'browser_open_tab' , 'http://127.0.0.1:4013/tab' ] ,
103+ ] as const
104+
105+ for ( const [ tool , url ] of navigations ) {
106+ await expect ( driver . executeTool ( 'chat-test' , tool , { url } ) ) . resolves . toMatchObject ( {
107+ ok : true ,
108+ } )
109+ expect ( grant ) . toHaveBeenCalledWith ( expect . anything ( ) , url )
110+ }
111+ expect ( grant ) . toHaveBeenCalledTimes ( navigations . length )
112+ } )
113+
71114 it ( 'reports an aborted navigation when Chromium never leaves the current URL' , async ( ) => {
72115 vi . useFakeTimers ( )
73116 try {
@@ -578,17 +621,80 @@ describe('executeTool', () => {
578621 } )
579622
580623 it ( 'bounds pending authorizations without materializing their scopes' , ( ) => {
581- const boundaries = Array . from ( { length : driver . BROWSER_TOOL_ADMISSION_LIMITS . perScope } , ( ) =>
582- driver . captureBrowserToolQueueBoundary ( 'chat-pending-authorization' )
583- )
624+ const boundaries = capturePendingAuthorizations ( driver , 'chat-pending-authorization' )
584625
585626 expect ( boundaries . every ( ( boundary ) => boundary ?. generation === null ) ) . toBe ( true )
586627 expect ( driver . captureBrowserToolQueueBoundary ( 'chat-pending-authorization' ) ) . toBeNull ( )
587628
588- for ( const boundary of boundaries ) {
589- if ( boundary ) driver . releaseBrowserToolQueueBoundary ( boundary )
590- }
591- expect ( driver . captureBrowserToolQueueBoundary ( 'chat-pending-authorization' ) ) . not . toBeNull ( )
629+ releasePendingAuthorizations ( driver , boundaries )
630+ const replacement = driver . captureBrowserToolQueueBoundary ( 'chat-pending-authorization' )
631+ expect ( replacement ) . not . toBeNull ( )
632+ if ( replacement ) driver . releaseBrowserToolQueueBoundary ( replacement )
633+ } )
634+
635+ it ( 'retains cancelled authorization admissions until their fetches settle' , ( ) => {
636+ const boundaries = capturePendingAuthorizations ( driver , 'chat-test' )
637+
638+ expect ( driver . cancelActiveTool ( 'chat-test' ) ) . toBe ( true )
639+ expect ( boundaries . every ( ( boundary ) => boundary . cancelled ) ) . toBe ( true )
640+ expect ( driver . captureBrowserToolQueueBoundary ( 'chat-test' ) ) . toBeNull ( )
641+
642+ releasePendingAuthorizations ( driver , boundaries )
643+ const replacement = driver . captureBrowserToolQueueBoundary ( 'chat-test' )
644+ expect ( replacement ) . not . toBeNull ( )
645+ if ( replacement ) driver . releaseBrowserToolQueueBoundary ( replacement )
646+ } )
647+
648+ it ( 'retains disposed-scope authorization admissions until their fetches settle' , ( ) => {
649+ const boundaries = capturePendingAuthorizations ( driver , 'chat-disposed-authorizations' )
650+
651+ driver . disposeBrowserScope ( 'chat-disposed-authorizations' )
652+ expect ( boundaries . every ( ( boundary ) => boundary . cancelled ) ) . toBe ( true )
653+ expect ( driver . captureBrowserToolQueueBoundary ( 'chat-disposed-authorizations' ) ) . toBeNull ( )
654+
655+ releasePendingAuthorizations ( driver , boundaries )
656+ const replacement = driver . captureBrowserToolQueueBoundary ( 'chat-disposed-authorizations' )
657+ expect ( replacement ) . not . toBeNull ( )
658+ if ( replacement ) driver . releaseBrowserToolQueueBoundary ( replacement )
659+ } )
660+
661+ it ( 'retains suspended-scope authorization admissions until their fetches settle' , ( ) => {
662+ const boundaries = capturePendingAuthorizations ( driver , 'chat-suspended-authorizations' )
663+
664+ expect ( driver . suspendBrowserScope ( 'chat-suspended-authorizations' ) ) . toBe ( true )
665+ expect ( boundaries . every ( ( boundary ) => boundary . cancelled ) ) . toBe ( true )
666+ expect ( driver . captureBrowserToolQueueBoundary ( 'chat-suspended-authorizations' ) ) . toBeNull ( )
667+
668+ releasePendingAuthorizations ( driver , boundaries )
669+ const replacement = driver . captureBrowserToolQueueBoundary ( 'chat-suspended-authorizations' )
670+ expect ( replacement ) . not . toBeNull ( )
671+ if ( replacement ) driver . releaseBrowserToolQueueBoundary ( replacement )
672+ } )
673+
674+ it ( 'retains process-wide authorization admissions across driver reinitialization' , ( ) => {
675+ const boundaries = [ 'chat-auth-a' , 'chat-auth-b' , 'chat-auth-c' , 'chat-auth-d' ] . flatMap (
676+ ( scopeId ) => capturePendingAuthorizations ( driver , scopeId )
677+ )
678+ expect ( boundaries ) . toHaveLength ( driver . BROWSER_TOOL_ADMISSION_LIMITS . process )
679+
680+ driver . initDriver (
681+ {
682+ onPageState : vi . fn ( ) ,
683+ onTabsState : vi . fn ( ) ,
684+ onSessionStatus : vi . fn ( ) ,
685+ onFillAvailability : vi . fn ( ) ,
686+ } ,
687+ ( ) => null
688+ )
689+
690+ expect ( boundaries . every ( ( boundary ) => boundary . cancelled ) ) . toBe ( true )
691+ expect ( driver . captureBrowserToolQueueBoundary ( 'chat-after-reinit' ) ) . toBeNull ( )
692+
693+ driver . releaseBrowserToolQueueBoundary ( boundaries [ 0 ] )
694+ const replacement = driver . captureBrowserToolQueueBoundary ( 'chat-after-reinit' )
695+ expect ( replacement ) . not . toBeNull ( )
696+ if ( replacement ) driver . releaseBrowserToolQueueBoundary ( replacement )
697+ releasePendingAuthorizations ( driver , boundaries . slice ( 1 ) )
592698 } )
593699
594700 it ( 'honors cancellation that arrives before the authorized tool invocation' , async ( ) => {
@@ -1066,6 +1172,104 @@ describe('executeTool', () => {
10661172 expect ( driver . migrateBrowserScope ( 'pending:other-chat' , 'chat-occupied' ) ) . toBe ( false )
10671173 } )
10681174
1175+ it ( 'cancels only the replaced destination authorizations during migration' , async ( ) => {
1176+ await driver . executeTool ( 'pending:new-chat' , 'browser_open_tab' , { } )
1177+ driver . activateBrowserScope ( 'chat-real' )
1178+ const sourceBoundary = driver . captureBrowserToolQueueBoundary ( 'pending:new-chat' )
1179+ const destinationBoundary = driver . captureBrowserToolQueueBoundary ( 'chat-real' )
1180+ const otherBoundary = driver . captureBrowserToolQueueBoundary ( 'chat-other' )
1181+ expect ( sourceBoundary ) . not . toBeNull ( )
1182+ expect ( destinationBoundary ) . not . toBeNull ( )
1183+ expect ( otherBoundary ) . not . toBeNull ( )
1184+ if ( ! sourceBoundary || ! destinationBoundary || ! otherBoundary ) {
1185+ throw new Error ( 'Expected browser tool authorization admissions' )
1186+ }
1187+
1188+ expect ( driver . migrateBrowserScope ( 'pending:new-chat' , 'chat-real' ) ) . toBe ( true )
1189+
1190+ await expect (
1191+ driver . executeTool (
1192+ 'chat-real' ,
1193+ 'browser_list_tabs' ,
1194+ { } ,
1195+ 'tool-destination-before-migration' ,
1196+ destinationBoundary
1197+ )
1198+ ) . resolves . toMatchObject ( {
1199+ ok : false ,
1200+ error : expect . stringContaining ( 'cancelled before it started' ) ,
1201+ } )
1202+ await expect (
1203+ driver . executeTool (
1204+ 'chat-real' ,
1205+ 'browser_list_tabs' ,
1206+ { } ,
1207+ 'tool-source-before-migration' ,
1208+ sourceBoundary
1209+ )
1210+ ) . resolves . toMatchObject ( { ok : true } )
1211+ await expect (
1212+ driver . executeTool (
1213+ 'chat-other' ,
1214+ 'browser_list_tabs' ,
1215+ { } ,
1216+ 'tool-other-during-migration' ,
1217+ otherBoundary
1218+ )
1219+ ) . resolves . toMatchObject ( { ok : true } )
1220+ } )
1221+
1222+ it ( 'retains replaced destination admissions until their authorization fetches settle' , async ( ) => {
1223+ await driver . executeTool ( 'pending:new-chat' , 'browser_open_tab' , { } )
1224+ driver . activateBrowserScope ( 'chat-real' )
1225+ const sourceBoundary = driver . captureBrowserToolQueueBoundary ( 'pending:new-chat' )
1226+ const destinationBoundaries = capturePendingAuthorizations (
1227+ driver ,
1228+ 'chat-real' ,
1229+ driver . BROWSER_TOOL_ADMISSION_LIMITS . perScope - 1
1230+ )
1231+ expect ( sourceBoundary ) . not . toBeNull ( )
1232+ if ( ! sourceBoundary ) throw new Error ( 'Expected source authorization admission' )
1233+
1234+ expect ( driver . migrateBrowserScope ( 'pending:new-chat' , 'chat-real' ) ) . toBe ( true )
1235+
1236+ expect ( destinationBoundaries . every ( ( boundary ) => boundary . cancelled ) ) . toBe ( true )
1237+ expect ( sourceBoundary . cancelled ) . toBe ( false )
1238+ expect ( driver . captureBrowserToolQueueBoundary ( 'chat-real' ) ) . toBeNull ( )
1239+
1240+ releasePendingAuthorizations ( driver , destinationBoundaries )
1241+ await expect (
1242+ driver . executeTool (
1243+ 'chat-real' ,
1244+ 'browser_list_tabs' ,
1245+ { } ,
1246+ 'tool-source-after-destination-settlement' ,
1247+ sourceBoundary
1248+ )
1249+ ) . resolves . toMatchObject ( { ok : true } )
1250+ const replacement = driver . captureBrowserToolQueueBoundary ( 'chat-real' )
1251+ expect ( replacement ) . not . toBeNull ( )
1252+ if ( replacement ) driver . releaseBrowserToolQueueBoundary ( replacement )
1253+ } )
1254+
1255+ it ( 'keeps migrated source admissions charged to the durable scope after disposal' , ( ) => {
1256+ driver . activateBrowserScope ( 'pending:new-chat' )
1257+ const sourceBoundaries = capturePendingAuthorizations ( driver , 'pending:new-chat' )
1258+
1259+ expect ( driver . migrateBrowserScope ( 'pending:new-chat' , 'chat-real' ) ) . toBe ( true )
1260+ expect ( sourceBoundaries . every ( ( boundary ) => boundary . scopeId === 'chat-real' ) ) . toBe ( true )
1261+
1262+ driver . disposeBrowserScope ( 'chat-real' )
1263+ driver . activateBrowserScope ( 'chat-real' )
1264+ expect ( sourceBoundaries . every ( ( boundary ) => boundary . cancelled ) ) . toBe ( true )
1265+ expect ( driver . captureBrowserToolQueueBoundary ( 'chat-real' ) ) . toBeNull ( )
1266+
1267+ releasePendingAuthorizations ( driver , sourceBoundaries )
1268+ const replacement = driver . captureBrowserToolQueueBoundary ( 'chat-real' )
1269+ expect ( replacement ) . not . toBeNull ( )
1270+ if ( replacement ) driver . releaseBrowserToolQueueBoundary ( replacement )
1271+ } )
1272+
10691273 it ( 'retains a migrated provisional alias for callbacks until durable disposal' , async ( ) => {
10701274 await driver . executeTool ( 'pending:new-chat' , 'browser_open_tab' , { } )
10711275 const tab = session . withBrowserScope ( 'pending:new-chat' , ( ) => session . requireTab ( ) )
@@ -1215,6 +1419,41 @@ describe('executeTool', () => {
12151419 }
12161420 } )
12171421
1422+ it ( 'expires a bounded queue wait without running the stale action later' , async ( ) => {
1423+ await driver . executeTool ( 'chat-test' , 'browser_open_tab' , { } )
1424+ const contents = session . requireTab ( ) . view . webContents
1425+ vi . mocked ( contents . loadURL ) . mockClear ( )
1426+ vi . useFakeTimers ( )
1427+ try {
1428+ const waiting = driver . executeTool (
1429+ 'chat-test' ,
1430+ 'browser_wait_for' ,
1431+ { timeoutMs : 120_000 } ,
1432+ 'tool-queue-head'
1433+ )
1434+ await vi . advanceTimersByTimeAsync ( 0 )
1435+ const queued = driver . executeTool (
1436+ 'chat-test' ,
1437+ 'browser_navigate' ,
1438+ { url : 'http://127.0.0.1/expired' } ,
1439+ 'tool-queue-expired'
1440+ )
1441+
1442+ await vi . advanceTimersByTimeAsync ( BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS )
1443+ await expect ( queued ) . resolves . toMatchObject ( {
1444+ ok : false ,
1445+ error : expect . stringContaining ( 'waited too long for earlier browser work' ) ,
1446+ } )
1447+
1448+ expect ( driver . cancelTool ( 'chat-test' , 'tool-queue-head' ) ) . toBe ( true )
1449+ await vi . advanceTimersByTimeAsync ( 0 )
1450+ await expect ( waiting ) . resolves . toMatchObject ( { ok : false } )
1451+ expect ( contents . loadURL ) . not . toHaveBeenCalledWith ( 'http://127.0.0.1/expired' )
1452+ } finally {
1453+ vi . useRealTimers ( )
1454+ }
1455+ } )
1456+
12181457 it ( 'bounds one scope queue and admits new work after the held head is cancelled' , async ( ) => {
12191458 vi . useFakeTimers ( )
12201459 try {
0 commit comments