diff --git a/apps/docs/content/docs/en/agents/custom-tools.mdx b/apps/docs/content/docs/en/agents/custom-tools.mdx
index c551d942652..9648b00910e 100644
--- a/apps/docs/content/docs/en/agents/custom-tools.mdx
+++ b/apps/docs/content/docs/en/agents/custom-tools.mdx
@@ -156,7 +156,7 @@ From **Settings → Custom Tools** you can:
\ No newline at end of file
+]} />
diff --git a/apps/docs/content/docs/en/logs-debugging/index.mdx b/apps/docs/content/docs/en/logs-debugging/index.mdx
index df544382d9a..fcf9b8c8f49 100644
--- a/apps/docs/content/docs/en/logs-debugging/index.mdx
+++ b/apps/docs/content/docs/en/logs-debugging/index.mdx
@@ -43,7 +43,7 @@ This is the level you debug at, because a run fails when one of its blocks fails
### Input and output
-Each block in the sidebar has two tabs. The **Input** tab shows the resolved values the block actually ran with: the literal values you typed, and the earlier outputs it read by reference (with API keys redacted). The **Output** tab shows what the block produced, formatted as an object, with markdown rendered for agent-generated text.
+Each block in the sidebar has two tabs. The **Input** tab shows the resolved values the block actually ran with: the literal values you typed, and the earlier outputs it read by reference. Exact values successfully substituted from Secrets through `{{KEY}}` are masked in this trace copy; see [Execution log protection](/platform/credentials#execution-log-protection). The **Output** tab shows what the block produced, formatted as an object, with markdown rendered for agent-generated text.
The input tab is the important one. A block reads earlier outputs by name, written ``, and the input tab shows what those references resolved to at run time. If a reference pointed at a value that was not there, you see it here as missing or wrong, not as the tag you wrote. See [how blocks pass data](/workflows/data-flow) for how those references resolve.
diff --git a/apps/docs/content/docs/en/logs-debugging/logging.mdx b/apps/docs/content/docs/en/logs-debugging/logging.mdx
index 3c1b3970501..fdbcbdbd64f 100644
--- a/apps/docs/content/docs/en/logs-debugging/logging.mdx
+++ b/apps/docs/content/docs/en/logs-debugging/logging.mdx
@@ -57,7 +57,7 @@ Click any entry to open its sidebar: the run's timeline (start/end, total durati
The block's result — JSON-formatted structured data, markdown rendering for AI content, and a copy button.
- What the block received — resolved variable values, referenced outputs, and environment variables. API keys are automatically redacted.
+ What the block received — resolved variable values, referenced outputs, and environment variables. Exact secret values activated by a successful `{{KEY}}` substitution are masked in this trace view. See [Execution log protection](/platform/credentials#execution-log-protection).
@@ -95,7 +95,7 @@ import { FAQ } from '@/components/ui/faq'
+### Execution log protection
+
+When a saved secret is successfully substituted through a `{{KEY}}` reference, Sim masks exact, case-sensitive occurrences of its resolved value in log-facing content. This includes the editor's live block-log display, Logs Overview input and output, stored execution traces, log-read API responses, and the Logs block's **Get Run Details** output. Function and Agent span inputs, outputs, and errors, Agent thinking, and Agent tool-call arguments, results, and errors are protected. The replacement is normally shown as `{{KEY}}`.
+
+This is an observability projection only. Secret resolution and workflow behavior are unchanged: blocks, tools, models, and downstream steps receive the real runtime value. Stored functional execution data, workflow execution responses, streams, callbacks, block state, and snapshots are not rewritten. Log-facing views and read APIs receive a separate protected copy, so the Logs Overview **Workflow Input** and **Workflow Output** are masked without changing the underlying workflow result.
+
-Secret values are never exposed in the workflow editor or execution logs — they are only resolved during execution.
+Masking is activated only when Sim successfully resolves a value from **Settings → Secrets** through `{{KEY}}`. A hardcoded literal, direct `environmentVariables['KEY']` read, or shell `$KEY` read does not activate it by itself. Once activated, every exact occurrence of that value in the run's log-facing content is masked. Encoded, hashed, or otherwise transformed versions are not matched. Do not deliberately return or print secrets.
## Secret Details
@@ -115,7 +121,8 @@ When a workflow runs, secrets resolve in this order:
- **Never hardcode secrets** in workflow input fields — always use `{{KEY}}` references
The block always operates on the current workspace. Costs are denominated in credits, both for
- the cost filter and the cost output.
+ the cost filter and the cost output. **Get Run Details** is a log-read path, so both `traceSpans`
+ and `finalOutput` come from the protected log-facing projection described under
+ [Execution log protection](/platform/credentials#execution-log-protection). The underlying runtime
+ result remains unchanged.
{
describe('Template Variable Resolution', () => {
it.concurrent('should resolve environment variables with {{var_name}} syntax', async () => {
- const req = createMockRequest('POST', {
- code: 'return {{API_KEY}}',
- envVars: {
- API_KEY: 'secret-key-123',
+ const req = createMockRequest(
+ 'POST',
+ {
+ code: 'return {{API_KEY}}',
+ envVars: {
+ API_KEY: 'secret-key-123',
+ },
},
- })
+ {
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1',
+ }
+ )
const response = await POST(req)
+ const data = await response.json()
expect(response.status).toBe(200)
+ expect(data.__resolvedSecretNames).toEqual(['API_KEY'])
+ })
+
+ it('reports only successful references sourced from scoped environment variables', async () => {
+ const envResponse = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ code: 'return {{SHARED}} + {{ENV_ONLY}} + {{MISSING}}',
+ params: { SHARED: 'param-value', MISSING: 'ordinary-param' },
+ envVars: { SHARED: 'secret-value', ENV_ONLY: 'other-secret' },
+ },
+ {
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1',
+ }
+ )
+ )
+ const envData = await envResponse.json()
+
+ const directResponse = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ code: 'return environmentVariables.API_KEY + params.API_KEY',
+ params: { API_KEY: 'ordinary-param' },
+ envVars: { API_KEY: 'secret-value' },
+ },
+ {
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1',
+ }
+ )
+ )
+ const directData = await directResponse.json()
+
+ expect(envData.__resolvedSecretNames).toEqual(['ENV_ONLY', 'SHARED'])
+ expect(directData.__resolvedSecretNames).toEqual([])
+ })
+
+ it('reports shell {{NAME}} substitutions but not direct shell environment access', async () => {
+ envFlagsMock.isRemoteSandboxEnabled = true
+
+ const referencedResponse = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ code: 'printf "%s" "{{API_KEY}}"',
+ language: 'shell',
+ envVars: { API_KEY: 'secret-value' },
+ },
+ {
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1',
+ }
+ )
+ )
+ const referencedData = await referencedResponse.json()
+
+ const directResponse = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ code: 'printf "%s" "$API_KEY"',
+ language: 'shell',
+ envVars: { API_KEY: 'secret-value' },
+ },
+ {
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1',
+ }
+ )
+ )
+ const directData = await directResponse.json()
+
+ expect(referencedData.__resolvedSecretNames).toEqual(['API_KEY'])
+ expect(directData.__resolvedSecretNames).toEqual([])
+ })
+
+ it('reports only substitutions allowed by the Function secret scope', async () => {
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ code: 'return {{ALLOWED}} + {{BLOCKED}}',
+ envVars: { ALLOWED: 'allowed-secret', BLOCKED: 'blocked-secret' },
+ secretScope: 'selected',
+ mountedSecrets: ['ALLOWED'],
+ },
+ {
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1',
+ }
+ )
+ )
+
+ expect((await response.json()).__resolvedSecretNames).toEqual(['ALLOWED'])
})
it.concurrent('should resolve tag variables with syntax', async () => {
diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts
index 59a295bc924..4f72d777a38 100644
--- a/apps/sim/app/api/function/execute/route.ts
+++ b/apps/sim/app/api/function/execute/route.ts
@@ -35,6 +35,12 @@ import {
} from '@/lib/execution/payloads/materialization.server'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
+import {
+ PRIVATE_TOOL_METADATA_RESPONSE_HEADER,
+ RESOLVED_SECRET_NAMES_FIELD,
+ RESOLVED_SECRET_NAMES_METADATA_V1,
+ requestsPrivateToolMetadata,
+} from '@/lib/execution/private-tool-metadata'
import {
executeInSandbox,
executeShellInSandbox,
@@ -65,6 +71,8 @@ const E2B_JS_WRAPPER_LINES = 3
const E2B_PYTHON_WRAPPER_LINES = 1
const MAX_SANDBOX_OUTPUT_FILES = 20
const MAX_SANDBOX_OUTPUT_BYTES = 50 * 1024 * 1024
+const MAX_PRIVATE_RESOLVED_SECRET_NAMES = 10_000
+const MAX_PRIVATE_RESOLVED_SECRET_NAMES_BYTES = 1024 * 1024
/** Matches valid JS identifier names (letters, digits, underscore; no leading digit). */
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/
@@ -586,7 +594,8 @@ function resolveEnvironmentVariables(
code: string,
params: Record,
envVars: Record,
- contextVariables: Record
+ contextVariables: Record,
+ onResolvedSecret?: (name: string) => void
): string {
let resolvedCode = code
@@ -627,6 +636,9 @@ function resolveEnvironmentVariables(
const safeVarName = `__var_${varName.replace(/[^a-zA-Z0-9_]/g, '_')}`
contextVariables[safeVarName] = varValue
+ if (Object.hasOwn(envVars, varName) && envVars[varName] === varValue) {
+ onResolvedSecret?.(varName)
+ }
resolvedCode =
resolvedCode.slice(0, index) + safeVarName + resolvedCode.slice(index + matchStr.length)
}
@@ -704,13 +716,20 @@ function resolveCodeVariables(
blockNameMapping: Record = {},
blockOutputSchemas: Record = {},
workflowVariables: Record = {},
- language = 'javascript'
+ language = 'javascript',
+ onResolvedSecret?: (name: string) => void
): { resolvedCode: string; contextVariables: Record } {
let resolvedCode = code
const contextVariables: Record = {}
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
- resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
+ resolvedCode = resolveEnvironmentVariables(
+ resolvedCode,
+ params,
+ envVars,
+ contextVariables,
+ onResolvedSecret
+ )
resolvedCode = resolveTagVariables(
resolvedCode,
blockData,
@@ -796,6 +815,8 @@ interface FunctionRouteExecutionContext {
allowLargeValueWorkflowScope?: boolean
userId?: string
requestId: string
+ resolvedSecretNames: Set
+ includePrivateResolvedSecretNames: boolean
}
function asRecord(value: unknown): Record {
@@ -931,7 +952,7 @@ async function functionJsonResponse(
context: FunctionRouteExecutionContext,
init?: ResponseInit
) {
- return NextResponse.json(
+ const response = NextResponse.json(
await compactFunctionRouteBody(
{
...body,
@@ -942,6 +963,52 @@ async function functionJsonResponse(
),
init
)
+ return appendResolvedSecretNames(response, context)
+}
+
+function getPrivateResolvedSecretNames(context: FunctionRouteExecutionContext): string[] | null {
+ if (context.resolvedSecretNames.size > MAX_PRIVATE_RESOLVED_SECRET_NAMES) return null
+
+ const names = Array.from(context.resolvedSecretNames).sort()
+ let bytes = 0
+ for (const name of names) {
+ bytes += Buffer.byteLength(name, 'utf8')
+ if (bytes > MAX_PRIVATE_RESOLVED_SECRET_NAMES_BYTES) return null
+ }
+ return names
+}
+
+async function appendResolvedSecretNames(
+ response: NextResponse,
+ context: FunctionRouteExecutionContext
+): Promise {
+ const names = context.includePrivateResolvedSecretNames
+ ? getPrivateResolvedSecretNames(context)
+ : null
+ return appendPrivateResolvedSecretNames(response, names)
+}
+
+async function appendPrivateResolvedSecretNames(
+ response: NextResponse,
+ names: string[] | null
+): Promise {
+ if (!names) return response
+
+ try {
+ const body = (await response.clone().json()) as Record
+ const headers = new Headers(response.headers)
+ headers.delete('content-length')
+ headers.set(PRIVATE_TOOL_METADATA_RESPONSE_HEADER, RESOLVED_SECRET_NAMES_METADATA_V1)
+ return NextResponse.json(
+ {
+ ...body,
+ [RESOLVED_SECRET_NAMES_FIELD]: names,
+ },
+ { status: response.status, statusText: response.statusText, headers }
+ )
+ } catch {
+ return response
+ }
}
/**
@@ -1411,6 +1478,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
let resolvedCode = '' // Store resolved code for error reporting
let sourceCodeForErrors: string | undefined
let routeContext: FunctionRouteExecutionContext | undefined
+ let includePrivateResolvedSecretNames = false
try {
const auth = await checkInternalAuth(req)
@@ -1419,8 +1487,18 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
+ includePrivateResolvedSecretNames = requestsPrivateToolMetadata(
+ req.headers,
+ RESOLVED_SECRET_NAMES_METADATA_V1
+ )
+
const parsed = await parseRequest(functionExecuteContract, req, {})
- if (!parsed.success) return parsed.response
+ if (!parsed.success) {
+ return appendPrivateResolvedSecretNames(
+ parsed.response,
+ includePrivateResolvedSecretNames ? [] : null
+ )
+ }
const { body } = parsed.data
const { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants')
@@ -1473,12 +1551,15 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
.map((file) => file.sandboxPath)
.filter((path): path is string => Boolean(path))
if (outputSandboxPaths.length > MAX_SANDBOX_OUTPUT_FILES) {
- return NextResponse.json(
- {
- success: false,
- error: `Too many sandbox output files requested (${outputSandboxPaths.length}). Maximum is ${MAX_SANDBOX_OUTPUT_FILES}.`,
- },
- { status: 400 }
+ return appendPrivateResolvedSecretNames(
+ NextResponse.json(
+ {
+ success: false,
+ error: `Too many sandbox output files requested (${outputSandboxPaths.length}). Maximum is ${MAX_SANDBOX_OUTPUT_FILES}.`,
+ },
+ { status: 400 }
+ ),
+ includePrivateResolvedSecretNames ? [] : null
)
}
@@ -1504,6 +1585,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
allowLargeValueWorkflowScope,
userId: auth.userId,
requestId,
+ resolvedSecretNames: new Set(),
+ includePrivateResolvedSecretNames,
}
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
@@ -1512,7 +1595,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
if (lang === CodeLanguage.Shell) {
// For shell, env vars are injected as OS env vars via shellEnvs.
// Replace {{VAR}} placeholders with $VAR so the shell can access them natively.
- resolvedCode = code.replace(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g, '$$$1')
+ resolvedCode = code.replace(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g, (_match, name) => {
+ if (Object.hasOwn(envVars, name)) {
+ routeContext?.resolvedSecretNames.add(name)
+ }
+ return `$${name}`
+ })
// Carry pre-resolved block output variables (e.g. __blockRef_N) so they can be
// injected as shell env vars below. The executor replaces block references in the
// code with these names, so the values must be present at runtime.
@@ -1526,7 +1614,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
blockNameMapping,
blockOutputSchemas,
workflowVariables,
- lang
+ lang,
+ (name) => routeContext?.resolvedSecretNames.add(name)
)
resolvedCode = codeResolution.resolvedCode
// Merge pre-resolved block output variables from the executor. These take precedence
@@ -1625,7 +1714,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
stdout: shellStdout,
executionTime,
})
- if (fileExportResponse) return fileExportResponse
+ if (fileExportResponse) {
+ return appendResolvedSecretNames(fileExportResponse, routeContext)
+ }
}
return functionJsonResponse(
@@ -1794,7 +1885,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
stdout,
executionTime,
})
- if (fileExportResponse) return fileExportResponse
+ if (fileExportResponse) {
+ return appendResolvedSecretNames(fileExportResponse, routeContext)
+ }
}
return functionJsonResponse(
@@ -1884,7 +1977,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
stdout,
executionTime,
})
- if (fileExportResponse) return fileExportResponse
+ if (fileExportResponse) {
+ return appendResolvedSecretNames(fileExportResponse, routeContext)
+ }
}
return functionJsonResponse(
@@ -2043,17 +2138,20 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
{ status: error.statusCode }
)
}
- return NextResponse.json(
- {
- success: false,
- error: error.message,
- output: {
- result: null,
- stdout: cleanStdout(stdout),
- executionTime,
+ return appendPrivateResolvedSecretNames(
+ NextResponse.json(
+ {
+ success: false,
+ error: error.message,
+ output: {
+ result: null,
+ stdout: cleanStdout(stdout),
+ executionTime,
+ },
},
- },
- { status: error.statusCode }
+ { status: error.statusCode }
+ ),
+ includePrivateResolvedSecretNames ? [] : null
)
}
@@ -2072,7 +2170,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
}
return routeContext
? functionJsonResponse(killResponse, routeContext, { status: 500 })
- : NextResponse.json(killResponse, { status: 500 })
+ : appendPrivateResolvedSecretNames(
+ NextResponse.json(killResponse, { status: 500 }),
+ includePrivateResolvedSecretNames ? [] : null
+ )
}
logger.error(`[${requestId}] Function execution failed`, {
@@ -2120,6 +2221,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
return functionJsonResponse(errorResponse, routeContext, { status: 500 })
}
- return NextResponse.json(errorResponse, { status: 500 })
+ return appendPrivateResolvedSecretNames(
+ NextResponse.json(errorResponse, { status: 500 }),
+ includePrivateResolvedSecretNames ? [] : null
+ )
}
})
diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts
index bca5e283796..0d8ebc032a5 100644
--- a/apps/sim/app/api/logs/export/route.ts
+++ b/apps/sim/app/api/logs/export/route.ts
@@ -8,7 +8,7 @@ import { getSession } from '@/lib/auth'
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
+import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store'
import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters'
import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -112,11 +112,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
rows as any[],
MATERIALIZE_CONCURRENCY,
(r) =>
- materializeExecutionData(r.executionData as Record | null, {
- workspaceId: params.workspaceId,
- workflowId: r.workflowId,
- executionId: r.executionId,
- })
+ materializeExecutionDataForDisplay(
+ r.executionData as Record | null,
+ {
+ workspaceId: params.workspaceId,
+ workflowId: r.workflowId,
+ executionId: r.executionId,
+ userId: session.user.id,
+ }
+ )
)
for (let j = 0; j < rows.length; j++) {
diff --git a/apps/sim/app/api/mcp/tools/execute/route.test.ts b/apps/sim/app/api/mcp/tools/execute/route.test.ts
new file mode 100644
index 00000000000..ee42b7d5da4
--- /dev/null
+++ b/apps/sim/app/api/mcp/tools/execute/route.test.ts
@@ -0,0 +1,199 @@
+/**
+ * @vitest-environment node
+ */
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockDiscoverServerTools, mockExecuteTool, mockReadResponseToBufferWithLimit } = vi.hoisted(
+ () => ({
+ mockDiscoverServerTools: vi.fn(),
+ mockExecuteTool: vi.fn(),
+ mockReadResponseToBufferWithLimit: vi.fn(),
+ })
+)
+
+vi.mock('@/lib/core/utils/stream-limits', () => ({
+ readResponseToBufferWithLimit: mockReadResponseToBufferWithLimit,
+}))
+
+vi.mock('@/lib/mcp/middleware', () => ({
+ withMcpAuth:
+ () =>
+ (
+ handler: (
+ request: NextRequest,
+ context: {
+ userId: string
+ workspaceId: string
+ requestId: string
+ authType: 'internal_jwt' | 'session'
+ },
+ routeContext: { params: Promise> }
+ ) => Promise
+ ) =>
+ (request: NextRequest) =>
+ handler(
+ request,
+ {
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ requestId: 'request-1',
+ authType: request.headers.has('x-test-session') ? 'session' : 'internal_jwt',
+ },
+ { params: Promise.resolve({}) }
+ ),
+ readMcpJsonBodyWithLimit: (request: NextRequest) => request.json(),
+ mcpBodyReadErrorResponse: () => null,
+}))
+
+vi.mock('@/lib/mcp/service', () => ({
+ mcpService: {
+ discoverServerTools: mockDiscoverServerTools,
+ executeTool: mockExecuteTool,
+ },
+}))
+
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ requireBillingAttributionHeader: () => ({ payerSubscription: { plan: 'pro' } }),
+ resolveBillingAttribution: async () => ({ payerSubscription: { plan: 'pro' } }),
+}))
+
+vi.mock('@/lib/core/execution-limits', () => ({
+ DEFAULT_EXECUTION_TIMEOUT_MS: 30_000,
+ getExecutionTimeout: () => 0,
+}))
+
+vi.mock('@/ee/access-control/utils/permission-check', () => ({
+ assertPermissionsAllowed: async () => {},
+ McpToolsNotAllowedError: class McpToolsNotAllowedError extends Error {},
+}))
+
+vi.mock('@/lib/core/telemetry', () => ({
+ PlatformEvents: { mcpToolExecuted: vi.fn() },
+}))
+
+import { POST } from '@/app/api/mcp/tools/execute/route'
+
+const URL = 'http://localhost/api/mcp/tools/execute'
+const REQUEST_BODY = {
+ workspaceId: 'workspace-1',
+ serverId: 'server-1',
+ toolName: 'example_tool',
+ arguments: {},
+}
+
+function createRequest(headers: Record = {}): NextRequest {
+ return new NextRequest(URL, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', ...headers },
+ body: JSON.stringify(REQUEST_BODY),
+ })
+}
+
+describe('MCP tool execution private secret provenance', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDiscoverServerTools.mockResolvedValue([{ name: 'example_tool', inputSchema: {} }])
+ mockExecuteTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] })
+ mockReadResponseToBufferWithLimit.mockImplementation(async (response: Response) =>
+ Buffer.from(await response.arrayBuffer())
+ )
+ })
+
+ it('returns fail-closed scoped provenance only to an authenticated internal caller', async () => {
+ mockDiscoverServerTools.mockImplementationOnce(
+ async (
+ _userId: string,
+ _serverId: string,
+ _workspaceId: string,
+ _forceRefresh: boolean,
+ report: (value: unknown) => void
+ ) => {
+ report({
+ version: 1,
+ complete: false,
+ entries: [],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ })
+ return [{ name: 'example_tool', inputSchema: {} }]
+ }
+ )
+ mockExecuteTool.mockImplementationOnce(
+ async (
+ _userId: string,
+ _serverId: string,
+ _toolCall: unknown,
+ _workspaceId: string,
+ _headers: unknown,
+ report: (value: unknown) => void
+ ) => {
+ report({
+ version: 1,
+ complete: true,
+ entries: [{ name: 'NEW_TOKEN', encryptedValue: 'encrypted-v2' }],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ })
+ return { content: [{ type: 'text', text: 'ok' }] }
+ }
+ )
+ const request = createRequest({
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
+ })
+
+ const response = await POST(request, {})
+ const body = (await response.json()) as Record
+
+ expect(response.headers.get('x-sim-private-tool-metadata')).toBe(
+ 'resolved-secret-provenance-v1'
+ )
+ expect(body.__resolvedSecretTraceProvenance).toEqual({
+ version: 1,
+ complete: false,
+ entries: [],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ })
+ })
+
+ it('does not expose private provenance metadata to a session caller', async () => {
+ const request = createRequest({
+ 'x-test-session': 'true',
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
+ })
+
+ const response = await POST(request, {})
+ const body = (await response.json()) as Record
+
+ expect(response.headers.has('x-sim-private-tool-metadata')).toBe(false)
+ expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance')
+ expect(mockDiscoverServerTools.mock.calls[0]?.[4]).toBeUndefined()
+ expect(mockExecuteTool.mock.calls[0]?.[5]).toBeUndefined()
+ })
+
+ it('preserves the functional response when private provenance cannot be attached', async () => {
+ mockReadResponseToBufferWithLimit.mockRejectedValueOnce(new Error('Response exceeds limit'))
+ mockExecuteTool.mockResolvedValueOnce({
+ content: [{ type: 'text', text: 'unchanged' }],
+ })
+ const request = createRequest({
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
+ })
+
+ const response = await POST(request, {})
+ const body = (await response.json()) as Record
+
+ expect(response.status).toBe(200)
+ expect(response.ok).toBe(true)
+ expect(response.headers.has('x-sim-private-tool-metadata')).toBe(false)
+ expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance')
+ expect(body).toMatchObject({
+ success: true,
+ data: {
+ success: true,
+ output: { content: [{ type: 'text' }] },
+ },
+ })
+ expect(
+ (body.data as { output: { content: Array<{ text?: unknown }> } }).output.content[0]?.text
+ ).toBe('unchanged')
+ })
+})
diff --git a/apps/sim/app/api/mcp/tools/execute/route.ts b/apps/sim/app/api/mcp/tools/execute/route.ts
index a60c32443f6..564fb0cf0aa 100644
--- a/apps/sim/app/api/mcp/tools/execute/route.ts
+++ b/apps/sim/app/api/mcp/tools/execute/route.ts
@@ -11,8 +11,15 @@ import {
} from '@/lib/billing/core/billing-attribution'
import { getExecutionTimeout } from '@/lib/core/execution-limits'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
+import { readResponseToBufferWithLimit } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
+import {
+ PRIVATE_TOOL_METADATA_RESPONSE_HEADER,
+ RESOLVED_SECRET_PROVENANCE_FIELD,
+ RESOLVED_SECRET_PROVENANCE_METADATA_V1,
+ requestsPrivateToolMetadata,
+} from '@/lib/execution/private-tool-metadata'
import {
mcpBodyReadErrorResponse,
readMcpJsonBodyWithLimit,
@@ -31,8 +38,13 @@ import {
assertPermissionsAllowed,
McpToolsNotAllowedError,
} from '@/ee/access-control/utils/permission-check'
+import {
+ ResolvedSecretTraceProvenanceAccumulator,
+ type ResolvedSecretTraceProvenanceV1,
+} from '@/executor/utils/resolved-secret-trace-registry'
const logger = createLogger('McpToolExecutionAPI')
+const MAX_PRIVATE_MCP_RESPONSE_BYTES = 10 * 1024 * 1024
export const dynamic = 'force-dynamic'
@@ -55,6 +67,35 @@ function hasType(prop: unknown): prop is SchemaProperty {
return typeof prop === 'object' && prop !== null && 'type' in prop
}
+async function attachPrivateProvenance(
+ response: NextResponse,
+ provenance: ResolvedSecretTraceProvenanceAccumulator
+): Promise {
+ let payload: Record
+ try {
+ const body = await readResponseToBufferWithLimit(response.clone(), {
+ maxBytes: MAX_PRIVATE_MCP_RESPONSE_BYTES,
+ label: 'MCP private metadata response',
+ allowNoBodyFallback: true,
+ })
+ const parsed: unknown = JSON.parse(body.toString('utf8'))
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error('MCP response is not a JSON object')
+ }
+ payload = parsed as Record
+ } catch {
+ return response
+ }
+
+ const headers = new Headers(response.headers)
+ headers.delete('content-length')
+ headers.set(PRIVATE_TOOL_METADATA_RESPONSE_HEADER, RESOLVED_SECRET_PROVENANCE_METADATA_V1)
+ return NextResponse.json(
+ { ...payload, [RESOLVED_SECRET_PROVENANCE_FIELD]: provenance.exportProvenance() },
+ { status: response.status, headers }
+ )
+}
+
/**
* POST - Execute a tool on an MCP server
*/
@@ -62,239 +103,268 @@ export const POST = withRouteHandler(
withMcpAuth('read')(
async (request: NextRequest, { userId, workspaceId, requestId, authType }) => {
let serverId: string | undefined
- try {
- const rawBody = await readMcpJsonBodyWithLimit(request)
- const parsedBody = mcpToolExecutionBodySchema.safeParse(rawBody)
+ const includePrivateProvenance =
+ authType === AuthType.INTERNAL_JWT &&
+ requestsPrivateToolMetadata(request.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1)
+ const resolvedSecretTraceProvenance = includePrivateProvenance
+ ? new ResolvedSecretTraceProvenanceAccumulator({ userId, workspaceId })
+ : undefined
+ const recordProvenance = resolvedSecretTraceProvenance
+ ? (provenance: ResolvedSecretTraceProvenanceV1): void => {
+ resolvedSecretTraceProvenance.record(provenance)
+ }
+ : undefined
+ const response = await (async (): Promise => {
+ try {
+ const rawBody = await readMcpJsonBodyWithLimit(request)
+ const parsedBody = mcpToolExecutionBodySchema.safeParse(rawBody)
- if (!parsedBody.success) {
- return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
- }
+ if (!parsedBody.success) {
+ return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
+ }
- const body = parsedBody.data
+ const body = parsedBody.data
- logger.info(`[${requestId}] MCP tool execution request received`, {
- hasAuthHeader: !!request.headers.get('authorization'),
- bodyKeys: Object.keys(body),
- serverId: body.serverId,
- toolName: body.toolName,
- hasWorkflowId: !!body.workflowId,
- workflowId: body.workflowId,
- userId: userId,
- })
+ logger.info(`[${requestId}] MCP tool execution request received`, {
+ hasAuthHeader: !!request.headers.get('authorization'),
+ bodyKeys: Object.keys(body),
+ serverId: body.serverId,
+ toolName: body.toolName,
+ hasWorkflowId: !!body.workflowId,
+ workflowId: body.workflowId,
+ userId: userId,
+ })
- const { toolName, arguments: rawArgs } = body
- serverId = body.serverId
- const args = rawArgs || {}
+ const { toolName, arguments: rawArgs } = body
+ serverId = body.serverId
+ const args = rawArgs || {}
- try {
- await assertPermissionsAllowed({
- userId,
- workspaceId,
- toolKind: 'mcp',
- })
- } catch (err) {
- if (err instanceof McpToolsNotAllowedError) {
- return createMcpErrorResponse(err, err.message, 403)
+ try {
+ await assertPermissionsAllowed({
+ userId,
+ workspaceId,
+ toolKind: 'mcp',
+ })
+ } catch (err) {
+ if (err instanceof McpToolsNotAllowedError) {
+ return createMcpErrorResponse(err, err.message, 403)
+ }
+ throw err
}
- throw err
- }
-
- logger.info(
- `[${requestId}] Executing tool ${toolName} on server ${serverId} for user ${userId} in workspace ${workspaceId}`
- )
- let tool: McpTool | null = null
- try {
- const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
- tool = tools.find((t) => t.name === toolName) ?? null
+ logger.info(
+ `[${requestId}] Executing tool ${toolName} on server ${serverId} for user ${userId} in workspace ${workspaceId}`
+ )
- if (!tool) {
- logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, {
- availableTools: tools.map((t) => t.name),
- })
- return createMcpErrorResponse(
- new Error('Tool not found'),
- 'Tool not found on the specified server',
- 404
+ let tool: McpTool | null = null
+ try {
+ const tools = await mcpService.discoverServerTools(
+ userId,
+ serverId,
+ workspaceId,
+ false,
+ recordProvenance
)
- }
+ tool = tools.find((t) => t.name === toolName) ?? null
- if (tool.inputSchema?.properties) {
- for (const [paramName, paramSchema] of Object.entries(tool.inputSchema.properties)) {
- const schema = hasType(paramSchema) ? paramSchema : null
- if (!schema) continue
- const value = args[paramName]
+ if (!tool) {
+ logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, {
+ availableTools: tools.map((t) => t.name),
+ })
+ return createMcpErrorResponse(
+ new Error('Tool not found'),
+ 'Tool not found on the specified server',
+ 404
+ )
+ }
- if (value === undefined || value === null) {
- continue
- }
+ if (tool.inputSchema?.properties) {
+ for (const [paramName, paramSchema] of Object.entries(tool.inputSchema.properties)) {
+ const schema = hasType(paramSchema) ? paramSchema : null
+ if (!schema) continue
+ const value = args[paramName]
- if (
- (schema.type === 'number' || schema.type === 'integer') &&
- typeof value === 'string'
- ) {
- const numValue =
- schema.type === 'integer' ? Number.parseInt(value) : Number.parseFloat(value)
- if (!Number.isNaN(numValue)) {
- args[paramName] = numValue
- }
- } else if (schema.type === 'boolean' && typeof value === 'string') {
- if (value.toLowerCase() === 'true') {
- args[paramName] = true
- } else if (value.toLowerCase() === 'false') {
- args[paramName] = false
+ if (value === undefined || value === null) {
+ continue
}
- } else if (schema.type === 'array' && typeof value === 'string') {
- const stringValue = value.trim()
- if (stringValue) {
- try {
- const parsed = JSON.parse(stringValue)
- if (Array.isArray(parsed)) {
- args[paramName] = parsed
- } else {
- args[paramName] = [parsed]
- }
- } catch {
- if (stringValue.includes(',')) {
- args[paramName] = stringValue
- .split(',')
- .map((item) => item.trim())
- .filter((item) => item)
- } else {
- args[paramName] = [stringValue]
+
+ if (
+ (schema.type === 'number' || schema.type === 'integer') &&
+ typeof value === 'string'
+ ) {
+ const numValue =
+ schema.type === 'integer' ? Number.parseInt(value) : Number.parseFloat(value)
+ if (!Number.isNaN(numValue)) {
+ args[paramName] = numValue
+ }
+ } else if (schema.type === 'boolean' && typeof value === 'string') {
+ if (value.toLowerCase() === 'true') {
+ args[paramName] = true
+ } else if (value.toLowerCase() === 'false') {
+ args[paramName] = false
+ }
+ } else if (schema.type === 'array' && typeof value === 'string') {
+ const stringValue = value.trim()
+ if (stringValue) {
+ try {
+ const parsed = JSON.parse(stringValue)
+ if (Array.isArray(parsed)) {
+ args[paramName] = parsed
+ } else {
+ args[paramName] = [parsed]
+ }
+ } catch {
+ if (stringValue.includes(',')) {
+ args[paramName] = stringValue
+ .split(',')
+ .map((item) => item.trim())
+ .filter((item) => item)
+ } else {
+ args[paramName] = [stringValue]
+ }
}
+ } else {
+ args[paramName] = []
}
- } else {
- args[paramName] = []
}
}
}
- }
- } catch (error) {
- logger.warn(
- `[${requestId}] Failed to discover tools for validation, proceeding without schema`,
- error
- )
- }
-
- if (tool) {
- const validationError = validateToolArguments(tool, args)
- if (validationError) {
- logger.warn(`[${requestId}] Tool validation failed: ${validationError}`)
- return createMcpErrorResponse(
- new Error(`Invalid arguments for tool ${toolName}: ${validationError}`),
- 'Invalid tool arguments',
- 400
+ } catch (error) {
+ logger.warn(
+ `[${requestId}] Failed to discover tools for validation, proceeding without schema`,
+ error
)
}
- }
- const toolCall: McpToolCall = {
- name: toolName,
- arguments: args,
- }
+ if (tool) {
+ const validationError = validateToolArguments(tool, args)
+ if (validationError) {
+ logger.warn(`[${requestId}] Tool validation failed: ${validationError}`)
+ return createMcpErrorResponse(
+ new Error(`Invalid arguments for tool ${toolName}: ${validationError}`),
+ 'Invalid tool arguments',
+ 400
+ )
+ }
+ }
- const billingAttribution =
- authType === AuthType.INTERNAL_JWT
- ? requireBillingAttributionHeader(request.headers, {
- actorUserId: userId,
- workspaceId,
- })
- : await resolveBillingAttribution({ actorUserId: userId, workspaceId })
- const executionTimeout = getExecutionTimeout(
- billingAttribution.payerSubscription?.plan as SubscriptionPlan | undefined,
- 'sync'
- )
-
- const simViaHeader = request.headers.get(SIM_VIA_HEADER)
- const extraHeaders: Record = {}
- if (simViaHeader) {
- extraHeaders[SIM_VIA_HEADER] = simViaHeader
- }
+ const toolCall: McpToolCall = {
+ name: toolName,
+ arguments: args,
+ }
- let timeoutHandle: ReturnType | undefined
- const executePromise = mcpService.executeTool(
- userId,
- serverId,
- toolCall,
- workspaceId,
- extraHeaders
- )
- // A zero timeout means "no timeout" (billing-disabled deployments).
- const result = await (executionTimeout > 0
- ? Promise.race([
- executePromise,
- new Promise((_, reject) => {
- timeoutHandle = setTimeout(
- () => reject(new Error('Tool execution timeout')),
- executionTimeout
- )
- }),
- ])
- : executePromise
- ).finally(() => {
- if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
- })
-
- const transformedResult = transformToolResult(result)
-
- if (result.isError) {
- logger.warn(`[${requestId}] Tool execution returned error for ${toolName} on ${serverId}`)
- return createMcpErrorResponse(
- transformedResult,
- transformedResult.error || 'Tool execution failed',
- 400
+ const billingAttribution =
+ authType === AuthType.INTERNAL_JWT
+ ? requireBillingAttributionHeader(request.headers, {
+ actorUserId: userId,
+ workspaceId,
+ })
+ : await resolveBillingAttribution({ actorUserId: userId, workspaceId })
+ const executionTimeout = getExecutionTimeout(
+ billingAttribution.payerSubscription?.plan as SubscriptionPlan | undefined,
+ 'sync'
)
- }
- logger.info(`[${requestId}] Successfully executed tool ${toolName} on server ${serverId}`)
- try {
- const { PlatformEvents } = await import('@/lib/core/telemetry')
- PlatformEvents.mcpToolExecuted({
- serverId,
- toolName,
- status: 'success',
- workspaceId,
- })
- } catch (error) {
- logger.warn('Failed to record MCP tool execution telemetry', {
- error: getErrorMessage(error),
+ const simViaHeader = request.headers.get(SIM_VIA_HEADER)
+ const extraHeaders: Record = {}
+ if (simViaHeader) {
+ extraHeaders[SIM_VIA_HEADER] = simViaHeader
+ }
+
+ let timeoutHandle: ReturnType | undefined
+ const executePromise = mcpService.executeTool(
+ userId,
serverId,
- toolName,
+ toolCall,
workspaceId,
+ extraHeaders,
+ recordProvenance
+ )
+ // A zero timeout means "no timeout" (billing-disabled deployments).
+ const result = await (executionTimeout > 0
+ ? Promise.race([
+ executePromise,
+ new Promise((_, reject) => {
+ timeoutHandle = setTimeout(
+ () => reject(new Error('Tool execution timeout')),
+ executionTimeout
+ )
+ }),
+ ])
+ : executePromise
+ ).finally(() => {
+ if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
})
- }
- return createMcpSuccessResponse(transformedResult)
- } catch (error) {
- const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
- if (bodyErrorResponse) return bodyErrorResponse
- if (
- error instanceof McpOauthAuthorizationRequiredError ||
- error instanceof McpOauthRedirectRequired ||
- error instanceof UnauthorizedError
- ) {
- const errorServerId =
- error instanceof McpOauthAuthorizationRequiredError ? error.serverId : serverId
- logger.warn(`[${requestId}] OAuth re-authorization required for MCP tool execution`, {
- serverId: errorServerId,
- })
- return NextResponse.json(
- {
- success: false,
- error: 'OAuth re-authorization required',
- code: 'reauth_required',
+ const transformedResult = transformToolResult(result)
+
+ if (result.isError) {
+ logger.warn(
+ `[${requestId}] Tool execution returned error for ${toolName} on ${serverId}`
+ )
+ return createMcpErrorResponse(
+ transformedResult,
+ transformedResult.error || 'Tool execution failed',
+ 400
+ )
+ }
+ logger.info(`[${requestId}] Successfully executed tool ${toolName} on server ${serverId}`)
+
+ try {
+ const { PlatformEvents } = await import('@/lib/core/telemetry')
+ PlatformEvents.mcpToolExecuted({
+ serverId,
+ toolName,
+ status: 'success',
+ workspaceId,
+ })
+ } catch (error) {
+ logger.warn('Failed to record MCP tool execution telemetry', {
+ error: getErrorMessage(error),
+ serverId,
+ toolName,
+ workspaceId,
+ })
+ }
+
+ return createMcpSuccessResponse(transformedResult)
+ } catch (error) {
+ if (getErrorMessage(error) === 'Tool execution timeout') {
+ resolvedSecretTraceProvenance?.markIncomplete()
+ }
+ const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
+ if (bodyErrorResponse) return bodyErrorResponse
+ if (
+ error instanceof McpOauthAuthorizationRequiredError ||
+ error instanceof McpOauthRedirectRequired ||
+ error instanceof UnauthorizedError
+ ) {
+ const errorServerId =
+ error instanceof McpOauthAuthorizationRequiredError ? error.serverId : serverId
+ logger.warn(`[${requestId}] OAuth re-authorization required for MCP tool execution`, {
serverId: errorServerId,
- },
- { status: 401 }
- )
- }
+ })
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'OAuth re-authorization required',
+ code: 'reauth_required',
+ serverId: errorServerId,
+ },
+ { status: 401 }
+ )
+ }
- logger.error(`[${requestId}] Error executing MCP tool:`, error)
+ logger.error(`[${requestId}] Error executing MCP tool:`, error)
- const { message, status } = categorizeError(error)
- return createMcpErrorResponse(new Error(message), message, status)
- }
+ const { message, status } = categorizeError(error)
+ return createMcpErrorResponse(new Error(message), message, status)
+ }
+ })()
+
+ return resolvedSecretTraceProvenance
+ ? attachPrivateProvenance(response, resolvedSecretTraceProvenance)
+ : response
}
)
)
diff --git a/apps/sim/app/api/mothership/execute/route.test.ts b/apps/sim/app/api/mothership/execute/route.test.ts
index 014a86134b7..350e85bd8b1 100644
--- a/apps/sim/app/api/mothership/execute/route.test.ts
+++ b/apps/sim/app/api/mothership/execute/route.test.ts
@@ -1,7 +1,98 @@
-import { describe, expect, it } from 'vitest'
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockAssertActiveWorkspaceAccess,
+ mockBuildIntegrationToolSchemas,
+ mockBuildSelectedMcpToolSchemas,
+ mockBuildTaggedMcpToolSchemas,
+ mockCheckInternalAuth,
+ mockComputeWorkspaceEntitlements,
+ mockDecryptSecret,
+ mockGenerateWorkspaceContext,
+ mockGetPersonalAndWorkspaceEnv,
+ mockProcessContextsServer,
+ mockRequestExplicitStreamAbort,
+ mockRequireBillingAttributionHeader,
+ mockRunHeadlessCopilotLifecycle,
+} = vi.hoisted(() => ({
+ mockAssertActiveWorkspaceAccess: vi.fn(),
+ mockBuildIntegrationToolSchemas: vi.fn(),
+ mockBuildSelectedMcpToolSchemas: vi.fn(),
+ mockBuildTaggedMcpToolSchemas: vi.fn(),
+ mockCheckInternalAuth: vi.fn(),
+ mockComputeWorkspaceEntitlements: vi.fn(),
+ mockDecryptSecret: vi.fn(),
+ mockGenerateWorkspaceContext: vi.fn(),
+ mockGetPersonalAndWorkspaceEnv: vi.fn(),
+ mockProcessContextsServer: vi.fn(),
+ mockRequestExplicitStreamAbort: vi.fn(),
+ mockRequireBillingAttributionHeader: vi.fn(),
+ mockRunHeadlessCopilotLifecycle: vi.fn(),
+}))
+
+vi.mock('@/lib/core/security/encryption', () => ({
+ decryptSecret: mockDecryptSecret,
+}))
+
+vi.mock('@/lib/auth/hybrid', () => ({
+ checkInternalAuth: mockCheckInternalAuth,
+}))
+
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ requireBillingAttributionHeader: mockRequireBillingAttributionHeader,
+}))
+
+vi.mock('@/lib/copilot/chat/payload', () => ({
+ buildIntegrationToolSchemas: mockBuildIntegrationToolSchemas,
+}))
+
+vi.mock('@/lib/copilot/chat/process-contents', () => ({
+ processContextsServer: mockProcessContextsServer,
+}))
+
+vi.mock('@/lib/copilot/chat/workspace-context', () => ({
+ generateWorkspaceContext: mockGenerateWorkspaceContext,
+}))
+
+vi.mock('@/lib/copilot/entitlements', () => ({
+ computeWorkspaceEntitlements: mockComputeWorkspaceEntitlements,
+}))
+
+vi.mock('@/lib/copilot/mcp-tools', () => ({
+ buildSelectedMcpToolSchemas: mockBuildSelectedMcpToolSchemas,
+ buildTaggedMcpToolSchemas: mockBuildTaggedMcpToolSchemas,
+}))
+
+vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({
+ runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle,
+}))
+
+vi.mock('@/lib/copilot/request/session/explicit-abort', () => ({
+ requestExplicitStreamAbort: mockRequestExplicitStreamAbort,
+}))
+
+vi.mock('@/lib/core/config/env-flags', () => ({
+ isDocSandboxEnabled: false,
+}))
+
+vi.mock('@/lib/environment/utils', () => ({
+ getPersonalAndWorkspaceEnv: mockGetPersonalAndWorkspaceEnv,
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess,
+ isWorkspaceAccessDeniedError: vi.fn(() => false),
+}))
+
+import type { CopilotLifecycleOptions } from '@/lib/copilot/request/lifecycle/run'
import {
buildExecuteResponsePayload,
CALLER_VISIBLE_SERVER_TOOLS,
+ POST,
} from '@/app/api/mothership/execute/route'
type Payload = Parameters[0]
@@ -48,3 +139,362 @@ describe('buildExecuteResponsePayload', () => {
expect(CALLER_VISIBLE_SERVER_TOOLS.has('complete_scheduled_task')).toBe(true)
})
})
+
+describe('mothership private trace provenance transport', () => {
+ const requestBody = {
+ messages: [{ role: 'user', content: 'hello' }],
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ chatId: 'chat-1',
+ messageId: 'message-1',
+ requestId: 'request-1',
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckInternalAuth.mockResolvedValue({
+ success: true,
+ userId: 'user-1',
+ authType: 'internal_jwt',
+ })
+ mockAssertActiveWorkspaceAccess.mockResolvedValue({ permission: 'write' })
+ mockRequireBillingAttributionHeader.mockReturnValue({
+ actorUserId: 'user-1',
+ workspaceId: 'workspace-1',
+ })
+ mockGetPersonalAndWorkspaceEnv.mockResolvedValue({
+ personalEncrypted: { API_KEY: 'encrypted-secret' },
+ workspaceEncrypted: {},
+ personalDecrypted: { API_KEY: 'secret-value' },
+ workspaceDecrypted: {},
+ decryptionFailures: [],
+ })
+ mockGenerateWorkspaceContext.mockResolvedValue({})
+ mockBuildIntegrationToolSchemas.mockResolvedValue([])
+ mockBuildSelectedMcpToolSchemas.mockResolvedValue([])
+ mockBuildTaggedMcpToolSchemas.mockResolvedValue([])
+ mockComputeWorkspaceEntitlements.mockResolvedValue([])
+ mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' })
+ mockProcessContextsServer.mockResolvedValue([])
+ mockRequestExplicitStreamAbort.mockResolvedValue(undefined)
+ })
+
+ function successResult() {
+ return {
+ success: true,
+ content: 'secret-value',
+ contentBlocks: [],
+ toolCalls: [],
+ chatId: 'chat-1',
+ }
+ }
+
+ function activateSecret(options: CopilotLifecycleOptions): void {
+ options.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'secret-value')
+ }
+
+ it('does not expose private provenance unless the internal caller requests it', async () => {
+ mockRunHeadlessCopilotLifecycle.mockImplementation(
+ async (_payload: Record, options: CopilotLifecycleOptions) => {
+ activateSecret(options)
+ return successResult()
+ }
+ )
+
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ requestBody,
+ { Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' },
+ 'http://localhost:3000/api/mothership/execute'
+ )
+ )
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('x-sim-private-tool-metadata')).toBeNull()
+ expect(body.content).toBe('secret-value')
+ expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance')
+ expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled()
+ expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({ resolvedSecretTraceRegistry: undefined })
+ )
+ })
+
+ it('keeps execution functional and fails trace provenance closed when catalog setup fails', async () => {
+ mockGetPersonalAndWorkspaceEnv.mockRejectedValueOnce(new Error('catalog unavailable'))
+ mockRunHeadlessCopilotLifecycle.mockImplementation(
+ async (_payload: Record, options: CopilotLifecycleOptions) => {
+ expect(options.resolvedSecretTraceRegistry?.isComplete()).toBe(false)
+ return successResult()
+ }
+ )
+
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ requestBody,
+ {
+ Authorization: 'Bearer internal',
+ 'x-sim-billing-attribution': 'billing',
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
+ },
+ 'http://localhost:3000/api/mothership/execute'
+ )
+ )
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.content).toBe('secret-value')
+ expect(body.__resolvedSecretTraceProvenance).toEqual({
+ version: 1,
+ complete: false,
+ entries: [],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ })
+ })
+
+ it('fails provenance closed without changing a runtime value that rotated after catalog load', async () => {
+ mockRunHeadlessCopilotLifecycle.mockImplementation(
+ async (_payload: Record, options: CopilotLifecycleOptions) => {
+ expect(
+ options.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'rotated-secret-value')
+ ).toBe(false)
+ return { ...successResult(), content: 'rotated-secret-value' }
+ }
+ )
+
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ requestBody,
+ {
+ Authorization: 'Bearer internal',
+ 'x-sim-billing-attribution': 'billing',
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
+ },
+ 'http://localhost:3000/api/mothership/execute'
+ )
+ )
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.content).toBe('rotated-secret-value')
+ expect(body.__resolvedSecretTraceProvenance).toEqual({
+ version: 1,
+ complete: false,
+ entries: [],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ })
+ })
+
+ it('returns encrypted provenance on a marker-gated successful request', async () => {
+ mockRunHeadlessCopilotLifecycle.mockImplementation(
+ async (_payload: Record, options: CopilotLifecycleOptions) => {
+ activateSecret(options)
+ return successResult()
+ }
+ )
+
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ requestBody,
+ {
+ Authorization: 'Bearer internal',
+ 'x-sim-billing-attribution': 'billing',
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
+ },
+ 'http://localhost:3000/api/mothership/execute'
+ )
+ )
+ const body = await response.json()
+
+ expect(response.headers.get('x-sim-private-tool-metadata')).toBe(
+ 'resolved-secret-provenance-v1'
+ )
+ expect(body.content).toBe('secret-value')
+ expect(body.__resolvedSecretTraceProvenance).toEqual({
+ version: 1,
+ complete: true,
+ entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ })
+ expect(JSON.stringify(body.__resolvedSecretTraceProvenance)).not.toContain('secret-value')
+ })
+
+ it('imports MCP schema-discovery provenance before starting the lifecycle', async () => {
+ const provenance = {
+ version: 1,
+ complete: true,
+ entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ }
+ mockBuildTaggedMcpToolSchemas.mockImplementationOnce(
+ async (
+ _userId: string,
+ _workspaceId: string,
+ _serverIds: string[],
+ report: (value: unknown) => void
+ ) => {
+ report(provenance)
+ return []
+ }
+ )
+ mockRunHeadlessCopilotLifecycle.mockImplementation(
+ async (payload: Record, options: CopilotLifecycleOptions) => {
+ expect(options.resolvedSecretTraceRegistry?.exportProvenance()).toEqual(provenance)
+ expect(JSON.stringify(payload)).not.toContain('encrypted-secret')
+ expect(JSON.stringify(payload)).not.toContain('__resolvedSecretTraceProvenance')
+ return successResult()
+ }
+ )
+
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ ...requestBody,
+ contexts: [{ kind: 'mcp', label: 'Docs', serverId: 'server-1' }],
+ },
+ {
+ Authorization: 'Bearer internal',
+ 'x-sim-billing-attribution': 'billing',
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
+ },
+ 'http://localhost:3000/api/mothership/execute'
+ )
+ )
+ const body = await response.json()
+
+ expect({ status: response.status, provenance: body.__resolvedSecretTraceProvenance }).toEqual({
+ status: 200,
+ provenance,
+ })
+ })
+
+ it('marks the lifecycle registry incomplete for malformed MCP discovery provenance', async () => {
+ mockBuildTaggedMcpToolSchemas.mockImplementationOnce(
+ async (
+ _userId: string,
+ _workspaceId: string,
+ _serverIds: string[],
+ report: (value: unknown) => void
+ ) => {
+ report({ version: 1, complete: true, entries: 'invalid' })
+ return []
+ }
+ )
+ mockRunHeadlessCopilotLifecycle.mockImplementation(
+ async (_payload: Record, options: CopilotLifecycleOptions) => {
+ expect(options.resolvedSecretTraceRegistry?.isComplete()).toBe(false)
+ return successResult()
+ }
+ )
+
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ ...requestBody,
+ contexts: [{ kind: 'mcp', label: 'Docs', serverId: 'server-1' }],
+ },
+ {
+ Authorization: 'Bearer internal',
+ 'x-sim-billing-attribution': 'billing',
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
+ },
+ 'http://localhost:3000/api/mothership/execute'
+ )
+ )
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.__resolvedSecretTraceProvenance).toEqual({
+ version: 1,
+ complete: false,
+ entries: [],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ })
+ })
+
+ it('returns encrypted provenance with marker-gated failures', async () => {
+ mockRunHeadlessCopilotLifecycle.mockImplementation(
+ async (_payload: Record, options: CopilotLifecycleOptions) => {
+ activateSecret(options)
+ return {
+ ...successResult(),
+ success: false,
+ error: 'failed with secret-value',
+ content: 'secret-value',
+ }
+ }
+ )
+
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ requestBody,
+ {
+ Authorization: 'Bearer internal',
+ 'x-sim-billing-attribution': 'billing',
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
+ },
+ 'http://localhost:3000/api/mothership/execute'
+ )
+ )
+ const body = await response.json()
+
+ expect(response.status).toBe(500)
+ expect(response.headers.get('x-sim-private-tool-metadata')).toBe(
+ 'resolved-secret-provenance-v1'
+ )
+ expect(body.content).toBe('secret-value')
+ expect(body.__resolvedSecretTraceProvenance.entries).toEqual([
+ { name: 'API_KEY', encryptedValue: 'encrypted-secret' },
+ ])
+ })
+
+ it('places encrypted provenance only on the terminal streamed event', async () => {
+ mockRunHeadlessCopilotLifecycle.mockImplementation(
+ async (_payload: Record, options: CopilotLifecycleOptions) => {
+ activateSecret(options)
+ return successResult()
+ }
+ )
+
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ requestBody,
+ {
+ Authorization: 'Bearer internal',
+ 'x-sim-billing-attribution': 'billing',
+ 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
+ 'x-mothership-execute-stream': 'ndjson',
+ },
+ 'http://localhost:3000/api/mothership/execute'
+ )
+ )
+ const events = (await response.text())
+ .trim()
+ .split('\n')
+ .map((line) => JSON.parse(line) as Record)
+
+ expect(response.headers.get('x-sim-private-tool-metadata')).toBe(
+ 'resolved-secret-provenance-v1'
+ )
+ expect(events[0]).toMatchObject({ type: 'heartbeat' })
+ expect(events[0]).not.toHaveProperty('__resolvedSecretTraceProvenance')
+ expect(events.at(-1)).toMatchObject({
+ type: 'final',
+ data: {
+ content: 'secret-value',
+ __resolvedSecretTraceProvenance: {
+ entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }],
+ },
+ },
+ })
+ })
+})
diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts
index 3e46be58217..1febf5e513e 100644
--- a/apps/sim/app/api/mothership/execute/route.ts
+++ b/apps/sim/app/api/mothership/execute/route.ts
@@ -20,10 +20,23 @@ import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explic
import type { StreamEvent } from '@/lib/copilot/request/types'
import { isDocSandboxEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
+import {
+ PRIVATE_TOOL_METADATA_RESPONSE_HEADER,
+ RESOLVED_SECRET_PROVENANCE_FIELD,
+ RESOLVED_SECRET_PROVENANCE_METADATA_V1,
+ requestsPrivateToolMetadata,
+} from '@/lib/execution/private-tool-metadata'
import {
assertActiveWorkspaceAccess,
isWorkspaceAccessDeniedError,
} from '@/lib/workspaces/permissions/utils'
+import {
+ createIncompleteResolvedSecretTraceRegistry,
+ createResolvedSecretTraceRegistry,
+ ResolvedSecretTraceProvenanceAccumulator,
+ type ResolvedSecretTraceRegistry,
+} from '@/executor/utils/resolved-secret-trace-registry'
import type { ChatContext } from '@/stores/panel'
export const maxDuration = 3600
@@ -35,6 +48,28 @@ const MOTHERSHIP_EXECUTE_STREAM_CONTENT_TYPE = 'application/x-ndjson'
const MOTHERSHIP_EXECUTE_HEARTBEAT_INTERVAL_MS = 15_000
const ndjsonEncoder = new TextEncoder()
+function withPrivateProvenance>(
+ payload: T,
+ registry: ResolvedSecretTraceRegistry | undefined,
+ include: boolean
+): T & Partial> {
+ return {
+ ...payload,
+ ...(include && registry
+ ? { [RESOLVED_SECRET_PROVENANCE_FIELD]: registry.exportProvenance() }
+ : {}),
+ }
+}
+
+function privateResponseHeaders(
+ registry: ResolvedSecretTraceRegistry | undefined,
+ include: boolean
+): Record {
+ return include && registry
+ ? { [PRIVATE_TOOL_METADATA_RESPONSE_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1 }
+ : {}
+}
+
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
@@ -99,6 +134,11 @@ export function buildExecuteResponsePayload(
export const POST = withRouteHandler(async (req: NextRequest) => {
let messageId: string | undefined
let requestId: string | undefined
+ let resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined
+ const includePrivateProvenance = requestsPrivateToolMetadata(
+ req.headers,
+ RESOLVED_SECRET_PROVENANCE_METADATA_V1
+ )
try {
const auth = await checkInternalAuth(req, { requireWorkflowId: false })
@@ -146,6 +186,39 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
actorUserId: userId,
workspaceId,
})
+ if (includePrivateProvenance) {
+ const scope = { userId, workspaceId }
+ try {
+ const environment = await getPersonalAndWorkspaceEnv(userId, workspaceId, {
+ workspaceAccess,
+ })
+ resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({
+ personalEncrypted: environment.personalEncrypted,
+ workspaceEncrypted: environment.workspaceEncrypted,
+ personalDecrypted: environment.personalDecrypted,
+ workspaceDecrypted: environment.workspaceDecrypted,
+ decryptionFailures: environment.decryptionFailures,
+ scope,
+ })
+ } catch (error) {
+ logger.warn('Failed to build Mothership trace secret catalog', {
+ error: getErrorMessage(error),
+ userId,
+ workspaceId,
+ })
+ resolvedSecretTraceRegistry = createIncompleteResolvedSecretTraceRegistry(scope)
+ }
+ }
+ const activeResolvedSecretTraceRegistry = resolvedSecretTraceRegistry
+ const mcpDiscoveryProvenance = new ResolvedSecretTraceProvenanceAccumulator({
+ userId,
+ workspaceId,
+ })
+ const recordMcpDiscoveryProvenance = includePrivateProvenance
+ ? (provenance: unknown): void => {
+ mcpDiscoveryProvenance.record(provenance)
+ }
+ : undefined
const effectiveChatId = chatId || generateId()
messageId = providedMessageId || generateId()
@@ -164,17 +237,38 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
const nonMcpAgentMentions = agentMentions?.filter((context) => context.kind !== 'mcp')
const userPermission = workspaceAccess.permission
+ const mothershipToolsPromise = Promise.allSettled([
+ buildSelectedMcpToolSchemas(
+ userId,
+ workspaceId,
+ mcpTools ?? [],
+ recordMcpDiscoveryProvenance
+ ),
+ buildTaggedMcpToolSchemas(
+ userId,
+ workspaceId,
+ taggedMcpServerIds,
+ recordMcpDiscoveryProvenance
+ ),
+ ]).then(async (results) => {
+ if (activeResolvedSecretTraceRegistry) {
+ await activeResolvedSecretTraceRegistry.importProvenance(
+ mcpDiscoveryProvenance.exportProvenance(),
+ { trusted: true }
+ )
+ }
+ const groups = results.map((result) => {
+ if (result.status === 'rejected') throw result.reason
+ return result.value
+ })
+ const byName = new Map(groups.flat().map((tool) => [tool.name, tool]))
+ return [...byName.values()]
+ })
const [workspaceContext, integrationTools, mothershipTools, entitlements, agentContexts] =
await Promise.all([
generateWorkspaceContext(workspaceId, userId, { workspaceAccess }),
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
- Promise.all([
- buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []),
- buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds),
- ]).then((groups) => {
- const byName = new Map(groups.flat().map((tool) => [tool.name, tool]))
- return [...byName.values()]
- }),
+ mothershipToolsPromise,
computeWorkspaceEntitlements(workspaceId, userId),
processContextsServer(
nonMcpAgentMentions,
@@ -282,6 +376,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
interactive: false,
abortSignal: lifecycleAbortController.signal,
billingAttribution,
+ resolvedSecretTraceRegistry,
onEvent,
})
@@ -326,7 +421,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
allowExplicitAbort = false
if (lifecycleAbortController.signal.aborted) {
- send({ type: 'error', error: 'Sim execution aborted' })
+ send(
+ withPrivateProvenance(
+ { type: 'error', error: 'Sim execution aborted' },
+ resolvedSecretTraceRegistry,
+ includePrivateProvenance
+ )
+ )
return
}
@@ -343,17 +444,27 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
errors: result.errors,
}
)
- send({
- type: 'error',
- error: result.error || 'Sim execution failed',
- content: result.content || '',
- })
+ send(
+ withPrivateProvenance(
+ {
+ type: 'error',
+ error: result.error || 'Sim execution failed',
+ content: result.content || '',
+ },
+ resolvedSecretTraceRegistry,
+ includePrivateProvenance
+ )
+ )
return
}
send({
type: 'final',
- data: buildExecuteResponsePayload(result, effectiveChatId, integrationTools),
+ data: withPrivateProvenance(
+ buildExecuteResponsePayload(result, effectiveChatId, integrationTools),
+ resolvedSecretTraceRegistry,
+ includePrivateProvenance
+ ),
})
} catch (error) {
if (
@@ -367,7 +478,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
: 'Mothership execute aborted',
{ requestId }
)
- send({ type: 'error', error: 'Sim execution aborted' })
+ send(
+ withPrivateProvenance(
+ { type: 'error', error: 'Sim execution aborted' },
+ resolvedSecretTraceRegistry,
+ includePrivateProvenance
+ )
+ )
return
}
@@ -380,10 +497,16 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
error: getErrorMessage(error, 'Unknown error'),
}
)
- send({
- type: 'error',
- error: getErrorMessage(error, 'Internal server error'),
- })
+ send(
+ withPrivateProvenance(
+ {
+ type: 'error',
+ error: getErrorMessage(error, 'Internal server error'),
+ },
+ resolvedSecretTraceRegistry,
+ includePrivateProvenance
+ )
+ )
} finally {
allowExplicitAbort = false
if (heartbeatId) {
@@ -410,6 +533,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
headers: {
'Content-Type': `${MOTHERSHIP_EXECUTE_STREAM_CONTENT_TYPE}; charset=utf-8`,
'Cache-Control': 'no-cache, no-transform',
+ ...privateResponseHeaders(resolvedSecretTraceRegistry, includePrivateProvenance),
},
})
}
@@ -421,7 +545,17 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
if (lifecycleAbortController.signal.aborted || req.signal.aborted) {
reqLogger.info('Mothership execute aborted after lifecycle completion')
- return NextResponse.json({ error: 'Sim execution aborted' }, { status: 499 })
+ return NextResponse.json(
+ withPrivateProvenance(
+ { error: 'Sim execution aborted' },
+ resolvedSecretTraceRegistry,
+ includePrivateProvenance
+ ),
+ {
+ status: 499,
+ headers: privateResponseHeaders(resolvedSecretTraceRegistry, includePrivateProvenance),
+ }
+ )
}
if (!result.success) {
@@ -438,16 +572,30 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
}
)
return NextResponse.json(
+ withPrivateProvenance(
+ {
+ error: result.error || 'Sim execution failed',
+ content: result.content || '',
+ },
+ resolvedSecretTraceRegistry,
+ includePrivateProvenance
+ ),
{
- error: result.error || 'Sim execution failed',
- content: result.content || '',
- },
- { status: 500 }
+ status: 500,
+ headers: privateResponseHeaders(resolvedSecretTraceRegistry, includePrivateProvenance),
+ }
)
}
return NextResponse.json(
- buildExecuteResponsePayload(result, effectiveChatId, integrationTools)
+ withPrivateProvenance(
+ buildExecuteResponsePayload(result, effectiveChatId, integrationTools),
+ resolvedSecretTraceRegistry,
+ includePrivateProvenance
+ ),
+ {
+ headers: privateResponseHeaders(resolvedSecretTraceRegistry, includePrivateProvenance),
+ }
)
} finally {
allowExplicitAbort = false
@@ -465,7 +613,17 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
}
)
- return NextResponse.json({ error: 'Sim execution aborted' }, { status: 499 })
+ return NextResponse.json(
+ withPrivateProvenance(
+ { error: 'Sim execution aborted' },
+ resolvedSecretTraceRegistry,
+ includePrivateProvenance
+ ),
+ {
+ status: 499,
+ headers: privateResponseHeaders(resolvedSecretTraceRegistry, includePrivateProvenance),
+ }
+ )
}
if (isWorkspaceAccessDeniedError(error)) {
@@ -481,8 +639,15 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
return NextResponse.json(
- { error: getErrorMessage(error, 'Internal server error') },
- { status: 500 }
+ withPrivateProvenance(
+ { error: getErrorMessage(error, 'Internal server error') },
+ resolvedSecretTraceRegistry,
+ includePrivateProvenance
+ ),
+ {
+ status: 500,
+ headers: privateResponseHeaders(resolvedSecretTraceRegistry, includePrivateProvenance),
+ }
)
}
})
diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts
index 148d3811895..108e9fc534e 100644
--- a/apps/sim/app/api/v1/logs/[id]/route.ts
+++ b/apps/sim/app/api/v1/logs/[id]/route.ts
@@ -7,7 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { v1GetLogContract } from '@/lib/api/contracts/v1/logs'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
+import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
checkRateLimit,
@@ -100,12 +100,13 @@ export const GET = withRouteHandler(
totalDurationMs: log.totalDurationMs,
files: log.files || undefined,
workflow: workflowSummary,
- executionData: (await materializeExecutionData(
+ executionData: (await materializeExecutionDataForDisplay(
log.executionData as Record | null,
{
workspaceId: log.workspaceId,
workflowId: log.workflowId,
executionId: log.executionId,
+ userId,
}
)) as any,
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts
index be40f9ae2dd..1da2529a84f 100644
--- a/apps/sim/app/api/v1/logs/route.ts
+++ b/apps/sim/app/api/v1/logs/route.ts
@@ -8,7 +8,7 @@ import { v1ListLogsContract } from '@/lib/api/contracts/v1/logs'
import { parseRequest } from '@/lib/api/server'
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
+import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store'
import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
@@ -167,12 +167,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => {
const result = buildBase(log)
if (log.executionData) {
- const execData = (await materializeExecutionData(
+ const execData = (await materializeExecutionDataForDisplay(
log.executionData as Record | null,
{
workspaceId: log.workspaceId,
workflowId: log.workflowId,
executionId: log.executionId,
+ userId,
}
)) as any
if (params.includeFinalOutput && execData.finalOutput) {
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
index 771e7cda706..c55a9c1030d 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
@@ -9,9 +9,11 @@ import {
executionPreprocessingMockFns,
hybridAuthMockFns,
loggingSessionMock,
+ queueTableRows,
requestUtilsMockFns,
resetDbChainMock,
resetEnvMock,
+ schemaMock,
setEnv,
workflowAuthzMockFns,
workflowsPersistenceUtilsMock,
@@ -339,6 +341,125 @@ describe('workflow execute async route', () => {
mockHandlePostExecutionPauseState.mockResolvedValue(undefined)
})
+ it('reuses raw workflow input by execution ID without returning it to the client', async () => {
+ const sourceInput = { token: 'raw-secret-1234', nested: { value: 42 } }
+ queueTableRows(schemaMock.workflowExecutionLogs, [
+ {
+ executionId: 'source-execution',
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ executionData: { workflowInput: sourceInput },
+ },
+ ])
+ const request = createMockRequest(
+ 'POST',
+ { inputFromExecutionId: 'source-execution' },
+ {
+ 'Content-Type': 'application/json',
+ 'X-Execution-Mode': 'async',
+ Cookie: 'session=value',
+ }
+ )
+
+ const response = await POST(request, { params: Promise.resolve({ id: 'workflow-1' }) })
+ const responseBody = await response.json()
+
+ expect(response.status).toBe(202)
+ expect(responseBody).not.toHaveProperty('input')
+ expect(JSON.stringify(responseBody)).not.toContain('raw-secret-1234')
+ expect(mockEnqueue).toHaveBeenCalledWith(
+ 'workflow-execution',
+ expect.objectContaining({ input: sourceInput }),
+ expect.any(Object)
+ )
+ })
+
+ it('recovers legacy starter input by execution ID without returning it to the client', async () => {
+ const sourceInput = { token: 'legacy-retry-input', nested: { value: 42 } }
+ queueTableRows(schemaMock.workflowExecutionLogs, [
+ {
+ executionId: 'source-execution',
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ executionData: {
+ executionState: {
+ blockStates: {
+ start: {
+ output: sourceInput,
+ executed: false,
+ executionTime: 0,
+ },
+ },
+ },
+ },
+ },
+ ])
+ const request = createMockRequest(
+ 'POST',
+ { inputFromExecutionId: 'source-execution' },
+ {
+ 'Content-Type': 'application/json',
+ 'X-Execution-Mode': 'async',
+ Cookie: 'session=value',
+ }
+ )
+
+ const response = await POST(request, { params: Promise.resolve({ id: 'workflow-1' }) })
+ const responseBody = await response.json()
+
+ expect(response.status).toBe(202)
+ expect(responseBody).not.toHaveProperty('input')
+ expect(JSON.stringify(responseBody)).not.toContain('legacy-retry-input')
+ expect(mockEnqueue).toHaveBeenCalledWith(
+ 'workflow-execution',
+ expect.objectContaining({ input: sourceInput }),
+ expect.any(Object)
+ )
+ })
+
+ it('rejects client input alongside a stored execution input reference', async () => {
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ {
+ input: { replacement: true },
+ inputFromExecutionId: 'source-execution',
+ },
+ { 'Content-Type': 'application/json', Cookie: 'session=value' }
+ ),
+ { params: Promise.resolve({ id: 'workflow-1' }) }
+ )
+
+ expect(response.status).toBe(400)
+ await expect(response.json()).resolves.toEqual({
+ error: 'Provide either input or inputFromExecutionId, not both',
+ })
+ expect(mockEnqueue).not.toHaveBeenCalled()
+ })
+
+ it('rejects stored execution input references from external callers', async () => {
+ mockCheckHybridAuth.mockResolvedValue({
+ success: true,
+ userId: 'personal-key-user-1',
+ authType: 'api_key',
+ apiKeyType: 'personal',
+ })
+ const response = await POST(
+ createMockRequest(
+ 'POST',
+ { inputFromExecutionId: 'source-execution' },
+ { 'Content-Type': 'application/json', 'X-API-Key': 'personal-key' }
+ ),
+ { params: Promise.resolve({ id: 'workflow-1' }) }
+ )
+
+ expect(response.status).toBe(403)
+ await expect(response.json()).resolves.toEqual({
+ error: 'Stored execution input can only be reused by an authenticated session',
+ })
+ expect(mockEnqueue).not.toHaveBeenCalled()
+ })
+
it('queues async execution with matching correlation metadata', async () => {
const req = createMockRequest(
'POST',
@@ -750,6 +871,173 @@ describe('workflow execute async route', () => {
expect(mockReleaseExecutionIdClaim).not.toHaveBeenCalled()
})
+ it('loads trusted run-from-block state by execution ID and preserves its source identity', async () => {
+ const sourceState = {
+ blockStates: { previous: { output: { value: 'cached' } } },
+ executedBlocks: ['previous'],
+ blockLogs: [],
+ decisions: { router: {}, condition: {} },
+ completedLoops: [],
+ activeExecutionPath: [],
+ resolvedSecretTraceProvenance: {
+ version: 1,
+ complete: true,
+ entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }],
+ scope: { userId: 'owner-1', workspaceId: 'workspace-1' },
+ },
+ }
+ queueTableRows(schemaMock.workflowExecutionLogs, [
+ {
+ executionId: 'source-execution',
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ executionData: { executionState: sourceState },
+ },
+ ])
+ const request = createMockRequest(
+ 'POST',
+ {
+ input: { hello: 'world' },
+ runFromBlock: {
+ startBlockId: 'start-block',
+ executionId: 'source-execution',
+ },
+ },
+ {
+ 'Content-Type': 'application/json',
+ Cookie: 'session=value',
+ }
+ )
+
+ const response = await POST(request, { params: Promise.resolve({ id: 'workflow-1' }) })
+
+ expect(response.status).toBe(200)
+ expect(mockExecuteWorkflowCore).toHaveBeenCalledWith(
+ expect.objectContaining({
+ runFromBlock: {
+ startBlockId: 'start-block',
+ sourceSnapshot: sourceState,
+ sourceExecutionId: 'source-execution',
+ },
+ })
+ )
+ })
+
+ it('falls back to an untrusted client snapshot while stored run-from-block state is pending', async () => {
+ const sourceSnapshot = {
+ blockStates: { previous: { output: { value: 'cached' } } },
+ executedBlocks: ['previous'],
+ blockLogs: [],
+ decisions: { router: {}, condition: {} },
+ completedLoops: [],
+ activeExecutionPath: [],
+ resolvedSecretTraceProvenance: {
+ version: 1,
+ complete: true,
+ entries: [{ name: 'TOKEN', encryptedValue: 'untrusted-ciphertext' }],
+ },
+ }
+ queueTableRows(schemaMock.workflowExecutionLogs, [])
+ const request = createMockRequest(
+ 'POST',
+ {
+ input: { hello: 'world' },
+ runFromBlock: {
+ startBlockId: 'start-block',
+ executionId: 'source-execution',
+ sourceSnapshot,
+ },
+ },
+ {
+ 'Content-Type': 'application/json',
+ Cookie: 'session=value',
+ }
+ )
+
+ const response = await POST(request, { params: Promise.resolve({ id: 'workflow-1' }) })
+
+ expect(response.status).toBe(200)
+ expect(mockExecuteWorkflowCore).toHaveBeenCalledWith(
+ expect.objectContaining({
+ runFromBlock: {
+ startBlockId: 'start-block',
+ sourceSnapshot: expect.objectContaining({
+ blockStates: sourceSnapshot.blockStates,
+ executedBlocks: sourceSnapshot.executedBlocks,
+ }),
+ },
+ })
+ )
+ const runFromBlock = mockExecuteWorkflowCore.mock.calls[0]?.[0]?.runFromBlock
+ expect(runFromBlock).not.toHaveProperty('sourceExecutionId')
+ expect(runFromBlock?.sourceSnapshot).not.toHaveProperty('resolvedSecretTraceProvenance')
+ })
+
+ it('returns encrypted resolution provenance only to an authenticated internal tool caller', async () => {
+ const caller = EXECUTION_CALLERS[4]
+ configureExecutionCaller(caller)
+ const provenance = {
+ version: 1,
+ complete: true,
+ entries: [{ name: 'CHILD_SECRET', encryptedValue: 'encrypted-child-secret' }],
+ }
+ mockExecuteWorkflowCore.mockResolvedValueOnce({
+ success: true,
+ status: 'completed',
+ output: { ok: true },
+ executionState: { resolvedSecretTraceProvenance: provenance },
+ metadata: {
+ duration: 100,
+ startTime: '2026-01-01T00:00:00Z',
+ endTime: '2026-01-01T00:00:01Z',
+ },
+ })
+ const request = createCallerExecutionRequest(caller, undefined, 'sync')
+ request.headers.set('x-sim-request-private-tool-metadata', 'resolved-secret-provenance-v1')
+
+ const response = await POST(request, { params: Promise.resolve({ id: 'workflow-1' }) })
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('x-sim-private-tool-metadata')).toBe(
+ 'resolved-secret-provenance-v1'
+ )
+ await expect(response.json()).resolves.toMatchObject({
+ output: { ok: true },
+ __resolvedSecretTraceProvenance: provenance,
+ })
+ })
+
+ it('does not expose private provenance metadata to non-internal callers', async () => {
+ const caller = EXECUTION_CALLERS[1]
+ configureExecutionCaller(caller)
+ mockExecuteWorkflowCore.mockResolvedValueOnce({
+ success: true,
+ status: 'completed',
+ output: { ok: true },
+ executionState: {
+ resolvedSecretTraceProvenance: {
+ version: 1,
+ complete: true,
+ entries: [{ name: 'SECRET', encryptedValue: 'encrypted-secret' }],
+ },
+ },
+ metadata: {
+ duration: 100,
+ startTime: '2026-01-01T00:00:00Z',
+ endTime: '2026-01-01T00:00:01Z',
+ },
+ })
+ const request = createCallerExecutionRequest(caller, undefined, 'sync')
+ request.headers.set('x-sim-request-private-tool-metadata', 'resolved-secret-provenance-v1')
+
+ const response = await POST(request, { params: Promise.resolve({ id: 'workflow-1' }) })
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('x-sim-private-tool-metadata')).toBeNull()
+ expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance')
+ })
+
it('releases the admission reservation when enqueue proves non-acceptance', async () => {
mockEnqueue.mockRejectedValueOnce(
new AsyncJobEnqueueError('queue rejected the job', {
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts
index f56a89504e6..d9712918d9e 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.ts
@@ -58,6 +58,12 @@ import {
import { containsLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { compactBlockLogs, compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { type PreprocessExecutionSuccess, preprocessExecution } from '@/lib/execution/preprocessing'
+import {
+ PRIVATE_TOOL_METADATA_RESPONSE_HEADER,
+ RESOLVED_SECRET_PROVENANCE_FIELD,
+ RESOLVED_SECRET_PROVENANCE_METADATA_V1,
+ requestsPrivateToolMetadata,
+} from '@/lib/execution/private-tool-metadata'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import {
MAX_MCP_WORKFLOW_RESPONSE_BYTES,
@@ -114,12 +120,18 @@ import {
import { normalizeName } from '@/executor/constants'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type {
+ BlockCompletionCallbackData,
ChildWorkflowContext,
ExecutionMetadata,
IterationContext,
SerializableExecutionState,
} from '@/executor/execution/types'
-import type { BlockLog, NormalizedBlockOutput, StreamingExecution } from '@/executor/types'
+import type {
+ BlockLog,
+ ExecutionResult,
+ NormalizedBlockOutput,
+ StreamingExecution,
+} from '@/executor/types'
import { getExecutionErrorStatus, hasExecutionResult } from '@/executor/utils/errors'
import { Serializer } from '@/serializer'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
@@ -133,6 +145,31 @@ const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
+function createExecutionJsonResponse(
+ body: Record,
+ init: ResponseInit | undefined,
+ includePrivateProvenance: boolean,
+ result?: ExecutionResult
+): NextResponse {
+ if (!includePrivateProvenance) {
+ return NextResponse.json(body, init)
+ }
+
+ const headers = new Headers(init?.headers)
+ headers.set(PRIVATE_TOOL_METADATA_RESPONSE_HEADER, RESOLVED_SECRET_PROVENANCE_METADATA_V1)
+ return NextResponse.json(
+ {
+ ...body,
+ [RESOLVED_SECRET_PROVENANCE_FIELD]: result?.executionState?.resolvedSecretTraceProvenance ?? {
+ version: 1,
+ complete: false,
+ entries: [],
+ },
+ },
+ { ...init, headers }
+ )
+}
+
async function compactRoutePayload(
value: T,
context: {
@@ -573,6 +610,10 @@ async function handleExecutePost(
const isMcpBridgeRequest =
auth.authType === AuthType.INTERNAL_JWT && req.headers.get(MCP_TOOL_BRIDGE_HEADER) === 'true'
+ const includePrivateTraceProvenance =
+ auth.success &&
+ auth.authType === AuthType.INTERNAL_JWT &&
+ requestsPrivateToolMetadata(req.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1)
const useMcpBridgeAuthenticatedUserAsActor =
isMcpBridgeRequest && req.headers.get(MCP_TOOL_BRIDGE_ACTOR_HEADER) === 'authenticated-user'
@@ -682,6 +723,7 @@ async function handleExecutePost(
includeToolCalls: requestedIncludeToolCalls,
useDraftState,
input: validatedInput,
+ inputFromExecutionId,
isClientSession = false,
includeFileBase64,
base64MaxBytes,
@@ -730,6 +772,20 @@ async function handleExecutePost(
)
}
+ if (inputFromExecutionId && (isPublicApiAccess || auth.authType !== AuthType.SESSION)) {
+ return NextResponse.json(
+ { error: 'Stored execution input can only be reused by an authenticated session' },
+ { status: 403 }
+ )
+ }
+
+ if (inputFromExecutionId && validatedInput !== undefined) {
+ return NextResponse.json(
+ { error: 'Provide either input or inputFromExecutionId, not both' },
+ { status: 400 }
+ )
+ }
+
if (auth.authType === 'api_key') {
if (isClientSession) {
return NextResponse.json(
@@ -776,14 +832,7 @@ async function handleExecutePost(
)
}
- if (rawRunFromBlock.sourceSnapshot && !isPublicApiAccess) {
- // Public API callers cannot inject arbitrary block state via sourceSnapshot.
- // They must use executionId to resume from a server-stored execution state.
- resolvedRunFromBlock = {
- startBlockId: rawRunFromBlock.startBlockId,
- sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState,
- }
- } else if (rawRunFromBlock.executionId) {
+ if (rawRunFromBlock.executionId) {
const { getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId } =
await import('@/lib/workflows/executor/execution-state')
const sourceExecution =
@@ -795,17 +844,32 @@ async function handleExecutePost(
}
const snapshot = sourceExecution?.state
if (!snapshot) {
- return NextResponse.json(
- {
- error: `No execution state found for ${rawRunFromBlock.executionId === 'latest' ? 'workflow' : `execution ${rawRunFromBlock.executionId}`}. Run the full workflow first.`,
- },
- { status: 400 }
- )
+ if (rawRunFromBlock.sourceSnapshot && !isPublicApiAccess) {
+ resolvedRunFromBlock = {
+ startBlockId: rawRunFromBlock.startBlockId,
+ sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState,
+ }
+ } else {
+ return NextResponse.json(
+ {
+ error: `No execution state found for ${rawRunFromBlock.executionId === 'latest' ? 'workflow' : `execution ${rawRunFromBlock.executionId}`}. Run the full workflow first.`,
+ },
+ { status: 400 }
+ )
+ }
+ } else {
+ resolvedRunFromBlock = {
+ startBlockId: rawRunFromBlock.startBlockId,
+ sourceSnapshot: snapshot,
+ sourceExecutionId: sourceExecution.executionId,
+ }
}
+ } else if (rawRunFromBlock.sourceSnapshot && !isPublicApiAccess) {
+ // Public API callers cannot inject arbitrary block state via sourceSnapshot.
+ // They must use executionId to resume from a server-stored execution state.
resolvedRunFromBlock = {
startBlockId: rawRunFromBlock.startBlockId,
- sourceSnapshot: snapshot,
- sourceExecutionId: sourceExecution.executionId,
+ sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState,
}
} else {
return NextResponse.json(
@@ -817,7 +881,7 @@ async function handleExecutePost(
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
// For session auth, the input is explicitly provided in the input field
- const input = isMcpBridgeRequest
+ let input = isMcpBridgeRequest
? validatedInput
: isPublicApiAccess ||
auth.authType === AuthType.API_KEY ||
@@ -828,6 +892,7 @@ async function handleExecutePost(
triggerType,
stream,
useDraftState,
+ inputFromExecutionId: _inputFromExecutionId,
includeFileBase64,
base64MaxBytes,
workflowStateOverride,
@@ -962,6 +1027,20 @@ async function handleExecutePost(
)
}
+ if (inputFromExecutionId) {
+ const { getExecutionInputForWorkflow } = await import(
+ '@/lib/workflows/executor/execution-state'
+ )
+ const sourceExecution = await getExecutionInputForWorkflow(inputFromExecutionId, workflowId)
+ if (!sourceExecution.found) {
+ return NextResponse.json(
+ { error: 'Source workflow execution was not found' },
+ { status: 404 }
+ )
+ }
+ input = sourceExecution.input
+ }
+
const upstreamBillingAttribution =
auth.authType === AuthType.INTERNAL_JWT && workflowAuthorization.workflow?.workspaceId
? requireBillingAttributionHeader(req.headers, {
@@ -1287,7 +1366,7 @@ async function handleExecutePost(
workflowResponseCompaction
)
- return NextResponse.json(
+ return createExecutionJsonResponse(
{
success: false,
output: compactResultOutput,
@@ -1300,7 +1379,9 @@ async function handleExecutePost(
}
: undefined,
},
- { status: 408 }
+ { status: 408 },
+ includePrivateTraceProvenance,
+ result
)
}
@@ -1367,7 +1448,12 @@ async function handleExecutePost(
: undefined,
}
- return NextResponse.json(filteredResult)
+ return createExecutionJsonResponse(
+ filteredResult,
+ undefined,
+ includePrivateTraceProvenance,
+ result
+ )
} catch (error: unknown) {
const errorMessage = getErrorMessage(error, 'Unknown error')
@@ -1405,7 +1491,7 @@ async function handleExecutePost(
throw compactError
}
}
- return NextResponse.json(
+ return createExecutionJsonResponse(
{
success: false,
output: compactErrorOutput,
@@ -1418,7 +1504,9 @@ async function handleExecutePost(
}
: undefined,
},
- { status }
+ { status },
+ includePrivateTraceProvenance,
+ executionResult
)
} finally {
requestAbort.cleanup()
@@ -1663,7 +1751,7 @@ async function handleExecutePost(
blockId: string,
blockName: string,
blockType: string,
- callbackData: any,
+ callbackData: BlockCompletionCallbackData,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => {
@@ -1686,7 +1774,13 @@ async function handleExecutePost(
preserveRoot: true,
}),
}
- const hasError = compactCallbackData.output?.error
+ const callbackError = compactCallbackData.output?.error
+ const hasError = typeof callbackError === 'string' && callbackError.length > 0
+ const display = await loggingSession.projectDisplayContent({
+ input: compactCallbackData.input,
+ output: compactCallbackData.output,
+ ...(hasError ? { error: callbackError } : {}),
+ })
const childWorkflowData = childWorkflowContext
? {
childWorkflowBlockId: childWorkflowContext.parentBlockId,
@@ -1703,7 +1797,7 @@ async function handleExecutePost(
blockId,
blockName,
blockType,
- error: compactCallbackData.output.error,
+ error: display.error,
})
await sendEvent({
type: 'block:error',
@@ -1715,7 +1809,12 @@ async function handleExecutePost(
blockName,
blockType,
input: compactCallbackData.input,
- error: compactCallbackData.output.error,
+ error: callbackError,
+ display: {
+ ...(Object.hasOwn(display, 'input') ? { input: display.input } : {}),
+ ...(display.error !== undefined ? { error: display.error } : {}),
+ ...(display.clearLiveDisplay ? { clearLiveDisplay: true as const } : {}),
+ },
durationMs: compactCallbackData.executionTime || 0,
startedAt: compactCallbackData.startedAt,
executionOrder: compactCallbackData.executionOrder,
@@ -1750,6 +1849,11 @@ async function handleExecutePost(
blockType,
input: compactCallbackData.input,
output: compactCallbackData.output,
+ display: {
+ ...(Object.hasOwn(display, 'input') ? { input: display.input } : {}),
+ ...(Object.hasOwn(display, 'output') ? { output: display.output } : {}),
+ ...(display.clearLiveDisplay ? { clearLiveDisplay: true as const } : {}),
+ },
durationMs: compactCallbackData.executionTime || 0,
startedAt: compactCallbackData.startedAt,
executionOrder: compactCallbackData.executionOrder,
@@ -1786,6 +1890,7 @@ async function handleExecutePost(
workflowId,
sendEvent,
forwardAnswerText: answerTextFromSink,
+ projectDisplay: (field, value) => loggingSession.projectLiveDisplayText(field, value),
})
const reader = streamingExec.stream.getReader()
@@ -1807,12 +1912,13 @@ async function handleExecutePost(
if (answerTextFromSink) continue
const chunk = decoder.decode(value, { stream: true })
+ const display = await loggingSession.projectLiveDisplayText('chunk', chunk)
await sendEvent({
type: 'stream:chunk',
timestamp: new Date().toISOString(),
executionId,
workflowId,
- data: { blockId, chunk },
+ data: { blockId, chunk, display },
})
}
@@ -1931,7 +2037,10 @@ async function handleExecutePost(
* object storage, so doing it twice would double the latency and storage
* load on the happy path.
*/
- const compactedBlockLogs = await compactBlockLogs(result.logs, {
+ const displayBlockLogs = await loggingSession.projectBlockLogsForDisplay(
+ result.logs ?? []
+ )
+ const compactedBlockLogs = await compactBlockLogs(displayBlockLogs, {
workspaceId,
workflowId,
executionId,
@@ -1947,6 +2056,9 @@ async function handleExecutePost(
})
await loggingSession.markAsFailed(timeoutErrorMessage)
+ const timeoutDisplay = await loggingSession.projectDisplayContent({
+ error: timeoutErrorMessage,
+ })
finalMetaStatus = 'error'
await sendEvent(
@@ -1957,6 +2069,11 @@ async function handleExecutePost(
workflowId,
data: {
error: timeoutErrorMessage,
+ display: {
+ ...(timeoutDisplay.error !== undefined
+ ? { error: timeoutDisplay.error }
+ : {}),
+ },
duration: result.metadata?.duration || 0,
finalBlockLogs: compactedBlockLogs,
},
@@ -2061,13 +2178,16 @@ async function handleExecutePost(
let compactErrorLogs: BlockLog[] | undefined
try {
compactErrorLogs = executionResult?.logs
- ? await compactBlockLogs(executionResult.logs, {
- workspaceId,
- workflowId,
- executionId,
- userId: actorUserId,
- requireDurable: true,
- })
+ ? await compactBlockLogs(
+ await loggingSession.projectBlockLogsForDisplay(executionResult.logs),
+ {
+ workspaceId,
+ workflowId,
+ executionId,
+ userId: actorUserId,
+ requireDurable: true,
+ }
+ )
: undefined
} catch (compactionError) {
reqLogger.warn('Failed to compact SSE error logs, omitting oversized error details', {
@@ -2076,6 +2196,10 @@ async function handleExecutePost(
}
finalMetaStatus = 'error'
+ const terminalError = executionResult?.error || errorMessage
+ const terminalDisplay = await loggingSession.projectDisplayContent({
+ error: terminalError,
+ })
await sendEvent(
{
type: 'execution:error',
@@ -2083,7 +2207,10 @@ async function handleExecutePost(
executionId,
workflowId,
data: {
- error: executionResult?.error || errorMessage,
+ error: terminalError,
+ display: {
+ ...(terminalDisplay.error !== undefined ? { error: terminalDisplay.error } : {}),
+ },
duration: executionResult?.metadata?.duration || 0,
finalBlockLogs: compactErrorLogs,
},
diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts
index d3ff2d2a8a6..d7923d662bc 100644
--- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts
+++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts
@@ -9,6 +9,12 @@ import {
} from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ collectFunctionalBlockOutputs,
+ FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE,
+ type FunctionalExecutionDataSource,
+ FunctionalOutputsUnavailableError,
+} from '@/lib/logs/execution/functional-outputs'
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
@@ -18,32 +24,10 @@ const logger = createLogger('WorkflowExecutionStatusAPI')
type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
-interface TraceSpanShape {
- blockId?: string
- output?: Record
- children?: TraceSpanShape[]
-}
-
-interface ExecutionDataShape {
+interface ExecutionDataShape extends FunctionalExecutionDataSource {
finalOutput?: { error?: string } & Record
error?: { message?: string } | string
completionFailure?: string
- traceSpans?: TraceSpanShape[]
-}
-
-function collectBlockOutputs(spans: TraceSpanShape[] | undefined): Map {
- const map = new Map()
- const visit = (list?: TraceSpanShape[]): void => {
- if (!list) return
- for (const span of list) {
- if (span.blockId && span.output !== undefined && !map.has(span.blockId)) {
- map.set(span.blockId, span.output)
- }
- if (span.children) visit(span.children)
- }
- }
- visit(spans)
- return map
}
function resolvePath(value: unknown, path: string[]): unknown {
@@ -204,10 +188,23 @@ export const GET = withRouteHandler(
? (executionData.finalOutput ?? null)
: null
- const blockOutputs =
- selectedOutputs.length > 0
- ? pickSelectedOutputs(selectedOutputs, collectBlockOutputs(executionData?.traceSpans))
- : null
+ let blockOutputs: Record | null = null
+ if (selectedOutputs.length > 0) {
+ try {
+ blockOutputs = pickSelectedOutputs(
+ selectedOutputs,
+ collectFunctionalBlockOutputs(executionData)
+ )
+ } catch (error) {
+ if (error instanceof FunctionalOutputsUnavailableError) {
+ return NextResponse.json(
+ { error: FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE },
+ { status: 409 }
+ )
+ }
+ throw error
+ }
+ }
const response: WorkflowExecutionStatusResponse = {
executionId: logRow.executionId,
diff --git a/apps/sim/app/api/workflows/[id]/log/route.test.ts b/apps/sim/app/api/workflows/[id]/log/route.test.ts
index 9b6c35578d5..98dc6ac9631 100644
--- a/apps/sim/app/api/workflows/[id]/log/route.test.ts
+++ b/apps/sim/app/api/workflows/[id]/log/route.test.ts
@@ -13,6 +13,7 @@ const {
mockResolveBillingAttribution,
mockAssertBillingAttributionSnapshot,
mockStart,
+ mockSetResolvedSecretTraceRegistry,
mockSafeComplete,
mockSafeCompleteWithError,
} = vi.hoisted(() => ({
@@ -21,6 +22,7 @@ const {
mockResolveBillingAttribution: vi.fn(),
mockAssertBillingAttributionSnapshot: vi.fn((value) => value),
mockStart: vi.fn().mockResolvedValue(undefined),
+ mockSetResolvedSecretTraceRegistry: vi.fn(),
mockSafeComplete: vi.fn().mockResolvedValue(undefined),
mockSafeCompleteWithError: vi.fn().mockResolvedValue(undefined),
}))
@@ -42,6 +44,7 @@ vi.mock('@/lib/logs/execution/logging-session', () => ({
LoggingSession: vi.fn(function LoggingSession() {
return {
start: mockStart,
+ setResolvedSecretTraceRegistry: mockSetResolvedSecretTraceRegistry,
markAsFailed: vi.fn().mockResolvedValue(undefined),
safeCompleteWithError: mockSafeCompleteWithError,
safeComplete: mockSafeComplete,
@@ -307,4 +310,104 @@ describe('POST /api/workflows/[id]/log completion attribution', () => {
)
expect(mockSafeComplete).toHaveBeenCalledOnce()
})
+
+ it('restores trusted Secrets provenance before projecting legacy completion traces', async () => {
+ const trustedExecutionState = {
+ blockStates: { 'function-1': { output: { result: 'raw-secret-value' } } },
+ executedBlocks: ['function-1'],
+ blockLogs: [],
+ decisions: { router: {}, condition: {} },
+ completedLoops: [],
+ activeExecutionPath: ['function-1'],
+ resolvedSecretTraceProvenance: { version: 1, complete: true, entries: [] },
+ }
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ workflowId: OWNER_WORKFLOW_ID,
+ workspaceId: 'workspace-1',
+ executionData: {
+ billingAttribution: storedBillingAttribution,
+ executionState: trustedExecutionState,
+ },
+ },
+ ])
+
+ const res = await POST(
+ makeRequest(OWNER_WORKFLOW_ID, {
+ executionId: 'trusted-provenance-execution-id',
+ result: validResult,
+ }),
+ { params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
+ )
+
+ expect(res.status).toBe(200)
+ const registry = mockSetResolvedSecretTraceRegistry.mock.calls[0]?.[0]
+ expect(registry?.isComplete()).toBe(true)
+ expect(registry?.exportProvenance()).toEqual({
+ version: 1,
+ complete: true,
+ entries: [],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ })
+ expect(mockSafeComplete).toHaveBeenCalledWith(
+ expect.objectContaining({ executionState: trustedExecutionState })
+ )
+ })
+
+ it('forwards trusted execution state through legacy error completion', async () => {
+ const trustedExecutionState = {
+ blockStates: { 'function-1': { output: { error: 'raw-secret-value' } } },
+ executedBlocks: ['function-1'],
+ blockLogs: [],
+ decisions: { router: {}, condition: {} },
+ completedLoops: [],
+ activeExecutionPath: ['function-1'],
+ resolvedSecretTraceProvenance: { version: 1, complete: true, entries: [] },
+ }
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ workflowId: OWNER_WORKFLOW_ID,
+ workspaceId: 'workspace-1',
+ executionData: {
+ billingAttribution: storedBillingAttribution,
+ executionState: trustedExecutionState,
+ },
+ },
+ ])
+
+ const res = await POST(
+ makeRequest(OWNER_WORKFLOW_ID, {
+ executionId: 'trusted-error-execution-id',
+ result: { success: false, error: 'failed' },
+ }),
+ { params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
+ )
+
+ expect(res.status).toBe(200)
+ expect(mockSafeCompleteWithError).toHaveBeenCalledWith(
+ expect.objectContaining({ executionState: trustedExecutionState })
+ )
+ })
+
+ it('forces structural-only traces when trusted stored provenance is unavailable', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ workflowId: OWNER_WORKFLOW_ID,
+ workspaceId: 'workspace-1',
+ executionData: { billingAttribution: storedBillingAttribution },
+ },
+ ])
+
+ const res = await POST(
+ makeRequest(OWNER_WORKFLOW_ID, {
+ executionId: 'legacy-no-provenance-execution-id',
+ result: validResult,
+ }),
+ { params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
+ )
+
+ expect(res.status).toBe(200)
+ const registry = mockSetResolvedSecretTraceRegistry.mock.calls[0]?.[0]
+ expect(registry?.isComplete()).toBe(false)
+ })
})
diff --git a/apps/sim/app/api/workflows/[id]/log/route.ts b/apps/sim/app/api/workflows/[id]/log/route.ts
index f79888ca363..8e5123504ed 100644
--- a/apps/sim/app/api/workflows/[id]/log/route.ts
+++ b/apps/sim/app/api/workflows/[id]/log/route.ts
@@ -13,9 +13,12 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
+import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
+import type { SerializableExecutionState } from '@/executor/execution/types'
import type { ExecutionResult } from '@/executor/types'
+import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
const logger = createLogger('WorkflowLogAPI')
@@ -119,6 +122,31 @@ export const POST = withRouteHandler(
const isChatExecution = result.metadata?.source === 'chat'
const triggerType = isChatExecution ? 'chat' : 'manual'
const loggingSession = new LoggingSession(id, executionId, triggerType, requestId)
+ const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([], {
+ userId: actorUserId,
+ workspaceId: existingLog.workspaceId,
+ })
+ const trustedExecutionData = await materializeExecutionData(
+ existingLog.executionData as Record,
+ {
+ workspaceId: existingLog.workspaceId,
+ workflowId: existingLog.workflowId,
+ executionId,
+ }
+ )
+ const trustedExecutionState =
+ trustedExecutionData.executionState &&
+ typeof trustedExecutionData.executionState === 'object' &&
+ !Array.isArray(trustedExecutionData.executionState)
+ ? (trustedExecutionData.executionState as SerializableExecutionState)
+ : undefined
+ const trustedProvenance = trustedExecutionState?.resolvedSecretTraceProvenance
+ if (trustedProvenance === undefined) {
+ resolvedSecretTraceRegistry.markIncomplete()
+ } else {
+ await resolvedSecretTraceRegistry.importProvenance(trustedProvenance, { trusted: true })
+ }
+ loggingSession.setResolvedSecretTraceRegistry(resolvedSecretTraceRegistry)
await loggingSession.start({
userId: actorUserId,
@@ -143,6 +171,7 @@ export const POST = withRouteHandler(
totalDurationMs: totalDuration || result.metadata?.duration || 0,
error: { message },
traceSpans,
+ executionState: trustedExecutionState,
})
} else {
await loggingSession.safeComplete({
@@ -150,6 +179,7 @@ export const POST = withRouteHandler(
totalDurationMs: totalDuration || result.metadata?.duration || 0,
finalOutput: result.output || {},
traceSpans,
+ executionState: trustedExecutionState,
})
}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
index f161f672f78..7bf6142e139 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
@@ -72,8 +72,6 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
import { getBlock } from '@/blocks/registry'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import {
- fetchLogDetail,
- logKeys,
prefetchLogDetail,
useCancelExecution,
useDashboardStats,
@@ -90,7 +88,6 @@ import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { Dashboard, ExecutionSnapshot, LogDetails, LogRowContextMenu } from './components'
import {
DELETED_WORKFLOW_LABEL,
- extractRetryInput,
formatDate,
getDisplayStatus,
type LogStatus,
@@ -573,17 +570,11 @@ export default function Logs() {
const retryLog = useCallback(
async (log: WorkflowLogRow | null) => {
const workflowId = log?.workflow?.id || log?.workflowId
- const logId = log?.id
- if (!workflowId || !logId) return
+ const executionId = log?.executionId
+ if (!workflowId || !executionId) return
try {
- const detailLog = await queryClient.fetchQuery({
- queryKey: logKeys.detail(workspaceId, logId),
- queryFn: ({ signal }) => fetchLogDetail(logId, workspaceId, signal),
- staleTime: 30 * 1000,
- })
- const input = extractRetryInput(detailLog)
- await retryExecution.mutateAsync({ workflowId, input })
+ await retryExecution.mutateAsync({ workflowId, executionId })
toast.success('Retry started')
} catch {
toast.error('Failed to retry execution')
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
index cadb003f19a..fbffba81702 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
@@ -2,7 +2,6 @@ import React from 'react'
import { Badge } from '@sim/emcn'
import { formatDuration, formatRelativeTime } from '@sim/utils/formatting'
import { format } from 'date-fns'
-import type { WorkflowLogDetail } from '@/lib/api/contracts/logs'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
@@ -213,37 +212,3 @@ export const formatDate = (dateString: string) => {
relative: formatRelativeTime(dateString),
}
}
-
-/**
- * Extracts the original workflow input from a log entry for retry.
- * Prefers the persisted `workflowInput` field (new logs), falls back to
- * reconstructing from `executionState.blockStates` (old logs).
- */
-export function extractRetryInput(log: WorkflowLogDetail): unknown | undefined {
- const execData = log.executionData
- if (!execData) return undefined
-
- if (execData.workflowInput !== undefined) {
- return execData.workflowInput
- }
-
- const executionState = (execData as Record).executionState as
- | {
- blockStates?: Record<
- string,
- { output?: unknown; executed?: boolean; executionTime?: number }
- >
- }
- | undefined
- if (!executionState?.blockStates) return undefined
-
- // Starter/trigger blocks are pre-populated with executed: false and
- // executionTime: 0, which distinguishes them from blocks that actually ran.
- for (const state of Object.values(executionState.blockStates)) {
- if (state.executed === false && state.executionTime === 0 && state.output != null) {
- return state.output
- }
- }
-
- return undefined
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx
index f62015495af..3ed0c144334 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx
@@ -10,8 +10,11 @@ const {
DirectUploadErrorMock,
executionStoreState,
mockExecute,
+ mockExecuteFromBlock,
mockFetch,
+ mockResolveStartCandidates,
mockRunUploadStrategy,
+ mockSelectBestTrigger,
terminalStoreState,
workflowBlocks,
workflowStoreState,
@@ -50,7 +53,7 @@ const {
const executionStoreState = {
workflowExecutions: new Map([['workflow-1', idleExecution]]),
getWorkflowExecution: vi.fn(() => idleExecution),
- getCurrentExecutionId: vi.fn(() => null),
+ getCurrentExecutionId: vi.fn<() => string | null>(() => null),
getLastExecutionSnapshot: vi.fn(() => null),
setCurrentExecutionId: vi.fn(),
setIsExecuting: vi.fn(),
@@ -88,8 +91,11 @@ const {
DirectUploadErrorMock,
executionStoreState,
mockExecute: vi.fn(),
+ mockExecuteFromBlock: vi.fn(),
mockFetch: vi.fn(),
+ mockResolveStartCandidates: vi.fn(),
mockRunUploadStrategy: vi.fn(),
+ mockSelectBestTrigger: vi.fn(),
terminalStoreState,
workflowBlocks,
workflowStoreState,
@@ -133,12 +139,12 @@ vi.mock('@/lib/workflows/input-format', () => ({
vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({
extractTriggerMockPayload: () => ({}),
- selectBestTrigger: () => [],
+ selectBestTrigger: mockSelectBestTrigger,
triggerNeedsMockPayload: () => false,
}))
vi.mock('@/lib/workflows/triggers/triggers', () => ({
- resolveStartCandidates: () => [],
+ resolveStartCandidates: mockResolveStartCandidates,
StartBlockPath: {
SPLIT_API: 'split-api',
SPLIT_INPUT: 'split-input',
@@ -206,7 +212,7 @@ vi.mock('@/hooks/use-execution-stream', () => {
SSEStreamInterruptedError,
useExecutionStream: () => ({
execute: mockExecute,
- executeFromBlock: vi.fn(),
+ executeFromBlock: mockExecuteFromBlock,
reconnect: vi.fn(),
cancel: vi.fn(),
cancelExecute: vi.fn(),
@@ -344,6 +350,9 @@ async function drainStream(value: unknown): Promise {
describe('useWorkflowExecution attachment uploads', () => {
beforeEach(() => {
vi.clearAllMocks()
+ executionStoreState.getCurrentExecutionId.mockReturnValue(null)
+ mockResolveStartCandidates.mockReturnValue([])
+ mockSelectBestTrigger.mockReturnValue([])
vi.stubGlobal('fetch', mockFetch)
mockRunUploadStrategy.mockRejectedValue(
new DirectUploadErrorMock('Server signaled fallback to API upload', 'FALLBACK_REQUIRED')
@@ -355,6 +364,8 @@ describe('useWorkflowExecution attachment uploads', () => {
})
)
mockExecute.mockResolvedValue(undefined)
+ mockExecuteFromBlock.mockResolvedValue(undefined)
+ workflowStoreState.edges.length = 0
})
afterEach(() => {
@@ -470,4 +481,174 @@ describe('useWorkflowExecution attachment uploads', () => {
unmount()
})
+
+ it('uses only projected live thinking without changing normal settle behavior', async () => {
+ mockExecute.mockImplementationOnce(async (options) => {
+ options.onExecutionId?.('execution-1')
+ await options.callbacks?.onStreamThinking?.({
+ blockId: 'agent-1',
+ text: 'sk-resolved-secret',
+ display: { text: '{{OPENAI_API_KEY}}' },
+ })
+ await options.callbacks?.onStreamDone?.({ blockId: 'agent-1' })
+ await options.callbacks?.onBlockCompleted?.({
+ blockId: 'agent-1',
+ blockName: 'Agent 1',
+ blockType: 'agent',
+ executionOrder: 1,
+ output: { content: 'sk-resolved-secret' },
+ display: { output: { content: '{{OPENAI_API_KEY}}' } },
+ durationMs: 10,
+ startedAt: '2026-07-31T00:00:00.000Z',
+ endedAt: '2026-07-31T00:00:00.010Z',
+ })
+ })
+
+ const { result, unmount } = renderWorkflowExecutionHook()
+
+ await act(async () => {
+ const runResult = await result().handleRunWorkflow({ input: 'chat input' })
+ await drainStream(runResult)
+ })
+
+ expect(terminalStoreState.updateConsole).toHaveBeenCalledWith(
+ 'agent-1',
+ expect.objectContaining({ agentStreamThinking: '{{OPENAI_API_KEY}}' }),
+ 'execution-1'
+ )
+ expect(terminalStoreState.updateConsole).not.toHaveBeenCalledWith(
+ 'agent-1',
+ expect.objectContaining({ clearAgentStreamThinking: true }),
+ 'execution-1'
+ )
+ expect(JSON.stringify(terminalStoreState.updateConsole.mock.calls)).not.toContain(
+ 'sk-resolved-secret'
+ )
+
+ unmount()
+ })
+
+ it('preserves legacy live thinking when no display projection field is sent', async () => {
+ mockExecute.mockImplementationOnce(async (options) => {
+ options.onExecutionId?.('execution-1')
+ await options.callbacks?.onStreamThinking?.({
+ blockId: 'agent-1',
+ text: 'sk-resolved-secret',
+ })
+ await options.callbacks?.onStreamDone?.({ blockId: 'agent-1' })
+ })
+
+ const { result, unmount } = renderWorkflowExecutionHook()
+
+ await act(async () => {
+ const runResult = await result().handleRunWorkflow({ input: 'chat input' })
+ await drainStream(runResult)
+ })
+
+ expect(terminalStoreState.updateConsole).toHaveBeenCalledWith(
+ 'agent-1',
+ expect.objectContaining({ agentStreamThinking: 'sk-resolved-secret' }),
+ 'execution-1'
+ )
+
+ unmount()
+ })
+
+ it('clears live thinking when the server sends an empty display projection', async () => {
+ mockExecute.mockImplementationOnce(async (options) => {
+ options.onExecutionId?.('execution-1')
+ await options.callbacks?.onStreamThinking?.({
+ blockId: 'agent-1',
+ text: 'sk-resolved-secret',
+ display: {},
+ })
+ })
+
+ const { result, unmount } = renderWorkflowExecutionHook()
+
+ await act(async () => {
+ const runResult = await result().handleRunWorkflow({ input: 'chat input' })
+ await drainStream(runResult)
+ })
+
+ expect(terminalStoreState.updateConsole).toHaveBeenCalledWith(
+ 'agent-1',
+ { clearAgentStreamThinking: true },
+ 'execution-1'
+ )
+ expect(JSON.stringify(terminalStoreState.updateConsole.mock.calls)).not.toContain(
+ 'sk-resolved-secret'
+ )
+
+ unmount()
+ })
+
+ it('keeps the trusted execution ID when storing a run-until-block snapshot', async () => {
+ const startCandidate = {
+ blockId: 'start',
+ block: workflowBlocks.start,
+ path: 'legacy-starter',
+ }
+ mockResolveStartCandidates.mockReturnValue([startCandidate])
+ mockSelectBestTrigger.mockReturnValue([startCandidate])
+ mockExecute.mockImplementationOnce(async (options) => {
+ const executionId = options.executionId as string
+ executionStoreState.getCurrentExecutionId.mockReturnValue(executionId)
+ options.onExecutionId?.(executionId)
+ await options.callbacks?.onExecutionCompleted?.({
+ success: true,
+ output: {},
+ duration: 10,
+ startTime: '2026-07-31T00:00:00.000Z',
+ endTime: '2026-07-31T00:00:00.010Z',
+ finalBlockLogs: [],
+ })
+ })
+
+ const { result, unmount } = renderWorkflowExecutionHook()
+
+ await act(async () => {
+ await result().handleRunUntilBlock('function-1', 'workflow-1')
+ })
+
+ const executionId = mockExecute.mock.calls[0]?.[0]?.executionId
+ expect(executionId).toEqual(expect.any(String))
+ expect(executionStoreState.setLastExecutionSnapshot).toHaveBeenCalledWith(
+ 'workflow-1',
+ expect.objectContaining({ sourceExecutionId: executionId })
+ )
+
+ unmount()
+ })
+
+ it('sends the snapshot as a fallback with a trusted run-from-block execution ID', async () => {
+ const sourceSnapshot = {
+ blockStates: { start: { output: { value: 'ready' } } },
+ executedBlocks: ['start'],
+ blockLogs: [],
+ decisions: { router: {}, condition: {} },
+ completedLoops: [],
+ activeExecutionPath: ['start'],
+ sourceExecutionId: 'source-execution-1',
+ }
+ executionStoreState.getLastExecutionSnapshot.mockReturnValueOnce(sourceSnapshot)
+ workflowStoreState.edges.push({ source: 'start', target: 'function-1' } as never)
+
+ const { result, unmount } = renderWorkflowExecutionHook()
+
+ await act(async () => {
+ await result().handleRunFromBlock('function-1', 'workflow-1')
+ })
+
+ expect(mockExecuteFromBlock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workflowId: 'workflow-1',
+ startBlockId: 'function-1',
+ sourceExecutionId: 'source-execution-1',
+ sourceSnapshot,
+ })
+ )
+
+ unmount()
+ })
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
index 7c708170f14..201e9b62207 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
@@ -16,6 +16,7 @@ import {
} from '@/components/agent-stream/tool-call-lifecycle'
import { requestJson } from '@/lib/api/client/request'
import { cancelWorkflowExecutionContract, workflowLogContract } from '@/lib/api/contracts/workflows'
+import type { SecretSafeBlockLog } from '@/lib/logs/execution/display-types'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import type {
@@ -103,6 +104,20 @@ interface DebugValidationResult {
const WORKFLOW_EXECUTION_FAILURE_MESSAGE = 'Workflow execution failed'
+function getExecutionDisplayError(data: unknown): {
+ displayError?: string
+ hasDisplayProjection: boolean
+} {
+ if (!data || typeof data !== 'object' || !Object.hasOwn(data, 'display')) {
+ return { hasDisplayProjection: false }
+ }
+ const display = (data as { display?: { error?: unknown } }).display
+ return {
+ hasDisplayProjection: true,
+ ...(typeof display?.error === 'string' ? { displayError: display.error } : {}),
+ }
+}
+
async function persistExecutionPointerProgress(
workflowId: string,
executionId: string,
@@ -261,6 +276,16 @@ function createAgentStreamChrome({ executionIdRef, updateConsole }: AgentStreamC
)
}
+ const clearThinking = (blockId: string) => {
+ const timer = thinkingFlushTimers.get(blockId)
+ if (timer !== undefined) {
+ clearTimeout(timer)
+ thinkingFlushTimers.delete(blockId)
+ }
+ thinkingByBlock.delete(blockId)
+ updateConsole(blockId, { clearAgentStreamThinking: true }, executionIdRef.current)
+ }
+
const settleBlock = (blockId: string, status: 'success' | 'error' | 'cancelled') => {
flushThinking(blockId)
const map = toolCallsByBlock.get(blockId)
@@ -288,8 +313,21 @@ function createAgentStreamChrome({ executionIdRef, updateConsole }: AgentStreamC
}
const onStreamThinking = (data: StreamThinkingData) => {
+ const display = (
+ data as StreamThinkingData & {
+ display?: { text?: string; clearLiveDisplay?: true }
+ }
+ ).display
+ const hasDisplayProjection = Object.hasOwn(data, 'display')
+ const text = hasDisplayProjection ? display?.text : data.text
+ if (display?.clearLiveDisplay || (hasDisplayProjection && typeof text !== 'string')) {
+ clearThinking(data.blockId)
+ return
+ }
+ if (!text) return
+
const prev = thinkingByBlock.get(data.blockId) ?? ''
- thinkingByBlock.set(data.blockId, prev + data.text)
+ thinkingByBlock.set(data.blockId, prev + text)
if (!thinkingFlushTimers.has(data.blockId)) {
thinkingFlushTimers.set(
data.blockId,
@@ -334,7 +372,14 @@ function createAgentStreamChrome({ executionIdRef, updateConsole }: AgentStreamC
settleBlock(data.blockId, 'success')
}
- return { flushThinking, settleBlock, settleAll, onStreamThinking, onStreamTool, onStreamDone }
+ return {
+ flushThinking,
+ settleBlock,
+ settleAll,
+ onStreamThinking,
+ onStreamTool,
+ onStreamDone,
+ }
}
export function useWorkflowExecution() {
@@ -464,10 +509,12 @@ export function useWorkflowExecution() {
workflowId?: string
executionId?: string
error?: string
+ displayError?: string
+ hasDisplayProjection?: boolean
durationMs?: number
blockLogs: BlockLog[]
isPreExecutionError?: boolean
- finalBlockLogs?: BlockLog[]
+ finalBlockLogs?: SecretSafeBlockLog[]
}) => {
if (!params.workflowId) return
sharedHandleExecutionErrorConsole(
@@ -483,7 +530,7 @@ export function useWorkflowExecution() {
workflowId?: string
executionId?: string
durationMs?: number
- finalBlockLogs?: BlockLog[]
+ finalBlockLogs?: SecretSafeBlockLog[]
}) => {
if (!params.workflowId) return
sharedHandleExecutionCancelledConsole(
@@ -862,27 +909,7 @@ export function useWorkflowExecution() {
streamedContent.set(id, chunks.join(''))
}
- // Update streamed content and apply tokenization
if (result.logs) {
- result.logs.forEach((log: BlockLog) => {
- if (streamedContent.has(log.blockId)) {
- // For console display, show the actual structured block output instead of formatted streaming content
- // This ensures console logs match the block state structure
- // Use replaceOutput to completely replace the output instead of merging
- // Use the executionId from this execution context
- useTerminalConsoleStore.getState().updateConsole(
- log.blockId,
- {
- executionOrder: log.executionOrder,
- replaceOutput: log.output,
- success: true,
- },
- executionId
- )
- }
- })
-
- // Process all logs for streaming tokenization
const processedCount = processStreamingBlockLogs(result.logs, streamedContent)
logger.info(`Processed ${processedCount} blocks for streaming tokenization`)
}
@@ -1208,7 +1235,6 @@ export function useWorkflowExecution() {
const activeBlockRefCounts = new Map()
const streamedChunks = new Map()
const agentStreamChrome = createAgentStreamChrome({ executionIdRef, updateConsole })
- const settleAgentStreamChrome = agentStreamChrome.settleBlock
const settleAllAgentStreamChrome = agentStreamChrome.settleAll
const accumulatedBlockLogs: BlockLog[] = []
const accumulatedBlockStates = new Map()
@@ -1282,8 +1308,7 @@ export function useWorkflowExecution() {
onBlockStarted: blockHandlers.onBlockStarted,
onBlockCompleted: blockHandlers.onBlockCompleted,
onBlockError: (data) => {
- // Failures often skip stream:done — settle thinking/tool chrome here.
- settleAgentStreamChrome(data.blockId, 'error')
+ agentStreamChrome.settleBlock(data.blockId, 'error')
blockHandlers.onBlockError(data)
},
onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted,
@@ -1388,6 +1413,7 @@ export function useWorkflowExecution() {
decisions: existingSnapshot?.decisions || { router: {}, condition: {} },
completedLoops: existingSnapshot?.completedLoops || [],
activeExecutionPath: Array.from(mergedExecutedBlocks),
+ sourceExecutionId: executionIdRef.current,
}
setLastExecutionSnapshot(activeWorkflowId, snapshot)
logger.info('Merged execution snapshot after run-until-block', {
@@ -1403,6 +1429,7 @@ export function useWorkflowExecution() {
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: Array.from(executedBlockIds),
+ sourceExecutionId: executionIdRef.current,
}
setLastExecutionSnapshot(activeWorkflowId, snapshot)
logger.info('Stored execution snapshot for run-from-block', {
@@ -1508,6 +1535,7 @@ export function useWorkflowExecution() {
workflowId: activeWorkflowId,
executionId: executionIdRef.current,
error: data.error,
+ ...getExecutionDisplayError(data),
durationMs: data.duration,
blockLogs: accumulatedBlockLogs,
isPreExecutionError,
@@ -1927,6 +1955,7 @@ export function useWorkflowExecution() {
const effectiveSnapshot: SerializableExecutionState = isTriggerBlock
? emptySnapshot
: snapshot || emptySnapshot
+ const sourceExecutionId = isTriggerBlock ? undefined : effectiveSnapshot.sourceExecutionId
// Extract mock payload for trigger blocks
let workflowInput: any
@@ -2009,6 +2038,7 @@ export function useWorkflowExecution() {
workflowId,
startBlockId: blockId,
sourceSnapshot: effectiveSnapshot,
+ ...(sourceExecutionId ? { sourceExecutionId } : {}),
input: workflowInput,
onExecutionId: (id) => {
if (runFromBlockOwnerRef.current !== runOwnerId) return
@@ -2031,7 +2061,6 @@ export function useWorkflowExecution() {
onBlockStarted: blockHandlers.onBlockStarted,
onBlockCompleted: blockHandlers.onBlockCompleted,
onBlockError: (data) => {
- // Failures often skip stream:done — settle thinking/tool chrome here.
agentStreamChrome.settleBlock(data.blockId, 'error')
blockHandlers.onBlockError(data)
},
@@ -2065,6 +2094,7 @@ export function useWorkflowExecution() {
const updatedSnapshot: SerializableExecutionState = {
...effectiveSnapshot,
+ sourceExecutionId: executionId,
blockStates: mergedBlockStates,
executedBlocks: Array.from(mergedExecutedBlocks),
blockLogs: [...effectiveSnapshot.blockLogs, ...accumulatedBlockLogs],
@@ -2116,6 +2146,7 @@ export function useWorkflowExecution() {
workflowId,
executionId,
error: data.error,
+ ...getExecutionDisplayError(data),
durationMs: data.duration,
blockLogs: accumulatedBlockLogs,
finalBlockLogs: data.finalBlockLogs,
@@ -2473,6 +2504,7 @@ export function useWorkflowExecution() {
workflowId: reconnectWorkflowId,
executionId: capturedExecutionId,
error: data.error,
+ ...getExecutionDisplayError(data),
blockLogs: accumulatedBlockLogs,
finalBlockLogs: data.finalBlockLogs,
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.integration.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.integration.test.ts
index f3b3d7cde6c..642b6327c58 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.integration.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.integration.test.ts
@@ -30,6 +30,49 @@ describe('reconcileFinalBlockLogs (real store)', () => {
} as any)
})
+ it('replaces completed terminal content with the final server projection', () => {
+ const store = useTerminalConsoleStore.getState()
+ store.addConsole({
+ workflowId: 'wf-1',
+ blockId: 'function-1',
+ blockName: 'Function 1',
+ blockType: 'function',
+ executionId: 'exec-1',
+ executionOrder: 1,
+ isRunning: false,
+ success: false,
+ input: { code: 'return resolved-secret-value-123' },
+ output: { error: 'resolved-secret-value-123' },
+ error: 'SyntaxError: resolved-secret-value-123',
+ agentStreamThinking: 'resolved-secret-value-123',
+ })
+
+ const startedAt = new Date().toISOString()
+ reconcileFinalBlockLogs(store.updateConsole, 'wf-1', 'exec-1', [
+ {
+ blockId: 'function-1',
+ blockName: 'Function 1',
+ blockType: 'function',
+ executionOrder: 1,
+ success: false,
+ input: { code: 'return {{SECRET_NAME}}' },
+ output: { error: '{{SECRET_NAME}}' },
+ error: 'SyntaxError: {{SECRET_NAME}}',
+ clearLiveDisplay: true,
+ startedAt,
+ endedAt: startedAt,
+ durationMs: 1,
+ } as any,
+ ])
+
+ const entry = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')[0]
+ expect(entry.input).toEqual({ code: 'return {{SECRET_NAME}}' })
+ expect(entry.output).toEqual({ error: '{{SECRET_NAME}}' })
+ expect(entry.error).toBe('SyntaxError: {{SECRET_NAME}}')
+ expect(entry.agentStreamThinking).toBeUndefined()
+ expect(JSON.stringify(entry)).not.toContain('resolved-secret-value-123')
+ })
+
it('actually flips a child-workflow inner block from running to success', () => {
const store = useTerminalConsoleStore.getState()
store.addConsole({
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.test.ts
index e7272f901cf..3b9739a99c3 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.test.ts
@@ -5,6 +5,7 @@ import { resetTerminalConsoleMock, terminalConsoleMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
addExecutionErrorConsoleEntry,
+ addHttpErrorConsoleEntry,
createBlockEventHandlers,
handleExecutionErrorConsole,
reconcileFinalBlockLogs,
@@ -195,6 +196,171 @@ describe('workflow-execution-utils', () => {
'exec-1',
])
})
+
+ it('keeps raw completion data functional while writing only the display projection', async () => {
+ const accumulatedBlockLogs: BlockLog[] = []
+ const accumulatedBlockStates = new Map()
+ const updateConsole = vi.fn()
+ const onBlockCompleteCallback = vi.fn().mockResolvedValue(undefined)
+ const handlers = createBlockEventHandlers(
+ {
+ workflowId: 'wf-1',
+ executionIdRef: { current: 'exec-1' },
+ workflowEdges: [],
+ activeBlocksSet: new Set(),
+ activeBlockRefCounts: new Map(),
+ accumulatedBlockLogs,
+ accumulatedBlockStates,
+ executedBlockIds: new Set(),
+ includeStartConsoleEntry: true,
+ onBlockCompleteCallback,
+ },
+ {
+ addConsole: vi.fn(),
+ updateConsole,
+ setActiveBlocks: vi.fn(),
+ setBlockRunStatus: vi.fn(),
+ setEdgeRunStatus: vi.fn(),
+ }
+ )
+
+ handlers.onBlockCompleted({
+ blockId: 'fn-1',
+ blockName: 'Function 1',
+ blockType: 'function',
+ executionOrder: 1,
+ input: { code: 'return sk-resolved-secret' },
+ output: { result: 'sk-resolved-secret' },
+ display: {
+ input: { code: 'return {{OPENAI_API_KEY}}' },
+ output: { result: '{{OPENAI_API_KEY}}' },
+ },
+ durationMs: 10,
+ startedAt: '2026-07-31T00:00:00.000Z',
+ endedAt: '2026-07-31T00:00:00.010Z',
+ } as any)
+
+ expect(accumulatedBlockLogs[0]).toMatchObject({
+ input: { code: 'return sk-resolved-secret' },
+ output: { result: 'sk-resolved-secret' },
+ })
+ expect(accumulatedBlockStates.get('fn-1')?.output).toEqual({
+ result: 'sk-resolved-secret',
+ })
+ expect(onBlockCompleteCallback).toHaveBeenCalledWith('fn-1', {
+ result: 'sk-resolved-secret',
+ })
+ expect(updateConsole).toHaveBeenCalledWith(
+ 'fn-1',
+ expect.objectContaining({
+ input: { code: 'return {{OPENAI_API_KEY}}' },
+ replaceOutput: { result: '{{OPENAI_API_KEY}}' },
+ }),
+ 'exec-1'
+ )
+ expect(updateConsole.mock.calls[0][1]).not.toHaveProperty('clearAgentStreamThinking')
+ expect(JSON.stringify(updateConsole.mock.calls)).not.toContain('sk-resolved-secret')
+ })
+
+ it('does not fall back to a raw block error when the display projection is empty', () => {
+ const accumulatedBlockLogs: BlockLog[] = []
+ const accumulatedBlockStates = new Map()
+ const updateConsole = vi.fn()
+ const handlers = createBlockEventHandlers(
+ {
+ workflowId: 'wf-1',
+ executionIdRef: { current: 'exec-1' },
+ workflowEdges: [],
+ activeBlocksSet: new Set(),
+ activeBlockRefCounts: new Map(),
+ accumulatedBlockLogs,
+ accumulatedBlockStates,
+ executedBlockIds: new Set(),
+ includeStartConsoleEntry: true,
+ },
+ {
+ addConsole: vi.fn(),
+ updateConsole,
+ setActiveBlocks: vi.fn(),
+ setBlockRunStatus: vi.fn(),
+ setEdgeRunStatus: vi.fn(),
+ }
+ )
+
+ handlers.onBlockError({
+ blockId: 'fn-1',
+ blockName: 'Function 1',
+ blockType: 'function',
+ executionOrder: 1,
+ input: { code: 'return sk-resolved-secret' },
+ error: 'SyntaxError: sk-resolved-secret',
+ display: {},
+ durationMs: 10,
+ startedAt: '2026-07-31T00:00:00.000Z',
+ endedAt: '2026-07-31T00:00:00.010Z',
+ })
+
+ expect(accumulatedBlockLogs[0]?.error).toBe('SyntaxError: sk-resolved-secret')
+ expect(accumulatedBlockStates.get('fn-1')?.output).toEqual({
+ error: 'SyntaxError: sk-resolved-secret',
+ })
+ expect(updateConsole).toHaveBeenCalledWith(
+ 'fn-1',
+ expect.objectContaining({
+ input: {},
+ replaceOutput: {},
+ error: 'Block failed',
+ clearAgentStreamThinking: true,
+ }),
+ 'exec-1'
+ )
+ expect(JSON.stringify(updateConsole.mock.calls)).not.toContain('sk-resolved-secret')
+ })
+
+ it('preserves legacy block error display when the server sends no projection', () => {
+ const updateConsole = vi.fn()
+ const handlers = createBlockEventHandlers(
+ {
+ workflowId: 'wf-1',
+ executionIdRef: { current: 'exec-1' },
+ workflowEdges: [],
+ activeBlocksSet: new Set(),
+ activeBlockRefCounts: new Map(),
+ accumulatedBlockLogs: [],
+ accumulatedBlockStates: new Map(),
+ executedBlockIds: new Set(),
+ includeStartConsoleEntry: true,
+ },
+ {
+ addConsole: vi.fn(),
+ updateConsole,
+ setActiveBlocks: vi.fn(),
+ setBlockRunStatus: vi.fn(),
+ setEdgeRunStatus: vi.fn(),
+ }
+ )
+
+ handlers.onBlockError({
+ blockId: 'fn-1',
+ blockName: 'Function 1',
+ blockType: 'function',
+ executionOrder: 1,
+ input: { code: 'return ordinary-value' },
+ error: 'SyntaxError: ordinary-value',
+ durationMs: 10,
+ startedAt: '2026-07-31T00:00:00.000Z',
+ endedAt: '2026-07-31T00:00:00.010Z',
+ })
+
+ expect(updateConsole).toHaveBeenCalledWith(
+ 'fn-1',
+ expect.objectContaining({
+ input: { code: 'return ordinary-value' },
+ error: 'SyntaxError: ordinary-value',
+ }),
+ 'exec-1'
+ )
+ })
})
describe('addExecutionErrorConsoleEntry', () => {
@@ -204,6 +370,7 @@ describe('workflow-execution-utils', () => {
workflowId: 'wf-1',
executionId: 'exec-1',
error: 'Run failed',
+ displayError: 'Safe run failure',
durationMs: 1234,
blockLogs: [],
})
@@ -212,7 +379,45 @@ describe('workflow-execution-utils', () => {
const entry = addConsole.mock.calls[0][0]
expect(entry.blockName).toBe('Run Error')
expect(entry.blockType).toBe('error')
- expect(entry.error).toBe('Run failed')
+ expect(entry.error).toBe('Safe run failure')
+ })
+
+ it('does not use the raw execution error when the server projection is empty', () => {
+ const addConsole = vi.fn()
+ addExecutionErrorConsoleEntry(addConsole, {
+ workflowId: 'wf-1',
+ executionId: 'exec-1',
+ error: 'SyntaxError: sk-resolved-secret',
+ hasDisplayProjection: true,
+ blockLogs: [],
+ })
+
+ expect(addConsole.mock.calls[0][0].error).toBe('Run failed')
+ expect(JSON.stringify(addConsole.mock.calls)).not.toContain('sk-resolved-secret')
+ })
+
+ it('preserves legacy execution errors when the server sends no projection', () => {
+ const addConsole = vi.fn()
+ addExecutionErrorConsoleEntry(addConsole, {
+ workflowId: 'wf-1',
+ executionId: 'exec-1',
+ error: 'Legacy run failure',
+ blockLogs: [],
+ })
+
+ expect(addConsole.mock.calls[0][0].error).toBe('Legacy run failure')
+ })
+
+ it('preserves HTTP error detail before SSE projection is available', () => {
+ const addConsole = vi.fn()
+ addHttpErrorConsoleEntry(addConsole, {
+ workflowId: 'wf-1',
+ executionId: 'exec-1',
+ error: 'Workflow is archived',
+ httpStatus: 409,
+ })
+
+ expect(addConsole.mock.calls[0][0].error).toBe('Workflow is archived')
})
it('skips when blockLogs already contain a block-level error', () => {
@@ -407,6 +612,74 @@ describe('workflow-execution-utils', () => {
expect(updateConsole).not.toHaveBeenCalled()
})
+ it('reprojects completed content without deep-comparing authoritative finalBlockLogs', () => {
+ terminalConsoleMockFns.mockAddConsole({
+ workflowId: 'wf-1',
+ blockId: 'fn-1',
+ blockName: 'Function',
+ blockType: 'function',
+ executionId: 'exec-1',
+ executionOrder: 1,
+ isRunning: false,
+ success: false,
+ input: { code: 'return sk-resolved-secret' },
+ output: { error: 'sk-resolved-secret' },
+ error: 'SyntaxError: sk-resolved-secret',
+ agentStreamThinking: 'sk-resolved-secret',
+ })
+
+ const updateConsole = vi.fn()
+ reconcileFinalBlockLogs(updateConsole, 'wf-1', 'exec-1', [
+ makeLog({
+ blockId: 'fn-1',
+ input: { code: 'return {{OPENAI_API_KEY}}' },
+ output: { error: '{{OPENAI_API_KEY}}' },
+ error: 'SyntaxError: {{OPENAI_API_KEY}}',
+ success: false,
+ }),
+ ])
+
+ expect(updateConsole).toHaveBeenCalledWith(
+ 'fn-1',
+ expect.objectContaining({
+ input: { code: 'return {{OPENAI_API_KEY}}' },
+ replaceOutput: { error: '{{OPENAI_API_KEY}}' },
+ error: 'SyntaxError: {{OPENAI_API_KEY}}',
+ }),
+ 'exec-1'
+ )
+ expect(updateConsole.mock.calls[0][1]).not.toHaveProperty('clearAgentStreamThinking')
+ expect(JSON.stringify(updateConsole.mock.calls)).not.toContain('sk-resolved-secret')
+ })
+
+ it('clears live content when the final projection is structural-only', () => {
+ terminalConsoleMockFns.mockAddConsole({
+ workflowId: 'wf-1',
+ blockId: 'fn-1',
+ blockName: 'Function',
+ blockType: 'function',
+ executionId: 'exec-1',
+ executionOrder: 1,
+ isRunning: false,
+ success: false,
+ input: { code: 'return sk-resolved-secret' },
+ output: { error: 'sk-resolved-secret' },
+ error: 'SyntaxError: sk-resolved-secret',
+ })
+
+ const updateConsole = vi.fn()
+ reconcileFinalBlockLogs(updateConsole, 'wf-1', 'exec-1', [
+ makeLog({ blockId: 'fn-1', success: false }),
+ ])
+
+ expect(updateConsole.mock.calls[0][1]).toMatchObject({
+ input: {},
+ replaceOutput: {},
+ error: null,
+ clearAgentStreamThinking: true,
+ })
+ })
+
it('reconciles child workflow spans before running entries are swept to canceled', () => {
terminalConsoleMockFns.mockAddConsole({
workflowId: 'wf-1',
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
index 782e00428f9..c344decafaf 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
+import type { SecretSafeBlockLog } from '@/lib/logs/execution/display-types'
import type { TraceSpan } from '@/lib/logs/types'
import type {
BlockChildWorkflowStartedData,
@@ -15,9 +16,6 @@ import {
SSEEventHandlerError,
SSEStreamInterruptedError,
} from '@/hooks/use-execution-stream'
-
-const logger = createLogger('workflow-execution-utils')
-
import { useExecutionStore } from '@/stores/execution'
import type { ConsoleEntry, ConsoleUpdate } from '@/stores/terminal'
import {
@@ -29,6 +27,11 @@ import {
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
+const logger = createLogger('workflow-execution-utils')
+const BLOCK_FAILURE_DISPLAY_MESSAGE = 'Block failed'
+const RUN_FAILURE_DISPLAY_MESSAGE = 'Run failed'
+const VALIDATION_FAILURE_DISPLAY_MESSAGE = 'Workflow validation failed'
+
/**
* Updates the active blocks set and ref counts for a single block.
* Ref counting ensures a block stays active until all parallel branches for it complete.
@@ -132,6 +135,46 @@ interface BlockEventHandlerDeps {
type BlockChildWorkflowStartedUpdate = BlockChildWorkflowStartedData
+interface BlockCompletedDisplayProjection {
+ input?: unknown
+ output?: unknown
+ clearLiveDisplay?: true
+}
+
+interface BlockErrorDisplayProjection {
+ input?: unknown
+ error?: string
+ clearLiveDisplay?: true
+}
+
+interface ExecutionErrorDisplayProjection {
+ present: boolean
+ error?: string
+}
+
+function getBlockCompletedDisplay(data: BlockCompletedData): BlockCompletedDisplayProjection {
+ const event = data as BlockCompletedData & { display?: BlockCompletedDisplayProjection }
+ if (!Object.hasOwn(event, 'display')) {
+ return { input: data.input, output: data.output }
+ }
+ return event.display ?? {}
+}
+
+function getBlockErrorDisplay(data: BlockErrorData): BlockErrorDisplayProjection {
+ const event = data as BlockErrorData & { display?: BlockErrorDisplayProjection }
+ if (!Object.hasOwn(event, 'display')) {
+ return { input: data.input, error: data.error }
+ }
+ return event.display ?? {}
+}
+
+function getExecutionErrorDisplay(data: { error: string }): ExecutionErrorDisplayProjection {
+ const event = data as typeof data & { display?: Omit }
+ if (!Object.hasOwn(event, 'display')) return { present: false }
+ const error = event.display?.error
+ return { present: true, ...(typeof error === 'string' ? { error } : {}) }
+}
+
/**
* Creates block event handlers for SSE execution events.
* Shared by the workflow execution hook and standalone execution utilities.
@@ -299,19 +342,26 @@ export function createBlockEventHandlers(
})
const updateConsoleEntry = (data: BlockCompletedData) => {
+ const display = getBlockCompletedDisplay(data)
+ const projectionOmittedContent =
+ display.clearLiveDisplay === true ||
+ (Object.hasOwn(data, 'display') &&
+ ((!Object.hasOwn(display, 'input') && data.input !== undefined) ||
+ (!Object.hasOwn(display, 'output') && data.output !== undefined)))
updateConsole(
data.blockId,
{
blockName: data.blockName,
blockType: data.blockType,
executionOrder: data.executionOrder,
- input: data.input || {},
- replaceOutput: data.output,
+ input: display.input ?? {},
+ replaceOutput: (display.output ?? {}) as Record,
success: true,
durationMs: data.durationMs,
startedAt: data.startedAt,
endedAt: data.endedAt,
isRunning: false,
+ ...(projectionOmittedContent ? { clearAgentStreamThinking: true } : {}),
...extractIterationFields(data),
},
executionIdRef.current
@@ -319,20 +369,27 @@ export function createBlockEventHandlers(
}
const updateConsoleErrorEntry = (data: BlockErrorData) => {
+ const display = getBlockErrorDisplay(data)
+ const projectionOmittedContent =
+ display.clearLiveDisplay === true ||
+ (Object.hasOwn(data, 'display') &&
+ ((!Object.hasOwn(display, 'input') && data.input !== undefined) ||
+ (!Object.hasOwn(display, 'error') && data.error !== undefined)))
updateConsole(
data.blockId,
{
blockName: data.blockName,
blockType: data.blockType,
executionOrder: data.executionOrder,
- input: data.input || {},
+ input: display.input ?? {},
replaceOutput: {},
success: false,
- error: data.error,
+ error: display.error || BLOCK_FAILURE_DISPLAY_MESSAGE,
durationMs: data.durationMs,
startedAt: data.startedAt,
endedAt: data.endedAt,
isRunning: false,
+ ...(projectionOmittedContent ? { clearAgentStreamThinking: true } : {}),
...extractIterationFields(data),
},
executionIdRef.current
@@ -472,16 +529,16 @@ interface ExecutionConsoleDeps {
}
/**
- * Reconciles still-running console entries with the server's authoritative
- * `finalBlockLogs` so that any block whose terminal `block:completed`/`block:error`
- * SSE event was lost gets the correct success/error state instead of being
- * swept to "canceled".
+ * Reconciles console entries with the server's authoritative, secret-safe
+ * `finalBlockLogs`. Reapplying running or content-bearing rows both recovers
+ * dropped terminal events and replaces an earlier live projection after late
+ * secret activation.
*/
export function reconcileFinalBlockLogs(
updateConsole: UpdateConsoleFn,
workflowId: string,
executionId: string | undefined,
- finalBlockLogs: BlockLog[] | undefined
+ finalBlockLogs: SecretSafeBlockLog[] | undefined
): void {
if (!finalBlockLogs?.length || !executionId) return
for (const log of finalBlockLogs) {
@@ -491,8 +548,17 @@ export function reconcileFinalBlockLogs(
entry.executionId === executionId &&
entry.executionOrder === log.executionOrder
const matchingEntry = entries.find(matchesFinalLog)
- const runningEntry = entries.find((entry) => matchesFinalLog(entry) && entry.isRunning)
- if (runningEntry) {
+ const hasExistingContent =
+ matchingEntry?.input !== undefined ||
+ matchingEntry?.output !== undefined ||
+ matchingEntry?.error !== undefined ||
+ matchingEntry?.agentStreamThinking !== undefined
+ if (matchingEntry && (matchingEntry.isRunning || hasExistingContent)) {
+ const projectionOmittedContent =
+ log.clearLiveDisplay === true ||
+ (log.input === undefined && matchingEntry.input !== undefined) ||
+ (log.output === undefined && matchingEntry.output !== undefined) ||
+ (log.error === undefined && matchingEntry.error !== undefined)
updateConsole(
log.blockId,
{
@@ -500,14 +566,15 @@ export function reconcileFinalBlockLogs(
blockName: log.blockName,
blockType: log.blockType,
replaceOutput: (log.output ?? {}) as Record,
- ...(log.input ? { input: log.input } : {}),
+ input: log.input ?? {},
success: log.success,
- ...(log.error ? { error: log.error } : {}),
+ error: log.error ?? null,
durationMs: log.durationMs,
startedAt: log.startedAt,
endedAt: log.endedAt,
isRunning: false,
isCanceled: false,
+ ...(projectionOmittedContent ? { clearAgentStreamThinking: true } : {}),
},
executionId
)
@@ -549,19 +616,26 @@ function reconcileChildTraceSpans(
? findConsoleEntryForSpan(workflowId, executionId, childWorkflowInstanceId, span)
: undefined
if (span.blockId) {
- const errorMessage = normalizeSpanError(span.output?.error)
+ const errorMessage = normalizeSpanError(span.errorMessage ?? span.output?.error)
+ const projectionOmittedContent = matchingEntry
+ ? (span.input === undefined && matchingEntry.input !== undefined) ||
+ (span.output === undefined && matchingEntry.output !== undefined) ||
+ (errorMessage === undefined && matchingEntry.error !== undefined)
+ : false
updateConsole(
span.blockId,
{
...spanConsoleIdentity(span, childWorkflowInstanceId),
+ input: span.input ?? {},
replaceOutput: (span.output ?? {}) as Record,
success: span.status !== 'error',
- ...(errorMessage !== undefined ? { error: errorMessage } : {}),
+ error: errorMessage ?? null,
durationMs: span.duration,
startedAt: span.startTime,
endedAt: span.endTime,
isRunning: false,
isCanceled: false,
+ ...(projectionOmittedContent ? { clearAgentStreamThinking: true } : {}),
},
executionId
)
@@ -670,12 +744,17 @@ export function buildExecutionTiming(durationMs?: number): ExecutionTimingFields
interface ExecutionErrorConsoleParams {
workflowId: string
executionId?: string
+ /** Raw runtime error used only for functional classification and result propagation. */
error?: string
+ /** Server-projected error text safe for terminal display. */
+ displayError?: string
+ /** Distinguishes an intentionally empty projection from a legacy event with no projection. */
+ hasDisplayProjection?: boolean
durationMs?: number
blockLogs: BlockLog[]
isPreExecutionError?: boolean
/** Server's authoritative per-block terminal states, used to reconcile lost SSE events. */
- finalBlockLogs?: BlockLog[]
+ finalBlockLogs?: SecretSafeBlockLog[]
}
/**
@@ -702,8 +781,14 @@ export function addExecutionErrorConsoleEntry(
const isPreExecutionError = params.isPreExecutionError ?? false
if (!isPreExecutionError && hasBlockError) return
- const errorMessage = params.error || 'Run failed'
- const isTimeout = errorMessage.toLowerCase().includes('timed out')
+ const hasDisplayProjection = params.hasDisplayProjection ?? params.displayError !== undefined
+ const fallbackMessage = isPreExecutionError
+ ? VALIDATION_FAILURE_DISPLAY_MESSAGE
+ : RUN_FAILURE_DISPLAY_MESSAGE
+ const errorMessage = hasDisplayProjection
+ ? params.displayError || fallbackMessage
+ : params.error || fallbackMessage
+ const isTimeout = (params.error ?? params.displayError ?? '').toLowerCase().includes('timed out')
const timing = buildExecutionTiming(params.durationMs)
addConsole({
@@ -784,7 +869,7 @@ interface CancelledConsoleParams {
executionId?: string
durationMs?: number
/** Server's authoritative per-block terminal states, used to reconcile lost SSE events. */
- finalBlockLogs?: BlockLog[]
+ finalBlockLogs?: SecretSafeBlockLog[]
}
/**
@@ -1060,6 +1145,7 @@ export async function executeWorkflowWithFullLogging(
if (!isCurrentExecution()) return
executionFinished = true
const errorMessage = data.error || 'Run failed'
+ const display = getExecutionErrorDisplay(data)
executionResult = {
success: false,
output: {},
@@ -1074,6 +1160,8 @@ export async function executeWorkflowWithFullLogging(
workflowId: wfId,
executionId: executionIdRef.current,
error: errorMessage,
+ displayError: display.error,
+ hasDisplayProjection: display.present,
durationMs: data.duration || 0,
blockLogs: accumulatedBlockLogs,
isPreExecutionError: accumulatedBlockLogs.length === 0,
diff --git a/apps/sim/background/async-preprocessing-correlation.test.ts b/apps/sim/background/async-preprocessing-correlation.test.ts
index e86d3bd66de..40832442576 100644
--- a/apps/sim/background/async-preprocessing-correlation.test.ts
+++ b/apps/sim/background/async-preprocessing-correlation.test.ts
@@ -9,6 +9,7 @@ import {
executionPreprocessingMockFns,
LoggingSessionMock,
loggingSessionMock,
+ loggingSessionMockFns,
resetDbChainMock,
workflowsPersistenceUtilsMock,
workflowsPersistenceUtilsMockFns,
@@ -16,13 +17,21 @@ import {
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ADMISSION_ERROR_CODE } from '@/lib/core/admission/transient-failure'
-const { mockTask, mockExecuteWorkflowCore, mockGetScheduleTimeValues, mockGetSubBlockValue } =
- vi.hoisted(() => ({
- mockTask: vi.fn((config) => config),
- mockExecuteWorkflowCore: vi.fn(),
- mockGetScheduleTimeValues: vi.fn(),
- mockGetSubBlockValue: vi.fn(),
- }))
+const {
+ mockTask,
+ mockExecuteWorkflowCore,
+ mockWasExecutionFinalizedByCore,
+ mockHasExecutionResult,
+ mockGetScheduleTimeValues,
+ mockGetSubBlockValue,
+} = vi.hoisted(() => ({
+ mockTask: vi.fn((config) => config),
+ mockExecuteWorkflowCore: vi.fn(),
+ mockWasExecutionFinalizedByCore: vi.fn(),
+ mockHasExecutionResult: vi.fn(),
+ mockGetScheduleTimeValues: vi.fn(),
+ mockGetSubBlockValue: vi.fn(),
+}))
const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution
const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState
@@ -55,7 +64,7 @@ vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
vi.mock('@/lib/workflows/executor/execution-core', () => ({
executeWorkflowCore: mockExecuteWorkflowCore,
- wasExecutionFinalizedByCore: vi.fn().mockReturnValue(false),
+ wasExecutionFinalizedByCore: mockWasExecutionFinalizedByCore,
}))
vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({
@@ -78,7 +87,7 @@ vi.mock('@/executor/execution/snapshot', () => ({
}))
vi.mock('@/executor/utils/errors', () => ({
- hasExecutionResult: vi.fn().mockReturnValue(false),
+ hasExecutionResult: mockHasExecutionResult,
}))
import { executeScheduleJob } from './schedule-execution'
@@ -100,6 +109,8 @@ const billingAttribution = {
describe('async preprocessing correlation threading', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockWasExecutionFinalizedByCore.mockReturnValue(false)
+ mockHasExecutionResult.mockReturnValue(false)
resetDbChainMock()
dbChainMockFns.limit.mockResolvedValue([
{
@@ -166,6 +177,84 @@ describe('async preprocessing correlation threading', () => {
)
})
+ it('preserves a core-finalized execution error for task failure semantics', async () => {
+ const rawError = Object.assign(new Error('Function 1 failed with activated-secret-value'), {
+ executionResult: {
+ success: false,
+ output: { error: 'Function failed' },
+ logs: [],
+ },
+ })
+ mockPreprocessExecution.mockResolvedValueOnce({
+ success: true,
+ actorUserId: 'actor-1',
+ workflowRecord: {
+ id: 'workflow-1',
+ userId: 'owner-1',
+ workspaceId: 'workspace-1',
+ variables: {},
+ },
+ billingAttribution,
+ executionTimeout: {},
+ })
+ mockExecuteWorkflowCore.mockRejectedValueOnce(rawError)
+ mockHasExecutionResult.mockImplementation((error) => error === rawError)
+ mockWasExecutionFinalizedByCore.mockReturnValue(true)
+
+ await expect(
+ executeWorkflowJob({
+ workflowId: 'workflow-1',
+ userId: 'actor-1',
+ workspaceId: 'workspace-1',
+ billingAttribution,
+ triggerType: 'api',
+ executionId: 'execution-finalized',
+ requestId: 'request-finalized',
+ })
+ ).rejects.toBe(rawError)
+
+ expect(loggingSessionMockFns.mockWaitForPostExecution).not.toHaveBeenCalled()
+ expect(mockWasExecutionFinalizedByCore).toHaveBeenCalledWith(rawError, 'execution-finalized')
+ expect(loggingSessionMockFns.mockSafeCompleteWithError).not.toHaveBeenCalled()
+ })
+
+ it('persists and rethrows the original unfinalized execution error', async () => {
+ const rawError = new Error('Function 1 failed with activated-secret-value')
+ mockPreprocessExecution.mockResolvedValueOnce({
+ success: true,
+ actorUserId: 'actor-1',
+ workflowRecord: {
+ id: 'workflow-1',
+ userId: 'owner-1',
+ workspaceId: 'workspace-1',
+ variables: {},
+ },
+ billingAttribution,
+ executionTimeout: {},
+ })
+ mockExecuteWorkflowCore.mockRejectedValueOnce(rawError)
+
+ await expect(
+ executeWorkflowJob({
+ workflowId: 'workflow-1',
+ userId: 'actor-1',
+ workspaceId: 'workspace-1',
+ billingAttribution,
+ triggerType: 'api',
+ executionId: 'execution-fault',
+ requestId: 'request-fault',
+ })
+ ).rejects.toBe(rawError)
+
+ expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: expect.objectContaining({
+ message: 'Function 1 failed with activated-secret-value',
+ }),
+ })
+ )
+ })
+
it('does not pre-start schedule logging before core execution', async () => {
mockPreprocessExecution.mockResolvedValueOnce({
success: true,
diff --git a/apps/sim/background/resume-execution.test.ts b/apps/sim/background/resume-execution.test.ts
new file mode 100644
index 00000000000..fe3dacc1439
--- /dev/null
+++ b/apps/sim/background/resume-execution.test.ts
@@ -0,0 +1,94 @@
+/**
+ * @vitest-environment node
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockTask,
+ mockGetPausedExecutionById,
+ mockStartResumeExecution,
+ mockFindCellContextByExecutionId,
+ mockSnapshotFromJson,
+} = vi.hoisted(() => ({
+ mockTask: vi.fn((config) => config),
+ mockGetPausedExecutionById: vi.fn(),
+ mockStartResumeExecution: vi.fn(),
+ mockFindCellContextByExecutionId: vi.fn(),
+ mockSnapshotFromJson: vi.fn(),
+}))
+
+vi.mock('@trigger.dev/sdk', () => ({ task: mockTask }))
+
+vi.mock('@/lib/billing/core/billing-attribution', () => ({
+ assertBillingAttributionSnapshot: vi.fn((value) => value),
+}))
+
+vi.mock('@/lib/table/cascade-lock', () => ({ withCascadeLock: vi.fn() }))
+vi.mock('@/lib/table/deps', () => ({ isExecCancelled: vi.fn(() => false) }))
+
+vi.mock('@/lib/table/workflow-columns', () => ({
+ findCellContextByExecutionId: mockFindCellContextByExecutionId,
+}))
+
+vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({
+ PauseResumeManager: {
+ getPausedExecutionById: mockGetPausedExecutionById,
+ startResumeExecution: mockStartResumeExecution,
+ },
+}))
+
+vi.mock('@/executor/execution/snapshot', () => ({
+ ExecutionSnapshot: { fromJSON: mockSnapshotFromJson },
+}))
+
+import { executeResumeJob, type ResumeExecutionPayload } from '@/background/resume-execution'
+
+const payload: ResumeExecutionPayload = {
+ resumeEntryId: 'resume-entry-1',
+ resumeExecutionId: 'resume-execution-1',
+ pausedExecutionId: 'paused-execution-1',
+ contextId: 'context-1',
+ resumeInput: {},
+ userId: 'user-1',
+ workflowId: 'workflow-1',
+ parentExecutionId: 'parent-execution-1',
+}
+
+describe('executeResumeJob terminal errors', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetPausedExecutionById.mockResolvedValue({
+ executionSnapshot: { snapshot: {} },
+ })
+ mockSnapshotFromJson.mockReturnValue({
+ metadata: {
+ billingAttribution: {
+ actorUserId: 'user-1',
+ workspaceId: 'workspace-1',
+ },
+ },
+ })
+ mockFindCellContextByExecutionId.mockResolvedValue(null)
+ })
+
+ it('rethrows the original core-finalized resume error', async () => {
+ const rawError = Object.assign(new Error('Agent tool exposed activated-secret-value'), {
+ executionResult: {
+ success: false,
+ output: { error: 'Agent tool failed' },
+ logs: [],
+ },
+ })
+ mockStartResumeExecution.mockRejectedValue(rawError)
+
+ await expect(executeResumeJob(payload)).rejects.toBe(rawError)
+ })
+
+ it('rethrows the original genuine resume fault', async () => {
+ const rawError = new Error('MCP setup exposed activated-secret-value')
+ mockStartResumeExecution.mockRejectedValue(rawError)
+
+ await expect(executeResumeJob(payload)).rejects.toBe(rawError)
+ })
+})
diff --git a/apps/sim/background/schedule-execution.test.ts b/apps/sim/background/schedule-execution.test.ts
index 8ebb5cdd8c4..771da4f2f98 100644
--- a/apps/sim/background/schedule-execution.test.ts
+++ b/apps/sim/background/schedule-execution.test.ts
@@ -21,7 +21,13 @@ vi.mock('@/lib/workflows/schedules/disable-notifications', () => ({
// does not re-export. Widen it rather than rewriting the source's imports.
vi.mock('@sim/db', () => ({ ...databaseMock, ...schemaMock }))
-import { applyScheduleFailureUpdate, releaseScheduleLock } from '@/background/schedule-execution'
+import {
+ applyScheduleFailureUpdate,
+ readScheduledMothershipErrorResponse,
+ readScheduledMothershipJsonResponse,
+ releaseScheduleLock,
+} from '@/background/schedule-execution'
+import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
const BASE = {
scheduleId: 'schedule-1',
@@ -99,3 +105,71 @@ describe('releaseScheduleLock', () => {
expect(notifyMock).not.toHaveBeenCalled()
})
})
+
+describe('scheduled Mothership response handling', () => {
+ it('parses JSON responses', async () => {
+ const response = new Response(JSON.stringify({ content: 'ok' }), {
+ headers: { 'content-type': 'application/json' },
+ })
+
+ await expect(readScheduledMothershipJsonResponse(response)).resolves.toEqual({ content: 'ok' })
+ })
+
+ it('preserves staging acceptance for responses above the generic tool cap', async () => {
+ const response = new Response(JSON.stringify({ content: 'unchanged' }), {
+ headers: {
+ 'content-length': String(10 * 1024 * 1024 + 1),
+ 'content-type': 'application/json',
+ },
+ })
+
+ await expect(readScheduledMothershipJsonResponse(response)).resolves.toEqual({
+ content: 'unchanged',
+ })
+ })
+
+ it('does not include malformed response content in parse failures', async () => {
+ const response = new Response('secret-bearing-non-json')
+ const error = await readScheduledMothershipJsonResponse(response).catch((caught) => caught)
+
+ expect(error).toBeInstanceOf(Error)
+ expect(error.message).toBe('Sim execution returned an invalid response')
+ expect(error.message).not.toContain('secret-bearing-non-json')
+ })
+
+ it('preserves the functional error body when no private metadata is present', async () => {
+ const registry = {
+ markIncomplete: vi.fn(),
+ importProvenance: vi.fn(),
+ } as unknown as ResolvedSecretTraceRegistry
+ const response = new Response('secret-bearing-error-body', { status: 500 })
+
+ const message = await readScheduledMothershipErrorResponse(response, registry)
+
+ expect(message).toBe('secret-bearing-error-body')
+ expect(registry.markIncomplete).toHaveBeenCalledTimes(1)
+ })
+
+ it('strips private provenance metadata without replacing the provider error', async () => {
+ const registry = {
+ markIncomplete: vi.fn(),
+ importProvenance: vi.fn().mockReturnValue(true),
+ } as unknown as ResolvedSecretTraceRegistry
+ const response = new Response(
+ JSON.stringify({
+ error: 'provider error detail',
+ __resolvedSecretTraceProvenance: { version: 1, complete: true, entries: [] },
+ }),
+ {
+ status: 500,
+ headers: { 'x-sim-private-tool-metadata': 'resolved-secret-provenance-v1' },
+ }
+ )
+
+ const message = await readScheduledMothershipErrorResponse(response, registry)
+
+ expect(JSON.parse(message)).toEqual({ error: 'provider error detail' })
+ expect(message).not.toContain('__resolvedSecretTraceProvenance')
+ expect(registry.importProvenance).toHaveBeenCalledOnce()
+ })
+})
diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts
index b684f180dc7..bd537a81359 100644
--- a/apps/sim/background/schedule-execution.ts
+++ b/apps/sim/background/schedule-execution.ts
@@ -9,6 +9,7 @@ import {
import { createLogger, runWithRequestContext } from '@sim/logger'
import { describeError, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
+import { isPlainRecord } from '@sim/utils/object'
import { task } from '@trigger.dev/sdk'
import { Cron } from 'croner'
import { and, eq, isNull, ne, type SQL, sql } from 'drizzle-orm'
@@ -32,8 +33,16 @@ import {
} from '@/lib/core/execution-limits'
import type { DbOrTx } from '@/lib/db/types'
import { preprocessExecution } from '@/lib/execution/preprocessing'
+import {
+ PRIVATE_TOOL_METADATA_REQUEST_HEADER,
+ RESOLVED_SECRET_PROVENANCE_FIELD,
+ RESOLVED_SECRET_PROVENANCE_METADATA_V1,
+ responseHasPrivateToolMetadata,
+} from '@/lib/execution/private-tool-metadata'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
+import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
+import type { TraceSpan } from '@/lib/logs/types'
import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base64.server'
import {
executeWorkflowCore,
@@ -61,6 +70,7 @@ import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata } from '@/executor/execution/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
+import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
const logger = createLogger('ScheduleExecution')
@@ -660,6 +670,7 @@ async function runWorkflowExecution({
stackTrace: error instanceof Error ? error.stack : undefined,
},
traceSpans,
+ executionState: executionResult?.executionState,
})
throw error
@@ -1207,14 +1218,94 @@ function buildJobPrompt(jobRecord: {
return parts.join('\n')
}
+async function consumeJobTraceProvenance(
+ response: Response,
+ payload: Record,
+ registry: ResolvedSecretTraceRegistry
+): Promise {
+ const hasProvenance = Object.hasOwn(payload, RESOLVED_SECRET_PROVENANCE_FIELD)
+ const provenance = payload[RESOLVED_SECRET_PROVENANCE_FIELD]
+ delete payload[RESOLVED_SECRET_PROVENANCE_FIELD]
+
+ if (
+ !responseHasPrivateToolMetadata(response.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1) ||
+ !hasProvenance
+ ) {
+ registry.markIncomplete()
+ return false
+ }
+
+ return registry.importProvenance(provenance, { trusted: true })
+}
+
+/** Reads and validates a scheduled Mothership JSON response without changing its size contract. */
+export async function readScheduledMothershipJsonResponse(
+ response: Response
+): Promise> {
+ let payload: unknown
+
+ try {
+ payload = await response.json()
+ } catch {
+ throw new Error('Sim execution returned an invalid response')
+ }
+
+ if (!isPlainRecord(payload)) {
+ throw new Error('Sim execution returned an invalid response')
+ }
+ return payload
+}
+
+/** Reads the functional error body and strips private provenance metadata when present. */
+export async function readScheduledMothershipErrorResponse(
+ response: Response,
+ registry: ResolvedSecretTraceRegistry
+): Promise {
+ const responseText = await response.text()
+ const hasPrivateMarker = responseHasPrivateToolMetadata(
+ response.headers,
+ RESOLVED_SECRET_PROVENANCE_METADATA_V1
+ )
+ const mayContainPrivateProvenance = responseText.includes(`"${RESOLVED_SECRET_PROVENANCE_FIELD}"`)
+
+ if (!hasPrivateMarker && !mayContainPrivateProvenance) {
+ registry.markIncomplete()
+ return responseText
+ }
+
+ let payload: unknown
+
+ try {
+ payload = JSON.parse(responseText)
+ } catch {
+ registry.markIncomplete()
+ return responseText
+ }
+
+ if (!isPlainRecord(payload)) {
+ registry.markIncomplete()
+ return responseText
+ }
+
+ const hadPrivateProvenance = Object.hasOwn(payload, RESOLVED_SECRET_PROVENANCE_FIELD)
+ try {
+ await consumeJobTraceProvenance(response, payload, registry)
+ } catch {
+ registry.markIncomplete()
+ }
+ return hadPrivateProvenance ? JSON.stringify(payload) : responseText
+}
+
async function createJobLogEntry(params: {
scheduleId: string
workspaceId: string
+ userId: string
jobTitle: string | null
startTime: Date
endTime: Date
durationMs: number
success: boolean
+ resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry
responseBody?: Record
errorMessage?: string
}): Promise {
@@ -1222,14 +1313,17 @@ async function createJobLogEntry(params: {
const {
scheduleId,
workspaceId,
+ userId,
jobTitle,
startTime,
endTime,
durationMs,
success,
+ resolvedSecretTraceRegistry,
responseBody,
} = params
const name = jobTitle || 'Sim Job'
+ const executionId = generateId()
const toolCallsList = (responseBody?.toolCalls || []).map((tc: Record) => ({
name: tc.name,
@@ -1246,7 +1340,7 @@ async function createJobLogEntry(params: {
status: tc.error ? 'error' : 'success',
}))
- const traceSpan = {
+ const traceSpan: TraceSpan = {
id: generateId(),
name,
type: 'mothership',
@@ -1263,12 +1357,20 @@ async function createJobLogEntry(params: {
cost: responseBody?.cost || undefined,
tokens: responseBody?.tokens || undefined,
}
+ const [projectedTraceSpan] = await projectTraceSpansForSecrets([traceSpan], {
+ registry: resolvedSecretTraceRegistry,
+ store: {
+ workspaceId,
+ executionId,
+ userId,
+ },
+ })
await db.insert(jobExecutionLogs).values({
id: generateId(),
scheduleId,
workspaceId,
- executionId: generateId(),
+ executionId,
level: success ? 'info' : 'error',
status: success ? 'completed' : 'failed',
trigger: 'mothership',
@@ -1277,8 +1379,11 @@ async function createJobLogEntry(params: {
totalDurationMs: durationMs,
executionData: {
enhanced: true,
- traceSpans: [traceSpan],
+ traceSpans: [projectedTraceSpan],
finalOutput: responseBody?.content ? { content: responseBody.content } : undefined,
+ executionState: {
+ resolvedSecretTraceProvenance: resolvedSecretTraceRegistry.exportProvenance(),
+ },
trigger: {
type: 'mothership',
source: name,
@@ -1369,6 +1474,10 @@ export async function executeJobInline(payload: JobExecutionPayload) {
}
const promptText = buildJobPrompt(jobRecord)
+ const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([], {
+ userId: jobRecord.sourceUserId,
+ workspaceId: jobRecord.sourceWorkspaceId,
+ })
try {
const billingAttribution = await resolveBillingAttribution({
@@ -1382,6 +1491,7 @@ export async function executeJobInline(payload: JobExecutionPayload) {
const url = buildAPIUrl('/api/mothership/execute')
const headers = await buildAuthHeaders(jobRecord.sourceUserId)
headers[BILLING_ATTRIBUTION_HEADER] = serializeBillingAttributionHeader(billingAttribution)
+ headers[PRIVATE_TOOL_METADATA_REQUEST_HEADER] = RESOLVED_SECRET_PROVENANCE_METADATA_V1
const body = {
messages: [{ role: 'user', content: promptText }],
@@ -1404,7 +1514,11 @@ export async function executeJobInline(payload: JobExecutionPayload) {
})
if (!response.ok) {
- const errorText = await response.text().catch(() => {
+ const errorText = await readScheduledMothershipErrorResponse(
+ response,
+ resolvedSecretTraceRegistry
+ ).catch(() => {
+ resolvedSecretTraceRegistry.markIncomplete()
if (timeoutController.isTimedOut()) {
throw new Error(getTimeoutErrorMessage(null, timeoutController.timeoutMs))
}
@@ -1416,11 +1530,13 @@ export async function executeJobInline(payload: JobExecutionPayload) {
await createJobLogEntry({
scheduleId: payload.scheduleId,
workspaceId: jobRecord.sourceWorkspaceId,
+ userId: jobRecord.sourceUserId,
jobTitle: jobRecord.jobTitle,
startTime,
endTime,
durationMs,
success: false,
+ resolvedSecretTraceRegistry,
errorMessage: errorText,
})
@@ -1430,10 +1546,17 @@ export async function executeJobInline(payload: JobExecutionPayload) {
let responseBody: Record = {}
let wasCompletedByTool = false
try {
- responseBody = await response.json()
+ const payload = await readScheduledMothershipJsonResponse(response)
+ responseBody = payload
+ try {
+ await consumeJobTraceProvenance(response, payload, resolvedSecretTraceRegistry)
+ } catch {
+ resolvedSecretTraceRegistry.markIncomplete()
+ }
const toolCalls = responseBody?.toolCalls as Array<{ name?: string }> | undefined
wasCompletedByTool = toolCalls?.some((tc) => tc.name === 'complete_scheduled_task') ?? false
} catch {
+ resolvedSecretTraceRegistry.markIncomplete()
if (timeoutController.isTimedOut()) {
throw new Error(getTimeoutErrorMessage(null, timeoutController.timeoutMs))
}
@@ -1444,11 +1567,13 @@ export async function executeJobInline(payload: JobExecutionPayload) {
await createJobLogEntry({
scheduleId: payload.scheduleId,
workspaceId: jobRecord.sourceWorkspaceId,
+ userId: jobRecord.sourceUserId,
jobTitle: jobRecord.jobTitle,
startTime,
endTime,
durationMs,
success: true,
+ resolvedSecretTraceRegistry,
responseBody,
})
diff --git a/apps/sim/background/webhook-execution.test.ts b/apps/sim/background/webhook-execution.test.ts
index fa44602b09b..c765770fec2 100644
--- a/apps/sim/background/webhook-execution.test.ts
+++ b/apps/sim/background/webhook-execution.test.ts
@@ -4,30 +4,33 @@
import {
dbChainMockFns,
+ environmentUtilsMockFns,
executionPreprocessingMock,
executionPreprocessingMockFns,
+ LoggingSessionMock,
loggingSessionMock,
loggingSessionMockFns,
+ resetEnvironmentUtilsMock,
} from '@sim/testing'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockResolveWebhookRecordProviderConfig,
mockExecuteWorkflowCore,
mockWasExecutionFinalizedByCore,
- mockRecordException,
- mockGetActiveSpan,
mockExecuteWithIdempotency,
mockReleaseExecutionSlot,
mockLoadDeploymentVersionState,
+ mockGetProviderHandler,
+ mockSetResolvedSecretTraceRegistry,
} = vi.hoisted(() => ({
mockResolveWebhookRecordProviderConfig: vi.fn(),
mockExecuteWorkflowCore: vi.fn(),
mockWasExecutionFinalizedByCore: vi.fn(),
- mockRecordException: vi.fn(),
- mockGetActiveSpan: vi.fn(),
mockExecuteWithIdempotency: vi.fn(),
mockReleaseExecutionSlot: vi.fn(),
+ mockGetProviderHandler: vi.fn(() => ({})),
+ mockSetResolvedSecretTraceRegistry: vi.fn(),
mockLoadDeploymentVersionState: vi.fn(
async (_workflowId: string, deploymentVersionId: string) => ({
blocks: {},
@@ -39,9 +42,10 @@ const {
),
}))
-vi.mock('@opentelemetry/api', () => ({
- trace: { getActiveSpan: mockGetActiveSpan },
-}))
+const mockGetEffectiveEnvironmentSnapshot =
+ environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot
+
+afterAll(resetEnvironmentUtilsMock)
vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
@@ -77,9 +81,7 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({
loadWorkflowDeploymentVersionState: mockLoadDeploymentVersionState,
}))
-vi.mock('@/lib/webhooks/providers', () => ({
- getProviderHandler: vi.fn(() => ({})),
-}))
+vi.mock('@/lib/webhooks/providers', () => ({ getProviderHandler: mockGetProviderHandler }))
vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
buildTraceSpans: vi.fn(() => ({ traceSpans: [] })),
@@ -208,6 +210,17 @@ describe('executeWebhookJob fault vs error handling', () => {
beforeEach(() => {
vi.clearAllMocks()
+ LoggingSessionMock.mockImplementation(function LoggingSession() {
+ return {
+ safeStart: loggingSessionMockFns.mockSafeStart,
+ safeComplete: loggingSessionMockFns.mockSafeComplete,
+ safeCompleteWithError: loggingSessionMockFns.mockSafeCompleteWithError,
+ waitForPostExecution: loggingSessionMockFns.mockWaitForPostExecution,
+ markAsFailed: loggingSessionMockFns.mockMarkAsFailed,
+ setResolvedSecretTraceRegistry: mockSetResolvedSecretTraceRegistry,
+ }
+ })
+ mockGetProviderHandler.mockReturnValue({})
mockExecuteWithIdempotency.mockImplementation(
(_provider: string, _key: string, operation: () => Promise) => operation()
)
@@ -225,8 +238,15 @@ describe('executeWebhookJob fault vs error handling', () => {
executionTimeout: { async: 120_000 },
})
mockResolveWebhookRecordProviderConfig.mockImplementation(async (record) => record)
+ mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({
+ personalEncrypted: {},
+ workspaceEncrypted: {},
+ personalDecrypted: {},
+ workspaceDecrypted: {},
+ conflicts: [],
+ decryptionFailures: [],
+ })
dbChainMockFns.limit.mockResolvedValue([{ id: 'webhook-1' }])
- mockGetActiveSpan.mockReturnValue({ recordException: mockRecordException })
})
it('completes the run (does not throw) when the failure was finalized by core', async () => {
@@ -246,17 +266,14 @@ describe('executeWebhookJob fault vs error handling', () => {
expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalled()
// User/workflow errors are already recorded by core — the catch must not re-log them.
expect(loggingSessionMockFns.mockSafeCompleteWithError).not.toHaveBeenCalled()
- // The error is still recorded on the run span so it stays visible in traces.
- expect(mockRecordException).toHaveBeenCalledWith(
- expect.objectContaining({ message: 'Gmail 2 is missing required fields: Label' })
- )
})
it('faults the run (re-throws) when the failure was not finalized by core', async () => {
- mockExecuteWorkflowCore.mockRejectedValue(new Error('Workflow state not found'))
+ const rawError = new Error('Workflow state not found')
+ mockExecuteWorkflowCore.mockRejectedValue(rawError)
mockWasExecutionFinalizedByCore.mockReturnValue(false)
- await expect(executeWebhookJob(payload)).rejects.toThrow('Workflow state not found')
+ await expect(executeWebhookJob(payload)).rejects.toBe(rawError)
// waitForPostExecution must run on every path so the finalized-by-core signal is always reliable.
expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalled()
// Pipeline/infra errors are recorded here before re-throwing to fault the trigger.dev run.
@@ -291,6 +308,92 @@ describe('executeWebhookJob fault vs error handling', () => {
)
})
+ it('passes encrypted webhook resolution provenance into workflow execution', async () => {
+ mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({
+ personalEncrypted: { WEBHOOK_SECRET: 'personal-ciphertext' },
+ workspaceEncrypted: { WEBHOOK_SECRET: 'workspace-ciphertext' },
+ personalDecrypted: { WEBHOOK_SECRET: 'personal-value' },
+ workspaceDecrypted: { WEBHOOK_SECRET: 'workspace-value' },
+ conflicts: ['WEBHOOK_SECRET'],
+ decryptionFailures: [],
+ })
+ mockResolveWebhookRecordProviderConfig.mockImplementation(
+ async (record, _userId, _workspaceId, options) => {
+ options.onResolved('WEBHOOK_SECRET', options.envVars.WEBHOOK_SECRET)
+ return record
+ }
+ )
+ mockExecuteWorkflowCore.mockResolvedValue({
+ success: true,
+ status: 'completed',
+ output: {},
+ logs: [],
+ executionState: {
+ blockStates: {},
+ executedBlocks: [],
+ blockLogs: [],
+ decisions: {},
+ completedLoops: [],
+ activeExecutionPath: [],
+ },
+ })
+
+ await executeWebhookJob(payload)
+
+ expect(mockResolveWebhookRecordProviderConfig).toHaveBeenCalledWith(
+ { id: 'webhook-1' },
+ 'user-1',
+ 'workspace-1',
+ expect.objectContaining({
+ envVars: { WEBHOOK_SECRET: 'workspace-value' },
+ onResolved: expect.any(Function),
+ })
+ )
+ expect(mockExecuteWorkflowCore).toHaveBeenCalledWith(
+ expect.objectContaining({
+ trustedInitialResolvedSecretTraceProvenance: {
+ version: 1,
+ complete: true,
+ entries: [{ name: 'WEBHOOK_SECRET', encryptedValue: 'workspace-ciphertext' }],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ },
+ })
+ )
+ expect(mockSetResolvedSecretTraceRegistry).toHaveBeenCalledOnce()
+ })
+
+ it('installs provenance before a post-resolution webhook setup failure', async () => {
+ const rawMessage = 'Webhook handler exposed activated-secret-value'
+ const rawError = new Error(rawMessage)
+ mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({
+ personalEncrypted: {},
+ workspaceEncrypted: { WEBHOOK_SECRET: 'workspace-ciphertext' },
+ personalDecrypted: {},
+ workspaceDecrypted: { WEBHOOK_SECRET: 'activated-secret-value' },
+ conflicts: [],
+ decryptionFailures: [],
+ })
+ mockResolveWebhookRecordProviderConfig.mockImplementation(
+ async (record, _userId, _workspaceId, options) => {
+ options.onResolved('WEBHOOK_SECRET', options.envVars.WEBHOOK_SECRET)
+ return record
+ }
+ )
+ mockGetProviderHandler.mockReturnValue({
+ formatInput: vi.fn().mockRejectedValue(rawError),
+ })
+
+ await expect(executeWebhookJob(payload)).rejects.toBe(rawError)
+
+ expect(mockSetResolvedSecretTraceRegistry).toHaveBeenCalledOnce()
+ expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: expect.objectContaining({ message: rawMessage }),
+ })
+ )
+ expect(mockExecuteWorkflowCore).not.toHaveBeenCalled()
+ })
+
it('acknowledges and skips queued webhook work after the workflow is undeployed', async () => {
executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValueOnce({
success: true,
diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts
index 2ffb0dcaab8..5bea7691d3a 100644
--- a/apps/sim/background/webhook-execution.ts
+++ b/apps/sim/background/webhook-execution.ts
@@ -1,4 +1,3 @@
-import { trace } from '@opentelemetry/api'
import { db } from '@sim/db'
import { account, webhook } from '@sim/db/schema'
import { createLogger, runWithRequestContext } from '@sim/logger'
@@ -15,6 +14,10 @@ import {
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits'
import { IdempotencyService, webhookIdempotency } from '@/lib/core/idempotency'
+import {
+ type EnvironmentResolutionSnapshot,
+ getEffectiveEnvironmentSnapshot,
+} from '@/lib/environment/utils'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
@@ -22,7 +25,10 @@ import {
type WebhookAttachment,
WebhookAttachmentProcessor,
} from '@/lib/webhooks/attachment-processor'
-import { resolveWebhookRecordProviderConfig } from '@/lib/webhooks/env-resolver'
+import {
+ resolveWebhookRecordProviderConfig,
+ type WebhookEnvResolutionOptions,
+} from '@/lib/webhooks/env-resolver'
import { getProviderHandler } from '@/lib/webhooks/providers'
import {
executeWorkflowCore,
@@ -40,6 +46,10 @@ import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata } from '@/executor/execution/types'
import type { ExecutionResult } from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors'
+import {
+ createIncompleteResolvedSecretTraceRegistry,
+ createResolvedSecretTraceRegistry,
+} from '@/executor/utils/resolved-secret-trace-registry'
import { safeAssign } from '@/tools/safe-assign'
import { getTrigger, isTriggerValid } from '@/triggers'
@@ -316,10 +326,32 @@ export async function resolveWebhookExecutionProviderConfig<
webhookRecord: T,
provider: string,
userId: string,
- workspaceId?: string
+ workspaceId?: string,
+ options?: WebhookEnvResolutionOptions & {
+ onEnvironmentSnapshot?: (snapshot: EnvironmentResolutionSnapshot) => void | Promise
+ }
): Promise }> {
try {
- return await resolveWebhookRecordProviderConfig(webhookRecord, userId, workspaceId)
+ if (!options) {
+ return await resolveWebhookRecordProviderConfig(webhookRecord, userId, workspaceId)
+ }
+
+ const { onEnvironmentSnapshot, ...resolutionOptions } = options
+ if (onEnvironmentSnapshot && resolutionOptions.envVars === undefined) {
+ const snapshot = await getEffectiveEnvironmentSnapshot(userId, workspaceId)
+ await onEnvironmentSnapshot(snapshot)
+ resolutionOptions.envVars = {
+ ...snapshot.personalDecrypted,
+ ...snapshot.workspaceDecrypted,
+ }
+ }
+
+ return await resolveWebhookRecordProviderConfig(
+ webhookRecord,
+ userId,
+ workspaceId,
+ resolutionOptions
+ )
} catch (error) {
const errorMessage = toError(error).message
throw new Error(
@@ -489,11 +521,36 @@ async function executeWebhookJobInternal(
throw new Error(`Webhook record not found: ${payload.webhookId}`)
}
+ const secretScope = { userId: workflowRecord.userId, workspaceId }
+ let resolvedSecretTraceRegistry = createIncompleteResolvedSecretTraceRegistry(secretScope)
const resolvedWebhookRecord = await resolveWebhookExecutionProviderConfig(
webhookRecord,
payload.provider,
workflowRecord.userId,
- workspaceId
+ workspaceId,
+ {
+ onEnvironmentSnapshot: async (secretEnvironment) => {
+ try {
+ resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({
+ personalEncrypted: secretEnvironment.personalEncrypted,
+ workspaceEncrypted: secretEnvironment.workspaceEncrypted,
+ personalDecrypted: secretEnvironment.personalDecrypted,
+ workspaceDecrypted: secretEnvironment.workspaceDecrypted,
+ decryptionFailures: secretEnvironment.decryptionFailures,
+ scope: secretScope,
+ })
+ } catch (error) {
+ logger.warn(`[${requestId}] Failed to build webhook trace secret catalog`, {
+ error: toError(error).message,
+ })
+ resolvedSecretTraceRegistry = createIncompleteResolvedSecretTraceRegistry(secretScope)
+ }
+ loggingSession.setResolvedSecretTraceRegistry(resolvedSecretTraceRegistry)
+ },
+ onResolved: (name, value) => {
+ resolvedSecretTraceRegistry.recordResolved(name, value)
+ },
+ }
)
if (handler.formatInput) {
@@ -665,6 +722,7 @@ async function executeWebhookJobInternal(
snapshot,
callbacks: {},
loggingSession,
+ trustedInitialResolvedSecretTraceProvenance: resolvedSecretTraceRegistry.exportProvenance(),
includeFileBase64: false,
base64MaxBytes: undefined,
abortSignal: timeoutController.signal,
@@ -712,10 +770,6 @@ async function executeWebhookJobInternal(
// not a trigger.dev job fault — complete the run normally so we don't fire a false alert. Errors
// that were not finalized came from the webhook pipeline itself, so we re-throw to fault below.
if (wasExecutionFinalizedByCore(error, executionId)) {
- // Record the exception on the run span so it stays visible in traces without
- // marking the span as ERROR — that status is what faults the trigger.dev run.
- trace.getActiveSpan()?.recordException(toError(error))
-
return {
success: false,
workflowId: payload.workflowId,
@@ -757,6 +811,7 @@ async function executeWebhookJobInternal(
stackTrace: errorStack,
},
traceSpans,
+ executionState: executionResult.executionState,
})
} catch (loggingError) {
logger.error(`[${requestId}] Failed to complete logging session`, loggingError)
diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts
index cce7d55dd3e..cf7a6b09994 100644
--- a/apps/sim/background/workflow-execution.ts
+++ b/apps/sim/background/workflow-execution.ts
@@ -219,6 +219,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
stackTrace: error instanceof Error ? error.stack : undefined,
},
traceSpans,
+ executionState: executionResult?.executionState,
})
throw error
diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts
index fe4bb0e624d..e267183e144 100644
--- a/apps/sim/executor/execution/block-executor.test.ts
+++ b/apps/sim/executor/execution/block-executor.test.ts
@@ -4,11 +4,14 @@
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 { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection'
+import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { BlockType } from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { BlockExecutor } from '@/executor/execution/block-executor'
import { ExecutionState } from '@/executor/execution/state'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
+import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
@@ -436,6 +439,108 @@ describe('BlockExecutor', () => {
expect(output?.error).toBeTruthy()
expect(output).not.toEqual({ content: '' })
})
+
+ it('projects a resolved secret out of Function syntax-error TraceSpans only', async () => {
+ const secret = 'function-secret-literal-7f3a91'
+ const block = createBlock()
+ block.metadata.name = 'Function 1'
+ block.config.params = {
+ code: 'return {{OPENAI_API_KEY}}',
+ language: 'javascript',
+ }
+ const workflow: SerializedWorkflow = {
+ version: '1',
+ blocks: [block],
+ connections: [],
+ loops: {},
+ parallels: {},
+ }
+ const state = new ExecutionState()
+ const registry = new ResolvedSecretTraceRegistry([
+ {
+ name: 'OPENAI_API_KEY',
+ plaintext: secret,
+ encryptedValue: 'encrypted-openai-api-key',
+ },
+ ])
+ const resolver = new VariableResolver(workflow, {}, state)
+ const syntaxError = `Syntax Error: Line 1: \`return ${secret}\` - Invalid or unexpected token`
+ const handler: BlockHandler = {
+ canHandle: () => true,
+ execute: async (_ctx, _block, inputs) => {
+ expect(inputs.code).toBe(`return ${secret}`)
+ throw new Error(syntaxError)
+ },
+ }
+ const executor = 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(),
+ },
+ },
+ state
+ )
+ const ctx = createContext(state)
+ ctx.environmentVariables = { OPENAI_API_KEY: secret }
+ ctx.resolvedSecretTraceRegistry = registry
+
+ await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(
+ `Function 1: ${syntaxError}`
+ )
+
+ expect(registry.getActiveMatches()).toEqual([
+ { plaintext: secret, replacement: '{{OPENAI_API_KEY}}' },
+ ])
+ expect(state.getBlockOutput(block.id)).toEqual({ error: syntaxError })
+ expect(ctx.blockLogs[0]).toMatchObject({
+ input: { code: `return ${secret}` },
+ output: { error: syntaxError },
+ error: syntaxError,
+ })
+
+ const rawLogs = structuredClone(ctx.blockLogs)
+ const { traceSpans: rawTraceSpans } = buildTraceSpans({
+ success: false,
+ output: { error: syntaxError },
+ error: `Function 1: ${syntaxError}`,
+ logs: ctx.blockLogs,
+ })
+ const rawTraceSnapshot = structuredClone(rawTraceSpans)
+ const projectedTraceSpans = await projectTraceSpansForSecrets(rawTraceSpans, {
+ registry,
+ store: {
+ workspaceId: 'workspace-1',
+ workflowId: 'workflow-1',
+ executionId: 'execution-1',
+ userId: 'user-1',
+ },
+ })
+
+ expect(ctx.blockLogs).toEqual(rawLogs)
+ expect(rawTraceSpans).toEqual(rawTraceSnapshot)
+ expect(projectedTraceSpans).toEqual([
+ expect.objectContaining({
+ name: 'Function 1',
+ input: expect.objectContaining({ code: 'return {{OPENAI_API_KEY}}' }),
+ output: {
+ error: 'Syntax Error: Line 1: `return {{OPENAI_API_KEY}}` - Invalid or unexpected token',
+ },
+ }),
+ ])
+ expect(JSON.stringify(projectedTraceSpans)).not.toContain(secret)
+ })
})
describe('BlockExecutor streaming pump', () => {
diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts
index 9c65779a8eb..bebdd37db72 100644
--- a/apps/sim/executor/execution/engine.ts
+++ b/apps/sim/executor/execution/engine.ts
@@ -180,6 +180,7 @@ export class ExecutionEngine {
output: this.finalOutput,
error: errorMessage,
logs: this.context.blockLogs,
+ executionState: this.getSerializableExecutionState(),
metadata: this.context.metadata,
}
diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts
index d73481f6b95..92058b99ed7 100644
--- a/apps/sim/executor/execution/executor.ts
+++ b/apps/sim/executor/execution/executor.ts
@@ -438,6 +438,7 @@ export class DAGExecutor {
},
startRunMetadata: this.contextExtensions.startRunMetadata,
environmentVariables: this.environmentVariables,
+ resolvedSecretTraceRegistry: this.contextExtensions.resolvedSecretTraceRegistry,
workflowVariables: this.workflowVariables,
decisions: {
router: snapshotState?.decisions?.router
diff --git a/apps/sim/executor/execution/snapshot-serializer.test.ts b/apps/sim/executor/execution/snapshot-serializer.test.ts
index c8630a77748..2c44d6c2a0c 100644
--- a/apps/sim/executor/execution/snapshot-serializer.test.ts
+++ b/apps/sim/executor/execution/snapshot-serializer.test.ts
@@ -6,6 +6,7 @@ import type { DAG, DAGNode } from '@/executor/dag/builder'
import { EdgeManager } from '@/executor/execution/edge-manager'
import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer'
import type { ExecutionContext } from '@/executor/types'
+import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
function createContext(overrides: Partial = {}): ExecutionContext {
return {
@@ -38,6 +39,45 @@ function createContext(overrides: Partial = {}): ExecutionCont
}
describe('serializePauseSnapshot', () => {
+ it('persists encrypted resolved-secret provenance and the source execution id', () => {
+ const registry = new ResolvedSecretTraceRegistry([
+ { name: 'TOKEN', plaintext: 'raw-secret', encryptedValue: 'ciphertext' },
+ ])
+ registry.recordResolved('TOKEN', 'raw-secret')
+ const context = createContext({ resolvedSecretTraceRegistry: registry })
+
+ const snapshot = serializePauseSnapshot(context, ['next-block'])
+ const serialized = JSON.parse(snapshot.snapshot)
+
+ expect(serialized.state.sourceExecutionId).toBe('execution-1')
+ expect(serialized.state.resolvedSecretTraceProvenance).toEqual({
+ version: 1,
+ complete: true,
+ entries: [{ name: 'TOKEN', encryptedValue: 'ciphertext' }],
+ })
+ expect(snapshot.snapshot).not.toContain('raw-secret')
+ })
+
+ it('persists a complete zero-entry provenance state for a fresh execution', () => {
+ const registry = new ResolvedSecretTraceRegistry([], {
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ })
+
+ const snapshot = serializePauseSnapshot(
+ createContext({ resolvedSecretTraceRegistry: registry }),
+ ['next-block']
+ )
+ const serialized = JSON.parse(snapshot.snapshot)
+
+ expect(serialized.state.resolvedSecretTraceProvenance).toEqual({
+ version: 1,
+ complete: true,
+ entries: [],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ })
+ })
+
it('serializes batched parallel accumulated outputs for cross-process resume', () => {
const context = createContext({
parallelExecutions: new Map([
diff --git a/apps/sim/executor/execution/snapshot-serializer.ts b/apps/sim/executor/execution/snapshot-serializer.ts
index d6025fcc0fd..fe8721a4875 100644
--- a/apps/sim/executor/execution/snapshot-serializer.ts
+++ b/apps/sim/executor/execution/snapshot-serializer.ts
@@ -224,6 +224,19 @@ export function serializePauseSnapshot(
dagIncomingEdges,
deactivatedEdges: edgeManager?.getDeactivatedEdges(),
nodesWithActivatedEdge: edgeManager?.getNodesWithActivatedEdge(),
+ sourceExecutionId: context.executionId,
+ trustedLargeValueAccess: {
+ executionIds: Array.from(
+ new Set(
+ [context.executionId, ...(context.largeValueExecutionIds ?? [])].filter(
+ (id): id is string => Boolean(id)
+ )
+ )
+ ),
+ largeValueKeys: Array.from(new Set(context.largeValueKeys ?? [])),
+ fileKeys: Array.from(new Set(context.fileKeys ?? [])),
+ },
+ resolvedSecretTraceProvenance: context.resolvedSecretTraceRegistry?.exportProvenance(),
}
assertSnapshotValueIsCompact(context.workflowVariables, 'workflow variables')
diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts
index 02723e9fcdf..23a03cbd7a0 100644
--- a/apps/sim/executor/execution/types.ts
+++ b/apps/sim/executor/execution/types.ts
@@ -10,6 +10,10 @@ import type {
StartBlockRunMetadata,
StreamingExecution,
} from '@/executor/types'
+import type {
+ ResolvedSecretTraceProvenanceV1,
+ ResolvedSecretTraceRegistry,
+} from '@/executor/utils/resolved-secret-trace-registry'
import type { RunFromBlockContext } from '@/executor/utils/run-from-block'
import type { SubflowType } from '@/stores/workflows/workflow/types'
@@ -88,6 +92,16 @@ export interface SerializableExecutionState {
deactivatedEdges?: string[]
nodesWithActivatedEdge?: string[]
completedPauseContexts?: string[]
+ /** Server execution that produced this state; callers must still verify it against storage. */
+ sourceExecutionId?: string
+ /** Server-only closure authorizing offloaded values carried by trusted restored state. */
+ trustedLargeValueAccess?: {
+ executionIds: string[]
+ largeValueKeys: string[]
+ fileKeys: string[]
+ }
+ /** Encrypted-only provenance for Secrets-tab values resolved during this execution. */
+ resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
}
/**
@@ -144,6 +158,17 @@ export interface ChildWorkflowContext {
depth: number
}
+export interface BlockCompletionCallbackData {
+ input?: unknown
+ output: NormalizedBlockOutput
+ executionTime: number
+ startedAt: string
+ executionOrder: number
+ endedAt: string
+ /** Per-invocation unique ID linking this workflow block execution to its child block events. */
+ childWorkflowInstanceId?: string
+}
+
export interface ExecutionCallbacks {
onStream?: (streamingExec: StreamingExecution) => Promise
onBlockStart?: (
@@ -158,7 +183,7 @@ export interface ExecutionCallbacks {
blockId: string,
blockName: string,
blockType: string,
- output: any,
+ output: BlockCompletionCallbackData,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => Promise
@@ -214,6 +239,7 @@ export interface ContextExtensions {
}>
dagIncomingEdges?: Record
snapshotState?: SerializableExecutionState
+ resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
metadata?: ExecutionMetadata
/**
* Trusted run metadata injected into the Start block output when its
@@ -247,16 +273,7 @@ export interface ContextExtensions {
blockId: string,
blockName: string,
blockType: string,
- output: {
- input?: any
- output: NormalizedBlockOutput
- executionTime: number
- startedAt: string
- executionOrder: number
- endedAt: string
- /** Per-invocation unique ID linking this workflow block execution to its child block events. */
- childWorkflowInstanceId?: string
- },
+ output: BlockCompletionCallbackData,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => Promise
diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts
index 968097b2014..43ab9d7c1b0 100644
--- a/apps/sim/executor/handlers/agent/agent-handler.ts
+++ b/apps/sim/executor/handlers/agent/agent-handler.ts
@@ -1182,48 +1182,55 @@ export class AgentBlockHandler implements BlockHandler {
const { blockData, blockNameMapping } = collectBlockData(ctx)
- const response = await executeProviderRequest(providerId, {
- model,
- systemPrompt: 'systemPrompt' in providerRequest ? providerRequest.systemPrompt : undefined,
- context: 'context' in providerRequest ? providerRequest.context : undefined,
- tools: providerRequest.tools,
- temperature: providerRequest.temperature,
- maxTokens: providerRequest.maxTokens,
- apiKey: finalApiKey,
- azureEndpoint: providerRequest.azureEndpoint,
- azureApiVersion: providerRequest.azureApiVersion,
- vertexProject: providerRequest.vertexProject,
- vertexLocation: providerRequest.vertexLocation,
- bedrockAccessKeyId: providerRequest.bedrockAccessKeyId,
- bedrockSecretKey: providerRequest.bedrockSecretKey,
- bedrockRegion: providerRequest.bedrockRegion,
- responseFormat: providerRequest.responseFormat,
- workflowId: providerRequest.workflowId,
- workspaceId: ctx.workspaceId,
- userId: ctx.userId,
- stream: providerRequest.stream,
- messages: 'messages' in providerRequest ? providerRequest.messages : undefined,
- environmentVariables: normalizeStringRecord(ctx.environmentVariables),
- workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
- blockData,
- blockNameMapping,
- isDeployedContext: ctx.isDeployedContext,
- callChain: ctx.callChain,
- billingAttribution: ctx.metadata.billingAttribution,
- // Reaches tool `_context` via `prepareToolExecution`, so a tool that starts
- // its own child execution (a custom block) correlates and cancels against
- // this real run instead of minting a phantom id.
- executionId: ctx.executionId,
- reasoningEffort: providerRequest.reasoningEffort,
- verbosity: providerRequest.verbosity,
- thinkingLevel: providerRequest.thinkingLevel,
- promptCaching: providerRequest.promptCaching,
- // Stable per-block identity; providers use it to route cache lookups.
- blockId: block.id,
- previousInteractionId: providerRequest.previousInteractionId,
- agentEvents: providerRequest.agentEvents,
- abortSignal: ctx.abortSignal,
- })
+ const response = await executeProviderRequest(
+ providerId,
+ {
+ model,
+ systemPrompt:
+ 'systemPrompt' in providerRequest ? providerRequest.systemPrompt : undefined,
+ context: 'context' in providerRequest ? providerRequest.context : undefined,
+ tools: providerRequest.tools,
+ temperature: providerRequest.temperature,
+ maxTokens: providerRequest.maxTokens,
+ apiKey: finalApiKey,
+ azureEndpoint: providerRequest.azureEndpoint,
+ azureApiVersion: providerRequest.azureApiVersion,
+ vertexProject: providerRequest.vertexProject,
+ vertexLocation: providerRequest.vertexLocation,
+ bedrockAccessKeyId: providerRequest.bedrockAccessKeyId,
+ bedrockSecretKey: providerRequest.bedrockSecretKey,
+ bedrockRegion: providerRequest.bedrockRegion,
+ responseFormat: providerRequest.responseFormat,
+ workflowId: providerRequest.workflowId,
+ workspaceId: ctx.workspaceId,
+ userId: ctx.userId,
+ stream: providerRequest.stream,
+ messages: 'messages' in providerRequest ? providerRequest.messages : undefined,
+ environmentVariables: normalizeStringRecord(ctx.environmentVariables),
+ workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
+ blockData,
+ blockNameMapping,
+ isDeployedContext: ctx.isDeployedContext,
+ callChain: ctx.callChain,
+ billingAttribution: ctx.metadata.billingAttribution,
+ // Reaches tool `_context` via `prepareToolExecution`, so a tool that starts
+ // its own child execution (a custom block) correlates and cancels against
+ // this real run instead of minting a phantom id.
+ executionId: ctx.executionId,
+ reasoningEffort: providerRequest.reasoningEffort,
+ verbosity: providerRequest.verbosity,
+ thinkingLevel: providerRequest.thinkingLevel,
+ promptCaching: providerRequest.promptCaching,
+ // Stable per-block identity; providers use it to route cache lookups.
+ blockId: block.id,
+ previousInteractionId: providerRequest.previousInteractionId,
+ agentEvents: providerRequest.agentEvents,
+ abortSignal: ctx.abortSignal,
+ },
+ {
+ resolvedSecretTraceRegistry: ctx.resolvedSecretTraceRegistry,
+ }
+ )
return this.processProviderResponse(response, block, responseFormat)
} catch (error) {
diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts
index 69098fae148..c70776aba80 100644
--- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts
+++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler'
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
+import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import type { SerializedBlock } from '@/serializer/types'
const BILLING_ATTRIBUTION = {
@@ -20,6 +21,13 @@ const BILLING_ATTRIBUTION = {
payerSubscription: null,
} as const
+const PRIVATE_PROVENANCE_TYPE = 'resolved-secret-provenance-v1'
+const PRIVATE_PROVENANCE = {
+ version: 1,
+ complete: true,
+ entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }],
+}
+
const {
mockBuildAuthHeaders,
mockBuildAPIUrl,
@@ -96,6 +104,19 @@ async function readStreamText(stream: ReadableStream): Promise {
return text
}
+function createTraceRegistryMock(): ResolvedSecretTraceRegistry & {
+ importProvenance: ReturnType
+ markIncomplete: ReturnType
+} {
+ return {
+ importProvenance: vi.fn().mockResolvedValue(true),
+ markIncomplete: vi.fn(),
+ } as unknown as ResolvedSecretTraceRegistry & {
+ importProvenance: ReturnType
+ markIncomplete: ReturnType
+ }
+}
+
describe('MothershipBlockHandler', () => {
let handler: MothershipBlockHandler
let block: SerializedBlock
@@ -152,7 +173,7 @@ describe('MothershipBlockHandler', () => {
resetEnvMock()
})
- function createNdjsonResponse(events: unknown[]): Response {
+ function createNdjsonResponse(events: unknown[], headers: Record = {}): Response {
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
@@ -165,11 +186,146 @@ describe('MothershipBlockHandler', () => {
}),
{
status: 200,
- headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
+ headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8', ...headers },
}
)
}
+ it('imports marker-gated JSON provenance without exposing it in block output', async () => {
+ const registry = createTraceRegistryMock()
+ context.resolvedSecretTraceRegistry = registry
+ mockGenerateId
+ .mockReturnValueOnce('chat-uuid')
+ .mockReturnValueOnce('message-uuid')
+ .mockReturnValueOnce('request-uuid')
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ content: 'raw secret remains functional',
+ toolCalls: [],
+ __resolvedSecretTraceProvenance: PRIVATE_PROVENANCE,
+ }),
+ {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-sim-private-tool-metadata': PRIVATE_PROVENANCE_TYPE,
+ },
+ }
+ )
+ )
+
+ const result = await handler.execute(context, block, { prompt: 'Hello' })
+
+ const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
+ expect(options.headers).toMatchObject({
+ 'x-sim-request-private-tool-metadata': PRIVATE_PROVENANCE_TYPE,
+ })
+ expect(registry.importProvenance).toHaveBeenCalledWith(PRIVATE_PROVENANCE, {
+ trusted: true,
+ })
+ expect(registry.markIncomplete).not.toHaveBeenCalled()
+ expect(result).toMatchObject({ content: 'raw secret remains functional' })
+ expect(JSON.stringify(result)).not.toContain('__resolvedSecretTraceProvenance')
+ expect(JSON.stringify(result)).not.toContain('encrypted-secret')
+ })
+
+ it('rejects unmarked provenance while keeping private metadata out of block output', async () => {
+ const registry = createTraceRegistryMock()
+ context.resolvedSecretTraceRegistry = registry
+ mockGenerateId
+ .mockReturnValueOnce('chat-uuid')
+ .mockReturnValueOnce('message-uuid')
+ .mockReturnValueOnce('request-uuid')
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ content: 'unchanged output',
+ toolCalls: [],
+ __resolvedSecretTraceProvenance: PRIVATE_PROVENANCE,
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ )
+ )
+
+ const result = await handler.execute(context, block, { prompt: 'Hello' })
+
+ expect(registry.importProvenance).not.toHaveBeenCalled()
+ expect(registry.markIncomplete).toHaveBeenCalledOnce()
+ expect(result).toMatchObject({ content: 'unchanged output' })
+ expect(JSON.stringify(result)).not.toContain('__resolvedSecretTraceProvenance')
+ expect(JSON.stringify(result)).not.toContain('encrypted-secret')
+ })
+
+ it('imports provenance from a terminal NDJSON error without forcing structural fallback', async () => {
+ const registry = createTraceRegistryMock()
+ context.resolvedSecretTraceRegistry = registry
+ mockGenerateId
+ .mockReturnValueOnce('chat-uuid')
+ .mockReturnValueOnce('message-uuid')
+ .mockReturnValueOnce('request-uuid')
+ fetchMock.mockResolvedValue(
+ createNdjsonResponse(
+ [
+ {
+ type: 'error',
+ error: 'secret-backed failure',
+ __resolvedSecretTraceProvenance: PRIVATE_PROVENANCE,
+ },
+ ],
+ { 'x-sim-private-tool-metadata': PRIVATE_PROVENANCE_TYPE }
+ )
+ )
+
+ await expect(handler.execute(context, block, { prompt: 'Hello' })).rejects.toThrow(
+ 'Sim execution failed: secret-backed failure'
+ )
+
+ expect(registry.importProvenance).toHaveBeenCalledWith(PRIVATE_PROVENANCE, {
+ trusted: true,
+ })
+ expect(registry.markIncomplete).not.toHaveBeenCalled()
+ })
+
+ it('imports final provenance for selected-output streaming without adding it to output', async () => {
+ const registry = createTraceRegistryMock()
+ context.resolvedSecretTraceRegistry = registry
+ context.stream = true
+ context.selectedOutputs = [`${block.id}_content`]
+ mockGenerateId
+ .mockReturnValueOnce('chat-uuid')
+ .mockReturnValueOnce('message-uuid')
+ .mockReturnValueOnce('request-uuid')
+ fetchMock.mockResolvedValue(
+ createNdjsonResponse(
+ [
+ { type: 'chunk', content: 'unchanged' },
+ {
+ type: 'final',
+ data: {
+ content: 'unchanged',
+ toolCalls: [],
+ __resolvedSecretTraceProvenance: PRIVATE_PROVENANCE,
+ },
+ },
+ ],
+ { 'x-sim-private-tool-metadata': PRIVATE_PROVENANCE_TYPE }
+ )
+ )
+
+ const result = (await handler.execute(context, block, {
+ prompt: 'Hello',
+ })) as StreamingExecution
+
+ await expect(readStreamText(result.stream)).resolves.toBe('unchanged')
+ expect(registry.importProvenance).toHaveBeenCalledWith(PRIVATE_PROVENANCE, {
+ trusted: true,
+ })
+ expect(registry.markIncomplete).not.toHaveBeenCalled()
+ expect(JSON.stringify(result.execution.output)).not.toContain('__resolvedSecretTraceProvenance')
+ expect(JSON.stringify(result.execution.output)).not.toContain('encrypted-secret')
+ })
+
it('forwards workflow and execution metadata with generated UUID ids', async () => {
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts
index 7a082b731e1..b12dea62f66 100644
--- a/apps/sim/executor/handlers/mothership/mothership-handler.ts
+++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts
@@ -8,6 +8,12 @@ import {
import { env } from '@/lib/core/config/env'
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
import { readUserFileContent } from '@/lib/execution/payloads/materialization.server'
+import {
+ PRIVATE_TOOL_METADATA_REQUEST_HEADER,
+ RESOLVED_SECRET_PROVENANCE_FIELD,
+ RESOLVED_SECRET_PROVENANCE_METADATA_V1,
+ responseHasPrivateToolMetadata,
+} from '@/lib/execution/private-tool-metadata'
import {
createFileContentFromBase64,
type MessageContent,
@@ -24,6 +30,7 @@ import type {
StreamingExecution,
} from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
+import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('MothershipBlockHandler')
@@ -43,13 +50,31 @@ type MothershipExecuteResult = {
tokens?: Record
toolCalls?: Array>
cost?: unknown
-}
+} & Partial>
type MothershipExecuteStreamEvent =
| { type: 'heartbeat'; timestamp?: string }
| { type: 'chunk'; content?: string }
| { type: 'final'; data: MothershipExecuteResult }
- | { type: 'error'; error?: string }
+ | ({ type: 'error'; error?: string } & Partial<
+ Record
+ >)
+
+async function consumeMothershipProvenance(
+ payload: Partial>,
+ response: Response,
+ registry?: ResolvedSecretTraceRegistry
+): Promise {
+ if (!registry) return true
+ if (
+ !responseHasPrivateToolMetadata(response.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1) ||
+ !Object.hasOwn(payload, RESOLVED_SECRET_PROVENANCE_FIELD)
+ ) {
+ registry.markIncomplete()
+ return false
+ }
+ return registry.importProvenance(payload[RESOLVED_SECRET_PROVENANCE_FIELD], { trusted: true })
+}
function parseMothershipExecuteStreamLine(line: string): MothershipExecuteStreamEvent | undefined {
const trimmed = line.trim()
@@ -100,10 +125,15 @@ function isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedB
)
}
-async function readMothershipExecuteResponse(response: Response): Promise {
+async function readMothershipExecuteResponse(
+ response: Response,
+ registry?: ResolvedSecretTraceRegistry
+): Promise {
const contentType = response.headers.get('content-type') || ''
if (!contentType.includes('application/x-ndjson')) {
- return response.json()
+ const result = (await response.json()) as MothershipExecuteResult
+ await consumeMothershipProvenance(result, response, registry)
+ return result
}
if (!response.body) {
@@ -114,8 +144,9 @@ async function readMothershipExecuteResponse(response: Response): Promise {
+ const processLine = async (line: string): Promise => {
const event = parseMothershipExecuteStreamLine(line)
if (!event) return
@@ -124,10 +155,12 @@ async function readMothershipExecuteResponse(response: Response): Promise void
onDone?: () => void
+ registry?: ResolvedSecretTraceRegistry
} = {}
): StreamingExecution {
if (!response.body) {
@@ -191,8 +226,9 @@ function createMothershipStreamingExecution(
const encoder = new TextEncoder()
let buffer = ''
let sawFinal = false
+ let receivedTerminalProvenance = false
- const processLine = (line: string) => {
+ const processLine = async (line: string): Promise => {
const event = parseMothershipExecuteStreamLine(line)
if (!event) return
@@ -208,10 +244,16 @@ function createMothershipStreamingExecution(
}
if (event.type === 'error') {
+ receivedTerminalProvenance = await consumeMothershipProvenance(
+ event,
+ response,
+ options.registry
+ )
throw new Error(`Sim execution failed: ${event.error || 'Unknown error'}`)
}
if (event.type === 'final') {
+ await consumeMothershipProvenance(event.data, response, options.registry)
sawFinal = true
Object.assign(output, formatMothershipBlockOutput(event.data, fallbackChatId))
return
@@ -230,12 +272,12 @@ function createMothershipStreamingExecution(
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
- processLine(line)
+ await processLine(line)
}
}
buffer += decoder.decode()
- processLine(buffer)
+ await processLine(buffer)
if (!sawFinal) {
throw new Error('Sim execution stream ended without a final result')
@@ -249,6 +291,7 @@ function createMothershipStreamingExecution(
controller.error(error)
}
} finally {
+ if (!sawFinal && !receivedTerminalProvenance) options.registry?.markIncomplete()
cleanup()
reader?.releaseLock()
}
@@ -382,6 +425,9 @@ export class MothershipBlockHandler implements BlockHandler {
const headers = await buildAuthHeaders(ctx.userId)
headers.Accept = 'application/x-ndjson'
headers[MOTHERSHIP_EXECUTE_STREAM_HEADER] = MOTHERSHIP_EXECUTE_STREAM_VALUE
+ if (ctx.resolvedSecretTraceRegistry) {
+ headers[PRIVATE_TOOL_METADATA_REQUEST_HEADER] = RESOLVED_SECRET_PROVENANCE_METADATA_V1
+ }
if (!ctx.metadata.billingAttribution) {
throw new Error('Billing attribution is required for Mothership execution')
}
@@ -474,6 +520,14 @@ export class MothershipBlockHandler implements BlockHandler {
})
if (!response.ok) {
+ if (ctx.resolvedSecretTraceRegistry) {
+ try {
+ const payload = (await response.clone().json()) as MothershipExecuteResult
+ await consumeMothershipProvenance(payload, response, ctx.resolvedSecretTraceRegistry)
+ } catch {
+ ctx.resolvedSecretTraceRegistry.markIncomplete()
+ }
+ }
const errorMsg = await extractAPIErrorMessage(response)
throw new Error(`Sim execution failed: ${errorMsg}`)
}
@@ -486,12 +540,13 @@ export class MothershipBlockHandler implements BlockHandler {
}
},
onDone: cleanupAbortListeners,
+ registry: ctx.resolvedSecretTraceRegistry,
})
cleanupImmediately = false
return streamingExecution
}
- const result = await readMothershipExecuteResponse(response)
+ const result = await readMothershipExecuteResponse(response, ctx.resolvedSecretTraceRegistry)
return formatMothershipBlockOutput(result, chatId)
} finally {
if (cleanupImmediately) {
diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts
index 39898e9148d..ff7a3692004 100644
--- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts
+++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts
@@ -18,6 +18,7 @@ import {
buildCustomBlockExecutionContext,
runCustomBlockTool,
} from '@/executor/handlers/workflow/custom-block-tool-runner'
+import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
describe('buildCustomBlockExecutionContext', () => {
it('carries consumer identity, inherits the call chain, and is fully scaffolded', () => {
@@ -145,3 +146,16 @@ describe('buildCustomBlockExecutionContext cancellation', () => {
expect(buildCustomBlockExecutionContext({ workspaceId: 'ws-1' }).abortSignal).toBeUndefined()
})
})
+
+describe('buildCustomBlockExecutionContext secret provenance', () => {
+ it('carries the server-only parent registry without putting it in model parameters', () => {
+ const registry = new ResolvedSecretTraceRegistry()
+
+ const ctx = buildCustomBlockExecutionContext(
+ { workspaceId: 'ws-1' },
+ { resolvedSecretTraceRegistry: registry }
+ )
+
+ expect(ctx.resolvedSecretTraceRegistry).toBe(registry)
+ })
+})
diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts
index ce8ca3b67c8..79539ced526 100644
--- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts
+++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts
@@ -4,6 +4,7 @@ import { generateId } from '@sim/utils/id'
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
import type { ExecutionContext } from '@/executor/types'
+import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import type { SerializedBlock } from '@/serializer/types'
import type { ToolResponse } from '@/tools/types'
@@ -49,7 +50,10 @@ interface CustomBlockToolParams {
*/
export function buildCustomBlockExecutionContext(
context: CustomBlockExecutorContext,
- options: { abortSignal?: AbortSignal } = {}
+ options: {
+ abortSignal?: AbortSignal
+ resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
+ } = {}
): 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.
@@ -66,6 +70,7 @@ export function buildCustomBlockExecutionContext(
// Without this the child's cancellation bridge has nothing to abort on:
// the agent tool loop owns the only signal reaching this path.
abortSignal: options.abortSignal,
+ resolvedSecretTraceRegistry: options.resolvedSecretTraceRegistry,
environmentVariables: {},
blockStates: new Map(),
executedBlocks: new Set(),
@@ -102,7 +107,10 @@ export function buildCustomBlockExecutionContext(
*/
export async function runCustomBlockTool(
params: CustomBlockToolParams,
- options: { abortSignal?: AbortSignal } = {}
+ options: {
+ abortSignal?: AbortSignal
+ resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
+ } = {}
): Promise {
if (!params.blockType) {
return { success: false, output: {}, error: 'Missing custom block type' }
@@ -110,6 +118,7 @@ export async function runCustomBlockTool(
const ctx = buildCustomBlockExecutionContext(params._context ?? {}, {
abortSignal: options.abortSignal,
+ resolvedSecretTraceRegistry: options.resolvedSecretTraceRegistry,
})
const block: SerializedBlock = {
id: generateId(),
diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
index 9acbc2a1905..ac8e0c3458e 100644
--- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
+++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
@@ -1,4 +1,4 @@
-import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing'
+import { encryptionMockFns, environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing'
import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { getBlock } from '@/blocks/registry'
import { BlockType } from '@/executor/constants'
@@ -9,6 +9,10 @@ import {
WorkflowBlockHandler,
} from '@/executor/handlers/workflow/workflow-handler'
import type { ExecutionContext } from '@/executor/types'
+import {
+ ANONYMOUS_SECRET_TRACE_REPLACEMENT,
+ ResolvedSecretTraceRegistry,
+} from '@/executor/utils/resolved-secret-trace-registry'
import type { SerializedBlock } from '@/serializer/types'
const {
@@ -24,6 +28,8 @@ const {
mockSafeComplete,
mockSafeCompleteWithError,
mockSafeCompleteWithCancellation,
+ mockSetResolvedSecretTraceRegistry,
+ mockSetTraceLargeValueAccess,
mockDispose,
executorOptions,
loggingSessionArgs,
@@ -40,6 +46,8 @@ const {
mockSafeComplete: vi.fn(),
mockSafeCompleteWithError: vi.fn(),
mockSafeCompleteWithCancellation: vi.fn(),
+ mockSetResolvedSecretTraceRegistry: vi.fn(),
+ mockSetTraceLargeValueAccess: vi.fn(),
mockDispose: vi.fn(),
executorOptions: [] as Array>,
loggingSessionArgs: [] as Array,
@@ -54,6 +62,8 @@ vi.mock('@/lib/logs/execution/logging-session', () => ({
safeComplete = mockSafeComplete
safeCompleteWithError = mockSafeCompleteWithError
safeCompleteWithCancellation = mockSafeCompleteWithCancellation
+ setResolvedSecretTraceRegistry = mockSetResolvedSecretTraceRegistry
+ setTraceLargeValueAccess = mockSetTraceLargeValueAccess
onBlockStart = vi.fn()
onBlockComplete = vi.fn()
},
@@ -63,6 +73,11 @@ vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
buildTraceSpans: mockBuildTraceSpans,
}))
+vi.mock('@/lib/core/security/encryption', () => ({
+ decryptSecret: encryptionMockFns.mockDecryptSecret,
+ encryptSecret: encryptionMockFns.mockEncryptSecret,
+}))
+
vi.mock('@/lib/workflows/custom-blocks/child-execution', () => ({
admitCustomBlockChildExecution: mockAdmitCustomBlockChildExecution,
trackChildRun: mockTrackChildRun,
@@ -1147,6 +1162,54 @@ describe('WorkflowBlockHandler', () => {
expect(ctx.largeValueExecutionIds).toContain('grandchild-execution-id')
})
+ it('imports only publisher secret provenance that crosses the curated output boundary', async () => {
+ encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({
+ decrypted: 'publisher-secret',
+ })
+ mockGetPersonalAndWorkspaceEnv.mockResolvedValueOnce({
+ personalDecrypted: { SECRET: 'publisher-secret', UNUSED: 'unused-secret' },
+ workspaceDecrypted: {},
+ personalEncrypted: {
+ SECRET: 'publisher-ciphertext',
+ UNUSED: 'unused-ciphertext',
+ },
+ workspaceEncrypted: {},
+ decryptionFailures: [],
+ })
+ mockExecutorExecute.mockImplementationOnce(async () => {
+ const childRegistry = executorOptions.at(-1)?.contextExtensions
+ .resolvedSecretTraceRegistry as ResolvedSecretTraceRegistry
+ childRegistry.recordResolved('SECRET', 'publisher-secret')
+ childRegistry.recordResolved('UNUSED', 'unused-secret')
+ return {
+ success: true,
+ output: {},
+ logs: [
+ {
+ blockId: 'b1',
+ success: true,
+ output: { content: 'value=publisher-secret' },
+ },
+ ],
+ }
+ })
+ const parentRegistry = new ResolvedSecretTraceRegistry()
+ const result = await handler.execute(
+ customBlockContext({ resolvedSecretTraceRegistry: parentRegistry }),
+ customBlock(),
+ {}
+ )
+
+ expect(result).toMatchObject({ answer: 'value=publisher-secret', success: true })
+ expect(parentRegistry.getActiveMatches()).toEqual([
+ {
+ plaintext: 'publisher-secret',
+ replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT,
+ },
+ ])
+ expect(mockSetResolvedSecretTraceRegistry).toHaveBeenCalledTimes(1)
+ })
+
it('does not duplicate ids across repeated invocations', async () => {
const ctx = customBlockContext()
await handler.execute(ctx, customBlock(), {})
@@ -1179,9 +1242,19 @@ describe('WorkflowBlockHandler', () => {
})
it('completes the child session and disposes the cancellation bridge', async () => {
+ const executionState = {
+ blockStates: { 'function-1': { output: { result: 'raw-secret-value' } } },
+ }
+ mockExecutorExecute.mockResolvedValue({
+ success: true,
+ output: { data: 'ok' },
+ executionState,
+ })
+
await handler.execute(customBlockContext(), customBlock(), {})
expect(mockSafeComplete).toHaveBeenCalledTimes(1)
+ expect(mockSafeComplete).toHaveBeenCalledWith(expect.objectContaining({ executionState }))
expect(mockSafeCompleteWithError).not.toHaveBeenCalled()
expect(mockDispose).toHaveBeenCalledTimes(1)
})
@@ -1189,15 +1262,22 @@ describe('WorkflowBlockHandler', () => {
it('records a cancelled child through the cancellation path', async () => {
// Production shape: the engine reports cancellation as `success: false`
// plus `status: 'cancelled'` on the ExecutionResult (never on metadata).
+ const executionState = {
+ blockStates: { 'function-1': { output: { result: 'raw-secret-value' } } },
+ }
mockExecutorExecute.mockResolvedValue({
success: false,
output: {},
status: 'cancelled',
+ executionState,
})
await handler.execute(customBlockContext(), customBlock(), {}).catch(() => {})
expect(mockSafeCompleteWithCancellation).toHaveBeenCalledTimes(1)
+ expect(mockSafeCompleteWithCancellation).toHaveBeenCalledWith(
+ expect.objectContaining({ executionState })
+ )
expect(mockSafeComplete).not.toHaveBeenCalled()
// Already finalized as cancelled — must not be re-completed as an error.
expect(mockSafeCompleteWithError).not.toHaveBeenCalled()
@@ -1407,12 +1487,14 @@ describe('WorkflowBlockHandler', () => {
})
it('leaves regular workflow blocks entirely alone', async () => {
+ const registry = new ResolvedSecretTraceRegistry()
const ctx = {
...mockContext,
workspaceId: 'workspace-1',
executionId: 'parent-execution-id',
onBlockStart: vi.fn(),
onStream: vi.fn(),
+ resolvedSecretTraceRegistry: registry,
} as unknown as ExecutionContext
mockFetch.mockResolvedValue({
ok: true,
@@ -1431,6 +1513,7 @@ describe('WorkflowBlockHandler', () => {
expect(loggingSessionArgs).toHaveLength(0)
const extensions = executorOptions[0].contextExtensions
expect(extensions.executionId).toBe('parent-execution-id')
+ expect(extensions.resolvedSecretTraceRegistry).toBe(registry)
expect(extensions.onStream).toBe(ctx.onStream)
expect(extensions.childWorkflowContext).toBeDefined()
})
diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts
index 07648a6c368..97ded3b267e 100644
--- a/apps/sim/executor/handlers/workflow/workflow-handler.ts
+++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts
@@ -45,6 +45,7 @@ import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { getIterationContext } from '@/executor/utils/iteration-context'
import { parseJSON } from '@/executor/utils/json'
import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup'
+import { createResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import { isRunMetadataEnabled, resolveExecutorStartBlock } from '@/executor/utils/start-block'
import { Serializer } from '@/serializer'
import type { SerializedBlock } from '@/serializer/types'
@@ -269,6 +270,7 @@ export class WorkflowBlockHandler implements BlockHandler {
/** Set for custom blocks only: the child's own execution id / log row. */
let childExecutionId: string | undefined
let childSession: LoggingSession | undefined
+ let childResolvedSecretTraceRegistry = ctx.resolvedSecretTraceRegistry
let childSessionStarted = false
/** Set once the child's session reached a terminal state, so the catch doesn't re-complete it. */
let childSessionFinalized = false
@@ -431,6 +433,24 @@ export class WorkflowBlockHandler implements BlockHandler {
...ownerEnv.personalEncrypted,
...ownerEnv.workspaceEncrypted,
}
+ childResolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({
+ personalEncrypted: ownerEnv.personalEncrypted,
+ workspaceEncrypted: ownerEnv.workspaceEncrypted,
+ personalDecrypted: ownerEnv.personalDecrypted,
+ workspaceDecrypted: ownerEnv.workspaceDecrypted,
+ decryptionFailures: ownerEnv.decryptionFailures,
+ scope: { userId: loadUserId, workspaceId: sourceWorkspaceId },
+ })
+ if (ctx.resolvedSecretTraceRegistry) {
+ const crossingProvenance = ctx.resolvedSecretTraceRegistry.exportProvenanceForValue(
+ childWorkflowInput,
+ { anonymous: true }
+ )
+ await childResolvedSecretTraceRegistry.importProvenance(crossingProvenance, {
+ trusted: true,
+ anonymous: true,
+ })
+ }
// Custom-block children authenticate internal tool calls as the source
// owner in the source workspace, so the consumer's snapshot would fail
// the internal routes' actor/workspace scope match. Resolve the
@@ -459,6 +479,7 @@ export class WorkflowBlockHandler implements BlockHandler {
// child is part of that same logical run and must not add a second.
{ baseExecutionCharge: 0 }
)
+ childSession.setResolvedSecretTraceRegistry(childResolvedSecretTraceRegistry)
const correlation = buildCustomBlockCorrelation({
invokerExecutionId: ctx.executionId,
invokerRequestId: ctx.metadata.requestId,
@@ -509,6 +530,12 @@ export class WorkflowBlockHandler implements BlockHandler {
for (const id of [ctx.executionId, childExecutionId]) {
if (id && !sharedLargeValueIds.includes(id)) sharedLargeValueIds.push(id)
}
+ childSession.setTraceLargeValueAccess({
+ largeValueExecutionIds: sharedLargeValueIds,
+ largeValueKeys: ctx.largeValueKeys,
+ fileKeys: ctx.fileKeys,
+ allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
+ })
}
// Trusted run metadata for the child's Start block. Every field describes
@@ -570,6 +597,7 @@ export class WorkflowBlockHandler implements BlockHandler {
// internal tool calls (knowledge, guardrails, MCP, Mothership) can
// attach the required billing attribution header.
billingAttribution: childBillingAttribution,
+ resolvedSecretTraceRegistry: childResolvedSecretTraceRegistry,
// Fall back to the inherited metadata so a toggle-off intermediate
// child still carries the trusted identity chain to deeper children.
startRunMetadata: childStartRunMetadata ?? inherited,
@@ -672,7 +700,18 @@ export class WorkflowBlockHandler implements BlockHandler {
// failures surface identically; we just reshape the successful output. The
// child's spend is billed by its own session, not rolled onto this block.
if (isCustomBlock) {
- return this.projectCustomBlockOutput(executionResult, exposedOutputs)
+ const exposedOutput = this.projectCustomBlockOutput(executionResult, exposedOutputs)
+ if (ctx.resolvedSecretTraceRegistry && childResolvedSecretTraceRegistry) {
+ const crossingProvenance = childResolvedSecretTraceRegistry.exportProvenanceForValue(
+ exposedOutput,
+ { anonymous: true }
+ )
+ await ctx.resolvedSecretTraceRegistry.importProvenance(crossingProvenance, {
+ trusted: true,
+ anonymous: true,
+ })
+ }
+ return exposedOutput
}
return mappedResult
@@ -766,7 +805,12 @@ export class WorkflowBlockHandler implements BlockHandler {
// Cancellation lives on `ExecutionResult.status` — `ExecutionMetadata.status`
// has no 'cancelled' member, so reading it there never matches.
if (executionResult.status === 'cancelled') {
- await session.safeCompleteWithCancellation({ endedAt, totalDurationMs, traceSpans })
+ await session.safeCompleteWithCancellation({
+ endedAt,
+ totalDurationMs,
+ traceSpans,
+ executionState: executionResult.executionState,
+ })
return
}
@@ -776,6 +820,7 @@ export class WorkflowBlockHandler implements BlockHandler {
finalOutput: executionResult.output ?? {},
traceSpans,
workflowInput,
+ executionState: executionResult.executionState,
})
}
@@ -792,6 +837,7 @@ export class WorkflowBlockHandler implements BlockHandler {
totalDurationMs: totalDuration ?? 0,
error: { message: normalized.message, stackTrace: normalized.stack },
traceSpans,
+ executionState: executionResult?.executionState,
})
}
diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts
index 653d1c51d8e..ef5afe1ab6b 100644
--- a/apps/sim/executor/types.ts
+++ b/apps/sim/executor/types.ts
@@ -9,6 +9,7 @@ import type {
PiiBlockOutputRedaction,
SerializableExecutionState,
} from '@/executor/execution/types'
+import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import type { RunFromBlockContext } from '@/executor/utils/run-from-block'
import type { AgentStreamSink, UnsubscribeAgentStreamSink } from '@/providers/stream-events'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
@@ -354,6 +355,7 @@ export interface ExecutionContext {
/** Trusted run metadata for the Start block's "Add run metadata" toggle. */
startRunMetadata?: StartBlockRunMetadata
environmentVariables: Record
+ resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
workflowVariables?: Record
decisions: {
diff --git a/apps/sim/executor/utils/reference-validation.test.ts b/apps/sim/executor/utils/reference-validation.test.ts
new file mode 100644
index 00000000000..5dcf8e24059
--- /dev/null
+++ b/apps/sim/executor/utils/reference-validation.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it, vi } from 'vitest'
+import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
+
+describe('resolveEnvVarReferences provenance', () => {
+ it('reports each successful exact, embedded, and deep substitution', () => {
+ const onResolved = vi.fn()
+
+ const result = resolveEnvVarReferences(
+ {
+ exact: '{{TOKEN}}',
+ embedded: 'Bearer {{TOKEN}} and {{OTHER}}',
+ missing: '{{MISSING}}',
+ },
+ { TOKEN: 'secret', OTHER: 'other-secret' },
+ { deep: true, onResolved }
+ )
+
+ expect(result).toEqual({
+ exact: 'secret',
+ embedded: 'Bearer secret and other-secret',
+ missing: '{{MISSING}}',
+ })
+ expect(onResolved.mock.calls).toEqual([
+ ['TOKEN', 'secret'],
+ ['TOKEN', 'secret'],
+ ['OTHER', 'other-secret'],
+ ])
+ })
+
+ it('does not report disabled embedded or missing references', () => {
+ const onResolved = vi.fn()
+ expect(
+ resolveEnvVarReferences(
+ 'prefix {{TOKEN}} {{MISSING}}',
+ { TOKEN: 'secret' },
+ {
+ allowEmbedded: false,
+ onResolved,
+ }
+ )
+ ).toBe('prefix {{TOKEN}} {{MISSING}}')
+ expect(onResolved).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/executor/utils/reference-validation.ts b/apps/sim/executor/utils/reference-validation.ts
index 18b2b76d89f..91130a19683 100644
--- a/apps/sim/executor/utils/reference-validation.ts
+++ b/apps/sim/executor/utils/reference-validation.ts
@@ -26,6 +26,7 @@ export interface EnvVarResolveOptions {
onMissing?: 'keep' | 'throw' | 'empty'
deep?: boolean
missingKeys?: string[]
+ onResolved?: (name: string, value: string) => void
}
/**
@@ -37,7 +38,9 @@ export interface EnvVarResolveOptions {
* - `onMissing: 'keep'` - Unknown patterns pass through (e.g., Grafana's `{{instance}}`)
* - `deep: false` - Only processes strings by default; set `true` for nested objects
*/
-export const ENV_VAR_RESOLVE_DEFAULTS: Required> = {
+export const ENV_VAR_RESOLVE_DEFAULTS: Required<
+ Omit
+> = {
resolveExactMatch: true,
allowEmbedded: true,
trimKeys: true,
@@ -70,7 +73,10 @@ export function resolveEnvVarReferences(
if (exactMatch) {
const envKey = trimKeys ? exactMatch[1].trim() : exactMatch[1]
const envValue = envVars[envKey]
- if (envValue !== undefined) return envValue
+ if (envValue !== undefined) {
+ if (Object.hasOwn(envVars, envKey)) options.onResolved?.(envKey, envValue)
+ return envValue
+ }
if (options.missingKeys) options.missingKeys.push(envKey)
if (onMissing === 'throw') {
throw new Error(`Environment variable "${envKey}" was not found`)
@@ -88,7 +94,10 @@ export function resolveEnvVarReferences(
return value.replace(envVarPattern, (match, varName) => {
const envKey = trimKeys ? String(varName).trim() : String(varName)
const envValue = envVars[envKey]
- if (envValue !== undefined) return envValue
+ if (envValue !== undefined) {
+ if (Object.hasOwn(envVars, envKey)) options.onResolved?.(envKey, envValue)
+ return envValue
+ }
if (options.missingKeys) options.missingKeys.push(envKey)
if (onMissing === 'throw') {
throw new Error(`Environment variable "${envKey}" was not found`)
diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts
new file mode 100644
index 00000000000..6b435afff6d
--- /dev/null
+++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts
@@ -0,0 +1,655 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockDecryptSecret } = vi.hoisted(() => ({
+ mockDecryptSecret: vi.fn(),
+}))
+
+vi.mock('@/lib/core/security/encryption', () => ({
+ decryptSecret: mockDecryptSecret,
+}))
+
+import {
+ ANONYMOUS_SECRET_TRACE_REPLACEMENT,
+ createResolvedSecretTraceRegistry,
+ isResolvedSecretTraceProvenanceV1,
+ ResolvedSecretTraceProvenanceAccumulator,
+ type ResolvedSecretTraceProvenanceV1,
+ ResolvedSecretTraceRegistry,
+} from '@/executor/utils/resolved-secret-trace-registry'
+
+describe('ResolvedSecretTraceProvenanceAccumulator', () => {
+ const scope = { userId: 'user-1', workspaceId: 'workspace-1' }
+
+ it('unions cold, warm, and retry reports while complete', () => {
+ const accumulator = new ResolvedSecretTraceProvenanceAccumulator(scope)
+
+ expect(
+ accumulator.record({
+ version: 1,
+ complete: true,
+ entries: [{ name: 'OLD_TOKEN', encryptedValue: 'encrypted-v1' }],
+ scope,
+ })
+ ).toBe(true)
+ expect(
+ accumulator.record({
+ version: 1,
+ complete: true,
+ entries: [{ name: 'NEW_TOKEN', encryptedValue: 'encrypted-v2' }],
+ scope,
+ })
+ ).toBe(true)
+ accumulator.record({
+ version: 1,
+ complete: true,
+ entries: [{ name: 'OLD_TOKEN', encryptedValue: 'encrypted-v1' }],
+ scope,
+ })
+
+ expect(accumulator.exportProvenance()).toEqual({
+ version: 1,
+ complete: true,
+ entries: [
+ { name: 'OLD_TOKEN', encryptedValue: 'encrypted-v1' },
+ { name: 'NEW_TOKEN', encryptedValue: 'encrypted-v2' },
+ ],
+ scope,
+ })
+ })
+
+ it('discards accumulated and future entries once completeness is lost', () => {
+ const accumulator = new ResolvedSecretTraceProvenanceAccumulator(scope)
+
+ accumulator.record({
+ version: 1,
+ complete: true,
+ entries: [{ name: 'OLD_TOKEN', encryptedValue: 'encrypted-v1' }],
+ scope,
+ })
+ expect(
+ accumulator.record({
+ version: 1,
+ complete: false,
+ entries: [],
+ scope,
+ })
+ ).toBe(true)
+ expect(
+ accumulator.record({
+ version: 1,
+ complete: true,
+ entries: [{ name: 'NEW_TOKEN', encryptedValue: 'encrypted-v2' }],
+ scope,
+ })
+ ).toBe(true)
+
+ expect(accumulator.exportProvenance()).toEqual({
+ version: 1,
+ complete: false,
+ entries: [],
+ scope,
+ })
+ })
+
+ it('discards mismatched-scope reports and marks them incomplete', () => {
+ const accumulator = new ResolvedSecretTraceProvenanceAccumulator(scope)
+
+ expect(
+ accumulator.record({
+ version: 1,
+ complete: true,
+ entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-value' }],
+ scope: { userId: 'user-1', workspaceId: 'workspace-2' },
+ })
+ ).toBe(true)
+
+ expect(accumulator.exportProvenance()).toEqual({
+ version: 1,
+ complete: false,
+ entries: [],
+ scope,
+ })
+ })
+
+ it('fails closed for malformed reports and terminal incompleteness', () => {
+ const accumulator = new ResolvedSecretTraceProvenanceAccumulator(scope)
+ accumulator.record({
+ version: 1,
+ complete: true,
+ entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-value' }],
+ scope,
+ })
+
+ expect(accumulator.record({ version: 1 })).toBe(false)
+ expect(accumulator.exportProvenance()).toEqual({
+ version: 1,
+ complete: false,
+ entries: [],
+ scope,
+ })
+
+ accumulator.record({
+ version: 1,
+ complete: true,
+ entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-value' }],
+ scope,
+ })
+ accumulator.markIncomplete()
+ expect(accumulator.exportProvenance().entries).toEqual([])
+ })
+})
+
+describe('ResolvedSecretTraceRegistry', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({
+ decrypted: `decrypted:${encryptedValue}`,
+ }))
+ })
+
+ it('starts with an inert catalog and activates only an exact successful resolution', () => {
+ const registry = new ResolvedSecretTraceRegistry([
+ { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-value' },
+ ])
+
+ expect(registry.getActiveMatches()).toEqual([])
+ expect(registry.recordResolved('API_KEY', 'wrong-value')).toBe(false)
+ expect(registry.recordResolved('MISSING', 'secret-value')).toBe(false)
+ expect(registry.getActiveMatches()).toEqual([])
+ expect(registry.isComplete()).toBe(false)
+
+ expect(registry.recordResolved('API_KEY', 'secret-value')).toBe(true)
+ expect(registry.getActiveMatches()).toEqual([
+ { plaintext: 'secret-value', replacement: '{{API_KEY}}' },
+ ])
+ })
+
+ it('uses the workspace catalog entry when personal and workspace names conflict', async () => {
+ const registry = await createResolvedSecretTraceRegistry({
+ personalEncrypted: { SHARED: 'personal-encrypted' },
+ workspaceEncrypted: { SHARED: 'workspace-encrypted' },
+ personalDecrypted: { SHARED: 'personal-secret' },
+ workspaceDecrypted: { SHARED: 'workspace-secret' },
+ })
+
+ expect(registry.recordResolved('SHARED', 'workspace-secret')).toBe(true)
+ expect(registry.exportProvenance()).toEqual({
+ version: 1,
+ complete: true,
+ entries: [{ name: 'SHARED', encryptedValue: 'workspace-encrypted' }],
+ })
+ })
+
+ it('ignores empty decryption failures but fails closed for a resolved value outside the catalog', async () => {
+ const registry = await createResolvedSecretTraceRegistry({
+ personalEncrypted: { FAILED: 'failed-ciphertext' },
+ workspaceEncrypted: {},
+ personalDecrypted: { FAILED: '', DECRYPTED_ONLY: 'not-catalogued' },
+ workspaceDecrypted: {},
+ decryptionFailures: ['FAILED'],
+ })
+
+ expect(registry.isComplete()).toBe(true)
+ expect(registry.recordResolved('FAILED', '')).toBe(false)
+ expect(registry.isComplete()).toBe(true)
+ expect(registry.recordResolved('DECRYPTED_ONLY', 'not-catalogued')).toBe(false)
+ expect(registry.isComplete()).toBe(false)
+ })
+
+ it('keeps a successful workspace override when the shadowed personal value failed', async () => {
+ const registry = await createResolvedSecretTraceRegistry({
+ personalEncrypted: { SHARED: 'broken-personal-ciphertext' },
+ workspaceEncrypted: { SHARED: 'workspace-ciphertext' },
+ personalDecrypted: { SHARED: '' },
+ workspaceDecrypted: { SHARED: 'workspace-secret' },
+ decryptionFailures: ['SHARED'],
+ })
+
+ expect(registry.recordResolved('SHARED', 'workspace-secret')).toBe(true)
+ expect(registry.exportProvenance().entries).toEqual([
+ { name: 'SHARED', encryptedValue: 'workspace-ciphertext' },
+ ])
+ })
+
+ it('exports encrypted active provenance without plaintext', () => {
+ const registry = new ResolvedSecretTraceRegistry([
+ { name: 'TOKEN', plaintext: 'raw-secret', encryptedValue: 'ciphertext' },
+ ])
+ registry.recordResolved('TOKEN', 'raw-secret')
+
+ const serialized = JSON.stringify(registry.exportProvenance())
+ expect(serialized).toContain('ciphertext')
+ expect(serialized).not.toContain('raw-secret')
+ })
+
+ it('restores old encrypted values alongside the current catalog after rotation', async () => {
+ mockDecryptSecret.mockResolvedValueOnce({ decrypted: 'old-secret' })
+ const oldProvenance: ResolvedSecretTraceProvenanceV1 = {
+ version: 1,
+ complete: true,
+ entries: [{ name: 'TOKEN', encryptedValue: 'old-ciphertext' }],
+ }
+ const registry = await createResolvedSecretTraceRegistry({
+ personalEncrypted: { TOKEN: 'new-ciphertext' },
+ workspaceEncrypted: {},
+ personalDecrypted: { TOKEN: 'new-secret' },
+ workspaceDecrypted: {},
+ restoredProvenance: oldProvenance,
+ restoreTrusted: true,
+ requireRestoredProvenance: true,
+ })
+
+ registry.recordResolved('TOKEN', 'new-secret')
+
+ expect(registry.getActiveMatches()).toEqual([
+ { plaintext: 'new-secret', replacement: '{{TOKEN}}' },
+ { plaintext: 'old-secret', replacement: '{{TOKEN}}' },
+ ])
+ expect(registry.exportProvenance().entries).toEqual([
+ { name: 'TOKEN', encryptedValue: 'new-ciphertext' },
+ { name: 'TOKEN', encryptedValue: 'old-ciphertext' },
+ ])
+ })
+
+ it('marks untrusted, missing, malformed, and undecryptable restoration incomplete', async () => {
+ const provenance: ResolvedSecretTraceProvenanceV1 = {
+ version: 1,
+ complete: true,
+ entries: [{ name: 'TOKEN', encryptedValue: 'ciphertext' }],
+ }
+ const untrusted = new ResolvedSecretTraceRegistry()
+ expect(await untrusted.importProvenance(provenance, { trusted: false })).toBe(false)
+ expect(untrusted.isComplete()).toBe(false)
+ expect(mockDecryptSecret).not.toHaveBeenCalled()
+
+ const missing = await createResolvedSecretTraceRegistry({
+ personalEncrypted: {},
+ workspaceEncrypted: {},
+ personalDecrypted: {},
+ workspaceDecrypted: {},
+ requireRestoredProvenance: true,
+ restoreTrusted: true,
+ })
+ expect(missing.isComplete()).toBe(false)
+
+ const malformed = new ResolvedSecretTraceRegistry()
+ expect(await malformed.importProvenance({ version: 1 }, { trusted: true })).toBe(false)
+ expect(malformed.isComplete()).toBe(false)
+
+ mockDecryptSecret.mockRejectedValueOnce(new Error('cannot decrypt'))
+ const undecryptable = new ResolvedSecretTraceRegistry()
+ expect(await undecryptable.importProvenance(provenance, { trusted: true })).toBe(false)
+ expect(undecryptable.isComplete()).toBe(false)
+ })
+
+ it('uses anonymous replacements for cross-scope provenance', async () => {
+ mockDecryptSecret.mockResolvedValueOnce({ decrypted: 'publisher-secret' })
+ const registry = new ResolvedSecretTraceRegistry()
+ await registry.importProvenance(
+ {
+ version: 1,
+ complete: true,
+ entries: [{ name: 'PUBLISHER_TOKEN', encryptedValue: 'publisher-ciphertext' }],
+ },
+ { trusted: true, anonymous: true }
+ )
+
+ expect(registry.getActiveMatches()).toEqual([
+ { plaintext: 'publisher-secret', replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT },
+ ])
+ expect(registry.exportProvenance().entries).toEqual([
+ { encryptedValue: 'publisher-ciphertext' },
+ ])
+ })
+
+ it('preserves labels only when imported provenance has the same complete scope', async () => {
+ const provenance: ResolvedSecretTraceProvenanceV1 = {
+ version: 1,
+ complete: true,
+ entries: [{ name: 'TOKEN', encryptedValue: 'ciphertext' }],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ }
+ const sameScope = new ResolvedSecretTraceRegistry([], {
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ })
+ const mismatchedScope = new ResolvedSecretTraceRegistry([], {
+ userId: 'user-1',
+ workspaceId: 'workspace-2',
+ })
+ const missingReceiverScope = new ResolvedSecretTraceRegistry()
+ const missingSourceScope = new ResolvedSecretTraceRegistry([], {
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ })
+
+ expect(await sameScope.importProvenance(provenance, { trusted: true })).toBe(true)
+ expect(await mismatchedScope.importProvenance(provenance, { trusted: true })).toBe(true)
+ expect(await missingReceiverScope.importProvenance(provenance, { trusted: true })).toBe(true)
+ expect(
+ await missingSourceScope.importProvenance(
+ { version: 1, complete: true, entries: provenance.entries },
+ { trusted: true }
+ )
+ ).toBe(true)
+
+ expect(sameScope.getActiveMatches()).toEqual([
+ { plaintext: 'decrypted:ciphertext', replacement: '{{TOKEN}}' },
+ ])
+ for (const registry of [mismatchedScope, missingReceiverScope, missingSourceScope]) {
+ expect(registry.getActiveMatches()).toEqual([
+ {
+ plaintext: 'decrypted:ciphertext',
+ replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT,
+ },
+ ])
+ }
+ })
+
+ it('imports all same-scope provenance across an in-process boundary', async () => {
+ const registry = new ResolvedSecretTraceRegistry([], {
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ })
+ const provenance: ResolvedSecretTraceProvenanceV1 = {
+ version: 1,
+ complete: true,
+ entries: [
+ { name: 'PRESENT', encryptedValue: 'present-ciphertext' },
+ { name: 'DORMANT_IN_OUTPUT', encryptedValue: 'other-ciphertext' },
+ ],
+ scope: { userId: 'user-1', workspaceId: 'workspace-1' },
+ }
+
+ expect(
+ await registry.importCrossingProvenance(
+ provenance,
+ { output: 'decrypted:present-ciphertext' },
+ { trusted: true }
+ )
+ ).toBe(true)
+
+ expect(registry.getActiveMatches()).toEqual([
+ { plaintext: 'decrypted:present-ciphertext', replacement: '{{PRESENT}}' },
+ { plaintext: 'decrypted:other-ciphertext', replacement: '{{DORMANT_IN_OUTPUT}}' },
+ ])
+ })
+
+ it('filters and anonymizes provenance crossing from another scope', async () => {
+ const registry = new ResolvedSecretTraceRegistry([], {
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ })
+ const provenance: ResolvedSecretTraceProvenanceV1 = {
+ version: 1,
+ complete: true,
+ entries: [
+ { name: 'PRESENT', encryptedValue: 'present-ciphertext' },
+ { name: 'ABSENT', encryptedValue: 'absent-ciphertext' },
+ ],
+ scope: { userId: 'user-1', workspaceId: 'workspace-2' },
+ }
+
+ expect(
+ await registry.importCrossingProvenance(
+ provenance,
+ { output: 'decrypted:present-ciphertext' },
+ { trusted: true }
+ )
+ ).toBe(true)
+
+ expect(registry.getActiveMatches()).toEqual([
+ {
+ plaintext: 'decrypted:present-ciphertext',
+ replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT,
+ },
+ ])
+ })
+
+ it('exports only active secrets whose exact literals cross a value boundary', () => {
+ const registry = new ResolvedSecretTraceRegistry([
+ { name: 'PRESENT', plaintext: 'present-secret', encryptedValue: 'present-ciphertext' },
+ { name: 'ABSENT', plaintext: 'absent-secret', encryptedValue: 'absent-ciphertext' },
+ { name: 'UNUSED', plaintext: 'unused-secret', encryptedValue: 'unused-ciphertext' },
+ ])
+ registry.recordResolved('PRESENT', 'present-secret')
+ registry.recordResolved('ABSENT', 'absent-secret')
+
+ const provenance = registry.exportProvenanceForValue(
+ { nested: [{ 'key-present-secret': new Error('failed with present-secret') }] },
+ { anonymous: true }
+ )
+
+ expect(provenance).toEqual({
+ version: 1,
+ complete: true,
+ entries: [{ encryptedValue: 'present-ciphertext' }],
+ })
+ })
+
+ it('exports active numeric, boolean, and null literals crossing a value boundary', () => {
+ const registry = new ResolvedSecretTraceRegistry([
+ { name: 'NUMBER', plaintext: '1234', encryptedValue: 'number-ciphertext' },
+ { name: 'BOOLEAN', plaintext: 'false', encryptedValue: 'boolean-ciphertext' },
+ { name: 'NULL', plaintext: 'null', encryptedValue: 'null-ciphertext' },
+ { name: 'ABSENT', plaintext: '5678', encryptedValue: 'absent-ciphertext' },
+ ])
+ registry.recordResolved('NUMBER', '1234')
+ registry.recordResolved('BOOLEAN', 'false')
+ registry.recordResolved('NULL', 'null')
+ registry.recordResolved('ABSENT', '5678')
+
+ expect(
+ registry.exportProvenanceForValue(
+ { number: 1234, boolean: false, nullable: null },
+ { anonymous: true }
+ )
+ ).toEqual({
+ version: 1,
+ complete: true,
+ entries: [
+ { encryptedValue: 'boolean-ciphertext' },
+ { encryptedValue: 'null-ciphertext' },
+ { encryptedValue: 'number-ciphertext' },
+ ],
+ })
+ })
+
+ it('marks a bounded cross-boundary scan incomplete when an enumerable accessor is opaque', () => {
+ const registry = new ResolvedSecretTraceRegistry([
+ { name: 'TOKEN', plaintext: 'secret', encryptedValue: 'ciphertext' },
+ ])
+ registry.recordResolved('TOKEN', 'secret')
+ const value = {}
+ Object.defineProperty(value, 'opaque', {
+ enumerable: true,
+ get: () => 'secret',
+ })
+
+ expect(registry.exportProvenanceForValue(value, { anonymous: true })).toEqual({
+ version: 1,
+ complete: false,
+ entries: [],
+ })
+ })
+
+ it('does not claim a complete cross-boundary scan for opaque large-value refs', () => {
+ const registry = new ResolvedSecretTraceRegistry([
+ { name: 'TOKEN', plaintext: 'secret', encryptedValue: 'ciphertext' },
+ ])
+ registry.recordResolved('TOKEN', 'secret')
+
+ expect(
+ registry.exportProvenanceForValue(
+ {
+ __simLargeValueRef: true,
+ version: 1,
+ id: 'lv_ABCDEFGHIJKL',
+ kind: 'object',
+ size: 1024,
+ },
+ { anonymous: true }
+ )
+ ).toEqual({ version: 1, complete: false, entries: [] })
+ })
+
+ it('does not snapshot or enqueue an entire wide crossing object', () => {
+ const registry = new ResolvedSecretTraceRegistry([
+ { name: 'TOKEN', plaintext: 'needle-value', encryptedValue: 'ciphertext' },
+ ])
+ registry.recordResolved('TOKEN', 'needle-value')
+ const wideValue: Record = {}
+ for (let index = 0; index < 30_000; index++) {
+ wideValue[`field_${index}`] = 'safe-value'
+ }
+ const descriptorSnapshotSpy = vi.spyOn(Object, 'getOwnPropertyDescriptors')
+ const provenance = registry.exportProvenanceForValue(wideValue, { anonymous: true })
+ const descriptorSnapshotCalls = descriptorSnapshotSpy.mock.calls.length
+ descriptorSnapshotSpy.mockRestore()
+
+ expect(provenance).toEqual({
+ version: 1,
+ complete: false,
+ entries: [],
+ })
+ expect(descriptorSnapshotCalls).toBe(0)
+ })
+
+ it('handles duplicate and empty values deterministically', () => {
+ const registry = new ResolvedSecretTraceRegistry([
+ { name: 'Z_TOKEN', plaintext: 'same', encryptedValue: 'z-ciphertext' },
+ { name: 'A_TOKEN', plaintext: 'same', encryptedValue: 'a-ciphertext' },
+ { name: 'EMPTY', plaintext: '', encryptedValue: 'empty-ciphertext' },
+ { name: 'A', plaintext: 'A', encryptedValue: 'short-ciphertext' },
+ ])
+ registry.recordResolved('Z_TOKEN', 'same')
+ registry.recordResolved('A_TOKEN', 'same')
+ registry.recordResolved('EMPTY', '')
+
+ expect(registry.getActiveMatches()).toEqual([{ plaintext: 'same', replacement: '{{A_TOKEN}}' }])
+
+ registry.recordResolved('A', 'A')
+ expect(registry.getActiveMatches()).toEqual([
+ { plaintext: 'same', replacement: '{{A_TOKEN}}' },
+ { plaintext: 'A', replacement: '{{A}}' },
+ ])
+ })
+
+ it('marks the registry incomplete when active provenance exceeds its hard cap', () => {
+ const entries = Array.from({ length: 10_001 }, (_, index) => ({
+ name: `SECRET_${index}`,
+ plaintext: `value-${index}`,
+ encryptedValue: `ciphertext-${index}`,
+ }))
+ const registry = new ResolvedSecretTraceRegistry(entries)
+
+ for (const entry of entries) {
+ registry.recordResolved(entry.name, entry.plaintext)
+ }
+
+ expect(registry.isComplete()).toBe(false)
+ expect(registry.exportProvenance().entries).toEqual([])
+ })
+
+ it('bounds provenance by serialized JSON bytes including control-character escapes', () => {
+ const encryptedValue = '\u0000'.repeat(1_400_000)
+ const provenance: ResolvedSecretTraceProvenanceV1 = {
+ version: 1,
+ complete: true,
+ entries: [{ name: 'TOKEN', encryptedValue }],
+ }
+
+ expect(Buffer.byteLength(encryptedValue, 'utf8')).toBeLessThan(8 * 1024 * 1024)
+ expect(Buffer.byteLength(JSON.stringify(provenance), 'utf8')).toBeGreaterThan(8 * 1024 * 1024)
+ expect(isResolvedSecretTraceProvenanceV1(provenance)).toBe(false)
+
+ const registry = new ResolvedSecretTraceRegistry([
+ { name: 'TOKEN', plaintext: 'secret', encryptedValue },
+ ])
+ expect(registry.recordResolved('TOKEN', 'secret')).toBe(true)
+ expect(registry.isComplete()).toBe(false)
+ expect(registry.exportProvenance().entries).toEqual([])
+ })
+
+ it('rejects incomplete provenance that still carries entries', () => {
+ expect(
+ isResolvedSecretTraceProvenanceV1({
+ version: 1,
+ complete: false,
+ entries: [{ name: 'TOKEN', encryptedValue: 'ciphertext' }],
+ })
+ ).toBe(false)
+ })
+
+ it('rejects non-canonical provenance fields before applying the serialized-size bound', () => {
+ const entry = { name: 'TOKEN', encryptedValue: 'ciphertext' }
+ const scope = { userId: 'user-1', workspaceId: 'workspace-1' }
+
+ expect(
+ isResolvedSecretTraceProvenanceV1({
+ version: 1,
+ complete: true,
+ entries: [entry],
+ scope,
+ extra: 'not-transported',
+ })
+ ).toBe(false)
+ expect(
+ isResolvedSecretTraceProvenanceV1({
+ version: 1,
+ complete: true,
+ entries: [{ ...entry, extra: 'not-transported' }],
+ scope,
+ })
+ ).toBe(false)
+ expect(
+ isResolvedSecretTraceProvenanceV1({
+ version: 1,
+ complete: true,
+ entries: [entry],
+ scope: { ...scope, extra: 'not-transported' },
+ })
+ ).toBe(false)
+
+ const entries = [entry]
+ Object.assign(entries, { extra: 'not-transported' })
+ expect(isResolvedSecretTraceProvenanceV1({ version: 1, complete: true, entries, scope })).toBe(
+ false
+ )
+ })
+
+ it('stops consuming a dormant catalog when its entry cap is exceeded', () => {
+ let yieldedEntries = 0
+ function* catalogEntries() {
+ for (let index = 0; index < 20_000; index++) {
+ yieldedEntries++
+ yield {
+ name: `SECRET_${index}`,
+ plaintext: `value-${index}`,
+ encryptedValue: `ciphertext-${index}`,
+ }
+ }
+ }
+
+ const registry = new ResolvedSecretTraceRegistry(catalogEntries())
+
+ expect(yieldedEntries).toBe(10_001)
+ expect(registry.isComplete()).toBe(false)
+ expect(registry.exportProvenance().entries).toEqual([])
+ })
+
+ it('marks an oversized dormant catalog value incomplete without retaining it', () => {
+ const oversizedPlaintext = 'x'.repeat(8 * 1024 * 1024)
+ const registry = new ResolvedSecretTraceRegistry([
+ {
+ name: 'OVERSIZED',
+ plaintext: oversizedPlaintext,
+ encryptedValue: 'ciphertext',
+ },
+ ])
+
+ expect(registry.isComplete()).toBe(false)
+ expect(registry.recordResolved('OVERSIZED', oversizedPlaintext)).toBe(false)
+ expect(registry.getActiveMatches()).toEqual([])
+ })
+})
diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts
new file mode 100644
index 00000000000..632a1a56872
--- /dev/null
+++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts
@@ -0,0 +1,784 @@
+import { decryptSecret } from '@/lib/core/security/encryption'
+import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
+import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
+
+export const ANONYMOUS_SECRET_TRACE_REPLACEMENT = '[REDACTED_SECRET]'
+
+const MAX_PROVENANCE_ENTRIES = 10_000
+const MAX_SERIALIZED_PROVENANCE_BYTES = 8 * 1024 * 1024
+const MAX_TRACE_CATALOG_ENTRIES = MAX_PROVENANCE_ENTRIES
+const MAX_TRACE_CATALOG_BYTES = 8 * 1024 * 1024
+const MAX_PROVENANCE_FILTER_NODES = 50_000
+const MAX_PROVENANCE_FILTER_CHARACTERS = 16 * 1024 * 1024
+const MAX_PROVENANCE_FILTER_COMPARISONS = 1_000_000
+const ERROR_CONTENT_PROPERTY_NAMES = ['name', 'message', 'stack', 'cause', 'errors'] as const
+const PROVENANCE_PROPERTY_NAMES = new Set(['version', 'complete', 'entries', 'scope'])
+const PROVENANCE_ENTRY_PROPERTY_NAMES = new Set(['encryptedValue', 'name'])
+const PROVENANCE_SCOPE_PROPERTY_NAMES = new Set(['userId', 'workspaceId'])
+
+export interface ResolvedSecretTraceCatalogEntry {
+ name: string
+ plaintext: string
+ encryptedValue: string
+}
+
+export interface ResolvedSecretTraceMatch {
+ plaintext: string
+ replacement: string
+}
+
+export interface ResolvedSecretTraceProvenanceEntryV1 {
+ encryptedValue: string
+ name?: string
+}
+
+export interface ResolvedSecretTraceScopeV1 {
+ userId: string
+ workspaceId?: string
+}
+
+export interface ResolvedSecretTraceProvenanceV1 {
+ version: 1
+ complete: boolean
+ entries: ResolvedSecretTraceProvenanceEntryV1[]
+ scope?: ResolvedSecretTraceScopeV1
+}
+
+interface ActiveSecretEntry extends ResolvedSecretTraceCatalogEntry {
+ anonymous: boolean
+}
+
+export interface ImportResolvedSecretTraceProvenanceOptions {
+ trusted: boolean
+ anonymous?: boolean
+}
+
+export interface ExportResolvedSecretTraceProvenanceForValueOptions {
+ anonymous?: boolean
+}
+
+export interface CreateResolvedSecretTraceRegistryOptions {
+ personalEncrypted: Record
+ workspaceEncrypted: Record
+ personalDecrypted: Record
+ workspaceDecrypted: Record
+ decryptionFailures?: readonly string[]
+ restoredProvenance?: unknown
+ restoreTrusted?: boolean
+ requireRestoredProvenance?: boolean
+ scope?: ResolvedSecretTraceScopeV1
+}
+
+function compareStrings(left: string, right: string): number {
+ if (left < right) return -1
+ if (left > right) return 1
+ return 0
+}
+
+function cloneProvenanceScope(scope: ResolvedSecretTraceScopeV1): ResolvedSecretTraceScopeV1 {
+ return {
+ userId: scope.userId,
+ ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}),
+ }
+}
+
+function serializedJsonStringByteSize(value: string): number {
+ let byteSize = 2
+ for (let index = 0; index < value.length; index++) {
+ const codeUnit = value.charCodeAt(index)
+ if (codeUnit === 0x22 || codeUnit === 0x5c) {
+ byteSize += 2
+ } else if (
+ codeUnit === 0x08 ||
+ codeUnit === 0x09 ||
+ codeUnit === 0x0a ||
+ codeUnit === 0x0c ||
+ codeUnit === 0x0d
+ ) {
+ byteSize += 2
+ } else if (codeUnit <= 0x1f) {
+ byteSize += 6
+ } else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
+ const nextCodeUnit = value.charCodeAt(index + 1)
+ if (nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff) {
+ byteSize += 4
+ index++
+ } else {
+ byteSize += 6
+ }
+ } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
+ byteSize += 6
+ } else if (codeUnit <= 0x7f) {
+ byteSize++
+ } else if (codeUnit <= 0x7ff) {
+ byteSize += 2
+ } else {
+ byteSize += 3
+ }
+ }
+ return byteSize
+}
+
+function serializedProvenanceEntryByteSize(entry: ResolvedSecretTraceProvenanceEntryV1): number {
+ let byteSize =
+ Buffer.byteLength('{"encryptedValue":', 'utf8') +
+ serializedJsonStringByteSize(entry.encryptedValue)
+ if (entry.name !== undefined) {
+ byteSize += Buffer.byteLength(',"name":', 'utf8') + serializedJsonStringByteSize(entry.name)
+ }
+ return byteSize + 1
+}
+
+function serializedProvenanceEnvelopeByteSize(
+ complete: boolean,
+ scope: ResolvedSecretTraceScopeV1 | undefined
+): number {
+ let byteSize = Buffer.byteLength(
+ `{"version":1,"complete":${complete ? 'true' : 'false'},"entries":[]`,
+ 'utf8'
+ )
+ if (scope) {
+ byteSize +=
+ Buffer.byteLength(',"scope":{"userId":', 'utf8') + serializedJsonStringByteSize(scope.userId)
+ if (scope.workspaceId !== undefined) {
+ byteSize +=
+ Buffer.byteLength(',"workspaceId":', 'utf8') +
+ serializedJsonStringByteSize(scope.workspaceId)
+ }
+ byteSize++
+ }
+ return byteSize + 1
+}
+
+function isSerializedProvenanceWithinLimit(
+ complete: boolean,
+ entries: readonly ResolvedSecretTraceProvenanceEntryV1[],
+ scope: ResolvedSecretTraceScopeV1 | undefined
+): boolean {
+ let byteSize = serializedProvenanceEnvelopeByteSize(complete, scope)
+ for (let index = 0; index < entries.length; index++) {
+ byteSize += serializedProvenanceEntryByteSize(entries[index]) + (index === 0 ? 0 : 1)
+ if (byteSize > MAX_SERIALIZED_PROVENANCE_BYTES) return false
+ }
+ return byteSize <= MAX_SERIALIZED_PROVENANCE_BYTES
+}
+
+function toProvenanceEntry(entry: ActiveSecretEntry): ResolvedSecretTraceProvenanceEntryV1 {
+ return {
+ encryptedValue: entry.encryptedValue,
+ ...(!entry.anonymous && entry.name ? { name: entry.name } : {}),
+ }
+}
+
+function hasOwn(record: Record, name: string): boolean {
+ return Object.hasOwn(record, name)
+}
+
+function isExactPlainDataRecord(
+ value: unknown,
+ allowedProperties: ReadonlySet,
+ requiredProperties: readonly string[]
+): value is Record {
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
+
+ try {
+ const prototype = Object.getPrototypeOf(value)
+ if (prototype !== Object.prototype && prototype !== null) return false
+
+ const properties = Reflect.ownKeys(value)
+ for (const property of properties) {
+ if (typeof property !== 'string' || !allowedProperties.has(property)) return false
+ const descriptor = Object.getOwnPropertyDescriptor(value, property)
+ if (!descriptor?.enumerable || !('value' in descriptor)) return false
+ }
+ return requiredProperties.every((property) => Object.hasOwn(value, property))
+ } catch {
+ return false
+ }
+}
+
+function isExactProvenanceEntriesArray(value: unknown): value is unknown[] {
+ if (!Array.isArray(value)) return false
+
+ try {
+ if (Object.getPrototypeOf(value) !== Array.prototype) return false
+ const properties = Reflect.ownKeys(value)
+ if (properties.length !== value.length + 1 || !properties.includes('length')) return false
+
+ for (let index = 0; index < value.length; index++) {
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
+ if (!descriptor?.enumerable || !('value' in descriptor)) return false
+ }
+ return true
+ } catch {
+ return false
+ }
+}
+
+function catalogEntryByteSize(entry: ResolvedSecretTraceCatalogEntry): number {
+ return (
+ Buffer.byteLength(entry.name, 'utf8') +
+ Buffer.byteLength(entry.plaintext, 'utf8') +
+ Buffer.byteLength(entry.encryptedValue, 'utf8')
+ )
+}
+
+function buildEffectiveCatalogEntry(
+ options: CreateResolvedSecretTraceRegistryOptions,
+ failedNames: ReadonlySet,
+ name: string,
+ encryptedValue: string
+): ResolvedSecretTraceCatalogEntry | undefined {
+ const plaintext = hasOwn(options.workspaceDecrypted, name)
+ ? options.workspaceDecrypted[name]
+ : options.personalDecrypted[name]
+ if (plaintext === undefined || (plaintext.length === 0 && failedNames.has(name))) {
+ return undefined
+ }
+ return { name, plaintext, encryptedValue }
+}
+
+function* iterateEffectiveCatalogEntries(
+ options: CreateResolvedSecretTraceRegistryOptions,
+ failedNames: ReadonlySet
+): Generator {
+ for (const name in options.personalEncrypted) {
+ if (!hasOwn(options.personalEncrypted, name)) continue
+ const encryptedValue = hasOwn(options.workspaceEncrypted, name)
+ ? options.workspaceEncrypted[name]
+ : options.personalEncrypted[name]
+ const entry = buildEffectiveCatalogEntry(options, failedNames, name, encryptedValue)
+ if (entry) yield entry
+ }
+
+ for (const name in options.workspaceEncrypted) {
+ if (!hasOwn(options.workspaceEncrypted, name) || hasOwn(options.personalEncrypted, name)) {
+ continue
+ }
+ const entry = buildEffectiveCatalogEntry(
+ options,
+ failedNames,
+ name,
+ options.workspaceEncrypted[name]
+ )
+ if (entry) yield entry
+ }
+}
+
+function isProvenanceEntry(value: unknown): value is ResolvedSecretTraceProvenanceEntryV1 {
+ if (!isExactPlainDataRecord(value, PROVENANCE_ENTRY_PROPERTY_NAMES, ['encryptedValue'])) {
+ return false
+ }
+ const entry = value
+ return (
+ typeof entry.encryptedValue === 'string' &&
+ entry.encryptedValue.length > 0 &&
+ (entry.name === undefined || (typeof entry.name === 'string' && entry.name.length > 0))
+ )
+}
+
+function isProvenanceScope(value: unknown): value is ResolvedSecretTraceScopeV1 {
+ if (!isExactPlainDataRecord(value, PROVENANCE_SCOPE_PROPERTY_NAMES, ['userId'])) return false
+ const scope = value
+ return (
+ typeof scope.userId === 'string' &&
+ scope.userId.length > 0 &&
+ (scope.workspaceId === undefined ||
+ (typeof scope.workspaceId === 'string' && scope.workspaceId.length > 0))
+ )
+}
+
+function scopesMatch(
+ left: ResolvedSecretTraceScopeV1 | undefined,
+ right: ResolvedSecretTraceScopeV1 | undefined
+): boolean {
+ return (
+ (left === undefined && right === undefined) ||
+ (left !== undefined &&
+ right !== undefined &&
+ left.userId === right.userId &&
+ left.workspaceId === right.workspaceId)
+ )
+}
+
+export function isResolvedSecretTraceProvenanceV1(
+ value: unknown
+): value is ResolvedSecretTraceProvenanceV1 {
+ if (
+ !isExactPlainDataRecord(value, PROVENANCE_PROPERTY_NAMES, ['version', 'complete', 'entries'])
+ ) {
+ return false
+ }
+ const provenance = value
+ if (
+ provenance.version !== 1 ||
+ typeof provenance.complete !== 'boolean' ||
+ !isExactProvenanceEntriesArray(provenance.entries) ||
+ provenance.entries.length > MAX_PROVENANCE_ENTRIES ||
+ !provenance.entries.every(isProvenanceEntry) ||
+ (!provenance.complete && provenance.entries.length > 0) ||
+ (provenance.scope !== undefined && !isProvenanceScope(provenance.scope))
+ ) {
+ return false
+ }
+
+ return isSerializedProvenanceWithinLimit(
+ provenance.complete,
+ provenance.entries,
+ provenance.scope
+ )
+}
+
+/**
+ * Unions encrypted provenance reports emitted during one internal transport invocation.
+ * It never decrypts values or projects trace content; the execution registry remains the
+ * only owner of plaintext matches and replacement policy.
+ */
+export class ResolvedSecretTraceProvenanceAccumulator {
+ private readonly scope?: ResolvedSecretTraceScopeV1
+ private provenance: ResolvedSecretTraceProvenanceV1
+
+ constructor(scope?: ResolvedSecretTraceScopeV1) {
+ this.scope = scope ? cloneProvenanceScope(scope) : undefined
+ this.provenance = this.emptyProvenance(true)
+ }
+
+ /** Adds one cold, warm-pool, or retry report without decrypting its entries. */
+ record(provenance: unknown): boolean {
+ if (!isResolvedSecretTraceProvenanceV1(provenance)) {
+ this.provenance = this.emptyProvenance(false)
+ return false
+ }
+
+ const sameScope = scopesMatch(provenance.scope, this.scope)
+ const complete = this.provenance.complete && provenance.complete && sameScope
+ if (!complete) {
+ this.provenance = this.emptyProvenance(false)
+ return true
+ }
+
+ const entries = new Map(
+ this.provenance.entries.map((entry) => [
+ `${entry.name ?? ''}\u0000${entry.encryptedValue}`,
+ entry,
+ ])
+ )
+ for (const entry of provenance.entries) {
+ const scopedEntry: ResolvedSecretTraceProvenanceEntryV1 = {
+ encryptedValue: entry.encryptedValue,
+ ...(entry.name ? { name: entry.name } : {}),
+ }
+ entries.set(`${scopedEntry.name ?? ''}\u0000${scopedEntry.encryptedValue}`, scopedEntry)
+ }
+
+ const merged: ResolvedSecretTraceProvenanceV1 = {
+ version: 1,
+ complete: true,
+ entries: [...entries.values()],
+ ...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}),
+ }
+ if (!isResolvedSecretTraceProvenanceV1(merged)) {
+ this.provenance = this.emptyProvenance(false)
+ return false
+ }
+
+ this.provenance = merged
+ return true
+ }
+
+ /** Marks the invocation incomplete and discards entries that can no longer be trusted. */
+ markIncomplete(): void {
+ this.provenance = this.emptyProvenance(false)
+ }
+
+ exportProvenance(): ResolvedSecretTraceProvenanceV1 {
+ return structuredClone(this.provenance)
+ }
+
+ private emptyProvenance(complete: boolean): ResolvedSecretTraceProvenanceV1 {
+ return {
+ version: 1,
+ complete,
+ entries: [],
+ ...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}),
+ }
+ }
+}
+
+/**
+ * Tracks Secrets-tab values that were actually substituted through `{{NAME}}` during one run.
+ * The current catalog is inert until a successful resolution activates an entry.
+ */
+export class ResolvedSecretTraceRegistry {
+ private readonly catalog = new Map()
+ private catalogBytes = 0
+ private readonly activeEntries = new Map()
+ private activeProvenanceEntryBytes = 0
+ private complete = true
+ private readonly scope?: ResolvedSecretTraceScopeV1
+ private readonly completeProvenanceEnvelopeBytes: number
+
+ constructor(
+ catalogEntries: Iterable = [],
+ scope?: ResolvedSecretTraceScopeV1
+ ) {
+ this.scope = scope ? cloneProvenanceScope(scope) : undefined
+ this.completeProvenanceEnvelopeBytes = serializedProvenanceEnvelopeByteSize(true, this.scope)
+ if (this.completeProvenanceEnvelopeBytes > MAX_SERIALIZED_PROVENANCE_BYTES) {
+ this.markIncomplete()
+ }
+ let catalogEntriesSeen = 0
+ for (const entry of catalogEntries) {
+ catalogEntriesSeen++
+ if (catalogEntriesSeen > MAX_TRACE_CATALOG_ENTRIES) {
+ this.markIncomplete()
+ break
+ }
+ if (!this.addCatalogEntry(entry)) break
+ }
+ }
+
+ /** Activates a configured secret only when the resolved runtime value matches its catalog value. */
+ recordResolved(name: string, resolvedValue: string): boolean {
+ if (resolvedValue.length === 0) return false
+ const catalogEntry = this.catalog.get(name)
+ if (!catalogEntry || catalogEntry.plaintext !== resolvedValue) {
+ this.markIncomplete()
+ return false
+ }
+
+ this.addActiveEntry({ ...catalogEntry, anonymous: false })
+ return true
+ }
+
+ /** Imports encrypted provenance only from a boundary that has already established trust. */
+ async importProvenance(
+ provenance: unknown,
+ options: ImportResolvedSecretTraceProvenanceOptions
+ ): Promise {
+ if (!options.trusted || !isResolvedSecretTraceProvenanceV1(provenance)) {
+ this.markIncomplete()
+ return false
+ }
+
+ if (!provenance.complete) {
+ this.markIncomplete()
+ }
+
+ const sameScope = scopesMatch(provenance.scope, this.scope)
+ let importedAll = true
+ for (const entry of provenance.entries) {
+ try {
+ const { decrypted } = await decryptSecret(entry.encryptedValue)
+ this.addActiveEntry({
+ name: entry.name ?? '',
+ plaintext: decrypted,
+ encryptedValue: entry.encryptedValue,
+ anonymous: options.anonymous === true || !sameScope || entry.name === undefined,
+ })
+ } catch {
+ importedAll = false
+ this.markIncomplete()
+ }
+ }
+
+ return importedAll
+ }
+
+ /** Imports all same-scope provenance, or only anonymous literals crossing a trust boundary. */
+ async importCrossingProvenance(
+ provenance: unknown,
+ crossingValue: unknown,
+ options: { trusted: boolean }
+ ): Promise {
+ if (!options.trusted || !isResolvedSecretTraceProvenanceV1(provenance)) {
+ return this.importProvenance(provenance, { trusted: options.trusted })
+ }
+
+ if (scopesMatch(provenance.scope, this.scope)) {
+ return this.importProvenance(provenance, { trusted: true })
+ }
+
+ const sourceRegistry = new ResolvedSecretTraceRegistry([], provenance.scope)
+ const sourceImported = await sourceRegistry.importProvenance(provenance, { trusted: true })
+ const crossingProvenance = sourceRegistry.exportProvenanceForValue(crossingValue, {
+ anonymous: true,
+ })
+ const crossingImported = await this.importProvenance(crossingProvenance, { trusted: true })
+ return sourceImported && crossingImported
+ }
+
+ /** Returns deterministic literal replacements for the terminal TraceSpan projection. */
+ getActiveMatches(): readonly ResolvedSecretTraceMatch[] {
+ const candidatesByPlaintext = new Map()
+ for (const entry of this.activeEntries.values()) {
+ if (entry.plaintext.length === 0) continue
+ const candidates = candidatesByPlaintext.get(entry.plaintext) ?? []
+ candidates.push(entry)
+ candidatesByPlaintext.set(entry.plaintext, candidates)
+ }
+
+ const matches = [...candidatesByPlaintext.keys()].map((plaintext) => {
+ const candidates = candidatesByPlaintext.get(plaintext) ?? []
+ const anonymous = candidates.some((candidate) => candidate.anonymous)
+ const firstNamed = candidates
+ .filter((candidate) => !candidate.anonymous)
+ .sort((left, right) => compareStrings(left.name, right.name))[0]
+ const replacement = anonymous
+ ? ANONYMOUS_SECRET_TRACE_REPLACEMENT
+ : `{{${firstNamed?.name ?? ''}}}`
+
+ return { plaintext, replacement }
+ })
+
+ return matches.sort(
+ (left, right) =>
+ right.plaintext.length - left.plaintext.length ||
+ compareStrings(left.plaintext, right.plaintext) ||
+ compareStrings(left.replacement, right.replacement)
+ )
+ }
+
+ isComplete(): boolean {
+ return this.complete
+ }
+
+ markIncomplete(): void {
+ this.complete = false
+ }
+
+ /** Serializes only encrypted active values; plaintext never enters execution state. */
+ exportProvenance(): ResolvedSecretTraceProvenanceV1 {
+ const entries = this.complete
+ ? this.buildProvenanceEntries([...this.activeEntries.values()])
+ : []
+
+ return {
+ version: 1,
+ complete: this.complete,
+ entries,
+ ...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}),
+ }
+ }
+
+ /**
+ * Exports only active provenance whose exact plaintext occurs in a cross-boundary value.
+ * Bounded traversal returns incomplete provenance instead of broadening to unrelated secrets.
+ */
+ exportProvenanceForValue(
+ value: unknown,
+ options: ExportResolvedSecretTraceProvenanceForValueOptions = {}
+ ): ResolvedSecretTraceProvenanceV1 {
+ if (!this.complete) return { version: 1, complete: false, entries: [] }
+
+ const candidatesByPlaintext = new Map()
+ const sortedActiveEntries = [...this.activeEntries.values()].sort(
+ (left, right) =>
+ compareStrings(left.name, right.name) ||
+ compareStrings(left.encryptedValue, right.encryptedValue)
+ )
+ for (const entry of sortedActiveEntries) {
+ if (entry.plaintext.length > 0 && !candidatesByPlaintext.has(entry.plaintext)) {
+ candidatesByPlaintext.set(entry.plaintext, entry)
+ }
+ }
+
+ const matchedEntries = new Map()
+ const pendingValues: unknown[] = [value]
+ const visited = new WeakSet