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
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,26 +15,36 @@ vi.mock('@/executor/handlers/workflow/workflow-handler', () => ({
}))

import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
import type { PiiBlockOutputRedaction } from '@/executor/execution/types'
import {
buildCustomBlockExecutionContext,
runCustomBlockTool,
} from '@/executor/handlers/workflow/custom-block-tool-runner'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'

const PII_POLICY: PiiBlockOutputRedaction = {
enabled: true,
entityTypes: ['EMAIL_ADDRESS'],
language: 'en',
}

const mockRunnerLogger =
vi.mocked(createLogger).mock.results[
vi.mocked(createLogger).mock.calls.findIndex(([name]) => name === 'CustomBlockToolRunner')
].value

describe('buildCustomBlockExecutionContext', () => {
it('carries consumer identity, inherits the call chain, and is fully scaffolded', () => {
const ctx = buildCustomBlockExecutionContext({
workspaceId: 'ws-consumer',
userId: 'u-consumer',
workflowId: 'wf-parent',
callChain: ['wf-parent'],
billingAttribution: { actorUserId: 'u-consumer', workspaceId: 'ws-consumer' } as any,
})
const ctx = buildCustomBlockExecutionContext(
{
workspaceId: 'ws-consumer',
userId: 'u-consumer',
workflowId: 'wf-parent',
callChain: ['wf-parent'],
billingAttribution: { actorUserId: 'u-consumer', workspaceId: 'ws-consumer' } as any,
},
{ environmentVariables: {} }
)

expect(ctx.workspaceId).toBe('ws-consumer')
expect(ctx.userId).toBe('u-consumer')
Expand All@@ -59,7 +69,20 @@ describe('buildCustomBlockExecutionContext', () => {
})

it('defaults the call chain to [] when none is provided', () => {
expect(buildCustomBlockExecutionContext({}).callChain).toEqual([])
expect(buildCustomBlockExecutionContext({}, { environmentVariables: {} }).callChain).toEqual([])
})

it('carries the caller-supplied env map and redaction policy verbatim', () => {
const ctx = buildCustomBlockExecutionContext(
{ workspaceId: 'ws-1' },
{
environmentVariables: { MY_API_KEY: 'secret-value' },
piiBlockOutputRedaction: PII_POLICY,
}
)

expect(ctx.environmentVariables).toEqual({ MY_API_KEY: 'secret-value' })
expect(ctx.piiBlockOutputRedaction).toBe(PII_POLICY)
})
})

Expand DownExpand Up@@ -135,6 +158,16 @@ describe('runCustomBlockTool', () => {
expect(res.output).toEqual({})
})

it('runs the child with no env and no redaction policy — the custom branch re-derives both', async () => {
mockExecute.mockResolvedValue({ success: true })

await runCustomBlockTool({ blockType: 'custom_block_abc', _context: {} })

const [ctxArg] = mockExecute.mock.calls[0]
expect(ctxArg.environmentVariables).toEqual({})
expect(ctxArg.piiBlockOutputRedaction).toBeUndefined()
})

it('rejects a missing block type without invoking the handler', async () => {
const res = await runCustomBlockTool({ _context: {} })
expect(res.success).toBe(false)
Expand All@@ -144,19 +177,25 @@ describe('runCustomBlockTool', () => {

describe('buildCustomBlockExecutionContext invoker identity', () => {
it("adopts the invoking run's ids so correlation names a real execution", () => {
const ctx = buildCustomBlockExecutionContext({
workspaceId: 'ws-1',
executionId: 'agent-execution-id',
requestId: 'agent-request-id',
})
const ctx = buildCustomBlockExecutionContext(
{
workspaceId: 'ws-1',
executionId: 'agent-execution-id',
requestId: 'agent-request-id',
},
{ environmentVariables: {} }
)

expect(ctx.executionId).toBe('agent-execution-id')
expect(ctx.metadata.executionId).toBe('agent-execution-id')
expect(ctx.metadata.requestId).toBe('agent-request-id')
})

it('falls back to generated ids when the caller supplies none', () => {
const ctx = buildCustomBlockExecutionContext({ workspaceId: 'ws-1' })
const ctx = buildCustomBlockExecutionContext(
{ workspaceId: 'ws-1' },
{ environmentVariables: {} }
)

expect(ctx.executionId).toBeTruthy()
expect(ctx.metadata.requestId).toBeTruthy()
Expand All@@ -169,14 +208,17 @@ describe('buildCustomBlockExecutionContext cancellation', () => {
const controller = new AbortController()
const ctx = buildCustomBlockExecutionContext(
{ workspaceId: 'ws-1' },
{ abortSignal: controller.signal }
{ environmentVariables: {}, abortSignal: controller.signal }
)

expect(ctx.abortSignal).toBe(controller.signal)
})

it('leaves the signal undefined when the caller has none', () => {
expect(buildCustomBlockExecutionContext({ workspaceId: 'ws-1' }).abortSignal).toBeUndefined()
expect(
buildCustomBlockExecutionContext({ workspaceId: 'ws-1' }, { environmentVariables: {} })
.abortSignal
).toBeUndefined()
})
})

Expand All@@ -186,7 +228,7 @@ describe('buildCustomBlockExecutionContext secret provenance', () => {

const ctx = buildCustomBlockExecutionContext(
{ workspaceId: 'ws-1' },
{ resolvedSecretTraceRegistry: registry }
{ environmentVariables: {}, resolvedSecretTraceRegistry: registry }
)

expect(ctx.resolvedSecretTraceRegistry).toBe(registry)
Expand Down
42 changes: 31 additions & 11 deletions apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { isPlainRecord } from '@sim/utils/object'
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
import type { PiiBlockOutputRedaction } from '@/executor/execution/types'
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
import type { ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types'
import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection'
Expand DownExpand Up@@ -40,23 +41,40 @@ interface CustomBlockToolParams {
}

/**
* Build a minimal top-level `ExecutionContext` for running a custom block as an
* agent tool. Every value comes from the server-set `_context` (LLM-proof),
* including the invoking run's execution and request ids so the child's log
* correlation names a real execution. `WorkflowBlockHandler`'s path re-derives owner
* identity, env, and billing from `getCustomBlockAuthority`, so this only needs the
* fields that path reads — `workspaceId` (org-scopes the authority lookup),
* `metadata` (read unconditionally at `executeCore`), and `callChain` (recursion
* depth guard, inherited so it never resets across hops) — plus the non-optional
* scaffolding. Keep in sync with `WorkflowBlockHandler.executeCore`'s custom branch.
* Build a minimal top-level `ExecutionContext` for running a workflow or a custom
* block as an agent tool. Every value comes from the server-set `_context`
* (LLM-proof) or from `options` (not model-reachable at all), including the
* invoking run's execution and request ids so the child's log correlation names a
* real execution. `WorkflowBlockHandler.executeCore` reads `workspaceId` (org-scopes
* the authority lookup), `metadata` (read unconditionally), and `callChain`
* (recursion depth guard, inherited so it never resets across hops), plus the
* non-optional scaffolding.
*
* `environmentVariables` is required rather than defaulted because only the caller
* knows which identity's env the child must run under: the custom-block branch
* re-derives the publisher's env from `getCustomBlockAuthority` and passes `{}`,
* while every other caller must forward the invoking run's map or the child
* resolves `{{VAR}}` to the literal reference string. Silent omission is exactly
* how the workflow-as-agent-tool path shipped with an empty map.
*
* `piiBlockOutputRedaction` stays optional because `undefined` is its correct
* value rather than a wrong identity: most tenants have no policy at all, and the
* custom-block branch omits it deliberately — that child runs cross-workspace
* under the publisher's identity, so the consumer's redaction rules would be the
* wrong tenant's, exactly as the consumer's env would be.
* Keep in sync with `WorkflowBlockHandler.executeCore`.
*/
export function buildCustomBlockExecutionContext(
context: CustomBlockExecutorContext,
options: {
/** The invoking run's decrypted env, or `{}` when the child re-derives its own. */
environmentVariables: Record<string, string>
abortSignal?: AbortSignal
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
executorDelegationOrigin?: ExecutorDelegationOrigin
} = {}
/** The invoking run's in-flight block-output redaction policy. */
piiBlockOutputRedaction?: PiiBlockOutputRedaction
}
): ExecutionContext {
// Prefer the invoking agent run's ids so correlation and cancellation both
// point at a real execution; fall back only when a caller could not supply them.
Expand All@@ -75,7 +93,8 @@ export function buildCustomBlockExecutionContext(
// the agent tool loop owns the only signal reaching this path.
abortSignal: options.abortSignal,
resolvedSecretTraceRegistry: options.resolvedSecretTraceRegistry,
environmentVariables: {},
environmentVariables: options.environmentVariables,
piiBlockOutputRedaction: options.piiBlockOutputRedaction,
blockStates: new Map(),
executedBlocks: new Set(),
blockLogs: [],
Expand DownExpand Up@@ -121,6 +140,7 @@ export async function runCustomBlockTool(
}

const ctx = buildCustomBlockExecutionContext(params._context ?? {}, {
environmentVariables: {},
abortSignal: options.abortSignal,
resolvedSecretTraceRegistry: options.resolvedSecretTraceRegistry,
})
Expand Down
37 changes: 37 additions & 0 deletions apps/sim/executor/handlers/workflow/workflow-handler.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -516,6 +516,43 @@ describe('WorkflowBlockHandler', () => {
expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
})

it("runs a non-custom child under the parent's env and redaction policy", async () => {
const piiBlockOutputRedaction = {
enabled: true,
entityTypes: ['EMAIL_ADDRESS'],
language: 'en',
}
const ctx = {
...mockContext,
workspaceId: 'workspace-parent',
environmentVariables: { MY_API_KEY: 'parent-secret' },
piiBlockOutputRedaction,
} as unknown as ExecutionContext

mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Child Workflow',
workspaceId: 'workspace-parent',
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
},
}),
})
mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } })
mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } })

await handler.execute(ctx, mockBlock, inputs)

expect(executorOptions).toHaveLength(1)
expect(executorOptions[0].envVarValues).toEqual({ MY_API_KEY: 'parent-secret' })
expect(executorOptions[0].contextExtensions.piiBlockOutputRedaction).toBe(
piiBlockOutputRedaction
)
expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled()
})

it('resolves a source-scoped billing attribution for custom block children', async () => {
const consumerAttribution = { actorUserId: 'consumer-1', workspaceId: 'workspace-consumer' }
const sourceAttribution = { actorUserId: 'owner-9', workspaceId: 'workspace-source' }
Expand Down
63 changes: 63 additions & 0 deletions apps/sim/executor/handlers/workflow/workflow-tool-runner.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() }))

