From 4dbdcb9ddccfc5b7590f718d33e4614b66510670 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 11:46:01 -0800 Subject: [PATCH 1/4] feat(computer-use): enable computer use by default, governed by a permission-group restriction Removes the mothership-computer-use feature flag. Each device still opts in under Settings > Desktop > Computer Use; enterprise admins can withhold it with the new Computer Use permission-group restriction (computer_use.use, which also follows Chat). --- apps/desktop/README.md | 9 +-- .../platform/enterprise/access-control.mdx | 1 + apps/docs/openapi-v2-resources.json | 74 ++++++++++++----- .../desktop/computer/availability/route.ts | 21 ----- apps/sim/app/extension/providers.tsx | 1 - apps/sim/app/o/[organizationId]/layout.tsx | 4 - .../mothership-chat/rollout-controls.test.tsx | 1 - .../[workspaceId]/home/hooks/use-chat.ts | 13 +-- .../app/workspace/[workspaceId]/layout.tsx | 4 - .../providers/feature-flags-provider.tsx | 1 - .../components/desktop/computer-use.test.tsx | 64 ++++----------- .../components/desktop/computer-use.tsx | 15 ++-- .../components/desktop/desktop.test.tsx | 5 +- .../components/computer-use/activity.test.tsx | 64 +-------------- apps/sim/components/computer-use/activity.tsx | 2 +- apps/sim/ee/access-requests/lib/targets.ts | 1 + apps/sim/hooks/use-computer-use-status.ts | 14 ++-- apps/sim/lib/api/contracts/computer-use.ts | 17 +--- .../application/authorize.test.ts | 32 +++++--- .../lib/computer-use/application/authorize.ts | 7 +- .../computer-use/application/availability.ts | 57 ++++++------- .../lib/computer-use/availability.server.ts | 6 -- apps/sim/lib/core/config/env.ts | 1 - .../sim/lib/core/config/feature-flags.test.ts | 19 +++-- apps/sim/lib/core/config/feature-flags.ts | 6 -- apps/sim/lib/desktop/index.test.ts | 79 ++++--------------- apps/sim/lib/desktop/index.ts | 28 ++----- .../sim/lib/permission-groups/capabilities.ts | 9 +++ apps/sim/lib/permission-groups/fields.test.ts | 2 + apps/sim/lib/permission-groups/fields.ts | 7 ++ packages/sim-cli/src/generated/v2-api.ts | 23 ++++++ 31 files changed, 218 insertions(+), 369 deletions(-) delete mode 100644 apps/sim/app/api/desktop/computer/availability/route.ts delete mode 100644 apps/sim/lib/computer-use/availability.server.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 3b33249a679..11eecdcbb00 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -234,11 +234,10 @@ Cadence: Electron ships a major every ~8 weeks and supports the latest 3 — bud ## Native Mac computer use -Mothership's `computer` tool is available on macOS 14+ when the global runtime flag -`mothership-computer-use` is enabled and the user opts in under **Settings → Desktop → Computer Use**. -The flag defaults off; local/self-hosted deployments use `MSHIP_COMPUTER_USE=true`, while hosted -rollout uses the existing AppConfig feature-flags document. Older desktop builds omit this optional -bridge and continue to work. +Mothership's `computer` tool is available on macOS 14+ once the user opts in under +**Settings → Desktop → Computer Use**. Enterprise organizations can withhold it from a permission +group with the **Computer Use** restriction. Older desktop builds omit this optional bridge and +continue to work. The user grants Accessibility and Screen Recording through macOS, then approves each app for the current task or persistently. Persistent app approvals are listed in Settings and can be revoked. diff --git a/apps/docs/content/docs/platform/enterprise/access-control.mdx b/apps/docs/content/docs/platform/enterprise/access-control.mdx index 8f87c9cf670..3b3b27c4e3f 100644 --- a/apps/docs/content/docs/platform/enterprise/access-control.mdx +++ b/apps/docs/content/docs/platform/enterprise/access-control.mdx @@ -95,6 +95,7 @@ Most platform restrictions refuse the protected operation at the server as well | Tools | Custom Tools | Prevents agents from calling custom tools. | | Tools | Skills | Prevents agents from loading skills. | | Tools | Tool Auto-Approval | Prevents storing an always-allow preference for tool calls. | +| Tools | Computer Use | Prevents Chat from operating apps on a member's Mac, even when the device allows it. | | Logs | Trace Spans | Withholds per-block spans from log reads and API responses. | | Logs | Log Export | Prevents CSV execution-log downloads. | | Logs | Execution Cost | Omits cost and token spend from governed logs and member exports. Organization-admin data drains are not projected this way. | diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index e9f650b62d7..1cc4e6524dc 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -7431,7 +7431,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -7592,7 +7593,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -7753,7 +7755,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -7922,7 +7925,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -22420,6 +22424,10 @@ "maxLength": 1024 }, "description": "Access requests are unavailable for the listed canonical target keys when access is restricted." + }, + "disableComputerUse": { + "type": "boolean", + "description": "Revoke computer use. Chat cannot operate apps on a member's Mac, even when the device allows it." } }, "required": [ @@ -22467,7 +22475,8 @@ "disableKnowledgeBaseExport", "deniedPartialAccessProjectIssues", "defaultAgentModel", - "deniedAccessRequests" + "deniedAccessRequests", + "disableComputerUse" ], "additionalProperties": false, "description": "Resolved group settings. True disables a boolean capability; null allowlists permit every value and empty allowlists permit none. defaultAgentModel selects the default for new Agent blocks without restricting model selection." @@ -22600,7 +22609,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -22678,7 +22688,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -22962,6 +22973,10 @@ "maxLength": 1024 }, "description": "Access requests are unavailable for the listed canonical target keys when access is restricted." + }, + "disableComputerUse": { + "type": "boolean", + "description": "Revoke computer use. Chat cannot operate apps on a member's Mac, even when the device allows it." } }, "additionalProperties": false, @@ -23056,7 +23071,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -23132,7 +23148,8 @@ "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], "defaultAgentModel": null, - "deniedAccessRequests": [] + "deniedAccessRequests": [], + "disableComputerUse": false }, "isDefault": false, "membershipMode": "inherit", @@ -23423,6 +23440,10 @@ "maxLength": 1024 }, "description": "Access requests are unavailable for the listed canonical target keys when access is restricted." + }, + "disableComputerUse": { + "type": "boolean", + "description": "Revoke computer use. Chat cannot operate apps on a member's Mac, even when the device allows it." } }, "additionalProperties": false, @@ -25920,6 +25941,10 @@ "maxLength": 1024 }, "description": "Access requests are unavailable for the listed canonical target keys when access is restricted." + }, + "disableComputerUse": { + "type": "boolean", + "description": "Revoke computer use. Chat cannot operate apps on a member's Mac, even when the device allows it." } }, "required": [ @@ -25967,7 +25992,8 @@ "disableKnowledgeBaseExport", "deniedPartialAccessProjectIssues", "defaultAgentModel", - "deniedAccessRequests" + "deniedAccessRequests", + "disableComputerUse" ], "additionalProperties": false }, @@ -26727,7 +26753,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "disableComputerUse" ], "description": "Feature restriction key returned by access discovery." } @@ -27039,7 +27066,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "disableComputerUse" ], "description": "Feature restriction key returned by access discovery." } @@ -27439,7 +27467,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "disableComputerUse" ], "description": "Feature restriction key returned by access discovery." } @@ -27837,7 +27866,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "disableComputerUse" ], "description": "Feature restriction key returned by access discovery." } @@ -28193,7 +28223,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "disableComputerUse" ], "description": "Feature restriction key returned by access discovery." } @@ -28452,7 +28483,7 @@ "description": "Access request being reviewed." }, "changes": { - "maxItems": 43, + "maxItems": 44, "type": "array", "items": { "type": "object", @@ -28502,7 +28533,8 @@ "hideSandboxesTab", "disableOAuthAppAccess", "disableKnowledgeBaseExport", - "deniedPartialAccessProjectIssues" + "deniedPartialAccessProjectIssues", + "disableComputerUse" ], "description": "Permission restriction changed by approval." }, @@ -28755,7 +28787,8 @@ "disableToolAutoApproval", "hideSandboxesTab", "disableOAuthAppAccess", - "disableKnowledgeBaseExport" + "disableKnowledgeBaseExport", + "disableComputerUse" ], "description": "Feature restriction key returned by access discovery." } @@ -29014,7 +29047,7 @@ "description": "Access request being reviewed." }, "changes": { - "maxItems": 43, + "maxItems": 44, "type": "array", "items": { "type": "object", @@ -29064,7 +29097,8 @@ "hideSandboxesTab", "disableOAuthAppAccess", "disableKnowledgeBaseExport", - "deniedPartialAccessProjectIssues" + "deniedPartialAccessProjectIssues", + "disableComputerUse" ], "description": "Permission restriction changed by approval." }, diff --git a/apps/sim/app/api/desktop/computer/availability/route.ts b/apps/sim/app/api/desktop/computer/availability/route.ts deleted file mode 100644 index 884fe581ade..00000000000 --- a/apps/sim/app/api/desktop/computer/availability/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { computerUseAvailabilityContract } from '@/lib/api/contracts/computer-use' -import { - defineInternalJsonRoute, - internalOrchestrationErrorPolicy, - internalRateLimits, - internalSessionAuth, -} from '@/lib/api/server/routes' -import { readComputerUseAvailability } from '@/lib/computer-use/application/availability' - -export const GET = defineInternalJsonRoute({ - contract: computerUseAvailabilityContract, - auth: internalSessionAuth, - operation: readComputerUseAvailability.operation, - rateLimit: internalRateLimits.none({ - reason: 'Read-only rollout check used on each desktop chat admission.', - }), - errorPolicy: internalOrchestrationErrorPolicy, - mapInput: ({ query }) => query, - useCase: readComputerUseAvailability, - present: (result) => result, -}) diff --git a/apps/sim/app/extension/providers.tsx b/apps/sim/app/extension/providers.tsx index 18e4cf02865..764bceb2e5d 100644 --- a/apps/sim/app/extension/providers.tsx +++ b/apps/sim/app/extension/providers.tsx @@ -55,7 +55,6 @@ export function ExtensionProviders({ changelog: false, 'mothership-model-selector': modelSelectorEnabled, 'mothership-plan-mode': planModeEnabled, - 'mothership-computer-use': false, }} > diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index ddaa31bf583..018d7f5730f 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -7,7 +7,6 @@ import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { canUseBenchmarks } from '@/lib/benchmarks/config' import { isChangelogEnabled } from '@/lib/changelog/feature-flag' -import { isComputerUseAvailable } from '@/lib/computer-use/availability.server' import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag' import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' @@ -82,7 +81,6 @@ export default async function OrganizationLayout({ modelSelectorEnabled, dashboardsEnabled, benchmarkEnabled, - computerUseEnabled, workflowTestsEnabled, changelogEnabled, projectsEnabled, @@ -102,7 +100,6 @@ export default async function OrganizationLayout({ logger.warn('Could not resolve benchmark navigation access') return false }), - isComputerUseAvailable(organizationId), isWorkflowTestsEnabled(organizationId), isChangelogEnabled(organizationId), isProjectsEnabled(), @@ -124,7 +121,6 @@ export default async function OrganizationLayout({ 'workflow-tests': workflowTestsEnabled, changelog: changelogEnabled, 'mothership-model-selector': modelSelectorEnabled, - 'mothership-computer-use': computerUseEnabled, 'mothership-plan-mode': benchmarkEnabled, 'mothership-memory-spaces': benchmarkEnabled, projects: projectsEnabled, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/rollout-controls.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/rollout-controls.test.tsx index e88c7682c09..c45b418ede6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/rollout-controls.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/rollout-controls.test.tsx @@ -60,7 +60,6 @@ async function render(advanced: boolean, workspaceId?: string) { changelog: false, 'mothership-model-selector': advanced, 'mothership-plan-mode': true, - 'mothership-computer-use': false, }} > ({ @@ -20,6 +20,10 @@ const native = vi.hoisted(() => ({ cancel: vi.fn<() => Promise>(), onActivity: vi.fn<(listener: (activity: ComputerUseActivity | null) => void) => () => void>(), })) +const permission = vi.hoisted(() => ({ config: {} as Record })) +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ config: permission.config }), +})) vi.mock('@/lib/desktop', () => libDesktopMock) const initialPermissions = [ @@ -29,12 +33,11 @@ const initialPermissions = [ let permissions: ComputerUseAppPermission[] let activity: ComputerUseActivity | null let listeners: Set<(activity: ComputerUseActivity | null) => void> -let enabled: boolean let root: Root let container: HTMLDivElement beforeEach(() => { - enabled = true + permission.config = { ...DEFAULT_PERMISSION_GROUP_CONFIG } permissions = [...initialPermissions] activity = null listeners = new Set() @@ -69,53 +72,18 @@ afterEach(async () => { }) function renderSettings() { - root.render( - - - - ) + root.render() } -it('keeps settings dormant while rollout is off, including focus and activity changes', async () => { - enabled = false - await act(async () => renderSettings()) - await act(async () => window.dispatchEvent(new Event('focus'))) - expect(native.getStatus).not.toHaveBeenCalled() - expect(native.listAppPermissions).not.toHaveBeenCalled() - expect(container.textContent).toBe('') - - enabled = true - await act(async () => renderSettings()) - expect(native.getStatus).toHaveBeenCalled() - expect(appRow('com.example.First')).toBeDefined() - await act(async () => { - activity = { toolCallId: 'in-flight', scopeId: 'chat', action: 'click', startedAt: Date.now() } - for (const listener of listeners) listener(activity) - }) - enabled = false - await act(async () => renderSettings()) - native.getStatus.mockClear() - native.listAppPermissions.mockClear() - await act(async () => { - window.dispatchEvent(new Event('focus')) - for (const listener of listeners) listener(activity) - }) - expect(native.getStatus).not.toHaveBeenCalled() - expect(native.listAppPermissions).not.toHaveBeenCalled() - const stop = container.querySelector('button') - expect(stop?.textContent).toContain('Stop') - await act(async () => stop?.click()) - expect(native.cancel).toHaveBeenCalledOnce() -}) +it.each(['disableComputerUse', 'hideCopilot'])( + 'withholds the device controls when the permission group sets %s', + async (key) => { + permission.config = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [key]: true } + await act(async () => renderSettings()) + expect(appRow('com.example.First')).toBeUndefined() + expect(container.querySelector('[role="switch"]')).toBeNull() + } +) function appRow(bundleId: string) { return container.querySelector(`[title="${bundleId}"]`)?.parentElement diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx index 4d38a5241ec..33251dafa23 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx @@ -5,9 +5,10 @@ import type { ComputerUseAppPermission } from '@sim/desktop-bridge' import { Chip, ChipSwitch, Label, toast } from '@sim/emcn' import { ComputerUseActivity } from '@/components/computer-use/activity' import { getDesktopBridge } from '@/lib/desktop' -import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useComputerUseStatus } from '@/hooks/use-computer-use-status' +import { usePermissionConfig } from '@/hooks/use-permission-config' export function ComputerUseSettings() { return ( @@ -21,7 +22,7 @@ export function ComputerUseSettings() { function ComputerUseSettingsControls() { const requestVersion = useRef(0) const bridge = getDesktopBridge()?.computerUse - const enabled = useFeatureFlag('mothership-computer-use') + const { config: permissionConfig } = usePermissionConfig() const { status, setStatus, refresh, error } = useComputerUseStatus() const [apps, setApps] = useState([]) const [pending, setPending] = useState(false) @@ -36,13 +37,17 @@ function ComputerUseSettingsControls() { } }, [bridge]) useEffect(() => { - if (!enabled) return void refreshApps() return () => { requestVersion.current += 1 } - }, [enabled, refreshApps, status?.activeAction]) - if (!bridge || !enabled || status?.supported === false) return null + }, [refreshApps, status?.activeAction]) + if ( + !bridge || + CAPABILITY_RULES['computer_use.use'].deniedBy(permissionConfig) || + status?.supported === false + ) + return null const update = async (action: () => Promise) => { setPending(true) try { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx index 5d4ee1e2bf5..09da50149b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx @@ -8,6 +8,10 @@ import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' +const permission = vi.hoisted(() => ({ config: {} as Record })) +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ config: permission.config }), +})) vi.mock('@sim/emcn', () => ({ ...emcnMock, Label: ({ children, htmlFor }: { children?: ReactNode; htmlFor?: string }) => ( @@ -66,7 +70,6 @@ async function render(device: { deviceId: string } | null) { changelog: false, 'mothership-model-selector': false, 'mothership-plan-mode': false, - 'mothership-computer-use': false, }} > diff --git a/apps/sim/components/computer-use/activity.test.tsx b/apps/sim/components/computer-use/activity.test.tsx index ebda9e89236..be79032b95f 100644 --- a/apps/sim/components/computer-use/activity.test.tsx +++ b/apps/sim/components/computer-use/activity.test.tsx @@ -1,12 +1,10 @@ /** @vitest-environment jsdom */ import { act } from 'react' import type { ComputerUseActivity as Activity, ComputerUseStatus } from '@sim/desktop-bridge' -import { createDeferred } from '@sim/testing/helpers/deferred' import { libDesktopMock, libDesktopMockFns } from '@sim/testing/mocks/lib-desktop.mock' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { ComputerUseActivity } from '@/components/computer-use/activity' -import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' const native = vi.hoisted(() => ({ getActivity: vi.fn<() => Promise>(), @@ -39,22 +37,7 @@ it.each(['pending', 'failed'] as const)( native.getStatus.mockImplementation(() => state === 'failed' ? Promise.reject(new Error('unavailable')) : new Promise(() => {}) ) - await act(async () => - root.render( - - - - ) - ) + await act(async () => root.render()) const listener = native.onActivity.mock.calls[0]?.[0] if (!listener) throw new Error('Activity listener not registered') await act(async () => @@ -74,48 +57,3 @@ it.each(['pending', 'failed'] as const)( expect(container.querySelector('button')).toBeNull() } ) - -it.each(['active', 'completed'] as const)( - 'loads an existing %s action without waking the helper when rollout is off', - async (state) => { - const snapshot = createDeferred() - native.getActivity.mockReturnValue(snapshot.promise) - await act(async () => - root.render( - - - - ) - ) - if (state === 'completed') { - const listener = native.onActivity.mock.calls[0]?.[0] - if (!listener) throw new Error('Activity listener not registered') - await act(async () => listener(null)) - } - await act(async () => - snapshot.resolve({ - toolCallId: 'existing', - scopeId: 'chat', - action: 'click', - startedAt: Date.now(), - }) - ) - expect(native.getStatus).not.toHaveBeenCalled() - const stop = container.querySelector('button') - if (state === 'completed') expect(stop).toBeNull() - else { - expect(stop?.textContent).toContain('Stop') - await act(async () => stop?.click()) - expect(native.cancel).toHaveBeenCalledOnce() - } - } -) diff --git a/apps/sim/components/computer-use/activity.tsx b/apps/sim/components/computer-use/activity.tsx index 44502d4c9f9..384d8d0764f 100644 --- a/apps/sim/components/computer-use/activity.tsx +++ b/apps/sim/components/computer-use/activity.tsx @@ -19,7 +19,7 @@ const ACTION_LABELS: Record = { perform_action: 'Interacting', } -/** Remains visible while a native action is active, even if rollout or device access is revoked. */ +/** Remains visible while a native action is active, even if the permission group or device access revokes it. */ export function ComputerUseActivity() { const { activeAction: activity } = useComputerUseStatus() if (!activity) return null diff --git a/apps/sim/ee/access-requests/lib/targets.ts b/apps/sim/ee/access-requests/lib/targets.ts index be0cf1174ff..a34e87b2fe9 100644 --- a/apps/sim/ee/access-requests/lib/targets.ts +++ b/apps/sim/ee/access-requests/lib/targets.ts @@ -98,6 +98,7 @@ const FEATURE_PARENTS: Partial< disableKnowledgeBaseCreation: ['hideKnowledgeBaseTab'], disableKnowledgeBaseFileUpload: ['hideKnowledgeBaseTab'], disableKnowledgeBaseExport: ['hideKnowledgeBaseTab'], + disableComputerUse: ['hideCopilot'], disableTableCreation: ['hideTablesTab'], disableTableExport: ['hideTablesTab'], disableBulkFileDownload: ['hideFilesTab'], diff --git a/apps/sim/hooks/use-computer-use-status.ts b/apps/sim/hooks/use-computer-use-status.ts index a44fd9e69fb..7521354f4f4 100644 --- a/apps/sim/hooks/use-computer-use-status.ts +++ b/apps/sim/hooks/use-computer-use-status.ts @@ -1,11 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { ComputerUseStatus } from '@sim/desktop-bridge' import { getDesktopBridge } from '@/lib/desktop' -import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' /** Refresh native permissions when the user returns from System Settings and on activity changes. */ export function useComputerUseStatus() { - const enabled = useFeatureFlag('mothership-computer-use') const requestVersion = useRef(0) const activityVersion = useRef(0) const [status, setStatus] = useState | null>(null) @@ -21,7 +19,7 @@ export function useComputerUseStatus() { }, []) const refresh = useCallback(async () => { const bridge = getDesktopBridge()?.computerUse - if (!bridge || !enabled) return + if (!bridge) return const version = ++requestVersion.current try { const nextStatus = await bridge.getStatus() @@ -31,7 +29,7 @@ export function useComputerUseStatus() { if (version !== requestVersion.current) return setError(true) } - }, [enabled, updateStatus]) + }, [updateStatus]) useEffect(() => { const bridge = getDesktopBridge()?.computerUse if (!bridge) return @@ -51,19 +49,19 @@ export function useComputerUseStatus() { const onFocus = () => { void refresh() } - if (enabled) window.addEventListener('focus', onFocus) + window.addEventListener('focus', onFocus) return () => { requestVersion.current += 1 activityVersion.current += 1 unsubscribe() window.removeEventListener('focus', onFocus) } - }, [enabled, refresh]) + }, [refresh]) return { - status: enabled && status ? { ...status, activeAction } : null, + status: status ? { ...status, activeAction } : null, activeAction, setStatus: updateStatus, refresh, - error: enabled && error, + error, } } diff --git a/apps/sim/lib/api/contracts/computer-use.ts b/apps/sim/lib/api/contracts/computer-use.ts index 3c4737e6493..52f2730a46b 100644 --- a/apps/sim/lib/api/contracts/computer-use.ts +++ b/apps/sim/lib/api/contracts/computer-use.ts @@ -1,22 +1,7 @@ import { ComputerUseSchema } from '@sim/desktop-bridge/computer-use' import { z } from 'zod' import { desktopToolCallIdSchema } from '@/lib/api/contracts/desktop-tool-authorization' -import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' -import { type ContractQuery, defineRouteContract } from '@/lib/api/contracts/types' - -/** The chat owner whose rollout applies: exactly one of a workspace or an organization. */ -const computerUseAvailabilityQuerySchema = z.union([ - z.object({ workspaceId: workspaceIdSchema }).strict(), - z.object({ organizationId: organizationIdSchema }).strict(), -]) -const computerUseAvailabilityResponseSchema = z.object({ enabled: z.boolean() }).strict() -export const computerUseAvailabilityContract = defineRouteContract({ - method: 'GET', - path: '/api/desktop/computer/availability', - query: computerUseAvailabilityQuerySchema, - response: { mode: 'json', schema: computerUseAvailabilityResponseSchema }, -}) -export type ComputerUseOwner = ContractQuery +import { defineRouteContract } from '@/lib/api/contracts/types' const authorizeComputerUseBodySchema = z.object({ toolCallId: desktopToolCallIdSchema }).strict() const authorizeComputerUseResponseSchema = z diff --git a/apps/sim/lib/computer-use/application/authorize.test.ts b/apps/sim/lib/computer-use/application/authorize.test.ts index b4549ca00b4..51afc06f4a5 100644 --- a/apps/sim/lib/computer-use/application/authorize.test.ts +++ b/apps/sim/lib/computer-use/application/authorize.test.ts @@ -7,23 +7,26 @@ import { organizationAuthorizationMock, organizationAuthorizationMockFns, } from '@sim/testing/mocks/organization-authorization.mock' +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing/mocks/permission-group-scope.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ ownedChat: vi.fn(), - available: vi.fn(), claim: vi.fn(), })) vi.mock('@/lib/mothership/chat/application/context', () => ({ resolveOwnedChatContext: mocks.ownedChat, })) -vi.mock('@/lib/computer-use/availability.server', () => ({ - isComputerUseAvailable: mocks.available, -})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) vi.mock('@/lib/computer-use/repository', () => ({ claimComputerUseTool: mocks.claim })) import { authorizeComputerUse } from '@/lib/computer-use/application/authorize' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const const input = { toolCallId: 'call-1' } @@ -56,7 +59,7 @@ describe('native computer authorization', () => { mothershipAsyncRunsMockFns.mockGetRunSegment.mockResolvedValue(run) mocks.ownedChat.mockResolvedValue(context) workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('read') - mocks.available.mockResolvedValue(true) + resetPermissionGroupScopeMock() mocks.claim.mockResolvedValue({ args: { action: 'list_apps' } }) }) it('returns canonical arguments only after current chat access and an atomic claim', async () => { @@ -81,11 +84,20 @@ describe('native computer authorization', () => { ).rejects.toThrow() expect(mothershipAsyncRunsMockFns.mockGetAsyncToolCall).not.toHaveBeenCalled() }) - it('rejects disabled rollout without claiming an action', async () => { - mocks.available.mockResolvedValue(false) - await expect(authorizeComputerUse.execute({ principal, input })).rejects.toThrow('unavailable') - expect(mocks.claim).not.toHaveBeenCalled() - }) + it.each(['disableComputerUse', 'hideCopilot'] as const)( + 'rejects a permission group with %s without claiming an action', + async (key) => { + mocks.ownedChat.mockResolvedValue({ ...context, workspaceOrganizationId: 'org-1' }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + [key]: true, + }) + await expect(authorizeComputerUse.execute({ principal, input })).rejects.toThrow( + 'Computer use is not available' + ) + expect(mocks.claim).not.toHaveBeenCalled() + } + ) it('rechecks current workspace membership', async () => { workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue(null) await expect(authorizeComputerUse.execute({ principal, input })).rejects.toThrow() diff --git a/apps/sim/lib/computer-use/application/authorize.ts b/apps/sim/lib/computer-use/application/authorize.ts index 5ba96e12911..20f0e0f1f0a 100644 --- a/apps/sim/lib/computer-use/application/authorize.ts +++ b/apps/sim/lib/computer-use/application/authorize.ts @@ -1,6 +1,5 @@ import type { SessionPrincipal } from '@sim/auth/principal' import { ComputerUseSchema } from '@sim/desktop-bridge/computer-use' -import { isComputerUseAvailable } from '@/lib/computer-use/availability.server' import { claimComputerUseTool } from '@/lib/computer-use/repository' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' @@ -19,13 +18,13 @@ export const authorizeComputerUse = defineAuthorizedChatUseCase({ id: 'desktop.computer.execute', minimumRole: 'read', workspaceApiKey: 'deny', - capability: 'copilot.use', + capability: 'computer_use.use', principalKinds: ['session'], }), organizationOperation: defineOrganizationOperation({ id: 'desktop.computer.execute', minimumRole: 'member', - capability: 'copilot.use', + capability: 'computer_use.use', principalKinds: ['session'], }), async resolveContext({ @@ -60,8 +59,6 @@ export const authorizeComputerUse = defineAuthorizedChatUseCase({ }, authorizationOptions: {}, async execute({ principal, context, input }) { - if (!(await isComputerUseAvailable(context.organizationId ?? context.workspaceOrganizationId))) - throw new OrchestrationError('forbidden', 'Computer use is unavailable on this deployment') const claimed = await claimComputerUseTool({ toolCallId: input.toolCallId, runId: context.runId, diff --git a/apps/sim/lib/computer-use/application/availability.ts b/apps/sim/lib/computer-use/application/availability.ts index 36e84c9c3a4..370c4c02d53 100644 --- a/apps/sim/lib/computer-use/application/availability.ts +++ b/apps/sim/lib/computer-use/application/availability.ts @@ -1,17 +1,14 @@ import type { Principal } from '@sim/auth/principal' -import type { ComputerUseOwner } from '@/lib/api/contracts/computer-use' -import { isComputerUseAvailable } from '@/lib/computer-use/availability.server' import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application' import { defineAuthorizedOrganizationUseCase } from '@/lib/core/application/authorized-organization-use-case' -import { - assertOperationPrincipal, - defineOperation, - type OperationUseCase, -} from '@/lib/core/application/operation' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { + isOrganizationCapabilityWithheld, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context' -/** permission-group-exempt: reports the rollout for a workspace the caller can read; actions require chat authorization. */ +/** permission-group-exempt: reports whether computer_use.use is withheld instead of refusing; actions declare it. */ const workspaceAvailabilityOperation = defineWorkspaceOperation({ id: 'desktop.computer.availability', minimumRole: 'read', @@ -20,7 +17,7 @@ const workspaceAvailabilityOperation = defineWorkspaceOperation({ principalKinds: ['session'], }) -/** permission-group-exempt: reports the rollout for an organization the caller belongs to; actions require chat authorization. */ +/** permission-group-exempt: reports whether computer_use.use is withheld instead of refusing; actions declare it. */ const organizationAvailabilityOperation = defineOrganizationOperation({ id: 'desktop.computer.availability', minimumRole: 'member', @@ -33,19 +30,29 @@ const workspaceAvailability = defineAuthorizedWorkspaceUseCase({ resolveContext: ({ input }: { input: { workspaceId: string } }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), authorizationOptions: {}, - execute: ({ context }) => isComputerUseAvailable(context.workspaceOrganizationId), + execute: async ({ principal, input, context }) => + // permission-group-enforced: computer_use.use — an availability read answers false rather than 403 + !(await isWorkspaceCapabilityWithheld( + principal.userId, + input.workspaceId, + 'computer_use.use', + context.workspaceOrganizationId + )), }) const organizationAvailability = defineAuthorizedOrganizationUseCase({ operation: organizationAvailabilityOperation, - execute: ({ input }: { input: { organizationId: string } }) => - isComputerUseAvailable(input.organizationId), + execute: async ({ input }: { input: { organizationId: string } }) => + // permission-group-enforced: computer_use.use — an availability read answers false rather than 403 + !(await isOrganizationCapabilityWithheld(input.organizationId, 'computer_use.use')), }) +/** The chat whose permission group applies: exactly one of a workspace or an organization. */ +type ComputerUseOwner = { workspaceId: string } | { organizationId: string } + /** - * Whether computer use is rolled out for a chat's owner: an organization chat follows its - * organization, a workspace chat its workspace's organization, and a personal workspace the - * global switch. + * Whether the chat owner's permission group allows computer use: an organization chat follows + * its organization's default group, a workspace chat the group governing that workspace. */ export function isComputerUseAvailableFor( principal: Principal, @@ -55,23 +62,3 @@ export function isComputerUseAvailableFor( ? organizationAvailability.execute({ principal, input: owner }) : workspaceAvailability.execute({ principal, input: owner }) } - -/** permission-group-exempt: dispatches to the owner's authorized rollout read above. */ -const availabilityOperation = defineOperation({ - id: 'desktop.computer.availability', - capability: 'none', - principalKinds: ['session'], -}) - -/** The desktop's rollout read before it touches the native helper; no device or app data leaves the desktop. */ -export const readComputerUseAvailability: OperationUseCase< - typeof availabilityOperation, - ComputerUseOwner, - { enabled: boolean } -> = { - operation: availabilityOperation, - async execute({ principal, input }) { - assertOperationPrincipal(principal, availabilityOperation) - return { enabled: await isComputerUseAvailableFor(principal, input) } - }, -} diff --git a/apps/sim/lib/computer-use/availability.server.ts b/apps/sim/lib/computer-use/availability.server.ts deleted file mode 100644 index c42a84fb23d..00000000000 --- a/apps/sim/lib/computer-use/availability.server.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' - -/** Computer use rolls out by organization; personal workspaces follow the global switch. */ -export function isComputerUseAvailable(orgId?: string | null): Promise { - return isFeatureEnabled('mothership-computer-use', orgId ? { orgId } : {}) -} diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 5aae00c5a09..b87c640130d 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -618,7 +618,6 @@ export const env = createEnv({ AGENTMAIL_DOMAIN: z.string().optional(), // Custom domain for AgentMail inboxes (default: agentmail.to) DASHBOARDS: z.boolean().optional(), PROJECTS: z.boolean().optional(), // Fallback for the `projects` feature flag off AppConfig - MSHIP_COMPUTER_USE: z.boolean().optional(), WORKFLOW_TESTS: z.boolean().optional(), // Fallback for the `workflow-tests` feature flag off AppConfig CHANGELOG: z.boolean().optional(), // Fallback for the `changelog` feature flag off AppConfig SIM_LINKS: z.boolean().optional(), // Fallback for the `sim-links` feature flag off AppConfig diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 881de5a4fca..4a7295d4440 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -41,7 +41,6 @@ setEnv({ APPCONFIG_ENVIRONMENT: 'staging', DASHBOARDS: undefined, MSHIP_MODEL_SELECTOR: undefined, - MSHIP_COMPUTER_USE: undefined, AGENT_MEMORY_HISTORY: undefined, KNOWLEDGE_MEMBER_ACCESS: undefined, SLACK_SEARCH_SHARED_APP: undefined, @@ -64,16 +63,16 @@ const enabled = (flag: string, ctx?: FeatureFlagContext) => afterAll(resetEnvFlagsMock) describe('getFeatureFlags', () => { - it('gates computer use globally and defaults off without AppConfig', async () => { - withAppConfig({ 'mothership-computer-use': { enabled: true } }) - expect(await isFeatureEnabled('mothership-computer-use')).toBe(true) - withAppConfig({ 'mothership-computer-use': { enabled: false, userIds: ['user-1'] } }) - expect(await isFeatureEnabled('mothership-computer-use')).toBe(false) + it('gates a flag globally and defaults off without AppConfig', async () => { + withAppConfig({ dashboards: { enabled: true } }) + expect(await isFeatureEnabled('dashboards')).toBe(true) + withAppConfig({ dashboards: { enabled: false, userIds: ['user-1'] } }) + expect(await isFeatureEnabled('dashboards')).toBe(false) setEnvFlags({ isAppConfigEnabled: false }) - expect(await isFeatureEnabled('mothership-computer-use')).toBe(false) - envRef.MSHIP_COMPUTER_USE = true - expect(await isFeatureEnabled('mothership-computer-use')).toBe(true) - envRef.MSHIP_COMPUTER_USE = undefined + expect(await isFeatureEnabled('dashboards')).toBe(false) + envRef.DASHBOARDS = true + expect(await isFeatureEnabled('dashboards')).toBe(true) + envRef.DASHBOARDS = undefined }) beforeEach(() => { diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 5ebab319615..f2c79eb2d10 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -51,12 +51,6 @@ const FEATURE_FLAGS = { 'Enable dashboard resources, rendering, analytics, and Mothership authoring. Supports global and organization rollout; disabled by default.', fallback: 'DASHBOARDS', }, - 'mothership-computer-use': { - description: - 'Enable native macOS computer use in Mothership. Supports global and organization rollout; ' + - 'personal workspaces follow the global switch. Each device must also opt in.', - fallback: 'MSHIP_COMPUTER_USE', - }, 'workflow-tests': { description: 'Enable workflow tests: the workspace Tests page, tests/.test.js files, and the Sim tests tool. Supports global and organization rollout; disabled by default.', diff --git a/apps/sim/lib/desktop/index.test.ts b/apps/sim/lib/desktop/index.test.ts index 1a7436c7e83..d959b8b1c20 100644 --- a/apps/sim/lib/desktop/index.test.ts +++ b/apps/sim/lib/desktop/index.test.ts @@ -1,13 +1,4 @@ -import { createDeferred } from '@sim/testing/helpers/deferred' -import { - apiClientRequestMock, - apiClientRequestMockFns, -} from '@sim/testing/mocks/api-client-request.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/api/client/request', () => apiClientRequestMock) -const requestAvailability = apiClientRequestMockFns.mockRequestJson - import { getDesktopChatCapabilities, hasBrowserAgent, @@ -39,13 +30,12 @@ function installBridge(value: unknown): void { describe('desktop surface availability', () => { it.each([ - [true, true, true, true], - [true, true, false, false], - [true, false, true, false], - [false, true, true, false], + [true, true, true], + [true, false, false], + [false, true, false], ])( - 'advertises computer use only for a supported enabled device and server rollout (%s,%s,%s)', - async (supported, enabled, rollout, expected) => { + 'advertises computer use only for a supported, opted-in device (%s,%s)', + async (supported, enabled, expected) => { installBridge({ computerUse: { getStatus: vi.fn(async () => ({ @@ -61,62 +51,23 @@ describe('desktop surface availability', () => { browserEnabled: false, terminalEnabled: false, }) - requestAvailability.mockResolvedValueOnce({ enabled: rollout }) - const result = await getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' }) + const result = await getDesktopChatCapabilities('chat-1') expect(result.desktopCapabilities?.computerUse ?? false).toBe(expected) } ) - it('leaves ordinary chat capabilities independent of computer use while rollout is off', async () => { - const getStatus = vi.fn(() => new Promise(() => {})) - installBridge({ - computerUse: { getStatus }, - localFiles: vi.fn(), - terminal: { getTabs: vi.fn(async () => ({ tabs: [] })) }, - browserAgent: { getKnownSessions: vi.fn(async () => ({ sessions: [] })) }, - }) - requestAvailability.mockResolvedValue({ enabled: false }) - const result = await getDesktopChatCapabilities('chat-1', null) - expect(requestAvailability).not.toHaveBeenCalled() - expect(getStatus).not.toHaveBeenCalled() - expect(result.desktopCapabilities).toMatchObject({ - localFiles: true, - browser: true, - terminal: true, - }) - }) - it('does not touch native computer use before the current rollout is confirmed', async () => { - const rollout = createDeferred<{ enabled: boolean }>() - const getStatus = vi.fn(async () => ({ supported: true, enabled: true })) - installBridge({ computerUse: { getStatus } }) - setDesktopPreferencesSnapshot({ - ...ENABLED_PREFERENCES, - browserEnabled: false, - terminalEnabled: false, + it('fails closed when the native status cannot be read', async () => { + installBridge({ + computerUse: { getStatus: vi.fn(async () => Promise.reject(new Error('gone'))) }, }) - requestAvailability.mockReturnValueOnce(rollout.promise) - const result = getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' }) - await vi.waitFor(() => expect(requestAvailability).toHaveBeenCalledOnce()) - expect(getStatus).not.toHaveBeenCalled() - rollout.resolve({ enabled: false }) - expect((await result).desktopCapabilities?.computerUse).toBeUndefined() - expect(getStatus).not.toHaveBeenCalled() - }) - - it('fails closed when the computer rollout cannot be resolved', async () => { - const getStatus = vi.fn(async () => ({ supported: true, enabled: true })) - installBridge({ computerUse: { getStatus } }) setDesktopPreferencesSnapshot({ ...ENABLED_PREFERENCES, browserEnabled: false, terminalEnabled: false, }) - requestAvailability.mockRejectedValueOnce(new Error('offline')) expect( - (await getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' })).desktopCapabilities - ?.computerUse + (await getDesktopChatCapabilities('chat-1')).desktopCapabilities?.computerUse ).toBeUndefined() - expect(getStatus).not.toHaveBeenCalled() }) beforeEach(() => { @@ -144,12 +95,12 @@ describe('desktop surface availability', () => { browserEnabled: false, terminalEnabled: false, }) - expect(await getDesktopChatCapabilities('org-chat', null)).toMatchObject({ + expect(await getDesktopChatCapabilities('org-chat')).toMatchObject({ desktopCapabilities: { localFiles: true }, }) installBridge({}) expect( - (await getDesktopChatCapabilities('org-chat', null)).desktopCapabilities?.localFiles + (await getDesktopChatCapabilities('org-chat')).desktopCapabilities?.localFiles ).toBeUndefined() }) @@ -161,9 +112,7 @@ describe('desktop surface availability', () => { }) const offerFrom = async (bridge: Record) => { installBridge({ localFiles: vi.fn(), ...bridge }) - const { desktopCapabilities } = await getDesktopChatCapabilities('chat-1', { - workspaceId: 'ws-1', - }) + const { desktopCapabilities } = await getDesktopChatCapabilities('chat-1') return { deviceId: desktopCapabilities?.deviceId, executor: desktopCapabilities?.executor } } const unset = { deviceId: undefined, executor: undefined } @@ -211,7 +160,7 @@ describe('desktop surface availability', () => { browserEnabled: false, }) - const capabilities = await getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' }) + const capabilities = await getDesktopChatCapabilities('chat-1') expect(capabilities.desktopCapabilities?.terminals).toEqual([ { diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts index 44ce2c97b81..307c72b9a75 100644 --- a/apps/sim/lib/desktop/index.ts +++ b/apps/sim/lib/desktop/index.ts @@ -24,11 +24,6 @@ import type { BrowserKnownSession } from '@sim/browser-protocol' import type { DesktopPreferences, SimDesktopApi } from '@sim/desktop-bridge' import { truncate } from '@sim/utils/string' -import { requestJson } from '@/lib/api/client/request' -import { - type ComputerUseOwner, - computerUseAvailabilityContract, -} from '@/lib/api/contracts/computer-use' import { DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH, DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH, @@ -179,8 +174,7 @@ export interface DesktopChatCapabilities { * in a plain web browser the model never sees the features. */ export async function getDesktopChatCapabilities( - scopeId: string, - computerUseOwner: ComputerUseOwner | null + scopeId: string ): Promise { const bridge = getDesktopBridge() // Never advertise a surface the user switched off, even on the first @@ -190,19 +184,13 @@ export async function getDesktopChatCapabilities( const localFilesystem = hasLocalFilesystem() const browser = isBrowserAgentEnabled() const terminal = isTerminalEnabled() - const computerUse = - computerUseOwner && bridge?.computerUse - ? await requestJson(computerUseAvailabilityContract, { - query: computerUseOwner, - signal: AbortSignal.timeout(5000), - }) - .then(async (rollout) => { - if (!rollout.enabled) return false - const status = await bridge.computerUse?.getStatus() - return status?.supported === true && status.enabled - }) - .catch(() => false) - : false + // Device state only; the server decides whether the chat's permission group allows it. + const computerUse = bridge?.computerUse + ? await bridge.computerUse + .getStatus() + .then((status) => status.supported && status.enabled) + .catch(() => false) + : false // Sent every request so the agent knows what is already running without // spending a tool call to ask — and, more importantly, so it notices a // terminal that is occupied instead of launching a second copy into it. diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts index ab3743d91a2..d5c921c8faf 100644 --- a/apps/sim/lib/permission-groups/capabilities.ts +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -59,6 +59,7 @@ export const CAPABILITY_IDS = [ 'sandboxes.use', 'knowledge.export', 'project_issues.use', + 'computer_use.use', ] as const export type PermissionGroupCapability = (typeof CAPABILITY_IDS)[number] @@ -440,6 +441,14 @@ export const CAPABILITY_RULES = { deniedBy: (config: PermissionGroupConfig, projectId: string) => config.deniedPartialAccessProjectIssues.includes(projectId), }, + /** Subsumes `copilot.use`: only Chat issues computer actions. */ + 'computer_use.use': { + kind: 'static', + configKeys: ['disableComputerUse', 'hideCopilot'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Computer use', + deniedBy: (config) => config.disableComputerUse || config.hideCopilot, + }, } satisfies { readonly [K in PermissionGroupCapability]: CapabilityRule } /** diff --git a/apps/sim/lib/permission-groups/fields.test.ts b/apps/sim/lib/permission-groups/fields.test.ts index 40248a40220..bc45e361939 100644 --- a/apps/sim/lib/permission-groups/fields.test.ts +++ b/apps/sim/lib/permission-groups/fields.test.ts @@ -165,6 +165,7 @@ const fixtures: readonly CoercionFixture[] = [ disableKnowledgeBaseExport: true, deniedPartialAccessProjectIssues: ['project-a'], deniedAccessRequests: ['feature:hideTablesTab'], + disableComputerUse: true, }, expected: { allowedIntegrations: ['slack_v2'], @@ -211,6 +212,7 @@ const fixtures: readonly CoercionFixture[] = [ disableKnowledgeBaseExport: true, deniedPartialAccessProjectIssues: ['project-a'], deniedAccessRequests: ['feature:hideTablesTab'], + disableComputerUse: true, defaultAgentModel: null, }, }, diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts index c3ae2521abf..fd157d7e2bb 100644 --- a/apps/sim/lib/permission-groups/fields.ts +++ b/apps/sim/lib/permission-groups/fields.ts @@ -533,6 +533,13 @@ export const PERMISSION_GROUP_FIELDS = { 'request', 'Access requests are unavailable for the listed canonical target keys when access is restricted.' ), + disableComputerUse: booleanRestriction('capability', { + scope: 'workspace-or-organization', + id: 'disable-computer-use', + label: 'Computer Use', + category: 'Tools', + hint: "Revoke computer use. Chat cannot operate apps on a member's Mac, even when the device allows it.", + }), } satisfies Record export type PermissionGroupFields = typeof PERMISSION_GROUP_FIELDS diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index cc633376b8f..43e63a5150a 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1063,6 +1063,7 @@ type CancelOrganizationAccessRequestResponseRef0 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -1436,6 +1437,7 @@ type CancelWorkspaceAccessRequestResponseRef0 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -2806,6 +2808,7 @@ export type CreateOrganizationAccessRequestBody = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -2885,6 +2888,7 @@ type CreateOrganizationAccessRequestResponseRef0 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -3020,6 +3024,7 @@ export type CreatePermissionGroupBody = { deniedPartialAccessProjectIssues?: Array defaultAgentModel?: string | null deniedAccessRequests?: Array + disableComputerUse?: boolean } isDefault?: boolean workspaceIds?: Array @@ -3076,6 +3081,7 @@ type CreatePermissionGroupResponseRef0 = { deniedPartialAccessProjectIssues: Array defaultAgentModel: string | null deniedAccessRequests: Array + disableComputerUse: boolean } isDefault: boolean membershipMode: string @@ -4109,6 +4115,7 @@ export type CreateWorkspaceAccessRequestBody = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -4188,6 +4195,7 @@ type CreateWorkspaceAccessRequestResponseRef0 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -5243,6 +5251,7 @@ type DiscoverOrganizationAccessRequestsResponseRef0 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -5351,6 +5360,7 @@ type DiscoverWorkspaceAccessRequestsResponseRef0 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -6921,6 +6931,7 @@ type GetPermissionGroupResponseRef0 = { deniedPartialAccessProjectIssues: Array defaultAgentModel: string | null deniedAccessRequests: Array + disableComputerUse: boolean } isDefault: boolean membershipMode: string @@ -8393,6 +8404,7 @@ type GetWorkspacePermissionConfigResponseRef0 = { deniedPartialAccessProjectIssues: Array defaultAgentModel: string | null deniedAccessRequests: Array + disableComputerUse: boolean } | null entitled: boolean organizationId: string | null @@ -9578,6 +9590,7 @@ type ListMyOrganizationAccessRequestsResponseRef0 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -9686,6 +9699,7 @@ type ListMyWorkspaceAccessRequestsResponseRef0 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -9795,6 +9809,7 @@ type ListOrganizationAccessRequestsResponseRef0 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -10198,6 +10213,7 @@ type ListPermissionGroupsResponseRef0 = { deniedPartialAccessProjectIssues: Array defaultAgentModel: string | null deniedAccessRequests: Array + disableComputerUse: boolean } isDefault: boolean membershipMode: string @@ -11456,6 +11472,7 @@ type PreviewOrganizationAccessRequestResponseRef0 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -11547,6 +11564,7 @@ type PreviewOrganizationAccessRequestResponseRef0 = { | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' | 'deniedPartialAccessProjectIssues' + | 'disableComputerUse' label: string before: boolean | Array | null after: boolean | Array | null @@ -11613,6 +11631,7 @@ type PreviewOrganizationAccessRequestResponseRef1 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -11704,6 +11723,7 @@ type PreviewOrganizationAccessRequestResponseRef1 = { | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' | 'deniedPartialAccessProjectIssues' + | 'disableComputerUse' label: string before: boolean | Array | null after: boolean | Array | null @@ -13390,6 +13410,7 @@ type ResolveOrganizationAccessRequestResponseRef0 = { | 'hideSandboxesTab' | 'disableOAuthAppAccess' | 'disableKnowledgeBaseExport' + | 'disableComputerUse' } | { kind: 'integration' @@ -15160,6 +15181,7 @@ export type UpdatePermissionGroupBody = { deniedPartialAccessProjectIssues?: Array defaultAgentModel?: string | null deniedAccessRequests?: Array + disableComputerUse?: boolean } isDefault?: boolean workspaceIds?: Array @@ -15216,6 +15238,7 @@ type UpdatePermissionGroupResponseRef0 = { deniedPartialAccessProjectIssues: Array defaultAgentModel: string | null deniedAccessRequests: Array + disableComputerUse: boolean } isDefault: boolean membershipMode: string From 9d1f891edddc994d3890e9a2cd45de690a7ae6d3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 12:01:42 -0800 Subject: [PATCH 2/4] fix(computer-use): hide device settings until the permission group is known Also gives the local-files e2e settings fixture a QueryClient now that the Desktop page reads the permission config. --- apps/desktop/e2e/local-files.spec.ts | 5 +++-- .../components/desktop/computer-use.test.tsx | 12 ++++++++++-- .../settings/components/desktop/computer-use.tsx | 3 ++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/desktop/e2e/local-files.spec.ts b/apps/desktop/e2e/local-files.spec.ts index 68d8c1a1274..a00fe40cf40 100644 --- a/apps/desktop/e2e/local-files.spec.ts +++ b/apps/desktop/e2e/local-files.spec.ts @@ -60,6 +60,7 @@ test('native file tools remember folder consent across chats and restarts until stdin: { contents: `import { createRoot } from 'react-dom/client'; import { ToastProvider } from '@sim/emcn'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'; import { PathParamsContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime'; import { Desktop } from '@/app/workspace/[workspaceId]/settings/components/desktop/desktop'; @@ -70,11 +71,11 @@ const router = { bfcacheId: 'fixture', back: () => history.back(), forward: () = createRoot(document.getElementById('settings')).render( - + - + );`, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx index 90d2184c1e8..5eb128fff44 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx @@ -20,9 +20,9 @@ const native = vi.hoisted(() => ({ cancel: vi.fn<() => Promise>(), onActivity: vi.fn<(listener: (activity: ComputerUseActivity | null) => void) => () => void>(), })) -const permission = vi.hoisted(() => ({ config: {} as Record })) +const permission = vi.hoisted(() => ({ config: {} as Record, ready: true })) vi.mock('@/hooks/use-permission-config', () => ({ - usePermissionConfig: () => ({ config: permission.config }), + usePermissionConfig: () => ({ config: permission.config, isPermissionReady: permission.ready }), })) vi.mock('@/lib/desktop', () => libDesktopMock) @@ -38,6 +38,7 @@ let container: HTMLDivElement beforeEach(() => { permission.config = { ...DEFAULT_PERMISSION_GROUP_CONFIG } + permission.ready = true permissions = [...initialPermissions] activity = null listeners = new Set() @@ -85,6 +86,13 @@ it.each(['disableComputerUse', 'hideCopilot'])( } ) +it('withholds the device controls until the permission read succeeds', async () => { + permission.ready = false + await act(async () => renderSettings()) + expect(appRow('com.example.First')).toBeUndefined() + expect(container.querySelector('[role="switch"]')).toBeNull() +}) + function appRow(bundleId: string) { return container.querySelector(`[title="${bundleId}"]`)?.parentElement } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx index 33251dafa23..4ca2a539583 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx @@ -22,7 +22,7 @@ export function ComputerUseSettings() { function ComputerUseSettingsControls() { const requestVersion = useRef(0) const bridge = getDesktopBridge()?.computerUse - const { config: permissionConfig } = usePermissionConfig() + const { config: permissionConfig, isPermissionReady } = usePermissionConfig() const { status, setStatus, refresh, error } = useComputerUseStatus() const [apps, setApps] = useState([]) const [pending, setPending] = useState(false) @@ -44,6 +44,7 @@ function ComputerUseSettingsControls() { }, [refreshApps, status?.activeAction]) if ( !bridge || + !isPermissionReady || CAPABILITY_RULES['computer_use.use'].deniedBy(permissionConfig) || status?.supported === false ) From dc5a9df06bac3f2784811e1b5cbf102b4ec2e608 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 12:14:13 -0800 Subject: [PATCH 3/4] fix(computer-use): read the permission group with the lightweight query hook usePermissionConfig pulls the block and provider registries into the Desktop settings bundle; the settings gate only needs the user's resolved config. --- .../components/desktop/computer-use.test.tsx | 15 +++++++++++---- .../settings/components/desktop/computer-use.tsx | 11 +++++++---- .../settings/components/desktop/desktop.test.tsx | 12 +++++++++--- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx index 5eb128fff44..74f1217e983 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx @@ -20,10 +20,17 @@ const native = vi.hoisted(() => ({ cancel: vi.fn<() => Promise>(), onActivity: vi.fn<(listener: (activity: ComputerUseActivity | null) => void) => () => void>(), })) -const permission = vi.hoisted(() => ({ config: {} as Record, ready: true })) -vi.mock('@/hooks/use-permission-config', () => ({ - usePermissionConfig: () => ({ config: permission.config, isPermissionReady: permission.ready }), +const permission = vi.hoisted(() => ({ + config: null as Record | null, + ready: true, })) +vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ + useUserPermissionConfig: () => + permission.ready + ? { isSuccess: true, data: { config: permission.config } } + : { isSuccess: false, data: undefined }, +})) +vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'ws-1' }) })) vi.mock('@/lib/desktop', () => libDesktopMock) const initialPermissions = [ @@ -37,7 +44,7 @@ let root: Root let container: HTMLDivElement beforeEach(() => { - permission.config = { ...DEFAULT_PERMISSION_GROUP_CONFIG } + permission.config = null permission.ready = true permissions = [...initialPermissions] activity = null diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx index 4ca2a539583..0a0ae7d1e9a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx @@ -3,12 +3,13 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { ComputerUseAppPermission } from '@sim/desktop-bridge' import { Chip, ChipSwitch, Label, toast } from '@sim/emcn' +import { useParams } from 'next/navigation' import { ComputerUseActivity } from '@/components/computer-use/activity' import { getDesktopBridge } from '@/lib/desktop' import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' import { useComputerUseStatus } from '@/hooks/use-computer-use-status' -import { usePermissionConfig } from '@/hooks/use-permission-config' export function ComputerUseSettings() { return ( @@ -22,7 +23,8 @@ export function ComputerUseSettings() { function ComputerUseSettingsControls() { const requestVersion = useRef(0) const bridge = getDesktopBridge()?.computerUse - const { config: permissionConfig, isPermissionReady } = usePermissionConfig() + const { workspaceId } = useParams<{ workspaceId: string }>() + const permission = useUserPermissionConfig(workspaceId) const { status, setStatus, refresh, error } = useComputerUseStatus() const [apps, setApps] = useState([]) const [pending, setPending] = useState(false) @@ -44,8 +46,9 @@ function ComputerUseSettingsControls() { }, [refreshApps, status?.activeAction]) if ( !bridge || - !isPermissionReady || - CAPABILITY_RULES['computer_use.use'].deniedBy(permissionConfig) || + !permission.isSuccess || + (permission.data.config !== null && + CAPABILITY_RULES['computer_use.use'].deniedBy(permission.data.config)) || status?.supported === false ) return null diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx index 09da50149b7..c847090771d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.test.tsx @@ -8,9 +8,15 @@ import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -const permission = vi.hoisted(() => ({ config: {} as Record })) -vi.mock('@/hooks/use-permission-config', () => ({ - usePermissionConfig: () => ({ config: permission.config }), +const permission = vi.hoisted(() => ({ + config: null as Record | null, + ready: true, +})) +vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ + useUserPermissionConfig: () => + permission.ready + ? { isSuccess: true, data: { config: permission.config } } + : { isSuccess: false, data: undefined }, })) vi.mock('@sim/emcn', () => ({ ...emcnMock, From aca76461da7a935b8c4e53669976ca6cc056a230 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 12:27:25 -0800 Subject: [PATCH 4/4] fix(computer-use): mount the device controls only once the permission group allows them Their effects no longer call the native helper for loading or restricted members. --- .../components/desktop/computer-use.test.tsx | 8 +++++++- .../components/desktop/computer-use.tsx | 20 +++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx index 74f1217e983..6c03a168096 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.test.tsx @@ -8,6 +8,7 @@ import type { import { toast } from '@sim/emcn' import { createDeferred } from '@sim/testing/helpers/deferred' import { libDesktopMock, libDesktopMockFns } from '@sim/testing/mocks/lib-desktop.mock' +import { nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' @@ -30,7 +31,10 @@ vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ ? { isSuccess: true, data: { config: permission.config } } : { isSuccess: false, data: undefined }, })) -vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'ws-1' }) })) +vi.mock( + 'next/navigation', + async () => (await import('@sim/testing/mocks/next-navigation.mock')).nextNavigationMock +) vi.mock('@/lib/desktop', () => libDesktopMock) const initialPermissions = [ @@ -46,6 +50,7 @@ let container: HTMLDivElement beforeEach(() => { permission.config = null permission.ready = true + nextNavigationMockFns.mockUseParams.mockReturnValue({ workspaceId: 'ws-1' }) permissions = [...initialPermissions] activity = null listeners = new Set() @@ -98,6 +103,7 @@ it('withholds the device controls until the permission read succeeds', async () await act(async () => renderSettings()) expect(appRow('com.example.First')).toBeUndefined() expect(container.querySelector('[role="switch"]')).toBeNull() + expect(native.listAppPermissions).not.toHaveBeenCalled() }) function appRow(bundleId: string) { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx index 0a0ae7d1e9a..af632d25f50 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/computer-use.tsx @@ -11,11 +11,18 @@ import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/componen import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' import { useComputerUseStatus } from '@/hooks/use-computer-use-status' +/** The activity stays visible so an in-flight action can be stopped; the device controls need the permission group's consent. */ export function ComputerUseSettings() { + const { workspaceId } = useParams<{ workspaceId: string }>() + const permission = useUserPermissionConfig(workspaceId) + const allowed = + permission.isSuccess && + (permission.data.config === null || + !CAPABILITY_RULES['computer_use.use'].deniedBy(permission.data.config)) return ( <> - + {allowed && } ) } @@ -23,8 +30,6 @@ export function ComputerUseSettings() { function ComputerUseSettingsControls() { const requestVersion = useRef(0) const bridge = getDesktopBridge()?.computerUse - const { workspaceId } = useParams<{ workspaceId: string }>() - const permission = useUserPermissionConfig(workspaceId) const { status, setStatus, refresh, error } = useComputerUseStatus() const [apps, setApps] = useState([]) const [pending, setPending] = useState(false) @@ -44,14 +49,7 @@ function ComputerUseSettingsControls() { requestVersion.current += 1 } }, [refreshApps, status?.activeAction]) - if ( - !bridge || - !permission.isSuccess || - (permission.data.config !== null && - CAPABILITY_RULES['computer_use.use'].deniedBy(permission.data.config)) || - status?.supported === false - ) - return null + if (!bridge || status?.supported === false) return null const update = async (action: () => Promise) => { setPending(true) try {