Skip to content

Commit c99237a

Browse files
committed
fix(desktop): cancel computer use when its renderer exits
1 parent 7c83594 commit c99237a

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
@@ -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')

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

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2065,22 +2065,48 @@ export function registerIpcHandlers(deps: IpcDeps): void {
20652065
throw new Error('Computer Use is switched off on this Mac.')
20662066
const toolCallId = args[0]
20672067
if (!isDesktopToolCallId(toolCallId)) throw new Error('Invalid tool call identifier.')
2068-
return deps.computerUse.executeAuthorized(toolCallId, async () => {
2069-
const authorization = await fetchDesktopToolAuthorization(
2070-
event,
2071-
deps,
2072-
toolCallId,
2073-
false,
2074-
undefined,
2075-
'/api/desktop/computer/authorize'
2076-
)
2077-
if (!authorization || authorization.toolName !== 'computer') {
2078-
throw new Error('This is not an authorized pending Computer Use action.')
2079-
}
2080-
if (!senderAllowed(event, spec.gate) || !deps.accountDataAvailable())
2081-
throw new Error('Computer Use session ended.')
2082-
return { scopeId: authorization.chatId, input: authorization.args }
2083-
})
2068+
const computerUse = deps.computerUse
2069+
let ownerEnded = false
2070+
const cancelOwnedTool = (): void => {
2071+
if (ownerEnded) return
2072+
ownerEnded = true
2073+
computerUse.cancel(toolCallId)
2074+
}
2075+
const onNavigation = (
2076+
_event: unknown,
2077+
_url: string,
2078+
isInPlace: boolean,
2079+
isMainFrame: boolean
2080+
): void => {
2081+
if (!isInPlace && isMainFrame) cancelOwnedTool()
2082+
}
2083+
/** A crashed renderer cannot send pagehide or Stop; main owns the action lifetime. */
2084+
event.sender.on('destroyed', cancelOwnedTool)
2085+
event.sender.on('render-process-gone', cancelOwnedTool)
2086+
event.sender.on('did-start-navigation', onNavigation)
2087+
try {
2088+
if (event.sender.isDestroyed()) throw new Error('Computer Use session ended.')
2089+
return await computerUse.executeAuthorized(toolCallId, async () => {
2090+
const authorization = await fetchDesktopToolAuthorization(
2091+
event,
2092+
deps,
2093+
toolCallId,
2094+
false,
2095+
undefined,
2096+
'/api/desktop/computer/authorize'
2097+
)
2098+
if (!authorization || authorization.toolName !== 'computer') {
2099+
throw new Error('This is not an authorized pending Computer Use action.')
2100+
}
2101+
if (!senderAllowed(event, spec.gate) || !deps.accountDataAvailable())
2102+
throw new Error('Computer Use session ended.')
2103+
return { scopeId: authorization.chatId, input: authorization.args }
2104+
})
2105+
} finally {
2106+
event.sender.removeListener('destroyed', cancelOwnedTool)
2107+
event.sender.removeListener('render-process-gone', cancelOwnedTool)
2108+
event.sender.removeListener('did-start-navigation', onNavigation)
2109+
}
20842110
}
20852111
if (channel === 'browser-agent:execute-tool') {
20862112
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)