vi.mock('@/executor/handlers/workflow/workflow-handler', () => ({
WorkflowBlockHandler: class {
execute = mockExecute
},
}))

import type { PiiBlockOutputRedaction } from '@/executor/execution/types'
import { runWorkflowTool } from '@/executor/handlers/workflow/workflow-tool-runner'

const PII_POLICY: PiiBlockOutputRedaction = {
enabled: true,
entityTypes: ['EMAIL_ADDRESS'],
language: 'en',
}

describe('runWorkflowTool execution context', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExecute.mockResolvedValue({ success: true })
})

it("runs the child under the invoking run's environment variables", async () => {
await runWorkflowTool(
{ workflowId: 'wf-child', _context: { workspaceId: 'ws-1' } },
{ environmentVariables: { MY_API_KEY: 'secret-value' } }
)

const [ctxArg] = mockExecute.mock.calls[0]
expect(ctxArg.environmentVariables).toEqual({ MY_API_KEY: 'secret-value' })
})

it("forwards the invoking run's block-output redaction policy", async () => {
await runWorkflowTool(
{ workflowId: 'wf-child', _context: { workspaceId: 'ws-1' } },
{ environmentVariables: {}, piiBlockOutputRedaction: PII_POLICY }
)

const [ctxArg] = mockExecute.mock.calls[0]
expect(ctxArg.piiBlockOutputRedaction).toBe(PII_POLICY)
})

