Skip to content

Commit f6813af

Browse files
improvement(copilot): consolidate application adapters
1 parent 2cab7d7 commit f6813af

22 files changed

Lines changed: 736 additions & 321 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE,
7+
messageForCopilotApplicationError,
8+
} from '@/lib/copilot/application/error'
9+
import { OrchestrationError } from '@/lib/core/orchestration/types'
10+
11+
describe('Copilot application error projection', () => {
12+
it('exposes only non-internal application errors', () => {
13+
expect(
14+
messageForCopilotApplicationError(new OrchestrationError('conflict', 'Name already exists'))
15+
).toBe('Name already exists')
16+
})
17+
18+
it('projects internal and unknown infrastructure failures to a generic retryable message', () => {
19+
expect(
20+
messageForCopilotApplicationError(
21+
new OrchestrationError('internal', 'select secret_column from workspace_files')
22+
)
23+
).toBe(COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE)
24+
expect(messageForCopilotApplicationError(new Error('storage bucket credential rejected'))).toBe(
25+
COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE
26+
)
27+
})
28+
29+
it('supports a caller-defined safe fallback without exposing the cause', () => {
30+
expect(
31+
messageForCopilotApplicationError(
32+
new Error('update workspace_files set content = raw'),
33+
'File operation failed. Please retry.'
34+
)
35+
).toBe('File operation failed. Please retry.')
36+
})
37+
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { asOrchestrationError } from '@/lib/core/orchestration/types'
2+
3+
export const COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE =
4+
'The operation failed due to a system error. Please retry.'
5+
6+
/** Projects only caller-actionable application failures into Copilot-visible content. */
7+
export function messageForCopilotApplicationError(
8+
error: unknown,
9+
fallback = COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE
10+
): string {
11+
const classified = asOrchestrationError(error)
12+
return classified && classified.code !== 'internal' ? classified.message : fallback
13+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { DelegatedPrincipal } from '@sim/auth/principal'
5+
import { describe, expect, it, vi } from 'vitest'
6+
import { createCopilotApplicationUseCaseExecutor } from '@/lib/copilot/application/execute-application-use-case'
7+
import {
8+
type CopilotDelegationConfiguration,
9+
type CopilotResourceScope,
10+
createCopilotApplicationPrincipal,
11+
type TrustedCopilotExecutionContext,
12+
} from '@/lib/copilot/auth/application-delegation'
13+
import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application'
14+
15+
const operation = defineWorkspaceOperation({
16+
id: 'files.read',
17+
minimumRole: 'read',
18+
workspaceApiKey: 'deny',
19+
principalKinds: ['delegated'],
20+
})
21+
22+
const delegation = {
23+
serviceId: 'copilot',
24+
audience: 'sim:files',
25+
ttlMs: 5 * 60 * 1000,
26+
createDelegationId: (context) => `copilot-tool:${context.toolCallId}`,
27+
} as const satisfies CopilotDelegationConfiguration
28+
29+
const trustedContext = {
30+
userId: 'trusted-user',
31+
workspaceId: 'workspace-1',
32+
chatId: 'chat-1',
33+
executionId: 'execution-1',
34+
toolCallId: 'tool-call-1',
35+
copilotToolExecution: true,
36+
} as const
37+
38+
interface FileScopeInput {
39+
fileId: string
40+
}
41+
42+
function createExecutor(
43+
createPrincipal?: (args: {
44+
context: TrustedCopilotExecutionContext
45+
resourceScope: CopilotResourceScope
46+
}) => DelegatedPrincipal
47+
) {
48+
return createCopilotApplicationUseCaseExecutor<WorkspaceOperation, FileScopeInput>({
49+
domain: 'file',
50+
delegation,
51+
operations: { read: operation },
52+
projectResourceScope: ({ fileId }) => ({ fileId }),
53+
createPrincipal,
54+
})
55+
}
56+
57+
describe('Copilot application use-case executor', () => {
58+
it('binds only code-projected scope and leaves model input non-authoritative', async () => {
59+
const execute = vi.fn().mockResolvedValue({ ok: true })
60+
61+
await createExecutor()(
62+
trustedContext,
63+
{ operation, execute },
64+
{ fileId: 'model-forged-file' },
65+
{ fileId: 'trusted-file' }
66+
)
67+
68+
expect(execute).toHaveBeenCalledWith({
69+
principal: expect.objectContaining({
70+
subjectUserId: 'trusted-user',
71+
workspaceId: 'workspace-1',
72+
resourceScope: {
73+
fileId: 'trusted-file',
74+
chatId: 'chat-1',
75+
executionId: 'execution-1',
76+
},
77+
}),
78+
input: { fileId: 'model-forged-file' },
79+
})
80+
})
81+
82+
it('rejects unregistered and same-ID forged operation objects', () => {
83+
const executeCopilotUseCase = createExecutor()
84+
const unregistered = defineWorkspaceOperation({
85+
id: 'files.unregistered',
86+
minimumRole: 'read',
87+
workspaceApiKey: 'deny',
88+
principalKinds: ['delegated'],
89+
})
90+
const sameIdDifferentPolicy = defineWorkspaceOperation({
91+
id: operation.id,
92+
minimumRole: 'write',
93+
workspaceApiKey: 'deny',
94+
principalKinds: ['delegated'],
95+
})
96+
97+
expect(() =>
98+
executeCopilotUseCase(
99+
trustedContext,
100+
{ operation: unregistered, execute: vi.fn() },
101+
{},
102+
{ fileId: 'file-1' }
103+
)
104+
).toThrow('Unregistered Copilot file operation')
105+
expect(() =>
106+
executeCopilotUseCase(
107+
trustedContext,
108+
{ operation: sameIdDifferentPolicy, execute: vi.fn() },
109+
{},
110+
{ fileId: 'file-1' }
111+
)
112+
).toThrow('Unregistered Copilot file operation')
113+
})
114+
115+
it('rejects a principal factory that changes the configured audience', () => {
116+
const execute = vi.fn()
117+
const executeCopilotUseCase = createExecutor(({ context, resourceScope }) => ({
118+
...createCopilotApplicationPrincipal(context, { ...delegation, resourceScope }),
119+
audience: 'sim:forged',
120+
}))
121+
122+
expect(() =>
123+
executeCopilotUseCase(trustedContext, { operation, execute }, {}, { fileId: 'file-1' })
124+
).toThrow('configured delegation identity')
125+
expect(execute).not.toHaveBeenCalled()
126+
})
127+
128+
it('rejects an expired principal before application execution', () => {
129+
const execute = vi.fn()
130+
const executeCopilotUseCase = createExecutor(({ context, resourceScope }) => {
131+
const principal = createCopilotApplicationPrincipal(context, {
132+
...delegation,
133+
resourceScope,
134+
})
135+
return { ...principal, expiresAt: new Date(principal.issuedAt.getTime() - 1) }
136+
})
137+
138+
expect(() =>
139+
executeCopilotUseCase(trustedContext, { operation, execute }, {}, { fileId: 'file-1' })
140+
).toThrow('configured delegation expiry')
141+
expect(execute).not.toHaveBeenCalled()
142+
})
143+
144+
it('rejects a principal factory scoped to a different resource', () => {
145+
const execute = vi.fn()
146+
const executeCopilotUseCase = createExecutor(({ context, resourceScope }) => {
147+
const principal = createCopilotApplicationPrincipal(context, {
148+
...delegation,
149+
resourceScope,
150+
})
151+
return { ...principal, resourceScope: { ...principal.resourceScope, fileId: 'file-2' } }
152+
})
153+
154+
expect(() =>
155+
executeCopilotUseCase(trustedContext, { operation, execute }, {}, { fileId: 'file-1' })
156+
).toThrow('configured resource scope')
157+
expect(execute).not.toHaveBeenCalled()
158+
})
159+
})
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { DelegatedPrincipal } from '@sim/auth/principal'
2+
import {
3+
type CopilotDelegationConfiguration,
4+
type CopilotExecutionContext,
5+
type CopilotResourceScope,
6+
createCopilotApplicationPrincipal,
7+
requireTrustedCopilotExecutionContext,
8+
type TrustedCopilotExecutionContext,
9+
} from '@/lib/copilot/auth/application-delegation'
10+
import type { OperationUseCase, WorkspaceOperation } from '@/lib/core/application'
11+
12+
type CopilotApplicationPrincipalFactory = (args: {
13+
context: TrustedCopilotExecutionContext
14+
resourceScope: CopilotResourceScope
15+
}) => DelegatedPrincipal
16+
17+
interface CopilotApplicationUseCaseExecutorOptions<
18+
O extends WorkspaceOperation,
19+
ScopeInput = undefined,
20+
> {
21+
domain: string
22+
delegation: CopilotDelegationConfiguration
23+
operations: Readonly<Record<string, O>>
24+
projectResourceScope?(
25+
input: ScopeInput,
26+
context: TrustedCopilotExecutionContext
27+
): CopilotResourceScope
28+
createPrincipal?: CopilotApplicationPrincipalFactory
29+
}
30+
31+
type ScopeArguments<ScopeInput> = [ScopeInput] extends [undefined] ? [] : [scope: ScopeInput]
32+
33+
const RESOURCE_SCOPE_KEYS = ['fileId', 'tableId', 'chatId', 'executionId'] as const
34+
35+
function requireValidProjectedResourceScope(resourceScope: CopilotResourceScope): void {
36+
if (resourceScope.fileId !== undefined && !resourceScope.fileId.trim()) {
37+
throw new Error('Copilot application resource scope contains an invalid file ID')
38+
}
39+
if (resourceScope.tableId !== undefined && !resourceScope.tableId.trim()) {
40+
throw new Error('Copilot application resource scope contains an invalid table ID')
41+
}
42+
}
43+
44+
function expectedResourceScope(
45+
context: TrustedCopilotExecutionContext,
46+
resourceScope: CopilotResourceScope
47+
): NonNullable<DelegatedPrincipal['resourceScope']> {
48+
return {
49+
...resourceScope,
50+
...(context.chatId ? { chatId: context.chatId } : {}),
51+
...(context.executionId ? { executionId: context.executionId } : {}),
52+
}
53+
}
54+
55+
function requireMatchingPrincipal(
56+
principal: DelegatedPrincipal,
57+
context: TrustedCopilotExecutionContext,
58+
delegation: CopilotDelegationConfiguration,
59+
resourceScope: CopilotResourceScope
60+
): void {
61+
const delegationId = delegation.createDelegationId(context)
62+
if (
63+
principal.kind !== 'delegated' ||
64+
principal.serviceId !== delegation.serviceId ||
65+
principal.subjectUserId !== context.userId ||
66+
principal.workspaceId !== context.workspaceId ||
67+
!delegationId.trim() ||
68+
principal.delegationId !== delegationId ||
69+
principal.audience !== delegation.audience
70+
) {
71+
throw new Error('Copilot principal factory violated the configured delegation identity')
72+
}
73+
74+
const issuedAt = principal.issuedAt.getTime()
75+
const expiresAt = principal.expiresAt.getTime()
76+
if (
77+
!Number.isFinite(issuedAt) ||
78+
!Number.isFinite(expiresAt) ||
79+
issuedAt > Date.now() ||
80+
expiresAt <= Date.now() ||
81+
expiresAt - issuedAt !== delegation.ttlMs
82+
) {
83+
throw new Error('Copilot principal factory violated the configured delegation expiry')
84+
}
85+
86+
const expectedScope = expectedResourceScope(context, resourceScope)
87+
if (RESOURCE_SCOPE_KEYS.some((key) => principal.resourceScope?.[key] !== expectedScope[key])) {
88+
throw new Error('Copilot principal factory violated the configured resource scope')
89+
}
90+
}
91+
92+
/** Binds a domain operation registry and delegation policy to the trusted Copilot runtime. */
93+
export function createCopilotApplicationUseCaseExecutor<
94+
O extends WorkspaceOperation,
95+
ScopeInput = undefined,
96+
>(options: CopilotApplicationUseCaseExecutorOptions<O, ScopeInput>) {
97+
if (!options.domain.trim()) throw new Error('Copilot application executor requires a domain')
98+
if (options.delegation.serviceId !== 'copilot') {
99+
throw new Error('Copilot application executor requires the Copilot service identity')
100+
}
101+
if (!options.delegation.audience.trim()) {
102+
throw new Error('Copilot application executor requires a delegation audience')
103+
}
104+
if (!Number.isInteger(options.delegation.ttlMs) || options.delegation.ttlMs <= 0) {
105+
throw new Error('Copilot application executor requires a positive integer delegation TTL')
106+
}
107+
108+
const operations = Object.values(options.operations)
109+
if (operations.length === 0) {
110+
throw new Error(`Copilot ${options.domain} operation registry cannot be empty`)
111+
}
112+
const operationIds = new Set<string>()
113+
for (const operation of operations) {
114+
if (!Object.isFrozen(operation)) {
115+
throw new Error(`Copilot ${options.domain} operation ${operation.id} must be immutable`)
116+
}
117+
if (operationIds.has(operation.id)) {
118+
throw new Error(`Copilot ${options.domain} operation registry contains duplicate IDs`)
119+
}
120+
operationIds.add(operation.id)
121+
}
122+
const registeredOperations = new Set<O>(operations)
123+
124+
return function executeCopilotApplicationUseCase<Selected extends O, I, R>(
125+
context: CopilotExecutionContext | undefined,
126+
useCase: OperationUseCase<Selected, I, R>,
127+
input: I,
128+
...scopeArguments: ScopeArguments<ScopeInput>
129+
): Promise<R> {
130+
if (!registeredOperations.has(useCase.operation)) {
131+
throw new Error(`Unregistered Copilot ${options.domain} operation: ${useCase.operation.id}`)
132+
}
133+
134+
const trustedContext = requireTrustedCopilotExecutionContext(context)
135+
let resourceScope: CopilotResourceScope = {}
136+
if (options.projectResourceScope) {
137+
if (scopeArguments.length !== 1) {
138+
throw new Error(`Copilot ${options.domain} execution requires trusted scope input`)
139+
}
140+
resourceScope = options.projectResourceScope(scopeArguments[0], trustedContext)
141+
} else if (scopeArguments.length !== 0) {
142+
throw new Error(`Copilot ${options.domain} execution does not accept resource scope input`)
143+
}
144+
requireValidProjectedResourceScope(resourceScope)
145+
146+
const principal = options.createPrincipal
147+
? options.createPrincipal({ context: trustedContext, resourceScope })
148+
: createCopilotApplicationPrincipal(trustedContext, {
149+
...options.delegation,
150+
resourceScope,
151+
})
152+
requireMatchingPrincipal(principal, trustedContext, options.delegation, resourceScope)
153+
154+
return useCase.execute({ principal, input })
155+
}
156+
}
Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case'
1+
import { createCopilotApplicationUseCaseExecutor } from '@/lib/copilot/application/execute-application-use-case'
22
import { CUSTOM_TOOL_DELEGATION_AUDIENCE } from '@/lib/custom-tools/application/authorization'
33
import { customToolOperations } from '@/lib/custom-tools/application/operations'
44

5-
export const executeCopilotCustomToolUseCase = createCopilotWorkspaceUseCaseExecutor({
6-
audience: CUSTOM_TOOL_DELEGATION_AUDIENCE,
5+
const COPILOT_CUSTOM_TOOL_DELEGATION_TTL_MS = 5 * 60 * 1000
6+
7+
export const executeCopilotCustomToolUseCase = createCopilotApplicationUseCaseExecutor({
8+
domain: 'custom tool',
9+
delegation: {
10+
serviceId: 'copilot',
11+
audience: CUSTOM_TOOL_DELEGATION_AUDIENCE,
12+
ttlMs: COPILOT_CUSTOM_TOOL_DELEGATION_TTL_MS,
13+
createDelegationId: (context) => `copilot-tool:${context.toolCallId}`,
14+
},
715
operations: customToolOperations,
816
})

0 commit comments

Comments
 (0)