Skip to content

Commit 053aca3

Browse files
Sg312Vikhyath Mondreti
authored andcommitted
fix(desktop): cancel computer use when its renderer exits
1 parent b55efe2 commit 053aca3

3 files changed

Lines changed: 247 additions & 19 deletions

File tree

‎apps/desktop/src/main/ipc.test.ts‎

Lines changed: 204 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
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'
25
import { fileURLToPath } from 'node:url'
36
import { PASTE_LIMITS } from '@sim/utils/paste'
47
import { 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'
117121
import type { WebContents } from 'electron'
118122
import { clipboard, ipcMain, shell } from 'electron'
119123
import * as browserDriver from '@/main/browser-agent/driver'
@@ -132,6 +136,8 @@ import {
132136
listChromeImportProfiles,
133137
} from '@/main/browser-import'
134138
import { getSearchSuggestions } from '@/main/browser-search/suggestions'
139+
import { ComputerUseService } from '@/main/computer-use/service'
140+
import { createConfigStore } from '@/main/config'
135141
import { trackInputActivity } from '@/main/input-activity'
136142
import { type IpcDeps, registerIpcHandlers } from '@/main/ipc'
137143
import { LocalFilesystemService } from '@/main/local-filesystem'
@@ -260,6 +266,7 @@ const _activeChooserEvent = {
260266

261267
describe('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')

‎apps/desktop/src/main/ipc.ts‎

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2125,22 +2125,48 @@ export function registerIpcHandlers(deps: IpcDeps): void {
21252125
throw new Error('Computer Use is switched off on this Mac.')
21262126
const toolCallId = args[0]
21272127
if (!isDesktopToolCallId(toolCallId)) throw new Error('Invalid tool call identifier.')
2128-
return deps.computerUse.executeAuthorized(toolCallId, async () => {
2129-
const authorization = await fetchDesktopToolAuthorization(
2130-
event,
2131-
deps,
2132-
toolCallId,
2133-
false,
2134-
undefined,
2135-
'/api/desktop/computer/authorize'
2136-
)
2137-
if (!authorization || authorization.toolName !== 'computer') {
2138-
throw new Error('This is not an authorized pending Computer Use action.')
2139-
}
2140-
if (!senderAllowed(event, spec.gate) || !deps.accountDataAvailable())
2141-
throw new Error('Computer Use session ended.')
2142-
return { scopeId: authorization.chatId, input: authorization.args }
2143-
})
2128+
const computerUse = deps.computerUse
2129+
let ownerEnded = false
2130+
const cancelOwnedTool = (): void => {
2131+
if (ownerEnded) return
2132+
ownerEnded = true
2133+
computerUse.cancel(toolCallId)
2134+
}
2135+
const onNavigation = (
2136+
_event: unknown,
2137+
_url: string,
2138+
isInPlace: boolean,
2139+
isMainFrame: boolean
2140+
): void => {
2141+
if (!isInPlace && isMainFrame) cancelOwnedTool()
2142+
}
2143+
/** A crashed renderer cannot send pagehide or Stop; main owns the action lifetime. */
2144+
event.sender.on('destroyed', cancelOwnedTool)
2145+
event.sender.on('render-process-gone', cancelOwnedTool)
2146+
event.sender.on('did-start-navigation', onNavigation)
2147+
try {
2148+
if (event.sender.isDestroyed()) throw new Error('Computer Use session ended.')
2149+
return await computerUse.executeAuthorized(toolCallId, async () => {
2150+
const authorization = await fetchDesktopToolAuthorization(
2151+
event,
2152+
deps,
2153+
toolCallId,
2154+
false,
2155+
undefined,
2156+
'/api/desktop/computer/authorize'
2157+
)
2158+
if (!authorization || authorization.toolName !== 'computer') {
2159+
throw new Error('This is not an authorized pending Computer Use action.')
2160+
}
2161+
if (!senderAllowed(event, spec.gate) || !deps.accountDataAvailable())
2162+
throw new Error('Computer Use session ended.')
2163+
return { scopeId: authorization.chatId, input: authorization.args }
2164+
})
2165+
} finally {
2166+
event.sender.removeListener('destroyed', cancelOwnedTool)
2167+
event.sender.removeListener('render-process-gone', cancelOwnedTool)
2168+
event.sender.removeListener('did-start-navigation', onNavigation)
2169+
}
21442170
}
21452171
if (channel === 'browser-agent:execute-tool') {
21462172
const toolCallId = args[0]

‎apps/sim/lib/computer-use/application/availability.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import {
55
type OperationUseCase,
66
} from '@/lib/core/application/operation'
77

8+
/** permission-group-exempt: reports only the deployment-wide rollout switch; actions require chat authorization. */
89
const availabilityOperation = defineOperation({
910
id: 'desktop.computer.availability',
10-
/** permission-group-exempt: reports only the deployment-wide rollout switch; actions require chat authorization. */
1111
capability: 'none',
1212
principalKinds: ['session'],
1313
})

0 commit comments

Comments
 (0)