Skip to content

Commit 0f35b12

Browse files
committed
fix(desktop): keep disabled computer use dormant
1 parent 0e9c8a1 commit 0f35b12

17 files changed

Lines changed: 345 additions & 75 deletions

File tree

‎apps/desktop/src/main/computer-use/service.test.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,19 @@ describe('native computer use authority and lifecycle', () => {
9292
expect(native.request).not.toHaveBeenCalled()
9393
})
9494

95-
it('reports native permissions separately from the opt-in switch', async () => {
96-
const { service } = setup(false)
97-
expect(await service.getStatus()).toEqual({
98-
supported: true,
99-
enabled: false,
95+
it('keeps the helper dormant until device opt-in and stops it when switched off', async () => {
96+
const { service, native } = setup(false)
97+
expect(await service.getStatus()).toMatchObject({ enabled: false, activeAction: null })
98+
expect(native.request).not.toHaveBeenCalled()
99+
expect(await service.setEnabled(true)).toMatchObject({
100+
enabled: true,
100101
permissions: { accessibility: true, screenCapture: false },
101-
activeAction: null,
102102
})
103+
expect(native.request).toHaveBeenCalledOnce()
104+
native.request.mockClear()
105+
expect(await service.setEnabled(false)).toMatchObject({ enabled: false })
106+
expect(native.stop).toHaveBeenCalledOnce()
107+
expect(native.request).not.toHaveBeenCalled()
103108
})
104109

105110
it('does not read app state when the user denies app access', async () => {

‎apps/desktop/src/main/computer-use/service.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,18 @@ export class ComputerUseService {
5757
return this.deps.supported && this.deps.config.get('computerUseEnabled') === true
5858
}
5959

60+
getActivity(): ComputerUseActivity | null {
61+
return this.activity
62+
}
63+
6064
async getStatus(): Promise<ComputerUseStatus> {
6165
const status: ComputerUseStatus = {
6266
supported: this.deps.supported,
6367
enabled: this.isEnabled(),
6468
permissions: { accessibility: false, screenCapture: false },
6569
activeAction: this.activity,
6670
}
67-
if (!status.supported) return status
71+
if (!status.enabled) return status
6872
const native = await this.deps.native.request('status', {})
6973
if (native.kind !== 'status') throw new Error('Computer Use permission status is unavailable.')
7074
status.permissions = {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,13 @@ export function registerIpcHandlers(deps: IpcDeps): void {
698698
}
699699

700700
const channels: Record<string, ChannelSpec> = {
701+
'computer-use:get-activity': {
702+
kind: 'invoke',
703+
gate: 'app-origin',
704+
requiresAccountData: true,
705+
denied: null,
706+
handler: () => deps.computerUse?.getActivity() ?? null,
707+
},
701708
'computer-use:status': {
702709
kind: 'invoke',
703710
gate: 'app-origin',

‎apps/desktop/src/preload/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ const api: SimDesktopApi = {
124124
...(process.platform === 'darwin'
125125
? {
126126
computerUse: {
127+
getActivity: () => ipcRenderer.invoke('computer-use:get-activity'),
127128
getStatus: () => ipcRenderer.invoke('computer-use:status'),
128129
setEnabled: (enabled: boolean) => ipcRenderer.invoke('computer-use:set-enabled', enabled),
129130
requestPermission: (permission: 'accessibility' | 'screenCapture') =>

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { SettingsNavigationProvider } from '@/components/settings/settings-navig
66
import { getSession } from '@/lib/auth'
77
import { getActiveOrganizationId } from '@/lib/auth/session-response'
88
import { canUseBenchmarks } from '@/lib/benchmarks/application/access'
9+
import { isComputerUseAvailable } from '@/lib/computer-use/availability.server'
910
import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
1011
import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags'
1112
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
@@ -60,22 +61,29 @@ export default async function OrganizationLayout({
6061
if (!context.mothershipAvailable && !context.searchAccess.memberScoped)
6162
redirect(WORKSPACE_SETTINGS_PATH)
6263

63-
const [, tableRowTtlEnabled, modelSelectorEnabled, dashboardsEnabled, benchmarkEnabled] =
64-
await Promise.all([
65-
prefetchOrganizationSidebar(
66-
queryClient,
67-
organizationId,
68-
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
69-
getActiveOrganizationId(session)
70-
),
71-
isTableRowTtlEnabled(),
72-
isMothershipModelSelectorEnabled(),
73-
isDashboardsEnabled(organizationId),
74-
canUseBenchmarks(session.user.id).catch(() => {
75-
logger.warn('Could not resolve benchmark navigation access')
76-
return false
77-
}),
78-
])
64+
const [
65+
,
66+
tableRowTtlEnabled,
67+
modelSelectorEnabled,
68+
dashboardsEnabled,
69+
benchmarkEnabled,
70+
computerUseEnabled,
71+
] = await Promise.all([
72+
prefetchOrganizationSidebar(
73+
queryClient,
74+
organizationId,
75+
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
76+
getActiveOrganizationId(session)
77+
),
78+
isTableRowTtlEnabled(),
79+
isMothershipModelSelectorEnabled(),
80+
isDashboardsEnabled(organizationId),
81+
canUseBenchmarks(session.user.id).catch(() => {
82+
logger.warn('Could not resolve benchmark navigation access')
83+
return false
84+
}),
85+
isComputerUseAvailable(),
86+
])
7987
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
8088

8189
return (
@@ -85,6 +93,7 @@ export default async function OrganizationLayout({
8593
dashboards: dashboardsEnabled,
8694
'table-row-ttl': tableRowTtlEnabled,
8795
'mothership-model-selector': modelSelectorEnabled,
96+
'mothership-computer-use': computerUseEnabled,
8897
'mothership-plan-mode': benchmarkEnabled,
8998
'mothership-memory-spaces': benchmarkEnabled,
9099
'mothership-desktop-background-executor': false,

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,7 @@ export function useChat(
738738
options?: UseChatOptions
739739
): UseChatReturn {
740740
const modelSelectorEnabled = useFeatureFlag('mothership-model-selector')
741+
const computerUseEnabled = useFeatureFlag('mothership-computer-use')
741742
const workspaceId = typeof owner === 'string' ? owner : undefined
742743
const organizationId = typeof owner === 'string' ? undefined : owner.organizationId
743744
const scopeKey = typeof owner === 'string' ? owner : `organization:${owner.organizationId}`
@@ -3910,7 +3911,7 @@ export function useChat(
39103911
const desktopChatCapabilities =
39113912
options?.requestMode === 'assistant'
39123913
? {}
3913-
: await getDesktopChatCapabilities(desktopScopeIdRef.current)
3914+
: await getDesktopChatCapabilities(desktopScopeIdRef.current, computerUseEnabled)
39143915

39153916
admissionUnknown = true
39163917
/** A reload from here on may find the server holding this id. */
@@ -4271,6 +4272,7 @@ export function useChat(
42714272
queryClient,
42724273
upsertChatHistory,
42734274
modelSelectorEnabled,
4275+
computerUseEnabled,
42744276
processSSEStream,
42754277
finalize,
42764278
resumeOrFinalize,

‎apps/sim/app/workspace/[workspaceId]/layout.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'
44
import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider'
55
import { getSession } from '@/lib/auth'
66
import { getActiveOrganizationId } from '@/lib/auth/session-response'
7+
import { isComputerUseAvailable } from '@/lib/computer-use/availability.server'
78
import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
89
import { isDesktopBackgroundExecutorEnabled } from '@/lib/desktop/executor/flag'
910
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
@@ -69,6 +70,7 @@ export default async function WorkspaceLayout({
6970
organizationHref,
7071
dashboardsEnabled,
7172
desktopBackgroundExecutorEnabled,
73+
computerUseEnabled,
7274
] = await Promise.all([
7375
cookies(),
7476
hostContext.hostOrganizationId
@@ -87,6 +89,7 @@ export default async function WorkspaceLayout({
8789
resolveOrganizationEntryPath(session),
8890
isDashboardsEnabled(hostContext.hostOrganizationId),
8991
isDesktopBackgroundExecutorEnabled(session.user.id),
92+
isComputerUseAvailable(),
9093
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
9194
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
9295
])
@@ -99,6 +102,7 @@ export default async function WorkspaceLayout({
99102
dashboards: dashboardsEnabled,
100103
'table-row-ttl': tableRowTtlEnabled,
101104
'mothership-model-selector': modelSelectorEnabled,
105+
'mothership-computer-use': computerUseEnabled,
102106
'mothership-plan-mode': planModeEnabled,
103107
'mothership-desktop-background-executor': desktopBackgroundExecutorEnabled,
104108
}}

‎apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface WorkspaceFeatureFlags {
77
'table-row-ttl': boolean
88
'mothership-model-selector': boolean
99
'mothership-plan-mode': boolean
10+
'mothership-computer-use'?: boolean
1011
'mothership-desktop-background-executor': boolean
1112
'mothership-memory-spaces'?: boolean
1213
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/** @vitest-environment jsdom */
2+
import { act } from 'react'
3+
import type {
4+
ComputerUseActivity,
5+
ComputerUseAppPermission,
6+
ComputerUseStatus,
7+
} from '@sim/desktop-bridge'
8+
import { toast } from '@sim/emcn'
9+
import { libDesktopMock, libDesktopMockFns } from '@sim/testing/mocks/lib-desktop.mock'
10+
import { createRoot, type Root } from 'react-dom/client'
11+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
12+
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
13+
import { ComputerUseSettings } from '@/app/workspace/[workspaceId]/settings/components/desktop/computer-use'
14+
15+
const native = vi.hoisted(() => ({
16+
getStatus: vi.fn<() => Promise<ComputerUseStatus>>(),
17+
listAppPermissions: vi.fn<() => Promise<ComputerUseAppPermission[]>>(),
18+
revokeApp: vi.fn<(bundleId: string) => Promise<void>>(),
19+
cancel: vi.fn<() => Promise<void>>(),
20+
onActivity: vi.fn<(listener: (activity: ComputerUseActivity | null) => void) => () => void>(),
21+
}))
22+
vi.mock('@/lib/desktop', () => libDesktopMock)
23+
24+
const initialPermissions = [
25+
{ bundleId: 'com.example.First', displayName: 'First app' },
26+
{ bundleId: 'com.example.Second', displayName: 'Second app' },
27+
]
28+
let permissions: ComputerUseAppPermission[]
29+
let activity: ComputerUseActivity | null
30+
let listeners: Set<(activity: ComputerUseActivity | null) => void>
31+
let enabled: boolean
32+
let root: Root
33+
let container: HTMLDivElement
34+
35+
beforeEach(() => {
36+
enabled = true
37+
permissions = [...initialPermissions]
38+
activity = null
39+
listeners = new Set()
40+
native.getStatus.mockImplementation(async () => ({
41+
supported: true,
42+
enabled: true,
43+
permissions: { accessibility: true, screenCapture: true },
44+
activeAction: activity,
45+
}))
46+
native.listAppPermissions.mockImplementation(async () => [...permissions])
47+
native.revokeApp.mockImplementation(async (bundleId) => {
48+
permissions = permissions.filter((app) => app.bundleId !== bundleId)
49+
})
50+
native.cancel.mockResolvedValue(undefined)
51+
native.onActivity.mockImplementation((listener) => {
52+
listeners.add(listener)
53+
return () => {
54+
listeners.delete(listener)
55+
}
56+
})
57+
libDesktopMockFns.mockGetDesktopBridge.mockReturnValue({ computerUse: native })
58+
vi.spyOn(toast, 'error').mockImplementation(() => '')
59+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
60+
container = document.createElement('div')
61+
document.body.append(container)
62+
root = createRoot(container)
63+
})
64+
65+
afterEach(async () => {
66+
await act(async () => root.unmount())
67+
container.remove()
68+
})
69+
70+
function renderSettings() {
71+
root.render(
72+
<FeatureFlagsProvider
73+
flags={{
74+
dashboards: false,
75+
'table-row-ttl': false,
76+
'mothership-model-selector': false,
77+
'mothership-plan-mode': false,
78+
'mothership-desktop-background-executor': false,
79+
'mothership-computer-use': enabled,
80+
}}
81+
>
82+
<ComputerUseSettings />
83+
</FeatureFlagsProvider>
84+
)
85+
}
86+
87+
it('keeps settings dormant while rollout is off, including focus and activity changes', async () => {
88+
enabled = false
89+
await act(async () => renderSettings())
90+
await act(async () => window.dispatchEvent(new Event('focus')))
91+
expect(native.getStatus).not.toHaveBeenCalled()
92+
expect(native.listAppPermissions).not.toHaveBeenCalled()
93+
expect(container.textContent).toBe('')
94+
95+
enabled = true
96+
await act(async () => renderSettings())
97+
expect(native.getStatus).toHaveBeenCalled()
98+
expect(appRow('com.example.First')).toBeDefined()
99+
await act(async () => {
100+
activity = { toolCallId: 'in-flight', scopeId: 'chat', action: 'click', startedAt: Date.now() }
101+
for (const listener of listeners) listener(activity)
102+
})
103+
enabled = false
104+
await act(async () => renderSettings())
105+
native.getStatus.mockClear()
106+
native.listAppPermissions.mockClear()
107+
await act(async () => {
108+
window.dispatchEvent(new Event('focus'))
109+
for (const listener of listeners) listener(activity)
110+
})
111+
expect(native.getStatus).not.toHaveBeenCalled()
112+
expect(native.listAppPermissions).not.toHaveBeenCalled()
113+
const stop = container.querySelector<HTMLButtonElement>('button')
114+
expect(stop?.textContent).toContain('Stop')
115+
await act(async () => stop?.click())
116+
expect(native.cancel).toHaveBeenCalledOnce()
117+
})
118+
119+
function appRow(bundleId: string) {
120+
return container.querySelector<HTMLElement>(`[title="${bundleId}"]`)?.parentElement
121+
}

‎apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import type { ComputerUseAppPermission } from '@sim/desktop-bridge'
55
import { Chip, ChipSwitch, Label, toast } from '@sim/emcn'
66
import { ComputerUseActivity } from '@/components/computer-use/activity'
77
import { getDesktopBridge } from '@/lib/desktop'
8+
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
89
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
9-
import { useComputerUseAvailability } from '@/hooks/queries/computer-use'
1010
import { useComputerUseStatus } from '@/hooks/use-computer-use-status'
1111

1212
export function ComputerUseSettings() {
@@ -20,18 +20,18 @@ export function ComputerUseSettings() {
2020

2121
function ComputerUseSettingsControls() {
2222
const bridge = getDesktopBridge()?.computerUse
23-
const availability = useComputerUseAvailability(Boolean(bridge))
23+
const enabled = useFeatureFlag('mothership-computer-use')
2424
const { status, setStatus, refresh, error } = useComputerUseStatus()
2525
const [apps, setApps] = useState<ComputerUseAppPermission[]>([])
2626
const [pending, setPending] = useState(false)
2727
useEffect(() => {
28-
if (!bridge || !availability.data?.enabled) return
28+
if (!bridge || !enabled) return
2929
void bridge
3030
.listAppPermissions()
3131
.then(setApps)
3232
.catch(() => toast.error('Could not load approved apps'))
33-
}, [bridge, availability.data?.enabled, status?.activeAction])
34-
if (!bridge || !availability.data?.enabled || status?.supported === false) return null
33+
}, [bridge, enabled, status?.activeAction])
34+
if (!bridge || !enabled || status?.supported === false) return null
3535
const update = async (action: () => Promise<void>) => {
3636
setPending(true)
3737
try {
@@ -79,7 +79,7 @@ function ComputerUseSettingsControls() {
7979
<span className='text-[var(--text-muted)] text-sm'>Allowed</span>
8080
) : (
8181
<Chip
82-
disabled={pending || !status}
82+
disabled={pending || !status?.enabled}
8383
onClick={() =>
8484
void update(async () => setStatus(await bridge.requestPermission(permission)))
8585
}

0 commit comments

Comments
 (0)