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 , 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
@@ -350,6 +357,7 @@ describe('registerIpcHandlers', () => {
350357
351358 afterEach ( ( ) => {
352359 vi . useRealTimers ( )
360+ for ( const root of computerRoots . splice ( 0 ) ) rmSync ( root , { recursive : true , force : true } )
353361 } )
354362
355363 it ( 'opens validated external URLs only after recent user input' , async ( ) => {
@@ -613,6 +621,200 @@ describe('registerIpcHandlers', () => {
613621 expect ( await handler ?.( explicitPort ) ) . toMatchObject ( { notificationsEnabled : true } )
614622 } )
615623
624+ function computerFixture ( ) {
625+ const root = mkdtempSync ( join ( tmpdir ( ) , 'computer-ipc-' ) )
626+ computerRoots . push ( root )
627+ const config = createConfigStore ( join ( root , 'settings.json' ) )
628+ config . set ( 'computerUseEnabled' , true )
629+ const status : ComputerUseResult = {
630+ kind : 'status' ,
631+ platform : 'darwin' ,
632+ accessibility : true ,
633+ screenRecording : true ,
634+ }
635+ const native = {
636+ request : vi . fn (
637+ async ( method : string ) : Promise < ComputerUseResult > =>
638+ method === 'list_apps' ? { kind : 'apps' , apps : [ ] } : status
639+ ) ,
640+ stop : vi . fn ( ) ,
641+ }
642+ const approveApp = vi . fn (
643+ async ( _app : ComputerUseAppPermission , _signal : AbortSignal ) : Promise < 'once' | 'deny' > =>
644+ 'once'
645+ )
646+ const service = new ComputerUseService ( {
647+ config,
648+ native,
649+ supported : true ,
650+ approveApp,
651+ onActivity : vi . fn ( ) ,
652+ } )
653+ deps . computerUse = service
654+ const cancel = vi . spyOn ( service , 'cancel' )
655+ const execute = collectHandlers ( ) . invoke . get ( 'computer-use:execute-tool' ) !
656+ return { native, approveApp, status, cancel, execute }
657+ }
658+
659+ function computerSender ( fetch : ( url : string , init ?: RequestInit ) => Promise < Response > ) {
660+ const sender = Object . assign ( new EventEmitter ( ) , {
661+ session : { fetch } ,
662+ isDestroyed : ( ) => false ,
663+ } )
664+ return { sender, senderFrame : { url : `${ APP } /workspace/ws1` } }
665+ }
666+
667+ function computerAuthorization ( args : Record < string , unknown > = { action : 'status' } ) {
668+ return Response . json ( { chatId : 'computer-chat' , toolName : 'computer' , args } )
669+ }
670+
671+ function expectComputerListenersRemoved ( sender : EventEmitter ) {
672+ for ( const event of [ 'destroyed' , 'render-process-gone' , 'did-start-navigation' ] )
673+ expect ( sender . listenerCount ( event ) ) . toBe ( 0 )
674+ }
675+
676+ it . each ( [ 'destroyed' , 'render-process-gone' , 'did-start-navigation' ] ) (
677+ 'cancels authorization pending on owning renderer %s' ,
678+ async ( eventName ) => {
679+ const { native, cancel, execute } = computerFixture ( )
680+ let finishAuthorization : ( response : Response ) => void = ( ) => { }
681+ const owner = computerSender (
682+ vi . fn (
683+ ( ) =>
684+ new Promise < Response > ( ( resolve ) => {
685+ finishAuthorization = resolve
686+ } )
687+ )
688+ )
689+ const pending = execute ( owner , 'owner-tool' , { action : 'status' } )
690+ const rejected = expect ( pending ) . rejects . toThrow ( 'stopped' )
691+ owner . sender . emit ( eventName , { } , `${ APP } /reload` , false , true )
692+ owner . sender . emit ( 'destroyed' )
693+ expect ( cancel ) . toHaveBeenCalledExactlyOnceWith ( 'owner-tool' )
694+ finishAuthorization ( computerAuthorization ( ) )
695+ await rejected
696+ expect ( native . request ) . not . toHaveBeenCalled ( )
697+ expectComputerListenersRemoved ( owner . sender )
698+ }
699+ )
700+
701+ it . each ( [ 'destroyed' , 'render-process-gone' , 'did-start-navigation' ] ) (
702+ 'stops native work on owning renderer %s' ,
703+ async ( eventName ) => {
704+ const { native, cancel, execute } = computerFixture ( )
705+ let rejectNative : ( error : Error ) => void = ( ) => { }
706+ native . request . mockImplementation (
707+ ( ) =>
708+ new Promise < ComputerUseResult > ( ( _resolve , reject ) => {
709+ rejectNative = reject
710+ } )
711+ )
712+ native . stop . mockImplementation ( ( ) => rejectNative ( new Error ( 'native stopped' ) ) )
713+ const owner = computerSender ( vi . fn ( async ( ) => computerAuthorization ( ) ) )
714+ const pending = execute ( owner , 'active-tool' , { action : 'status' } )
715+ const rejected = expect ( pending ) . rejects . toThrow ( 'stopped' )
716+ await vi . waitFor ( ( ) => expect ( native . request ) . toHaveBeenCalledOnce ( ) )
717+ owner . sender . emit ( eventName , { } , `${ APP } /reload` , false , true )
718+ owner . sender . emit ( 'destroyed' )
719+ await rejected
720+ expect ( cancel ) . toHaveBeenCalledExactlyOnceWith ( 'active-tool' )
721+ expect ( native . stop ) . toHaveBeenCalledOnce ( )
722+ expectComputerListenersRemoved ( owner . sender )
723+ }
724+ )
725+
726+ it ( 'aborts the app approval when its renderer crashes' , async ( ) => {
727+ const { native, approveApp, execute } = computerFixture ( )
728+ approveApp . mockImplementation (
729+ ( _app , signal ) =>
730+ new Promise ( ( resolve ) => {
731+ signal . addEventListener ( 'abort' , ( ) => resolve ( 'deny' ) , { once : true } )
732+ } )
733+ )
734+ const owner = computerSender (
735+ vi . fn ( async ( ) =>
736+ computerAuthorization ( {
737+ action : 'activate_app' ,
738+ bundleId : 'com.example.Fixture' ,
739+ } )
740+ )
741+ )
742+ const pending = execute ( owner , 'approval-tool' , { action : 'status' } )
743+ const rejected = expect ( pending ) . rejects . toThrow ( 'stopped' )
744+ await vi . waitFor ( ( ) => expect ( approveApp ) . toHaveBeenCalledOnce ( ) )
745+ owner . sender . emit ( 'render-process-gone' )
746+ await rejected
747+ expect ( native . request . mock . calls . map ( ( [ method ] ) => method ) ) . toEqual ( [ 'list_apps' ] )
748+ expectComputerListenersRemoved ( owner . sender )
749+ } )
750+
751+ it ( 'canceling another renderer admission leaves the active owner running' , async ( ) => {
752+ const { native, status, cancel, execute } = computerFixture ( )
753+ let finishNative : ( result : ComputerUseResult ) => void = ( ) => { }
754+ native . request . mockImplementation (
755+ ( ) =>
756+ new Promise < ComputerUseResult > ( ( resolve ) => {
757+ finishNative = resolve
758+ } )
759+ )
760+ const owner = computerSender ( vi . fn ( async ( ) => computerAuthorization ( ) ) )
761+ const active = execute ( owner , 'active-tool' , { action : 'status' } )
762+ await vi . waitFor ( ( ) => expect ( native . request ) . toHaveBeenCalledOnce ( ) )
763+ let finishAuthorization : ( response : Response ) => void = ( ) => { }
764+ const other = computerSender (
765+ vi . fn (
766+ ( ) =>
767+ new Promise < Response > ( ( resolve ) => {
768+ finishAuthorization = resolve
769+ } )
770+ )
771+ )
772+ const pending = execute ( other , 'other-tool' , { action : 'status' } )
773+ const rejected = expect ( pending ) . rejects . toThrow ( 'stopped' )
774+ other . sender . emit ( 'render-process-gone' )
775+ expect ( cancel ) . toHaveBeenCalledExactlyOnceWith ( 'other-tool' )
776+ expect ( native . stop ) . not . toHaveBeenCalled ( )
777+ finishAuthorization ( computerAuthorization ( ) )
778+ await rejected
779+ finishNative ( status )
780+ await expect ( active ) . resolves . toEqual ( status )
781+ expect ( native . request ) . toHaveBeenCalledOnce ( )
782+ expectComputerListenersRemoved ( owner . sender )
783+ expectComputerListenersRemoved ( other . sender )
784+ } )
785+
786+ it ( 'keeps native work through SPA/subframe navigation and releases listeners on success' , async ( ) => {
787+ const { native, status, cancel, execute } = computerFixture ( )
788+ let finishNative : ( result : ComputerUseResult ) => void = ( ) => { }
789+ native . request . mockImplementation (
790+ ( ) =>
791+ new Promise < ComputerUseResult > ( ( resolve ) => {
792+ finishNative = resolve
793+ } )
794+ )
795+ const owner = computerSender ( vi . fn ( async ( ) => computerAuthorization ( ) ) )
796+ const pending = execute ( owner , 'navigation-tool' , { action : 'status' } )
797+ await vi . waitFor ( ( ) => expect ( native . request ) . toHaveBeenCalledOnce ( ) )
798+ owner . sender . emit ( 'did-start-navigation' , { } , `${ APP } /another-chat` , true , true )
799+ owner . sender . emit ( 'did-start-navigation' , { } , 'https://example.com' , false , false )
800+ expect ( cancel ) . not . toHaveBeenCalled ( )
801+ finishNative ( status )
802+ await expect ( pending ) . resolves . toEqual ( status )
803+ expectComputerListenersRemoved ( owner . sender )
804+ owner . sender . emit ( 'destroyed' )
805+ expect ( cancel ) . not . toHaveBeenCalled ( )
806+ } )
807+
808+ it ( 'releases owner listeners after authorization failure' , async ( ) => {
809+ const { native, cancel, execute } = computerFixture ( )
810+ const owner = computerSender ( vi . fn ( async ( ) => new Response ( null , { status : 403 } ) ) )
811+ await expect ( execute ( owner , 'denied-tool' , { action : 'status' } ) ) . rejects . toThrow ( 'authorized' )
812+ expectComputerListenersRemoved ( owner . sender )
813+ owner . sender . emit ( 'render-process-gone' )
814+ expect ( cancel ) . not . toHaveBeenCalled ( )
815+ expect ( native . request ) . not . toHaveBeenCalled ( )
816+ } )
817+
616818 it ( 'restricts browser-agent tool execution to the app origin and known tools' , async ( ) => {
617819 const { invoke } = collectHandlers ( )
618820 const handler = invoke . get ( 'browser-agent:execute-tool' )
0 commit comments