diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index fe4bb0e624d..b6fcb7a2f97 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -4,11 +4,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata' +import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' +import { calculateStreamingCost } from '@/lib/tokenization' import { BlockType } from '@/executor/constants' import type { DAGNode } from '@/executor/dag/builder' import { BlockExecutor } from '@/executor/execution/block-executor' +import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer' import { ExecutionState } from '@/executor/execution/state' -import type { BlockHandler, ExecutionContext } from '@/executor/types' +import type { ContextExtensions } from '@/executor/execution/types' +import type { BlockHandler, ExecutionContext, ExecutionResult } from '@/executor/types' import { VariableResolver } from '@/executor/variables/resolver' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' @@ -75,6 +79,44 @@ function createNode(block: SerializedBlock): DAGNode { } } +function createExecutorForTest( + block: SerializedBlock, + state: ExecutionState, + handler: BlockHandler, + extensions: Partial = {} +): BlockExecutor { + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const resolver = new VariableResolver(workflow, {}, state) + + return new BlockExecutor( + [handler], + resolver, + { + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + ...extensions, + }, + state + ) +} + describe('BlockExecutor', () => { beforeEach(() => { vi.clearAllMocks() @@ -82,6 +124,304 @@ describe('BlockExecutor', () => { mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey })) }) + it.each([ + { label: 'provider time segments', includeTimeSegments: true }, + { label: 'fallback tool calls', includeTimeSegments: false }, + ])( + 'sanitizes agent trace I/O while preserving runtime values with $label', + async ({ includeTimeSegments }) => { + const secret = 'trace-secret/value' + const unreferencedValue = 'us-east-1' + const block: SerializedBlock = { + ...createBlock(), + id: 'agent-block-1', + metadata: { id: BlockType.AGENT, name: 'Agent' }, + config: { + tool: BlockType.AGENT, + params: { + prompt: 'Use {{TRACE_SECRET}}', + }, + }, + } + const state = new ExecutionState() + const execute = vi.fn(async (_ctx, _block, inputs) => { + expect(inputs.prompt).toBe(`Use ${secret}`) + return { + content: `Echoed ${secret}`, + region: unreferencedValue, + providerTiming: { + startTime: '2024-01-01T10:00:00.000Z', + endTime: '2024-01-01T10:00:02.000Z', + duration: 2000, + ...(includeTimeSegments && { + timeSegments: [ + { + type: 'model' as const, + name: 'Model', + startTime: 1704103200000, + endTime: 1704103201000, + duration: 1000, + assistantContent: `Calling with ${secret}`, + toolCalls: [ + { + id: 'call-1', + name: 'lookup', + arguments: { query: secret }, + }, + ], + }, + { + type: 'tool' as const, + name: 'lookup', + startTime: 1704103201000, + endTime: 1704103202000, + duration: 1000, + }, + ], + }), + }, + toolCalls: { + list: [ + { + name: 'lookup', + arguments: { query: secret }, + result: { echoed: secret }, + duration: 1000, + }, + ], + count: 1, + }, + childTraceSpans: [ + { + id: 'child-1', + name: 'Child', + type: 'function', + duration: 1, + startTime: '2024-01-01T10:00:00.000Z', + endTime: '2024-01-01T10:00:00.001Z', + input: { query: secret }, + output: { echoed: secret }, + }, + ], + } + }) + const handler: BlockHandler = { + canHandle: () => true, + execute, + } + const onBlockComplete = vi.fn(async () => {}) + const executor = createExecutorForTest(block, state, handler, { onBlockComplete }) + const ctx = createContext(state) + ctx.environmentVariables = { + TRACE_SECRET: secret, + UNREFERENCED_REGION: unreferencedValue, + } + + const output = await executor.execute(ctx, createNode(block), block) + + expect(output.content).toBe(`Echoed ${secret}`) + expect(output.toolCalls.list[0].arguments.query).toBe(secret) + expect(state.getBlockOutput(block.id)?.content).toBe(`Echoed ${secret}`) + + await vi.waitFor(() => { + expect(onBlockComplete).toHaveBeenCalled() + }) + + const serializedLog = JSON.stringify(ctx.blockLogs[0]) + const serializedCallback = JSON.stringify(onBlockComplete.mock.calls[0]) + expect(serializedLog).not.toContain(secret) + expect(serializedLog).toContain('{{TRACE_SECRET}}') + expect(serializedLog).toContain(unreferencedValue) + expect(serializedCallback).not.toContain(secret) + expect(serializedCallback).toContain('{{TRACE_SECRET}}') + + const { traceSpans } = buildTraceSpans({ + success: true, + output: {}, + logs: ctx.blockLogs, + } as ExecutionResult) + const serializedSpans = JSON.stringify(traceSpans) + expect(serializedSpans).not.toContain(secret) + expect(serializedSpans).toContain('{{TRACE_SECRET}}') + } + ) + + it('sanitizes failed logs and callbacks while preserving runtime errors', async () => { + const secret = 'failure-secret' + const block: SerializedBlock = { + ...createBlock(), + config: { + tool: BlockType.FUNCTION, + params: { + code: 'throw new Error("{{TRACE_SECRET}}")', + }, + }, + } + const state = new ExecutionState() + const handler: BlockHandler = { + canHandle: () => true, + execute: async (_ctx, _block, inputs) => { + expect(inputs.code).toContain(secret) + throw new Error(`Execution failed with ${secret}`) + }, + } + const onBlockComplete = vi.fn(async () => {}) + const executor = createExecutorForTest(block, state, handler, { onBlockComplete }) + const ctx = createContext(state) + ctx.environmentVariables = { TRACE_SECRET: secret } + + await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(secret) + + expect(state.getBlockOutput(block.id)?.error).toContain(secret) + expect(ctx.blockLogs[0].error).toBe('Execution failed with {{TRACE_SECRET}}') + expect(JSON.stringify(ctx.blockLogs[0])).not.toContain(secret) + await vi.waitFor(() => { + expect(onBlockComplete).toHaveBeenCalled() + }) + expect(JSON.stringify(onBlockComplete.mock.calls[0])).not.toContain(secret) + }) + + it('sanitizes error-handler logs without changing the handled runtime output', async () => { + const secret = 'handled-secret' + const block: SerializedBlock = { + ...createBlock(), + config: { + tool: BlockType.FUNCTION, + params: { + code: 'throw new Error("{{TRACE_SECRET}}")', + }, + }, + } + const state = new ExecutionState() + const handler: BlockHandler = { + canHandle: () => true, + execute: async () => { + throw new Error(`Handled ${secret}`) + }, + } + const executor = createExecutorForTest(block, state, handler) + const infoSpy = vi.spyOn( + ( + executor as unknown as { + execLogger: { info: (message: string, metadata?: Record) => void } + } + ).execLogger, + 'info' + ) + const ctx = createContext(state) + ctx.environmentVariables = { TRACE_SECRET: secret } + const node = createNode(block) + node.outgoingEdges.set('error-edge', { + id: 'error-edge', + source: block.id, + target: 'error-handler', + sourceHandle: 'error', + targetHandle: 'target', + }) + + const output = await executor.execute(ctx, node, block) + + expect(output.error).toBe(`Handled ${secret}`) + expect(state.getBlockOutput(block.id)?.error).toBe(`Handled ${secret}`) + expect(ctx.blockLogs[0].errorHandled).toBe(true) + expect(JSON.stringify(ctx.blockLogs[0])).not.toContain(secret) + expect(JSON.stringify(ctx.blockLogs[0])).toContain('{{TRACE_SECRET}}') + expect(infoSpy).toHaveBeenCalledWith( + 'Block has error port - returning error output instead of throwing', + expect.objectContaining({ error: 'Handled {{TRACE_SECRET}}' }) + ) + }) + + it('sanitizes soft-abort agent inputs while leaving execution resolution unchanged', async () => { + const secret = 'abort-secret' + const block: SerializedBlock = { + ...createBlock(), + id: 'agent-block-1', + metadata: { id: BlockType.AGENT, name: 'Agent' }, + config: { + tool: BlockType.AGENT, + params: { + prompt: 'Use {{TRACE_SECRET}}', + }, + }, + } + const state = new ExecutionState() + const abortController = new AbortController() + const handler: BlockHandler = { + canHandle: () => true, + execute: async (_ctx, _block, inputs) => { + expect(inputs.prompt).toBe(`Use ${secret}`) + abortController.abort('user') + throw new DOMException(`Stopped ${secret}`, 'AbortError') + }, + } + const onBlockComplete = vi.fn(async () => {}) + const executor = createExecutorForTest(block, state, handler, { onBlockComplete }) + const ctx = createContext(state) + ctx.environmentVariables = { TRACE_SECRET: secret } + ctx.abortSignal = abortController.signal + + const output = await executor.execute(ctx, createNode(block), block) + + expect(output).toEqual({ content: '' }) + expect(ctx.blockLogs[0].success).toBe(true) + expect(JSON.stringify(ctx.blockLogs[0])).not.toContain(secret) + expect(JSON.stringify(ctx.blockLogs[0])).toContain('{{TRACE_SECRET}}') + await vi.waitFor(() => { + expect(onBlockComplete).toHaveBeenCalled() + }) + expect(JSON.stringify(onBlockComplete.mock.calls[0])).not.toContain(secret) + }) + + it('keeps snapshot state executable and re-resolves current values on retry', async () => { + const firstSecret = 'first-runtime-secret' + const secondSecret = 'second-runtime-secret' + const block: SerializedBlock = { + ...createBlock(), + config: { + tool: BlockType.FUNCTION, + params: { + code: 'return "{{TRACE_SECRET}}"', + }, + }, + } + const state = new ExecutionState() + const receivedRuntimeCode: string[] = [] + const handler: BlockHandler = { + canHandle: () => true, + execute: async (_ctx, _block, inputs) => { + receivedRuntimeCode.push(inputs.code) + return { result: inputs.code } + }, + } + const executor = createExecutorForTest(block, state, handler) + const firstContext = createContext(state) + firstContext.environmentVariables = { TRACE_SECRET: firstSecret } + + await executor.execute(firstContext, createNode(block), block) + const snapshot = JSON.parse(serializePauseSnapshot(firstContext, ['next-block']).snapshot) as { + state: { + blockStates: Record + blockLogs: ExecutionContext['blockLogs'] + } + } + + expect(snapshot.state.blockStates[block.id].output.result).toContain(firstSecret) + expect(JSON.stringify(snapshot.state.blockLogs)).not.toContain(firstSecret) + expect(JSON.stringify(snapshot.state.blockLogs)).toContain('{{TRACE_SECRET}}') + + const resumedContext = createContext(state) + resumedContext.blockLogs.push(...snapshot.state.blockLogs) + resumedContext.environmentVariables = { TRACE_SECRET: secondSecret } + + await executor.execute(resumedContext, createNode(block), block) + + expect(receivedRuntimeCode).toEqual([`return "${firstSecret}"`, `return "${secondSecret}"`]) + expect(state.getBlockOutput(block.id)?.result).toContain(secondSecret) + expect(JSON.stringify(resumedContext.blockLogs)).not.toContain(firstSecret) + expect(JSON.stringify(resumedContext.blockLogs)).not.toContain(secondSecret) + }) + it('persists function output arrays as manifests in execution state', async () => { const block = createBlock() const workflow: SerializedWorkflow = { @@ -439,20 +779,20 @@ describe('BlockExecutor', () => { }) describe('BlockExecutor streaming pump', () => { - function createAgentBlock(): SerializedBlock { + function createAgentBlock(params: Record = {}): SerializedBlock { return { id: 'agent-block-1', metadata: { id: BlockType.AGENT, name: 'Agent' }, position: { x: 0, y: 0 }, - config: { tool: BlockType.AGENT, params: {} }, + config: { tool: BlockType.AGENT, params }, inputs: {}, outputs: {}, enabled: true, } } - function createExecutor(handler: BlockHandler) { - const block = createAgentBlock() + function createExecutor(handler: BlockHandler, params: Record = {}) { + const block = createAgentBlock(params) const workflow: SerializedWorkflow = { version: '1', blocks: [block], @@ -615,6 +955,51 @@ describe('BlockExecutor streaming pump', () => { expect(state.getBlockOutput(block.id)?.content).toBe('offline answer') }) + it('estimates missing streaming usage from resolved input before sanitizing logs', async () => { + const secret = `sk-${'resolved-secret-'.repeat(20)}` + const params = { prompt: '{{OPENAI_API_KEY}}', model: 'gpt-4o' } + const handler: BlockHandler = { + canHandle: () => true, + execute: async (_ctx, _block, resolvedInputs) => ({ + stream: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('streamed answer')) + controller.close() + }, + }), + execution: { + success: true, + output: { content: '', model: 'gpt-4o' }, + logs: [], + metadata: { + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + duration: 1, + }, + }, + }), + } + const { executor, block, state } = createExecutor(handler, params) + const ctx = createContext(state) + ctx.environmentVariables = { OPENAI_API_KEY: secret } + + await executor.execute(ctx, createNode(block), block) + + const expected = calculateStreamingCost( + 'gpt-4o', + JSON.stringify({ prompt: secret, model: 'gpt-4o' }), + 'streamed answer' + ) + expect(state.getBlockOutput(block.id)?.tokens).toEqual(expected.tokens) + expect(state.getBlockOutput(block.id)?.cost).toEqual(expected.cost) + expect(ctx.blockLogs[0].input).toEqual({ + prompt: '{{OPENAI_API_KEY}}', + model: 'gpt-4o', + }) + expect(ctx.blockLogs[0].output?.tokens).toEqual(expected.tokens) + expect(JSON.stringify(ctx.blockLogs[0])).not.toContain(secret) + }) + it('throws on mid-stream provider error (no truncated success)', async () => { const handler = createAgentEventsStreamingHandler({ failAfterText: 'partial', @@ -676,16 +1061,20 @@ describe('BlockExecutor streaming pump', () => { }) it('fails on timeout but keeps drained answer text in block output', async () => { + const secret = 'partial-stream-secret' const abortController = new AbortController() const handler = createAgentEventsStreamingHandler({ events: [ - { type: 'text_delta', text: 'partial before timeout', turn: 'final' }, + { type: 'text_delta', text: `partial ${secret} before timeout`, turn: 'final' }, { type: 'thinking_delta', text: 'more' }, ], }) - const { executor, block, state } = createExecutor(handler) + const { executor, block, state } = createExecutor(handler, { + prompt: '{{TRACE_SECRET}}', + }) const ctx = createContext(state) + ctx.environmentVariables = { TRACE_SECRET: secret } ctx.abortSignal = abortController.signal ctx.onStream = async (streamingExec) => { streamingExec.subscribe?.({ onEvent: async () => {} }) @@ -707,7 +1096,9 @@ describe('BlockExecutor streaming pump', () => { const output = state.getBlockOutput(block.id) expect(output?.error).toBeTruthy() - expect(output?.content).toBe('partial before timeout') + expect(output?.content).toBe(`partial ${secret} before timeout`) + expect(JSON.stringify(ctx.blockLogs[0])).not.toContain(secret) + expect(ctx.blockLogs[0].output?.content).toBe('partial {{TRACE_SECRET}} before timeout') }) it('with PII redaction: no live forward and strips thinking from traces', async () => { diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index f4b12617509..a80692a06e9 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -7,6 +7,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' import { redactLargeValueRefsInValue } from '@/lib/logs/execution/pii-large-values' import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction' +import { processStreamingBlockLog } from '@/lib/tokenization' import { containsUserFileWithMetadata, hydrateUserFilesWithBase64, @@ -43,6 +44,10 @@ import { type StreamingExecution, } from '@/executor/types' import { streamingResponseFormatProcessor } from '@/executor/utils' +import { + createEnvironmentSecretSanitizer, + type EnvironmentSecretSanitizer, +} from '@/executor/utils/environment-secret-sanitizer' import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors' import { buildUnifiedParentIterations, @@ -100,6 +105,10 @@ export class BlockExecutor { const blockType = block.metadata?.id ?? '' const isSentinel = isSentinelBlockType(blockType) + const sanitizeEnvironmentSecrets = createEnvironmentSecretSanitizer( + block.config, + ctx.environmentVariables + ) // Capture startedAt and startTime at the same synchronous instant so // blockLog.startedAt and performance.now()-derived durationMs share a @@ -159,7 +168,11 @@ export class BlockExecutor { } if (blockLog) { - blockLog.input = this.sanitizeInputsForLog(inputsForLog, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog( + inputsForLog, + sanitizeEnvironmentSecrets, + block.metadata?.id + ) } } catch (error) { cleanupSelfReference?.() @@ -173,6 +186,7 @@ export class BlockExecutor { blockLog, inputsForLog, isSentinel, + sanitizeEnvironmentSecrets, 'input_resolution' ) } @@ -277,9 +291,13 @@ export class BlockExecutor { blockLog.endedAt = endedAt blockLog.durationMs = duration blockLog.success = true - blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block }) + blockLog.output = this.sanitizeOutputForLog( + block, + normalizedOutput, + sanitizeEnvironmentSecrets + ) if (normalizedOutput.childTraceSpans && Array.isArray(normalizedOutput.childTraceSpans)) { - blockLog.childTraceSpans = normalizedOutput.childTraceSpans + blockLog.childTraceSpans = sanitizeEnvironmentSecrets(normalizedOutput.childTraceSpans) } } @@ -291,15 +309,17 @@ export class BlockExecutor { typeof normalizedOutput._childWorkflowInstanceId === 'string' ? normalizedOutput._childWorkflowInstanceId : undefined - const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { + const displayOutput = this.sanitizeOutputForLog( block, - }) + normalizedOutput, + sanitizeEnvironmentSecrets + ) this.fireBlockCompleteCallback( blockStartPromise, ctx, node, block, - this.sanitizeInputsForLog(inputsForLog, block.metadata?.id), + this.sanitizeInputsForLog(inputsForLog, sanitizeEnvironmentSecrets, block.metadata?.id), displayOutput, duration, blockLog.startedAt, @@ -321,6 +341,7 @@ export class BlockExecutor { blockLog, inputsForLog, isSentinel, + sanitizeEnvironmentSecrets, 'execution', streamingPartialOutput ) @@ -379,12 +400,14 @@ export class BlockExecutor { blockLog: BlockLog | undefined, inputsForLog: Record, isSentinel: boolean, + sanitizeEnvironmentSecrets: EnvironmentSecretSanitizer, phase: 'input_resolution' | 'execution', streamingPartialOutput?: Record ): Promise { const endedAt = new Date().toISOString() const duration = performance.now() - startTime const errorMessage = normalizeError(error) + const sanitizedErrorMessage = sanitizeEnvironmentSecrets(errorMessage) const hasLogInputs = inputsForLog && typeof inputsForLog === 'object' && Object.keys(inputsForLog).length > 0 const input = hasLogInputs @@ -412,8 +435,12 @@ export class BlockExecutor { blockLog.durationMs = duration blockLog.success = true blockLog.error = undefined - blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) - blockLog.output = filterOutputForLog(block.metadata?.id || '', softOutput, { block }) + blockLog.input = this.sanitizeInputsForLog( + input, + sanitizeEnvironmentSecrets, + block.metadata?.id + ) + blockLog.output = this.sanitizeOutputForLog(block, softOutput, sanitizeEnvironmentSecrets) } this.execLogger.info('Block stream aborted by client; soft-completing', { @@ -427,8 +454,8 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(input, block.metadata?.id), - filterOutputForLog(block.metadata?.id || '', softOutput, { block }), + this.sanitizeInputsForLog(input, sanitizeEnvironmentSecrets, block.metadata?.id), + this.sanitizeOutputForLog(block, softOutput, sanitizeEnvironmentSecrets), duration, blockLog.startedAt, blockLog.executionOrder, @@ -463,12 +490,16 @@ export class BlockExecutor { blockLog.endedAt = endedAt blockLog.durationMs = duration blockLog.success = false - blockLog.error = errorMessage - blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) - blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) + blockLog.error = sanitizedErrorMessage + blockLog.input = this.sanitizeInputsForLog( + input, + sanitizeEnvironmentSecrets, + block.metadata?.id + ) + blockLog.output = this.sanitizeOutputForLog(block, errorOutput, sanitizeEnvironmentSecrets) if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) { - blockLog.childTraceSpans = error.childTraceSpans + blockLog.childTraceSpans = sanitizeEnvironmentSecrets(error.childTraceSpans) } } @@ -477,7 +508,7 @@ export class BlockExecutor { { blockId: node.id, blockType: block.metadata?.id, - error: errorMessage, + error: sanitizedErrorMessage, } ) @@ -485,13 +516,17 @@ export class BlockExecutor { const childWorkflowInstanceId = ChildWorkflowError.isChildWorkflowError(error) ? error.childWorkflowInstanceId : undefined - const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) + const displayOutput = this.sanitizeOutputForLog( + block, + errorOutput, + sanitizeEnvironmentSecrets + ) this.fireBlockCompleteCallback( blockStartPromise, ctx, node, block, - this.sanitizeInputsForLog(input, block.metadata?.id), + this.sanitizeInputsForLog(input, sanitizeEnvironmentSecrets, block.metadata?.id), displayOutput, duration, blockLog.startedAt, @@ -508,7 +543,7 @@ export class BlockExecutor { } this.execLogger.info('Block has error port - returning error output instead of throwing', { blockId: node.id, - error: errorMessage, + error: sanitizedErrorMessage, }) return errorOutput } @@ -615,6 +650,7 @@ export class BlockExecutor { */ private sanitizeInputsForLog( inputs: Record, + sanitizeEnvironmentSecrets: EnvironmentSecretSanitizer, blockType?: string ): Record { // Custom (deploy-as-block) blocks run via an internal `workflow_executor`; the @@ -671,7 +707,20 @@ export class BlockExecutor { } } - return redactApiKeys(result) + return sanitizeEnvironmentSecrets(redactApiKeys(result)) + } + + /** + * Builds the display-only output shared by persisted logs and live callbacks. + * Runtime output remains untouched for state, handlers, retries, and resume. + */ + private sanitizeOutputForLog( + block: SerializedBlock, + output: NormalizedBlockOutput, + sanitizeEnvironmentSecrets: EnvironmentSecretSanitizer + ): NormalizedBlockOutput { + const filteredOutput = filterOutputForLog(block.metadata?.id ?? '', output, { block }) + return sanitizeEnvironmentSecrets(redactApiKeys(filteredOutput)) as NormalizedBlockOutput } /** @@ -989,6 +1038,19 @@ export class BlockExecutor { if (!parsedForFormat) { executionOutput.content = fullContent } + + // Fallback usage estimation must happen while the resolved input is + // still available. The log copy is sanitized later, and estimating from + // `{{ENV_VAR}}` placeholders would skew token counts and cost. + processStreamingBlockLog( + { + blockId, + blockType: block.metadata?.id, + input: resolvedInputs, + output: streamingExec.execution.output, + }, + fullContent + ) } if (streamingExec.onFullContent) { diff --git a/apps/sim/executor/utils/environment-secret-sanitizer.test.ts b/apps/sim/executor/utils/environment-secret-sanitizer.test.ts new file mode 100644 index 00000000000..6f74eade253 --- /dev/null +++ b/apps/sim/executor/utils/environment-secret-sanitizer.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { createEnvironmentSecretSanitizer } from '@/executor/utils/environment-secret-sanitizer' + +describe('createEnvironmentSecretSanitizer', () => { + it('sanitizes referenced values recursively without mutating the source', () => { + const secret = 'top-secret/value' + const source = { + message: `Bearer ${secret}`, + nested: [{ [secret]: secret }], + } + const sanitize = createEnvironmentSecretSanitizer( + { credential: '{{API_SECRET}}' }, + { API_SECRET: secret } + ) + + const sanitized = sanitize(source) + + expect(sanitized).toEqual({ + message: 'Bearer {{API_SECRET}}', + nested: [{ '{{API_SECRET}}': '{{API_SECRET}}' }], + }) + expect(source).toEqual({ + message: `Bearer ${secret}`, + nested: [{ [secret]: secret }], + }) + expect(sanitized).not.toBe(source) + expect(sanitized.nested).not.toBe(source.nested) + }) + + it('sanitizes URL-encoded secret values', () => { + const secret = 'secret/value with spaces' + const sanitize = createEnvironmentSecretSanitizer('{{API_SECRET}}', { API_SECRET: secret }) + + expect(sanitize(`token=${encodeURIComponent(secret)}`)).toBe('token={{API_SECRET}}') + }) + + it('sanitizes mixed-case percent escapes without folding ordinary characters', () => { + const secret = 'CaseSensitive/value:next' + const sanitize = createEnvironmentSecretSanitizer('{{API_SECRET}}', { API_SECRET: secret }) + + expect(sanitize('token=CaseSensitive%2fvalue%3Anext')).toBe('token={{API_SECRET}}') + expect(sanitize('token=casesensitive%2fvalue%3Anext')).toBe( + 'token=casesensitive%2fvalue%3Anext' + ) + }) + + it('sanitizes form-encoded secrets that use plus for spaces', () => { + const secret = 'secret value/next' + const sanitize = createEnvironmentSecretSanitizer('{{API_SECRET}}', { API_SECRET: secret }) + + expect(sanitize('token=secret+value%2fnext')).toBe('token={{API_SECRET}}') + }) + + it('still sanitizes literal values that cannot be URI encoded', () => { + const secret = `secret-${String.fromCharCode(0xd800)}` + const sanitize = createEnvironmentSecretSanitizer('{{API_SECRET}}', { API_SECRET: secret }) + + expect(sanitize(secret)).toBe('{{API_SECRET}}') + }) + + it('only uses environment variables referenced by the block configuration', () => { + const sanitize = createEnvironmentSecretSanitizer( + { credential: '{{REFERENCED}}' }, + { + REFERENCED: 'replace-me', + UNREFERENCED: 'ordinary-value', + } + ) + + expect(sanitize('replace-me ordinary-value')).toBe('{{REFERENCED}} ordinary-value') + }) + + it('ignores empty and missing referenced values', () => { + const sanitize = createEnvironmentSecretSanitizer(['{{EMPTY}}', '{{MISSING}}'], { EMPTY: '' }) + + expect(sanitize('unchanged')).toBe('unchanged') + }) + + it('replaces overlapping values longest first', () => { + const sanitize = createEnvironmentSecretSanitizer(['{{SHORT}}', '{{LONG}}'], { + SHORT: 'secret', + LONG: 'secret-suffix', + }) + + expect(sanitize('secret-suffix secret')).toBe('{{LONG}} {{SHORT}}') + }) + + it('uses the lexicographically first name for duplicate values', () => { + const sanitize = createEnvironmentSecretSanitizer(['{{Z_SECRET}}', '{{A_SECRET}}'], { + Z_SECRET: 'same-value', + A_SECRET: 'same-value', + }) + + expect(sanitize('same-value')).toBe('{{A_SECRET}}') + }) + + it('does not re-sanitize placeholders inserted earlier in the same string', () => { + const sanitize = createEnvironmentSecretSanitizer(['{{A_SECRET}}', '{{B_SECRET}}'], { + A_SECRET: 'secret-value', + B_SECRET: 'A_SECRET', + }) + + expect(sanitize('secret-value A_SECRET')).toBe('{{A_SECRET}} {{B_SECRET}}') + }) + + it('handles special object keys without changing the object prototype', () => { + const source = Object.create(null) as Record + Object.defineProperty(source, '__proto__', { + value: 'secret', + enumerable: true, + configurable: true, + writable: true, + }) + source.constructor = 'secret' + const sanitize = createEnvironmentSecretSanitizer('{{SECRET}}', { SECRET: 'secret' }) + + const sanitized = sanitize(source) + + expect(Object.getPrototypeOf(sanitized)).toBeNull() + expect(Object.keys(sanitized)).toEqual(['__proto__', 'constructor']) + expect(sanitized.__proto__).toBe('{{SECRET}}') + expect(sanitized.constructor).toBe('{{SECRET}}') + }) + + it('leaves non-plain runtime objects unchanged', () => { + const date = new Date() + const sanitize = createEnvironmentSecretSanitizer('{{SECRET}}', { SECRET: 'secret' }) + + expect(sanitize(date)).toBe(date) + }) + + it('finds references in nested configuration keys and tolerates cycles', () => { + const configured: Record = {} + configured['prefix-{{SECRET}}'] = configured + const source: Record = { value: 'secret' } + source.self = source + const sanitize = createEnvironmentSecretSanitizer(configured, { SECRET: 'secret' }) + + const sanitized = sanitize(source) + + expect(sanitized.value).toBe('{{SECRET}}') + expect(sanitized.self).toBe(sanitized) + }) +}) diff --git a/apps/sim/executor/utils/environment-secret-sanitizer.ts b/apps/sim/executor/utils/environment-secret-sanitizer.ts new file mode 100644 index 00000000000..5daff377108 --- /dev/null +++ b/apps/sim/executor/utils/environment-secret-sanitizer.ts @@ -0,0 +1,223 @@ +import { createEnvVarPattern } from '@/executor/utils/reference-validation' + +interface SecretReplacement { + value: string + comparisonValue: string + normalizePercentEscapes: boolean + placeholder: string +} + +export type EnvironmentSecretSanitizer = (value: T) => T + +function isPlainObject(value: object): value is Record { + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function collectReferencedEnvironmentVariables( + value: unknown, + referencedNames: Set, + visited: WeakSet +): void { + if (typeof value === 'string') { + for (const match of value.matchAll(createEnvVarPattern())) { + const name = match[1]?.trim() + if (name) { + referencedNames.add(name) + } + } + return + } + + if (value === null || typeof value !== 'object' || visited.has(value)) { + return + } + visited.add(value) + + if (Array.isArray(value)) { + for (const item of value) { + collectReferencedEnvironmentVariables(item, referencedNames, visited) + } + return + } + + if (!isPlainObject(value)) { + return + } + + for (const [key, item] of Object.entries(value)) { + collectReferencedEnvironmentVariables(key, referencedNames, visited) + collectReferencedEnvironmentVariables(item, referencedNames, visited) + } +} + +function compareStrings(left: string, right: string): number { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + +function normalizePercentEscapeCase(value: string): string { + return value.replace(/%[0-9a-f]{2}/gi, (percentEscape) => percentEscape.toUpperCase()) +} + +function buildSecretReplacements( + configuredValue: unknown, + environmentVariables: Record +): SecretReplacement[] { + const referencedNames = new Set() + collectReferencedEnvironmentVariables(configuredValue, referencedNames, new WeakSet()) + + const replacementsByValue = new Map() + for (const name of [...referencedNames].sort()) { + const value = environmentVariables[name] + if (!value) { + continue + } + + const placeholder = `{{${name}}}` + const exactKey = `exact:${value}` + if (!replacementsByValue.has(exactKey)) { + replacementsByValue.set(exactKey, { + value, + comparisonValue: value, + normalizePercentEscapes: false, + placeholder, + }) + } + + let encodedValue: string + try { + encodedValue = encodeURIComponent(value) + } catch { + continue + } + + const encodedVariants = new Set([encodedValue, encodedValue.replaceAll('%20', '+')]) + for (const encodedVariant of encodedVariants) { + if (encodedVariant === value) { + continue + } + + const comparisonValue = normalizePercentEscapeCase(encodedVariant) + const encodedKey = `encoded:${comparisonValue}` + if (!replacementsByValue.has(encodedKey)) { + replacementsByValue.set(encodedKey, { + value: encodedVariant, + comparisonValue, + normalizePercentEscapes: true, + placeholder, + }) + } + } + } + + return [...replacementsByValue.values()].sort( + (left, right) => + right.value.length - left.value.length || + compareStrings(left.placeholder, right.placeholder) || + compareStrings(left.comparisonValue, right.comparisonValue) + ) +} + +function sanitizeString(value: string, replacements: SecretReplacement[]): string { + let cursor = 0 + let sanitized = '' + const percentNormalizedValue = replacements.some( + (replacement) => replacement.normalizePercentEscapes + ) + ? normalizePercentEscapeCase(value) + : value + + while (cursor < value.length) { + let nextIndex = -1 + let nextReplacement: SecretReplacement | undefined + + for (const replacement of replacements) { + const source = replacement.normalizePercentEscapes ? percentNormalizedValue : value + const index = source.indexOf(replacement.comparisonValue, cursor) + if (index !== -1 && (nextIndex === -1 || index < nextIndex)) { + nextIndex = index + nextReplacement = replacement + } + } + + if (!nextReplacement) { + sanitized += value.slice(cursor) + break + } + + sanitized += value.slice(cursor, nextIndex) + sanitized += nextReplacement.placeholder + cursor = nextIndex + nextReplacement.value.length + } + + return sanitized +} + +function sanitizeValue( + value: unknown, + replacements: SecretReplacement[], + visited: WeakMap +): unknown { + if (typeof value === 'string') { + return sanitizeString(value, replacements) + } + + if (value === null || typeof value !== 'object') { + return value + } + + const existing = visited.get(value) + if (existing !== undefined) { + return existing + } + + if (Array.isArray(value)) { + const sanitized: unknown[] = [] + visited.set(value, sanitized) + for (const item of value) { + sanitized.push(sanitizeValue(item, replacements, visited)) + } + return sanitized + } + + if (!isPlainObject(value)) { + return value + } + + const sanitized = Object.create(Object.getPrototypeOf(value)) as Record + visited.set(value, sanitized) + + for (const [key, item] of Object.entries(value)) { + const sanitizedKey = sanitizeString(key, replacements) + Object.defineProperty(sanitized, sanitizedKey, { + value: sanitizeValue(item, replacements, visited), + enumerable: true, + configurable: true, + writable: true, + }) + } + + return sanitized +} + +/** + * Creates a sanitizer for observability values using environment variables + * explicitly referenced by the unresolved block configuration. + * + * Runtime values are never changed. Sanitized arrays and plain objects are + * copied, while unsupported object types are returned unchanged. + */ +export function createEnvironmentSecretSanitizer( + configuredValue: unknown, + environmentVariables: Record +): EnvironmentSecretSanitizer { + const replacements = buildSecretReplacements(configuredValue, environmentVariables) + + if (replacements.length === 0) { + return (value: T): T => value + } + + return (value: T): T => sanitizeValue(value, replacements, new WeakMap()) as T +} diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts index e7672297f4f..92771e5a156 100644 --- a/apps/sim/executor/variables/resolver.test.ts +++ b/apps/sim/executor/variables/resolver.test.ts @@ -137,6 +137,47 @@ describe('VariableResolver function block inputs', () => { expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' }) }) + it('resolves environment variables only in runtime function code', async () => { + const { block, ctx, resolver } = createResolver('javascript') + ctx.environmentVariables = { API_SECRET: 'resolved-secret' } + + const result = await resolver.resolveInputsForFunctionBlock( + ctx, + 'function', + { code: 'return "{{API_SECRET}}"' }, + block + ) + + expect(result.resolvedInputs.code).toBe('return "resolved-secret"') + expect(result.displayInputs.code).toBe('return "{{API_SECRET}}"') + }) + + it('preserves environment references in multi-part function display code', async () => { + const { block, ctx, resolver } = createResolver('javascript') + ctx.environmentVariables = { API_SECRET: 'resolved-secret' } + + const result = await resolver.resolveInputsForFunctionBlock( + ctx, + 'function', + { + code: [ + { language: 'javascript', content: 'const key = "{{API_SECRET}}"' }, + { language: 'javascript', content: 'return "{{API_SECRET}}"' }, + ], + }, + block + ) + + expect(result.resolvedInputs.code).toEqual([ + { language: 'javascript', content: 'const key = "resolved-secret"' }, + { language: 'javascript', content: 'return "resolved-secret"' }, + ]) + expect(result.displayInputs.code).toEqual([ + { language: 'javascript', content: 'const key = "{{API_SECRET}}"' }, + { language: 'javascript', content: 'return "{{API_SECRET}}"' }, + ]) + }) + it('allows Variables block assignments to receive whole large refs', async () => { const producer = createBlock('producer', 'Producer', BlockType.API) const variablesBlock = createBlock('variables', 'Variables', BlockType.VARIABLES, { diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index 851358cd2c2..c4029c39384 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -367,9 +367,9 @@ export class VariableResolver { /** * Resolves a code template for a function block. Block output references are stored * in `contextVarAccumulator` as named variables (e.g. `__blockRef_0`) and replaced - * with those variable names in the returned code string. Non-block references (loop - * items, workflow variables, env vars) are still inlined as literals so they remain - * available without any extra passing mechanism. + * with those variable names in the returned code string. Environment variables are + * inlined only in runtime code; display code keeps their unresolved references so + * logs and function errors do not expose their values. */ private async resolveCodeWithContextVars( ctx: ExecutionContext, @@ -598,10 +598,6 @@ export class VariableResolver { const resolved = await this.resolveReference(match, resolutionContext) return typeof resolved === 'string' ? resolved : match }) - displayResult = await replaceEnvVarsAsync(displayResult, async (match) => { - const resolved = await this.resolveReference(match, resolutionContext) - return typeof resolved === 'string' ? resolved : match - }) return { resolvedCode: result, displayCode: displayResult } } diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index 4e9bf1f1646..fa9ee2ec6b0 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -73,6 +73,7 @@ vi.mock('@/lib/logs/execution/logging-factory', () => ({ })) import { calculateCostSummary } from '@/lib/logs/execution/logging-factory' +import { createEnvironmentSecretSanitizer } from '@/executor/utils/environment-secret-sanitizer' import { LoggingSession } from './logging-session' afterAll(resetDbChainMock) @@ -268,6 +269,123 @@ describe('LoggingSession completion retries', () => { ) }) + it('sanitizes workflow output and final trace spans without mutating runtime values', async () => { + const session = new LoggingSession('workflow-1', 'execution-safe', 'api', 'req-1') + const secret = 'sk-demo / trace?token=7f3a91' + const rawFinalOutput = { + result: { + resolvedAtRuntime: true, + echoed: `prefix:${secret}:suffix`, + encoded: encodeURIComponent(secret), + ordinary: 'us-east-1', + }, + } + const rawTraceSpans = [ + { + id: 'span-safe', + name: 'Function', + type: 'function', + duration: 1, + startTime: '2026-07-01T00:00:00.000Z', + endTime: '2026-07-01T00:00:00.001Z', + status: 'success', + output: { echoed: secret }, + }, + ] + + session.setEnvironmentSecretSanitizer( + createEnvironmentSecretSanitizer( + { code: 'return "{{OPENAI_API_KEY}}"' }, + { + OPENAI_API_KEY: secret, + UNREFERENCED_REGION: 'us-east-1', + } + ) + ) + completeWorkflowExecutionMock.mockResolvedValue({}) + + await session.safeComplete({ + finalOutput: rawFinalOutput, + traceSpans: rawTraceSpans as any, + }) + + expect(completeWorkflowExecutionMock).toHaveBeenCalledWith( + expect.objectContaining({ + finalOutput: { + result: { + resolvedAtRuntime: true, + echoed: 'prefix:{{OPENAI_API_KEY}}:suffix', + encoded: '{{OPENAI_API_KEY}}', + ordinary: 'us-east-1', + }, + }, + traceSpans: [ + expect.objectContaining({ + output: { echoed: '{{OPENAI_API_KEY}}' }, + }), + ], + }) + ) + expect(rawFinalOutput.result.echoed).toBe(`prefix:${secret}:suffix`) + expect(rawTraceSpans[0].output.echoed).toBe(secret) + expect(calculateCostSummary).toHaveBeenCalledWith(rawTraceSpans) + }) + + it('sanitizes synthetic workflow errors and completion failure metadata', async () => { + const session = new LoggingSession('workflow-1', 'execution-error-safe', 'api', 'req-1') + const secret = 'sk-demo-error-7f3a91' + + session.setEnvironmentSecretSanitizer( + createEnvironmentSecretSanitizer( + { code: 'throw new Error("{{OPENAI_API_KEY}}")' }, + { OPENAI_API_KEY: secret } + ) + ) + completeWorkflowExecutionMock.mockResolvedValue({}) + + await session.safeCompleteWithError({ + error: { message: `Function failed with ${secret}` }, + }) + + expect(completeWorkflowExecutionMock).toHaveBeenCalledWith( + expect.objectContaining({ + finalOutput: { error: 'Function failed with {{OPENAI_API_KEY}}' }, + traceSpans: [ + expect.objectContaining({ + output: { error: 'Function failed with {{OPENAI_API_KEY}}' }, + }), + ], + completionFailure: 'Function failed with {{OPENAI_API_KEY}}', + }) + ) + }) + + it('keeps workflow output sanitized when completion falls back to cost-only persistence', async () => { + const session = new LoggingSession('workflow-1', 'execution-fallback-safe', 'api', 'req-1') + const secret = 'sk-demo-fallback-7f3a91' + + session.setEnvironmentSecretSanitizer( + createEnvironmentSecretSanitizer( + { code: 'return "{{OPENAI_API_KEY}}"' }, + { OPENAI_API_KEY: secret } + ) + ) + completeWorkflowExecutionMock + .mockRejectedValueOnce(new Error('primary persistence failed')) + .mockResolvedValueOnce({}) + + await session.safeComplete({ + finalOutput: { echoed: secret }, + }) + + expect(completeWorkflowExecutionMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + finalOutput: { echoed: '{{OPENAI_API_KEY}}' }, + finalizationPath: 'fallback_completed', + }) + ) + }) + it('derives fallback cost from trace spans when the primary completion fails', async () => { const session = new LoggingSession('workflow-1', 'execution-6', 'api', 'req-1') as any diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index d8ba08e5ad5..c6ba028a4c7 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -30,6 +30,7 @@ import type { WorkflowState, } from '@/lib/logs/types' import type { SerializableExecutionState } from '@/executor/execution/types' +import type { EnvironmentSecretSanitizer } from '@/executor/utils/environment-secret-sanitizer' type TriggerData = Record & { correlation?: NonNullable['correlation'] @@ -86,6 +87,8 @@ const logger = createLogger('LoggingSession') type CompletionAttempt = 'complete' | 'error' | 'cancelled' | 'paused' +const identityEnvironmentSecretSanitizer: EnvironmentSecretSanitizer = (value: T): T => value + export interface SessionStartParams { userId?: string /** Explicit initiating actor for callers that do not populate `userId`. */ @@ -155,6 +158,8 @@ export class LoggingSession { private completionAttemptFailed = false private pendingProgressWrites = new Set>() private postExecutionPromise: Promise | null = null + private environmentSecretSanitizer: EnvironmentSecretSanitizer = + identityEnvironmentSecretSanitizer constructor( workflowId: string, @@ -170,6 +175,19 @@ export class LoggingSession { this.requestId = requestId } + /** + * Installs the workflow-scoped sanitizer used only for persisted and emitted + * observability values. The closure is retained in memory for this session + * and is never added to execution data. + */ + setEnvironmentSecretSanitizer(sanitizer: EnvironmentSecretSanitizer): void { + this.environmentSecretSanitizer = sanitizer + } + + private sanitizeForLog(value: T): T { + return this.environmentSecretSanitizer(value) + } + async onBlockStart( blockId: string, blockName: string, @@ -287,17 +305,24 @@ export class LoggingSession { level?: 'info' | 'error' status?: 'completed' | 'failed' | 'cancelled' | 'pending' }): Promise { + const finalOutput = this.sanitizeForLog(params.finalOutput) + const traceSpans = this.sanitizeForLog(params.traceSpans) + const completionFailure = + params.completionFailure === undefined + ? undefined + : this.sanitizeForLog(params.completionFailure) + await executionLogger.completeWorkflowExecution({ executionId: this.executionId, endedAt: params.endedAt, totalDurationMs: params.totalDurationMs, costSummary: params.costSummary, - finalOutput: params.finalOutput, - traceSpans: params.traceSpans, + finalOutput, + traceSpans, workflowInput: params.workflowInput, executionState: params.executionState, finalizationPath: params.finalizationPath, - completionFailure: params.completionFailure, + completionFailure, isResume: this.isResume, level: params.level, status: params.status, @@ -403,11 +428,13 @@ export class LoggingSession { } this.completing = true - const { endedAt, totalDurationMs, finalOutput, traceSpans, workflowInput, executionState } = - params + const { endedAt, totalDurationMs, workflowInput, executionState } = params + const finalOutput = this.sanitizeForLog(params.finalOutput || {}) + const rawTraceSpans = params.traceSpans || [] + const traceSpans = this.sanitizeForLog(rawTraceSpans) try { - const costSummary = calculateCostSummary(traceSpans || []) + const costSummary = calculateCostSummary(rawTraceSpans) const endTime = endedAt || new Date().toISOString() const duration = totalDurationMs || 0 @@ -415,8 +442,8 @@ export class LoggingSession { endedAt: endTime, totalDurationMs: duration, costSummary, - finalOutput: finalOutput || {}, - traceSpans: traceSpans || [], + finalOutput, + traceSpans, workflowInput, executionState, finalizationPath: 'completed', @@ -424,13 +451,13 @@ export class LoggingSession { this.completed = true - if (traceSpans && traceSpans.length > 0) { + if (traceSpans.length > 0) { try { const { PlatformEvents, createOTelSpansForWorkflowExecution } = await import( '@/lib/core/telemetry' ) - const hasErrors = traceSpans.some((span: any) => { + const hasErrors = rawTraceSpans.some((span: any) => { const checkForErrors = (s: any): boolean => { if (s.status === 'error' && !s.errorHandled) return true if (s.children && Array.isArray(s.children)) { @@ -506,13 +533,15 @@ export class LoggingSession { return } - const { endedAt, totalDurationMs, error, traceSpans, skipCost } = params + const { endedAt, totalDurationMs, error, skipCost } = params + const rawTraceSpans = params.traceSpans || [] + const traceSpans = this.sanitizeForLog(rawTraceSpans) const endTime = endedAt ? new Date(endedAt) : new Date() const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0 const startTime = new Date(endTime.getTime() - Math.max(1, durationMs)) - const hasProvidedSpans = Array.isArray(traceSpans) && traceSpans.length > 0 + const hasProvidedSpans = traceSpans.length > 0 // calculateCostSummary([]) / (undefined) already returns the base-charge // summary, so the no-spans branch needs no separate literal. @@ -528,9 +557,9 @@ export class LoggingSession { models: {}, charges: {}, } - : calculateCostSummary(traceSpans) + : calculateCostSummary(rawTraceSpans) - const message = error?.message || 'Run failed before starting blocks' + const message = this.sanitizeForLog(error?.message || 'Run failed before starting blocks') const errorSpan: TraceSpan = { id: 'workflow-error-root', @@ -615,7 +644,9 @@ export class LoggingSession { this.completing = true try { - const { endedAt, totalDurationMs, traceSpans } = params + const { endedAt, totalDurationMs } = params + const rawTraceSpans = params.traceSpans || [] + const traceSpans = this.sanitizeForLog(rawTraceSpans) const endTime = endedAt ? new Date(endedAt) : new Date() const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0 @@ -639,14 +670,14 @@ export class LoggingSession { // calculateCostSummary handles empty/undefined spans by returning the // base-charge summary, so no separate no-spans literal is needed. - const costSummary = calculateCostSummary(traceSpans) + const costSummary = calculateCostSummary(rawTraceSpans) await this.completeExecutionWithFinalization({ endedAt: endTime.toISOString(), totalDurationMs: Math.max(1, durationMs), costSummary, finalOutput: { cancelled: true }, - traceSpans: traceSpans || [], + traceSpans, finalizationPath: 'cancelled', status: 'cancelled', }) @@ -662,11 +693,11 @@ export class LoggingSession { durationMs: Math.max(1, durationMs), status: 'cancelled', trigger: this.triggerType, - blocksExecuted: traceSpans?.length || 0, + blocksExecuted: traceSpans.length, hasErrors: false, }) - if (traceSpans && traceSpans.length > 0) { + if (traceSpans.length > 0) { const startTime = new Date(endTime.getTime() - Math.max(1, durationMs)) createOTelSpansForWorkflowExecution({ workflowId: this.workflowId, @@ -709,7 +740,9 @@ export class LoggingSession { this.completing = true try { - const { endedAt, totalDurationMs, traceSpans, workflowInput } = params + const { endedAt, totalDurationMs, workflowInput } = params + const rawTraceSpans = params.traceSpans || [] + const traceSpans = this.sanitizeForLog(rawTraceSpans) const endTime = endedAt ? new Date(endedAt) : new Date() const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0 @@ -733,14 +766,14 @@ export class LoggingSession { // calculateCostSummary handles empty/undefined spans by returning the // base-charge summary, so no separate no-spans literal is needed. - const costSummary = calculateCostSummary(traceSpans) + const costSummary = calculateCostSummary(rawTraceSpans) await this.completeExecutionWithFinalization({ endedAt: endTime.toISOString(), totalDurationMs: Math.max(1, durationMs), costSummary, finalOutput: { paused: true }, - traceSpans: traceSpans || [], + traceSpans, workflowInput, finalizationPath: 'paused', status: 'pending', @@ -757,12 +790,12 @@ export class LoggingSession { durationMs: Math.max(1, durationMs), status: 'paused', trigger: this.triggerType, - blocksExecuted: traceSpans?.length || 0, + blocksExecuted: traceSpans.length, hasErrors: false, totalCost: costSummary.totalCost || 0, }) - if (traceSpans && traceSpans.length > 0) { + if (traceSpans.length > 0) { const startTime = new Date(endTime.getTime() - Math.max(1, durationMs)) createOTelSpansForWorkflowExecution({ workflowId: this.workflowId, diff --git a/apps/sim/lib/tokenization/streaming.test.ts b/apps/sim/lib/tokenization/streaming.test.ts new file mode 100644 index 00000000000..bceb9e1923d --- /dev/null +++ b/apps/sim/lib/tokenization/streaming.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { processStreamingBlockLog } from '@/lib/tokenization/streaming' + +describe('processStreamingBlockLog', () => { + it('does not estimate usage from sanitized environment references', () => { + const log = { + blockId: 'agent-1', + blockType: 'agent', + input: { + prompt: 'Use {{OPENAI_API_KEY}} to answer', + model: 'gpt-4o', + }, + output: { + content: 'streamed answer', + model: 'gpt-4o', + }, + } + + expect(processStreamingBlockLog(log, 'streamed answer')).toBe(false) + expect(log.output).toEqual({ + content: 'streamed answer', + model: 'gpt-4o', + }) + }) +}) diff --git a/apps/sim/lib/tokenization/streaming.ts b/apps/sim/lib/tokenization/streaming.ts index ca552fa8292..03448f0120b 100644 --- a/apps/sim/lib/tokenization/streaming.ts +++ b/apps/sim/lib/tokenization/streaming.ts @@ -16,11 +16,17 @@ import { import type { BlockLog } from '@/executor/types' const logger = createLogger('StreamingTokenization') +const ENVIRONMENT_REFERENCE_PATTERN = /\{\{[^}]+\}\}/ + +type StreamingTokenizationLog = Pick /** * Processes a block log and adds tokenization data if needed */ -export function processStreamingBlockLog(log: BlockLog, streamedContent: string): boolean { +export function processStreamingBlockLog( + log: StreamingTokenizationLog, + streamedContent: string +): boolean { // Check if this block should be tokenized if (!isTokenizableBlockType(log.blockType)) { return false @@ -47,6 +53,12 @@ export function processStreamingBlockLog(log: BlockLog, streamedContent: string) // Prepare input text from log const inputText = extractTextContent(log.input) + // Environment values are restored to references before logs leave the + // executor. Never use those shorter placeholders as a billing estimate: + // the executor performs this fallback once with the raw resolved input. + if (ENVIRONMENT_REFERENCE_PATTERN.test(inputText)) { + return false + } // Calculate streaming cost const systemPrompt = @@ -101,7 +113,7 @@ export function processStreamingBlockLog(log: BlockLog, streamedContent: string) /** * Determines the appropriate model for a block */ -function getModelForBlock(log: BlockLog): string { +function getModelForBlock(log: StreamingTokenizationLog): string { // Try to get model from output first if (log.output?.model?.trim()) { return log.output.model diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index a5f255bcf33..af94cbbfc6e 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -23,6 +23,7 @@ const { onBlockStartPersistenceMock, executorConstructorMock, findStartBlockMock, + setEnvironmentSecretSanitizerMock, } = vi.hoisted(() => ({ mergeSubblockStateWithValuesMock: vi.fn(), safeStartMock: vi.fn(), @@ -38,6 +39,7 @@ const { onBlockStartPersistenceMock: vi.fn(), executorConstructorMock: vi.fn(), findStartBlockMock: vi.fn(), + setEnvironmentSecretSanitizerMock: vi.fn(), })) const getPersonalAndWorkspaceEnvMock = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv @@ -110,6 +112,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { hasCompleted: hasCompletedMock, onBlockStart: onBlockStartPersistenceMock, onBlockComplete: vi.fn(), + setEnvironmentSecretSanitizer: setEnvironmentSecretSanitizerMock, setPostExecutionPromise: vi.fn(), waitForPostExecution: vi.fn().mockResolvedValue(undefined), } @@ -500,6 +503,148 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { ]) }) + it('registers a workflow-scoped log sanitizer while preserving raw runtime output', async () => { + const secret = 'sk-demo-core-7f3a91' + const runtimeOutput = { + keyWasResolved: true, + echoedKey: secret, + ordinary: 'us-east-1', + } + + getPersonalAndWorkspaceEnvMock.mockResolvedValue({ + personalEncrypted: {}, + workspaceEncrypted: { OPENAI_API_KEY: 'encrypted' }, + personalDecrypted: {}, + workspaceDecrypted: { + OPENAI_API_KEY: secret, + UNREFERENCED_REGION: 'us-east-1', + }, + }) + serializeWorkflowMock.mockReturnValue({ + blocks: [ + { + id: 'function-1', + config: { + tool: 'function', + params: { code: 'return "{{OPENAI_API_KEY}}"' }, + }, + }, + ], + connections: [], + loops: {}, + parallels: {}, + }) + executorExecuteMock.mockResolvedValue({ + success: true, + status: 'completed', + output: runtimeOutput, + logs: [], + metadata: { duration: 123, startTime: 'start', endTime: 'end' }, + }) + + const result = await executeWorkflowCore({ + snapshot: createSnapshot() as any, + callbacks: {}, + loggingSession: loggingSession as any, + }) + await loggingSession.setPostExecutionPromise.mock.calls[0][0] + + const sanitizer = setEnvironmentSecretSanitizerMock.mock.calls[0]?.[0] + expect(sanitizer).toBeTypeOf('function') + expect(sanitizer(runtimeOutput)).toEqual({ + keyWasResolved: true, + echoedKey: '{{OPENAI_API_KEY}}', + ordinary: 'us-east-1', + }) + expect(result.output).toEqual(runtimeOutput) + expect(result.output.echoedKey).toBe(secret) + expect(safeCompleteMock).toHaveBeenCalledWith( + expect.objectContaining({ finalOutput: runtimeOutput }) + ) + }) + + it('rebuilds the log sanitizer with current environment values for resumed runs', async () => { + const firstSecret = 'sk-demo-before-resume' + const resumedSecret = 'sk-demo-after-resume' + const workflowWithSecret = { + blocks: [ + { + id: 'function-1', + config: { + tool: 'function', + params: { code: 'return "{{OPENAI_API_KEY}}"' }, + }, + }, + ], + connections: [], + loops: {}, + parallels: {}, + } + + serializeWorkflowMock.mockReturnValue(workflowWithSecret) + getPersonalAndWorkspaceEnvMock + .mockResolvedValueOnce({ + personalEncrypted: {}, + workspaceEncrypted: { OPENAI_API_KEY: 'encrypted-before' }, + personalDecrypted: {}, + workspaceDecrypted: { OPENAI_API_KEY: firstSecret }, + }) + .mockResolvedValueOnce({ + personalEncrypted: {}, + workspaceEncrypted: { OPENAI_API_KEY: 'encrypted-after' }, + personalDecrypted: {}, + workspaceDecrypted: { OPENAI_API_KEY: resumedSecret }, + }) + executorExecuteMock + .mockResolvedValueOnce({ + success: true, + status: 'completed', + output: { echoedKey: firstSecret }, + logs: [], + metadata: { duration: 1, startTime: 'start', endTime: 'end' }, + }) + .mockResolvedValueOnce({ + success: true, + status: 'completed', + output: { echoedKey: resumedSecret }, + logs: [], + metadata: { duration: 1, startTime: 'start', endTime: 'end' }, + }) + + await executeWorkflowCore({ + snapshot: createSnapshot() as any, + callbacks: {}, + loggingSession: loggingSession as any, + }) + await loggingSession.setPostExecutionPromise.mock.calls[0][0] + + const resumedSnapshot = createSnapshot() + resumedSnapshot.metadata = { + ...resumedSnapshot.metadata, + executionId: 'execution-resumed', + resumeFromSnapshot: true, + resumeTerminalNoop: true, + } as any + + const resumedResult = await executeWorkflowCore({ + snapshot: resumedSnapshot as any, + callbacks: {}, + loggingSession: loggingSession as any, + skipLogCreation: true, + }) + await loggingSession.setPostExecutionPromise.mock.calls[1][0] + + const initialSanitizer = setEnvironmentSecretSanitizerMock.mock.calls[0]?.[0] + const resumedSanitizer = setEnvironmentSecretSanitizerMock.mock.calls[1]?.[0] + expect(initialSanitizer({ echoedKey: firstSecret })).toEqual({ + echoedKey: '{{OPENAI_API_KEY}}', + }) + expect(resumedSanitizer({ echoedKey: resumedSecret })).toEqual({ + echoedKey: '{{OPENAI_API_KEY}}', + }) + expect(resumedResult.output).toEqual({ echoedKey: resumedSecret }) + }) + it('awaits wrapped lifecycle persistence before terminal finalization returns', async () => { let releaseBlockStart: (() => void) | undefined const blockStartPromise = new Promise((resolve) => { diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 5733adef130..aabb80ba2d7 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -44,6 +44,7 @@ import type { NormalizedBlockOutput, StartBlockRunMetadata, } from '@/executor/types' +import { createEnvironmentSecretSanitizer } from '@/executor/utils/environment-secret-sanitizer' import { hasExecutionResult } from '@/executor/utils/errors' import { isRunMetadataEnabled } from '@/executor/utils/start-block' import { buildParallelSentinelEndId, buildSentinelEndId } from '@/executor/utils/subflow-utils' @@ -523,6 +524,12 @@ async function executeWorkflowCoreImpl( parallels, true ) + loggingSession.setEnvironmentSecretSanitizer( + createEnvironmentSecretSanitizer( + serializedWorkflow.blocks.map((block) => block.config), + decryptedEnvVars + ) + ) processedInput = input || {} diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts index 43403742eb0..6e03a9bcec0 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts @@ -21,6 +21,7 @@ vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({ })) import { + buildResumedOutput, PauseResumeManager, updateResumeOutputInAggregationBuffers, } from '@/lib/workflows/executor/human-in-the-loop-manager' @@ -92,6 +93,43 @@ describe('automatic resume waiting metadata compatibility', () => { }) }) +describe('buildResumedOutput', () => { + it('keeps raw state and sanitized log sources separate during resume', () => { + const secret = 'resolved-pause-secret' + const resumeParams = { + submissionPayload: { approved: true }, + resumeInput: { submission: { approved: true } }, + submittedAt: '2026-07-27T12:00:00.000Z', + pauseKind: 'human' as const, + parentExecutionId: 'execution-1', + pauseDurationMs: 1000, + } + + const runtimeOutput = buildResumedOutput({ + ...resumeParams, + existingOutput: { + prompt: secret, + response: { data: { prompt: secret } }, + }, + }) + const logOutput = buildResumedOutput({ + ...resumeParams, + existingOutput: { + prompt: '{{TRACE_SECRET}}', + response: { data: { prompt: '{{TRACE_SECRET}}' } }, + }, + }) + + expect(JSON.stringify(runtimeOutput)).toContain(secret) + expect(JSON.stringify(logOutput)).not.toContain(secret) + expect(JSON.stringify(logOutput)).toContain('{{TRACE_SECRET}}') + expect(logOutput).toMatchObject({ + submission: { approved: true }, + response: { data: { submission: { approved: true } } }, + }) + }) +}) + describe('updateResumeOutputInAggregationBuffers', () => { it('replaces a paused parallel branch placeholder with the resumed HITL output', () => { const pausedOutput = { diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 69adefd2174..2e66d3b3abf 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -9,6 +9,7 @@ import type { Edge } from 'reactflow' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import { assertBillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' +import { redactApiKeys } from '@/lib/core/security/redaction' import { createExecutionEventWriter, flushExecutionStreamReplayBuffer, @@ -92,6 +93,74 @@ function isPausedOutputForContext(output: unknown, contextId: string): boolean { return isRecordLike(metadata) && metadata.contextId === contextId } +/** + * Rebuilds a resumed pause output from the caller's chosen source. + * Runtime state passes raw output, while trace logs pass their sanitized copy. + */ +export function buildResumedOutput(params: { + existingOutput: Record + submissionPayload: Record + resumeInput: Record + submittedAt: string + pauseKind: PauseKind + parentExecutionId: string + pauseDurationMs: number +}): Record { + const { + existingOutput, + submissionPayload, + resumeInput, + submittedAt, + pauseKind, + parentExecutionId, + pauseDurationMs, + } = params + const existingResponse = isRecordLike(existingOutput.response) ? existingOutput.response : {} + const existingResponseData = isRecordLike(existingResponse.data) ? existingResponse.data : {} + const response = { + ...existingResponse, + data: { + ...existingResponseData, + submission: submissionPayload, + submittedAt, + }, + status: existingResponse.status ?? 200, + headers: existingResponse.headers ?? { 'Content-Type': 'application/json' }, + resume: existingResponse.resume ?? existingOutput.resume, + } + const output: Record = { + ...existingOutput, + response, + submission: submissionPayload, + resumeInput, + submittedAt, + _resumed: true, + _resumedFrom: parentExecutionId, + _pauseDurationMs: pauseDurationMs, + } + + if (pauseKind === 'time') { + output.status = 'completed' + } + + output.resume = output.resume ?? response.resume + const resumeLinks = output.resume ?? response.resume + if (isRecordLike(resumeLinks)) { + if (resumeLinks.uiUrl) { + output.url = resumeLinks.uiUrl + } + if (resumeLinks.apiUrl) { + output.resumeEndpoint = resumeLinks.apiUrl + } + } + + for (const [key, value] of Object.entries(submissionPayload)) { + output[key] = value + } + + return output +} + export function updateResumeOutputInAggregationBuffers( state: SerializableExecutionState, stateBlockKey: string, @@ -969,62 +1038,16 @@ export class PauseResumeManager { ? (normalizedResumeInputRaw.submission as Record) : (normalizedResumeInputRaw as Record) - const existingOutput = pauseBlockState.output || {} - const existingResponse = existingOutput.response || {} - const existingResponseData = - existingResponse && - typeof existingResponse.data === 'object' && - !Array.isArray(existingResponse.data) - ? existingResponse.data - : {} - const submittedAt = new Date().toISOString() - - const mergedResponseData = { - ...existingResponseData, - submission: submissionPayload, - submittedAt, - } - - const mergedResponse = { - ...existingResponse, - data: mergedResponseData, - status: existingResponse.status ?? 200, - headers: existingResponse.headers ?? { 'Content-Type': 'application/json' }, - resume: existingResponse.resume ?? existingOutput.resume, - } - - const mergedOutput: Record = { - ...existingOutput, - response: mergedResponse, - submission: submissionPayload, + const mergedOutput = buildResumedOutput({ + existingOutput: pauseBlockState.output || {}, + submissionPayload, resumeInput: normalizedResumeInputRaw, submittedAt, - _resumed: true, - _resumedFrom: pausedExecution.executionId, - _pauseDurationMs: pauseDurationMs, - } - - if (pausePoint.pauseKind === 'time') { - mergedOutput.status = 'completed' - } - - mergedOutput.resume = mergedOutput.resume ?? mergedResponse.resume - - // Preserve url and resumeEndpoint from resume links - const resumeLinks = mergedOutput.resume ?? mergedResponse.resume - if (resumeLinks && typeof resumeLinks === 'object') { - if (resumeLinks.uiUrl) { - mergedOutput.url = resumeLinks.uiUrl - } - if (resumeLinks.apiUrl) { - mergedOutput.resumeEndpoint = resumeLinks.apiUrl - } - } - - for (const [key, value] of Object.entries(submissionPayload)) { - mergedOutput[key] = value - } + pauseKind: pausePoint.pauseKind ?? 'human', + parentExecutionId: pausedExecution.executionId, + pauseDurationMs, + }) pauseBlockState.output = mergedOutput terminalResumeOutput = mergedOutput @@ -1056,11 +1079,21 @@ export class PauseResumeManager { log.blockId === contextId ) if (blockLogIndex !== -1) { - // Filter output for logging using shared utility - // 'resume' is redundant with url/resumeEndpoint so we filter it out - const filteredOutput = filterOutputForLog('human_in_the_loop', mergedOutput, { - additionalHiddenKeys: ['resume'], + const existingLogOutput = stateCopy.blockLogs[blockLogIndex].output ?? {} + const resumedLogOutput = buildResumedOutput({ + existingOutput: existingLogOutput, + submissionPayload, + resumeInput: normalizedResumeInputRaw, + submittedAt, + pauseKind: pausePoint.pauseKind ?? 'human', + parentExecutionId: pausedExecution.executionId, + pauseDurationMs, }) + const filteredOutput = redactApiKeys( + filterOutputForLog('human_in_the_loop', resumedLogOutput, { + additionalHiddenKeys: ['resume'], + }) + ) stateCopy.blockLogs[blockLogIndex] = { ...stateCopy.blockLogs[blockLogIndex], blockId: stateBlockKey, diff --git a/apps/sim/lib/workflows/streaming/streaming.test.ts b/apps/sim/lib/workflows/streaming/streaming.test.ts index c3c46efff1c..002d0882fd4 100644 --- a/apps/sim/lib/workflows/streaming/streaming.test.ts +++ b/apps/sim/lib/workflows/streaming/streaming.test.ts @@ -8,6 +8,7 @@ import { agentStreamProtocolResponseHeaders, createStreamingResponse, } from '@/lib/workflows/streaming/streaming' +import { createEnvironmentSecretSanitizer } from '@/executor/utils/environment-secret-sanitizer' const { mockDownloadFile } = vi.hoisted(() => ({ mockDownloadFile: vi.fn(), @@ -1309,6 +1310,80 @@ describe('createStreamingResponse agent-events-v1', () => { expect(events).toContainEqual({ event: 'error', error: 'Client cancelled request' }) }) + it('does not replace sanitized trace content with raw streamed text', async () => { + const secret = 'raw-stream-secret' + const sanitizer = createEnvironmentSecretSanitizer( + { prompt: 'Use {{TRACE_SECRET}}' }, + { TRACE_SECRET: secret } + ) + const persistedCompletion = vi.fn() + const safeComplete = vi.fn(async (params: Record) => { + persistedCompletion({ + ...params, + finalOutput: sanitizer(params.finalOutput), + traceSpans: sanitizer(params.traceSpans), + }) + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: {}, + executeFn: async ({ onStream }) => { + const textStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`Echo ${secret}`)) + controller.close() + }, + }) + + await onStream({ + stream: textStream, + streamFormat: 'text', + execution: { + blockId: 'agent-1', + success: true, + output: { content: `Echo ${secret}` }, + logs: [], + metadata: {}, + }, + } as any) + + return { + success: true, + output: { content: `Echo ${secret}` }, + logs: [ + { + blockId: 'agent-1', + blockName: 'Agent', + blockType: 'agent', + input: { prompt: 'Use {{TRACE_SECRET}}' }, + output: { content: 'Echo {{TRACE_SECRET}}' }, + startedAt: '2026-01-01T00:00:00.000Z', + endedAt: '2026-01-01T00:00:00.001Z', + durationMs: 1, + executionOrder: 1, + success: true, + }, + ], + _streamingMetadata: { + loggingSession: { safeComplete }, + processedInput: {}, + }, + } as any + }, + }) + + const events = await collectSSEEvents(stream) + + expect(safeComplete).toHaveBeenCalledOnce() + expect(persistedCompletion).toHaveBeenCalledOnce() + const serializedCompletion = JSON.stringify(persistedCompletion.mock.calls[0][0]) + expect(serializedCompletion).not.toContain(secret) + expect(serializedCompletion).toContain('{{TRACE_SECRET}}') + + const finalEvent = events.find((event) => event.event === 'final') + expect(JSON.stringify(finalEvent)).toContain(secret) + }) + it('thinking never enters streamedChunks / log content rewrite', async () => { const headers = new Headers({ 'x-sim-stream-protocol': 'agent-events-v1', diff --git a/apps/sim/lib/workflows/streaming/streaming.ts b/apps/sim/lib/workflows/streaming/streaming.ts index 56da480803c..5d4b0fe61da 100644 --- a/apps/sim/lib/workflows/streaming/streaming.ts +++ b/apps/sim/lib/workflows/streaming/streaming.ts @@ -447,29 +447,20 @@ async function buildMinimalResult( return minimalResult } -function updateLogsWithStreamedContent( +function updateLogsWithStreamCompletionTimes( logs: BlockLog[], - streamedContent: Map, streamCompletionTimes: Map ): BlockLog[] { return logs.map((log: BlockLog) => { - if (!streamedContent.has(log.blockId)) { + if (!streamCompletionTimes.has(log.blockId)) { return log } - const content = streamedContent.get(log.blockId) const updatedLog = { ...log } - - if (streamCompletionTimes.has(log.blockId)) { - const completionTime = streamCompletionTimes.get(log.blockId)! - const startTime = new Date(log.startedAt).getTime() - updatedLog.endedAt = new Date(completionTime).toISOString() - updatedLog.durationMs = completionTime - startTime - } - - if (log.output && content) { - updatedLog.output = { ...log.output, content } - } + const completionTime = streamCompletionTimes.get(log.blockId)! + const startTime = new Date(log.startedAt).getTime() + updatedLog.endedAt = new Date(completionTime).toISOString() + updatedLog.durationMs = completionTime - startTime return updatedLog }) @@ -822,9 +813,8 @@ export async function createStreamingResponse( state.streamedChunks.size > 0 ? resolveStreamedContent(state) : new Map() if (result.logs && streamedContent.size > 0) { - result.logs = updateLogsWithStreamedContent( + result.logs = updateLogsWithStreamCompletionTimes( result.logs, - streamedContent, state.streamCompletionTimes ) processStreamingBlockLogs(result.logs, streamedContent)