Skip to content

Commit 80f0569

Browse files
improvement(copilot): consolidate application adapters
1 parent aae5354 commit 80f0569

22 files changed

Lines changed: 733 additions & 321 deletions
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 { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter'
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 createCopilotApplicationAdapter<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: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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 CopilotApplicationAdapterOptions<O extends WorkspaceOperation, ScopeInput = undefined> {
18+
domain: string
19+
delegation: CopilotDelegationConfiguration
20+
operations: Readonly<Record<string, O>>
21+
projectResourceScope?(
22+
input: ScopeInput,
23+
context: TrustedCopilotExecutionContext
24+
): CopilotResourceScope
25+
createPrincipal?: CopilotApplicationPrincipalFactory
26+
}
27+
28+
type ScopeArguments<ScopeInput> = [ScopeInput] extends [undefined] ? [] : [scope: ScopeInput]
29+
30+
const RESOURCE_SCOPE_KEYS = ['fileId', 'tableId', 'chatId', 'executionId'] as const
31+
32+
function requireValidProjectedResourceScope(resourceScope: CopilotResourceScope): void {
33+
if (resourceScope.fileId !== undefined && !resourceScope.fileId.trim()) {
34+
throw new Error('Copilot application resource scope contains an invalid file ID')
35+
}
36+
if (resourceScope.tableId !== undefined && !resourceScope.tableId.trim()) {
37+
throw new Error('Copilot application resource scope contains an invalid table ID')
38+
}
39+
}
40+
41+
function expectedResourceScope(
42+
context: TrustedCopilotExecutionContext,
43+
resourceScope: CopilotResourceScope
44+
): NonNullable<DelegatedPrincipal['resourceScope']> {
45+
return {
46+
...resourceScope,
47+
...(context.chatId ? { chatId: context.chatId } : {}),
48+
...(context.executionId ? { executionId: context.executionId } : {}),
49+
}
50+
}
51+
52+
function requireMatchingPrincipal(
53+
principal: DelegatedPrincipal,
54+
context: TrustedCopilotExecutionContext,
55+
delegation: CopilotDelegationConfiguration,
56+
resourceScope: CopilotResourceScope
57+
): void {
58+
const delegationId = delegation.createDelegationId(context)
59+
if (
60+
principal.kind !== 'delegated' ||
61+
principal.serviceId !== delegation.serviceId ||
62+
principal.subjectUserId !== context.userId ||
63+
principal.workspaceId !== context.workspaceId ||
64+
!delegationId.trim() ||
65+
principal.delegationId !== delegationId ||
66+
principal.audience !== delegation.audience
67+
) {
68+
throw new Error('Copilot principal factory violated the configured delegation identity')
69+
}
70+
71+
const issuedAt = principal.issuedAt.getTime()
72+
const expiresAt = principal.expiresAt.getTime()
73+
if (
74+
!Number.isFinite(issuedAt) ||
75+
!Number.isFinite(expiresAt) ||
76+
issuedAt > Date.now() ||
77+
expiresAt <= Date.now() ||
78+
expiresAt - issuedAt !== delegation.ttlMs
79+
) {
80+
throw new Error('Copilot principal factory violated the configured delegation expiry')
81+
}
82+
83+
const expectedScope = expectedResourceScope(context, resourceScope)
84+
if (RESOURCE_SCOPE_KEYS.some((key) => principal.resourceScope?.[key] !== expectedScope[key])) {
85+
throw new Error('Copilot principal factory violated the configured resource scope')
86+
}
87+
}
88+
89+
/** Adapts trusted Copilot calls to a domain's existing application use cases. */
90+
export function createCopilotApplicationAdapter<
91+
O extends WorkspaceOperation,
92+
ScopeInput = undefined,
93+
>(options: CopilotApplicationAdapterOptions<O, ScopeInput>) {
94+
if (!options.domain.trim()) throw new Error('Copilot application executor requires a domain')
95+
if (options.delegation.serviceId !== 'copilot') {
96+
throw new Error('Copilot application executor requires the Copilot service identity')
97+
}
98+
if (!options.delegation.audience.trim()) {
99+
throw new Error('Copilot application executor requires a delegation audience')
100+
}
101+
if (!Number.isInteger(options.delegation.ttlMs) || options.delegation.ttlMs <= 0) {
102+
throw new Error('Copilot application executor requires a positive integer delegation TTL')
103+
}
104+
105+
const operations = Object.values(options.operations)
106+
if (operations.length === 0) {
107+
throw new Error(`Copilot ${options.domain} operation registry cannot be empty`)
108+
}
109+
const operationIds = new Set<string>()
110+
for (const operation of operations) {
111+
if (!Object.isFrozen(operation)) {
112+
throw new Error(`Copilot ${options.domain} operation ${operation.id} must be immutable`)
113+
}
114+
if (operationIds.has(operation.id)) {
115+
throw new Error(`Copilot ${options.domain} operation registry contains duplicate IDs`)
116+
}
117+
operationIds.add(operation.id)
118+
}
119+
const registeredOperations = new Set<O>(operations)
120+
121+
return function executeCopilotApplicationUseCase<Selected extends O, I, R>(
122+
context: CopilotExecutionContext | undefined,
123+
useCase: OperationUseCase<Selected, I, R>,
124+
input: I,
125+
...scopeArguments: ScopeArguments<ScopeInput>
126+
): Promise<R> {
127+
if (!registeredOperations.has(useCase.operation)) {
128+
throw new Error(`Unregistered Copilot ${options.domain} operation: ${useCase.operation.id}`)
129+
}
130+
131+
const trustedContext = requireTrustedCopilotExecutionContext(context)
132+
let resourceScope: CopilotResourceScope = {}
133+
if (options.projectResourceScope) {
134+
if (scopeArguments.length !== 1) {
135+
throw new Error(`Copilot ${options.domain} execution requires trusted scope input`)
136+
}
137+
resourceScope = options.projectResourceScope(scopeArguments[0], trustedContext)
138+
} else if (scopeArguments.length !== 0) {
139+
throw new Error(`Copilot ${options.domain} execution does not accept resource scope input`)
140+
}
141+
requireValidProjectedResourceScope(resourceScope)
142+
143+
const principal = options.createPrincipal
144+
? options.createPrincipal({ context: trustedContext, resourceScope })
145+
: createCopilotApplicationPrincipal(trustedContext, {
146+
...options.delegation,
147+
resourceScope,
148+
})
149+
requireMatchingPrincipal(principal, trustedContext, options.delegation, resourceScope)
150+
151+
return useCase.execute({ principal, input })
152+
}
153+
}
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: 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 { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter'
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 = createCopilotApplicationAdapter({
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)