diff --git a/apps/sim/lib/copilot/application/application-adapter.test.ts b/apps/sim/lib/copilot/application/application-adapter.test.ts new file mode 100644 index 00000000000..0ef223344bf --- /dev/null +++ b/apps/sim/lib/copilot/application/application-adapter.test.ts @@ -0,0 +1,191 @@ +/** + * @vitest-environment node + */ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { describe, expect, it, vi } from 'vitest' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { + type CopilotDelegationConfiguration, + type CopilotResourceScope, + createCopilotApplicationPrincipal, + type TrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' + +const operation = defineWorkspaceOperation({ + id: 'files.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +}) + +const delegation = { + audience: 'sim:files', + ttlMs: 5 * 60 * 1000, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, +} as const satisfies CopilotDelegationConfiguration + +const trustedContext = { + userId: 'trusted-user', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} as const + +interface FileScopeInput { + fileId: string +} + +function createAdapter( + createPrincipal?: (args: { + context: TrustedCopilotExecutionContext + resourceScope: CopilotResourceScope + }) => DelegatedPrincipal +) { + return createCopilotApplicationAdapter({ + domain: 'file', + delegation, + operations: { read: operation }, + projectResourceScope: ({ fileId }) => ({ fileId }), + createPrincipal, + }) +} + +describe('Copilot application adapter', () => { + it('binds only code-projected scope and leaves model input non-authoritative', async () => { + const execute = vi.fn().mockResolvedValue({ ok: true }) + + await createAdapter()( + trustedContext, + { operation, execute }, + { fileId: 'model-forged-file' }, + { fileId: 'trusted-file' } + ) + + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + subjectUserId: 'trusted-user', + workspaceId: 'workspace-1', + resourceScope: { + fileId: 'trusted-file', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }), + input: { fileId: 'model-forged-file' }, + }) + }) + + it('rejects unregistered and same-ID forged operation objects', () => { + const executeCopilotUseCase = createAdapter() + const unregistered = defineWorkspaceOperation({ + id: 'files.unregistered', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }) + const sameIdDifferentPolicy = defineWorkspaceOperation({ + id: operation.id, + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }) + + expect(() => + executeCopilotUseCase( + trustedContext, + { operation: unregistered, execute: vi.fn() }, + {}, + { fileId: 'file-1' } + ) + ).toThrow('Unregistered Copilot file operation') + expect(() => + executeCopilotUseCase( + trustedContext, + { operation: sameIdDifferentPolicy, execute: vi.fn() }, + {}, + { fileId: 'file-1' } + ) + ).toThrow('Unregistered Copilot file operation') + }) + + it('rejects an operation whose delegated identity policy excludes Copilot', () => { + const executorOperation = defineWorkspaceOperation({ + id: 'files.executor_only', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }) + const execute = vi.fn() + const executeCopilotUseCase = createCopilotApplicationAdapter< + WorkspaceOperation, + FileScopeInput + >({ + domain: 'file', + delegation, + operations: { executorOnly: executorOperation }, + projectResourceScope: ({ fileId }) => ({ fileId }), + }) + + expect(() => + executeCopilotUseCase( + trustedContext, + { operation: executorOperation, execute }, + {}, + { fileId: 'file-1' } + ) + ).toThrow('Delegated service copilot cannot perform operation files.executor_only') + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects a principal factory that changes the configured audience', () => { + const execute = vi.fn() + const executeCopilotUseCase = createAdapter(({ context, resourceScope }) => ({ + ...createCopilotApplicationPrincipal(context, { ...delegation, resourceScope }), + audience: 'sim:forged', + })) + + expect(() => + executeCopilotUseCase(trustedContext, { operation, execute }, {}, { fileId: 'file-1' }) + ).toThrow('configured delegation identity') + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects an expired principal before application execution', () => { + const execute = vi.fn() + const executeCopilotUseCase = createAdapter(({ context, resourceScope }) => { + const principal = createCopilotApplicationPrincipal(context, { + ...delegation, + resourceScope, + }) + return { ...principal, expiresAt: new Date(principal.issuedAt.getTime() - 1) } + }) + + expect(() => + executeCopilotUseCase(trustedContext, { operation, execute }, {}, { fileId: 'file-1' }) + ).toThrow('configured delegation expiry') + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects a principal factory scoped to a different resource', () => { + const execute = vi.fn() + const executeCopilotUseCase = createAdapter(({ context, resourceScope }) => { + const principal = createCopilotApplicationPrincipal(context, { + ...delegation, + resourceScope, + }) + return { ...principal, resourceScope: { ...principal.resourceScope, fileId: 'file-2' } } + }) + + expect(() => + executeCopilotUseCase(trustedContext, { operation, execute }, {}, { fileId: 'file-1' }) + ).toThrow('configured resource scope') + expect(execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/application/application-adapter.ts b/apps/sim/lib/copilot/application/application-adapter.ts new file mode 100644 index 00000000000..2cb58cd32c9 --- /dev/null +++ b/apps/sim/lib/copilot/application/application-adapter.ts @@ -0,0 +1,155 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { + type CopilotDelegationConfiguration, + type CopilotExecutionContext, + type CopilotResourceScope, + createCopilotApplicationPrincipal, + requireTrustedCopilotExecutionContext, + type TrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import { + type OperationUseCase, + requireAllowedWorkspacePrincipal, + type WorkspaceOperation, +} from '@/lib/core/application' + +type CopilotApplicationPrincipalFactory = (args: { + context: TrustedCopilotExecutionContext + resourceScope: CopilotResourceScope +}) => DelegatedPrincipal + +interface CopilotApplicationAdapterOptions { + domain: string + delegation: CopilotDelegationConfiguration + operations: Readonly> + projectResourceScope?( + input: ScopeInput, + context: TrustedCopilotExecutionContext + ): CopilotResourceScope + createPrincipal?: CopilotApplicationPrincipalFactory +} + +type ScopeArguments = [ScopeInput] extends [undefined] ? [] : [scope: ScopeInput] + +const RESOURCE_SCOPE_KEYS = ['fileId', 'tableId', 'chatId', 'executionId'] as const + +function requireValidProjectedResourceScope(resourceScope: CopilotResourceScope): void { + if (resourceScope.fileId !== undefined && !resourceScope.fileId.trim()) { + throw new Error('Copilot application resource scope contains an invalid file ID') + } + if (resourceScope.tableId !== undefined && !resourceScope.tableId.trim()) { + throw new Error('Copilot application resource scope contains an invalid table ID') + } +} + +function expectedResourceScope( + context: TrustedCopilotExecutionContext, + resourceScope: CopilotResourceScope +): NonNullable { + return { + ...resourceScope, + ...(context.chatId ? { chatId: context.chatId } : {}), + ...(context.executionId ? { executionId: context.executionId } : {}), + } +} + +function requireMatchingPrincipal( + principal: DelegatedPrincipal, + context: TrustedCopilotExecutionContext, + delegation: CopilotDelegationConfiguration, + resourceScope: CopilotResourceScope +): void { + const delegationId = delegation.createDelegationId(context) + if ( + principal.kind !== 'delegated' || + principal.serviceId !== 'copilot' || + principal.subjectUserId !== context.userId || + principal.workspaceId !== context.workspaceId || + !delegationId.trim() || + principal.delegationId !== delegationId || + principal.audience !== delegation.audience + ) { + throw new Error('Copilot principal factory violated the configured delegation identity') + } + + const issuedAt = principal.issuedAt.getTime() + const expiresAt = principal.expiresAt.getTime() + if ( + !Number.isFinite(issuedAt) || + !Number.isFinite(expiresAt) || + issuedAt > Date.now() || + expiresAt <= Date.now() || + expiresAt - issuedAt !== delegation.ttlMs + ) { + throw new Error('Copilot principal factory violated the configured delegation expiry') + } + + const expectedScope = expectedResourceScope(context, resourceScope) + if (RESOURCE_SCOPE_KEYS.some((key) => principal.resourceScope?.[key] !== expectedScope[key])) { + throw new Error('Copilot principal factory violated the configured resource scope') + } +} + +/** Adapts trusted Copilot calls to a domain's existing application use cases. */ +export function createCopilotApplicationAdapter< + O extends WorkspaceOperation, + ScopeInput = undefined, +>(options: CopilotApplicationAdapterOptions) { + if (!options.domain.trim()) throw new Error('Copilot application adapter requires a domain') + if (!options.delegation.audience.trim()) { + throw new Error('Copilot application adapter requires a delegation audience') + } + if (!Number.isInteger(options.delegation.ttlMs) || options.delegation.ttlMs <= 0) { + throw new Error('Copilot application adapter requires a positive integer delegation TTL') + } + + const operations = Object.values(options.operations) + if (operations.length === 0) { + throw new Error(`Copilot ${options.domain} operation registry cannot be empty`) + } + const operationIds = new Set() + for (const operation of operations) { + if (!Object.isFrozen(operation)) { + throw new Error(`Copilot ${options.domain} operation ${operation.id} must be immutable`) + } + if (operationIds.has(operation.id)) { + throw new Error(`Copilot ${options.domain} operation registry contains duplicate IDs`) + } + operationIds.add(operation.id) + } + const registeredOperations = new Set(operations) + + return function executeCopilotApplicationUseCase( + context: CopilotExecutionContext | undefined, + useCase: OperationUseCase, + input: I, + ...scopeArguments: ScopeArguments + ): Promise { + if (!registeredOperations.has(useCase.operation)) { + throw new Error(`Unregistered Copilot ${options.domain} operation: ${useCase.operation.id}`) + } + + const trustedContext = requireTrustedCopilotExecutionContext(context) + let resourceScope: CopilotResourceScope = {} + if (options.projectResourceScope) { + if (scopeArguments.length !== 1) { + throw new Error(`Copilot ${options.domain} execution requires trusted scope input`) + } + resourceScope = options.projectResourceScope(scopeArguments[0], trustedContext) + } else if (scopeArguments.length !== 0) { + throw new Error(`Copilot ${options.domain} execution does not accept resource scope input`) + } + requireValidProjectedResourceScope(resourceScope) + + const principal = options.createPrincipal + ? options.createPrincipal({ context: trustedContext, resourceScope }) + : createCopilotApplicationPrincipal(trustedContext, { + ...options.delegation, + resourceScope, + }) + requireMatchingPrincipal(principal, trustedContext, options.delegation, resourceScope) + requireAllowedWorkspacePrincipal(principal, useCase.operation) + + return useCase.execute({ principal, input }) + } +} diff --git a/apps/sim/lib/copilot/application/error.test.ts b/apps/sim/lib/copilot/application/error.test.ts new file mode 100644 index 00000000000..b0a0d4feb7b --- /dev/null +++ b/apps/sim/lib/copilot/application/error.test.ts @@ -0,0 +1,37 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE, + messageForCopilotApplicationError, +} from '@/lib/copilot/application/error' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +describe('Copilot application error projection', () => { + it('exposes only non-internal application errors', () => { + expect( + messageForCopilotApplicationError(new OrchestrationError('conflict', 'Name already exists')) + ).toBe('Name already exists') + }) + + it('projects internal and unknown infrastructure failures to a generic retryable message', () => { + expect( + messageForCopilotApplicationError( + new OrchestrationError('internal', 'select secret_column from workspace_files') + ) + ).toBe(COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE) + expect(messageForCopilotApplicationError(new Error('storage bucket credential rejected'))).toBe( + COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE + ) + }) + + it('supports a caller-defined safe fallback without exposing the cause', () => { + expect( + messageForCopilotApplicationError( + new Error('update workspace_files set content = raw'), + 'File operation failed. Please retry.' + ) + ).toBe('File operation failed. Please retry.') + }) +}) diff --git a/apps/sim/lib/copilot/application/error.ts b/apps/sim/lib/copilot/application/error.ts new file mode 100644 index 00000000000..966a3233b7c --- /dev/null +++ b/apps/sim/lib/copilot/application/error.ts @@ -0,0 +1,13 @@ +import { asOrchestrationError } from '@/lib/core/orchestration/types' + +export const COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE = + 'The operation failed due to a system error. Please retry.' + +/** Projects only caller-actionable application failures into Copilot-visible content. */ +export function messageForCopilotApplicationError( + error: unknown, + fallback = COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE +): string { + const classified = asOrchestrationError(error) + return classified && classified.code !== 'internal' ? classified.message : fallback +} diff --git a/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts b/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts index 0ba46365102..70662c129f3 100644 --- a/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts @@ -1,8 +1,14 @@ -import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' -import { CUSTOM_TOOL_DELEGATION_AUDIENCE } from '@/lib/custom-tools/application/authorization' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { customToolDelegationPolicy } from '@/lib/custom-tools/application/authorization' import { customToolOperations } from '@/lib/custom-tools/application/operations' -export const executeCopilotCustomToolUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: CUSTOM_TOOL_DELEGATION_AUDIENCE, +export const executeCopilotCustomToolUseCase = createCopilotApplicationAdapter({ + domain: 'custom tool', + delegation: { + audience: customToolDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, operations: customToolOperations, }) diff --git a/apps/sim/lib/copilot/application/execute-file-use-case.ts b/apps/sim/lib/copilot/application/execute-file-use-case.ts index 2532fe7c370..cb03a490bbe 100644 --- a/apps/sim/lib/copilot/application/execute-file-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-file-use-case.ts @@ -1,19 +1,32 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' import { type CopilotFileDelegationContext, resolveCopilotFilePrincipal, } from '@/lib/copilot/auth/file-delegation' import type { OperationUseCase } from '@/lib/core/application' +import { workspaceFileDelegationPolicy } from '@/lib/workspace-files/application/authorization' import { type FileOperation, fileOperations } from '@/lib/workspace-files/application/operations' import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' -const registeredFileOperationIds = new Set( - Object.values(fileOperations).map((operation) => operation.id) -) - interface ExecuteCopilotFileUseCaseOptions { fileId?: string } +const executeFileUseCase = createCopilotApplicationAdapter< + FileOperation, + ExecuteCopilotFileUseCaseOptions +>({ + domain: 'file', + delegation: { + audience: workspaceFileDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: fileOperations, + projectResourceScope: ({ fileId }) => (fileId ? { fileId } : {}), +}) + /** Normalizes trusted Copilot authentication before entering a file application use case. */ export function executeCopilotFileUseCase( context: CopilotFileDelegationContext | undefined, @@ -21,14 +34,7 @@ export function executeCopilotFileUseCase( input: I, options: ExecuteCopilotFileUseCaseOptions = {} ): Promise { - if (!registeredFileOperationIds.has(useCase.operation.id)) { - throw new Error(`Unregistered Copilot file operation: ${useCase.operation.id}`) - } - - return useCase.execute({ - principal: resolveCopilotFilePrincipal(context, options.fileId), - input, - }) + return executeFileUseCase(context, useCase, input, options) } /** Resolves a model-supplied VFS reference under a trusted Copilot delegation. */ diff --git a/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts b/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts index f9f9fb162f2..d88c5c940b3 100644 --- a/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts @@ -1,45 +1,42 @@ import type { DelegatedPrincipal } from '@sim/auth/principal' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + type CopilotExecutionContext, + createCopilotApplicationPrincipal, + requireTrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' import type { OperationUseCase } from '@/lib/core/application' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { createKnowledgeDelegatedPrincipal } from '@/lib/knowledge/application/delegated-principal' +import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' import { type KnowledgeOperation, knowledgeOperations, } from '@/lib/knowledge/application/operations' -export interface CopilotKnowledgeDelegationContext { - userId: string - workspaceId?: string - chatId?: string - executionId?: string - toolCallId?: string - copilotToolExecution?: boolean -} +export type CopilotKnowledgeDelegationContext = CopilotExecutionContext -const registeredKnowledgeOperationIds = new Set( - Object.values(knowledgeOperations).map((operation) => operation.id) -) +const knowledgeDelegation = { + audience: knowledgeDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context: Parameters[0]) => + context.toolCallId, +} as const + +const executeKnowledgeUseCase = createCopilotApplicationAdapter({ + domain: 'knowledge', + delegation: knowledgeDelegation, + operations: knowledgeOperations, +}) /** Normalizes immutable Copilot execution identity into a knowledge delegation. */ export function resolveCopilotKnowledgePrincipal( context: CopilotKnowledgeDelegationContext | undefined ): DelegatedPrincipal { - if (!context) throw new Error('Knowledge delegation requires a Copilot execution context') - if (!context.copilotToolExecution) { - throw new Error('Knowledge delegation requires a trusted Copilot execution context') - } - if (!context.userId) throw new Error('Knowledge delegation requires an authenticated user ID') - if (!context.workspaceId) throw new Error('Knowledge delegation requires a workspace ID') - if (!context.toolCallId) throw new Error('Knowledge delegation requires a tool call ID') - - return createKnowledgeDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: context.toolCallId, - chatId: context.chatId, - executionId: context.executionId, - }) + return createCopilotApplicationPrincipal( + requireTrustedCopilotExecutionContext(context), + knowledgeDelegation + ) } /** Enters a registered knowledge application use case with trusted Copilot identity. */ @@ -48,10 +45,7 @@ export function executeCopilotKnowledgeUseCase, input: I ): Promise { - if (!registeredKnowledgeOperationIds.has(useCase.operation.id)) { - throw new Error(`Unregistered Copilot knowledge operation: ${useCase.operation.id}`) - } - return useCase.execute({ principal: resolveCopilotKnowledgePrincipal(context), input }) + return executeKnowledgeUseCase(context, useCase, input) } /** Projects only caller-actionable application errors into a Copilot result. */ @@ -59,7 +53,5 @@ export function messageForCopilotKnowledgeError( error: unknown, fallback = 'Knowledge operation failed' ): string { - const classified = asOrchestrationError(error) - if (classified && classified.code !== 'internal') return classified.message - return fallback + return messageForCopilotApplicationError(error, fallback) } diff --git a/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts b/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts index 7744e0e0b2a..76c05447784 100644 --- a/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts @@ -1,8 +1,14 @@ -import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' -import { MCP_SERVER_DELEGATION_AUDIENCE } from '@/lib/mcp/application/authorization' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { mcpServerDelegationPolicy } from '@/lib/mcp/application/authorization' import { mcpServerOperations } from '@/lib/mcp/application/operations' -export const executeCopilotMcpServerUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: MCP_SERVER_DELEGATION_AUDIENCE, +export const executeCopilotMcpServerUseCase = createCopilotApplicationAdapter({ + domain: 'MCP server', + delegation: { + audience: mcpServerDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, operations: mcpServerOperations, }) diff --git a/apps/sim/lib/copilot/application/execute-skill-use-case.ts b/apps/sim/lib/copilot/application/execute-skill-use-case.ts index 8acce8e1e12..7e38e20d9ff 100644 --- a/apps/sim/lib/copilot/application/execute-skill-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-skill-use-case.ts @@ -1,8 +1,14 @@ -import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' -import { SKILL_DELEGATION_AUDIENCE } from '@/lib/skills/application/authorization' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { skillDelegationPolicy } from '@/lib/skills/application/authorization' import { skillOperations } from '@/lib/skills/application/operations' -export const executeCopilotSkillUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: SKILL_DELEGATION_AUDIENCE, +export const executeCopilotSkillUseCase = createCopilotApplicationAdapter({ + domain: 'skill', + delegation: { + audience: skillDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, operations: skillOperations, }) diff --git a/apps/sim/lib/copilot/application/execute-table-use-case.ts b/apps/sim/lib/copilot/application/execute-table-use-case.ts index 536a05f9161..b973f5fd1bf 100644 --- a/apps/sim/lib/copilot/application/execute-table-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-table-use-case.ts @@ -1,8 +1,8 @@ -import { - type CopilotTableDelegationContext, - resolveCopilotTablePrincipal, -} from '@/lib/copilot/auth/table-delegation' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import type { CopilotTableDelegationContext } from '@/lib/copilot/auth/table-delegation' import type { OperationUseCase } from '@/lib/core/application' +import { tableDelegationPolicy } from '@/lib/table/application/authorization' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' import { resolveActiveTableContext, @@ -10,10 +10,6 @@ import { } from '@/lib/table/application/context' import { type TableOperation, tableOperations } from '@/lib/table/application/operations' -const registeredTableOperationIds = new Set( - Object.values(tableOperations).map((operation) => operation.id) -) - interface ExecuteCopilotTableUseCaseOptions { tableId?: string } @@ -23,6 +19,20 @@ export interface AdmitCopilotTableOperationInput { tableId?: string } +const executeTableUseCase = createCopilotApplicationAdapter< + TableOperation, + ExecuteCopilotTableUseCaseOptions +>({ + domain: 'table', + delegation: { + audience: tableDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: tableOperations, + projectResourceScope: ({ tableId }) => (tableId ? { tableId } : {}), +}) + /** Enters a registered table application use case under trusted Copilot delegation. */ export function executeCopilotTableUseCase( context: CopilotTableDelegationContext | undefined, @@ -30,13 +40,7 @@ export function executeCopilotTableUseCase( input: I, options: ExecuteCopilotTableUseCaseOptions = {} ): Promise { - if (!registeredTableOperationIds.has(useCase.operation.id)) { - throw new Error(`Unregistered Copilot table operation: ${useCase.operation.id}`) - } - return useCase.execute({ - principal: resolveCopilotTablePrincipal(context, options.tableId), - input, - }) + return executeTableUseCase(context, useCase, input, options) } /** diff --git a/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts b/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts deleted file mode 100644 index 5e18cd751a7..00000000000 --- a/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * @vitest-environment node - */ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' - -const operation = { - id: 'skills.update', - minimumRole: 'read' as const, - workspaceApiKey: 'deny' as const, - principalKinds: ['delegated'] as const, - delegatedServices: ['copilot'] as const, -} - -describe('Copilot workspace application delegation', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('builds a bounded principal from trusted runtime context, never tool input identity', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) - const execute = vi.fn().mockResolvedValue({ ok: true }) - const executeCopilotUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: 'sim:skills', - operations: { update: operation }, - }) - - await executeCopilotUseCase( - { - userId: 'trusted-user', - workspaceId: 'workspace-1', - chatId: 'chat-1', - executionId: 'execution-1', - toolCallId: 'call-1', - copilotToolExecution: true, - }, - { operation, execute }, - { userId: 'model-supplied-user', workspaceId: 'workspace-1' } - ) - - expect(execute).toHaveBeenCalledWith({ - principal: { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'trusted-user', - workspaceId: 'workspace-1', - delegationId: 'copilot-tool:call-1', - audience: 'sim:skills', - issuedAt: new Date('2026-01-01T00:00:00Z'), - expiresAt: new Date('2026-01-01T00:05:00Z'), - resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, - }, - input: { userId: 'model-supplied-user', workspaceId: 'workspace-1' }, - }) - }) - - it('fails fast for an untrusted execution context', async () => { - const execute = vi.fn() - const executeCopilotUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: 'sim:skills', - operations: { update: operation }, - }) - - expect(() => - executeCopilotUseCase( - { - userId: 'user-1', - workspaceId: 'workspace-1', - toolCallId: 'call-1', - copilotToolExecution: false, - }, - { operation, execute }, - { workspaceId: 'workspace-1' } - ) - ).toThrow('trusted Copilot execution context') - expect(execute).not.toHaveBeenCalled() - }) - - it('fails fast when a tool adapter tries an unregistered operation', () => { - const executeCopilotUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: 'sim:skills', - operations: { update: operation }, - }) - const unregistered = { ...operation, id: 'skills.unregistered' } - - expect(() => - executeCopilotUseCase( - { - userId: 'user-1', - workspaceId: 'workspace-1', - toolCallId: 'call-1', - copilotToolExecution: true, - }, - { operation: unregistered, execute: vi.fn() }, - { workspaceId: 'workspace-1' } - ) - ).toThrow('Unregistered Copilot workspace operation') - }) -}) diff --git a/apps/sim/lib/copilot/application/execute-workspace-use-case.ts b/apps/sim/lib/copilot/application/execute-workspace-use-case.ts deleted file mode 100644 index 03774dee872..00000000000 --- a/apps/sim/lib/copilot/application/execute-workspace-use-case.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - type CopilotWorkspaceDelegationContext, - createCopilotWorkspacePrincipal, -} from '@/lib/copilot/auth/workspace-application-delegation' -import type { OperationUseCase, WorkspaceOperation } from '@/lib/core/application' - -interface CopilotWorkspaceUseCaseExecutorOptions { - audience: string - operations: Readonly> -} - -/** Binds a domain registry to the trusted Copilot workspace execution runtime. */ -export function createCopilotWorkspaceUseCaseExecutor( - options: CopilotWorkspaceUseCaseExecutorOptions -) { - const registeredOperationIds = new Set( - Object.values(options.operations).map((operation) => operation.id) - ) - - return function executeCopilotWorkspaceUseCase( - context: CopilotWorkspaceDelegationContext | undefined, - useCase: OperationUseCase, - input: I - ): Promise { - if (!registeredOperationIds.has(useCase.operation.id)) { - throw new Error(`Unregistered Copilot workspace operation: ${useCase.operation.id}`) - } - - return useCase.execute({ - principal: createCopilotWorkspacePrincipal(context, { audience: options.audience }), - input, - }) - } -} diff --git a/apps/sim/lib/copilot/auth/application-delegation.test.ts b/apps/sim/lib/copilot/auth/application-delegation.test.ts new file mode 100644 index 00000000000..1e0bdc5037c --- /dev/null +++ b/apps/sim/lib/copilot/auth/application-delegation.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createCopilotApplicationPrincipal, + requireTrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} as const + +describe('Copilot application delegation', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each([ + [ + 'marker', + { ...trustedContext, copilotToolExecution: undefined }, + 'trusted Copilot execution context', + ], + ['user', { ...trustedContext, userId: undefined }, 'authenticated user ID'], + ['workspace', { ...trustedContext, workspaceId: undefined }, 'workspace ID'], + ['tool call', { ...trustedContext, toolCallId: undefined }, 'tool call ID'], + ])('rejects a missing or invalid trusted %s', (_field, context, message) => { + expect(() => requireTrustedCopilotExecutionContext(context)).toThrow(message) + }) + + it('creates an explicitly bounded Copilot principal from trusted context', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + + expect( + createCopilotApplicationPrincipal(trustedContext, { + audience: 'sim:files', + ttlMs: 5 * 60 * 1000, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + resourceScope: { fileId: 'file-1' }, + }) + ).toEqual({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + audience: 'sim:files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + resourceScope: { + fileId: 'file-1', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }) + }) +}) diff --git a/apps/sim/lib/copilot/auth/application-delegation.ts b/apps/sim/lib/copilot/auth/application-delegation.ts new file mode 100644 index 00000000000..969bf37b325 --- /dev/null +++ b/apps/sim/lib/copilot/auth/application-delegation.ts @@ -0,0 +1,138 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' + +export const COPILOT_APPLICATION_DELEGATION_TTL_MS = 5 * 60 * 1000 + +export interface CopilotExecutionContext { + userId?: string + workspaceId?: string + chatId?: string + executionId?: string + toolCallId?: string + copilotToolExecution?: boolean +} + +export interface TrustedCopilotExecutionContext extends CopilotExecutionContext { + userId: string + workspaceId: string + toolCallId: string + copilotToolExecution: true +} + +export type CopilotResourceScope = Pick< + NonNullable, + 'fileId' | 'tableId' +> + +export interface CopilotDelegationConfiguration { + audience: string + ttlMs: number + createDelegationId(context: TrustedCopilotExecutionContext): string +} + +interface CreateCopilotApplicationPrincipalOptions extends CopilotDelegationConfiguration { + resourceScope?: CopilotResourceScope +} + +interface CreateTrustedCopilotPrincipalInput { + userId: string + workspaceId: string + delegationId: string + chatId?: string + executionId?: string +} + +interface CreateTrustedCopilotPrincipalOptions { + audience: string + ttlMs: number + resourceScope?: CopilotResourceScope +} + +function requireNonEmpty(value: string | undefined, field: string): asserts value is string { + if (!value?.trim()) throw new Error(`Copilot execution context requires ${field}`) +} + +/** Validates and narrows the server-authored identity attached to a Copilot tool call. */ +export function requireTrustedCopilotExecutionContext( + context: CopilotExecutionContext | undefined +): TrustedCopilotExecutionContext { + if (!context) throw new Error('Copilot execution context is required') + if (context.copilotToolExecution !== true) { + throw new Error('Copilot execution context requires a trusted Copilot execution context') + } + requireNonEmpty(context.userId, 'an authenticated user ID') + requireNonEmpty(context.workspaceId, 'a workspace ID') + requireNonEmpty(context.toolCallId, 'a tool call ID') + if (context.chatId !== undefined) requireNonEmpty(context.chatId, 'a valid chat ID') + if (context.executionId !== undefined) { + requireNonEmpty(context.executionId, 'a valid execution ID') + } + + return Object.freeze({ + userId: context.userId, + workspaceId: context.workspaceId, + ...(context.chatId ? { chatId: context.chatId } : {}), + ...(context.executionId ? { executionId: context.executionId } : {}), + toolCallId: context.toolCallId, + copilotToolExecution: true, + }) +} + +/** Creates a bounded Copilot principal from an explicitly trusted server lifecycle. */ +export function createTrustedCopilotPrincipal( + input: CreateTrustedCopilotPrincipalInput, + options: CreateTrustedCopilotPrincipalOptions +): DelegatedPrincipal { + requireNonEmpty(input.userId, 'an authenticated user ID') + requireNonEmpty(input.workspaceId, 'a workspace ID') + requireNonEmpty(input.delegationId, 'a delegation ID') + if (input.chatId !== undefined) requireNonEmpty(input.chatId, 'a valid chat ID') + if (input.executionId !== undefined) requireNonEmpty(input.executionId, 'a valid execution ID') + requireNonEmpty(options.audience, 'a delegation audience') + if (!Number.isInteger(options.ttlMs) || options.ttlMs <= 0) { + throw new Error('Copilot application delegation requires a positive integer TTL') + } + if (options.resourceScope?.fileId !== undefined) { + requireNonEmpty(options.resourceScope.fileId, 'a valid file scope') + } + if (options.resourceScope?.tableId !== undefined) { + requireNonEmpty(options.resourceScope.tableId, 'a valid table scope') + } + + const issuedAt = new Date() + const resourceScope = Object.freeze({ + ...(options.resourceScope?.fileId ? { fileId: options.resourceScope.fileId } : {}), + ...(options.resourceScope?.tableId ? { tableId: options.resourceScope.tableId } : {}), + ...(input.chatId ? { chatId: input.chatId } : {}), + ...(input.executionId ? { executionId: input.executionId } : {}), + }) + + return Object.freeze({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: input.userId, + workspaceId: input.workspaceId, + delegationId: input.delegationId, + audience: options.audience, + issuedAt, + expiresAt: new Date(issuedAt.getTime() + options.ttlMs), + resourceScope, + }) +} + +/** Creates a bounded principal from a validated Copilot tool execution context. */ +export function createCopilotApplicationPrincipal( + trustedContext: TrustedCopilotExecutionContext, + options: CreateCopilotApplicationPrincipalOptions +): DelegatedPrincipal { + const delegationId = options.createDelegationId(trustedContext) + return createTrustedCopilotPrincipal( + { + userId: trustedContext.userId, + workspaceId: trustedContext.workspaceId, + delegationId, + chatId: trustedContext.chatId, + executionId: trustedContext.executionId, + }, + options + ) +} diff --git a/apps/sim/lib/copilot/auth/file-delegation.ts b/apps/sim/lib/copilot/auth/file-delegation.ts index 8f673083aae..bda9619cddc 100644 --- a/apps/sim/lib/copilot/auth/file-delegation.ts +++ b/apps/sim/lib/copilot/auth/file-delegation.ts @@ -1,15 +1,15 @@ import type { DelegatedPrincipal } from '@sim/auth/principal' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + type CopilotExecutionContext, + createCopilotApplicationPrincipal, + createTrustedCopilotPrincipal, + requireTrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import { workspaceFileDelegationPolicy } from '@/lib/workspace-files/application/authorization' -export interface CopilotFileDelegationContext { - userId: string - workspaceId?: string - chatId?: string - executionId?: string - toolCallId?: string - copilotToolExecution?: boolean -} +export type CopilotFileDelegationContext = CopilotExecutionContext export interface CopilotChatFileDelegationContext { userId: string @@ -22,32 +22,21 @@ export interface CopilotWorkspaceContextFileDelegationContext executionId?: string } +const fileDelegation = { + audience: workspaceFileDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context: Parameters[0]) => + `copilot-tool:${context.toolCallId}`, +} as const + /** Normalizes a trusted Copilot tool context into the shared file principal. */ export function resolveCopilotFilePrincipal( context: CopilotFileDelegationContext | undefined, fileId?: string ): DelegatedPrincipal { - if (!context) { - throw new Error('File delegation requires a Copilot execution context') - } - if (!context.copilotToolExecution) { - throw new Error('File delegation requires a trusted Copilot execution context') - } - if (!context.toolCallId) { - throw new Error('File delegation requires a tool call ID') - } - if (!context.workspaceId) { - throw new Error('File delegation requires a workspace ID') - } - - return createWorkspaceFileDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: `copilot-tool:${context.toolCallId}`, - fileId, - chatId: context.chatId, - executionId: context.executionId, + return createCopilotApplicationPrincipal(requireTrustedCopilotExecutionContext(context), { + ...fileDelegation, + resourceScope: fileId ? { fileId } : undefined, }) } @@ -55,34 +44,36 @@ export function resolveCopilotFilePrincipal( export function createCopilotChatFilePrincipal( context: CopilotChatFileDelegationContext ): DelegatedPrincipal { - return createWorkspaceFileDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: `copilot-chat:${context.chatId ?? context.workspaceId}`, - chatId: context.chatId, - }) + return createTrustedCopilotPrincipal( + { + userId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-chat:${context.chatId ?? context.workspaceId}`, + chatId: context.chatId, + }, + fileDelegation + ) } /** Creates the principal used while materializing the Copilot workspace index. */ export function createCopilotWorkspaceContextFilePrincipal( context: CopilotWorkspaceContextFileDelegationContext ): DelegatedPrincipal { - return createWorkspaceFileDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: `copilot-workspace-context:${context.chatId ?? context.executionId ?? context.workspaceId}`, - chatId: context.chatId, - executionId: context.executionId, - }) + return createTrustedCopilotPrincipal( + { + userId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-workspace-context:${context.chatId ?? context.executionId ?? context.workspaceId}`, + chatId: context.chatId, + executionId: context.executionId, + }, + fileDelegation + ) } export function messageForCopilotFileError( error: unknown, fallback = 'File operation failed' ): string { - const classified = asOrchestrationError(error) - if (classified && classified.code !== 'internal') return classified.message - return fallback + return messageForCopilotApplicationError(error, fallback) } diff --git a/apps/sim/lib/copilot/auth/table-delegation.ts b/apps/sim/lib/copilot/auth/table-delegation.ts index 219985dc404..83a055b99c1 100644 --- a/apps/sim/lib/copilot/auth/table-delegation.ts +++ b/apps/sim/lib/copilot/auth/table-delegation.ts @@ -1,36 +1,25 @@ import type { DelegatedPrincipal } from '@sim/auth/principal' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { createTableDelegatedPrincipal } from '@/lib/table/application/delegated-principal' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + type CopilotExecutionContext, + createCopilotApplicationPrincipal, + requireTrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import { tableDelegationPolicy } from '@/lib/table/application/authorization' -export interface CopilotTableDelegationContext { - userId: string - workspaceId?: string - chatId?: string - executionId?: string - toolCallId?: string - copilotToolExecution?: boolean -} +export type CopilotTableDelegationContext = CopilotExecutionContext /** Normalizes trusted Copilot execution context into the shared table principal. */ export function resolveCopilotTablePrincipal( context: CopilotTableDelegationContext | undefined, tableId?: string ): DelegatedPrincipal { - if (!context) throw new Error('Table delegation requires a Copilot execution context') - if (!context.copilotToolExecution) { - throw new Error('Table delegation requires a trusted Copilot execution context') - } - if (!context.toolCallId) throw new Error('Table delegation requires a tool call ID') - if (!context.workspaceId) throw new Error('Table delegation requires a workspace ID') - - return createTableDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: `copilot-tool:${context.toolCallId}`, - tableId, - chatId: context.chatId, - executionId: context.executionId, + return createCopilotApplicationPrincipal(requireTrustedCopilotExecutionContext(context), { + audience: tableDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (trustedContext) => `copilot-tool:${trustedContext.toolCallId}`, + resourceScope: tableId ? { tableId } : undefined, }) } @@ -38,7 +27,5 @@ export function messageForCopilotTableError( error: unknown, fallback = 'Table operation failed' ): string { - const classified = asOrchestrationError(error) - if (classified && classified.code !== 'internal') return classified.message - return fallback + return messageForCopilotApplicationError(error, fallback) } diff --git a/apps/sim/lib/copilot/auth/workspace-application-delegation.ts b/apps/sim/lib/copilot/auth/workspace-application-delegation.ts deleted file mode 100644 index d2c40aae739..00000000000 --- a/apps/sim/lib/copilot/auth/workspace-application-delegation.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' - -const COPILOT_WORKSPACE_DELEGATION_TTL_MS = 5 * 60 * 1000 - -export interface CopilotWorkspaceDelegationContext { - userId: string - workspaceId?: string - chatId?: string - executionId?: string - toolCallId?: string - copilotToolExecution?: boolean -} - -interface CreateCopilotWorkspacePrincipalOptions { - audience: string -} - -/** Creates a delegated principal exclusively from server-authored Copilot execution context. */ -export function createCopilotWorkspacePrincipal( - context: CopilotWorkspaceDelegationContext | undefined, - options: CreateCopilotWorkspacePrincipalOptions -): DelegatedPrincipal { - if (!context) throw new Error('Workspace delegation requires a Copilot execution context') - if (!context.copilotToolExecution) { - throw new Error('Workspace delegation requires a trusted Copilot execution context') - } - if (!context.toolCallId) throw new Error('Workspace delegation requires a tool call ID') - if (!context.workspaceId) throw new Error('Workspace delegation requires a workspace ID') - if (!options.audience) throw new Error('Workspace delegation requires an audience') - - const issuedAt = new Date() - return { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: `copilot-tool:${context.toolCallId}`, - audience: options.audience, - issuedAt, - expiresAt: new Date(issuedAt.getTime() + COPILOT_WORKSPACE_DELEGATION_TTL_MS), - resourceScope: { - ...(context.chatId ? { chatId: context.chatId } : {}), - ...(context.executionId ? { executionId: context.executionId } : {}), - }, - } -} diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts index 3b205556cce..0f574e56112 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts @@ -2,17 +2,25 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TOOL_RESULT_UNAVAILABLE_ERROR } from '@/lib/copilot/request/tools/resolved-secret-result' -const routeExecution = vi.hoisted(() => vi.fn()) +const mocks = vi.hoisted(() => ({ + loggerError: vi.fn(), + routeExecution: vi.fn(), +})) -vi.mock('@/lib/copilot/tools/server/router', () => ({ routeExecution })) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: mocks.loggerError }), +})) + +vi.mock('@/lib/copilot/tools/server/router', () => ({ routeExecution: mocks.routeExecution })) import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' describe('server tool adapter authority boundary', () => { beforeEach(() => { vi.clearAllMocks() - routeExecution.mockResolvedValue({ success: true }) + mocks.routeExecution.mockResolvedValue({ success: true }) }) it('overwrites model-supplied workspace scope and forwards trusted delegation context', async () => { @@ -30,7 +38,7 @@ describe('server tool adapter authority boundary', () => { } ) - expect(routeExecution).toHaveBeenCalledWith( + expect(mocks.routeExecution).toHaveBeenCalledWith( 'workspace_file', expect.objectContaining({ workspaceId: 'workspace-1', operation: 'rename' }), expect.objectContaining({ @@ -42,4 +50,34 @@ describe('server tool adapter authority boundary', () => { }) ) }) + + it('logs unexpected failures in full and returns only a generic system message', async () => { + const storageError = new Error('update workspace_files set secret_column = value') + mocks.routeExecution.mockRejectedValue(storageError) + + const result = await createServerToolHandler('workspace_file')( + {}, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + } + ) + + expect(result).toEqual({ + success: false, + error: `[workspace_file] ${TOOL_RESULT_UNAVAILABLE_ERROR}`, + }) + expect(result.error).not.toContain('workspace_files') + expect(mocks.loggerError).toHaveBeenCalledWith( + 'Server tool execution failed', + { + toolId: 'workspace_file', + abortSignalAborted: false, + }, + storageError + ) + }) }) diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index 552ed7a7b32..76e001fe4f5 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ToolExecutionResult, ToolHandler } from '@/lib/copilot/tool-executor/types' import { routeExecution } from '@/lib/copilot/tools/server/router' @@ -42,15 +43,22 @@ export function createServerToolHandler(toolId: string): ToolHandler { } return { success: true, output: result } } catch (error) { - const message = toError(error).message - logger.error('Server tool execution failed', { - toolId, - error: projectToolErrorMessageForCopilot(message, context.resolvedSecretTraceRegistry), - abortSignalAborted: context.abortSignal?.aborted ?? false, - }) + const caughtError = toError(error) + logger.error( + 'Server tool execution failed', + { + toolId, + abortSignalAborted: context.abortSignal?.aborted ?? false, + }, + caughtError + ) + const safeMessage = projectToolErrorMessageForCopilot( + messageForCopilotApplicationError(error), + context.resolvedSecretTraceRegistry + ) return { success: false, - error: `[${toolId}] ${message}`, + error: `[${toolId}] ${safeMessage}`, } } } diff --git a/apps/sim/lib/knowledge/application/authorization.test.ts b/apps/sim/lib/knowledge/application/authorization.test.ts index 4952e6f787b..bb5ea1030d9 100644 --- a/apps/sim/lib/knowledge/application/authorization.test.ts +++ b/apps/sim/lib/knowledge/application/authorization.test.ts @@ -8,17 +8,25 @@ import { KNOWLEDGE_DELEGATION_AUDIENCE, knowledgeDelegationPolicy, } from '@/lib/knowledge/application/authorization' -import { createKnowledgeDelegatedPrincipal } from '@/lib/knowledge/application/delegated-principal' + +function createKnowledgePrincipal(overrides: Partial = {}): DelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: KNOWLEDGE_DELEGATION_AUDIENCE, + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { chatId: 'chat-1' }, + ...overrides, + } +} describe('knowledge delegation policy', () => { it('binds trusted delegation to the canonical workspace and audience', () => { - const principal = createKnowledgeDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'tool-call-1', - chatId: 'chat-1', - }) + const principal = createKnowledgePrincipal() expect(principal.audience).toBe(KNOWLEDGE_DELEGATION_AUDIENCE) expect(principal.resourceScope).toEqual({ chatId: 'chat-1' }) @@ -39,16 +47,7 @@ describe('knowledge delegation policy', () => { }) it('does not accept a model-authored audience', () => { - const principal: DelegatedPrincipal = { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'tool-call-1', - audience: 'model:chosen', - issuedAt: new Date(), - expiresAt: new Date(Date.now() + 60_000), - } + const principal = createKnowledgePrincipal({ audience: 'model:chosen' }) expect(principal.audience).not.toBe(knowledgeDelegationPolicy.audience) }) diff --git a/apps/sim/lib/knowledge/application/delegated-principal.ts b/apps/sim/lib/knowledge/application/delegated-principal.ts deleted file mode 100644 index ac0db06e992..00000000000 --- a/apps/sim/lib/knowledge/application/delegated-principal.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' -import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' - -const KNOWLEDGE_DELEGATION_TTL_MS = 5 * 60 * 1000 - -export interface CreateKnowledgeDelegatedPrincipalInput { - serviceId: DelegatedPrincipal['serviceId'] - subjectUserId: string - workspaceId: string - delegationId: string - chatId?: string - executionId?: string -} - -export function createKnowledgeDelegatedPrincipal( - input: CreateKnowledgeDelegatedPrincipalInput -): DelegatedPrincipal { - const issuedAt = new Date() - return { - kind: 'delegated', - serviceId: input.serviceId, - subjectUserId: input.subjectUserId, - workspaceId: input.workspaceId, - delegationId: input.delegationId, - audience: KNOWLEDGE_DELEGATION_AUDIENCE, - issuedAt, - expiresAt: new Date(issuedAt.getTime() + KNOWLEDGE_DELEGATION_TTL_MS), - resourceScope: { - ...(input.chatId ? { chatId: input.chatId } : {}), - ...(input.executionId ? { executionId: input.executionId } : {}), - }, - } -} diff --git a/apps/sim/lib/table/application/delegated-principal.ts b/apps/sim/lib/table/application/delegated-principal.ts deleted file mode 100644 index db2c3365c61..00000000000 --- a/apps/sim/lib/table/application/delegated-principal.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' -import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' - -const TABLE_DELEGATION_TTL_MS = 5 * 60 * 1000 - -export interface TableDelegationInput { - serviceId: DelegatedPrincipal['serviceId'] - subjectUserId: string - workspaceId: string - delegationId: string - tableId?: string - chatId?: string - executionId?: string -} - -export function createTableDelegatedPrincipal(input: TableDelegationInput): DelegatedPrincipal { - if (!input.subjectUserId || !input.workspaceId || !input.delegationId) { - throw new Error('Table delegation requires subject, workspace, and delegation IDs') - } - const issuedAt = new Date() - return { - kind: 'delegated', - serviceId: input.serviceId, - subjectUserId: input.subjectUserId, - workspaceId: input.workspaceId, - delegationId: input.delegationId, - audience: TABLE_DELEGATION_AUDIENCE, - issuedAt, - expiresAt: new Date(issuedAt.getTime() + TABLE_DELEGATION_TTL_MS), - resourceScope: { - ...(input.tableId ? { tableId: input.tableId } : {}), - ...(input.chatId ? { chatId: input.chatId } : {}), - ...(input.executionId ? { executionId: input.executionId } : {}), - }, - } -}