fix(security): redact workflow snapshot secrets (#6581)

* fix(security): redact opaque workflow snapshot inputs

* fix(security): document fail-closed tool redaction

* fix(security): redact malformed tool params

* fix(security): redact nested credential references

* fix(security): isolate opaque tool schemas
This commit is contained in:
Theodore Li
2026-08-11 23:14:15 -04:00
committed by GitHub
parent f306b517c1
commit 23318a13a0
12 changed files with 431 additions and 67 deletions
+1 -1
View File
@@ -1206,7 +1206,7 @@
"type": "null"
}
],
"description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input` values and `password: true` sub-block values are null, while `{{VAR}}` environment-variable references are preserved. Null when no snapshot is retained."
"description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained."
},
"traceSpans": {
"type": "array",
+1 -1
View File
@@ -2876,7 +2876,7 @@
"format": "date-time"
},
"state": {
"description": "Deployed workflow graph snapshot pinned by this version.",
"description": "Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.",
"$ref": "#/components/schemas/DeployedWorkflowState"
}
},
@@ -29,6 +29,23 @@ vi.mock('@/lib/workflows/application/context', () => ({
vi.mock('@/lib/workflows/persistence/utils', () => ({
getWorkflowDeploymentVersion: mocks.readVersion,
}))
vi.mock('@/lib/workflows/search-replace/indexer', () => ({
getToolInputParamConfigs: ({
tool,
}: {
tool: { type: string; params?: Record<string, unknown> }
}) =>
Object.entries(tool.params ?? {}).map(([paramId, value]) => ({
paramId,
authoritative: tool.type !== 'custom-tool' && tool.type !== 'mcp',
value,
config: {
id: paramId,
type: 'short-input',
password: paramId === 'apiKey',
},
})),
}))
vi.mock('@/blocks/registry', () => ({
getBlock: () => ({
name: 'Slack',
@@ -36,6 +53,8 @@ vi.mock('@/blocks/registry', () => ({
{ id: 'credential', type: 'oauth-input' },
{ id: 'botToken', type: 'short-input', password: true },
{ id: 'envToken', type: 'short-input', password: true },
{ id: 'tools', type: 'tool-input' },
{ id: 'headers', type: 'table' },
{ id: 'channel', type: 'short-input' },
],
outputs: {},
@@ -88,6 +107,21 @@ function versionState() {
credential: { id: 'credential', type: 'oauth-input', value: 'oauth-credential-id' },
botToken: { id: 'botToken', type: 'short-input', value: 'xoxb-plaintext-secret' },
envToken: { id: 'envToken', type: 'short-input', value: '{{SLACK_BOT_TOKEN}}' },
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'custom-tool',
params: { apiKey: 'sk-tool-plaintext-secret', query: 'safe input' },
},
],
},
headers: {
id: 'headers',
type: 'table',
value: [{ Key: 'Authorization', Value: 'Bearer table-plaintext-secret' }],
},
channel: { id: 'channel', type: 'short-input', value: '#general' },
},
},
@@ -149,6 +183,15 @@ describe('GET /api/v2/workflows/[id]/versions/[version]', () => {
expect(subBlocks.credential.value).toBeNull()
expect(subBlocks.botToken.value).toBeNull()
expect(subBlocks.envToken.value).toBe('{{SLACK_BOT_TOKEN}}')
expect(subBlocks.tools.value).toEqual([
{
type: 'custom-tool',
params: { apiKey: null, query: null },
},
])
expect(subBlocks.headers.value).toBeNull()
expect(subBlocks.channel.value).toBe('#general')
expect(JSON.stringify(subBlocks)).not.toContain('sk-tool-plaintext-secret')
expect(JSON.stringify(subBlocks)).not.toContain('table-plaintext-secret')
})
})
+1 -1
View File
@@ -74,7 +74,7 @@ const v2LogWorkflowStateSchema = z
)
.nullable()
.describe(
'Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input` values and `password: true` sub-block values are null, while `{{VAR}}` environment-variable references are preserved. Null when no snapshot is retained.'
'Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained.'
)
const v2LogWorkflowSummarySchema = z.object({
+1 -1
View File
@@ -615,7 +615,7 @@ export const v2WorkflowVersionDetailSchema = z
.describe('ISO 8601 timestamp when this version was created.')
.meta({ format: 'date-time' }),
state: deployedWorkflowStateSchema.describe(
'Deployed workflow graph snapshot pinned by this version.'
'Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.'
),
})
.meta({
@@ -43,6 +43,24 @@ vi.mock('@/lib/logs/execution/trace-store', () => ({
materializeExecutionDataForDisplay: mocks.materialize,
}))
vi.mock('@/lib/workflows/search-replace/indexer', () => ({
getToolInputParamConfigs: ({
tool,
}: {
tool: { type: string; params?: Record<string, unknown> }
}) =>
Object.entries(tool.params ?? {}).map(([paramId, value]) => ({
paramId,
authoritative: tool.type !== 'custom-tool' && tool.type !== 'mcp',
value,
config: {
id: paramId,
type: 'short-input',
password: paramId === 'apiKey',
},
})),
}))
vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit }))
/**
@@ -56,6 +74,8 @@ vi.mock('@/blocks/registry', () => ({
{ id: 'credential', type: 'oauth-input' },
{ id: 'botToken', type: 'short-input', password: true },
{ id: 'envToken', type: 'short-input', password: true },
{ id: 'tools', type: 'tool-input' },
{ id: 'headers', type: 'table' },
{ id: 'channel', type: 'short-input' },
],
outputs: {},
@@ -155,6 +175,21 @@ describe('public log application use cases', () => {
credential: { id: 'credential', type: 'oauth-input', value: 'cred_9f2a' },
botToken: { id: 'botToken', type: 'short-input', value: 'xoxb-plaintext-secret' },
envToken: { id: 'envToken', type: 'short-input', value: '{{SLACK_TOKEN}}' },
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'custom-tool',
params: { apiKey: 'sk-log-tool-secret', query: 'safe input' },
},
],
},
headers: {
id: 'headers',
type: 'table',
value: [{ Key: 'Authorization', Value: 'Bearer log-table-secret' }],
},
channel: { id: 'channel', type: 'short-input', value: '#general' },
},
},
@@ -177,7 +212,16 @@ describe('public log application use cases', () => {
expect(subBlocks.credential.value).toBeNull()
expect(subBlocks.botToken.value).toBeNull()
expect(subBlocks.envToken.value).toBe('{{SLACK_TOKEN}}')
expect(subBlocks.tools.value).toEqual([
{
type: 'custom-tool',
params: { apiKey: null, query: null },
},
])
expect(subBlocks.headers.value).toBeNull()
expect(subBlocks.channel.value).toBe('#general')
expect(JSON.stringify(subBlocks)).not.toContain('sk-log-tool-secret')
expect(JSON.stringify(subBlocks)).not.toContain('log-table-secret')
})
it('passes the personal-key subject through as the projection reader', async () => {
+6 -2
View File
@@ -11,7 +11,8 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
*
* `preserveEnvVars` keeps `{{VAR}}` references, which name a workspace environment variable
* rather than carrying its value — resolution happens at execution time — so the reference is
* not a secret and is what keeps consecutive run snapshots diffable.
* not a secret and is what keeps consecutive run snapshots diffable. Tool parameters without
* authoritative codec metadata are withheld rather than guessed safe.
*
* A run with no retained snapshot projects as `null`, and so does a stored value that is not an
* object: the sanitizer can make no guarantee about a shape it cannot walk, so it is withheld
@@ -19,5 +20,8 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
*/
export function sanitizeExecutionSnapshotState(state: unknown): Record<string, unknown> | null {
if (typeof state !== 'object' || state === null) return null
return sanitizeWorkflowForSharing(state as Partial<WorkflowState>, { preserveEnvVars: true })
return sanitizeWorkflowForSharing(state as Partial<WorkflowState>, {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})
}
@@ -20,10 +20,15 @@ function isWorkflowState(value: unknown): value is WorkflowState {
*
* `preserveEnvVars` keeps `{{VAR}}` references: those name a workspace environment variable
* rather than carrying its value — resolution happens at execution time — so the reference is
* not a secret and is what keeps the pinned graph diffable. Literal inline secrets are nulled.
* not a secret and is what keeps the pinned graph diffable. Literal inline secrets, opaque table
* cells, sensitive nested tool parameters, and tool parameters without authoritative codec
* metadata are nulled.
*/
function sanitizeVersionState(state: WorkflowState): WorkflowState {
const sanitized = sanitizeWorkflowForSharing(state, { preserveEnvVars: true })
const sanitized = sanitizeWorkflowForSharing(state, {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})
// double-cast-allowed: the sanitizer clones the graph and only nulls sub-block values, so the shape is unchanged, but its widened return type no longer overlaps WorkflowState
return sanitized as unknown as WorkflowState
}
@@ -5,11 +5,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EXPORT_PRESERVED_RESOURCE_TYPES,
sanitizeForExport,
sanitizeWorkflowForSharing,
} from '@/lib/workflows/credentials/credential-extractor'
import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry'
import { getBlock } from '@/blocks/registry'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
vi.mock('@/lib/workflows/search-replace/indexer', () => ({
getToolInputParamConfigs: ({
tool,
}: {
tool: { type: string; params?: Record<string, unknown> }
}) =>
Object.entries(tool.params ?? {}).map(([paramId, value]) => ({
paramId,
authoritative: tool.type !== 'custom-tool' && tool.type !== 'mcp',
value,
config: {
id: paramId,
type: 'short-input',
password: paramId === 'apiKey' || paramId === 'token',
canonicalParamId: paramId === 'manualCredential' ? 'oauthCredential' : undefined,
},
})),
}))
function stateWithSubBlock(type: string, value: unknown): Partial<WorkflowState> {
return {
blocks: {
@@ -93,4 +113,131 @@ describe('export sanitizer resource coverage', () => {
} as unknown as Partial<WorkflowState>)
expect(sanitized.blocks?.b1?.subBlocks?.tableId?.value).toBeNull()
})
it('uses authoritative tool-input codecs to withhold secrets while preserving safe config', () => {
const value = [
{
type: 'gmail',
toolId: 'gmail_send',
operation: 'send_gmail',
params: {
apiKey: 'sk-plaintext-secret',
query: 'safe input',
},
},
]
vi.mocked(getBlock).mockReturnValue({
name: 'Test',
description: '',
subBlocks: [{ id: 'field', title: 'Field', type: 'tool-input' }],
outputs: {},
} as never)
const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('tool-input', value), {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})
expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
{
type: 'gmail',
toolId: 'gmail_send',
operation: 'send_gmail',
params: { apiKey: null, query: 'safe input' },
},
])
})
it('withholds advanced credential selectors nested inside tool inputs', () => {
const value = [
{
type: 'gmail',
toolId: 'gmail_send',
operation: 'send_gmail',
params: {
manualCredential: 'credential-id',
query: 'safe input',
},
},
]
vi.mocked(getBlock).mockReturnValue({
name: 'Test',
description: '',
subBlocks: [{ id: 'field', title: 'Field', type: 'tool-input' }],
outputs: {},
} as never)
const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('tool-input', value), {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})
expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
{
type: 'gmail',
toolId: 'gmail_send',
operation: 'send_gmail',
params: { manualCredential: null, query: 'safe input' },
},
])
})
it('withholds opaque table values from public snapshots', () => {
const value = [
{ Key: 'Authorization', Value: 'Bearer plaintext-secret' },
{ Key: 'API_TOKEN', Value: '{{API_TOKEN}}' },
]
vi.mocked(getBlock).mockReturnValue({
name: 'Test',
description: '',
subBlocks: [{ id: 'field', title: 'Field', type: 'table' }],
outputs: {},
} as never)
const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('table', value), {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})
expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toBeNull()
})
it('withholds every unclassified custom-tool parameter', () => {
vi.mocked(getBlock).mockReturnValue(undefined as never)
const sanitized = sanitizeWorkflowForSharing(
stateWithSubBlock('tool-input', [
{
type: 'custom-tool',
params: { token: 'plaintext-secret', query: 'ordinary configuration' },
},
]),
{ redactOpaqueCredentialInputs: true }
)
expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
{ type: 'custom-tool', params: { token: null, query: null } },
])
})
it.each([
['string', 'plaintext-secret'],
['array', ['plaintext-secret']],
])('withholds malformed %s tool params', (_shape, params) => {
vi.mocked(getBlock).mockReturnValue({
name: 'Test',
description: '',
subBlocks: [{ id: 'field', title: 'Field', type: 'tool-input' }],
outputs: {},
} as never)
const sanitized = sanitizeWorkflowForSharing(
stateWithSubBlock('tool-input', [{ type: 'custom-tool', params }]),
{ redactOpaqueCredentialInputs: true }
)
expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
{ type: 'custom-tool', params: null },
])
})
})
@@ -1,4 +1,7 @@
import { isPlainRecord } from '@sim/utils/object'
import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer'
import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry'
import { setValueAtPath } from '@/lib/workflows/search-replace/value-walker'
import {
buildCanonicalIndex,
buildSubBlockValues,
@@ -8,6 +11,7 @@ import {
isSubBlockVisibleForMode,
type SubBlockCondition,
} from '@/lib/workflows/subblocks/visibility'
import { parseStoredToolInputValue } from '@/lib/workflows/tool-input/types'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
@@ -66,6 +70,8 @@ const WORKSPACE_SPECIFIC_TYPES: ReadonlySet<string> = new Set<string>([
* type-keyed registry above cannot supply, so this list stays explicit.
*/
const WORKSPACE_SPECIFIC_FIELDS = new Set([
'credentialId',
'oauthCredential',
'knowledgeBaseId',
'tagFilters',
'documentTags',
@@ -77,6 +83,15 @@ const WORKSPACE_SPECIFIC_FIELDS = new Set([
'folderId',
])
/**
* Sub-block values whose interior cannot be projected safely for a read-only snapshot API.
*
* Tables are arbitrary key/value rows used for authorization headers and sandbox environment
* variables. Their cells carry no password metadata, so public snapshots must withhold the whole
* value. Tool inputs are handled separately through the search-replace parameter codecs.
*/
const OPAQUE_CREDENTIAL_BEARING_TYPES: ReadonlySet<string> = new Set(['table'])
/**
* Extract required credentials from a workflow state
* This analyzes all blocks and their subblocks to identify credential requirements
@@ -192,9 +207,13 @@ function formatFieldName(fieldName: string): string {
.join(' ')
}
interface MutableSubBlockState extends Omit<SubBlockState, 'value'> {
value: unknown
}
/** Block state with mutable subBlocks for sanitization */
interface MutableBlockState extends Omit<BlockState, 'subBlocks'> {
subBlocks: Record<string, SubBlockState | null | undefined>
subBlocks: Record<string, MutableSubBlockState | null | undefined>
data?: Record<string, unknown>
}
@@ -248,6 +267,90 @@ interface SanitizedWorkflowState {
[key: string]: unknown
}
interface WorkflowSanitizationOptions {
preserveEnvVars?: boolean
redactOpaqueCredentialInputs?: boolean
}
type CredentialSanitizationConfig = Pick<
SubBlockConfig,
'id' | 'type' | 'password' | 'canonicalParamId'
>
function isEnvironmentVariableReference(value: unknown): value is string {
return typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')
}
/**
* Sanitizes nested tool parameters using the same codecs as workflow search and fork remapping.
* Only parameters resolved from a registered definition retain non-sensitive values. Custom, MCP,
* and unknown schemas lack reliable secret annotations, so their generic parameters are withheld.
*/
function sanitizeToolInputValue(value: unknown, options: WorkflowSanitizationOptions): unknown {
const tools = parseStoredToolInputValue(value)
if (!Array.isArray(value)) return null
if (tools.length !== value.length) return null
let sanitizedValue: unknown = value
tools.forEach((tool, toolIndex) => {
const storedTool = value[toolIndex]
if (!isPlainRecord(storedTool)) {
throw new Error(`Parsed tool input at index ${toolIndex} lost its object shape`)
}
if (storedTool.params != null && !isPlainRecord(storedTool.params)) {
sanitizedValue = setValueAtPath(sanitizedValue, [toolIndex, 'params'], null)
return
}
const configs = getToolInputParamConfigs({ tool, toolIndex })
const configByParamKey = new Map<
string,
{ config: CredentialSanitizationConfig; authoritative: boolean }
>()
configs.forEach(({ paramId, config, authoritative }) => {
configByParamKey.set(paramId, { config, authoritative })
if (config.canonicalParamId) {
configByParamKey.set(config.canonicalParamId, { config, authoritative })
}
})
Object.entries(tool.params ?? {}).forEach(([paramKey, paramValue]) => {
const resolved = configByParamKey.get(paramKey)
const nextValue = resolved?.authoritative
? sanitizeConfiguredSubBlockValue(paramValue, resolved.config, options)
: null
sanitizedValue = setValueAtPath(sanitizedValue, [toolIndex, 'params', paramKey], nextValue)
})
})
return sanitizedValue
}
function sanitizeConfiguredSubBlockValue(
value: unknown,
config: CredentialSanitizationConfig,
options: WorkflowSanitizationOptions
): unknown {
if (config.type === 'oauth-input') return null
if (options.redactOpaqueCredentialInputs && config.type === 'tool-input') {
return sanitizeToolInputValue(value, options)
}
if (options.redactOpaqueCredentialInputs && OPAQUE_CREDENTIAL_BEARING_TYPES.has(config.type)) {
return null
}
if (config.password === true) {
return options.preserveEnvVars && isEnvironmentVariableReference(value) ? value : null
}
if (
WORKSPACE_SPECIFIC_TYPES.has(config.type) ||
WORKSPACE_SPECIFIC_FIELDS.has(config.id) ||
(config.canonicalParamId != null && WORKSPACE_SPECIFIC_FIELDS.has(config.canonicalParamId))
) {
return null
}
return value
}
/**
* Sanitize workflow state by removing all credentials and workspace-specific data
* This is used for both template creation and workflow export to ensure consistency
@@ -257,9 +360,7 @@ interface SanitizedWorkflowState {
*/
export function sanitizeWorkflowForSharing(
state: Partial<WorkflowState> | null | undefined,
options: {
preserveEnvVars?: boolean // Keep {{VAR}} references for export
} = {}
options: WorkflowSanitizationOptions = {}
): SanitizedWorkflowState {
const sanitized = structuredClone(state) as SanitizedWorkflowState
@@ -281,35 +382,11 @@ export function sanitizeWorkflowForSharing(
if (block.subBlocks?.[subBlockConfig.id]) {
const subBlock = block.subBlocks[subBlockConfig.id]
// Clear OAuth credentials (type: 'oauth-input')
if (subBlockConfig.type === 'oauth-input') {
block.subBlocks[subBlockConfig.id]!.value = null
}
// Clear secret fields (password: true)
else if (subBlockConfig.password === true) {
// Preserve environment variable references if requested
if (
options.preserveEnvVars &&
typeof subBlock?.value === 'string' &&
subBlock.value.startsWith('{{') &&
subBlock.value.endsWith('}}')
) {
// Keep the env var reference
} else {
block.subBlocks[subBlockConfig.id]!.value = null
}
}
// Clear workspace-specific selectors
else if (WORKSPACE_SPECIFIC_TYPES.has(subBlockConfig.type)) {
block.subBlocks[subBlockConfig.id]!.value = null
}
// Clear workspace-specific fields by ID
else if (WORKSPACE_SPECIFIC_FIELDS.has(subBlockConfig.id)) {
block.subBlocks[subBlockConfig.id]!.value = null
}
block.subBlocks[subBlockConfig.id]!.value = sanitizeConfiguredSubBlockValue(
subBlock?.value,
subBlockConfig,
options
)
}
})
}
@@ -317,6 +394,14 @@ export function sanitizeWorkflowForSharing(
// Process subBlocks without config (fallback)
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([key, subBlock]) => {
if (options.redactOpaqueCredentialInputs && subBlock) {
if (subBlock.type === 'tool-input') {
subBlock.value = sanitizeToolInputValue(subBlock.value, options)
} else if (OPAQUE_CREDENTIAL_BEARING_TYPES.has(subBlock.type)) {
subBlock.value = null
}
}
// Clear workspace-specific fields by key name
if (WORKSPACE_SPECIFIC_FIELDS.has(key) && subBlock) {
subBlock.value = null
@@ -2,7 +2,10 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer'
import {
getToolInputParamConfigs,
indexWorkflowSearchMatches,
} from '@/lib/workflows/search-replace/indexer'
import { workflowSearchMatchMatchesQuery } from '@/lib/workflows/search-replace/resources'
import {
createSearchReplaceWorkflowFixture,
@@ -22,6 +25,37 @@ import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replac
vi.unmock('@/tools/registry')
describe('indexWorkflowSearchMatches', () => {
it('marks generic tool-param fallbacks as non-authoritative', () => {
expect(
getToolInputParamConfigs({
tool: { type: 'custom-tool', params: { apiKey: 'literal-secret' } },
})
).toEqual([
expect.objectContaining({
paramId: 'apiKey',
authoritative: false,
value: 'literal-secret',
}),
])
})
it.each(['custom-tool', 'mcp'])(
'keeps %s params non-authoritative when its tool ID collides with a built-in',
(type) => {
expect(
getToolInputParamConfigs({
tool: { type, toolId: 'gmail_send', params: { body: 'literal-secret' } },
})
).toEqual([
expect.objectContaining({
paramId: 'body',
authoritative: false,
value: 'literal-secret',
}),
])
}
)
it('finds plain text matches across nested subblock values', () => {
const workflow = createSearchReplaceWorkflowFixture()
@@ -668,6 +668,16 @@ function isVisibleToolParameter(param: ToolParameterConfig, values: Record<strin
)
}
export interface ResolvedToolInputParamConfig {
paramId: string
config: WorkflowSearchSubBlockConfig
value: unknown
/** False when the codec had no registered tool definition and inferred only a generic shape. */
authoritative: boolean
selectorContext?: SelectorContext
dependentValuePaths?: WorkflowSearchValuePath[]
}
/**
* Resolve a stored tool's params to their subBlock configs (the same resolution
* the search index + UI use). Exported so cross-workspace remapping (fork/promote)
@@ -687,17 +697,11 @@ export function getToolInputParamConfigs({
parentCanonicalModes?: CanonicalModeOverrides
credentialTypeById?: Record<string, string | undefined>
blockConfigs?: WorkflowSearchIndexerOptions['blockConfigs']
}): Array<{
paramId: string
config: WorkflowSearchSubBlockConfig
value: unknown
selectorContext?: SelectorContext
dependentValuePaths?: WorkflowSearchValuePath[]
}> {
const toolId =
tool.type !== 'custom-tool' && tool.type !== 'mcp'
? getToolIdForOperation(tool.type, tool.operation) || tool.toolId
: tool.toolId
}): ResolvedToolInputParamConfig[] {
const hasAuthoritativeRegistryDefinition = tool.type !== 'custom-tool' && tool.type !== 'mcp'
const toolId = hasAuthoritativeRegistryDefinition
? getToolIdForOperation(tool.type, tool.operation) || tool.toolId
: undefined
const toolParamValues = tool.params ?? {}
const values = { operation: tool.operation, ...toolParamValues }
const genericFallback = () =>
@@ -715,6 +719,7 @@ export function getToolInputParamConfigs({
const type = getFallbackToolParamType(value)
return {
paramId,
authoritative: false,
config: {
id: paramId,
title: paramId,
@@ -732,20 +737,14 @@ export function getToolInputParamConfigs({
toolIndex,
tool.type
)
const blockConfig =
tool.type !== 'custom-tool' && tool.type !== 'mcp'
? (blockConfigs?.[tool.type] ?? getBlock(tool.type))
: null
const subBlocksResult =
tool.type !== 'custom-tool' && tool.type !== 'mcp'
? getSubBlocksForToolInput(
toolId,
tool.type,
values,
scopedCanonicalModes,
blockConfig?.subBlocks ? { subBlocks: blockConfig.subBlocks } : undefined
)
: null
const blockConfig = blockConfigs?.[tool.type] ?? getBlock(tool.type)
const subBlocksResult = getSubBlocksForToolInput(
toolId,
tool.type,
values,
scopedCanonicalModes,
blockConfig?.subBlocks ? { subBlocks: blockConfig.subBlocks } : undefined
)
const toolParams = getToolParametersConfig(toolId, tool.type, values)
const displayParams = toolParams?.userInputParameters ?? []
@@ -759,6 +758,7 @@ export function getToolInputParamConfigs({
const config = buildToolInputSearchConfig(param)
return {
paramId: param.id,
authoritative: true,
config,
value: parseToolParamValue(toolParamValues[param.id], config.type),
selectorContext:
@@ -811,6 +811,7 @@ export function getToolInputParamConfigs({
const subBlockParams = visibleSubBlocks.map((config) => ({
paramId: config.id,
authoritative: true,
config,
value: parseToolParamValue(toolParamValues[config.id], config.type),
dependentValuePaths: getDependentValuePaths(config.id),
@@ -830,6 +831,7 @@ export function getToolInputParamConfigs({
const config = buildToolInputSearchConfig(param)
return {
paramId: param.id,
authoritative: true,
config,
value: parseToolParamValue(toolParamValues[param.id], config.type),
selectorContext: