mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
fix(core): Strip legacy unsupported config before agent JSON validation (#31577)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
332d2df44e
commit
255b7a1543
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,6 +19,25 @@ const createSimpleIntegrationSchema = <Value extends string>(typeName: Value) =>
|
|||
credentialId: z.string().min(1),
|
||||
});
|
||||
|
||||
const createDraftCredIntegrationSchema = <
|
||||
Value extends string,
|
||||
Settings extends z.ZodTypeAny | z.ZodEffects<z.ZodTypeAny>,
|
||||
>(
|
||||
typeName: Value,
|
||||
settingsSchema: Settings,
|
||||
) =>
|
||||
z.object({
|
||||
type: z.literal<Value>(typeName),
|
||||
credentialId: z.string(),
|
||||
settings: settingsSchema,
|
||||
});
|
||||
|
||||
const createDraftSimpleIntegrationSchema = <Value extends string>(typeName: Value) =>
|
||||
z.object({
|
||||
type: z.literal<Value>(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<typeof AgentTelegramSetti
|
|||
export const AgentIntegrationSettingsSchema = z.union([AgentTelegramSettingsSchema, z.undefined()]);
|
||||
export type AgentIntegrationSettings = z.infer<typeof AgentIntegrationSettingsSchema>;
|
||||
|
||||
/** 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<typeof AgentIntegrationSchema>;
|
||||
/** Draft config variant that allows cleared stale credential IDs. */
|
||||
export const AgentIntegrationConfigSchema = z.discriminatedUnion(
|
||||
'type',
|
||||
draftCredentialIntegrations,
|
||||
);
|
||||
|
||||
export type AgentIntegrationConfig = z.infer<typeof AgentIntegrationConfigSchema>;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
const sanitized: Record<string, unknown> = { ...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;
|
||||
}
|
||||
|
|
@ -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: '' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [] };
|
||||
|
|
|
|||
|
|
@ -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<AgentTaskSnapshot> = {}): AgentTask
|
|||
} as AgentTaskSnapshot;
|
||||
}
|
||||
|
||||
function mockProjectCredentials(credentialIds: string[]): void {
|
||||
const credentialsService = mock<CredentialsService>();
|
||||
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<typeof mock<AgentTaskService>> {
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
function clearUnknownCredentialId(
|
||||
credentialId: unknown,
|
||||
accessibleCredentialIds: ReadonlySet<string>,
|
||||
): unknown {
|
||||
if (typeof credentialId !== 'string' || credentialId === '') {
|
||||
return credentialId;
|
||||
}
|
||||
|
||||
return accessibleCredentialIds.has(credentialId) ? credentialId : '';
|
||||
}
|
||||
|
||||
function sanitizeUnknownCredentialsInValue(
|
||||
value: unknown,
|
||||
accessibleCredentialIds: ReadonlySet<string>,
|
||||
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<string, unknown>;
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown>).map(([credType, credRef]) => {
|
||||
if (typeof credRef !== 'object' || credRef === null || Array.isArray(credRef)) {
|
||||
return [credType, credRef];
|
||||
}
|
||||
|
||||
const credentialRef = credRef as Record<string, unknown>;
|
||||
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<string>,
|
||||
): unknown {
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return sanitizeUnknownCredentialsInValue(raw, accessibleCredentialIds);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user