Skip to content

Commit 8ffc43c

Browse files
committed
fix(execution): harden secret projection and block runs
1 parent 97ffff1 commit 8ffc43c

23 files changed

Lines changed: 1551 additions & 146 deletions

apps/sim/app/api/workflows/[id]/execute/route.async.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,44 @@ describe('workflow execute async route', () => {
468468
expect(body).toContain('execution:completed')
469469
})
470470

471+
it('executes a selected trigger as a fresh authenticated draft run', async () => {
472+
const response = await POST(
473+
createMockRequest(
474+
'POST',
475+
{
476+
stream: true,
477+
input: { message: 'hello' },
478+
startBlockId: 'start',
479+
triggerType: 'manual',
480+
useDraftState: true,
481+
isClientSession: true,
482+
},
483+
{
484+
'Content-Type': 'application/json',
485+
Cookie: 'session=value',
486+
}
487+
),
488+
{ params: Promise.resolve({ id: 'workflow-1' }) }
489+
)
490+
await response.text()
491+
492+
expect(response.status).toBe(200)
493+
expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
494+
workflowId: 'workflow-1',
495+
userId: 'session-user-1',
496+
action: 'write',
497+
})
498+
const executionArgs = mockExecuteWorkflowCore.mock.calls[0][0]
499+
expect(executionArgs.runFromBlock).toBeUndefined()
500+
expect(executionArgs.snapshot.metadata).toMatchObject({
501+
triggerType: 'manual',
502+
triggerBlockId: 'start',
503+
useDraftState: true,
504+
isClientSession: true,
505+
sessionUserId: 'session-user-1',
506+
})
507+
})
508+
471509
/**
472510
* A terminal event the replay buffer rejected leaves the stream meta on
473511
* `active`, so a reconnecting reader polls until its deadline and then errors.
@@ -1529,10 +1567,31 @@ describe('workflow execute async route', () => {
15291567
executionData: { executionState: sourceState },
15301568
},
15311569
])
1570+
const workflowStateOverride = {
1571+
blocks: {
1572+
'start-block': {
1573+
id: 'start-block',
1574+
type: 'function',
1575+
name: 'Function 1',
1576+
position: { x: 0, y: 0 },
1577+
subBlocks: {
1578+
code: { id: 'code', type: 'code', value: 'return "current editor state"' },
1579+
},
1580+
outputs: {},
1581+
enabled: true,
1582+
},
1583+
},
1584+
edges: [],
1585+
loops: {},
1586+
parallels: {},
1587+
}
15321588
const request = createMockRequest(
15331589
'POST',
15341590
{
15351591
input: { hello: 'world' },
1592+
useDraftState: true,
1593+
isClientSession: true,
1594+
workflowStateOverride,
15361595
runFromBlock: {
15371596
startBlockId: 'start-block',
15381597
executionId: 'source-execution',
@@ -1547,6 +1606,7 @@ describe('workflow execute async route', () => {
15471606
const response = await POST(request, { params: Promise.resolve({ id: 'workflow-1' }) })
15481607

15491608
expect(response.status).toBe(200)
1609+
const executionArgs = mockExecuteWorkflowCore.mock.calls[0]?.[0]
15501610
expect(mockExecuteWorkflowCore).toHaveBeenCalledWith(
15511611
expect.objectContaining({
15521612
runFromBlock: {
@@ -1556,6 +1616,12 @@ describe('workflow execute async route', () => {
15561616
},
15571617
})
15581618
)
1619+
expect(executionArgs?.snapshot.metadata).toMatchObject({
1620+
useDraftState: true,
1621+
isClientSession: true,
1622+
sessionUserId: 'session-user-1',
1623+
workflowStateOverride,
1624+
})
15591625
})
15601626

15611627
it('falls back to an untrusted client snapshot while stored run-from-block state is pending', async () => {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx

Lines changed: 150 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const {
1212
mockExecute,
1313
mockExecuteFromBlock,
1414
mockFetch,
15+
mockHandleExecutionErrorConsole,
1516
mockResolveStartCandidates,
1617
mockRunUploadStrategy,
1718
mockSelectBestTrigger,
@@ -35,7 +36,7 @@ const {
3536
type: 'starter',
3637
name: 'Start',
3738
enabled: true,
38-
subBlocks: {},
39+
subBlocks: { inputFormat: { value: 'persisted-state' } },
3940
},
4041
}
4142
const idleExecution = {
@@ -76,12 +77,13 @@ const {
7677
finishRunningEntries: vi.fn(),
7778
clearExecutionEntries: vi.fn(),
7879
}
80+
const workflowEdges: Array<{ source: string; target: string }> = []
7981
const workflowStoreState = {
8082
blocks: workflowBlocks,
81-
edges: [],
83+
edges: workflowEdges,
8284
getWorkflowState: vi.fn(() => ({
8385
blocks: workflowBlocks,
84-
edges: [],
86+
edges: workflowEdges,
8587
loops: {},
8688
parallels: {},
8789
})),
@@ -93,6 +95,7 @@ const {
9395
mockExecute: vi.fn(),
9496
mockExecuteFromBlock: vi.fn(),
9597
mockFetch: vi.fn(),
98+
mockHandleExecutionErrorConsole: vi.fn(),
9699
mockResolveStartCandidates: vi.fn(),
97100
mockRunUploadStrategy: vi.fn(),
98101
mockSelectBestTrigger: vi.fn(),
@@ -179,7 +182,7 @@ vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-u
179182
reconcileFinalBlockLogs: vi.fn(),
180183
addExecutionErrorConsoleEntry: vi.fn(),
181184
handleExecutionCancelledConsole: vi.fn(),
182-
handleExecutionErrorConsole: vi.fn(),
185+
handleExecutionErrorConsole: mockHandleExecutionErrorConsole,
183186
}))
184187

185188
vi.mock('@/blocks', () => ({
@@ -283,7 +286,7 @@ vi.mock('@/stores/workflows/registry/store', () => ({
283286
}))
284287

285288
vi.mock('@/stores/workflows/utils', () => ({
286-
mergeSubblockState: () => workflowBlocks,
289+
mergeSubblockState: (blocks: Record<string, unknown>) => blocks,
287290
}))
288291

289292
vi.mock('@/stores/workflows/workflow/store', () => ({
@@ -633,6 +636,22 @@ describe('useWorkflowExecution attachment uploads', () => {
633636
}
634637
executionStoreState.getLastExecutionSnapshot.mockReturnValueOnce(sourceSnapshot)
635638
workflowStoreState.edges.push({ source: 'start', target: 'function-1' } as never)
639+
const currentBlocks = {
640+
...workflowBlocks,
641+
'function-1': {
642+
id: 'function-1',
643+
type: 'function',
644+
name: 'Function 1',
645+
enabled: true,
646+
subBlocks: { code: { value: 'return "current editor state"' } },
647+
},
648+
}
649+
workflowStoreState.getWorkflowState.mockReturnValueOnce({
650+
blocks: currentBlocks,
651+
edges: workflowStoreState.edges,
652+
loops: {},
653+
parallels: {},
654+
})
636655

637656
const { result, unmount } = renderWorkflowExecutionHook()
638657

@@ -646,6 +665,132 @@ describe('useWorkflowExecution attachment uploads', () => {
646665
startBlockId: 'function-1',
647666
sourceExecutionId: 'source-execution-1',
648667
sourceSnapshot,
668+
useDraftState: true,
669+
isClientSession: true,
670+
workflowStateOverride: {
671+
blocks: currentBlocks,
672+
edges: workflowStoreState.edges,
673+
loops: {},
674+
parallels: {},
675+
},
676+
})
677+
)
678+
679+
unmount()
680+
})
681+
682+
it('uses fresh execution for trigger block runs instead of restoring an empty snapshot', async () => {
683+
const currentBlocks = {
684+
...workflowBlocks,
685+
start: {
686+
...workflowBlocks.start,
687+
subBlocks: { inputFormat: { value: 'current-editor-state' } },
688+
},
689+
}
690+
workflowStoreState.getWorkflowState.mockReturnValueOnce({
691+
blocks: currentBlocks,
692+
edges: [],
693+
loops: {},
694+
parallels: {},
695+
})
696+
const { result, unmount } = renderWorkflowExecutionHook()
697+
698+
await act(async () => {
699+
await result().handleRunFromBlock('start', 'workflow-1')
700+
})
701+
702+
expect(mockExecute).toHaveBeenCalledWith(
703+
expect.objectContaining({
704+
workflowId: 'workflow-1',
705+
startBlockId: 'start',
706+
triggerType: 'manual',
707+
useDraftState: true,
708+
isClientSession: true,
709+
workflowStateOverride: {
710+
blocks: currentBlocks,
711+
edges: [],
712+
loops: {},
713+
parallels: {},
714+
},
715+
})
716+
)
717+
expect(mockExecute.mock.calls[0]?.[0]).not.toHaveProperty('sourceSnapshot')
718+
expect(mockExecuteFromBlock).not.toHaveBeenCalled()
719+
720+
unmount()
721+
})
722+
723+
it('fails closed when a legacy run-from-block error has no display projection', async () => {
724+
mockExecute.mockImplementationOnce(async (options) => {
725+
executionStoreState.getCurrentExecutionId.mockReturnValue('execution-1')
726+
options.onExecutionId?.('execution-1')
727+
await options.callbacks?.onExecutionError?.({
728+
error: 'raw-secret-value caused the failure',
729+
duration: 8,
730+
finalBlockLogs: [],
731+
})
732+
})
733+
734+
const { result, unmount } = renderWorkflowExecutionHook()
735+
736+
await act(async () => {
737+
await result().handleRunFromBlock('start', 'workflow-1')
738+
})
739+
740+
expect(mockHandleExecutionErrorConsole).toHaveBeenCalledWith(
741+
expect.objectContaining({
742+
addConsole: terminalStoreState.addConsole,
743+
}),
744+
expect.objectContaining({
745+
workflowId: 'workflow-1',
746+
executionId: 'execution-1',
747+
error: 'raw-secret-value caused the failure',
748+
hasDisplayProjection: true,
749+
durationMs: 8,
750+
})
751+
)
752+
expect(mockHandleExecutionErrorConsole.mock.calls[0]?.[1]).not.toHaveProperty('displayError')
753+
754+
unmount()
755+
})
756+
757+
it('shows one safe error when run-from-block fails before receiving an execution ID', async () => {
758+
const sourceSnapshot = {
759+
blockStates: { start: { output: { value: 'ready' } } },
760+
executedBlocks: ['start'],
761+
blockLogs: [],
762+
decisions: { router: {}, condition: {} },
763+
completedLoops: [],
764+
activeExecutionPath: ['start'],
765+
sourceExecutionId: 'source-execution-1',
766+
}
767+
executionStoreState.getLastExecutionSnapshot.mockReturnValueOnce(sourceSnapshot)
768+
workflowStoreState.edges.push({ source: 'start', target: 'function-1' } as never)
769+
mockExecuteFromBlock.mockImplementationOnce(async (options) => {
770+
await options.callbacks?.onExecutionError?.({
771+
error: 'raw pre-execution failure',
772+
duration: 0,
773+
})
774+
throw new Error('raw pre-execution failure')
775+
})
776+
777+
const { result, unmount } = renderWorkflowExecutionHook()
778+
779+
await act(async () => {
780+
await result().handleRunFromBlock('function-1', 'workflow-1')
781+
})
782+
783+
expect(mockHandleExecutionErrorConsole).toHaveBeenCalledTimes(1)
784+
expect(mockHandleExecutionErrorConsole).toHaveBeenCalledWith(
785+
expect.objectContaining({
786+
addConsole: terminalStoreState.addConsole,
787+
}),
788+
expect.objectContaining({
789+
workflowId: 'workflow-1',
790+
error: 'raw pre-execution failure',
791+
hasDisplayProjection: true,
792+
durationMs: 0,
793+
blockLogs: [],
649794
})
650795
)
651796

0 commit comments

Comments
 (0)