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