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/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/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 +21,20 @@ const native = vi.hoisted(() => ({
cancel: vi.fn<() => Promise>(),
onActivity: vi.fn<(listener: (activity: ComputerUseActivity | null) => void) => () => void>(),
}))
+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',
+ async () => (await import('@sim/testing/mocks/next-navigation.mock')).nextNavigationMock
+)
vi.mock('@/lib/desktop', () => libDesktopMock)
const initialPermissions = [
@@ -29,12 +44,13 @@ 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 = null
+ permission.ready = true
+ nextNavigationMockFns.mockUseParams.mockReturnValue({ workspaceId: 'ws-1' })
permissions = [...initialPermissions]
activity = null
listeners = new Set()
@@ -69,52 +85,25 @@ 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('')
+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()
+ }
+)
- enabled = true
+it('withholds the device controls until the permission read succeeds', async () => {
+ permission.ready = false
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(appRow('com.example.First')).toBeUndefined()
+ expect(container.querySelector('[role="switch"]')).toBeNull()
expect(native.listAppPermissions).not.toHaveBeenCalled()
- const stop = container.querySelector('button')
- expect(stop?.textContent).toContain('Stop')
- await act(async () => stop?.click())
- expect(native.cancel).toHaveBeenCalledOnce()
})
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 4d38a5241ec..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
@@ -3,17 +3,26 @@
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 { 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 { 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 && }
>
)
}
@@ -21,7 +30,6 @@ export function ComputerUseSettings() {
function ComputerUseSettingsControls() {
const requestVersion = useRef(0)
const bridge = getDesktopBridge()?.computerUse
- const enabled = useFeatureFlag('mothership-computer-use')
const { status, setStatus, refresh, error } = useComputerUseStatus()
const [apps, setApps] = useState([])
const [pending, setPending] = useState(false)
@@ -36,13 +44,12 @@ 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 || 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..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,6 +8,16 @@ 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: 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,
Label: ({ children, htmlFor }: { children?: ReactNode; htmlFor?: string }) => (
@@ -66,7 +76,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