Skip to content

Commit 783e1b5

Browse files
fix(executor): restore delegated workflow execution (#6539)
* fix(executor): restore delegated workflow execution * fix(executor): trust custom block execution scope * fix(providers): keep tool parameters type safe
1 parent 7c75061 commit 783e1b5

19 files changed

Lines changed: 613 additions & 19 deletions

apps/sim/executor/execution/executor.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,3 +429,26 @@ describe('DAGExecutor createExecutionContext useDraftState', () => {
429429
expect(buildMetadataUseDraftState({ isDeployedContext: false })).toBe(true)
430430
})
431431
})
432+
433+
describe('DAGExecutor executor delegation origin', () => {
434+
it('copies the canonical origin into the runtime execution context', () => {
435+
const executorDelegationOrigin = {
436+
subjectUserId: 'user-1',
437+
workflowId: 'parent-workflow',
438+
executionId: 'parent-execution',
439+
}
440+
const executor = new DAGExecutor({
441+
workflow: { version: '1', blocks: [], connections: [] },
442+
contextExtensions: { executorDelegationOrigin },
443+
})
444+
445+
const { context } = (
446+
executor as unknown as {
447+
createExecutionContext: (workflowId: string) => { context: ExecutionContext }
448+
}
449+
).createExecutionContext('child-workflow')
450+
451+
expect(context.workflowId).toBe('child-workflow')
452+
expect(context.executorDelegationOrigin).toBe(executorDelegationOrigin)
453+
})
454+
})

