diff --git a/apps/sim/app/api/mothership/execute/route.test.ts b/apps/sim/app/api/mothership/execute/route.test.ts index b052d2cfa79..efa98cbf09a 100644 --- a/apps/sim/app/api/mothership/execute/route.test.ts +++ b/apps/sim/app/api/mothership/execute/route.test.ts @@ -18,6 +18,7 @@ const { mockRequestExplicitStreamAbort, mockRequireBillingAttributionHeader, mockRunHeadlessCopilotLifecycle, + mockGetAccessibleWorkspacesForCopilot, } = vi.hoisted(() => ({ mockAssertActiveWorkspaceAccess: vi.fn(), mockBuildIntegrationToolSchemas: vi.fn(), @@ -32,6 +33,11 @@ const { mockRequestExplicitStreamAbort: vi.fn(), mockRequireBillingAttributionHeader: vi.fn(), mockRunHeadlessCopilotLifecycle: vi.fn(), + mockGetAccessibleWorkspacesForCopilot: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/accessible-workspaces', () => ({ + getAccessibleWorkspacesForCopilot: mockGetAccessibleWorkspacesForCopilot, })) vi.mock('@/lib/core/security/encryption', () => ({ @@ -170,6 +176,10 @@ describe('mothership private trace provenance transport', () => { decryptionFailures: [], }) mockGenerateWorkspaceContext.mockResolvedValue({}) + mockGetAccessibleWorkspacesForCopilot.mockResolvedValue([ + { id: 'workspace-1', name: 'Production', permission: 'write' }, + { id: 'workspace-2', name: 'Marketing', permission: 'read' }, + ]) mockBuildIntegrationToolSchemas.mockResolvedValue([]) mockBuildSelectedMcpToolSchemas.mockResolvedValue([]) mockBuildTaggedMcpToolSchemas.mockResolvedValue([]) @@ -219,7 +229,12 @@ describe('mothership private trace provenance transport', () => { expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance') expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled() expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( - expect.any(Object), + expect.objectContaining({ + accessibleWorkspaces: [ + { id: 'workspace-1', name: 'Production', permission: 'write' }, + { id: 'workspace-2', name: 'Marketing', permission: 'read' }, + ], + }), expect.objectContaining({ environmentContext: undefined }) ) }) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index c3e9d23f77a..73b6c0922e3 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -6,6 +6,7 @@ import { mothershipExecuteContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { requireBillingAttributionHeader } from '@/lib/billing/core/billing-attribution' +import { getAccessibleWorkspacesForCopilot } from '@/lib/copilot/chat/accessible-workspaces' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' import { processContextsServer } from '@/lib/copilot/chat/process-contents' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' @@ -266,25 +267,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const byName = new Map(groups.flat().map((tool) => [tool.name, tool])) return [...byName.values()] }) - const [workspaceContext, integrationTools, mothershipTools, entitlements, agentContexts] = - await Promise.all([ - generateWorkspaceContext(workspaceId, userId, { workspaceAccess }), - buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), - mothershipToolsPromise, - computeWorkspaceEntitlements(workspaceId, userId), - processContextsServer( - nonMcpAgentMentions, - userId, - lastUserMessage, - workspaceId, - effectiveChatId - ).catch((error) => { - reqLogger.warn('Failed to resolve agent contexts for execution', { - error: toError(error).message, - }) - return [] - }), - ]) + const [ + workspaceContext, + accessibleWorkspaces, + integrationTools, + mothershipTools, + entitlements, + agentContexts, + ] = await Promise.all([ + generateWorkspaceContext(workspaceId, userId, { workspaceAccess }), + getAccessibleWorkspacesForCopilot(userId), + buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + mothershipToolsPromise, + computeWorkspaceEntitlements(workspaceId, userId), + processContextsServer( + nonMcpAgentMentions, + userId, + lastUserMessage, + workspaceId, + effectiveChatId + ).catch((error) => { + reqLogger.warn('Failed to resolve agent contexts for execution', { + error: toError(error).message, + }) + return [] + }), + ]) const requestPayload: Record = { messages, responseFormat, @@ -300,6 +308,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { messageId, isHosted: true, workspaceContext, + ...(accessibleWorkspaces.length > 0 ? { accessibleWorkspaces } : {}), ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), ...(userMetadata ? { userMetadata } : {}), ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), diff --git a/apps/sim/lib/copilot/chat/accessible-workspaces.test.ts b/apps/sim/lib/copilot/chat/accessible-workspaces.test.ts new file mode 100644 index 00000000000..d4d47473bdb --- /dev/null +++ b/apps/sim/lib/copilot/chat/accessible-workspaces.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListAccessibleWorkspaceRowsForUser } = vi.hoisted(() => ({ + mockListAccessibleWorkspaceRowsForUser: vi.fn(), +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + listAccessibleWorkspaceRowsForUser: mockListAccessibleWorkspaceRowsForUser, +})) + +import { getAccessibleWorkspacesForCopilot } from '@/lib/copilot/chat/accessible-workspaces' + +describe('getAccessibleWorkspacesForCopilot', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns active workspace identity and effective permission in stable order', async () => { + mockListAccessibleWorkspaceRowsForUser.mockResolvedValue([ + { workspace: { id: 'ws-2', name: 'Production' }, permissionType: 'admin' }, + { workspace: { id: 'ws-1', name: 'Marketing' }, permissionType: 'write' }, + ]) + + await expect(getAccessibleWorkspacesForCopilot('user-1')).resolves.toEqual([ + { id: 'ws-1', name: 'Marketing', permission: 'write' }, + { id: 'ws-2', name: 'Production', permission: 'admin' }, + ]) + expect(mockListAccessibleWorkspaceRowsForUser).toHaveBeenCalledWith('user-1') + }) + + it('degrades to no context when the informational lookup fails', async () => { + mockListAccessibleWorkspaceRowsForUser.mockRejectedValue(new Error('database unavailable')) + + await expect(getAccessibleWorkspacesForCopilot('user-1')).resolves.toEqual([]) + }) +}) diff --git a/apps/sim/lib/copilot/chat/accessible-workspaces.ts b/apps/sim/lib/copilot/chat/accessible-workspaces.ts new file mode 100644 index 00000000000..660225ede90 --- /dev/null +++ b/apps/sim/lib/copilot/chat/accessible-workspaces.ts @@ -0,0 +1,37 @@ +import { createLogger } from '@sim/logger' +import type { PermissionType } from '@sim/platform-authz/workspace' +import { getErrorMessage } from '@sim/utils/errors' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' + +const logger = createLogger('CopilotAccessibleWorkspaces') + +export interface AccessibleWorkspace { + id: string + name: string + permission: PermissionType +} + +/** + * Returns active workspaces visible to the current user for informational + * agent context. This data never replaces workspace authorization checks. + */ +export async function getAccessibleWorkspacesForCopilot( + userId: string +): Promise { + try { + const rows = await listAccessibleWorkspaceRowsForUser(userId) + return rows + .map(({ workspace, permissionType }) => ({ + id: workspace.id, + name: workspace.name, + permission: permissionType, + })) + .sort((a, b) => a.name.localeCompare(b.name, 'en') || a.id.localeCompare(b.id, 'en')) + } catch (error) { + logger.warn('Failed to load accessible workspaces for copilot context', { + userId, + error: getErrorMessage(error), + }) + return [] + } +} diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index b0f8609527e..61206e4db1c 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -274,6 +274,10 @@ describe('buildCopilotRequestPayload', () => { model: 'claude-opus-4-8', workspaceId: 'ws-1', workspaceContext: 'workspace inventory', + accessibleWorkspaces: [ + { id: 'ws-1', name: 'Production', permission: 'admin' }, + { id: 'ws-2', name: 'Marketing', permission: 'read' }, + ], }, { selectedModel: 'claude-opus-4-8' } ) @@ -282,6 +286,10 @@ describe('buildCopilotRequestPayload', () => { expect.objectContaining({ workspaceId: 'ws-1', workspaceContext: 'workspace inventory', + accessibleWorkspaces: [ + { id: 'ws-1', name: 'Production', permission: 'admin' }, + { id: 'ws-2', name: 'Marketing', permission: 'read' }, + ], }) ) }) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 4ba3cf6c37e..f0f7f2cf86e 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -6,6 +6,7 @@ import { LRUCache } from 'lru-cache' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { isPaid } from '@/lib/billing/plan-helpers' import { getBlockVisibilityForCopilot, visibilitySignature } from '@/lib/copilot/block-visibility' +import type { AccessibleWorkspace } from '@/lib/copilot/chat/accessible-workspaces' import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1' import { filterExposedIntegrationTools, @@ -47,6 +48,7 @@ interface BuildPayloadParams { prefetch?: boolean implicitFeedback?: string workspaceContext?: string + accessibleWorkspaces?: AccessibleWorkspace[] vfs?: VfsSnapshotV1 userPermission?: string /** Plan/flag-gated org capabilities (e.g. "custom-blocks") the mothership gates tools/prompts on. */ @@ -452,6 +454,9 @@ export async function buildCopilotRequestPayload( ...(mothershipTools.length > 0 ? { mothershipTools } : {}), ...(commands && commands.length > 0 ? { commands } : {}), ...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}), + ...(params.accessibleWorkspaces?.length + ? { accessibleWorkspaces: params.accessibleWorkspaces } + : {}), ...(params.vfs ? { vfs: params.vfs } : {}), ...(params.userPermission ? { userPermission: params.userPermission } : {}), ...(params.entitlements?.length ? { entitlements: params.entitlements } : {}), diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index cfd555b6db8..e198bd9e52c 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -35,6 +35,7 @@ const { finalizeAssistantTurn, appendCopilotChatMessages, mockPublishStatusChanged, + getAccessibleWorkspacesForCopilot, } = vi.hoisted(() => ({ generateWorkspaceSnapshot: vi.fn(), processContextsServer: vi.fn(), @@ -49,6 +50,7 @@ const { finalizeAssistantTurn: vi.fn(), appendCopilotChatMessages: vi.fn(), mockPublishStatusChanged: vi.fn(), + getAccessibleWorkspacesForCopilot: vi.fn(), })) const getSession = authMockFns.mockGetSession @@ -77,6 +79,10 @@ vi.mock('@/lib/copilot/chat/workspace-context', () => ({ generateWorkspaceSnapshot, })) +vi.mock('@/lib/copilot/chat/accessible-workspaces', () => ({ + getAccessibleWorkspacesForCopilot, +})) + vi.mock('@/lib/copilot/chat/process-contents', () => ({ processContextsServer, resolveActiveResourceContext, @@ -147,6 +153,10 @@ describe('handleUnifiedChatPost', () => { markdown: 'workspace context', snapshot: { workflows: [{ id: 'wf-1', name: 'Alpha', path: 'workflows/Alpha' }] }, }) + getAccessibleWorkspacesForCopilot.mockResolvedValue([ + { id: 'ws-1', name: 'Production', permission: 'write' }, + { id: 'ws-2', name: 'Marketing', permission: 'read' }, + ]) processContextsServer.mockResolvedValue([]) resolveActiveResourceContext.mockResolvedValue(null) buildCopilotRequestPayload.mockImplementation(async (params: Record) => params) @@ -187,6 +197,10 @@ describe('handleUnifiedChatPost', () => { expect.objectContaining({ model: 'claude-opus-4-8', workspaceContext: 'workspace context', + accessibleWorkspaces: [ + { id: 'ws-1', name: 'Production', permission: 'write' }, + { id: 'ws-2', name: 'Marketing', permission: 'read' }, + ], // Regression guard: the branch must forward the typed snapshot, not drop it. vfs: expect.objectContaining({ workflows: expect.any(Array) }), }), @@ -229,6 +243,10 @@ describe('handleUnifiedChatPost', () => { expect.objectContaining({ workspaceId: 'ws-1', workspaceContext: 'workspace context', + accessibleWorkspaces: [ + { id: 'ws-1', name: 'Production', permission: 'write' }, + { id: 'ws-2', name: 'Marketing', permission: 'read' }, + ], // Regression guard: the branch must forward the typed snapshot, not drop it. vfs: expect.objectContaining({ workflows: expect.any(Array) }), }), diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 27855a7d911..c4eb00031d8 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -10,6 +10,10 @@ import { z } from 'zod' import { isZodError, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { + type AccessibleWorkspace, + getAccessibleWorkspacesForCopilot, +} from '@/lib/copilot/chat/accessible-workspaces' import { type ChatLoadResult, resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload' @@ -278,6 +282,7 @@ type UnifiedChatBranch = prefetch?: boolean implicitFeedback?: string workspaceContext?: string + accessibleWorkspaces?: AccessibleWorkspace[] vfs?: VfsSnapshotV1 desktopLocalFilesystem?: boolean browserCapable?: boolean @@ -314,6 +319,7 @@ type UnifiedChatBranch = userTimezone?: string userMetadata?: { name?: string; email?: string; timezone?: string } workspaceContext?: string + accessibleWorkspaces?: AccessibleWorkspace[] vfs?: VfsSnapshotV1 desktopLocalFilesystem?: boolean browserCapable?: boolean @@ -787,6 +793,7 @@ async function resolveBranch(params: { prefetch: payloadParams.prefetch, implicitFeedback: payloadParams.implicitFeedback, workspaceContext: payloadParams.workspaceContext, + accessibleWorkspaces: payloadParams.accessibleWorkspaces, vfs: payloadParams.vfs, userPermission: payloadParams.userPermission, entitlements: payloadParams.entitlements, @@ -849,6 +856,7 @@ async function resolveBranch(params: { fileAttachments: payloadParams.fileAttachments, chatId: payloadParams.chatId, workspaceContext: payloadParams.workspaceContext, + accessibleWorkspaces: payloadParams.accessibleWorkspaces, vfs: payloadParams.vfs, userPermission: payloadParams.userPermission, entitlements: payloadParams.entitlements, @@ -1082,6 +1090,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { const entitlementsPromise = workspaceId ? computeWorkspaceEntitlements(workspaceId, authenticatedUserId) : Promise.resolve([]) + const accessibleWorkspacesPromise = getAccessibleWorkspacesForCopilot(authenticatedUserId) // Wrap the pre-LLM prep work in spans so the trace waterfall shows // where time is going between "request received" and "llm.stream // opens". Previously these ran bare under the root and inflated the @@ -1136,15 +1145,23 @@ export async function handleUnifiedChatPost(req: NextRequest) { activeOtelRoot.context ) - const [agentContexts, userPermission, entitlements, workspaceSnapshot, , executionContext] = - await Promise.all([ - agentContextsPromise, - userPermissionPromise, - entitlementsPromise, - workspaceContextPromise, - persistUserMessagePromise, - executionContextPromise, - ]) + const [ + agentContexts, + userPermission, + entitlements, + accessibleWorkspaces, + workspaceSnapshot, + , + executionContext, + ] = await Promise.all([ + agentContextsPromise, + userPermissionPromise, + entitlementsPromise, + accessibleWorkspacesPromise, + workspaceContextPromise, + persistUserMessagePromise, + executionContextPromise, + ]) // Both halves come from one primary-db fetch (workspace-context.ts): // `workspaceContext` is the markdown transition fallback, `vfs` is the // typed snapshot Go diffs into baseline+delta messages. @@ -1189,6 +1206,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { prefetch: body.prefetch, implicitFeedback: body.implicitFeedback, workspaceContext, + accessibleWorkspaces, vfs, desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true, browserCapable: @@ -1210,6 +1228,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { userTimezone: body.userTimezone, userMetadata, workspaceContext, + accessibleWorkspaces, vfs, desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true, browserCapable: diff --git a/apps/sim/lib/mothership/inbox/executor.test.ts b/apps/sim/lib/mothership/inbox/executor.test.ts index f8231124a92..72f20e90ae3 100644 --- a/apps/sim/lib/mothership/inbox/executor.test.ts +++ b/apps/sim/lib/mothership/inbox/executor.test.ts @@ -15,11 +15,17 @@ const { mockGetUserEntityPermissions, mockRunHeadlessCopilotLifecycle, mockSendInboxResponse, + mockGetAccessibleWorkspacesForCopilot, } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockRunHeadlessCopilotLifecycle: vi.fn(), mockSendInboxResponse: vi.fn(), + mockGetAccessibleWorkspacesForCopilot: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/accessible-workspaces', () => ({ + getAccessibleWorkspacesForCopilot: mockGetAccessibleWorkspacesForCopilot, })) vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) @@ -131,6 +137,10 @@ describe('Inbox raw-secret actor', () => { toolCalls: [], chatId: 'chat-1', }) + mockGetAccessibleWorkspacesForCopilot.mockResolvedValue([ + { id: 'workspace-1', name: 'Production', permission: 'write' }, + { id: 'workspace-2', name: 'Marketing', permission: 'read' }, + ]) mockSendInboxResponse.mockResolvedValue('response-1') dbChainMockFns.returning .mockResolvedValueOnce([{ id: 'task-1' }]) @@ -146,7 +156,12 @@ describe('Inbox raw-secret actor', () => { await executeInboxTask('task-1') expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( - expect.any(Object), + expect.objectContaining({ + accessibleWorkspaces: [ + { id: 'workspace-1', name: 'Production', permission: 'write' }, + { id: 'workspace-2', name: 'Marketing', permission: 'read' }, + ], + }), expect.objectContaining({ userId: 'member-1', secretActorUserId: 'member-1', @@ -176,6 +191,9 @@ describe('Inbox raw-secret actor', () => { }, }) ) + const [payload] = mockRunHeadlessCopilotLifecycle.mock.calls[0] + expect(payload).not.toHaveProperty('accessibleWorkspaces') + expect(mockGetAccessibleWorkspacesForCopilot).not.toHaveBeenCalled() expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 4359130a79d..cf43109b5ae 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -5,6 +5,7 @@ import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import { getActivelyBannedUserIds, isEmailBlocked } from '@/lib/auth/ban' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { getAccessibleWorkspacesForCopilot } from '@/lib/copilot/chat/accessible-workspaces' import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' @@ -216,14 +217,21 @@ export async function executeInboxTask(taskId: string): Promise { const workspaceAccess = await checkWorkspaceAccess(ws.id, userId) const userPermission = workspaceAccess.permission - const [attachmentResult, workspaceContext, integrationTools, billingAttribution, entitlements] = - await Promise.all([ - fetchAttachments(), - generateWorkspaceContext(ws.id, userId, { workspaceAccess }), - buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), - resolveBillingAttribution({ actorUserId: userId, workspaceId: ws.id }), - computeWorkspaceEntitlements(ws.id, userId), - ]) + const [ + attachmentResult, + workspaceContext, + accessibleWorkspaces, + integrationTools, + billingAttribution, + entitlements, + ] = await Promise.all([ + fetchAttachments(), + generateWorkspaceContext(ws.id, userId, { workspaceAccess }), + actor.secretActorUserId ? getAccessibleWorkspacesForCopilot(userId) : Promise.resolve([]), + buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), + resolveBillingAttribution({ actorUserId: userId, workspaceId: ws.id }), + computeWorkspaceEntitlements(ws.id, userId), + ]) const { attachments, fileAttachments, storedAttachments } = attachmentResult const truncatedTask = { @@ -241,6 +249,7 @@ export async function executeInboxTask(taskId: string): Promise { messageId: userMessageId, isHosted, workspaceContext, + ...(accessibleWorkspaces.length > 0 ? { accessibleWorkspaces } : {}), ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(userPermission ? { userPermission } : {}),