fix(core): Strip legacy unsupported config before agent JSON validation (#31577)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bjorger 2026-06-02 18:41:50 +02:00 committed by GitHub
parent 332d2df44e
commit 255b7a1543
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 866 additions and 25 deletions

View File

@ -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);
});
});

View File

@ -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>;

View File

@ -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)

View File

@ -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';

View File

@ -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;
}

View File

@ -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: '' },
});
});
});

View File

@ -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: [] };

View File

@ -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' });

View File

@ -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);

View File

@ -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) };
}

View File

@ -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',
);
});
});

View File

@ -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);
}