1- import { readFileSync } from 'node:fs'
1+ import { EventEmitter } from 'node:events'
2+ import { mkdtempSync , readFileSync , rmSync } from 'node:fs'
3+ import { tmpdir } from 'node:os'
4+ import { join } from 'node:path'
25import { fileURLToPath } from 'node:url'
36import { PASTE_LIMITS } from '@sim/utils/paste'
47import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
@@ -113,7 +116,8 @@ vi.mock('@/main/browser-agent/registry', () => ({
113116 ) ,
114117} ) )
115118
116- import type { DesktopPreferences } from '@sim/desktop-bridge'
119+ import type { ComputerUseAppPermission , DesktopPreferences } from '@sim/desktop-bridge'
120+ import type { ComputerUseResult } from '@sim/desktop-bridge/computer-use'
117121import type { WebContents } from 'electron'
118122import { clipboard , ipcMain , shell } from 'electron'
119123import * as browserDriver from '@/main/browser-agent/driver'
@@ -132,6 +136,8 @@ import {
132136 listChromeImportProfiles ,
133137} from '@/main/browser-import'
134138import { getSearchSuggestions } from '@/main/browser-search/suggestions'
139+ import { ComputerUseService } from '@/main/computer-use/service'
140+ import { createConfigStore } from '@/main/config'
135141import { trackInputActivity } from '@/main/input-activity'
136142import { type IpcDeps , openMicrophoneSettings , registerIpcHandlers } from '@/main/ipc'
137143import { LocalFilesystemService } from '@/main/local-filesystem'
@@ -260,6 +266,7 @@ const activeChooserEvent = {
260266
261267describe ( 'registerIpcHandlers' , ( ) => {
262268 let deps : IpcDeps
269+ const computerRoots : string [ ] = [ ]
263270
264271 beforeEach ( ( ) => {
265272 // Frozen so the input-recency windows cannot lapse mid-test: the gates read
@@ -346,6 +353,7 @@ describe('registerIpcHandlers', () => {
346353
347354 afterEach ( ( ) => {
348355 vi . useRealTimers ( )
356+ for ( const root of computerRoots . splice ( 0 ) ) rmSync ( root , { recursive : true , force : true } )
349357 } )
350358
351359 it ( 'opens validated external URLs only after recent user input' , async ( ) => {
@@ -797,6 +805,200 @@ describe('registerIpcHandlers', () => {
797805 expect ( deps . beginOAuthConnect ) . not . toHaveBeenCalled ( )
798806 } )
799807
808+ function computerFixture ( ) {
809+ const root = mkdtempSync ( join ( tmpdir ( ) , 'computer-ipc-' ) )
810+ computerRoots . push ( root )
811+ const config = createConfigStore ( join ( root , 'settings.json' ) )
812+ config . set ( 'computerUseEnabled' , true )
813+ const status : ComputerUseResult = {
814+ kind : 'status' ,
815+ platform : 'darwin' ,
816+ accessibility : true ,
817+ screenRecording : true ,
818+ }
819+ const native = {
820+ request : vi . fn (
821+ async ( method : string ) : Promise < ComputerUseResult > =>
822+ method === 'list_apps' ? { kind : 'apps' , apps : [ ] } : status
823+ ) ,
824+ stop : vi . fn ( ) ,
825+ }
826+ const approveApp = vi . fn (
827+ async ( _app : ComputerUseAppPermission , _signal : AbortSignal ) : Promise < 'once' | 'deny' > =>
828+ 'once'
829+ )
830+ const service = new ComputerUseService ( {
831+ config,
832+ native,
833+ supported : true ,
834+ approveApp,
835+ onActivity : vi . fn ( ) ,
836+ } )
837+ deps . computerUse = service
838+ const cancel = vi . spyOn ( service , 'cancel' )
839+ const execute = collectHandlers ( ) . invoke . get ( 'computer-use:execute-tool' ) !
840+ return { native, approveApp, status, cancel, execute }
841+ }
842+
843+ function computerSender ( fetch : ( url : string , init ?: RequestInit ) => Promise < Response > ) {
844+ const sender = Object . assign ( new EventEmitter ( ) , {
845+ session : { fetch } ,
846+ isDestroyed : ( ) => false ,
847+ } )
848+ return { sender, senderFrame : { url : `${ APP } /workspace/ws1` } }
849+ }
850+
851+ function computerAuthorization ( args : Record < string , unknown > = { action : 'status' } ) {
852+ return Response . json ( { chatId : 'computer-chat' , toolName : 'computer' , args } )
853+ }
854+
855+ function expectComputerListenersRemoved ( sender : EventEmitter ) {
856+ for ( const event of [ 'destroyed' , 'render-process-gone' , 'did-start-navigation' ] )
857+ expect ( sender . listenerCount ( event ) ) . toBe ( 0 )
858+ }
859+
860+ it . each ( [ 'destroyed' , 'render-process-gone' , 'did-start-navigation' ] ) (
861+ 'cancels authorization pending on owning renderer %s' ,
862+ async ( eventName ) => {
863+ const { native, cancel, execute } = computerFixture ( )
864+ let finishAuthorization : ( response : Response ) => void = ( ) => { }
865+ const owner = computerSender (
866+ vi . fn (
867+ ( ) =>
868+ new Promise < Response > ( ( resolve ) => {
869+ finishAuthorization = resolve
870+ } )
871+ )
872+ )
873+ const pending = execute ( owner , 'owner-tool' , { action : 'status' } )
874+ const rejected = expect ( pending ) . rejects . toThrow ( 'stopped' )
875+ owner . sender . emit ( eventName , { } , `${ APP } /reload` , false , true )
876+ owner . sender . emit ( 'destroyed' )
877+ expect ( cancel ) . toHaveBeenCalledExactlyOnceWith ( 'owner-tool' )
878+ finishAuthorization ( computerAuthorization ( ) )
879+ await rejected
880+ expect ( native . request ) . not . toHaveBeenCalled ( )
881+ expectComputerListenersRemoved ( owner . sender )
882+ }
883+ )
884+
885+ it . each ( [ 'destroyed' , 'render-process-gone' , 'did-start-navigation' ] ) (
886+ 'stops native work on owning renderer %s' ,
887+ async ( eventName ) => {
888+ const { native, cancel, execute } = computerFixture ( )
889+ let rejectNative : ( error : Error ) => void = ( ) => { }
890+ native . request . mockImplementation (
891+ ( ) =>
892+ new Promise < ComputerUseResult > ( ( _resolve , reject ) => {
893+ rejectNative = reject
894+ } )
895+ )
896+ native . stop . mockImplementation ( ( ) => rejectNative ( new Error ( 'native stopped' ) ) )
897+ const owner = computerSender ( vi . fn ( async ( ) => computerAuthorization ( ) ) )
898+ const pending = execute ( owner , 'active-tool' , { action : 'status' } )
899+ const rejected = expect ( pending ) . rejects . toThrow ( 'stopped' )
900+ await vi . waitFor ( ( ) => expect ( native . request ) . toHaveBeenCalledOnce ( ) )
901+ owner . sender . emit ( eventName , { } , `${ APP } /reload` , false , true )
902+ owner . sender . emit ( 'destroyed' )
903+ await rejected
904+ expect ( cancel ) . toHaveBeenCalledExactlyOnceWith ( 'active-tool' )
905+ expect ( native . stop ) . toHaveBeenCalledOnce ( )
906+ expectComputerListenersRemoved ( owner . sender )
907+ }
908+ )
909+
910+ it ( 'aborts the app approval when its renderer crashes' , async ( ) => {
911+ const { native, approveApp, execute } = computerFixture ( )
912+ approveApp . mockImplementation (
913+ ( _app , signal ) =>
914+ new Promise ( ( resolve ) => {
915+ signal . addEventListener ( 'abort' , ( ) => resolve ( 'deny' ) , { once : true } )
916+ } )
917+ )
918+ const owner = computerSender (
919+ vi . fn ( async ( ) =>
920+ computerAuthorization ( {
921+ action : 'activate_app' ,
922+ bundleId : 'com.example.Fixture' ,
923+ } )
924+ )
925+ )
926+ const pending = execute ( owner , 'approval-tool' , { action : 'status' } )
927+ const rejected = expect ( pending ) . rejects . toThrow ( 'stopped' )
928+ await vi . waitFor ( ( ) => expect ( approveApp ) . toHaveBeenCalledOnce ( ) )
929+ owner . sender . emit ( 'render-process-gone' )
930+ await rejected
931+ expect ( native . request . mock . calls . map ( ( [ method ] ) => method ) ) . toEqual ( [ 'list_apps' ] )
932+ expectComputerListenersRemoved ( owner . sender )
933+ } )
934+
935+ it ( 'canceling another renderer admission leaves the active owner running' , async ( ) => {
936+ const { native, status, cancel, execute } = computerFixture ( )
937+ let finishNative : ( result : ComputerUseResult ) => void = ( ) => { }
938+ native . request . mockImplementation (
939+ ( ) =>
940+ new Promise < ComputerUseResult > ( ( resolve ) => {
941+ finishNative = resolve
942+ } )
943+ )
944+ const owner = computerSender ( vi . fn ( async ( ) => computerAuthorization ( ) ) )
945+ const active = execute ( owner , 'active-tool' , { action : 'status' } )
946+ await vi . waitFor ( ( ) => expect ( native . request ) . toHaveBeenCalledOnce ( ) )
947+ let finishAuthorization : ( response : Response ) => void = ( ) => { }
948+ const other = computerSender (
949+ vi . fn (
950+ ( ) =>
951+ new Promise < Response > ( ( resolve ) => {
952+ finishAuthorization = resolve
953+ } )
954+ )
955+ )
956+ const pending = execute ( other , 'other-tool' , { action : 'status' } )
957+ const rejected = expect ( pending ) . rejects . toThrow ( 'stopped' )
958+ other . sender . emit ( 'render-process-gone' )
959+ expect ( cancel ) . toHaveBeenCalledExactlyOnceWith ( 'other-tool' )
960+ expect ( native . stop ) . not . toHaveBeenCalled ( )
961+ finishAuthorization ( computerAuthorization ( ) )
962+ await rejected
963+ finishNative ( status )
964+ await expect ( active ) . resolves . toEqual ( status )
965+ expect ( native . request ) . toHaveBeenCalledOnce ( )
966+ expectComputerListenersRemoved ( owner . sender )
967+ expectComputerListenersRemoved ( other . sender )
968+ } )
969+
970+ it ( 'keeps native work through SPA/subframe navigation and releases listeners on success' , async ( ) => {
971+ const { native, status, cancel, execute } = computerFixture ( )
972+ let finishNative : ( result : ComputerUseResult ) => void = ( ) => { }
973+ native . request . mockImplementation (
974+ ( ) =>
975+ new Promise < ComputerUseResult > ( ( resolve ) => {
976+ finishNative = resolve
977+ } )
978+ )
979+ const owner = computerSender ( vi . fn ( async ( ) => computerAuthorization ( ) ) )
980+ const pending = execute ( owner , 'navigation-tool' , { action : 'status' } )
981+ await vi . waitFor ( ( ) => expect ( native . request ) . toHaveBeenCalledOnce ( ) )
982+ owner . sender . emit ( 'did-start-navigation' , { } , `${ APP } /another-chat` , true , true )
983+ owner . sender . emit ( 'did-start-navigation' , { } , 'https://example.com' , false , false )
984+ expect ( cancel ) . not . toHaveBeenCalled ( )
985+ finishNative ( status )
986+ await expect ( pending ) . resolves . toEqual ( status )
987+ expectComputerListenersRemoved ( owner . sender )
988+ owner . sender . emit ( 'destroyed' )
989+ expect ( cancel ) . not . toHaveBeenCalled ( )
990+ } )
991+
992+ it ( 'releases owner listeners after authorization failure' , async ( ) => {
993+ const { native, cancel, execute } = computerFixture ( )
994+ const owner = computerSender ( vi . fn ( async ( ) => new Response ( null , { status : 403 } ) ) )
995+ await expect ( execute ( owner , 'denied-tool' , { action : 'status' } ) ) . rejects . toThrow ( 'authorized' )
996+ expectComputerListenersRemoved ( owner . sender )
997+ owner . sender . emit ( 'render-process-gone' )
998+ expect ( cancel ) . not . toHaveBeenCalled ( )
999+ expect ( native . request ) . not . toHaveBeenCalled ( )
1000+ } )
1001+
8001002 it ( 'restricts browser-agent tool execution to the app origin and known tools' , async ( ) => {
8011003 const { invoke } = collectHandlers ( )
8021004 const handler = invoke . get ( 'browser-agent:execute-tool' )
0 commit comments