Skip to content

Commit acc4f1d

Browse files
fix(executor): preserve actors for actorless tool calls (#7230)
* fix(executor): preserve actors for actorless tool calls * fix(auth): bind legacy execution actors to principals
1 parent 1ade0f4 commit acc4f1d

37 files changed

Lines changed: 667 additions & 197 deletions

apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ import type { ExecutionContext } from '@/executor/types'
88
import type { SerializedBlock } from '@/serializer/types'
99

1010
const mocks = vi.hoisted(() => ({
11-
authenticate: vi.fn(),
12-
buildHeaders: vi.fn(),
11+
createPrincipal: vi.fn(),
1312
createInviteLink: vi.fn(),
1413
enforceInviteRateLimit: vi.fn(),
1514
listCredentials: vi.fn(),
@@ -22,10 +21,6 @@ vi.mock('@/lib/credential-groups/application/create-invite-link', () => ({
2221
createCredentialGroupInviteLink: { execute: mocks.createInviteLink },
2322
}))
2423

25-
vi.mock('@/lib/credential-groups/application/delegation', () => ({
26-
authenticateCredentialGroupDelegation: mocks.authenticate,
27-
}))
28-
2924
vi.mock('@/lib/credential-groups/application/list-credentials', () => ({
3025
listCredentialGroupCredentials: { execute: mocks.listCredentials },
3126
}))
@@ -53,8 +48,8 @@ vi.mock('@/lib/credential-groups/rate-limit', () => ({
5348
enforceCredentialGroupInvitationExecutionRateLimit: mocks.enforceInviteRateLimit,
5449
}))
5550

56-
vi.mock('@/executor/utils/http', () => ({
57-
buildExecutorDelegationHeaders: mocks.buildHeaders,
51+
vi.mock('@/lib/internal/principals/executor', () => ({
52+
createExecutorPrincipalFromExecutionContext: mocks.createPrincipal,
5853
}))
5954

6055
import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler'
@@ -92,8 +87,7 @@ const block = { metadata: { id: BlockType.CREDENTIAL_GROUP } } as SerializedBloc
9287
describe('CredentialGroupBlockHandler', () => {
9388
beforeEach(() => {
9489
vi.clearAllMocks()
95-
mocks.buildHeaders.mockResolvedValue({ Authorization: 'Bearer executor-token' })
96-
mocks.authenticate.mockResolvedValue(principal)
90+
mocks.createPrincipal.mockResolvedValue(principal)
9791
})
9892

9993
it('recognizes only Credential Group blocks', () => {
@@ -122,7 +116,11 @@ describe('CredentialGroupBlockHandler', () => {
122116
cursor: ' credential-1 ',
123117
})
124118

125-
expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', 'group-1')
119+
expect(mocks.createPrincipal).toHaveBeenCalledWith({
120+
context,
121+
audience: 'sim:credential-groups',
122+
resourceScope: { credentialGroupId: 'group-1' },
123+
})
126124
expect(mocks.listCredentials).toHaveBeenCalledWith({
127125
principal,
128126
input: {
@@ -136,6 +134,73 @@ describe('CredentialGroupBlockHandler', () => {
136134
expect(result).toEqual({ credentials: [], count: 0, hasMore: false, nextCursor: null })
137135
})
138136

137+
it('lists credentials for an actorless workflow execution', async () => {
138+
const executionPrincipal = {
139+
kind: 'system' as const,
140+
serviceId: 'schedule' as const,
141+
workspaceId: 'workspace-1',
142+
workflowId: 'workflow-1',
143+
}
144+
const actorlessPrincipal: WorkflowExecutionDelegatedPrincipal = {
145+
kind: 'delegated',
146+
serviceId: 'executor',
147+
workspaceId: 'workspace-1',
148+
delegationId: 'delegation-actorless',
149+
audience: 'sim:credential-groups',
150+
issuedAt: new Date(Date.now() - 1_000),
151+
expiresAt: new Date(Date.now() + 60_000),
152+
resourceScope: { credentialGroupId: 'group-1' },
153+
delegationContext: {
154+
kind: 'workflow_execution',
155+
workflowId: 'workflow-1',
156+
principal: executionPrincipal,
157+
currentWorkflow: {
158+
workflowId: 'workflow-1',
159+
mode: 'deployment',
160+
deploymentVersionId: 'deployment-version-1',
161+
},
162+
},
163+
}
164+
const actorlessContext = {
165+
...context,
166+
userId: undefined,
167+
principal: executionPrincipal,
168+
executorDelegationOrigin: {
169+
workflowId: 'workflow-1',
170+
principal: executionPrincipal,
171+
currentWorkflow: actorlessPrincipal.delegationContext.currentWorkflow,
172+
},
173+
} as ExecutionContext
174+
mocks.createPrincipal.mockResolvedValueOnce(actorlessPrincipal)
175+
mocks.listCredentials.mockResolvedValue({
176+
credentials: [],
177+
count: 0,
178+
hasMore: false,
179+
nextCursor: null,
180+
})
181+
182+
await new CredentialGroupBlockHandler().execute(actorlessContext, block, {
183+
operation: 'list_credentials',
184+
credentialGroupId: 'group-1',
185+
})
186+
187+
expect(mocks.createPrincipal).toHaveBeenCalledWith({
188+
context: actorlessContext,
189+
audience: 'sim:credential-groups',
190+
resourceScope: { credentialGroupId: 'group-1' },
191+
})
192+
expect(mocks.listCredentials).toHaveBeenCalledWith({
193+
principal: actorlessPrincipal,
194+
input: {
195+
credentialGroupId: 'group-1',
196+
limit: 100,
197+
cursor: undefined,
198+
email: undefined,
199+
credentialProviderIds: undefined,
200+
},
201+
})
202+
})
203+
139204
it('lists groups under workspace-scoped delegation', async () => {
140205
mocks.listGroups.mockResolvedValue({
141206
credentialGroups: [],
@@ -149,7 +214,10 @@ describe('CredentialGroupBlockHandler', () => {
149214
limit: 10,
150215
})
151216

152-
expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', undefined)
217+
expect(mocks.createPrincipal).toHaveBeenCalledWith({
218+
context,
219+
audience: 'sim:credential-groups',
220+
})
153221
expect(mocks.listGroups).toHaveBeenCalledWith({
154222
principal,
155223
input: { workspaceId: 'workspace-1', limit: 10, cursor: undefined },
@@ -201,7 +269,11 @@ describe('CredentialGroupBlockHandler', () => {
201269
email: ' person@example.com ',
202270
})
203271

204-
expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', 'group-1')
272+
expect(mocks.createPrincipal).toHaveBeenCalledWith({
273+
context,
274+
audience: 'sim:credential-groups',
275+
resourceScope: { credentialGroupId: 'group-1' },
276+
})
205277
expect(mocks.enforceInviteRateLimit).toHaveBeenCalledWith('workspace-1')
206278
expect(mocks.enforceInviteRateLimit.mock.invocationCallOrder[0]).toBeLessThan(
207279
mocks.createInviteLink.mock.invocationCallOrder[0]!
@@ -236,7 +308,6 @@ describe('CredentialGroupBlockHandler', () => {
236308
await expect(
237309
new CredentialGroupBlockHandler().execute(context, block, { operation: 'unknown' })
238310
).rejects.toThrow('Unsupported Credential Group operation: unknown')
239-
expect(mocks.buildHeaders).not.toHaveBeenCalled()
240-
expect(mocks.authenticate).not.toHaveBeenCalled()
311+
expect(mocks.createPrincipal).not.toHaveBeenCalled()
241312
})
242313
})

apps/sim/executor/handlers/credential-group/credential-group-handler.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createLogger } from '@sim/logger'
2+
import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/application/authorization'
23
import { createCredentialGroupInviteLink } from '@/lib/credential-groups/application/create-invite-link'
3-
import { authenticateCredentialGroupDelegation } from '@/lib/credential-groups/application/delegation'
44
import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials'
55
import { listCredentialGroupsForWorkflow } from '@/lib/credential-groups/application/list-groups'
66
import {
@@ -11,10 +11,10 @@ import { sendCredentialGroupInvite } from '@/lib/credential-groups/application/s
1111
import { MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE } from '@/lib/credential-groups/credentials'
1212
import type { CredentialGroupEnrollmentStatus } from '@/lib/credential-groups/enrollments'
1313
import { enforceCredentialGroupInvitationExecutionRateLimit } from '@/lib/credential-groups/rate-limit'
14+
import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor'
1415
import type { BlockOutput } from '@/blocks/types'
1516
import { BlockType } from '@/executor/constants'
16-
import type { BlockHandler, ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types'
17-
import { buildExecutorDelegationHeaders } from '@/executor/utils/http'
17+
import type { BlockHandler, ExecutionContext } from '@/executor/types'
1818
import type { SerializedBlock } from '@/serializer/types'
1919

2020
const logger = createLogger('CredentialGroupBlockHandler')
@@ -84,13 +84,6 @@ function requireString(value: unknown, label: string): string {
8484
return parsed
8585
}
8686

87-
function delegationOrigin(ctx: ExecutionContext): ExecutorDelegationOrigin {
88-
if (!ctx.executorDelegationOrigin) {
89-
throw new Error('Credential Group operations require an authenticated workflow execution')
90-
}
91-
return ctx.executorDelegationOrigin
92-
}
93-
9487
export class CredentialGroupBlockHandler implements BlockHandler {
9588
canHandle(block: SerializedBlock): boolean {
9689
return block.metadata?.id === BlockType.CREDENTIAL_GROUP
@@ -103,14 +96,18 @@ export class CredentialGroupBlockHandler implements BlockHandler {
10396
): Promise<BlockOutput> {
10497
if (!ctx.workspaceId) throw new Error('workspaceId is required for Credential Group operations')
10598
const operation = parseOperation(inputs.operation)
99+
if (!ctx.executorDelegationOrigin) {
100+
throw new Error('Credential Group operations require an authenticated workflow execution')
101+
}
106102
const credentialGroupId =
107103
operation === 'list_groups'
108104
? undefined
109105
: requireString(inputs.credentialGroupId, 'Credential Group')
110-
const headers = await buildExecutorDelegationHeaders(delegationOrigin(ctx))
111-
const authorization = headers.Authorization
112-
if (!authorization) throw new Error('Executor delegation authorization is missing')
113-
const principal = await authenticateCredentialGroupDelegation(authorization, credentialGroupId)
106+
const principal = await createExecutorPrincipalFromExecutionContext({
107+
context: ctx,
108+
audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE,
109+
...(credentialGroupId ? { resourceScope: { credentialGroupId } } : {}),
110+
})
114111

115112
switch (operation) {
116113
case 'list_credentials': {

apps/sim/lib/auth/internal-delegation.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,41 @@ describe('bindInternalExecutorDelegation', () => {
102102
})
103103
})
104104

105+
it('binds the trusted legacy execution actor only for an actorless principal', async () => {
106+
const principal = await bindInternalExecutorDelegation(
107+
{
108+
...claims,
109+
subjectUserId: undefined,
110+
principal: {
111+
kind: 'system',
112+
serviceId: 'schedule',
113+
workspaceId: 'workspace-1',
114+
workflowId: 'workflow-1',
115+
},
116+
},
117+
{
118+
audience: 'sim:workspace-files',
119+
compatibilityActorUserId: 'execution-actor',
120+
}
121+
)
122+
123+
expect(principal.subjectUserId).toBeUndefined()
124+
expect(principal.delegationContext.compatibilityActor).toEqual({
125+
kind: 'legacy_execution_user',
126+
userId: 'execution-actor',
127+
})
128+
})
129+
130+
it('rejects a compatibility actor when the delegation has a user subject', async () => {
131+
await expect(
132+
bindInternalExecutorDelegation(claims, {
133+
audience: 'sim:workspace-files',
134+
compatibilityActorUserId: 'execution-actor',
135+
})
136+
).rejects.toThrow('cannot bind a compatibility actor to a user subject')
137+
expect(mockResolveWorkflow).not.toHaveBeenCalled()
138+
})
139+
105140
it('binds deployed child authority to its exact historical deployment version', async () => {
106141
const currentWorkflow = {
107142
workflowId: 'child-workflow',
@@ -260,6 +295,16 @@ describe('bindInternalExecutorDelegation', () => {
260295
expect(mockResolveWorkflow).not.toHaveBeenCalled()
261296
})
262297

298+
it('fails before canonical loading when the compatibility actor is empty', async () => {
299+
await expect(
300+
bindInternalExecutorDelegation(claims, {
301+
audience: 'sim:workspace-files',
302+
compatibilityActorUserId: ' ',
303+
})
304+
).rejects.toThrow('Internal delegation execution actor must not be empty')
305+
expect(mockResolveWorkflow).not.toHaveBeenCalled()
306+
})
307+
263308
it('classifies a missing canonical execution as an invalid delegation binding', async () => {
264309
mockResolveRun.mockRejectedValue(new OrchestrationError('not_found', 'Workflow run not found'))
265310

apps/sim/lib/auth/internal-delegation.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
export interface BindInternalExecutorDelegationOptions {
1616
audience: string
1717
resourceScope?: DelegatedPrincipal['resourceScope']
18+
compatibilityActorUserId?: string
1819
}
1920

2021
export class InvalidInternalDelegationBindingError extends Error {
@@ -30,6 +31,12 @@ export async function bindInternalExecutorDelegation(
3031
options: BindInternalExecutorDelegationOptions
3132
): Promise<BoundWorkflowExecutionDelegatedPrincipal> {
3233
if (!options.audience.trim()) throw new Error('Internal delegation audience must not be empty')
34+
if (options.compatibilityActorUserId !== undefined && !options.compatibilityActorUserId.trim()) {
35+
throw new Error('Internal delegation execution actor must not be empty')
36+
}
37+
if (claims.subjectUserId && options.compatibilityActorUserId) {
38+
throw new Error('Internal delegation cannot bind a compatibility actor to a user subject')
39+
}
3340

3441
let context: ActiveWorkflowApplicationContext
3542
let rootDeploymentVersionId: string | null | undefined
@@ -107,6 +114,14 @@ export async function bindInternalExecutorDelegation(
107114
...(claims.executionId ? { executionId: claims.executionId } : {}),
108115
...(claims.principal ? { principal: claims.principal } : {}),
109116
...(claims.currentWorkflow ? { currentWorkflow: claims.currentWorkflow } : {}),
117+
...(options.compatibilityActorUserId
118+
? {
119+
compatibilityActor: {
120+
kind: 'legacy_execution_user',
121+
userId: options.compatibilityActorUserId,
122+
} as const,
123+
}
124+
: {}),
110125
},
111126
}
112127
}

apps/sim/lib/auth/principal.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
requirePrincipalSubjectUserId,
88
resolvePrincipalAttribution,
99
resolvePrincipalAuditAttribution,
10+
resolvePrincipalExecutionActorUserId,
1011
resolvePrincipalSubject,
1112
resolvePrincipalSubjectUserId,
1213
serializePrincipal,
@@ -108,6 +109,49 @@ describe('principal subject users', () => {
108109
).toBeUndefined()
109110
})
110111

112+
it('resolves only a principal-bound compatibility actor for actorless execution', () => {
113+
const principal = {
114+
kind: 'delegated' as const,
115+
serviceId: 'executor' as const,
116+
workspaceId: 'workspace-1',
117+
delegationId: 'delegation-1',
118+
audience: 'sim:test',
119+
issuedAt: new Date('2026-01-01T00:00:00Z'),
120+
expiresAt: new Date('2026-01-01T00:05:00Z'),
121+
delegationContext: {
122+
kind: 'workflow_execution' as const,
123+
workflowId: 'workflow-1',
124+
currentWorkflow: {
125+
workflowId: 'workflow-1',
126+
mode: 'deployment' as const,
127+
deploymentVersionId: 'deployment-1',
128+
},
129+
compatibilityActor: {
130+
kind: 'legacy_execution_user' as const,
131+
userId: 'execution-actor',
132+
},
133+
},
134+
}
135+
136+
expect(resolvePrincipalSubjectUserId(principal)).toBeUndefined()
137+
expect(resolvePrincipalExecutionActorUserId(principal)).toBe('execution-actor')
138+
expect(
139+
resolvePrincipalExecutionActorUserId({
140+
...principal,
141+
subjectUserId: 'authenticated-user',
142+
})
143+
).toBe('authenticated-user')
144+
expect(
145+
resolvePrincipalExecutionActorUserId({
146+
...principal,
147+
delegationContext: {
148+
...principal.delegationContext,
149+
currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' },
150+
},
151+
})
152+
).toBeUndefined()
153+
})
154+
111155
it('fails fast instead of fabricating a workspace-key subject', () => {
112156
expect(() =>
113157
requirePrincipalSubjectUserId({

apps/sim/lib/core/orchestration/types.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,7 @@ export function asOrchestrationError(error: unknown): OrchestrationError | null
109109
return null
110110
}
111111

112-
/**
113-
* The slice of an HTTP request the audit log reads for client IP and user-agent
114-
* capture. Optional on every orchestration function so the non-HTTP callers —
115-
* copilot tools, background jobs — can omit what they do not have.
116-
*/
112+
/** Transport metadata available to an application operation for audit capture. */
117113
export interface OrchestrationRequestContext {
118114
headers: { get(name: string): string | null }
119115
}

0 commit comments

Comments
 (0)