Skip to content

Commit a33c173

Browse files
fix(copilot): strip hosted apiKey on type-less edit ops + guard hosting.enabled (#5220)
* fix(copilot): strip hosted apiKey on type-less edit ops + guard hosting.enabled The hosted-apiKey strip in preValidateCredentialInputs was gated on op.params.type, but edit ops omit type (they carry only changed inputs). An apiKey-only edit on a hosted-tool block therefore skipped both the model and tool strip paths, so a copilot-authored key persisted and disabled hosted-key injection. Resolve the block type from workflowState when the op omits it, so type-less edits run through the strip. Also guard tool.hosting.enabled() in try/catch like the tool selector. Add a regression test mirroring the observed failure (edit op with only apiKey, type+provider resolved from workflow state). * improvement(copilot): consolidate hosted-apiKey workflowState reads, gate off-hosted Quality cleanup (no behavior change): the main loop read workflowState.blocks[id] three times (block type, model fallback, toolParams merge). Collapse to one existingBlock lookup + one buildSubBlockValues; derive modelValue from the merged toolParams (drops the model-fallback ladder); gate the reconstruction on isHosted so buildSubBlockValues + spreads no longer run off-hosted or for blocks the collectors skip. Add an asRecord helper to cut repeated casts. * fix(copilot): resolve hosted-key strip against same-batch state; strip on enabled throw Addresses review on #5220: - Batch-aware block state: a later type-less edit now sees type/provider changed by an earlier op in the same edit_workflow request (was reading the stale initial snapshot), so a key can't survive on a block an earlier op just made hosted. - hosting.enabled() throwing now fails toward treating the key as managed (strip) instead of preserving it, since the hosting state is unknown on throw. * fix(copilot): unify hosted-key strip resolution across top-level and nested blocks Greptile flagged that the batch/snapshot state reconstruction was applied only to top-level blocks; nested loop/parallel children still used raw childInputs, so a same-batch provider/model change on a nested child followed by a type-less apiKey edit could leave the key. Route both paths through one collectForBlock helper keyed by the block's own id (incl. nested children), so they share batch accumulation + snapshot enrichment and can't drift. Test added for the nested same-batch case. * fix(copilot): recurse nestedNodes so grandchild hosted keys are stripped The collection loop and the strip/credential-removal loops only handled the first nestedNodes level, but the apply path processes nestedNodes recursively (loop/parallel children can themselves contain nestedNodes). A hosted key on a grandchild (e.g. loop-in-loop) survived. Collection now walks the nestedNodes tree recursively; the strip/removal loops locate a descendant's inputs at any depth via findNestedInputs. Test added for a two-level-deep grandchild. * fix(copilot): decide hosted-key strip against final batch state (two-pass) Forward-only accumulation missed reverse order (apiKey set in an early op, block made hosted by a later op) and let an earlier bogus/empty type poison snapshot fallback. Replace it with a two-pass approach: pass 1 folds every op (and nested descendant) into each block's FINAL effective type+values for the batch; pass 2 strips the managed fields each op sets, judged against that final state. Order-independent, any nesting depth, and empty/invalid types no longer block fallback (validType guard). Tests added for reverse order and bogus-type cases. * fix(copilot): tool selector throw falls back to access tools (fail toward strip) Symmetric with the enabled-throw fix: when tools.config.tool throws on partial params, scan all access tools instead of returning, so a hosted key can't slip through. Test added. * fix(copilot): only fold registry-known types into final batch state Pass 1 recorded any non-empty type into finalType, but apply skips type changes to unknown types (keeps the existing block). An unknown type on an earlier op could poison a later type-less apiKey edit. Only advance finalType to a getBlock-resolvable type so the fallback matches what apply persists. Test covers empty and unknown types.
1 parent 2fa3dd6 commit a33c173

2 files changed

Lines changed: 387 additions & 109 deletions

File tree

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

Lines changed: 256 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,31 @@ const imageBlockConfig = {
114114
tools: { access: ['image_generate'], config: { tool: () => 'image_generate' } },
115115
}
116116

117+
// Tool whose hosting.enabled predicate throws — used to assert fail-toward-strip behavior.
118+
const throwGateBlockConfig = {
119+
type: 'throw_gate_block',
120+
name: 'Throw Gate Block',
121+
outputs: {},
122+
subBlocks: [{ id: 'provider', type: 'dropdown' }],
123+
tools: { access: ['throw_gate_tool'], config: { tool: () => 'throw_gate_tool' } },
124+
}
125+
126+
// Block whose tool selector throws — should fall back to scanning access tools (video_falai).
127+
const throwSelectorBlockConfig = {
128+
type: 'throw_selector_block',
129+
name: 'Throw Selector Block',
130+
outputs: {},
131+
subBlocks: [{ id: 'provider', type: 'dropdown' }],
132+
tools: {
133+
access: ['video_falai'],
134+
config: {
135+
tool: () => {
136+
throw new Error('selector boom')
137+
},
138+
},
139+
},
140+
}
141+
117142
// Tool registry stand-in for the hosted-tool tests.
118143
const toolsByIdMock: Record<string, unknown> = {
119144
video_falai: { id: 'video_falai', hosting: { apiKeyParam: 'apiKey' } },
@@ -126,6 +151,15 @@ const toolsByIdMock: Record<string, unknown> = {
126151
enabled: (p: Record<string, unknown>) => p.provider === 'falai',
127152
},
128153
},
154+
throw_gate_tool: {
155+
id: 'throw_gate_tool',
156+
hosting: {
157+
apiKeyParam: 'apiKey',
158+
enabled: () => {
159+
throw new Error('boom')
160+
},
161+
},
162+
},
129163
}
130164

