Skip to content

Commit 2b026de

Browse files
Sg312icecrasher321
andauthored
fix(copilot): hosted api key validation + credential validation (#3000)
* Fix * Fix greptile * Fix validation * Fix comments * Lint * Fix * remove passed in workspace id ref * Fix comments --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
1 parent dca0758 commit 2b026de

File tree

2 files changed

+325
-2
lines changed

2 files changed

+325
-2
lines changed

apps/sim/lib/copilot/tools/client/knowledge/knowledge-base.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type KnowledgeBaseArgs,
1111
} from '@/lib/copilot/tools/shared/schemas'
1212
import { useCopilotStore } from '@/stores/panel/copilot/store'
13+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1314

1415
/**
1516
* Client tool for knowledge base operations
@@ -102,7 +103,19 @@ export class KnowledgeBaseClientTool extends BaseClientTool {
102103
const logger = createLogger('KnowledgeBaseClientTool')
103104
try {
104105
this.setState(ClientToolCallState.executing)
105-
const payload: KnowledgeBaseArgs = { ...(args || { operation: 'list' }) }
106+
107+
// Get the workspace ID from the workflow registry hydration state
108+
const { hydration } = useWorkflowRegistry.getState()
109+
const workspaceId = hydration.workspaceId
110+
111+
// Build payload with workspace ID included in args
112+
const payload: KnowledgeBaseArgs = {
113+
...(args || { operation: 'list' }),
114+
args: {
115+
...(args?.args || {}),
116+
workspaceId: workspaceId || undefined,
117+
},
118+
}
106119

107120
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
108121
method: 'POST',

apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts

Lines changed: 311 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
25762869
async 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

Comments
 (0)