Skip to content

Commit b3fa140

Browse files
committed
fix(desktop): cancel computer use when its renderer exits
1 parent b1c8c12 commit b3fa140

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, openMicrophoneSettings, 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 () => {
@@ -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')

‎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)