131165
vi.mock('@/blocks/registry', () => ({
@@ -150,7 +184,11 @@ vi.mock('@/blocks/registry', () => ({
150184
? customKeyBlockConfig
151185
: type === 'image_generator_v2'
152186
? imageBlockConfig
153-
: undefined,
187+
: type === 'throw_gate_block'
188+
? throwGateBlockConfig
189+
: type === 'throw_selector_block'
190+
? throwSelectorBlockConfig
191+
: undefined,
154192
}))
155193

156194
vi.mock('@/blocks/utils', () => ({
@@ -469,6 +507,31 @@ describe('preValidateCredentialInputs (hosted-tool blocks)', () => {
469507
expect(result.errors).toHaveLength(1)
470508
})
471509

510+
it('strips apiKey on a type-less edit op, resolving block type + provider from workflow state', async () => {
511+
// Mirrors the real failure: agent edits only { apiKey } with no `type` restated.
512+
const operations = [
513+
{
514+
operation_type: 'edit' as const,
515+
block_id: 'video-1',
516+
params: { inputs: { apiKey: 'test-api-key-12345' } },
517+
},
518+
]
519+
const workflowState = {
520+
blocks: {
521+
'video-1': {
522+
type: 'video_generator_v3',
523+
subBlocks: { provider: { value: 'falai' } },
524+
},
525+
},
526+
}
527+
528+
const result = await preValidateCredentialInputs(operations, ctx, workflowState)
529+
530+
expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
531+
expect(result.errors).toHaveLength(1)
532+
expect(result.errors[0]).toMatchObject({ blockId: 'video-1', field: 'apiKey' })
533+
})
534+
472535
it('strips apiKey on a hosted-tool block nested inside a loop', async () => {
473536
const operations = [
474537
{
@@ -516,6 +579,198 @@ describe('preValidateCredentialInputs (hosted-tool blocks)', () => {
516579
expect(result.errors[0]).toMatchObject({ blockId: 'custom-1', field: 'serviceKey' })
517580
})
518581

582+
it('strips apiKey on a grandchild block nested two levels deep (loop in loop)', async () => {
583+
const operations = [
584+
{
585+
operation_type: 'add' as const,
586+
block_id: 'outer-loop',
587+
params: {
588+
type: 'loop',
589+
inputs: {},
590+
nestedNodes: {
591+
'inner-loop': {
592+
type: 'loop',
593+
inputs: {},
594+
nestedNodes: {
595+
'video-child': {
596+
type: 'video_generator_v3',
597+
inputs: { provider: 'falai', apiKey: '{{FAL_API_KEY}}' },
598+
},
599+
},
600+
},
601+
},
602+
},
603+
},
604+
]
605+
606+
const result = await preValidateCredentialInputs(operations, ctx)
607+
608+
const innerInputs = (
609+
(result.filteredOperations[0]?.params?.nestedNodes as Record<string, any>)?.['inner-loop']
610+
?.nestedNodes as Record<string, { inputs?: Record<string, unknown> }>
611+
)?.['video-child']?.inputs
612+
expect(innerInputs?.apiKey).toBeUndefined()
613+
expect(result.errors).toHaveLength(1)
614+
expect(result.errors[0]).toMatchObject({ blockId: 'video-child', field: 'apiKey' })
615+
})
616+
617+
it('uses same-batch state for nested children (provider set earlier, apiKey set later)', async () => {
618+
const operations = [
619+
{
620+
operation_type: 'add' as const,
621+
block_id: 'loop-1',
622+
params: {
623+
type: 'loop',
624+
inputs: {},
625+
nestedNodes: {
626+
'video-child': { type: 'video_generator_v3', inputs: { provider: 'falai' } },
627+
},
628+
},
629+
},
630+
{
631+
operation_type: 'edit' as const,
632+
block_id: 'loop-1',
633+
params: {
634+
nestedNodes: {
635+
'video-child': { type: 'video_generator_v3', inputs: { apiKey: 'test-key' } },
636+
},
637+
},
638+
},
639+
]
640+
641+
const result = await preValidateCredentialInputs(operations, ctx)
642+
643+
const nested = result.filteredOperations[1]?.params?.nestedNodes as
644+
| Record<string, { inputs?: Record<string, unknown> }>
645+
| undefined
646+
expect(nested?.['video-child']?.inputs?.apiKey).toBeUndefined()
647+
expect(result.errors).toHaveLength(1)
648+
expect(result.errors[0]).toMatchObject({ blockId: 'video-child', field: 'apiKey' })
649+
})
650+
651+
it('strips a key set before a later op makes the block hosted (reverse batch order)', async () => {
652+
// op1 sets apiKey while the block is still non-hosted (runway); op2 later flips it to falai.
653+
// Deciding against final state must still strip op1's key.
654+
const operations = [
655+
{
656+
operation_type: 'edit' as const,
657+
block_id: 'video-1',
658+
params: { inputs: { apiKey: '{{FAL_API_KEY}}' } },
659+
},
660+
{
661+
operation_type: 'edit' as const,
662+
block_id: 'video-1',
663+
params: { inputs: { provider: 'falai' } },
664+
},
665+
]
666+
const workflowState = {
667+
blocks: {
668+
'video-1': { type: 'video_generator_v3', subBlocks: { provider: { value: 'runway' } } },
669+
},
670+
}
671+
672+
const result = await preValidateCredentialInputs(operations, ctx, workflowState)
673+
674+
expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
675+
expect(result.errors).toHaveLength(1)
676+
expect(result.errors[0]).toMatchObject({ blockId: 'video-1', field: 'apiKey' })
677+
})
678+
679+
it.each([{ type: '' }, { type: 'totally_unknown_type' }])(
680+
'does not let an invalid type (%o) on an earlier op block stripping on a later edit',
681+
async ({ type }) => {
682+
const operations = [
683+
{
684+
operation_type: 'edit' as const,
685+
block_id: 'video-1',
686+
params: { type, inputs: { prompt: 'x' } },
687+
},
688+
{
689+
operation_type: 'edit' as const,
690+
block_id: 'video-1',
691+
params: { inputs: { apiKey: '{{FAL_API_KEY}}' } },
692+
},
693+
]
694+
const workflowState = {
695+
blocks: {
696+
'video-1': { type: 'video_generator_v3', subBlocks: { provider: { value: 'falai' } } },
697+
},
698+
}
699+
700+
const result = await preValidateCredentialInputs(operations, ctx, workflowState)
701+
702+
expect(result.filteredOperations[1]?.params?.inputs?.apiKey).toBeUndefined()
703+
expect(result.errors).toHaveLength(1)
704+
}
705+
)
706+
707+
it('uses same-batch state: a type-less apiKey edit after an earlier op makes the block hosted', async () => {
708+
// op1 switches provider to falai (hosted); op2 (type-less) sets apiKey. op2 must see op1's
709+
// provider, not the stale snapshot (runway), and strip the key.
710+
const operations = [
711+
{
712+
operation_type: 'edit' as const,
713+
block_id: 'video-1',
714+
params: { inputs: { provider: 'falai' } },
715+
},
716+
{
717+
operation_type: 'edit' as const,
718+
block_id: 'video-1',
719+
params: { inputs: { apiKey: 'test-api-key-12345' } },
720+
},
721+
]
722+
const workflowState = {
723+
blocks: {
724+
'video-1': {
725+
type: 'video_generator_v3',
726+
subBlocks: { provider: { value: 'runway' } },
727+
},
728+
},
729+
}
730+
731+
const result = await preValidateCredentialInputs(operations, ctx, workflowState)
732+
733+
expect(result.filteredOperations[1]?.params?.inputs?.apiKey).toBeUndefined()
734+
expect(result.errors).toHaveLength(1)
735+
expect(result.errors[0]).toMatchObject({ blockId: 'video-1', field: 'apiKey' })
736+
})
737+
738+
it('strips apiKey when the tool selector throws (falls back to access tools)', async () => {
739+
const operations = [
740+
{
741+
operation_type: 'add' as const,
742+
block_id: 'sel-1',
743+
params: {
744+
type: 'throw_selector_block',
745+
inputs: { provider: 'falai', apiKey: 'user-key' },
746+
},
747+
},
748+
]
749+
750+
const result = await preValidateCredentialInputs(operations, ctx)
751+
752+
expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
753+
expect(result.errors).toHaveLength(1)
754+
})
755+
756+
it('strips apiKey when a tool hosting enabled predicate throws (fail toward stripping)', async () => {
757+
const operations = [
758+
{
759+
operation_type: 'add' as const,
760+
block_id: 'gate-1',
761+
params: {
762+
type: 'throw_gate_block',
763+
inputs: { provider: 'whatever', apiKey: 'user-key' },
764+
},
765+
},
766+
]
767+
768+
const result = await preValidateCredentialInputs(operations, ctx)
769+
770+
expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
771+
expect(result.errors).toHaveLength(1)
772+
})
773+
519774
it('preserves apiKey on self-hosted deployments (isHosted false)', async () => {
520775
mockEnvFlags.isHosted = false
521776
const operations = [

0 commit comments

Comments
 (0)