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
23 changes: 23 additions & 0 deletions apps/sim/executor/execution/executor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -429,3 +429,26 @@ describe('DAGExecutor createExecutionContext useDraftState', () => {
expect(buildMetadataUseDraftState({ isDeployedContext: false })).toBe(true)
})
})

describe('DAGExecutor executor delegation origin', () => {
it('copies the canonical origin into the runtime execution context', () => {
const executorDelegationOrigin = {
subjectUserId: 'user-1',
workflowId: 'parent-workflow',
executionId: 'parent-execution',
}
const executor = new DAGExecutor({
workflow: { version: '1', blocks: [], connections: [] },
contextExtensions: { executorDelegationOrigin },
})

const { context } = (
executor as unknown as {
createExecutionContext: (workflowId: string) => { context: ExecutionContext }
}
).createExecutionContext('child-workflow')

expect(context.workflowId).toBe('child-workflow')
expect(context.executorDelegationOrigin).toBe(executorDelegationOrigin)
})
})
1 change: 1 addition & 0 deletions apps/sim/executor/execution/executor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -421,6 +421,7 @@ export class DAGExecutor {
fileKeys: this.contextExtensions.fileKeys,
allowLargeValueWorkflowScope: this.contextExtensions.allowLargeValueWorkflowScope,
userId: this.contextExtensions.userId,
executorDelegationOrigin: this.contextExtensions.executorDelegationOrigin,
isDeployedContext: this.contextExtensions.isDeployedContext,
enforceCredentialAccess: this.contextExtensions.enforceCredentialAccess,
piiBlockOutputRedaction: this.contextExtensions.piiBlockOutputRedaction,
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/executor/execution/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import type { NodeMetadata } from '@/executor/dag/types'
import type {
BlockLog,
BlockState,
ExecutorDelegationOrigin,
NormalizedBlockOutput,
StartBlockRunMetadata,
StreamingExecution,
Expand DownExpand Up@@ -231,6 +232,8 @@ export interface ContextExtensions {
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
/** Canonical signed execution identity inherited by regular nested workflows. */
executorDelegationOrigin?: ExecutorDelegationOrigin
/**
* Immutable actor/payer decision for this execution. Child workflow
* executions receive it here (they carry no full metadata), so internal
Expand Down
60 changes: 59 additions & 1 deletion apps/sim/executor/handlers/agent/agent-handler.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,10 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr
import { executeProviderRequest } from '@/providers'
import { installStreamingCostPolicy } from '@/providers/cost-policy'
import { SIM_AUTO_MODEL_ID } from '@/providers/models'
import { getProviderToolInputProvenance } from '@/providers/tool-input-provenance'
import {
getProviderToolInputProvenance,
getProviderToolModelInputRegistry,
} from '@/providers/tool-input-provenance'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { executeTool } from '@/tools'
Expand DownExpand Up@@ -1155,6 +1158,61 @@ describe('AgentBlockHandler', () => {
expect(runtimeContext.resolvedSecretTraceRegistry.getActiveMatches()).toEqual([])
})

it('binds prompt-exposed placeholders to each provider tool for runtime rebinding', async () => {
const registry = new ResolvedSecretTraceRegistry([
{
name: 'TEST_API_KEY_PERSONAL',
plaintext: 'personal-secret-value',
encryptedValue: 'encrypted-personal-secret',
},
])
registry.recordResolvedAtInputPath('TEST_API_KEY_PERSONAL', 'personal-secret-value', [
'userPrompt',
])
registry.recordResolvedInputProjection(
['userPrompt'],
'Use personal-secret-value',
'Use {{TEST_API_KEY_PERSONAL}}'
)
mockContext.resolvedSecretTraceRegistry = registry

await handler.execute(mockContext, mockBlock, {
model: 'gpt-4o',
userPrompt: 'Use personal-secret-value',
tools: [
{
type: 'custom-tool',
title: 'canary',
schema: {
function: {
name: 'canary',
parameters: {
type: 'object',
properties: { secret: { type: 'string' } },
required: ['secret'],
},
},
},
},
],
})

const [, providerRequest] = mockExecuteProviderRequest.mock.calls[0]
const modelInputRegistry = getProviderToolModelInputRegistry(providerRequest.tools[0])
expect(providerRequest.messages).toEqual([
{ role: 'user', content: 'Use {{TEST_API_KEY_PERSONAL}}' },
])
expect(
modelInputRegistry?.resolveModelExposedEnvReferences({
secret: '{{TEST_API_KEY_PERSONAL}}',
})
).toMatchObject({
complete: true,
matched: true,
value: { secret: 'personal-secret-value' },
})
})

it('does not carry a projected system prompt into Agent output provenance', async () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'x', encryptedValue: 'encrypted-token' },
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/executor/handlers/agent/agent-handler.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,6 +85,7 @@ import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
import {
type ProviderToolInputProvenance,
registerProviderToolInputProvenance,
registerProviderToolModelInputRegistry,
} from '@/providers/tool-input-provenance'
import type { ProviderToolConfig } from '@/providers/types'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
Expand DownExpand Up@@ -397,6 +398,11 @@ export class AgentBlockHandler implements BlockHandler {

const settledInputRegistry = ctx.resolvedSecretTraceRegistry
const resultRegistry = settledInputRegistry?.forkForInputPaths([])
if (modelInputProjection.registry) {
for (const tool of formatted.tools) {
registerProviderToolModelInputRegistry(tool, modelInputProjection.registry)
}
}
if (resultRegistry && settledInputRegistry) {
for (const [tool, provenance] of formatted.inputProvenance) {
registerProviderToolInputProvenance(tool, {
Expand DownExpand Up@@ -2590,6 +2596,7 @@ export class AgentBlockHandler implements BlockHandler {
},
{
resolvedSecretTraceRegistry: modelRuntimeRegistry,
executionContext: ctx,
}
)

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { generateId } from '@sim/utils/id'
import { isPlainRecord } from '@sim/utils/object'
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
import type { ExecutionContext } from '@/executor/types'
import type { ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types'
import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import type { SerializedBlock } from '@/serializer/types'
Expand DownExpand Up@@ -55,6 +55,7 @@ export function buildCustomBlockExecutionContext(
options: {
abortSignal?: AbortSignal
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
executorDelegationOrigin?: ExecutorDelegationOrigin
} = {}
): ExecutionContext {
// Prefer the invoking agent run's ids so correlation and cancellation both
Expand All@@ -64,6 +65,7 @@ export function buildCustomBlockExecutionContext(
workflowId: context.workflowId ?? 'custom-block-tool',
workspaceId: context.workspaceId,
userId: context.userId,
executorDelegationOrigin: options.executorDelegationOrigin,
executionId,
isDeployedContext: context.isDeployedContext,
// Inherit the accumulated chain so the handler appends + validates depth;
Expand Down
72 changes: 72 additions & 0 deletions apps/sim/executor/handlers/workflow/workflow-handler.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -582,6 +582,11 @@ describe('WorkflowBlockHandler', () => {
expect(executorOptions[0].contextExtensions.billingAttribution).toBe(sourceAttribution)
expect(executorOptions[0].contextExtensions.userId).toBe('owner-9')
expect(executorOptions[0].contextExtensions.workspaceId).toBe('workspace-source')
expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({
subjectUserId: 'owner-9',
workflowId: 'source-workflow-id',
executionId: loggingSessionArgs[0][1],
})
})

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

it('fails before execution when the source child log row cannot be opened', async () => {
mockSafeStart.mockResolvedValue(false)

await expect(handler.execute(customBlockContext(), customBlock(), {})).rejects.toThrow()

expect(mockExecutorExecute).not.toHaveBeenCalled()
expect(executorOptions).toHaveLength(0)
})

it('runs the child under its own execution id but keeps the parent readable', async () => {
const ctx = customBlockContext()
await handler.execute(ctx, customBlock(), {})
Expand All@@ -1235,6 +1249,24 @@ describe('WorkflowBlockHandler', () => {
expect(ctx.largeValueExecutionIds).toContain(extensions.executionId)
})

it('replaces the consumer delegation origin with the source child execution', async () => {
const ctx = customBlockContext({
executorDelegationOrigin: {
subjectUserId: 'consumer-1',
workflowId: 'consumer-workflow',
executionId: 'parent-execution-id',
},
})

await handler.execute(ctx, customBlock(), {})

expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({
subjectUserId: 'owner-9',
workflowId: 'source-workflow-id',
executionId: executorOptions[0].contextExtensions.executionId,
})
})

it('shares one large-value id list so nested custom blocks propagate upward', async () => {
const ctx = customBlockContext()
await handler.execute(ctx, customBlock(), {})
Expand DownExpand Up@@ -1604,9 +1636,49 @@ describe('WorkflowBlockHandler', () => {
const extensions = executorOptions[0].contextExtensions
expect(extensions.executionId).toBe('parent-execution-id')
expect(extensions.resolvedSecretTraceRegistry).toBe(registry)
expect(extensions.executorDelegationOrigin).toEqual({
subjectUserId: 'user-1',
workflowId: 'parent-workflow-id',
executionId: 'parent-execution-id',
})
expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith(
extensions.executorDelegationOrigin
)
expect(extensions.onStream).toBe(ctx.onStream)
expect(extensions.childWorkflowContext).toBeDefined()
})

it('preserves the canonical parent origin through deeper regular children', async () => {
const ctx = {
...mockContext,
workspaceId: 'workspace-1',
workflowId: 'intermediate-workflow-id',
executionId: 'parent-execution-id',
executorDelegationOrigin: {
subjectUserId: 'user-1',
workflowId: 'root-workflow-id',
executionId: 'parent-execution-id',
},
} as ExecutionContext
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Grandchild Workflow',
workspaceId: 'workspace-1',
state: { blocks: [], edges: [], loops: {}, parallels: {} },
},
}),
})

await handler.execute(ctx, mockBlock, { workflowId: 'grandchild-workflow-id' })

expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith(ctx.executorDelegationOrigin)
expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toBe(
ctx.executorDelegationOrigin
)
})
})

describe('projectCustomBlockOutput', () => {
Expand Down
28 changes: 19 additions & 9 deletions apps/sim/executor/handlers/workflow/workflow-handler.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ import {
type BlockHandler,
type ExecutionContext,
type ExecutionResult,
type ExecutorDelegationOrigin,
START_BLOCK_METADATA_FIELD,
type StartBlockRunMetadata,
type StreamingExecution,
Expand DownExpand Up@@ -278,17 +279,22 @@ export class WorkflowBlockHandler implements BlockHandler {
/** Large-value id list shared with the child (and any nested custom blocks). */
let sharedLargeValueIds: string[] | undefined
let childCancellation: { signal: AbortSignal; dispose: () => void } | undefined
let childExecutorDelegationOrigin: ExecutorDelegationOrigin | undefined
/** Settled in `finally` once the child is fully done — see `trackChildRun`. */
let settleChildRun: (() => void) | undefined
try {
if (!loadUserId) {
throw new Error('Workflow child loading requires a human execution subject')
}
const workflowReadHeaders = await buildExecutorDelegationHeaders({
subjectUserId: loadUserId,
workflowId: isCustomBlock ? workflowId : ctx.workflowId,
...(!isCustomBlock && ctx.executionId ? { executionId: ctx.executionId } : {}),
})
const workflowReadDelegationOrigin: ExecutorDelegationOrigin = isCustomBlock
? { subjectUserId: loadUserId, workflowId }
: (ctx.executorDelegationOrigin ?? {
subjectUserId: loadUserId,
workflowId: ctx.workflowId,
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
})
if (!isCustomBlock) childExecutorDelegationOrigin = workflowReadDelegationOrigin
const workflowReadHeaders = await buildExecutorDelegationHeaders(workflowReadDelegationOrigin)

// A custom block runs the source's latest deployment; if the source has been
// undeployed there's nothing to run. `BoundarySafeError` marks the message as
Expand DownExpand Up@@ -509,10 +515,13 @@ export class WorkflowBlockHandler implements BlockHandler {
...(correlation ? { triggerData: { correlation } } : {}),
})
if (!childSessionStarted) {
logger.error('Custom block child logging failed to start; child spend will be unbilled', {
workflowId,
childExecutionId,
})
childExecutionId = undefined
throw new Error('Custom block child logging failed to start')
}
childExecutorDelegationOrigin = {
subjectUserId: loadUserId,
workflowId,
executionId: childExecutionId,
}
// The child no longer shares the parent's execution id, so it no longer
// hears the parent's cancellation event — bridge it explicitly.
Expand DownExpand Up@@ -599,6 +608,7 @@ export class WorkflowBlockHandler implements BlockHandler {
enforceCredentialAccess: ctx.enforceCredentialAccess,
workspaceId: childWorkspaceId,
userId: childUserId,
executorDelegationOrigin: childExecutorDelegationOrigin,
executionId: childExecutionId ?? ctx.executionId,
// Large values are cached per execution id, so a child running under its
// own id still needs the invoking run's id to read values in its inputs.
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/executor/handlers/workflow/workflow-tool-runner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
type CustomBlockExecutorContext,
} from '@/executor/handlers/workflow/custom-block-tool-runner'
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
import type { ExecutorDelegationOrigin } from '@/executor/types'
import { classifyExecutionError } from '@/executor/utils/errors'
import { parseJSON } from '@/executor/utils/json'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
Expand DownExpand Up@@ -52,6 +53,7 @@ export async function runWorkflowTool(
options: {
abortSignal?: AbortSignal
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
executorDelegationOrigin?: ExecutorDelegationOrigin
} = {}
): Promise<ToolResponse> {
if (!params.workflowId) {
Expand Down
15 changes: 15 additions & 0 deletions apps/sim/executor/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -338,6 +338,19 @@ export interface BlockState {
resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
}

/**
* Canonical signed execution identity used for executor-delegated internal operations.
*
* A nested workflow changes {@link ExecutionContext.workflowId} for execution semantics, but it
* still belongs to the parent log row identified here. Custom blocks replace this origin with the
* publisher-owned child execution after opening their own source-workspace log row.
*/
export interface ExecutorDelegationOrigin {
subjectUserId: string
workflowId: string
executionId?: string
}

export interface ExecutionContext {
workflowId: string
workspaceId?: string
Expand All@@ -347,6 +360,8 @@ export interface ExecutionContext {
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
/** Trusted origin for signed executor delegation, distinct from the currently executing child. */
executorDelegationOrigin?: ExecutorDelegationOrigin
isDeployedContext?: boolean
enforceCredentialAccess?: boolean
copilotToolExecution?: boolean
Expand Down
Loading
Loading