@@ -2508,6 +2508,10 @@ async function validateWorkflowSelectorIds(
25082508 for ( const subBlockConfig of blockConfig . subBlocks ) {
25092509 if ( ! SELECTOR_TYPES . has ( subBlockConfig . type ) ) continue
25102510
2511+ // Skip oauth-input - credentials are pre-validated before edit application
2512+ // This allows existing collaborator credentials to remain untouched
2513+ if ( subBlockConfig . type === 'oauth-input' ) continue
2514+
25112515 const subBlockValue = blockData . subBlocks ?. [ subBlockConfig . id ] ?. value
25122516 if ( ! subBlockValue ) continue
25132517
@@ -2573,6 +2577,295 @@ async function validateWorkflowSelectorIds(
25732577 return errors
25742578}
25752579
2580+ /**
2581+ * Pre-validates credential and apiKey inputs in operations before they are applied.
2582+ * - Validates oauth-input (credential) IDs belong to the user
2583+ * - Filters out apiKey inputs for hosted models when isHosted is true
2584+ * - Also validates credentials and apiKeys in nestedNodes (blocks inside loop/parallel)
2585+ * Returns validation errors for any removed inputs.
2586+ */
2587+ async function preValidateCredentialInputs (
2588+ operations : EditWorkflowOperation [ ] ,
2589+ context : { userId : string } ,
2590+ workflowState ?: Record < string , unknown >
2591+ ) : Promise < { filteredOperations : EditWorkflowOperation [ ] ; errors : ValidationError [ ] } > {
2592+ const { isHosted } = await import ( '@/lib/core/config/feature-flags' )
2593+ const { getHostedModels } = await import ( '@/providers/utils' )
2594+
2595+ const logger = createLogger ( 'PreValidateCredentials' )
2596+ const errors : ValidationError [ ] = [ ]
2597+
2598+ // Collect credential and apiKey inputs that need validation/filtering
2599+ const credentialInputs : Array < {
2600+ operationIndex : number
2601+ blockId : string
2602+ blockType : string
2603+ fieldName : string
2604+ value : string
2605+ nestedBlockId ?: string
2606+ } > = [ ]
2607+
2608+ const hostedApiKeyInputs : Array < {
2609+ operationIndex : number
2610+ blockId : string
2611+ blockType : string
2612+ model : string
2613+ nestedBlockId ?: string
2614+ } > = [ ]
2615+
2616+ const hostedModelsLower = isHosted ? new Set ( getHostedModels ( ) . map ( ( m ) => m . toLowerCase ( ) ) ) : null
2617+
2618+ /**
2619+ * Collect credential inputs from a block's inputs based on its block config
2620+ */
2621+ function collectCredentialInputs (
2622+ blockConfig : ReturnType < typeof getBlock > ,
2623+ inputs : Record < string , unknown > ,
2624+ opIndex : number ,
2625+ blockId : string ,
2626+ blockType : string ,
2627+ nestedBlockId ?: string
2628+ ) {
2629+ if ( ! blockConfig ) return
2630+
2631+ for ( const subBlockConfig of blockConfig . subBlocks ) {
2632+ if ( subBlockConfig . type !== 'oauth-input' ) continue
2633+
2634+ const inputValue = inputs [ subBlockConfig . id ]
2635+ if ( ! inputValue || typeof inputValue !== 'string' || inputValue . trim ( ) === '' ) continue
2636+
2637+ credentialInputs . push ( {
2638+ operationIndex : opIndex ,
2639+ blockId,
2640+ blockType,
2641+ fieldName : subBlockConfig . id ,
2642+ value : inputValue ,
2643+ nestedBlockId,
2644+ } )
2645+ }
2646+ }
2647+
2648+ /**
2649+ * Check if apiKey should be filtered for a block with the given model
2650+ */
2651+ function collectHostedApiKeyInput (
2652+ inputs : Record < string , unknown > ,
2653+ modelValue : string | undefined ,
2654+ opIndex : number ,
2655+ blockId : string ,
2656+ blockType : string ,
2657+ nestedBlockId ?: string
2658+ ) {
2659+ if ( ! hostedModelsLower || ! inputs . apiKey ) return
2660+ if ( ! modelValue || typeof modelValue !== 'string' ) return
2661+
2662+ if ( hostedModelsLower . has ( modelValue . toLowerCase ( ) ) ) {
2663+ hostedApiKeyInputs . push ( {
2664+ operationIndex : opIndex ,
2665+ blockId,
2666+ blockType,
2667+ model : modelValue ,
2668+ nestedBlockId,
2669+ } )
2670+ }
2671+ }
2672+
2673+ operations . forEach ( ( op , opIndex ) => {
2674+ // Process main block inputs
2675+ if ( op . params ?. inputs && op . params ?. type ) {
2676+ const blockConfig = getBlock ( op . params . type )
2677+ if ( blockConfig ) {
2678+ // Collect credentials from main block
2679+ collectCredentialInputs (
2680+ blockConfig ,
2681+ op . params . inputs as Record < string , unknown > ,
2682+ opIndex ,
2683+ op . block_id ,
2684+ op . params . type
2685+ )
2686+
2687+ // Check for apiKey inputs on hosted models
2688+ let modelValue = ( op . params . inputs as Record < string , unknown > ) . model as string | undefined
2689+
2690+ // For edit operations, if model is not being changed, check existing block's model
2691+ if (
2692+ ! modelValue &&
2693+ op . operation_type === 'edit' &&
2694+ ( op . params . inputs as Record < string , unknown > ) . apiKey &&
2695+ workflowState
2696+ ) {
2697+ const existingBlock = ( workflowState . blocks as Record < string , unknown > ) ?. [ op . block_id ] as
2698+ | Record < string , unknown >
2699+ | undefined
2700+ const existingSubBlocks = existingBlock ?. subBlocks as Record < string , unknown > | undefined
2701+ const existingModelSubBlock = existingSubBlocks ?. model as
2702+ | Record < string , unknown >
2703+ | undefined
2704+ modelValue = existingModelSubBlock ?. value as string | undefined
2705+ }
2706+
2707+ collectHostedApiKeyInput (
2708+ op . params . inputs as Record < string , unknown > ,
2709+ modelValue ,
2710+ opIndex ,
2711+ op . block_id ,
2712+ op . params . type
2713+ )
2714+ }
2715+ }
2716+
2717+ // Process nested nodes (blocks inside loop/parallel containers)
2718+ const nestedNodes = op . params ?. nestedNodes as
2719+ | Record < string , Record < string , unknown > >
2720+ | undefined
2721+ if ( nestedNodes ) {
2722+ Object . entries ( nestedNodes ) . forEach ( ( [ childId , childBlock ] ) => {
2723+ const childType = childBlock . type as string | undefined
2724+ const childInputs = childBlock . inputs as Record < string , unknown > | undefined
2725+ if ( ! childType || ! childInputs ) return
2726+
2727+ const childBlockConfig = getBlock ( childType )
2728+ if ( ! childBlockConfig ) return
2729+
2730+ // Collect credentials from nested block
2731+ collectCredentialInputs (
2732+ childBlockConfig ,
2733+ childInputs ,
2734+ opIndex ,
2735+ op . block_id ,
2736+ childType ,
2737+ childId
2738+ )
2739+
2740+ // Check for apiKey inputs on hosted models in nested block
2741+ const modelValue = childInputs . model as string | undefined
2742+ collectHostedApiKeyInput ( childInputs , modelValue , opIndex , op . block_id , childType , childId )
2743+ } )
2744+ }
2745+ } )
2746+
2747+ const hasCredentialsToValidate = credentialInputs . length > 0
2748+ const hasHostedApiKeysToFilter = hostedApiKeyInputs . length > 0
2749+
2750+ if ( ! hasCredentialsToValidate && ! hasHostedApiKeysToFilter ) {
2751+ return { filteredOperations : operations , errors }
2752+ }
2753+
2754+ // Deep clone operations so we can modify them
2755+ const filteredOperations = structuredClone ( operations )
2756+
2757+ // Filter out apiKey inputs for hosted models and add validation errors
2758+ if ( hasHostedApiKeysToFilter ) {
2759+ logger . info ( 'Filtering apiKey inputs for hosted models' , { count : hostedApiKeyInputs . length } )
2760+
2761+ for ( const apiKeyInput of hostedApiKeyInputs ) {
2762+ const op = filteredOperations [ apiKeyInput . operationIndex ]
2763+
2764+ // Handle nested block apiKey filtering
2765+ if ( apiKeyInput . nestedBlockId ) {
2766+ const nestedNodes = op . params ?. nestedNodes as
2767+ | Record < string , Record < string , unknown > >
2768+ | undefined
2769+ const nestedBlock = nestedNodes ?. [ apiKeyInput . nestedBlockId ]
2770+ const nestedInputs = nestedBlock ?. inputs as Record < string , unknown > | undefined
2771+ if ( nestedInputs ?. apiKey ) {
2772+ nestedInputs . apiKey = undefined
2773+ logger . debug ( 'Filtered apiKey for hosted model in nested block' , {
2774+ parentBlockId : apiKeyInput . blockId ,
2775+ nestedBlockId : apiKeyInput . nestedBlockId ,
2776+ model : apiKeyInput . model ,
2777+ } )
2778+
2779+ errors . push ( {
2780+ blockId : apiKeyInput . nestedBlockId ,
2781+ blockType : apiKeyInput . blockType ,
2782+ field : 'apiKey' ,
2783+ value : '[redacted]' ,
2784+ error : `Cannot set API key for hosted model "${ apiKeyInput . model } " - API keys are managed by the platform when using hosted models` ,
2785+ } )
2786+ }
2787+ } else if ( op . params ?. inputs ?. apiKey ) {
2788+ // Handle main block apiKey filtering
2789+ op . params . inputs . apiKey = undefined
2790+ logger . debug ( 'Filtered apiKey for hosted model' , {
2791+ blockId : apiKeyInput . blockId ,
2792+ model : apiKeyInput . model ,
2793+ } )
2794+
2795+ errors . push ( {
2796+ blockId : apiKeyInput . blockId ,
2797+ blockType : apiKeyInput . blockType ,
2798+ field : 'apiKey' ,
2799+ value : '[redacted]' ,
2800+ error : `Cannot set API key for hosted model "${ apiKeyInput . model } " - API keys are managed by the platform when using hosted models` ,
2801+ } )
2802+ }
2803+ }
2804+ }
2805+
2806+ // Validate credential inputs
2807+ if ( hasCredentialsToValidate ) {
2808+ logger . info ( 'Pre-validating credential inputs' , {
2809+ credentialCount : credentialInputs . length ,
2810+ userId : context . userId ,
2811+ } )
2812+
2813+ const allCredentialIds = credentialInputs . map ( ( c ) => c . value )
2814+ const validationResult = await validateSelectorIds ( 'oauth-input' , allCredentialIds , context )
2815+ const invalidSet = new Set ( validationResult . invalid )
2816+
2817+ if ( invalidSet . size > 0 ) {
2818+ for ( const credInput of credentialInputs ) {
2819+ if ( ! invalidSet . has ( credInput . value ) ) continue
2820+
2821+ const op = filteredOperations [ credInput . operationIndex ]
2822+
2823+ // Handle nested block credential removal
2824+ if ( credInput . nestedBlockId ) {
2825+ const nestedNodes = op . params ?. nestedNodes as
2826+ | Record < string , Record < string , unknown > >
2827+ | undefined
2828+ const nestedBlock = nestedNodes ?. [ credInput . nestedBlockId ]
2829+ const nestedInputs = nestedBlock ?. inputs as Record < string , unknown > | undefined
2830+ if ( nestedInputs ?. [ credInput . fieldName ] ) {
2831+ delete nestedInputs [ credInput . fieldName ]
2832+ logger . info ( 'Removed invalid credential from nested block' , {
2833+ parentBlockId : credInput . blockId ,
2834+ nestedBlockId : credInput . nestedBlockId ,
2835+ field : credInput . fieldName ,
2836+ invalidValue : credInput . value ,
2837+ } )
2838+ }
2839+ } else if ( op . params ?. inputs ?. [ credInput . fieldName ] ) {
2840+ // Handle main block credential removal
2841+ delete op . params . inputs [ credInput . fieldName ]
2842+ logger . info ( 'Removed invalid credential from operation' , {
2843+ blockId : credInput . blockId ,
2844+ field : credInput . fieldName ,
2845+ invalidValue : credInput . value ,
2846+ } )
2847+ }
2848+
2849+ const warningInfo = validationResult . warning ? `. ${ validationResult . warning } ` : ''
2850+ const errorBlockId = credInput . nestedBlockId ?? credInput . blockId
2851+ errors . push ( {
2852+ blockId : errorBlockId ,
2853+ blockType : credInput . blockType ,
2854+ field : credInput . fieldName ,
2855+ value : credInput . value ,
2856+ error : `Invalid credential ID "${ credInput . value } " - credential does not exist or user doesn't have access${ warningInfo } ` ,
2857+ } )
2858+ }
2859+
2860+ logger . warn ( 'Filtered out invalid credentials' , {
2861+ invalidCount : invalidSet . size ,
2862+ } )
2863+ }
2864+ }
2865+
2866+ return { filteredOperations, errors }
2867+ }
2868+
25762869async function getCurrentWorkflowStateFromDb (
25772870 workflowId : string
25782871) : Promise < { workflowState : any ; subBlockValues : Record < string , Record < string , any > > } > {
@@ -2657,12 +2950,29 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = {
26572950 // Get permission config for the user
26582951 const permissionConfig = context ?. userId ? await getUserPermissionConfig ( context . userId ) : null
26592952
2953+ // Pre-validate credential and apiKey inputs before applying operations
2954+ // This filters out invalid credentials and apiKeys for hosted models
2955+ let operationsToApply = operations
2956+ const credentialErrors : ValidationError [ ] = [ ]
2957+ if ( context ?. userId ) {
2958+ const { filteredOperations, errors : credErrors } = await preValidateCredentialInputs (
2959+ operations ,
2960+ { userId : context . userId } ,
2961+ workflowState
2962+ )
2963+ operationsToApply = filteredOperations
2964+ credentialErrors . push ( ...credErrors )
2965+ }
2966+
26602967 // Apply operations directly to the workflow state
26612968 const {
26622969 state : modifiedWorkflowState ,
26632970 validationErrors,
26642971 skippedItems,
2665- } = applyOperationsToWorkflowState ( workflowState , operations , permissionConfig )
2972+ } = applyOperationsToWorkflowState ( workflowState , operationsToApply , permissionConfig )
2973+
2974+ // Add credential validation errors
2975+ validationErrors . push ( ...credentialErrors )
26662976
26672977 // Get workspaceId for selector validation
26682978 let workspaceId : string | undefined
0 commit comments