Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/sim/app/api/desktop/computer/availability/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ export const GET = defineInternalJsonRoute({
auth: internalSessionAuth,
operation: readComputerUseAvailability.operation,
rateLimit: internalRateLimits.none({
reason: 'Read-only rollout switch used on each desktop chat admission.',
reason: 'Read-only rollout check used on each desktop chat admission.',
}),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: () => undefined,
mapInput: ({ query }) => query,
useCase: readComputerUseAvailability,
present: (result) => result,
})
2 changes: 1 addition & 1 deletion apps/sim/app/o/[organizationId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export default async function OrganizationLayout({
logger.warn('Could not resolve benchmark navigation access')
return false
}),
isComputerUseAvailable(),
isComputerUseAvailable(organizationId),
isWorkflowTestsEnabled(organizationId),
isChangelogEnabled(organizationId),
isProjectsEnabled(),
Expand Down
11 changes: 10 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3929,7 +3929,16 @@ export function useChat(
const desktopChatCapabilities =
options?.requestMode === 'assistant'
? {}
: await getDesktopChatCapabilities(desktopScopeIdRef.current, computerUseEnabled)
: await getDesktopChatCapabilities(
desktopScopeIdRef.current,
!computerUseEnabled
? null
: organizationId
? { organizationId }
: workspaceId
? { workspaceId }
: null
)

admissionUnknown = true
/** A reload from here on may find the server holding this id. */
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ async function WorkspaceContent({
isWorkflowTestsEnabled(hostContext.hostOrganizationId),
isChangelogEnabled(hostContext.hostOrganizationId),
hasDesktopBackgroundExecutor(session.user.id),
isComputerUseAvailable(),
isComputerUseAvailable(hostContext.hostOrganizationId),
isProjectsEnabled(),
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/lib/api/contracts/computer-use.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { ComputerUseSchema } from '@sim/desktop-bridge/computer-use'
import { z } from 'zod'
import { desktopToolCallIdSchema } from '@/lib/api/contracts/desktop-tool-authorization'
import { defineRouteContract } from '@/lib/api/contracts/types'
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<typeof computerUseAvailabilityContract>

const authorizeComputerUseBodySchema = z.object({ toolCallId: desktopToolCallIdSchema }).strict()
const authorizeComputerUseResponseSchema = z
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/computer-use/application/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const authorizeComputerUse = defineAuthorizedChatUseCase({
},
authorizationOptions: {},
async execute({ principal, context, input }) {
if (!(await isComputerUseAvailable()))
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,
Expand Down
61 changes: 56 additions & 5 deletions apps/sim/lib/computer-use/application/availability.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,77 @@
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 { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context'

/** permission-group-exempt: reports only the deployment-wide rollout switch; actions require chat authorization. */
/** permission-group-exempt: reports the rollout for a workspace the caller can read; actions require chat authorization. */
const workspaceAvailabilityOperation = defineWorkspaceOperation({
id: 'desktop.computer.availability',
minimumRole: 'read',
workspaceApiKey: 'deny',
capability: 'none',
principalKinds: ['session'],
})

/** permission-group-exempt: reports the rollout for an organization the caller belongs to; actions require chat authorization. */
const organizationAvailabilityOperation = defineOrganizationOperation({
id: 'desktop.computer.availability',
minimumRole: 'member',
capability: 'none',
principalKinds: ['session'],
})

const workspaceAvailability = defineAuthorizedWorkspaceUseCase({
operation: workspaceAvailabilityOperation,
resolveContext: ({ input }: { input: { workspaceId: string } }) =>
resolveActiveWorkspaceApplicationContext(input.workspaceId),
authorizationOptions: {},
execute: ({ context }) => isComputerUseAvailable(context.workspaceOrganizationId),
})

const organizationAvailability = defineAuthorizedOrganizationUseCase({
operation: organizationAvailabilityOperation,
execute: ({ input }: { input: { organizationId: string } }) =>
isComputerUseAvailable(input.organizationId),
})

/**
* 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.
*/
export function isComputerUseAvailableFor(
principal: Principal,
owner: ComputerUseOwner
): Promise<boolean> {
return 'organizationId' in owner
? 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 authenticated settings and capability read; no device or app data leaves the desktop. */
/** 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,
undefined,
ComputerUseOwner,
{ enabled: boolean }
> = {
operation: availabilityOperation,
async execute({ principal }) {
async execute({ principal, input }) {
assertOperationPrincipal(principal, availabilityOperation)
return { enabled: await isComputerUseAvailable() }
return { enabled: await isComputerUseAvailableFor(principal, input) }
},
}
6 changes: 3 additions & 3 deletions apps/sim/lib/computer-use/availability.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'

/** Re-evaluates the global rollout at every server admission boundary. */
export async function isComputerUseAvailable(): Promise<boolean> {
return isFeatureEnabled('mothership-computer-use')
/** Computer use rolls out by organization; personal workspaces follow the global switch. */
export function isComputerUseAvailable(orgId?: string | null): Promise<boolean> {
return isFeatureEnabled('mothership-computer-use', orgId ? { orgId } : {})
}
3 changes: 2 additions & 1 deletion apps/sim/lib/core/config/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ const FEATURE_FLAGS = {
},
'mothership-computer-use': {
description:
'Enable native macOS computer use in Mothership. Global on/off only; each device must also opt in.',
'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',
},
'mothership-search-integration-tools': {
Expand Down
19 changes: 11 additions & 8 deletions apps/sim/lib/desktop/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('desktop surface availability', () => {
terminalEnabled: false,
})
requestAvailability.mockResolvedValueOnce({ enabled: rollout })
const result = await getDesktopChatCapabilities('chat-1', true)
const result = await getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' })
expect(result.desktopCapabilities?.computerUse ?? false).toBe(expected)
}
)
Expand All @@ -75,7 +75,7 @@ describe('desktop surface availability', () => {
browserAgent: { getKnownSessions: vi.fn(async () => ({ sessions: [] })) },
})
requestAvailability.mockResolvedValue({ enabled: false })
const result = await getDesktopChatCapabilities('chat-1', false)
const result = await getDesktopChatCapabilities('chat-1', null)
expect(requestAvailability).not.toHaveBeenCalled()
expect(getStatus).not.toHaveBeenCalled()
expect(result.desktopCapabilities).toMatchObject({
Expand All @@ -95,7 +95,7 @@ describe('desktop surface availability', () => {
terminalEnabled: false,
})
requestAvailability.mockReturnValueOnce(rollout.promise)
const result = getDesktopChatCapabilities('chat-1', true)
const result = getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' })
await vi.waitFor(() => expect(requestAvailability).toHaveBeenCalledOnce())
expect(getStatus).not.toHaveBeenCalled()
rollout.resolve({ enabled: false })
Expand All @@ -113,7 +113,8 @@ describe('desktop surface availability', () => {
})
requestAvailability.mockRejectedValueOnce(new Error('offline'))
expect(
(await getDesktopChatCapabilities('chat-1', true)).desktopCapabilities?.computerUse
(await getDesktopChatCapabilities('chat-1', { workspaceId: 'ws-1' })).desktopCapabilities
?.computerUse
).toBeUndefined()
expect(getStatus).not.toHaveBeenCalled()
})
Expand Down Expand Up @@ -143,12 +144,12 @@ describe('desktop surface availability', () => {
browserEnabled: false,
terminalEnabled: false,
})
expect(await getDesktopChatCapabilities('org-chat', false)).toMatchObject({
expect(await getDesktopChatCapabilities('org-chat', null)).toMatchObject({
desktopCapabilities: { localFiles: true },
})
installBridge({})
expect(
(await getDesktopChatCapabilities('org-chat', false)).desktopCapabilities?.localFiles
(await getDesktopChatCapabilities('org-chat', null)).desktopCapabilities?.localFiles
).toBeUndefined()
})

Expand All @@ -160,7 +161,9 @@ describe('desktop surface availability', () => {
})
const offerFrom = async (bridge: Record<string, unknown>) => {
installBridge({ localFiles: vi.fn(), ...bridge })
const { desktopCapabilities } = await getDesktopChatCapabilities('chat-1', true)
const { desktopCapabilities } = await getDesktopChatCapabilities('chat-1', {
workspaceId: 'ws-1',
})
return { deviceId: desktopCapabilities?.deviceId, executor: desktopCapabilities?.executor }
}
const unset = { deviceId: undefined, executor: undefined }
Expand Down Expand Up @@ -208,7 +211,7 @@ describe('desktop surface availability', () => {
browserEnabled: false,
})

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

expect(capabilities.desktopCapabilities?.terminals).toEqual([
{
Expand Down
14 changes: 10 additions & 4 deletions apps/sim/lib/desktop/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ 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 { computerUseAvailabilityContract } from '@/lib/api/contracts/computer-use'
import {
type ComputerUseOwner,
computerUseAvailabilityContract,
} from '@/lib/api/contracts/computer-use'
import {
DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH,
DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH,
Expand Down Expand Up @@ -177,7 +180,7 @@ export interface DesktopChatCapabilities {
*/
export async function getDesktopChatCapabilities(
scopeId: string,
computerUseEnabled: boolean
computerUseOwner: ComputerUseOwner | null
): Promise<DesktopChatCapabilities> {
const bridge = getDesktopBridge()
// Never advertise a surface the user switched off, even on the first
Expand All @@ -188,8 +191,11 @@ export async function getDesktopChatCapabilities(
const browser = isBrowserAgentEnabled()
const terminal = isTerminalEnabled()
const computerUse =
computerUseEnabled && bridge?.computerUse
? await requestJson(computerUseAvailabilityContract, { signal: AbortSignal.timeout(5000) })
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()
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/lib/mothership/chat/payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ const {
mockSearchIntegrationToolsEnabled: vi.fn(async () => true),
}))

vi.mock('@/lib/computer-use/availability.server', () => ({
isComputerUseAvailable: mockComputerUseAvailable,
vi.mock('@/lib/computer-use/application/availability', () => ({
isComputerUseAvailableFor: mockComputerUseAvailable,
}))

vi.mock('@/lib/mothership/feature-flags', () => ({
Expand Down Expand Up @@ -819,13 +819,13 @@ describe('desktop request capabilities', () => {
message: 'Use Notes',
workspaceId: 'workspace',
userId: 'user',
principal: { kind: 'session', userId: 'user', sessionId: 'session' },
userMessageId: 'message',
mode: 'agent',
model: 'gpt-6-astra',
computerUse: true,
})
expect(payload.desktop?.computerUse ?? false).toBe(enabled)
expect(mockComputerUseAvailable).toHaveBeenCalledWith()
}
)

Expand Down
13 changes: 11 additions & 2 deletions apps/sim/lib/mothership/chat/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { toError } from '@sim/utils/errors'
import { LRUCache } from 'lru-cache'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isPaid } from '@/lib/billing/plan-helpers'
import { isComputerUseAvailable } from '@/lib/computer-use/availability.server'
import { isComputerUseAvailableFor } from '@/lib/computer-use/application/availability'
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
import { isHosted } from '@/lib/core/config/env-flags'
import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server'
Expand Down Expand Up @@ -323,8 +323,17 @@ export async function buildCopilotRequestPayload(params: BuildPayloadParams): Pr
params
const effectiveMode = mode === 'agent' ? 'build' : mode
const isAssistant = effectiveMode === 'assistant'
const computerUseOwner = params.organizationId
? { organizationId: params.organizationId }
: params.workspaceId
? { workspaceId: params.workspaceId }
: null
const computerUse =
!isAssistant && params.computerUse === true && (await isComputerUseAvailable())
!isAssistant &&
params.computerUse === true &&
params.principal !== undefined &&
computerUseOwner !== null &&
(await isComputerUseAvailableFor(params.principal, computerUseOwner))
const integrationGateway = !isAssistant || (await isSearchIntegrationToolsEnabled())

// Track uploaded files in the DB and build context tags instead of base64 inlining.
Expand Down
Loading