Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,31 @@ const imageBlockConfig = {
tools: { access: ['image_generate'], config: { tool: () => 'image_generate' } },
}

// Tool whose hosting.enabled predicate throws — used to assert fail-toward-strip behavior.
const throwGateBlockConfig = {
type: 'throw_gate_block',
name: 'Throw Gate Block',
outputs: {},
subBlocks: [{ id: 'provider', type: 'dropdown' }],
tools: { access: ['throw_gate_tool'], config: { tool: () => 'throw_gate_tool' } },
}

// Block whose tool selector throws — should fall back to scanning access tools (video_falai).
const throwSelectorBlockConfig = {
type: 'throw_selector_block',
name: 'Throw Selector Block',
outputs: {},
subBlocks: [{ id: 'provider', type: 'dropdown' }],
tools: {
access: ['video_falai'],
config: {
tool: () => {
throw new Error('selector boom')
},
},
},
}

// Tool registry stand-in for the hosted-tool tests.
const toolsByIdMock: Record<string, unknown> = {
video_falai: { id: 'video_falai', hosting: { apiKeyParam: 'apiKey' } },
Expand All @@ -126,6 +151,15 @@ const toolsByIdMock: Record<string, unknown> = {
enabled: (p: Record<string, unknown>) => p.provider === 'falai',
},
},
throw_gate_tool: {
id: 'throw_gate_tool',
hosting: {
apiKeyParam: 'apiKey',
enabled: () => {
throw new Error('boom')
},
},
},
}

