fix(core): Handle managed credentials in workflow codegen (#36210)

This commit is contained in:
Predrag Ristic
2026-08-18 15:04:36 +00:00
committed by GitHub
parent 7b94975c55
commit 59efcb95b2
9 changed files with 302 additions and 18 deletions
@@ -30,7 +30,7 @@ interface NormalizedNode {
typeVersion: number;
position: [number, number];
parameters: Record<string, unknown>;
credentials?: Record<string, { id?: string; name: string }>;
credentials?: NodeRaw['credentials'];
webhookId?: string;
disabled?: boolean;
notes?: string;
@@ -0,0 +1,86 @@
import type { WorkflowJSON } from '@n8n/workflow-sdk';
import { mock } from 'vitest-mock-extended';
import { executeTool } from '../../__tests__/tool-test-utils';
import type { InstanceAiContext } from '../../types';
import {
getWorkflowSourceFileBinding,
saveWorkflowSourceFileBinding,
} from '../workflows/workflow-file-bindings';
import { createWorkflowsTool } from '../workflows.tool';
interface GetAsCodeResult {
workflowId: string;
name: string;
code: string;
error?: string;
}
function makeManagedWorkflow(): WorkflowJSON {
return {
id: 'wf-managed',
name: 'Managed credential workflow',
nodes: [
{
id: 'slack-1',
name: 'Slack',
type: 'n8n-nodes-base.slack',
typeVersion: 2.2,
position: [0, 0],
parameters: { channel: '#alerts' },
credentials: {
slackApi: { id: null, name: 'n8n credits', __aiGatewayManaged: true },
},
},
],
connections: {},
};
}
function makeContext(workflow: WorkflowJSON): InstanceAiContext {
const context = mock<InstanceAiContext>();
context.threadId = undefined;
context.threadMemory = undefined;
context.workflowService.getAsWorkflowJSON = vi.fn().mockResolvedValue(workflow);
context.workflowService.get = vi.fn().mockResolvedValue({
id: 'wf-managed',
name: 'Managed credential workflow',
versionId: 'v-current',
checksum: 'checksum-current',
activeVersionId: null,
isArchived: false,
createdAt: '2026-08-13T00:00:00.000Z',
updatedAt: '2026-08-13T00:00:00.000Z',
nodes: [],
connections: {},
});
return context;
}
describe('workflows get-as-code integration', () => {
it('returns real TypeScript for a managed credential and refreshes its binding', async () => {
const context = makeContext(makeManagedWorkflow());
const filePath = 'src/workflows/managed.workflow.ts';
await saveWorkflowSourceFileBinding(context, {
filePath,
workflowId: 'wf-managed',
workflowVersionId: 'v-stale',
workflowChecksum: 'checksum-stale',
});
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool<GetAsCodeResult>(tool, {
action: 'get-as-code',
workflowId: 'wf-managed',
});
expect(result.error).toBeUndefined();
expect(result.code).not.toBe('');
expect(result.code).toContain("newCredential('n8n credits')");
expect(result.code).not.toContain("newCredential('n8n credits',");
await expect(getWorkflowSourceFileBinding(context, filePath)).resolves.toMatchObject({
workflowVersionId: 'v-current',
workflowChecksum: 'checksum-current',
});
});
});
@@ -55,6 +55,14 @@ function makeCredentialMap(credentials: CredentialEntry[]): CredentialMap {
return map;
}
function makeManagedCredential(): {
id: null;
name: string;
__aiGatewayManaged: true;
} {
return { id: null, name: 'n8n credits', __aiGatewayManaged: true };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -634,6 +642,46 @@ describe('resolveCredentials', () => {
slackApi: { id: 'existing-id', name: 'Existing Slack' },
});
});
it('restores a saved managed credential from a null placeholder', async () => {
const managedCredential = makeManagedCredential();
const workflow = makeWorkflow({
nodes: [
{
id: 'slack-1',
name: 'Slack',
type: 'n8n-nodes-base.slack',
typeVersion: 2.2,
position: [0, 0],
credentials: {
slackApi: null as unknown as { id: string; name: string },
},
},
],
});
const existingWorkflow = makeWorkflow({
nodes: [
{
id: 'slack-1',
name: 'Slack',
type: 'n8n-nodes-base.slack',
typeVersion: 2.2,
position: [0, 0],
credentials: { slackApi: managedCredential },
},
],
});
const result = await resolveCredentials(
workflow,
'wf-123',
createMockContext(existingWorkflow),
makeCredentialMap([]),
);
expect(result.mockedNodeNames).toEqual([]);
expect(workflow.nodes[0]?.credentials?.slackApi).toBe(managedCredential);
});
});
describe('sibling node credential reuse', () => {
@@ -138,6 +138,37 @@ describe('generateWorkflowCode', () => {
expect(code).toContain("slackApi: newCredential('My Slack', 'cred-123')");
});
it('should generate a placeholder for an AI Gateway managed credential', () => {
const json: WorkflowJSON = {
id: 'managed-credential-test',
name: 'Managed Credentials Test',
nodes: [
{
id: 'node-1',
name: 'Slack',
type: 'n8n-nodes-base.slack',
typeVersion: 2.2,
position: [0, 0],
parameters: {},
credentials: {
slackApi: {
id: null,
name: 'n8n Connect',
__aiGatewayManaged: true,
},
},
},
],
connections: {},
};
const code = generateWorkflowCode(json);
expect(code).toContain("slackApi: newCredential('n8n Connect')");
expect(code).not.toContain('id: null');
expect(code).not.toContain('__aiGatewayManaged');
});
it('should generate code for sticky notes', () => {
const json: WorkflowJSON = {
id: 'sticky-test',
@@ -427,6 +458,13 @@ describe('generateWorkflowCode with AI subnodes', () => {
parameters: {
model: 'gpt-4',
},
credentials: {
openAiApi: {
id: null,
name: 'n8n Connect',
__aiGatewayManaged: true,
},
},
},
],
connections: {
@@ -450,6 +488,9 @@ describe('generateWorkflowCode with AI subnodes', () => {
// Should reference the variable in subnodes config
expect(code).toContain('subnodes:');
expect(code).toMatch(/model: \w+/); // Variable reference, not inline call
expect(code).toContain("openAiApi: newCredential('n8n Connect')");
expect(code).not.toContain('id: null');
expect(code).not.toContain('__aiGatewayManaged');
});
it('should generate subnode config for AI agent with multiple subnodes', () => {
@@ -1,6 +1,11 @@
import type { IDataObject } from 'n8n-workflow';
import { generateSubnodeCall, generateSubnodesConfig, formatValue } from './subnode-generator';
import {
formatCredentials,
generateSubnodeCall,
generateSubnodesConfig,
formatValue,
} from './subnode-generator';
import type { SemanticGraph, SemanticNode, AiConnectionType } from './types';
/**
@@ -239,6 +244,32 @@ describe('generateSubnodesConfig', () => {
});
});
describe('formatCredentials', () => {
it('preserves a bare null credential ID as a literal', () => {
const result = formatCredentials({
openAiApi: { id: null, name: 'OpenAI API' },
});
expect(result).toBe("{ openAiApi: { id: null, name: 'OpenAI API' } }");
});
it('keeps string credential IDs in the existing two-argument form', () => {
const result = formatCredentials({
slackApi: { id: 'cred-123', name: 'My Slack' },
});
expect(result).toBe("{ slackApi: newCredential('My Slack', 'cred-123') }");
});
it('keeps name-only credentials in the existing one-argument form', () => {
const result = formatCredentials({
slackApi: { name: 'My Slack' },
});
expect(result).toBe("{ slackApi: newCredential('My Slack') }");
});
});
describe('formatValue', () => {
it('formats placeholder values as placeholder() function calls', () => {
const placeholderString = '<__PLACEHOLDER_VALUE__Enter Slack Channel__>';
@@ -5,6 +5,8 @@
* Supports both inline generation and variable reference modes.
*/
import { isRecord } from '@n8n/utils/is-record';
import {
AI_CONNECTION_TO_CONFIG_KEY,
AI_CONNECTION_TO_BUILDER,
@@ -25,29 +27,41 @@ import type { SemanticGraph, SemanticNode, AiConnectionType } from './types';
* Emits `newCredential('name', 'id')` for credentials with an id,
* or `newCredential('name')` for placeholder credentials.
*/
export function formatCredentials(
credentials: Record<string, { id?: string; name?: string }>,
): string {
export function formatCredentials(credentials: unknown): string {
// Guard: some workflows have credentials as a string (e.g. "[REDACTED]")
// instead of the expected Record<string, {id?, name?}>.
if (typeof credentials === 'string') {
return `'${escapeString(credentials)}'`;
}
if (!isRecord(credentials)) {
return formatValue(credentials);
}
const entries = Object.entries(credentials).map(([key, value]) => {
// If credential has a name, use newCredential() call
if (value.name !== undefined || value.id !== undefined) {
if (value.name !== undefined) {
if (value.id !== undefined) {
return `${formatKey(key)}: newCredential('${escapeString(value.name)}', '${escapeString(value.id)}')`;
}
return `${formatKey(key)}: newCredential('${escapeString(value.name)}')`;
if (!isRecord(value)) {
return `${formatKey(key)}: ${formatValue(value)}`;
}
const name = value.name;
const id = value.id;
if (typeof name === 'string') {
// Managed credentials have no persisted ID, so emit the placeholder form.
if (id === null && value.__aiGatewayManaged === true) {
return `${formatKey(key)}: newCredential('${escapeString(name)}')`;
}
// id-only credential (no name property) — emit raw object to preserve shape
if (value.id !== undefined) {
return `${formatKey(key)}: { id: '${escapeString(value.id)}' }`;
if (id === undefined) {
return `${formatKey(key)}: newCredential('${escapeString(name)}')`;
}
if (typeof id === 'string') {
return `${formatKey(key)}: newCredential('${escapeString(name)}', '${escapeString(id)}')`;
}
}
// Empty credential object — preserve as-is
// id-only credential (no name property) — emit raw object to preserve shape
if (name === undefined && typeof id === 'string') {
return `${formatKey(key)}: { id: '${escapeString(id)}' }`;
}
// Empty or malformed credential object — preserve as-is
return `${formatKey(key)}: ${formatValue(value)}`;
});
return `{ ${entries.join(', ')} }`;
+6 -1
View File
@@ -296,6 +296,11 @@ export function generateUniqueName(baseName: string, exists: (name: string) => b
// Internal: Serialization types
// =============================================================================
/** Persisted managed references use a null ID with the managed marker. */
type NodeJSONCredential =
| { id?: string; name: string; __aiGatewayManaged?: boolean }
| { id: null; name: string; __aiGatewayManaged?: boolean };
/**
* Node JSON representation (for serialization)
*/
@@ -306,7 +311,7 @@ export interface NodeJSON {
typeVersion: number;
position: [number, number];
parameters?: IDataObject;
credentials?: Record<string, { id?: string; name: string }>;
credentials?: Record<string, NodeJSONCredential>;
webhookId?: string;
disabled?: boolean;
notes?: string;
@@ -45,6 +45,7 @@ vi.mock('@n8n/ai-utilities', () => ({
}));
import { Container } from '@n8n/di';
import { generateWorkflowCode } from '@n8n/workflow-sdk';
import { mock } from 'vitest-mock-extended';
import { Expression } from 'n8n-workflow';
import type {
@@ -2123,6 +2124,64 @@ describe('createWorkflowAdapter', () => {
);
});
it('returns AI Gateway-managed credentials in a shape accepted by workflow codegen', async () => {
const { adapter, mockWorkflowFinderService } = createWorkflowAdapterForTests();
mockWorkflowFinderService.findWorkflowForUser.mockResolvedValue({
id: 'wf-managed',
name: 'Managed model workflow',
active: false,
versionId: 'version-id',
activeVersionId: null,
isArchived: false,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
nodes: [
{
id: 'agent-id',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3.1,
position: [0, 0],
parameters: { promptType: 'define', text: 'Summarize the input.' },
},
{
id: 'model-id',
name: 'Google Gemini Chat Model',
type: '@n8n/n8n-nodes-langchain.lmChatGoogleGemini',
typeVersion: 1.1,
position: [0, 200],
parameters: { modelName: 'models/gemini-3-flash-preview' },
credentials: {
googlePalmApi: {
id: null,
name: 'n8n credits',
__aiGatewayManaged: true,
},
},
},
],
connections: {
'Google Gemini Chat Model': {
ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]],
},
},
settings: {},
});
const workflow = await adapter.getAsWorkflowJSON('wf-managed');
expect(workflow.nodes[1].credentials).toEqual({
googlePalmApi: {
id: null,
name: 'n8n credits',
__aiGatewayManaged: true,
},
});
const code = generateWorkflowCode(workflow);
expect(code).toContain("newCredential('n8n credits')");
expect(code).not.toContain("newCredential('n8n credits', 'null')");
});
it('returns the version graph with current workflow metadata when a versionId is passed', async () => {
const { adapter, mockWorkflowHistoryService, mockUser } = createWorkflowAdapterForTests();
mockWorkflowHistoryService.getVersion.mockResolvedValue({
@@ -3951,7 +3951,7 @@ function toWorkflowJSON(
typeVersion: n.typeVersion,
position: n.position,
parameters: redact ? {} : n.parameters,
credentials: n.credentials as Record<string, { id?: string; name: string }> | undefined,
credentials: n.credentials,
webhookId: n.webhookId,
disabled: n.disabled,
notes: n.notes,