diff --git a/packages/@n8n/api-types/src/agents/__tests__/sanitize-agent-json-config.test.ts b/packages/@n8n/api-types/src/agents/__tests__/sanitize-agent-json-config.test.ts new file mode 100644 index 00000000000..efe5b7d23fa --- /dev/null +++ b/packages/@n8n/api-types/src/agents/__tests__/sanitize-agent-json-config.test.ts @@ -0,0 +1,185 @@ +import { AgentJsonConfigSchema } from '../agent-json-config.schema'; +import { sanitizeAgentJsonConfig } from '../sanitize-agent-json-config'; + +const baseConfig = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', +}; + +describe('sanitizeAgentJsonConfig', () => { + it('returns non-object payloads unchanged', () => { + expect(sanitizeAgentJsonConfig(null)).toBe(null); + expect(sanitizeAgentJsonConfig('config')).toBe('config'); + expect(sanitizeAgentJsonConfig([])).toEqual([]); + }); + + it('leaves a clean config unchanged', () => { + const config = { + ...baseConfig, + integrations: [{ type: 'slack', credentialId: 'cred-slack' }], + tools: [{ type: 'custom', id: 'my_tool' }], + }; + + expect(sanitizeAgentJsonConfig(config)).toEqual(config); + }); + + it('strips unsupported integration types such as the removed schedule integration', () => { + const config = { + ...baseConfig, + integrations: [ + { type: 'schedule', active: false, cronExpression: '0 8 * * 1' }, + { type: 'slack', credentialId: 'cred-slack' }, + ], + }; + + expect(sanitizeAgentJsonConfig(config)).toEqual({ + ...baseConfig, + integrations: [{ type: 'slack', credentialId: 'cred-slack' }], + }); + }); + + it('strips unsupported tool types while keeping supported ones', () => { + const config = { + ...baseConfig, + tools: [ + { type: 'legacy_tool', id: 'old' }, + { type: 'custom', id: 'my_tool' }, + ], + }; + + expect(sanitizeAgentJsonConfig(config)).toEqual({ + ...baseConfig, + tools: [{ type: 'custom', id: 'my_tool' }], + }); + }); + + it('keeps known integration types with invalid required fields so validation can fail', () => { + const config = { + ...baseConfig, + integrations: [ + { + type: 'telegram', + credentialId: 'cred-telegram', + settings: { accessMode: 'private', allowedUsers: [] }, + }, + ], + }; + + expect(sanitizeAgentJsonConfig(config)).toEqual(config); + expect(AgentJsonConfigSchema.safeParse(sanitizeAgentJsonConfig(config)).success).toBe(false); + }); + + it('sanitizes a realistic legacy agent config and leaves the remainder schema-valid', () => { + const legacyConfig = { + name: 'General Purpose Agent', + model: 'openai/gpt-5.5', + instructions: 'You are a helpful assistant.', + tools: [], + skills: [], + credential: '14YEs5SRPfAflDJG', + memory: { + enabled: true, + storage: 'n8n', + observationalMemory: { enabled: true }, + }, + providerTools: { + 'openai.web_search': { + externalWebAccess: true, + searchContextSize: 'medium', + }, + }, + config: { + webSearch: { enabled: true }, + }, + integrations: [ + { + type: 'schedule', + active: false, + cronExpression: '0 8 * * 1', + wakeUpPrompt: 'Check priorities for the week.', + }, + ], + legacyTopLevelField: 'should survive until schema parse', + }; + + const sanitized = sanitizeAgentJsonConfig(legacyConfig); + expect(sanitized).toMatchObject({ + ...legacyConfig, + integrations: [], + legacyTopLevelField: 'should survive until schema parse', + }); + + const parsed = AgentJsonConfigSchema.safeParse(sanitized); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + + expect(parsed.data.integrations).toEqual([]); + expect(parsed.data).not.toHaveProperty('legacyTopLevelField'); + }); + + it('strips unsupported entries across integrations, tools, skills, and tasks in one pass', () => { + const config = { + ...baseConfig, + integrations: [ + { type: 'schedule', cronExpression: '0 9 * * *' }, + { type: 'linear', credentialId: 'cred-linear' }, + ], + tools: [ + { type: 'skill', id: 'wrong_collection' }, + { type: 'workflow', workflow: 'wf-1' }, + ], + skills: [ + { type: 'custom', id: 'wrong_collection' }, + { type: 'skill', id: 'summarize' }, + ], + tasks: [ + { type: 'schedule', id: 'legacy_task', enabled: true }, + { type: 'task', id: 'weekly_review', enabled: true }, + ], + }; + + expect(sanitizeAgentJsonConfig(config)).toEqual({ + ...baseConfig, + integrations: [{ type: 'linear', credentialId: 'cred-linear' }], + tools: [{ type: 'workflow', workflow: 'wf-1' }], + skills: [{ type: 'skill', id: 'summarize' }], + tasks: [{ type: 'task', id: 'weekly_review', enabled: true }], + }); + }); + + it('leaves malformed typed-array entries in place for schema validation to reject', () => { + const config = { + ...baseConfig, + integrations: [null, { credentialId: 'cred-slack' }, { type: 123 }], + }; + + expect(sanitizeAgentJsonConfig(config)).toEqual(config); + expect(AgentJsonConfigSchema.safeParse(sanitizeAgentJsonConfig(config)).success).toBe(false); + }); + + it('does not mutate the input object', () => { + const config = { + ...baseConfig, + integrations: [{ type: 'schedule', cronExpression: '0 9 * * *' }], + }; + const originalIntegrations = config.integrations; + + sanitizeAgentJsonConfig(config); + + expect(config.integrations).toBe(originalIntegrations); + expect(config.integrations).toHaveLength(1); + }); + + it('preserves empty typed arrays', () => { + const config = { + ...baseConfig, + integrations: [], + tools: [], + skills: [], + tasks: [], + }; + + expect(sanitizeAgentJsonConfig(config)).toEqual(config); + }); +}); diff --git a/packages/@n8n/api-types/src/agents/agent-integration.schema.ts b/packages/@n8n/api-types/src/agents/agent-integration.schema.ts index b72911c0bc0..14bed4a5227 100644 --- a/packages/@n8n/api-types/src/agents/agent-integration.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-integration.schema.ts @@ -19,6 +19,25 @@ const createSimpleIntegrationSchema = (typeName: Value) => credentialId: z.string().min(1), }); +const createDraftCredIntegrationSchema = < + Value extends string, + Settings extends z.ZodTypeAny | z.ZodEffects, +>( + typeName: Value, + settingsSchema: Settings, +) => + z.object({ + type: z.literal(typeName), + credentialId: z.string(), + settings: settingsSchema, + }); + +const createDraftSimpleIntegrationSchema = (typeName: Value) => + z.object({ + type: z.literal(typeName), + credentialId: z.string(), + }); + export const AGENT_TELEGRAM_ACCESS_MODES = ['private', 'public'] as const; export const AgentTelegramSettingsSchema = z @@ -53,6 +72,10 @@ export type AgentTelegramIntegrationSettings = z.infer; +/** Supported chat integration types; keep in sync with `credentialIntegrations` below. */ +export const SUPPORTED_AGENT_INTEGRATION_TYPES = ['telegram', 'slack', 'linear'] as const; +export type SupportedAgentIntegrationType = (typeof SUPPORTED_AGENT_INTEGRATION_TYPES)[number]; + const credentialIntegrations = [ createCredIntegrationSchema('telegram', AgentTelegramSettingsSchema).extend({ // keep optional for older agents @@ -62,6 +85,20 @@ const credentialIntegrations = [ createSimpleIntegrationSchema('linear'), ] as const; +const draftCredentialIntegrations = [ + createDraftCredIntegrationSchema('telegram', AgentTelegramSettingsSchema).extend({ + settings: AgentTelegramSettingsSchema.optional(), + }), + createDraftSimpleIntegrationSchema('slack'), + createDraftSimpleIntegrationSchema('linear'), +] as const; + export const AgentIntegrationSchema = z.discriminatedUnion('type', credentialIntegrations); -export type AgentIntegrationConfig = z.infer; +/** Draft config variant that allows cleared stale credential IDs. */ +export const AgentIntegrationConfigSchema = z.discriminatedUnion( + 'type', + draftCredentialIntegrations, +); + +export type AgentIntegrationConfig = z.infer; diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index 94f5b4dca87..918d8ad4433 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -1,6 +1,6 @@ import { z, type ZodError } from 'zod'; -import { AgentIntegrationSchema } from './agent-integration.schema'; +import { AgentIntegrationConfigSchema } from './agent-integration.schema'; const SemanticRecallSchema = z.object({ topK: z.number().int().min(1).max(100), @@ -29,7 +29,7 @@ export const AgentModelSchema = z const MemoryWorkerModelSchema = z.object({ model: AgentModelSchema, - credential: z.string().trim().min(1), + credential: z.string().trim(), }); const ObservationalMemoryConfigSchema = z.object({ @@ -49,7 +49,7 @@ const EpisodicMemoryConfigSchema = z.discriminatedUnion('enabled', [ }), z.object({ enabled: z.literal(true), - credential: z.string().trim().min(1), + credential: z.string().trim(), extractorModel: MemoryWorkerModelSchema.optional(), reflectorModel: MemoryWorkerModelSchema.optional(), topK: z.number().int().min(1).max(100).optional(), @@ -255,7 +255,7 @@ export const AgentJsonConfigSchema = z.object({ skills: z.array(AgentJsonSkillConfigSchema).optional(), tasks: z.array(AgentJsonTaskConfigSchema).optional(), providerTools: z.record(z.record(z.unknown())).optional(), - integrations: z.array(AgentIntegrationSchema).optional(), + integrations: z.array(AgentIntegrationConfigSchema).optional(), mcpServers: z .array(McpServerConfigSchema) .max(20) diff --git a/packages/@n8n/api-types/src/agents/index.ts b/packages/@n8n/api-types/src/agents/index.ts index ada4e32445e..3c3a0a17bf9 100644 --- a/packages/@n8n/api-types/src/agents/index.ts +++ b/packages/@n8n/api-types/src/agents/index.ts @@ -1,6 +1,7 @@ export * from './agent-files.constants'; export * from './agent-integration.schema'; export * from './agent-json-config.schema'; +export * from './sanitize-agent-json-config'; export * from './agent-task.schema'; export * from './dto'; export * from './model-providers'; diff --git a/packages/@n8n/api-types/src/agents/sanitize-agent-json-config.ts b/packages/@n8n/api-types/src/agents/sanitize-agent-json-config.ts new file mode 100644 index 00000000000..94759a8f19f --- /dev/null +++ b/packages/@n8n/api-types/src/agents/sanitize-agent-json-config.ts @@ -0,0 +1,62 @@ +import { SUPPORTED_AGENT_INTEGRATION_TYPES } from './agent-integration.schema'; + +const SUPPORTED_TOOL_TYPES = ['custom', 'workflow', 'node'] as const; +const SUPPORTED_SKILL_TYPES = ['skill'] as const; +const SUPPORTED_TASK_TYPES = ['task'] as const; + +function filterUnsupportedTypedEntries( + entries: unknown, + supportedTypes: readonly string[], +): unknown { + if (!Array.isArray(entries)) return entries; + + return entries.filter((entry) => { + if (typeof entry !== 'object' || entry === null) { + return true; + } + + const type = (entry as { type?: unknown }).type; + if (typeof type !== 'string') { + return true; + } + + return supportedTypes.includes(type); + }); +} + +/** + * Strip legacy or unsupported typed entries from agent JSON config before strict + * Zod validation. Unknown top-level keys are still dropped by `AgentJsonConfigSchema`. + * + * Entries with a supported `type` but invalid required fields are kept so validation + * can surface the error instead of silently discarding user mistakes. + */ +export function sanitizeAgentJsonConfig(raw: unknown): unknown { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return raw; + } + + const config = raw as Record; + const sanitized: Record = { ...config }; + + if ('integrations' in sanitized) { + sanitized.integrations = filterUnsupportedTypedEntries( + sanitized.integrations, + SUPPORTED_AGENT_INTEGRATION_TYPES, + ); + } + + if ('tools' in sanitized) { + sanitized.tools = filterUnsupportedTypedEntries(sanitized.tools, SUPPORTED_TOOL_TYPES); + } + + if ('skills' in sanitized) { + sanitized.skills = filterUnsupportedTypedEntries(sanitized.skills, SUPPORTED_SKILL_TYPES); + } + + if ('tasks' in sanitized) { + sanitized.tasks = filterUnsupportedTypedEntries(sanitized.tasks, SUPPORTED_TASK_TYPES); + } + + return sanitized; +} diff --git a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts index dfce85f59b4..b538b6d602a 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts @@ -200,7 +200,21 @@ describe('AgentJsonConfigSchema — memory.observationalMemory', () => { expect(parsed.success).toBe(false); }); - it('rejects observational memory task models with blank credentials', () => { + it('accepts cleared observational memory task model credentials', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + memory: { + ...memoryBase, + observationalMemory: { + observerModel: { model: 'openai/gpt-4o-mini', credential: '' }, + }, + }, + }); + + expect(parsed.success).toBe(true); + }); + + it('accepts whitespace-only observational memory task model credentials after trim', () => { const parsed = AgentJsonConfigSchema.safeParse({ ...baseConfig, memory: { @@ -211,7 +225,13 @@ describe('AgentJsonConfigSchema — memory.observationalMemory', () => { }, }); - expect(parsed.success).toBe(false); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + + expect(parsed.data.memory?.observationalMemory?.observerModel).toEqual({ + model: 'openai/gpt-4o-mini', + credential: '', + }); }); it('rejects observer thresholds below one', () => { @@ -304,7 +324,19 @@ describe('AgentJsonConfigSchema — memory.episodicMemory', () => { expect(parsed.success).toBe(false); }); - it('rejects enabled episodic memory with a blank credential', () => { + it('accepts cleared episodic memory credentials', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + memory: { + ...memoryBase, + episodicMemory: { enabled: true, credential: '' }, + }, + }); + + expect(parsed.success).toBe(true); + }); + + it('accepts whitespace-only episodic memory credentials after trim', () => { const parsed = AgentJsonConfigSchema.safeParse({ ...baseConfig, memory: { @@ -313,10 +345,30 @@ describe('AgentJsonConfigSchema — memory.episodicMemory', () => { }, }); - expect(parsed.success).toBe(false); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + + const episodicMemory = parsed.data.memory?.episodicMemory; + expect(episodicMemory).toMatchObject({ enabled: true, credential: '' }); }); - it('rejects episodic memory task models with blank credentials', () => { + it('accepts cleared episodic memory task model credentials', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + memory: { + ...memoryBase, + episodicMemory: { + enabled: true, + credential: 'credential-id', + extractorModel: { model: 'openai/gpt-4o-mini', credential: '' }, + }, + }, + }); + + expect(parsed.success).toBe(true); + }); + + it('accepts whitespace-only episodic memory task model credentials after trim', () => { const parsed = AgentJsonConfigSchema.safeParse({ ...baseConfig, memory: { @@ -329,6 +381,12 @@ describe('AgentJsonConfigSchema — memory.episodicMemory', () => { }, }); - expect(parsed.success).toBe(false); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + + const episodicMemory = parsed.data.memory?.episodicMemory; + expect(episodicMemory).toMatchObject({ + extractorModel: { model: 'openai/gpt-4o-mini', credential: '' }, + }); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts index 949912f7b5c..024855bc910 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -262,6 +262,47 @@ describe('AgentsBuilderToolsService', () => { }); }); + it('patch_config strips legacy schedule integrations from the current snapshot', async () => { + const { service, agentsService } = makeService(); + const scheduleIntegration = { + type: 'schedule', + active: false, + cronExpression: '0 8 * * 1', + }; + const currentConfig = { + ...baseConfig, + integrations: [scheduleIntegration], + } as unknown as AgentJsonConfig; + const agent = makeAgent(baseConfig); + agent.integrations = [scheduleIntegration] as unknown as Agent['integrations']; + agentsService.findById.mockResolvedValue(agent); + agentsService.updateConfig.mockResolvedValue({ + config: { ...currentConfig, integrations: [], description: 'Updated description' }, + updatedAt: '2026-01-02T00:00:00.000Z', + versionId: 'v2', + }); + + const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!( + { + baseConfigHash: getAgentConfigHash(currentConfig), + operations: JSON.stringify([ + { op: 'add', path: '/description', value: 'Updated description' }, + ]), + }, + ctx, + ); + + expect(result).toEqual(expect.objectContaining({ ok: true })); + expect(agentsService.updateConfig).toHaveBeenCalledWith( + agentId, + projectId, + expect.objectContaining({ + integrations: [], + description: 'Updated description', + }), + ); + }); + it('write_config applies a full config when baseConfigHash matches', async () => { const { service, agentsService } = makeService(); const currentConfig = { ...baseConfig, integrations: [] }; @@ -296,6 +337,36 @@ describe('AgentsBuilderToolsService', () => { }); }); + it('write_config strips legacy schedule integrations before saving', async () => { + const { service, agentsService } = makeService(); + const currentConfig = { ...baseConfig, integrations: [] }; + const updatedConfig = { + ...currentConfig, + integrations: [{ type: 'schedule', active: false, cronExpression: '0 8 * * 1' }], + }; + agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); + agentsService.updateConfig.mockResolvedValue({ + config: { ...updatedConfig, integrations: [] }, + updatedAt: '2026-01-02T00:00:00.000Z', + versionId: 'v2', + }); + + const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!( + { + baseConfigHash: getAgentConfigHash(currentConfig), + json: JSON.stringify(updatedConfig), + }, + ctx, + ); + + expect(result).toEqual(expect.objectContaining({ ok: true })); + expect(agentsService.updateConfig).toHaveBeenCalledWith( + agentId, + projectId, + expect.objectContaining({ integrations: [] }), + ); + }); + it('write_config adds OpenAI native web search defaults', async () => { const { service, agentsService } = makeService(); const currentConfig = { ...baseConfig, integrations: [] }; diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index 1b2073cf2b8..92d320a5086 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -4,6 +4,7 @@ import { Container } from '@n8n/di'; import { type AgentIntegrationConfig, type AgentJsonConfig } from '@n8n/api-types'; import { mockLogger } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; +import type { CredentialsEntity } from '@n8n/db'; import { mock } from 'jest-mock-extended'; import type { Publisher } from '@/scaling/pubsub/publisher.service'; @@ -85,6 +86,22 @@ function makeTaskSnapshot(overrides: Partial = {}): AgentTask } as AgentTaskSnapshot; } +function mockProjectCredentials(credentialIds: string[]): void { + const credentialsService = mock(); + credentialsService.findAllCredentialIdsForProject.mockResolvedValue( + credentialIds.map( + (id) => + ({ + id, + name: id, + type: 'openAiApi', + }) as CredentialsEntity, + ), + ); + credentialsService.findAllGlobalCredentialIds.mockResolvedValue([]); + Container.set(CredentialsService, credentialsService); +} + // Publish/unpublish/delete call into AgentTaskService via the DI container; the // hooks await `requestReconcile(...)`, so the mock must resolve. function mockAgentTaskService(): ReturnType> { @@ -251,6 +268,56 @@ describe('AgentsService', () => { expect(result.valid).toBe(true); }); + + it('strips legacy schedule integrations before validating the remaining config', async () => { + const result = await service.validateConfig({ + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Help the user.', + integrations: [ + { type: 'schedule', active: false, cronExpression: '0 8 * * 1' }, + { type: 'slack', credentialId: 'cred-slack' }, + ], + }); + + expect(result.valid).toBe(true); + if (!result.valid) return; + + expect(result.config.integrations).toEqual([{ type: 'slack', credentialId: 'cred-slack' }]); + }); + + it('does not reject unknown credential IDs during schema validation', async () => { + const result = await service.validateConfig({ + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Help the user.', + credential: 'unknown-credential-id', + }); + + expect(result.valid).toBe(true); + if (!result.valid) return; + + expect(result.config.credential).toBe('unknown-credential-id'); + }); + + it('allows MCP servers with cleared credentials during draft validation', async () => { + const result = await service.validateConfig({ + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Help the user.', + mcpServers: [ + { + name: 'github', + url: 'https://example.com/mcp', + transport: 'streamableHttp', + authentication: 'bearerAuth', + credential: '', + }, + ], + }); + + expect(result.valid).toBe(true); + }); }); describe('create', () => { @@ -279,6 +346,7 @@ describe('AgentsService', () => { beforeEach(() => { jest.spyOn(service, 'validateConfig').mockResolvedValue({ valid: true, config }); agentRepository.save.mockImplementation(async (a) => a as Agent); + mockProjectCredentials([]); }); it('does not bump versionId when agent has never been published', async () => { @@ -437,6 +505,27 @@ describe('AgentsService', () => { expect(savedEntity.integrations).toEqual([]); }); + it('persists sanitized integrations when legacy schedule entries are present', async () => { + jest.spyOn(service, 'validateConfig').mockRestore(); + mockProjectCredentials(['cred-slack']); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + + const configWithLegacySchedule = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + integrations: [ + { type: 'schedule', active: false, cronExpression: '0 8 * * 1' }, + { type: 'slack', credentialId: 'cred-slack' }, + ], + }; + + await service.updateConfig(agentId, projectId, configWithLegacySchedule); + + const savedEntity = agentRepository.save.mock.calls[0][0] as Agent; + expect(savedEntity.integrations).toEqual([{ type: 'slack', credentialId: 'cred-slack' }]); + }); + it('preserves stored tool bodies when the inbound config omits the tools field', async () => { const existingTools = { 'tool-1': { @@ -559,6 +648,128 @@ describe('AgentsService', () => { expect(savedEntity.description).toBe(agent.description); }); + describe('credential cleanup', () => { + beforeEach(() => { + jest.spyOn(service, 'validateConfig').mockRestore(); + agentRepository.save.mockImplementation(async (a) => a as Agent); + }); + + it('clears a provided unknown top-level credential before persistence', async () => { + mockProjectCredentials(['known-cred']); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + + await service.updateConfig(agentId, projectId, { + name: 'Test Agent', + model: 'openai/gpt-5.5', + instructions: 'Help the user.', + credential: 'unknown-cred', + }); + + const savedEntity = agentRepository.save.mock.calls[0][0] as Agent; + expect((savedEntity.schema as AgentJsonConfig).credential).toBe(''); + }); + + it('preserves a provided credential when it is accessible to the project', async () => { + mockProjectCredentials(['known-cred']); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + + await service.updateConfig(agentId, projectId, { + name: 'Test Agent', + model: 'openai/gpt-5.5', + instructions: 'Help the user.', + credential: 'known-cred', + }); + + const savedEntity = agentRepository.save.mock.calls[0][0] as Agent; + expect((savedEntity.schema as AgentJsonConfig).credential).toBe('known-cred'); + }); + + it('preserves the stored credential when the inbound config omits credential', async () => { + mockProjectCredentials([]); + const agent = makeAgent({ + schema: { + name: 'Test Agent', + model: 'openai/gpt-5.5', + instructions: 'Help the user.', + credential: 'stored-cred', + } as AgentJsonConfig, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + await service.updateConfig(agentId, projectId, { + name: 'Test Agent', + model: 'openai/gpt-5.5', + instructions: 'Updated instructions', + }); + + const savedEntity = agentRepository.save.mock.calls[0][0] as Agent; + expect((savedEntity.schema as AgentJsonConfig).credential).toBe('stored-cred'); + }); + + it('clears unknown nested credentials before persistence', async () => { + mockProjectCredentials(['known-cred']); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + + await service.updateConfig(agentId, projectId, { + name: 'Test Agent', + model: 'openai/gpt-5.5', + instructions: 'Help the user.', + memory: { + enabled: true, + storage: 'n8n', + observationalMemory: { + observerModel: { model: 'openai/gpt-4o-mini', credential: 'unknown-cred' }, + reflectorModel: { model: 'anthropic/claude-sonnet-4-5', credential: 'known-cred' }, + }, + }, + integrations: [{ type: 'slack', credentialId: 'unknown-cred' }], + }); + + const savedEntity = agentRepository.save.mock.calls[0][0] as Agent; + const savedConfig = savedEntity.schema as AgentJsonConfig; + expect(savedConfig.memory?.observationalMemory?.observerModel).toEqual({ + model: 'openai/gpt-4o-mini', + credential: '', + }); + expect(savedConfig.memory?.observationalMemory?.reflectorModel).toEqual({ + model: 'anthropic/claude-sonnet-4-5', + credential: 'known-cred', + }); + expect(savedEntity.integrations).toEqual([{ type: 'slack', credentialId: '' }]); + }); + + it('clears unknown MCP server credentials and still saves the draft config', async () => { + mockProjectCredentials([]); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + + await service.updateConfig(agentId, projectId, { + name: 'Test Agent', + model: 'openai/gpt-5.5', + instructions: 'Help the user.', + mcpServers: [ + { + name: 'github', + url: 'https://example.com/mcp', + transport: 'streamableHttp', + authentication: 'bearerAuth', + credential: 'unknown-mcp-cred', + }, + ], + }); + + const savedEntity = agentRepository.save.mock.calls[0][0] as Agent; + expect((savedEntity.schema as AgentJsonConfig).mcpServers).toEqual([ + { + name: 'github', + url: 'https://example.com/mcp', + transport: 'streamableHttp', + authentication: 'bearerAuth', + credential: '', + }, + ]); + }); + }); + it('stores subAgents when the inbound config provides saved agent refs', async () => { const agent = makeAgent(); const subAgent = makeAgent({ id: 'agent-2', activeVersionId: 'published-version-2' }); diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index 923cd64fcef..daeb4a46b32 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -12,6 +12,7 @@ import { AgentIntegrationSchema, AgentJsonConfigSchema, isNodeToolsEnabled, + sanitizeAgentJsonConfig, isSubAgentsEnabled, AgentModelSchema, type AgentIntegrationConfig, @@ -94,6 +95,7 @@ import { N8nMemory } from './integrations/n8n-memory'; import { createGetEnvironmentTool } from './tools/environment-tool'; import { createRichInteractionTool } from './integrations/rich-interaction-tool'; import { composeJsonConfig, decomposeJsonConfig } from './json-config/agent-config-composition'; +import { sanitizeUnknownAgentCredentials } from './json-config/sanitize-unknown-agent-credentials'; import { buildFromJson, type MemoryFactory, @@ -1772,7 +1774,7 @@ export class AgentsService { async validateConfig( raw: unknown, ): Promise<{ valid: true; config: AgentJsonConfig } | { valid: false; error: string }> { - const parsed = AgentJsonConfigSchema.safeParse(raw); + const parsed = AgentJsonConfigSchema.safeParse(sanitizeAgentJsonConfig(raw)); if (!parsed.success) { return { valid: false, error: parsed.error.message }; } @@ -1787,16 +1789,6 @@ export class AgentsService { }; } - const mcpServers = config.mcpServers ?? []; - for (const server of mcpServers) { - if (server.authentication !== 'none' && !server.credential) { - return { - valid: false, - error: `MCP server "${server.name}" requires a credential when authentication is not "none".`, - }; - } - } - try { this.validateNodeToolExpressions(config); } catch (error) { @@ -1834,7 +1826,16 @@ export class AgentsService { const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); if (!entity) throw new NotFoundError('Agent not found'); - const result = await this.validateConfig(config); + const credentialProvider = this.createCredentialProvider(projectId); + const accessibleCredentialIds = new Set( + (await credentialProvider.list()).map((credential) => credential.id), + ); + const sanitizedConfig = sanitizeUnknownAgentCredentials( + sanitizeAgentJsonConfig(config), + accessibleCredentialIds, + ); + + const result = await this.validateConfig(sanitizedConfig); if (!result.valid) { throw new UserError(`Invalid agent config: ${result.error}`); } @@ -1989,6 +1990,10 @@ export class AgentsService { const validated = parseResult.data; const { type, credentialId } = validated; + if (credentialId === '') { + throw new UserError('Credential integration requires a credential ID.'); + } + const existing = agent.integrations ?? []; const alreadyExists = existing.some((i) => i.type === type && i.credentialId === credentialId); diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index 0af3cb9f0b9..87b60f3ad65 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -5,6 +5,7 @@ import { agentTaskSchema, formatZodErrors, RunnableAgentJsonConfigSchema, + sanitizeAgentJsonConfig, tryParseConfigJson, type AgentJsonConfig, type ConfigValidationError, @@ -267,7 +268,9 @@ export class AgentsBuilderToolsService { if (baseConfigHash !== snapshot.configHash) { return { ok: false, stage: 'stale', errors: [STALE_CONFIG_ERROR], ...snapshot }; } - const zodResult = RunnableAgentJsonConfigSchema.safeParse(parsed.data); + const zodResult = RunnableAgentJsonConfigSchema.safeParse( + sanitizeAgentJsonConfig(parsed.data), + ); if (!zodResult.success) { return { ok: false, errors: formatZodErrors(zodResult.error) }; } @@ -373,7 +376,9 @@ export class AgentsBuilderToolsService { const patched = jsonpatch.applyPatch(jsonpatch.deepClone(snapshot.config), ops) .newDocument as unknown as AgentJsonConfig; - const zodResult = RunnableAgentJsonConfigSchema.safeParse(patched); + const zodResult = RunnableAgentJsonConfigSchema.safeParse( + sanitizeAgentJsonConfig(patched), + ); if (!zodResult.success) { return { ok: false, stage: 'schema', errors: formatZodErrors(zodResult.error) }; } diff --git a/packages/cli/src/modules/agents/json-config/__tests__/sanitize-unknown-agent-credentials.test.ts b/packages/cli/src/modules/agents/json-config/__tests__/sanitize-unknown-agent-credentials.test.ts new file mode 100644 index 00000000000..b9a8dbc11c7 --- /dev/null +++ b/packages/cli/src/modules/agents/json-config/__tests__/sanitize-unknown-agent-credentials.test.ts @@ -0,0 +1,113 @@ +import { sanitizeUnknownAgentCredentials } from '../sanitize-unknown-agent-credentials'; + +describe('sanitizeUnknownAgentCredentials', () => { + const accessibleCredentialIds = new Set(['known-cred', 'nested-cred']); + + it('clears unknown top-level credential fields', () => { + const result = sanitizeUnknownAgentCredentials( + { + credential: 'unknown-cred', + model: 'openai/gpt-5.5', + name: 'General Purpose Agent', + }, + accessibleCredentialIds, + ); + + expect(result).toEqual({ + credential: '', + model: 'openai/gpt-5.5', + name: 'General Purpose Agent', + }); + }); + + it('preserves known credential fields', () => { + const result = sanitizeUnknownAgentCredentials( + { credential: 'known-cred', name: 'Agent' }, + accessibleCredentialIds, + ); + + expect(result).toEqual({ credential: 'known-cred', name: 'Agent' }); + }); + + it('clears unknown credentialId fields at arbitrary nesting depth', () => { + const result = sanitizeUnknownAgentCredentials( + { + integrations: [{ type: 'slack', credentialId: 'unknown-cred' }], + memory: { + episodicMemory: { + enabled: true, + credential: 'unknown-cred', + }, + }, + }, + accessibleCredentialIds, + ); + + expect(result).toEqual({ + integrations: [{ type: 'slack', credentialId: '' }], + memory: { + episodicMemory: { + enabled: true, + credential: '', + }, + }, + }); + }); + + it('clears unknown credentials map ids but leaves unrelated id fields untouched', () => { + const result = sanitizeUnknownAgentCredentials( + { + tools: [ + { type: 'custom', id: 'tool-1', credentials: { openAiApi: { id: 'unknown-cred' } } }, + ], + tasks: [{ type: 'task', id: 'task-1', enabled: true }], + }, + accessibleCredentialIds, + ); + + expect(result).toEqual({ + tools: [{ type: 'custom', id: 'tool-1', credentials: { openAiApi: { id: '' } } }], + tasks: [{ type: 'task', id: 'task-1', enabled: true }], + }); + }); + + it('preserves known nested credentials', () => { + const result = sanitizeUnknownAgentCredentials( + { + memory: { + observationalMemory: { + observerModel: { model: 'openai/gpt-4o-mini', credential: 'nested-cred' }, + }, + }, + integrations: [{ type: 'linear', credentialId: 'known-cred' }], + }, + accessibleCredentialIds, + ); + + expect(result).toEqual({ + memory: { + observationalMemory: { + observerModel: { model: 'openai/gpt-4o-mini', credential: 'nested-cred' }, + }, + }, + integrations: [{ type: 'linear', credentialId: 'known-cred' }], + }); + }); + + it('leaves non-string credential-like values untouched', () => { + const input = { + credential: 123, + credentialId: null, + credentials: { openAiApi: { id: false } }, + }; + + expect(sanitizeUnknownAgentCredentials(input, accessibleCredentialIds)).toEqual(input); + }); + + it('returns non-object input unchanged', () => { + expect(sanitizeUnknownAgentCredentials(null, accessibleCredentialIds)).toBeNull(); + expect(sanitizeUnknownAgentCredentials('credential', accessibleCredentialIds)).toBe( + 'credential', + ); + }); +}); diff --git a/packages/cli/src/modules/agents/json-config/sanitize-unknown-agent-credentials.ts b/packages/cli/src/modules/agents/json-config/sanitize-unknown-agent-credentials.ts new file mode 100644 index 00000000000..73cc71c35cc --- /dev/null +++ b/packages/cli/src/modules/agents/json-config/sanitize-unknown-agent-credentials.ts @@ -0,0 +1,93 @@ +function clearUnknownCredentialId( + credentialId: unknown, + accessibleCredentialIds: ReadonlySet, +): unknown { + if (typeof credentialId !== 'string' || credentialId === '') { + return credentialId; + } + + return accessibleCredentialIds.has(credentialId) ? credentialId : ''; +} + +function sanitizeUnknownCredentialsInValue( + value: unknown, + accessibleCredentialIds: ReadonlySet, + parentKey?: string, +): unknown { + if (Array.isArray(value)) { + return value.map((entry) => + sanitizeUnknownCredentialsInValue(entry, accessibleCredentialIds, parentKey), + ); + } + + if (typeof value !== 'object' || value === null) { + return value; + } + + const record = value as Record; + const sanitized: Record = {}; + + for (const [key, entry] of Object.entries(record)) { + if (key === 'credential' && typeof entry === 'string') { + sanitized[key] = clearUnknownCredentialId(entry, accessibleCredentialIds); + continue; + } + + if (key === 'credentialId' && typeof entry === 'string') { + sanitized[key] = clearUnknownCredentialId(entry, accessibleCredentialIds); + continue; + } + + if ( + key === 'credentials' && + typeof entry === 'object' && + entry !== null && + !Array.isArray(entry) + ) { + sanitized[key] = Object.fromEntries( + Object.entries(entry as Record).map(([credType, credRef]) => { + if (typeof credRef !== 'object' || credRef === null || Array.isArray(credRef)) { + return [credType, credRef]; + } + + const credentialRef = credRef as Record; + if (!('id' in credentialRef) || typeof credentialRef.id !== 'string') { + return [ + credType, + sanitizeUnknownCredentialsInValue(credentialRef, accessibleCredentialIds, key), + ]; + } + + return [ + credType, + { + ...credentialRef, + id: clearUnknownCredentialId(credentialRef.id, accessibleCredentialIds), + }, + ]; + }), + ); + continue; + } + + sanitized[key] = sanitizeUnknownCredentialsInValue(entry, accessibleCredentialIds, key); + } + + return sanitized; +} + +/** + * Replace credential IDs that are not accessible to the agent project with `""`. + * Walks the config recursively and only targets credential-like fields: + * `credential`, `credentialId`, and `credentials.*.id`. + */ +export function sanitizeUnknownAgentCredentials( + raw: unknown, + accessibleCredentialIds: ReadonlySet, +): unknown { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return raw; + } + + return sanitizeUnknownCredentialsInValue(raw, accessibleCredentialIds); +}