vi.mock('@/blocks/registry', () => ({
Expand All @@ -150,7 +184,11 @@ vi.mock('@/blocks/registry', () => ({
? customKeyBlockConfig
: type === 'image_generator_v2'
? imageBlockConfig
: undefined,
: type === 'throw_gate_block'
? throwGateBlockConfig
: type === 'throw_selector_block'
? throwSelectorBlockConfig
: undefined,
}))

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

it('strips apiKey on a type-less edit op, resolving block type + provider from workflow state', async () => {
// Mirrors the real failure: agent edits only { apiKey } with no `type` restated.
const operations = [
{
operation_type: 'edit' as const,
block_id: 'video-1',
params: { inputs: { apiKey: 'test-api-key-12345' } },
},
]
const workflowState = {
blocks: {
'video-1': {
type: 'video_generator_v3',
subBlocks: { provider: { value: 'falai' } },
},
},
}

const result = await preValidateCredentialInputs(operations, ctx, workflowState)

expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
expect(result.errors).toHaveLength(1)
expect(result.errors[0]).toMatchObject({ blockId: 'video-1', field: 'apiKey' })
})

it('strips apiKey on a hosted-tool block nested inside a loop', async () => {
const operations = [
{
Expand Down Expand Up @@ -516,6 +579,198 @@ describe('preValidateCredentialInputs (hosted-tool blocks)', () => {
expect(result.errors[0]).toMatchObject({ blockId: 'custom-1', field: 'serviceKey' })
})

it('strips apiKey on a grandchild block nested two levels deep (loop in loop)', async () => {
const operations = [
{
operation_type: 'add' as const,
block_id: 'outer-loop',
params: {
type: 'loop',
inputs: {},
nestedNodes: {
'inner-loop': {
type: 'loop',
inputs: {},
nestedNodes: {
'video-child': {
type: 'video_generator_v3',
inputs: { provider: 'falai', apiKey: '{{FAL_API_KEY}}' },
},
},
},
},
},
},
]

const result = await preValidateCredentialInputs(operations, ctx)

const innerInputs = (
(result.filteredOperations[0]?.params?.nestedNodes as Record<string, any>)?.['inner-loop']
?.nestedNodes as Record<string, { inputs?: Record<string, unknown> }>
)?.['video-child']?.inputs
expect(innerInputs?.apiKey).toBeUndefined()
expect(result.errors).toHaveLength(1)
expect(result.errors[0]).toMatchObject({ blockId: 'video-child', field: 'apiKey' })
})

it('uses same-batch state for nested children (provider set earlier, apiKey set later)', async () => {
const operations = [
{
operation_type: 'add' as const,
block_id: 'loop-1',
params: {
type: 'loop',
inputs: {},
nestedNodes: {
'video-child': { type: 'video_generator_v3', inputs: { provider: 'falai' } },
},
},
},
{
operation_type: 'edit' as const,
block_id: 'loop-1',
params: {
nestedNodes: {
'video-child': { type: 'video_generator_v3', inputs: { apiKey: 'test-key' } },
},
},
},
]

const result = await preValidateCredentialInputs(operations, ctx)

const nested = result.filteredOperations[1]?.params?.nestedNodes as
| Record<string, { inputs?: Record<string, unknown> }>
| undefined
expect(nested?.['video-child']?.inputs?.apiKey).toBeUndefined()
expect(result.errors).toHaveLength(1)
expect(result.errors[0]).toMatchObject({ blockId: 'video-child', field: 'apiKey' })
})

it('strips a key set before a later op makes the block hosted (reverse batch order)', async () => {
// op1 sets apiKey while the block is still non-hosted (runway); op2 later flips it to falai.
// Deciding against final state must still strip op1's key.
const operations = [
{
operation_type: 'edit' as const,
block_id: 'video-1',
params: { inputs: { apiKey: '{{FAL_API_KEY}}' } },
},
{
operation_type: 'edit' as const,
block_id: 'video-1',
params: { inputs: { provider: 'falai' } },
},
]
const workflowState = {
blocks: {
'video-1': { type: 'video_generator_v3', subBlocks: { provider: { value: 'runway' } } },
},
}

const result = await preValidateCredentialInputs(operations, ctx, workflowState)

expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
expect(result.errors).toHaveLength(1)
expect(result.errors[0]).toMatchObject({ blockId: 'video-1', field: 'apiKey' })
})

it.each([{ type: '' }, { type: 'totally_unknown_type' }])(
'does not let an invalid type (%o) on an earlier op block stripping on a later edit',
async ({ type }) => {
const operations = [
{
operation_type: 'edit' as const,
block_id: 'video-1',
params: { type, inputs: { prompt: 'x' } },
},
{
operation_type: 'edit' as const,
block_id: 'video-1',
params: { inputs: { apiKey: '{{FAL_API_KEY}}' } },
},
]
const workflowState = {
blocks: {
'video-1': { type: 'video_generator_v3', subBlocks: { provider: { value: 'falai' } } },
},
}

const result = await preValidateCredentialInputs(operations, ctx, workflowState)

expect(result.filteredOperations[1]?.params?.inputs?.apiKey).toBeUndefined()
expect(result.errors).toHaveLength(1)
}
)

it('uses same-batch state: a type-less apiKey edit after an earlier op makes the block hosted', async () => {
// op1 switches provider to falai (hosted); op2 (type-less) sets apiKey. op2 must see op1's
// provider, not the stale snapshot (runway), and strip the key.
const operations = [
{
operation_type: 'edit' as const,
block_id: 'video-1',
params: { inputs: { provider: 'falai' } },
},
{
operation_type: 'edit' as const,
block_id: 'video-1',
params: { inputs: { apiKey: 'test-api-key-12345' } },
},
]
const workflowState = {
blocks: {
'video-1': {
type: 'video_generator_v3',
subBlocks: { provider: { value: 'runway' } },
},
},
}

const result = await preValidateCredentialInputs(operations, ctx, workflowState)

expect(result.filteredOperations[1]?.params?.inputs?.apiKey).toBeUndefined()
expect(result.errors).toHaveLength(1)
expect(result.errors[0]).toMatchObject({ blockId: 'video-1', field: 'apiKey' })
})

it('strips apiKey when the tool selector throws (falls back to access tools)', async () => {
const operations = [
{
operation_type: 'add' as const,
block_id: 'sel-1',
params: {
type: 'throw_selector_block',
inputs: { provider: 'falai', apiKey: 'user-key' },
},
},
]

const result = await preValidateCredentialInputs(operations, ctx)

expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
expect(result.errors).toHaveLength(1)
})

it('strips apiKey when a tool hosting enabled predicate throws (fail toward stripping)', async () => {
const operations = [
{
operation_type: 'add' as const,
block_id: 'gate-1',
params: {
type: 'throw_gate_block',
inputs: { provider: 'whatever', apiKey: 'user-key' },
},
},
]

const result = await preValidateCredentialInputs(operations, ctx)

expect(result.filteredOperations[0]?.params?.inputs?.apiKey).toBeUndefined()
expect(result.errors).toHaveLength(1)
})

it('preserves apiKey on self-hosted deployments (isHosted false)', async () => {
mockEnvFlags.isHosted = false
const operations = [
Expand Down
Loading
Loading