it('ignores an env map smuggled in through the model-reachable _context bag', async () => {
const modelSuppliedContext = {
workspaceId: 'ws-1',
environmentVariables: { MY_API_KEY: 'model-injected' },
}

await runWorkflowTool(
{ workflowId: 'wf-child', _context: modelSuppliedContext },
{ environmentVariables: { MY_API_KEY: 'trusted' } }
)

const [ctxArg] = mockExecute.mock.calls[0]
expect(ctxArg.environmentVariables).toEqual({ MY_API_KEY: 'trusted' })
})
})
10 changes: 9 additions & 1 deletion apps/sim/executor/handlers/workflow/workflow-tool-runner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import { generateId } from '@sim/utils/id'
import { calculateCostSummary } from '@/lib/logs/execution/logging-factory'
import type { TraceSpan } from '@/lib/logs/types'
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
import type { PiiBlockOutputRedaction } from '@/executor/execution/types'
import {
buildCustomBlockExecutionContext,
type CustomBlockExecutorContext,
Expand DownExpand Up@@ -47,14 +48,21 @@ interface WorkflowToolParams {
* On failure the result carries the structured error + the child executionId
* in `output` so parent workflows can route on `error.code` and report a
* reproducible handle to the workflow's provider.
*
* The child runs under the invoking run's environment variables and block-output
* redaction policy, matching the canvas workflow block — `workflow-handler.ts`
* keeps both from the parent context on the non-custom branch. The handler's
* same-workspace assert bounds that forwarding to a single workspace.
*/
export async function runWorkflowTool(
params: WorkflowToolParams,
options: {
environmentVariables: Record<string, string>
abortSignal?: AbortSignal
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
executorDelegationOrigin?: ExecutorDelegationOrigin
} = {}
piiBlockOutputRedaction?: PiiBlockOutputRedaction
}
): Promise<ToolResponse> {
if (!params.workflowId) {
return { success: false, output: {}, error: 'Missing workflowId' }
Expand Down
Loading
Loading