diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 06e46c664a..726c29f70e 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -114,6 +114,31 @@ const imageBlockConfig = { tools: { access: ['image_generate'], config: { tool: () => 'image_generate' } }, } +// Tool whose hosting.enabled predicate throws — used to assert fail-toward-strip behavior. +const throwGateBlockConfig = { + type: 'throw_gate_block', + name: 'Throw Gate Block', + outputs: {}, + subBlocks: [{ id: 'provider', type: 'dropdown' }], + tools: { access: ['throw_gate_tool'], config: { tool: () => 'throw_gate_tool' } }, +} + +// Block whose tool selector throws — should fall back to scanning access tools (video_falai). +const throwSelectorBlockConfig = { + type: 'throw_selector_block', + name: 'Throw Selector Block', + outputs: {}, + subBlocks: [{ id: 'provider', type: 'dropdown' }], + tools: { + access: ['video_falai'], + config: { + tool: () => { + throw new Error('selector boom') + }, + }, + }, +} + // Tool registry stand-in for the hosted-tool tests. const toolsByIdMock: Record = { video_falai: { id: 'video_falai', hosting: { apiKeyParam: 'apiKey' } }, @@ -126,6 +151,15 @@ const toolsByIdMock: Record = { enabled: (p: Record) => p.provider === 'falai', }, }, + throw_gate_tool: { + id: 'throw_gate_tool', + hosting: { + apiKeyParam: 'apiKey', + enabled: () => { + throw new Error('boom') + }, + }, + }, } vi.mock('@/blocks/registry', () => ({ @@ -150,7 +184,11 @@ vi.mock('@/blocks/registry', () => ({ ? customKeyBlockConfig : type === 'image_generator_v2' ? imageBlockConfig - : undefined, + : type === 'throw_gate_block' + ? throwGateBlockConfig + : type === 'throw_selector_block' + ? throwSelectorBlockConfig + : undefined, })) vi.mock('@/blocks/utils', () => ({ @@ -469,6 +507,31 @@ describe('preValidateCredentialInputs (hosted-tool blocks)', () => { expect(result.errors).toHaveLength(1) }) + it('strips apiKey on a type-less edit op, resolving block type + provider from workflow state', async () => { + // Mirrors the real failure: agent edits only { apiKey } with no `type` restated. + const operations = [ + { + operation_type: 'edit' as const, + block_id: 'video-1', + params: { inputs: { apiKey: 'test-api-key-12345' } }, + }, + ] + const workflowState = { + blocks: { + 'video-1': { + type: 'video_generator_v3', + subBlocks: { provider: { value: 'falai' } }, + }, + }, + } + + const result = await preValidateCredentialInputs(operations, ctx, workflowState) + + expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]).toMatchObject({ blockId: 'video-1', field: 'apiKey' }) + }) + it('strips apiKey on a hosted-tool block nested inside a loop', async () => { const operations = [ { @@ -516,6 +579,198 @@ describe('preValidateCredentialInputs (hosted-tool blocks)', () => { expect(result.errors[0]).toMatchObject({ blockId: 'custom-1', field: 'serviceKey' }) }) + it('strips apiKey on a grandchild block nested two levels deep (loop in loop)', async () => { + const operations = [ + { + operation_type: 'add' as const, + block_id: 'outer-loop', + params: { + type: 'loop', + inputs: {}, + nestedNodes: { + 'inner-loop': { + type: 'loop', + inputs: {}, + nestedNodes: { + 'video-child': { + type: 'video_generator_v3', + inputs: { provider: 'falai', apiKey: '{{FAL_API_KEY}}' }, + }, + }, + }, + }, + }, + }, + ] + + const result = await preValidateCredentialInputs(operations, ctx) + + const innerInputs = ( + (result.filteredOperations[0]?.params?.nestedNodes as Record)?.['inner-loop'] + ?.nestedNodes as Record }> + )?.['video-child']?.inputs + expect(innerInputs?.apiKey).toBeUndefined() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]).toMatchObject({ blockId: 'video-child', field: 'apiKey' }) + }) + + it('uses same-batch state for nested children (provider set earlier, apiKey set later)', async () => { + const operations = [ + { + operation_type: 'add' as const, + block_id: 'loop-1', + params: { + type: 'loop', + inputs: {}, + nestedNodes: { + 'video-child': { type: 'video_generator_v3', inputs: { provider: 'falai' } }, + }, + }, + }, + { + operation_type: 'edit' as const, + block_id: 'loop-1', + params: { + nestedNodes: { + 'video-child': { type: 'video_generator_v3', inputs: { apiKey: 'test-key' } }, + }, + }, + }, + ] + + const result = await preValidateCredentialInputs(operations, ctx) + + const nested = result.filteredOperations[1]?.params?.nestedNodes as + | Record }> + | undefined + expect(nested?.['video-child']?.inputs?.apiKey).toBeUndefined() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]).toMatchObject({ blockId: 'video-child', field: 'apiKey' }) + }) + + it('strips a key set before a later op makes the block hosted (reverse batch order)', async () => { + // op1 sets apiKey while the block is still non-hosted (runway); op2 later flips it to falai. + // Deciding against final state must still strip op1's key. + const operations = [ + { + operation_type: 'edit' as const, + block_id: 'video-1', + params: { inputs: { apiKey: '{{FAL_API_KEY}}' } }, + }, + { + operation_type: 'edit' as const, + block_id: 'video-1', + params: { inputs: { provider: 'falai' } }, + }, + ] + const workflowState = { + blocks: { + 'video-1': { type: 'video_generator_v3', subBlocks: { provider: { value: 'runway' } } }, + }, + } + + const result = await preValidateCredentialInputs(operations, ctx, workflowState) + + expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]).toMatchObject({ blockId: 'video-1', field: 'apiKey' }) + }) + + it.each([{ type: '' }, { type: 'totally_unknown_type' }])( + 'does not let an invalid type (%o) on an earlier op block stripping on a later edit', + async ({ type }) => { + const operations = [ + { + operation_type: 'edit' as const, + block_id: 'video-1', + params: { type, inputs: { prompt: 'x' } }, + }, + { + operation_type: 'edit' as const, + block_id: 'video-1', + params: { inputs: { apiKey: '{{FAL_API_KEY}}' } }, + }, + ] + const workflowState = { + blocks: { + 'video-1': { type: 'video_generator_v3', subBlocks: { provider: { value: 'falai' } } }, + }, + } + + const result = await preValidateCredentialInputs(operations, ctx, workflowState) + + expect(result.filteredOperations[1]?.params?.inputs?.apiKey).toBeUndefined() + expect(result.errors).toHaveLength(1) + } + ) + + it('uses same-batch state: a type-less apiKey edit after an earlier op makes the block hosted', async () => { + // op1 switches provider to falai (hosted); op2 (type-less) sets apiKey. op2 must see op1's + // provider, not the stale snapshot (runway), and strip the key. + const operations = [ + { + operation_type: 'edit' as const, + block_id: 'video-1', + params: { inputs: { provider: 'falai' } }, + }, + { + operation_type: 'edit' as const, + block_id: 'video-1', + params: { inputs: { apiKey: 'test-api-key-12345' } }, + }, + ] + const workflowState = { + blocks: { + 'video-1': { + type: 'video_generator_v3', + subBlocks: { provider: { value: 'runway' } }, + }, + }, + } + + const result = await preValidateCredentialInputs(operations, ctx, workflowState) + + expect(result.filteredOperations[1]?.params?.inputs?.apiKey).toBeUndefined() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]).toMatchObject({ blockId: 'video-1', field: 'apiKey' }) + }) + + it('strips apiKey when the tool selector throws (falls back to access tools)', async () => { + const operations = [ + { + operation_type: 'add' as const, + block_id: 'sel-1', + params: { + type: 'throw_selector_block', + inputs: { provider: 'falai', apiKey: 'user-key' }, + }, + }, + ] + + const result = await preValidateCredentialInputs(operations, ctx) + + expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined() + expect(result.errors).toHaveLength(1) + }) + + it('strips apiKey when a tool hosting enabled predicate throws (fail toward stripping)', async () => { + const operations = [ + { + operation_type: 'add' as const, + block_id: 'gate-1', + params: { + type: 'throw_gate_block', + inputs: { provider: 'whatever', apiKey: 'user-key' }, + }, + }, + ] + + const result = await preValidateCredentialInputs(operations, ctx) + + expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined() + expect(result.errors).toHaveLength(1) + }) + it('preserves apiKey on self-hosted deployments (isHosted false)', async () => { mockEnvFlags.isHosted = false const operations = [ diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 76e214f6e3..1bda4606fa 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -1332,24 +1332,37 @@ export async function preValidateCredentialInputs( if (!isHosted || !blockConfig?.tools) return // Resolve which tool(s) the current inputs select. With a selector there is exactly one active - // tool; without one, every accessible tool is a candidate. + // tool; without one (or if the selector throws on partial params), every accessible tool is a + // candidate — failing toward considering all hosted params so a key can't slip through. + const accessToolIds = blockConfig.tools.access ?? [] let candidateToolIds: string[] const toolSelector = blockConfig.tools.config?.tool if (toolSelector) { try { candidateToolIds = [toolSelector(toolParams)] } catch { - return + candidateToolIds = accessToolIds } } else { - candidateToolIds = blockConfig.tools.access ?? [] + candidateToolIds = accessToolIds } const managedFieldIds = new Set() for (const toolId of candidateToolIds) { const tool = getTool(toolId) if (!tool?.hosting) continue - if (tool.hosting.enabled && !tool.hosting.enabled(toolParams)) continue + // The enabled predicate is tool-defined; guard it so a throw can't break edit_workflow. On + // a throw the hosting state is unknown, so fail toward treating the key as managed (strip) + // rather than preserving a key that may actually be platform-managed. + if (tool.hosting.enabled) { + let isManaged: boolean + try { + isManaged = tool.hosting.enabled(toolParams) + } catch { + isManaged = true + } + if (!isManaged) continue + } managedFieldIds.add(tool.hosting.apiKeyParam) } @@ -1377,113 +1390,131 @@ export async function preValidateCredentialInputs( } } - operations.forEach((op, opIndex) => { - // Process main block inputs - if (op.params?.inputs && op.params?.type) { - const blockConfig = getBlock(op.params.type) - if (blockConfig) { - // Collect credentials from main block - collectCredentialInputs( - blockConfig, - op.params.inputs as Record, + const asRecord = (value: unknown): Record | undefined => + value && typeof value === 'object' ? (value as Record) : undefined + const snapshotBlock = (blockId: string) => asRecord(asRecord(workflowState?.blocks)?.[blockId]) + + // nestedNodes can nest recursively (a loop/parallel child can itself contain nestedNodes), and + // the apply path processes them recursively — so find a descendant's inputs at any depth. + const findNestedInputs = ( + nodes: unknown, + targetId: string + ): Record | undefined => { + const map = asRecord(nodes) + if (!map) return undefined + for (const [id, node] of Object.entries(map)) { + if (id === targetId) return asRecord(asRecord(node)?.inputs) + const found = findNestedInputs(asRecord(node)?.nestedNodes, targetId) + if (found) return found + } + return undefined + } + + const validType = (type: unknown): string | undefined => + typeof type === 'string' && type.trim() ? type : undefined + + // Visit every block an op touches — its main block and each nestedNodes descendant (recursively, + // since loop/parallel children can themselves contain nestedNodes) — keyed by the block's own id. + const visitOpBlocks = ( + op: EditWorkflowOperation, + opIndex: number, + visit: (b: { + opIndex: number + stateKey: string + reportBlockId: string + rawType: string | undefined + inputs: Record | undefined + nestedBlockId?: string + }) => void + ) => { + visit({ + opIndex, + stateKey: op.block_id, + reportBlockId: op.block_id, + rawType: op.params?.type as string | undefined, + inputs: asRecord(op.params?.inputs), + }) + const walk = (nodes: unknown, parentBlockId: string) => { + const map = asRecord(nodes) + if (!map) return + for (const [childId, childBlock] of Object.entries(map)) { + const child = asRecord(childBlock) + visit({ opIndex, - op.block_id, - op.params.type - ) + stateKey: childId, + reportBlockId: parentBlockId, + rawType: child?.type as string | undefined, + inputs: asRecord(child?.inputs), + nestedBlockId: childId, + }) + walk(child?.nestedNodes, parentBlockId) + } + } + walk(op.params?.nestedNodes, op.block_id) + } - // Check for apiKey inputs on hosted models - let modelValue = (op.params.inputs as Record).model as string | undefined - - // For edit operations, if model is not being changed, check existing block's model - if ( - !modelValue && - op.operation_type === 'edit' && - (op.params.inputs as Record).apiKey && - workflowState - ) { - const existingBlock = (workflowState.blocks as Record)?.[op.block_id] as - | Record - | undefined - const existingSubBlocks = existingBlock?.subBlocks as Record | undefined - const existingModelSubBlock = existingSubBlocks?.model as - | Record - | undefined - modelValue = existingModelSubBlock?.value as string | undefined - } + // Pass 1: fold every op into each block's FINAL effective state (type + values) for the batch, + // seeded from the snapshot. Deciding strips against the final state (not a forward prefix) makes + // order irrelevant — a key set before a later op makes the block hosted is still caught. + const finalType = new Map() + const finalValues = new Map>() + for (const [opIndex, op] of operations.entries()) { + visitOpBlocks(op, opIndex, ({ stateKey, rawType, inputs }) => { + const priorType = finalType.has(stateKey) + ? finalType.get(stateKey) + : (snapshotBlock(stateKey)?.type as string | undefined) + // Only advance the type to one apply will honor: a registry-known type. An unknown type + // (apply skips the change and keeps the existing block) must not poison the fallback, or a + // later type-less apiKey edit would be judged against a block config that never applies. + const candidate = validType(rawType) + const resolved = (candidate && getBlock(candidate) ? candidate : undefined) ?? priorType + if (resolved) finalType.set(stateKey, resolved) + if (isHosted) { + const priorValues = + finalValues.get(stateKey) ?? + buildSubBlockValues( + (snapshotBlock(stateKey)?.subBlocks as Record) ?? {} + ) + finalValues.set(stateKey, { ...priorValues, ...(inputs ?? {}) }) + } + }) + } + // Pass 2: for each op, strip the managed fields it actually sets, judged against the block's + // final state. Both top-level and nested blocks route through the same visit so they can't drift. + for (const [opIndex, op] of operations.entries()) { + visitOpBlocks(op, opIndex, ({ stateKey, reportBlockId, inputs, nestedBlockId }) => { + const blockType = finalType.get(stateKey) + if (!inputs || !blockType) return + const blockConfig = getBlock(blockType) + if (!blockConfig) return + + collectCredentialInputs(blockConfig, inputs, opIndex, reportBlockId, blockType, nestedBlockId) + + // Hosted collectors no-op off hosted Sim, so only resolve the effective state when it matters. + if (isHosted) { + const toolParams = finalValues.get(stateKey) ?? inputs + const modelValue = toolParams.model as string | undefined collectHostedApiKeyInput( - op.params.inputs as Record, + inputs, modelValue, opIndex, - op.block_id, - op.params.type + reportBlockId, + blockType, + nestedBlockId ) - - // The active tool depends on inputs (e.g. provider). On edit ops that don't restate - // provider, merge the existing block's subblock values so the tool selector and its - // `enabled` gate resolve correctly. - let toolParams = op.params.inputs as Record - if (op.operation_type === 'edit' && workflowState) { - const existingBlock = (workflowState.blocks as Record)?.[op.block_id] as - | Record - | undefined - const existingSubBlocks = existingBlock?.subBlocks as - | Record - | undefined - if (existingSubBlocks) { - toolParams = { ...buildSubBlockValues(existingSubBlocks), ...toolParams } - } - } - collectHostedToolApiKeyInput( blockConfig, - op.params.inputs as Record, + inputs, toolParams, opIndex, - op.block_id, - op.params.type + reportBlockId, + blockType, + nestedBlockId ) } - } - - // Process nested nodes (blocks inside loop/parallel containers) - const nestedNodes = op.params?.nestedNodes as - | Record> - | undefined - if (nestedNodes) { - Object.entries(nestedNodes).forEach(([childId, childBlock]) => { - const childType = childBlock.type as string | undefined - const childInputs = childBlock.inputs as Record | undefined - if (!childType || !childInputs) return - - const childBlockConfig = getBlock(childType) - if (!childBlockConfig) return - - // Collect credentials from nested block - collectCredentialInputs( - childBlockConfig, - childInputs, - opIndex, - op.block_id, - childType, - childId - ) - - // Check for apiKey inputs on hosted models in nested block - const modelValue = childInputs.model as string | undefined - collectHostedApiKeyInput(childInputs, modelValue, opIndex, op.block_id, childType, childId) - collectHostedToolApiKeyInput( - childBlockConfig, - childInputs, - childInputs, - opIndex, - op.block_id, - childType, - childId - ) - }) - } - }) + }) + } const hasCredentialsToValidate = credentialInputs.length > 0 const hasHostedApiKeysToFilter = hostedApiKeyInputs.length > 0 @@ -1510,11 +1541,7 @@ export async function preValidateCredentialInputs( // Handle nested block apiKey filtering if (apiKeyInput.nestedBlockId) { - const nestedNodes = op.params?.nestedNodes as - | Record> - | undefined - const nestedBlock = nestedNodes?.[apiKeyInput.nestedBlockId] - const nestedInputs = nestedBlock?.inputs as Record | undefined + const nestedInputs = findNestedInputs(op.params?.nestedNodes, apiKeyInput.nestedBlockId) if (nestedInputs?.[field]) { nestedInputs[field] = undefined logger.debug('Filtered platform-managed apiKey in nested block', { @@ -1573,11 +1600,7 @@ export async function preValidateCredentialInputs( // Handle nested block credential removal if (credInput.nestedBlockId) { - const nestedNodes = op.params?.nestedNodes as - | Record> - | undefined - const nestedBlock = nestedNodes?.[credInput.nestedBlockId] - const nestedInputs = nestedBlock?.inputs as Record | undefined + const nestedInputs = findNestedInputs(op.params?.nestedNodes, credInput.nestedBlockId) if (nestedInputs?.[credInput.fieldName]) { delete nestedInputs[credInput.fieldName] logger.info('Removed invalid credential from nested block', {