apps/sim/executor/execution/executor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ export class DAGExecutor {
421421
fileKeys: this.contextExtensions.fileKeys,
422422
allowLargeValueWorkflowScope: this.contextExtensions.allowLargeValueWorkflowScope,
423423
userId: this.contextExtensions.userId,
424+
executorDelegationOrigin: this.contextExtensions.executorDelegationOrigin,
424425
isDeployedContext: this.contextExtensions.isDeployedContext,
425426
enforceCredentialAccess: this.contextExtensions.enforceCredentialAccess,
426427
piiBlockOutputRedaction: this.contextExtensions.piiBlockOutputRedaction,

apps/sim/executor/execution/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { NodeMetadata } from '@/executor/dag/types'
66
import type {
77
BlockLog,
88
BlockState,
9+
ExecutorDelegationOrigin,
910
NormalizedBlockOutput,
1011
StartBlockRunMetadata,
1112
StreamingExecution,
@@ -231,6 +232,8 @@ export interface ContextExtensions {
231232
fileKeys?: string[]
232233
allowLargeValueWorkflowScope?: boolean
233234
userId?: string
235+
/** Canonical signed execution identity inherited by regular nested workflows. */
236+
executorDelegationOrigin?: ExecutorDelegationOrigin
234237
/**
235238
* Immutable actor/payer decision for this execution. Child workflow
236239
* executions receive it here (they carry no full metadata), so internal

apps/sim/executor/handlers/agent/agent-handler.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr
2727
import { executeProviderRequest } from '@/providers'
2828
import { installStreamingCostPolicy } from '@/providers/cost-policy'
2929
import { SIM_AUTO_MODEL_ID } from '@/providers/models'
30-
import { getProviderToolInputProvenance } from '@/providers/tool-input-provenance'
30+
import {
31+
getProviderToolInputProvenance,
32+
getProviderToolModelInputRegistry,
33+
} from '@/providers/tool-input-provenance'
3134
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
3235
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
3336
import { executeTool } from '@/tools'
@@ -1155,6 +1158,61 @@ describe('AgentBlockHandler', () => {
11551158
expect(runtimeContext.resolvedSecretTraceRegistry.getActiveMatches()).toEqual([])
11561159
})
11571160

1161+
it('binds prompt-exposed placeholders to each provider tool for runtime rebinding', async () => {
1162+
const registry = new ResolvedSecretTraceRegistry([
1163+
{
1164+
name: 'TEST_API_KEY_PERSONAL',
1165+
plaintext: 'personal-secret-value',
1166+
encryptedValue: 'encrypted-personal-secret',
1167+
},
1168+
])
1169+
registry.recordResolvedAtInputPath('TEST_API_KEY_PERSONAL', 'personal-secret-value', [
1170+
'userPrompt',
1171+
])
1172+
registry.recordResolvedInputProjection(
1173+
['userPrompt'],
1174+
'Use personal-secret-value',
1175+
'Use {{TEST_API_KEY_PERSONAL}}'
1176+
)
1177+
mockContext.resolvedSecretTraceRegistry = registry
1178+
1179+
await handler.execute(mockContext, mockBlock, {
1180+
model: 'gpt-4o',
1181+
userPrompt: 'Use personal-secret-value',
1182+
tools: [
1183+
{
1184+
type: 'custom-tool',
1185+
title: 'canary',
1186+
schema: {
1187+
function: {
1188+
name: 'canary',
1189+
parameters: {
1190+
type: 'object',
1191+
properties: { secret: { type: 'string' } },
1192+
required: ['secret'],
1193+
},
1194+
},
1195+
},
1196+
},
1197+
],
1198+
})
1199+
1200+
const [, providerRequest] = mockExecuteProviderRequest.mock.calls[0]
1201+
const modelInputRegistry = getProviderToolModelInputRegistry(providerRequest.tools[0])
1202+
expect(providerRequest.messages).toEqual([
1203+
{ role: 'user', content: 'Use {{TEST_API_KEY_PERSONAL}}' },
1204+
])
1205+
expect(
1206+
modelInputRegistry?.resolveModelExposedEnvReferences({
1207+
secret: '{{TEST_API_KEY_PERSONAL}}',
1208+
})
1209+
).toMatchObject({
1210+
complete: true,
1211+
matched: true,
1212+
value: { secret: 'personal-secret-value' },
1213+
})
1214+
})
1215+
11581216
it('does not carry a projected system prompt into Agent output provenance', async () => {
11591217
const registry = new ResolvedSecretTraceRegistry([
11601218
{ name: 'TOKEN', plaintext: 'x', encryptedValue: 'encrypted-token' },

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
8585
import {
8686
type ProviderToolInputProvenance,
8787
registerProviderToolInputProvenance,
88+
registerProviderToolModelInputRegistry,
8889
} from '@/providers/tool-input-provenance'
8990
import type { ProviderToolConfig } from '@/providers/types'
9091
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
@@ -397,6 +398,11 @@ export class AgentBlockHandler implements BlockHandler {
397398

398399
const settledInputRegistry = ctx.resolvedSecretTraceRegistry
399400
const resultRegistry = settledInputRegistry?.forkForInputPaths([])
401+
if (modelInputProjection.registry) {
402+
for (const tool of formatted.tools) {
403+
registerProviderToolModelInputRegistry(tool, modelInputProjection.registry)
404+
}
405+
}
400406
if (resultRegistry && settledInputRegistry) {
401407
for (const [tool, provenance] of formatted.inputProvenance) {
402408
registerProviderToolInputProvenance(tool, {
@@ -2590,6 +2596,7 @@ export class AgentBlockHandler implements BlockHandler {
25902596
},
25912597
{
25922598
resolvedSecretTraceRegistry: modelRuntimeRegistry,
2599+
executionContext: ctx,
25932600
}
25942601
)
25952602

apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { generateId } from '@sim/utils/id'
44
import { isPlainRecord } from '@sim/utils/object'
55
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
66
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
7-
import type { ExecutionContext } from '@/executor/types'
7+
import type { ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types'
88
import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection'
99
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
1010
import type { SerializedBlock } from '@/serializer/types'
@@ -55,6 +55,7 @@ export function buildCustomBlockExecutionContext(
5555
options: {
5656
abortSignal?: AbortSignal
5757
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
58+
executorDelegationOrigin?: ExecutorDelegationOrigin
5859
} = {}
5960
): ExecutionContext {
6061
// Prefer the invoking agent run's ids so correlation and cancellation both
@@ -64,6 +65,7 @@ export function buildCustomBlockExecutionContext(
6465
workflowId: context.workflowId ?? 'custom-block-tool',
6566
workspaceId: context.workspaceId,
6667
userId: context.userId,
68+
executorDelegationOrigin: options.executorDelegationOrigin,
6769
executionId,
6870
isDeployedContext: context.isDeployedContext,
6971
// Inherit the accumulated chain so the handler appends + validates depth;

apps/sim/executor/handlers/workflow/workflow-handler.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,11 @@ describe('WorkflowBlockHandler', () => {
582582
expect(executorOptions[0].contextExtensions.billingAttribution).toBe(sourceAttribution)
583583
expect(executorOptions[0].contextExtensions.userId).toBe('owner-9')
584584
expect(executorOptions[0].contextExtensions.workspaceId).toBe('workspace-source')
585+
expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({
586+
subjectUserId: 'owner-9',
587+
workflowId: 'source-workflow-id',
588+
executionId: loggingSessionArgs[0][1],
589+
})
585590
})
586591

587592
it('builds trusted caller metadata for custom block children with the toggle on', async () => {
@@ -1225,6 +1230,15 @@ describe('WorkflowBlockHandler', () => {
12251230
expect(loggingSessionArgs).toHaveLength(0)
12261231
})
12271232

1233+
it('fails before execution when the source child log row cannot be opened', async () => {
1234+
mockSafeStart.mockResolvedValue(false)
1235+
1236+
await expect(handler.execute(customBlockContext(), customBlock(), {})).rejects.toThrow()
1237+
1238+
expect(mockExecutorExecute).not.toHaveBeenCalled()
1239+
expect(executorOptions).toHaveLength(0)
1240+
})
1241+
12281242
it('runs the child under its own execution id but keeps the parent readable', async () => {
12291243
const ctx = customBlockContext()
12301244
await handler.execute(ctx, customBlock(), {})
@@ -1235,6 +1249,24 @@ describe('WorkflowBlockHandler', () => {
12351249
expect(ctx.largeValueExecutionIds).toContain(extensions.executionId)
12361250
})
12371251

1252+
it('replaces the consumer delegation origin with the source child execution', async () => {
1253+
const ctx = customBlockContext({
1254+
executorDelegationOrigin: {
1255+
subjectUserId: 'consumer-1',
1256+
workflowId: 'consumer-workflow',
1257+
executionId: 'parent-execution-id',
1258+
},
1259+
})
1260+
1261+
await handler.execute(ctx, customBlock(), {})
1262+
1263+
expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({
1264+
subjectUserId: 'owner-9',
1265+
workflowId: 'source-workflow-id',
1266+
executionId: executorOptions[0].contextExtensions.executionId,
1267+
})
1268+
})
1269+
12381270
it('shares one large-value id list so nested custom blocks propagate upward', async () => {
12391271
const ctx = customBlockContext()
12401272
await handler.execute(ctx, customBlock(), {})
@@ -1604,9 +1636,49 @@ describe('WorkflowBlockHandler', () => {
16041636
const extensions = executorOptions[0].contextExtensions
16051637
expect(extensions.executionId).toBe('parent-execution-id')
16061638
expect(extensions.resolvedSecretTraceRegistry).toBe(registry)
1639+
expect(extensions.executorDelegationOrigin).toEqual({
1640+
subjectUserId: 'user-1',
1641+
workflowId: 'parent-workflow-id',
1642+
executionId: 'parent-execution-id',
1643+
})
1644+
expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith(
1645+
extensions.executorDelegationOrigin
1646+
)
16071647
expect(extensions.onStream).toBe(ctx.onStream)
16081648
expect(extensions.childWorkflowContext).toBeDefined()
16091649
})
1650+
1651+
it('preserves the canonical parent origin through deeper regular children', async () => {
1652+
const ctx = {
1653+
...mockContext,
1654+
workspaceId: 'workspace-1',
1655+
workflowId: 'intermediate-workflow-id',
1656+
executionId: 'parent-execution-id',
1657+
executorDelegationOrigin: {
1658+
subjectUserId: 'user-1',
1659+
workflowId: 'root-workflow-id',
1660+
executionId: 'parent-execution-id',
1661+
},
1662+
} as ExecutionContext
1663+
mockFetch.mockResolvedValue({
1664+
ok: true,
1665+
json: () =>
1666+
Promise.resolve({
1667+
data: {
1668+
name: 'Grandchild Workflow',
1669+
workspaceId: 'workspace-1',
1670+
state: { blocks: [], edges: [], loops: {}, parallels: {} },
1671+
},
1672+
}),
1673+
})
1674+
1675+
await handler.execute(ctx, mockBlock, { workflowId: 'grandchild-workflow-id' })
1676+
1677+
expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith(ctx.executorDelegationOrigin)
1678+
expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toBe(
1679+
ctx.executorDelegationOrigin
1680+
)
1681+
})
16101682
})
16111683

16121684
describe('projectCustomBlockOutput', () => {

apps/sim/executor/handlers/workflow/workflow-handler.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
type BlockHandler,
3838
type ExecutionContext,
3939
type ExecutionResult,
40+
type ExecutorDelegationOrigin,
4041
START_BLOCK_METADATA_FIELD,
4142
type StartBlockRunMetadata,
4243
type StreamingExecution,
@@ -278,17 +279,22 @@ export class WorkflowBlockHandler implements BlockHandler {
278279
/** Large-value id list shared with the child (and any nested custom blocks). */
279280
let sharedLargeValueIds: string[] | undefined
280281
let childCancellation: { signal: AbortSignal; dispose: () => void } | undefined
282+
let childExecutorDelegationOrigin: ExecutorDelegationOrigin | undefined
281283
/** Settled in `finally` once the child is fully done — see `trackChildRun`. */
282284
let settleChildRun: (() => void) | undefined
283285
try {
284286
if (!loadUserId) {
285287
throw new Error('Workflow child loading requires a human execution subject')
286288
}
287-
const workflowReadHeaders = await buildExecutorDelegationHeaders({
288-
subjectUserId: loadUserId,
289-
workflowId: isCustomBlock ? workflowId : ctx.workflowId,
290-
...(!isCustomBlock && ctx.executionId ? { executionId: ctx.executionId } : {}),
291-
})
289+
const workflowReadDelegationOrigin: ExecutorDelegationOrigin = isCustomBlock
290+
? { subjectUserId: loadUserId, workflowId }
291+
: (ctx.executorDelegationOrigin ?? {
292+
subjectUserId: loadUserId,
293+
workflowId: ctx.workflowId,
294+
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
295+
})
296+
if (!isCustomBlock) childExecutorDelegationOrigin = workflowReadDelegationOrigin
297+
const workflowReadHeaders = await buildExecutorDelegationHeaders(workflowReadDelegationOrigin)
292298

293299
// A custom block runs the source's latest deployment; if the source has been
294300
// undeployed there's nothing to run. `BoundarySafeError` marks the message as
@@ -509,10 +515,13 @@ export class WorkflowBlockHandler implements BlockHandler {
509515
...(correlation ? { triggerData: { correlation } } : {}),
510516
})
511517
if (!childSessionStarted) {
512-
logger.error('Custom block child logging failed to start; child spend will be unbilled', {
513-
workflowId,
514-
childExecutionId,
515-
})
518+
childExecutionId = undefined
519+
throw new Error('Custom block child logging failed to start')
520+
}
521+
childExecutorDelegationOrigin = {
522+
subjectUserId: loadUserId,
523+
workflowId,
524+
executionId: childExecutionId,
516525
}
517526
// The child no longer shares the parent's execution id, so it no longer
518527
// hears the parent's cancellation event — bridge it explicitly.
@@ -599,6 +608,7 @@ export class WorkflowBlockHandler implements BlockHandler {
599608
enforceCredentialAccess: ctx.enforceCredentialAccess,
600609
workspaceId: childWorkspaceId,
601610
userId: childUserId,
611+
executorDelegationOrigin: childExecutorDelegationOrigin,
602612
executionId: childExecutionId ?? ctx.executionId,
603613
// Large values are cached per execution id, so a child running under its
604614
// own id still needs the invoking run's id to read values in its inputs.

apps/sim/executor/handlers/workflow/workflow-tool-runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type CustomBlockExecutorContext,
1010
} from '@/executor/handlers/workflow/custom-block-tool-runner'
1111
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
12+
import type { ExecutorDelegationOrigin } from '@/executor/types'
1213
import { classifyExecutionError } from '@/executor/utils/errors'
1314
import { parseJSON } from '@/executor/utils/json'
1415
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
@@ -52,6 +53,7 @@ export async function runWorkflowTool(
5253
options: {
5354
abortSignal?: AbortSignal
5455
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
56+
executorDelegationOrigin?: ExecutorDelegationOrigin
5557
} = {}
5658
): Promise<ToolResponse> {
5759
if (!params.workflowId) {

apps/sim/executor/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,19 @@ export interface BlockState {
338338
resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
339339
}
340340

341+
/**
342+
* Canonical signed execution identity used for executor-delegated internal operations.
343+
*
344+
* A nested workflow changes {@link ExecutionContext.workflowId} for execution semantics, but it
345+
* still belongs to the parent log row identified here. Custom blocks replace this origin with the
346+
* publisher-owned child execution after opening their own source-workspace log row.
347+
*/
348+
export interface ExecutorDelegationOrigin {
349+
subjectUserId: string
350+
workflowId: string
351+
executionId?: string
352+
}
353+
341354
export interface ExecutionContext {
342355
workflowId: string
343356
workspaceId?: string
@@ -347,6 +360,8 @@ export interface ExecutionContext {
347360
fileKeys?: string[]
348361
allowLargeValueWorkflowScope?: boolean
349362
userId?: string
363+
/** Trusted origin for signed executor delegation, distinct from the currently executing child. */
364+
executorDelegationOrigin?: ExecutorDelegationOrigin
350365
isDeployedContext?: boolean
351366
enforceCredentialAccess?: boolean
352367
copilotToolExecution?: boolean

0 commit comments

Comments
 (0)