Skip to content

Commit 9f7ca62

Browse files
authored
improvement(computer-use): roll out computer use per organization (#9182)
* improvement(computer-use): roll out computer use per organization * test(computer-use): drop assertions that restate mocked rollout calls
1 parent d195710 commit 9f7ca62

13 files changed

Lines changed: 120 additions & 33 deletions

File tree

‎apps/sim/app/api/desktop/computer/availability/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ export const GET = defineInternalJsonRoute({
1212
auth: internalSessionAuth,
1313
operation: readComputerUseAvailability.operation,
1414
rateLimit: internalRateLimits.none({
15-
reason: 'Read-only rollout switch used on each desktop chat admission.',
15+
reason: 'Read-only rollout check used on each desktop chat admission.',
1616
}),
1717
errorPolicy: internalOrchestrationErrorPolicy,
18-
mapInput: () => undefined,
18+
mapInput: ({ query }) => query,
1919
useCase: readComputerUseAvailability,
2020
present: (result) => result,
2121
})

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export default async function OrganizationLayout({
8888
logger.warn('Could not resolve benchmark navigation access')
8989
return false
9090
}),
91-
isComputerUseAvailable(),
91+
isComputerUseAvailable(organizationId),
9292
isWorkflowTestsEnabled(organizationId),
9393
isChangelogEnabled(organizationId),
9494
isProjectsEnabled(),

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3929,7 +3929,16 @@ export function useChat(
39293929
const desktopChatCapabilities =
39303930
options?.requestMode === 'assistant'
39313931
? {}
3932-
: await getDesktopChatCapabilities(desktopScopeIdRef.current, computerUseEnabled)
3932+
: await getDesktopChatCapabilities(
3933+
desktopScopeIdRef.current,
3934+
!computerUseEnabled
3935+
? null
3936+
: organizationId
3937+
? { organizationId }
3938+
: workspaceId
3939+
? { workspaceId }
3940+
: null
3941+
)
39333942

39343943
admissionUnknown = true
39353944
/** A reload from here on may find the server holding this id. */

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ async function WorkspaceContent({
150150
isWorkflowTestsEnabled(hostContext.hostOrganizationId),
151151
isChangelogEnabled(hostContext.hostOrganizationId),
152152
hasDesktopBackgroundExecutor(session.user.id),
153-
isComputerUseAvailable(),
153+
isComputerUseAvailable(hostContext.hostOrganizationId),
154154
isProjectsEnabled(),
155155
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
156156
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),

‎apps/sim/lib/api/contracts/computer-use.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import { ComputerUseSchema } from '@sim/desktop-bridge/computer-use'
22
import { z } from 'zod'
33
import { desktopToolCallIdSchema } from '@/lib/api/contracts/desktop-tool-authorization'
4-
import { defineRouteContract } from '@/lib/api/contracts/types'
4+
import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
5+
import { type ContractQuery, defineRouteContract } from '@/lib/api/contracts/types'
56

7+
/** The chat owner whose rollout applies: exactly one of a workspace or an organization. */
8+
const computerUseAvailabilityQuerySchema = z.union([
9+
z.object({ workspaceId: workspaceIdSchema }).strict(),
10+
z.object({ organizationId: organizationIdSchema }).strict(),
11+
])
612
const computerUseAvailabilityResponseSchema = z.object({ enabled: z.boolean() }).strict()
713
export const computerUseAvailabilityContract = defineRouteContract({
814
method: 'GET',
915
path: '/api/desktop/computer/availability',
16+
query: computerUseAvailabilityQuerySchema,
1017
response: { mode: 'json', schema: computerUseAvailabilityResponseSchema },
1118
})
19+
export type ComputerUseOwner = ContractQuery<typeof computerUseAvailabilityContract>
1220

1321
const authorizeComputerUseBodySchema = z.object({ toolCallId: desktopToolCallIdSchema }).strict()
1422
const authorizeComputerUseResponseSchema = z

‎apps/sim/lib/computer-use/application/authorize.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export const authorizeComputerUse = defineAuthorizedChatUseCase({
6060
},
6161
authorizationOptions: {},
6262
async execute({ principal, context, input }) {
63-
if (!(await isComputerUseAvailable()))
63+
if (!(await isComputerUseAvailable(context.organizationId ?? context.workspaceOrganizationId)))
6464
throw new OrchestrationError('forbidden', 'Computer use is unavailable on this deployment')
6565
const claimed = await claimComputerUseTool({
6666
toolCallId: input.toolCallId,
Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,77 @@
1+
import type { Principal } from '@sim/auth/principal'
2+
import type { ComputerUseOwner } from '@/lib/api/contracts/computer-use'
13
import { isComputerUseAvailable } from '@/lib/computer-use/availability.server'
4+
import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application'
5+
import { defineAuthorizedOrganizationUseCase } from '@/lib/core/application/authorized-organization-use-case'
26
import {
37
assertOperationPrincipal,
48
defineOperation,
59
type OperationUseCase,
610
} from '@/lib/core/application/operation'
11+
import { defineOrganizationOperation } from '@/lib/core/application/organization-operation'
12+
import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context'
713

8-
/** permission-group-exempt: reports only the deployment-wide rollout switch; actions require chat authorization. */
14+
/** permission-group-exempt: reports the rollout for a workspace the caller can read; actions require chat authorization. */
15+
const workspaceAvailabilityOperation = defineWorkspaceOperation({
16+
id: 'desktop.computer.availability',
17+
minimumRole: 'read',
18+
workspaceApiKey: 'deny',
19+
capability: 'none',
20+
principalKinds: ['session'],
21+
})
22+
23+
/** permission-group-exempt: reports the rollout for an organization the caller belongs to; actions require chat authorization. */
24+
const organizationAvailabilityOperation = defineOrganizationOperation({
25+
id: 'desktop.computer.availability',
26+
minimumRole: 'member',
27+
capability: 'none',
28+
principalKinds: ['session'],
29+
})
30+
31+
const workspaceAvailability = defineAuthorizedWorkspaceUseCase({
32+
operation: workspaceAvailabilityOperation,
33+
resolveContext: ({ input }: { input: { workspaceId: string } }) =>
34+
resolveActiveWorkspaceApplicationContext(input.workspaceId),
35+
authorizationOptions: {},
36+
execute: ({ context }) => isComputerUseAvailable(context.workspaceOrganizationId),
37+
})
38+
39+
const organizationAvailability = defineAuthorizedOrganizationUseCase({
40+
operation: organizationAvailabilityOperation,
41+
execute: ({ input }: { input: { organizationId: string } }) =>
42+
isComputerUseAvailable(input.organizationId),
43+
})
44+
45+
/**
46+
* Whether computer use is rolled out for a chat's owner: an organization chat follows its
47+
* organization, a workspace chat its workspace's organization, and a personal workspace the
48+
* global switch.
49+
*/
50+
export function isComputerUseAvailableFor(
51+
principal: Principal,
52+
owner: ComputerUseOwner
53+
): Promise<boolean> {
54+
return 'organizationId' in owner
55+
? organizationAvailability.execute({ principal, input: owner })
56+
: workspaceAvailability.execute({ principal, input: owner })
57+
}
58+
59+
/** permission-group-exempt: dispatches to the owner's authorized rollout read above. */
960
const availabilityOperation = defineOperation({
1061
id: 'desktop.computer.availability',
1162
capability: 'none',
1263
principalKinds: ['session'],
1364
})
1465

15-
/** The authenticated settings and capability read; no device or app data leaves the desktop. */
66+
/** The desktop's rollout read before it touches the native helper; no device or app data leaves the desktop. */
1667
export const readComputerUseAvailability: OperationUseCase<
1768
typeof availabilityOperation,
18-
undefined,
69+
ComputerUseOwner,
1970
{ enabled: boolean }
2071
> = {
2172
operation: availabilityOperation,
22-
async execute({ principal }) {
73+
async execute({ principal, input }) {
2374
assertOperationPrincipal(principal, availabilityOperation)
24-
return { enabled: await isComputerUseAvailable() }
75+
return { enabled: await isComputerUseAvailableFor(principal, input) }
2576
},
2677
}
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
22

3-
/** Re-evaluates the global rollout at every server admission boundary. */
4-
export async function isComputerUseAvailable(): Promise<boolean> {
5-
return isFeatureEnabled('mothership-computer-use')
3+
/** Computer use rolls out by organization; personal workspaces follow the global switch. */
4+
export function isComputerUseAvailable(orgId?: string | null): Promise<boolean> {
5+
return isFeatureEnabled('mothership-computer-use', orgId ? { orgId } : {})
66
}

‎apps/sim/lib/core/config/feature-flags.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ const FEATURE_FLAGS = {
5353
},
5454
'mothership-computer-use': {
5555
description:
56-
'Enable native macOS computer use in Mothership. Global on/off only; each device must also opt in.',
56+
'Enable native macOS computer use in Mothership. Supports global and organization rollout; ' +
57+
'personal workspaces follow the global switch. Each device must also opt in.',
5758
fallback: 'MSHIP_COMPUTER_USE',
5859
},
5960
'mothership-search-integration-tools': {

‎apps/sim/lib/desktop/index.test.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe('desktop surface availability', () => {
6262
terminalEnabled: false,
6363
})
6464
requestAvailability.mockResolvedValueOnce({ enabled: rollout })
65-
const result = await getDesktopChatCapabilities('chat-1', true)
65+
const result = await getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' })
6666
expect(result.desktopCapabilities?.computerUse ?? false).toBe(expected)
6767
}
6868
)
@@ -75,7 +75,7 @@ describe('desktop surface availability', () => {
7575
browserAgent: { getKnownSessions: vi.fn(async () => ({ sessions: [] })) },
7676
})
7777
requestAvailability.mockResolvedValue({ enabled: false })
78-
const result = await getDesktopChatCapabilities('chat-1', false)
78+
const result = await getDesktopChatCapabilities('chat-1', null)
7979
expect(requestAvailability).not.toHaveBeenCalled()
8080
expect(getStatus).not.toHaveBeenCalled()
8181
expect(result.desktopCapabilities).toMatchObject({
@@ -95,7 +95,7 @@ describe('desktop surface availability', () => {
9595
terminalEnabled: false,
9696
})
9797
requestAvailability.mockReturnValueOnce(rollout.promise)
98-
const result = getDesktopChatCapabilities('chat-1', true)
98+
const result = getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' })
9999
await vi.waitFor(() => expect(requestAvailability).toHaveBeenCalledOnce())
100100
expect(getStatus).not.toHaveBeenCalled()
101101
rollout.resolve({ enabled: false })
@@ -113,7 +113,8 @@ describe('desktop surface availability', () => {
113113
})
114114
requestAvailability.mockRejectedValueOnce(new Error('offline'))
115115
expect(
116-
(await getDesktopChatCapabilities('chat-1', true)).desktopCapabilities?.computerUse
116+
(await getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' })).desktopCapabilities
117+
?.computerUse
117118
).toBeUndefined()
118119
expect(getStatus).not.toHaveBeenCalled()
119120
})
@@ -143,12 +144,12 @@ describe('desktop surface availability', () => {
143144
browserEnabled: false,
144145
terminalEnabled: false,
145146
})
146-
expect(await getDesktopChatCapabilities('org-chat', false)).toMatchObject({
147+
expect(await getDesktopChatCapabilities('org-chat', null)).toMatchObject({
147148
desktopCapabilities: { localFiles: true },
148149
})
149150
installBridge({})
150151
expect(
151-
(await getDesktopChatCapabilities('org-chat', false)).desktopCapabilities?.localFiles
152+
(await getDesktopChatCapabilities('org-chat', null)).desktopCapabilities?.localFiles
152153
).toBeUndefined()
153154
})
154155

@@ -160,7 +161,9 @@ describe('desktop surface availability', () => {
160161
})
161162
const offerFrom = async (bridge: Record<string, unknown>) => {
162163
installBridge({ localFiles: vi.fn(), ...bridge })
163-
const { desktopCapabilities } = await getDesktopChatCapabilities('chat-1', true)
164+
const { desktopCapabilities } = await getDesktopChatCapabilities('chat-1', {
165+
workspaceId: 'ws-1',
166+
})
164167
return { deviceId: desktopCapabilities?.deviceId, executor: desktopCapabilities?.executor }
165168
}
166169
const unset = { deviceId: undefined, executor: undefined }
@@ -208,7 +211,7 @@ describe('desktop surface availability', () => {
208211
browserEnabled: false,
209212
})
210213

211-
const capabilities = await getDesktopChatCapabilities('chat-1', true)
214+
const capabilities = await getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' })
212215

213216
expect(capabilities.desktopCapabilities?.terminals).toEqual([
214217
{

0 commit comments

Comments
 (0)