@@ -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,157 @@ 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+ * Returns validation errors for any removed inputs.
2585+ */
2586+ async function preValidateCredentialInputs (
2587+ operations : EditWorkflowOperation [ ] ,
2588+ context : { userId : string }
2589+ ) : Promise < { filteredOperations : EditWorkflowOperation [ ] ; errors : ValidationError [ ] } > {
2590+ const { isHosted } = await import ( '@/lib/core/config/feature-flags' )
2591+ const { getHostedModels } = await import ( '@/providers/utils' )
2592+
2593+ const logger = createLogger ( 'PreValidateCredentials' )
2594+ const errors : ValidationError [ ] = [ ]
2595+
2596+ // Collect credential and apiKey inputs that need validation/filtering
2597+ const credentialInputs : Array < {
2598+ operationIndex : number
2599+ blockId : string
2600+ blockType : string
2601+ fieldName : string
2602+ value : string
2603+ } > = [ ]
2604+
2605+ const hostedApiKeyInputs : Array < {
2606+ operationIndex : number
2607+ blockId : string
2608+ blockType : string
2609+ model : string
2610+ } > = [ ]
2611+
2612+ const hostedModels = isHosted ? getHostedModels ( ) : [ ]
2613+ const hostedModelsLower = new Set ( hostedModels . map ( ( m ) => m . toLowerCase ( ) ) )
2614+
2615+ operations . forEach ( ( op , opIndex ) => {
2616+ if ( ! op . params ?. inputs || ! op . params ?. type ) return
2617+
2618+ const blockConfig = getBlock ( op . params . type )
2619+ if ( ! blockConfig ) return
2620+
2621+ // Find oauth-input subblocks
2622+ for ( const subBlockConfig of blockConfig . subBlocks ) {
2623+ if ( subBlockConfig . type !== 'oauth-input' ) continue
2624+
2625+ const inputValue = op . params . inputs [ subBlockConfig . id ]
2626+ if ( ! inputValue || typeof inputValue !== 'string' || inputValue . trim ( ) === '' ) continue
2627+
2628+ credentialInputs . push ( {
2629+ operationIndex : opIndex ,
2630+ blockId : op . block_id ,
2631+ blockType : op . params . type ,
2632+ fieldName : subBlockConfig . id ,
2633+ value : inputValue ,
2634+ } )
2635+ }
2636+
2637+ // Check for apiKey inputs on hosted models
2638+ if ( isHosted && op . params . inputs . apiKey ) {
2639+ const modelValue = op . params . inputs . model
2640+ if ( modelValue && typeof modelValue === 'string' ) {
2641+ if ( hostedModelsLower . has ( modelValue . toLowerCase ( ) ) ) {
2642+ hostedApiKeyInputs . push ( {
2643+ operationIndex : opIndex ,
2644+ blockId : op . block_id ,
2645+ blockType : op . params . type ,
2646+ model : modelValue ,
2647+ } )
2648+ }
2649+ }
2650+ }
2651+ } )
2652+
2653+ const hasCredentialsToValidate = credentialInputs . length > 0
2654+ const hasHostedApiKeysToFilter = hostedApiKeyInputs . length > 0
2655+
2656+ if ( ! hasCredentialsToValidate && ! hasHostedApiKeysToFilter ) {
2657+ return { filteredOperations : operations , errors }
2658+ }
2659+
2660+ // Deep clone operations so we can modify them
2661+ const filteredOperations = JSON . parse ( JSON . stringify ( operations ) ) as EditWorkflowOperation [ ]
2662+
2663+ // Filter out apiKey inputs for hosted models
2664+ if ( hasHostedApiKeysToFilter ) {
2665+ logger . info ( 'Filtering apiKey inputs for hosted models' , { count : hostedApiKeyInputs . length } )
2666+
2667+ for ( const apiKeyInput of hostedApiKeyInputs ) {
2668+ const op = filteredOperations [ apiKeyInput . operationIndex ]
2669+ if ( op . params ?. inputs ?. apiKey ) {
2670+ delete op . params . inputs . apiKey
2671+ logger . info ( 'Removed apiKey for hosted model' , {
2672+ blockId : apiKeyInput . blockId ,
2673+ model : apiKeyInput . model ,
2674+ } )
2675+ }
2676+
2677+ errors . push ( {
2678+ blockId : apiKeyInput . blockId ,
2679+ blockType : apiKeyInput . blockType ,
2680+ field : 'apiKey' ,
2681+ value : '[redacted]' ,
2682+ error : `API key not allowed for hosted model "${ apiKeyInput . model } " - platform provides the key` ,
2683+ } )
2684+ }
2685+ }
2686+
2687+ // Validate credential inputs
2688+ if ( hasCredentialsToValidate ) {
2689+ logger . info ( 'Pre-validating credential inputs' , {
2690+ credentialCount : credentialInputs . length ,
2691+ userId : context . userId ,
2692+ } )
2693+
2694+ const allCredentialIds = credentialInputs . map ( ( c ) => c . value )
2695+ const validationResult = await validateSelectorIds ( 'oauth-input' , allCredentialIds , context )
2696+ const invalidSet = new Set ( validationResult . invalid )
2697+
2698+ if ( invalidSet . size > 0 ) {
2699+ for ( const credInput of credentialInputs ) {
2700+ if ( ! invalidSet . has ( credInput . value ) ) continue
2701+
2702+ const op = filteredOperations [ credInput . operationIndex ]
2703+ if ( op . params ?. inputs ?. [ credInput . fieldName ] ) {
2704+ delete op . params . inputs [ credInput . fieldName ]
2705+ logger . info ( 'Removed invalid credential from operation' , {
2706+ blockId : credInput . blockId ,
2707+ field : credInput . fieldName ,
2708+ invalidValue : credInput . value ,
2709+ } )
2710+ }
2711+
2712+ const warningInfo = validationResult . warning ? `. ${ validationResult . warning } ` : ''
2713+ errors . push ( {
2714+ blockId : credInput . blockId ,
2715+ blockType : credInput . blockType ,
2716+ field : credInput . fieldName ,
2717+ value : credInput . value ,
2718+ error : `Invalid credential ID "${ credInput . value } " - credential does not exist or user doesn't have access${ warningInfo } ` ,
2719+ } )
2720+ }
2721+
2722+ logger . warn ( 'Filtered out invalid credentials' , {
2723+ invalidCount : invalidSet . size ,
2724+ } )
2725+ }
2726+ }
2727+
2728+ return { filteredOperations, errors }
2729+ }
2730+
25762731async function getCurrentWorkflowStateFromDb (
25772732 workflowId : string
25782733) : Promise < { workflowState : any ; subBlockValues : Record < string , Record < string , any > > } > {
@@ -2657,12 +2812,28 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = {
26572812 // Get permission config for the user
26582813 const permissionConfig = context ?. userId ? await getUserPermissionConfig ( context . userId ) : null
26592814
2815+ // Pre-validate credential and apiKey inputs before applying operations
2816+ // This filters out invalid credentials and apiKeys for hosted models
2817+ let operationsToApply = operations
2818+ const credentialErrors : ValidationError [ ] = [ ]
2819+ if ( context ?. userId ) {
2820+ const { filteredOperations, errors : credErrors } = await preValidateCredentialInputs (
2821+ operations ,
2822+ { userId : context . userId }
2823+ )
2824+ operationsToApply = filteredOperations
2825+ credentialErrors . push ( ...credErrors )
2826+ }
2827+
26602828 // Apply operations directly to the workflow state
26612829 const {
26622830 state : modifiedWorkflowState ,
26632831 validationErrors,
26642832 skippedItems,
2665- } = applyOperationsToWorkflowState ( workflowState , operations , permissionConfig )
2833+ } = applyOperationsToWorkflowState ( workflowState , operationsToApply , permissionConfig )
2834+
2835+ // Add credential validation errors
2836+ validationErrors . push ( ...credentialErrors )
26662837
26672838 // Get workspaceId for selector validation
26682839 let workspaceId : string | undefined
0 commit comments