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
191 changes: 191 additions & 0 deletions apps/sim/lib/copilot/application/application-adapter.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<WorkspaceOperation, FileScopeInput>({
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()
})
})
155 changes: 155 additions & 0 deletions apps/sim/lib/copilot/application/application-adapter.ts
Original file line numberDiff line numberDiff line change
@@ -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<O extends WorkspaceOperation, ScopeInput = undefined> {
domain: string
delegation: CopilotDelegationConfiguration
operations: Readonly<Record<string, O>>
projectResourceScope?(
input: ScopeInput,
context: TrustedCopilotExecutionContext
): CopilotResourceScope
createPrincipal?: CopilotApplicationPrincipalFactory
}

type ScopeArguments<ScopeInput> = [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<DelegatedPrincipal['resourceScope']> {
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<O, ScopeInput>) {
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<string>()
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<O>(operations)

return function executeCopilotApplicationUseCase<Selected extends O, I, R>(
context: CopilotExecutionContext | undefined,
useCase: OperationUseCase<Selected, I, R>,
input: I,
...scopeArguments: ScopeArguments<ScopeInput>
): Promise<R> {
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 })
}
}
37 changes: 37 additions & 0 deletions apps/sim/lib/copilot/application/error.test.ts
Original file line numberDiff line numberDiff line change
@@ -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.')
})
})
13 changes: 13 additions & 0 deletions apps/sim/lib/copilot/application/error.ts
Original file line numberDiff line numberDiff line change
@@ -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
}
Loading
Loading