Skip to content

Commit 5db5963

Browse files
fix(copilot): strip platform-managed apiKey on hosted-tool blocks in edit_workflow (#5217)
preValidateCredentialInputs only stripped apiKey for hosted LLM models (getHostedModels). Agent-authored apiKey on hosted-tool blocks (Fal video/image, etc.) slipped through, disabling hosted-key injection and misleading the agent into telling users to bring a key. Generalize the strip to key off tool.hosting — the same canonical signal injectHostedKeyIfNeeded uses at execution. Resolve the block's active tool via its tools.config.tool selector, honor the per-provider enabled gate, and strip the exact hosting.apiKeyParam field (no hardcoded 'apiKey' assumption). Surfaces the existing non-fatal note to the agent. Self-hosted and non-hosted providers untouched.
1 parent d20deed commit 5db5963

2 files changed

Lines changed: 392 additions & 23 deletions

File tree

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

Lines changed: 261 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,24 @@
22
* @vitest-environment node
33
*/
44
import { envFlagsMock } from '@sim/testing'
5-
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66
import { normalizeConditionRouterIds } from './builders'
77

8-
const { mockValidateSelectorIds, mockGetModelOptions, mockGetCustomToolById, mockGetSkillById } =
9-
vi.hoisted(() => ({
10-
mockValidateSelectorIds: vi.fn(),
11-
mockGetModelOptions: vi.fn(() => []),
12-
mockGetCustomToolById: vi.fn(),
13-
mockGetSkillById: vi.fn(),
14-
}))
8+
const {
9+
mockValidateSelectorIds,
10+
mockGetModelOptions,
11+
mockEnvFlags,
12+
mockGetTool,
13+
mockGetCustomToolById,
14+
mockGetSkillById,
15+
} = vi.hoisted(() => ({
16+
mockValidateSelectorIds: vi.fn(),
17+
mockGetModelOptions: vi.fn(() => []),
18+
mockEnvFlags: { isHosted: false },
19+
mockGetTool: vi.fn(),
20+
mockGetCustomToolById: vi.fn(),
21+
mockGetSkillById: vi.fn(),
22+
}))
1523

1624
const conditionBlockConfig = {
1725
type: 'condition',
@@ -73,6 +81,53 @@ const canonicalCredBlockConfig = {
7381
],
7482
}
7583

84+
// Mirrors video_generator_v3: routes provider -> tool; only video_falai has hosting.
85+
const videoBlockConfig = {
86+
type: 'video_generator_v3',
87+
name: 'Video Generator',
88+
outputs: {},
89+
subBlocks: [{ id: 'provider', type: 'dropdown' }],
90+
tools: {
91+
access: ['video_runway', 'video_falai'],
92+
config: {
93+
tool: (params: Record<string, unknown>) =>
94+
params.provider === 'falai' ? 'video_falai' : 'video_runway',
95+
},
96+
},
97+
}
98+
99+
// A hosted block whose tool's managed key param is NOT named 'apiKey'.
100+
const customKeyBlockConfig = {
101+
type: 'custom_key_block',
102+
name: 'Custom Key Block',
103+
outputs: {},
104+
subBlocks: [{ id: 'serviceKey', type: 'short-input' }],
105+
tools: { access: ['custom_key_tool'], config: { tool: () => 'custom_key_tool' } },
106+
}
107+
108+
// Single tool with a per-provider `enabled` gate (mirrors image_generate, falai-only hosting).
109+
const imageBlockConfig = {
110+
type: 'image_generator_v2',
111+
name: 'Image Generator',
112+
outputs: {},
113+
subBlocks: [{ id: 'provider', type: 'dropdown' }],
114+
tools: { access: ['image_generate'], config: { tool: () => 'image_generate' } },
115+
}
116+
117+
// Tool registry stand-in for the hosted-tool tests.
118+
const toolsByIdMock: Record<string, unknown> = {
119+
video_falai: { id: 'video_falai', hosting: { apiKeyParam: 'apiKey' } },
120+
video_runway: { id: 'video_runway' },
121+
custom_key_tool: { id: 'custom_key_tool', hosting: { apiKeyParam: 'serviceKey' } },
122+
image_generate: {
123+
id: 'image_generate',
124+
hosting: {
125+
apiKeyParam: 'apiKey',
126+
enabled: (p: Record<string, unknown>) => p.provider === 'falai',
127+
},
128+
},
129+
}
130+
76131
vi.mock('@/blocks/registry', () => ({
77132
getBlock: (type: string) =>
78133
type === 'condition'
@@ -89,13 +144,23 @@ vi.mock('@/blocks/registry', () => ({
89144
? knowledgeBlockConfig
90145
: type === 'canonicalcred'
91146
? canonicalCredBlockConfig
92-
: undefined,
147+
: type === 'video_generator_v3'
148+
? videoBlockConfig
149+
: type === 'custom_key_block'
150+
? customKeyBlockConfig
151+
: type === 'image_generator_v2'
152+
? imageBlockConfig
153+
: undefined,
93154
}))
94155

95156
vi.mock('@/blocks/utils', () => ({
96157
getModelOptions: mockGetModelOptions,
97158
}))
98159

160+
vi.mock('@/tools/utils', () => ({
161+
getTool: mockGetTool,
162+
}))
163+
99164
vi.mock('@/lib/copilot/validation/selector-validator', () => ({
100165
validateSelectorIds: mockValidateSelectorIds,
101166
}))
@@ -108,7 +173,12 @@ vi.mock('@/lib/workflows/skills/operations', () => ({
108173
getSkillById: mockGetSkillById,
109174
}))
110175

111-
vi.mock('@/lib/core/config/env-flags', () => envFlagsMock)
176+
vi.mock('@/lib/core/config/env-flags', () => ({
177+
...envFlagsMock,
178+
get isHosted() {
179+
return mockEnvFlags.isHosted
180+
},
181+
}))
112182

113183
vi.mock('@/providers/utils', () => ({
114184
getHostedModels: () => [],
@@ -321,6 +391,187 @@ describe('preValidateCredentialInputs', () => {
321391
})
322392
})
323393

394+
describe('preValidateCredentialInputs (hosted-tool blocks)', () => {
395+
beforeEach(() => {
396+
vi.clearAllMocks()
397+
mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [] })
398+
mockGetTool.mockImplementation((id: string) => toolsByIdMock[id])
399+
mockEnvFlags.isHosted = true
400+
})
401+
402+
afterEach(() => {
403+
mockEnvFlags.isHosted = false
404+
})
405+
406+
const ctx = { userId: 'user-1', workspaceId: 'workspace-1' }
407+
408+
it('strips apiKey when the block resolves to a hosted tool on hosted Sim', async () => {
409+
const operations = [
410+
{
411+
operation_type: 'add' as const,
412+
block_id: 'video-1',
413+
params: {
414+
type: 'video_generator_v3',
415+
inputs: { provider: 'falai', model: 'veo-3.1', apiKey: '{{FAL_API_KEY}}' },
416+
},
417+
},
418+
]
419+
420+
const result = await preValidateCredentialInputs(operations, ctx)
421+
422+
expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
423+
expect(result.errors).toHaveLength(1)
424+
expect(result.errors[0]).toMatchObject({ blockId: 'video-1', field: 'apiKey' })
425+
expect(result.errors[0]?.error).toContain('managed by Sim')
426+
})
427+
428+
it('preserves apiKey when the resolved tool has no hosting (non-falai provider)', async () => {
429+
const operations = [
430+
{
431+
operation_type: 'add' as const,
432+
block_id: 'video-1',
433+
params: {
434+
type: 'video_generator_v3',
435+
inputs: { provider: 'runway', apiKey: 'user-runway-key' },
436+
},
437+
},
438+
]
439+
440+
const result = await preValidateCredentialInputs(operations, ctx)
441+
442+
expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBe('user-runway-key')
443+
expect(result.errors).toHaveLength(0)
444+
})
445+
446+
it('resolves provider from existing block state for edit ops that only set apiKey', async () => {
447+
const operations = [
448+
{
449+
operation_type: 'edit' as const,
450+
block_id: 'video-1',
451+
params: {
452+
type: 'video_generator_v3',
453+
inputs: { apiKey: '{{FAL_API_KEY}}' },
454+
},
455+
},
456+
]
457+
const workflowState = {
458+
blocks: {
459+
'video-1': {
460+
type: 'video_generator_v3',
461+
subBlocks: { provider: { value: 'falai' } },
462+
},
463+
},
464+
}
465+
466+
const result = await preValidateCredentialInputs(operations, ctx, workflowState)
467+
468+
expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
469+
expect(result.errors).toHaveLength(1)
470+
})
471+
472+
it('strips apiKey on a hosted-tool block nested inside a loop', async () => {
473+
const operations = [
474+
{
475+
operation_type: 'add' as const,
476+
block_id: 'loop-1',
477+
params: {
478+
type: 'loop',
479+
inputs: {},
480+
nestedNodes: {
481+
'video-child': {
482+
type: 'video_generator_v3',
483+
inputs: { provider: 'falai', model: 'veo-3.1', apiKey: '{{FAL_API_KEY}}' },
484+
},
485+
},
486+
},
487+
},
488+
]
489+
490+
const result = await preValidateCredentialInputs(operations, ctx)
491+
492+
const nested = result.filteredOperations[0]?.params?.nestedNodes as
493+
| Record<string, { inputs?: Record<string, unknown> }>
494+
| undefined
495+
expect(nested?.['video-child']?.inputs?.apiKey).toBeUndefined()
496+
expect(result.errors).toHaveLength(1)
497+
expect(result.errors[0]).toMatchObject({ blockId: 'video-child', field: 'apiKey' })
498+
})
499+
500+
it("strips a hosted tool's key field even when it is not named apiKey", async () => {
501+
const operations = [
502+
{
503+
operation_type: 'add' as const,
504+
block_id: 'custom-1',
505+
params: {
506+
type: 'custom_key_block',
507+
inputs: { serviceKey: '{{SOME_SERVICE_KEY}}' },
508+
},
509+
},
510+
]
511+
512+
const result = await preValidateCredentialInputs(operations, ctx)
513+
514+
expect(result.filteredOperations[0]?.params?.inputs?.serviceKey).toBeUndefined()
515+
expect(result.errors).toHaveLength(1)
516+
expect(result.errors[0]).toMatchObject({ blockId: 'custom-1', field: 'serviceKey' })
517+
})
518+
519+
it('preserves apiKey on self-hosted deployments (isHosted false)', async () => {
520+
mockEnvFlags.isHosted = false
521+
const operations = [
522+
{
523+
operation_type: 'add' as const,
524+
block_id: 'video-1',
525+
params: {
526+
type: 'video_generator_v3',
527+
inputs: { provider: 'falai', model: 'veo-3.1', apiKey: '{{FAL_API_KEY}}' },
528+
},
529+
},
530+
]
531+
532+
const result = await preValidateCredentialInputs(operations, ctx)
533+
534+
expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBe('{{FAL_API_KEY}}')
535+
expect(result.errors).toHaveLength(0)
536+
})
537+
538+
it('strips apiKey when the tool hosting enabled gate passes (image, falai)', async () => {
539+
const operations = [
540+
{
541+
operation_type: 'add' as const,
542+
block_id: 'image-1',
543+
params: {
544+
type: 'image_generator_v2',
545+
inputs: { provider: 'falai', apiKey: '{{FAL_API_KEY}}' },
546+
},
547+
},
548+
]
549+
550+
const result = await preValidateCredentialInputs(operations, ctx)
551+
552+
expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
553+
expect(result.errors).toHaveLength(1)
554+
})
555+
556+
it('preserves apiKey when the tool hosting enabled gate fails (image, non-falai)', async () => {
557+
const operations = [
558+
{
559+
operation_type: 'add' as const,
560+
block_id: 'image-1',
561+
params: {
562+
type: 'image_generator_v2',
563+
inputs: { provider: 'openai', apiKey: 'user-openai-key' },
564+
},
565+
},
566+
]
567+
568+
const result = await preValidateCredentialInputs(operations, ctx)
569+
570+
expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBe('user-openai-key')
571+
expect(result.errors).toHaveLength(0)
572+
})
573+
})
574+
324575
const CTX = { userId: 'user-1', workspaceId: 'workspace-1' }
325576

326577
describe('validateWorkflowSelectorIds (credential inclusion)', () => {

0 commit comments

Comments
 (0)