chore: Bundle 2.x (#37586)

Co-authored-by: Matsu <matias.huhta@n8n.io>
This commit is contained in:
n8n-assistant[bot] 2026-09-02 08:16:39 +03:00 committed by GitHub
parent d8194d4e66
commit cae5e093d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
213 changed files with 11228 additions and 1588 deletions

View File

@ -1,3 +1,4 @@
import { lookup as dnsLookup } from 'node:dns';
import { Agent, ProxyAgent, fetch as undiciFetch } from 'undici';
import type { MockedFunction } from 'vitest';
@ -14,6 +15,12 @@ vi.mock('undici', () => ({
fetch: vi.fn(),
}));
const DEFAULT_AGENT_OPTIONS = {
headersTimeout: 3600000,
bodyTimeout: 3600000,
connect: { lookup: dnsLookup },
};
describe('getProxyAgent', () => {
// Store original environment variables
const originalEnv = { ...process.env };
@ -36,20 +43,33 @@ describe('getProxyAgent', () => {
process.env = originalEnv;
});
describe('backward compatible behavior (no timeout options)', () => {
it('should return undefined when no proxy environment variables are set and no timeout options', () => {
describe('default behavior (no timeout options)', () => {
it('should return an Agent with the default lookup when no proxy environment variables are set and no timeout options', () => {
const agent = getProxyAgent();
expect(agent).toBeUndefined();
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
expect(ProxyAgent).not.toHaveBeenCalled();
expect(Agent).not.toHaveBeenCalled();
});
it('should return undefined when no proxy is configured for target URL and no timeout options', () => {
it('should return an Agent when no proxy is configured for target URL and no timeout options', () => {
const agent = getProxyAgent('https://api.openai.com/v1');
expect(agent).toBeUndefined();
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
expect(ProxyAgent).not.toHaveBeenCalled();
expect(Agent).not.toHaveBeenCalled();
});
it('should reuse a single Agent across calls when no proxy, timeout options nor custom lookup are given', () => {
const first = getProxyAgent('https://api.openai.com/v1');
const second = getProxyAgent('https://api.anthropic.com/v1');
expect(second).toBe(first);
});
it('should build a fresh Agent when timeout options or a custom lookup are given', () => {
const shared = getProxyAgent('https://api.openai.com/v1');
expect(getProxyAgent('https://api.openai.com/v1', {})).not.toBe(shared);
expect(getProxyAgent('https://api.openai.com/v1', undefined, vi.fn())).not.toBe(shared);
});
it('should create ProxyAgent with default timeouts when HTTPS_PROXY is set', () => {
@ -137,7 +157,7 @@ describe('getProxyAgent', () => {
const agent = getProxyAgent('http://localhost:3000');
expect(agent).toBeUndefined();
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
expect(ProxyAgent).not.toHaveBeenCalled();
});
@ -148,7 +168,7 @@ describe('getProxyAgent', () => {
const agent = getProxyAgent('https://api.internal.company.com');
expect(agent).toBeUndefined();
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
expect(ProxyAgent).not.toHaveBeenCalled();
});
@ -199,20 +219,18 @@ describe('getProxyAgent', () => {
expect(Agent).toHaveBeenCalledWith({
headersTimeout: 120000,
bodyTimeout: 180000,
connect: { lookup: dnsLookup },
});
expect(agent).toEqual({
type: 'Agent',
options: { headersTimeout: 120000, bodyTimeout: 180000 },
options: { headersTimeout: 120000, bodyTimeout: 180000, connect: { lookup: dnsLookup } },
});
});
it('should use default timeouts when empty timeout options object is passed', () => {
getProxyAgent('https://api.openai.com/v1', {});
expect(Agent).toHaveBeenCalledWith({
headersTimeout: 3600000,
bodyTimeout: 3600000,
});
expect(Agent).toHaveBeenCalledWith(DEFAULT_AGENT_OPTIONS);
});
it('should include connectTimeout when provided', () => {
@ -226,6 +244,7 @@ describe('getProxyAgent', () => {
headersTimeout: 60000,
bodyTimeout: 60000,
connectTimeout: 30000,
connect: { lookup: dnsLookup },
});
});
@ -243,7 +262,8 @@ describe('getProxyAgent', () => {
expect(Agent).toHaveBeenCalled();
});
it('should return an Agent instead of undefined when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => {
it('should build a fresh Agent when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => {
const shared = getProxyAgent('https://api.openai.com/v1');
process.env.N8N_AI_TIMEOUT_MAX = '120000';
const agent = getProxyAgent('https://api.openai.com/v1');
@ -251,8 +271,8 @@ describe('getProxyAgent', () => {
// DEFAULT_TIMEOUT was captured from the env at module load time (before this test set it),
// so the value here reflects that capture, not '120000' — the module-reset test below
// covers the env value actually being picked up end to end.
expect(Agent).toHaveBeenCalled();
expect(agent).toEqual(expect.objectContaining({ type: 'Agent' }));
expect(agent).not.toBe(shared);
expect(ProxyAgent).not.toHaveBeenCalled();
});
@ -268,10 +288,15 @@ describe('getProxyAgent', () => {
expect(undici.Agent).toHaveBeenCalledWith({
headersTimeout: 120000,
bodyTimeout: 120000,
connect: { lookup: dnsLookup },
});
expect(agent).toEqual({
type: 'Agent',
options: { headersTimeout: 120000, bodyTimeout: 120000 },
options: {
headersTimeout: 120000,
bodyTimeout: 120000,
connect: { lookup: dnsLookup },
},
});
});
});
@ -335,18 +360,39 @@ describe('proxyFetch', () => {
});
describe('with no proxy configured', () => {
it('should call fetch with undefined dispatcher when no proxy is set and no timeout options', async () => {
it('should call fetch with an Agent dispatcher when no proxy is set and no timeout options', async () => {
const url = 'https://api.openai.com/v1';
await proxyFetch(url);
await proxyFetch({ input: url, lookup: dnsLookup });
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: undefined,
dispatcher: expect.objectContaining({ type: 'Agent' }),
});
});
it('should reuse the same dispatcher across plain calls', async () => {
await proxyFetch({ input: 'https://api.openai.com/v1', lookup: dnsLookup });
await proxyFetch({ input: 'https://api.anthropic.com/v1', lookup: dnsLookup });
const [[, first], [, second]] = mockFetch.mock.calls as unknown as Array<
[unknown, { dispatcher: unknown }]
>;
expect(second.dispatcher).toBe(first.dispatcher);
});
it('should build the dispatcher with the supplied lookup', async () => {
const lookup = vi.fn();
await proxyFetch({ input: 'https://api.openai.com/v1', lookup });
expect(Agent).toHaveBeenCalledWith(expect.objectContaining({ connect: { lookup } }));
});
it('should call fetch with Agent dispatcher when timeout options are provided', async () => {
const url = 'https://api.openai.com/v1';
await proxyFetch(url, undefined, { headersTimeout: 60000 });
await proxyFetch({
input: url,
timeoutOptions: { headersTimeout: 60000 },
lookup: dnsLookup,
});
expect(Agent).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(url, {
@ -362,20 +408,20 @@ describe('proxyFetch', () => {
body: JSON.stringify({ test: 'data' }),
};
await proxyFetch(url, init);
await proxyFetch({ input: url, init, lookup: dnsLookup });
expect(mockFetch).toHaveBeenCalledWith(url, {
...init,
dispatcher: undefined,
dispatcher: expect.objectContaining({ type: 'Agent' }),
});
});
it('should handle URL objects', async () => {
const url = new URL('https://api.openai.com/v1');
await proxyFetch(url);
await proxyFetch({ input: url, lookup: dnsLookup });
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: undefined,
dispatcher: expect.objectContaining({ type: 'Agent' }),
});
});
@ -385,10 +431,10 @@ describe('proxyFetch', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test: 'data' }),
});
await proxyFetch(request);
await proxyFetch({ input: request, lookup: dnsLookup });
expect(mockFetch).toHaveBeenCalledWith(request, {
dispatcher: undefined,
dispatcher: expect.objectContaining({ type: 'Agent' }),
});
});
});
@ -399,7 +445,7 @@ describe('proxyFetch', () => {
process.env.HTTPS_PROXY = proxyUrl;
const url = 'https://api.openai.com/v1';
await proxyFetch(url);
await proxyFetch({ input: url, lookup: dnsLookup });
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(url, {
@ -417,7 +463,7 @@ describe('proxyFetch', () => {
headers: { Authorization: 'Bearer token123' },
};
await proxyFetch(url, init);
await proxyFetch({ input: url, init, lookup: dnsLookup });
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(url, {
@ -431,7 +477,7 @@ describe('proxyFetch', () => {
process.env.HTTP_PROXY = proxyUrl;
const url = new URL('http://api.example.com/data');
await proxyFetch(url);
await proxyFetch({ input: url, lookup: dnsLookup });
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(url, {
@ -444,7 +490,7 @@ describe('proxyFetch', () => {
process.env.HTTPS_PROXY = proxyUrl;
const request = new Request('https://api.openai.com/v1');
await proxyFetch(request);
await proxyFetch({ input: request, lookup: dnsLookup });
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(request, {
@ -458,11 +504,12 @@ describe('proxyFetch', () => {
process.env.NO_PROXY = 'localhost,127.0.0.1';
const url = 'https://localhost:3000/api';
await proxyFetch(url);
await proxyFetch({ input: url, lookup: dnsLookup });
// Should not create ProxyAgent for localhost
expect(ProxyAgent).not.toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: undefined,
dispatcher: expect.objectContaining({ type: 'Agent' }),
});
});
@ -471,7 +518,11 @@ describe('proxyFetch', () => {
process.env.HTTPS_PROXY = proxyUrl;
const url = 'https://api.openai.com/v1';
await proxyFetch(url, undefined, { headersTimeout: 300000, bodyTimeout: 300000 });
await proxyFetch({
input: url,
timeoutOptions: { headersTimeout: 300000, bodyTimeout: 300000 },
lookup: dnsLookup,
});
expect(ProxyAgent).toHaveBeenCalledWith({
uri: proxyUrl,
@ -490,7 +541,7 @@ describe('proxyFetch', () => {
mockFetch.mockResolvedValueOnce(expectedResponse);
const url = 'https://api.openai.com/v1';
const result = await proxyFetch(url);
const result = await proxyFetch({ input: url, lookup: dnsLookup });
expect(result).toBe(expectedResponse);
});
@ -501,7 +552,7 @@ describe('proxyFetch', () => {
const url = 'https://api.openai.com/v1';
await expect(proxyFetch(url)).rejects.toThrow('Network error');
await expect(proxyFetch({ input: url, lookup: dnsLookup })).rejects.toThrow('Network error');
});
it('should return error responses without throwing', async () => {
@ -512,10 +563,9 @@ describe('proxyFetch', () => {
mockFetch.mockResolvedValueOnce(errorResponse);
const url = 'https://api.openai.com/v1';
const result = await proxyFetch(url);
const result = await proxyFetch({ input: url, lookup: dnsLookup });
expect(result).toBe(errorResponse);
expect(result.status).toBe(404);
});
});
});

View File

@ -15,6 +15,7 @@ export { N8nBinaryLoader } from './utils/n8n-binary-loader';
export { N8nJsonLoader } from './utils/n8n-json-loader';
export { N8nPdfLoader } from './utils/loaders/n8n-pdf-loader';
export { N8nLlmTracing } from './utils/n8n-llm-tracing';
export { redactHeaderValues } from './utils/redact-headers';
export {
TextEditorDocument,
NoMatchFoundError,
@ -55,6 +56,7 @@ export {
getNodeProxyAgent,
proxyFetch,
type AgentTimeoutOptions,
type ProxyFetchOptions,
} from './utils/http-proxy-agent';
export { braveSearch, searxngSearch, type BraveSearchOptions } from './web-search';
export type { WebSearchOptions, WebSearchResponse, WebSearchResult } from './web-search';

View File

@ -17,6 +17,7 @@
* and left these runner-side ones in place).
*/
import { createHttpsProxyAgent, resolveProxyUrl } from '@n8n/backend-network/proxy'; // `@n8n/backend-network/proxy` is a DI-free subpath: it pulls in only the proxy-agent libs
import { lookup as defaultLookup } from 'node:dns';
import type { AgentOptions } from 'node:https';
import type { LookupFunction } from 'node:net';
/* eslint-disable n8n-local-rules/no-uncentralized-http -- raw dispatchers for AI SDK `fetchOptions`; see block comment above */
@ -43,30 +44,33 @@ const DEFAULT_TIMEOUT = parseInt(process.env.N8N_AI_TIMEOUT_MAX ?? '3600000', 10
*/
const PROXY_FALLBACK_TARGET = 'https://example.nonexistent/';
/**
* Agent shared by every caller that needs no proxy, no timeout overrides and the
* plain system DNS lookup, so those callers keep a single connection pool instead
* of building an agent (and a pool) per request.
*/
let sharedAgent: Agent | undefined;
/**
* Returns an undici Agent or ProxyAgent with configured timeouts based on the environment variables and target URL.
* When target URL is not provided, NO_PROXY environment variable is not respected.
*
* @param targetUrl - The target URL to check proxy configuration for (optional)
* @param timeoutOptions - Optional timeout configuration to override defaults. When provided,
* always returns an Agent/ProxyAgent (even without proxy) to ensure timeouts are applied.
* @param lookup - Optional DNS lookup to pin the resolved address at connect time (e.g. an egress
* filter's secure lookup). When provided (without a proxy) an Agent is always returned.
* @returns An Agent (no proxy with timeout options, a lookup, or `N8N_AI_TIMEOUT_MAX` set) or ProxyAgent
* (with proxy) configured with timeouts, or undefined if no proxy, timeout options, lookup, nor
* `N8N_AI_TIMEOUT_MAX` are provided/set (backward compatible behavior).
* @param timeoutOptions - Optional timeout configuration to override defaults.
* @param lookup - Connect-time DNS lookup, applied to every Agent this builds.
* Defaults to the plain system lookup.
* @returns A ProxyAgent when a proxy applies to the target, otherwise an Agent. Callers using the
* defaults (no timeout options, no `N8N_AI_TIMEOUT_MAX`, plain lookup) share one Agent.
*
* @remarks
* When timeoutOptions are provided, this function always returns an agent to ensure timeouts are properly configured.
* The default undici timeouts (5 minutes) are too short for many AI operations.
* When timeoutOptions are NOT provided, this still returns an agent if `N8N_AI_TIMEOUT_MAX` is set,
* so the env override isn't silently ignored just because no proxy is configured. Otherwise, returns
* undefined if no proxy is configured (backward compatible).
* The default undici timeouts (5 minutes) are too short for many AI operations, so an agent is
* always returned rather than falling back to undici's global dispatcher. `N8N_AI_TIMEOUT_MAX`
* is honoured even when no proxy and no explicit timeout options are configured.
*/
export function getProxyAgent(
targetUrl?: string,
timeoutOptions?: AgentTimeoutOptions,
lookup?: LookupFunction,
lookup: LookupFunction = defaultLookup,
) {
const proxyUrl = resolveProxyUrl(targetUrl, PROXY_FALLBACK_TARGET);
@ -78,37 +82,45 @@ export function getProxyAgent(
}),
};
if (!proxyUrl) {
if (lookup) {
return new Agent({ ...agentOptions, connect: { lookup } });
}
if (timeoutOptions) {
return new Agent(agentOptions);
}
if (process.env.N8N_AI_TIMEOUT_MAX) {
return new Agent(agentOptions);
}
return undefined;
if (proxyUrl) {
return new ProxyAgent({ uri: proxyUrl, ...agentOptions });
}
return new ProxyAgent({ uri: proxyUrl, ...agentOptions });
const isDefaultCase =
!timeoutOptions && !process.env.N8N_AI_TIMEOUT_MAX && lookup === defaultLookup;
if (isDefaultCase) {
sharedAgent ??= new Agent({ ...agentOptions, connect: { lookup } });
return sharedAgent;
}
return new Agent({ ...agentOptions, connect: { lookup } });
}
/**
* Options for {@link proxyFetch}.
*/
export interface ProxyFetchOptions {
/** The URL to fetch */
input: RequestInfo | URL;
/** Standard fetch RequestInit options */
init?: RequestInit;
/** Optional timeout configuration to override defaults */
timeoutOptions?: AgentTimeoutOptions;
/** Connect-time DNS lookup, e.g. an egress filter's secure lookup */
lookup: LookupFunction;
}
/**
* Make a fetch() request with an Agent/ProxyAgent that has configured timeouts.
* If proxy environment variables are set, uses ProxyAgent; otherwise uses Agent.
*
* @param input - The URL to fetch
* @param init - Standard fetch RequestInit options
* @param timeoutOptions - Optional timeout configuration to override defaults
* @param lookup - Optional connect-time DNS lookup (e.g. an egress filter's secure lookup)
*/
export async function proxyFetch(
input: RequestInfo | URL,
init?: RequestInit,
timeoutOptions?: AgentTimeoutOptions,
lookup?: LookupFunction,
): Promise<Response> {
export async function proxyFetch({
input,
init,
timeoutOptions,
lookup,
}: ProxyFetchOptions): Promise<Response> {
const targetUrl = input instanceof Request ? input.url : input.toString();
const dispatcher = getProxyAgent(targetUrl, timeoutOptions, lookup);

View File

@ -63,6 +63,14 @@ describe('InstanceAiConfirmRequestDto', () => {
'credentialAutoSetup with attempt id',
{ kind: 'credentialAutoSetup', credentialType: 'firecrawlApi', attemptId: 'attempt-1' },
],
[
'credential destination approval',
{
kind: 'credentialDestination',
approved: true,
origin: 'https://api.example.com',
},
],
// DomainAccessApproval: handleAction (primary path — with action)
[
'domainAccessApprove with allow_domain',

View File

@ -1,6 +1,7 @@
import { z } from 'zod';
import {
credentialDestinationDecisionSchema,
domainAccessActionSchema,
instanceAiApprovalResumeSchema,
instanceGatewayResourceDecisionSchema,
@ -49,6 +50,11 @@ const credentialAutoSetupConfirmSchema = z.object({
attemptId: z.string().trim().min(1).max(64).optional(),
});
const credentialDestinationConfirmSchema = credentialDestinationDecisionSchema.extend({
kind: z.literal('credentialDestination'),
approved: z.boolean(),
});
/** Domain-access approval — `domainAccessAction` carries which scope the user picked. */
const domainAccessApproveSchema = z.object({
kind: z.literal('domainAccessApprove'),
@ -106,6 +112,7 @@ export const InstanceAiConfirmRequestDto = z.discriminatedUnion('kind', [
questionsConfirmSchema,
credentialSelectionConfirmSchema,
credentialAutoSetupConfirmSchema,
credentialDestinationConfirmSchema,
domainAccessApproveSchema,
domainAccessDenySchema,
planDenySchema,

View File

@ -306,6 +306,7 @@ export {
export {
buildRunWorkflowSessionGrantKey,
buildUpdateWorkflowSessionGrantKey,
buildCredentialDestinationGrantKey,
buildDataTablesSessionGrantKey,
buildSetupSkipGrantKey,
parseSetupSkipGrants,
@ -369,6 +370,8 @@ export {
instanceAiApprovalResumeSchema,
webSearchMetaSchema,
credentialFlowSchema,
credentialDestinationSchema,
credentialDestinationDecisionSchema,
instanceAiCredentialHandoffContextSchema,
instanceAiAgentPreviewHandoffContextSchema,
instanceAiHandoffContextSchema,
@ -538,6 +541,8 @@ export type {
DomainAccessMeta,
WebSearchMeta,
InstanceAiCredentialFlow,
InstanceAiCredentialDestination,
InstanceAiCredentialDestinationDecision,
InstanceAiCredentialHandoffContext,
InstanceAiAgentPreviewHandoffContext,
InstanceAiHandoffContext,

View File

@ -778,6 +778,39 @@ describe('agent-run-reducer', () => {
});
});
it('confirmation-request passes through credential destination metadata when present', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'workflows'));
reduceEvent(state, {
type: 'confirmation-request',
runId: 'run-1',
agentId: 'root',
payload: {
requestId: 'req-destination',
toolCallId: 'tc-1',
toolName: 'workflows',
args: { action: 'setup' },
severity: 'warning',
message: 'Review where this credential will be used',
credentialDestination: {
origin: 'https://api.example.com',
nodeNames: ['Fetch data'],
},
},
});
const tc = state.toolCallsById['tc-1'];
expect(tc.confirmation).toEqual({
requestId: 'req-destination',
severity: 'warning',
message: 'Review where this credential will be used',
credentialDestination: {
origin: 'https://api.example.com',
nodeNames: ['Fetch data'],
},
});
});
it('confirmation-request passes through projectId when present', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'setup-credentials'));

View File

@ -10,6 +10,7 @@ import {
instanceAiFileAttachmentSchema,
MAX_ATTACHMENT_BASE64_BYTES,
applyBranchReadOnlyOverrides,
buildCredentialDestinationGrantKey,
buildDataTablesSessionGrantKey,
buildUpdateWorkflowSessionGrantKey,
buildSetupSkipGrantKey,
@ -280,6 +281,30 @@ describe('confirmationRequestPayloadSchema', () => {
expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload);
});
it('preserves a credential destination requiring approval', () => {
const payload = makeConfirmation({
credentialDestination: {
origin: 'https://api.example.com',
nodeNames: ['Fetch account'],
},
});
expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload);
});
it('requires a credential destination to be an exact HTTP origin', () => {
const result = confirmationRequestPayloadSchema.safeParse(
makeConfirmation({
credentialDestination: {
origin: 'https://api.example.com/v1',
nodeNames: ['Fetch account'],
},
}),
);
expect(result.success).toBe(false);
});
});
describe('isDisplayableConfirmationRequest', () => {
@ -515,6 +540,14 @@ describe('workflow update session grant keys', () => {
});
});
describe('credential destination session grant keys', () => {
it('encodes the workflow and exact origin', () => {
expect(buildCredentialDestinationGrantKey('workflow:1', 'https://api.example.com:8443')).toBe(
'credential-destination:workflow%3A1:https%3A%2F%2Fapi.example.com%3A8443',
);
});
});
describe('workflow-setup skip keys', () => {
it('round-trips credential types and ignores unrelated keys', () => {
const keys = new Set([

View File

@ -471,29 +471,13 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
if (!isSafeObjectKey(event.payload.toolCallId)) break;
const tc = state.toolCallsById[event.payload.toolCallId];
if (tc) {
tc.confirmation = {
requestId: event.payload.requestId,
inputThreadId: event.payload.inputThreadId,
severity: event.payload.severity,
message: event.payload.message,
targetApproval: event.payload.targetApproval,
credentialRequests: event.payload.credentialRequests,
requireUserSelection: event.payload.requireUserSelection,
projectId: event.payload.projectId,
inputType: event.payload.inputType,
domainAccess: event.payload.domainAccess,
webSearch: event.payload.webSearch,
credentialFlow: event.payload.credentialFlow,
setupRequests: event.payload.setupRequests,
workflowId: event.payload.workflowId,
planItems: event.payload.planItems,
questions: event.payload.questions,
introMessage: event.payload.introMessage,
tasks: event.payload.tasks,
resourceDecision: event.payload.resourceDecision,
channelConfig: event.payload.channelConfig,
mcpConnectRequest: event.payload.mcpConnectRequest,
};
const {
toolCallId: _toolCallId,
toolName: _toolName,
args: _args,
...confirmation
} = event.payload;
tc.confirmation = confirmation;
}
break;
}

View File

@ -64,6 +64,11 @@ export function buildUpdateWorkflowSessionGrantKey(workflowId: string): string {
return `workflows:update:${workflowId}`;
}
/** Builds the thread-level grant for using a credential with one exact service origin. */
export function buildCredentialDestinationGrantKey(workflowId: string, origin: string): string {
return `credential-destination:${encodeURIComponent(workflowId)}:${encodeURIComponent(origin)}`;
}
/**
* Builds the thread-level "always allow" grant key for a data-tables action
* (e.g. `create`, `insert-rows`). Must match the frontend key
@ -413,6 +418,18 @@ export const credentialPlaceholderDefSchema = z.object({
});
export type InstanceAiCredentialPlaceholderDef = z.infer<typeof credentialPlaceholderDefSchema>;
const exactHttpOriginSchema = z
.string()
.max(300)
.refine((value) => {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) && url.origin === value;
} catch {
return false;
}
}, 'Expected an exact HTTP origin');
/**
* Agent-supplied recipe for creating a Templated Custom Auth credential: the
* auth request parts with `{{placeholder}}` markers where user-provided values
@ -439,6 +456,9 @@ export const credentialSetupHintSchema = z.object({
* being set up (never model-supplied). Stamped into the created credential
* so setup surfaces only offer it to nodes calling the same service. */
serviceHost: z.string().optional(),
/** Exact origin of the API the recipe targets, derived server-side from the
* node being set up. Used to bind automatic credential tests to that API. */
serviceOrigin: exactHttpOriginSchema.optional(),
});
export type InstanceAiCredentialSetupHint = z.infer<typeof credentialSetupHintSchema>;
@ -458,6 +478,19 @@ export const credentialFlowSchema = z.object({
});
export type InstanceAiCredentialFlow = z.infer<typeof credentialFlowSchema>;
export const credentialDestinationSchema = z.object({
origin: exactHttpOriginSchema,
nodeNames: z.array(z.string().trim().min(1).max(255)).min(1),
});
export type InstanceAiCredentialDestination = z.infer<typeof credentialDestinationSchema>;
export const credentialDestinationDecisionSchema = credentialDestinationSchema.pick({
origin: true,
});
export type InstanceAiCredentialDestinationDecision = z.infer<
typeof credentialDestinationDecisionSchema
>;
export const workflowSetupNodeSchema = z.object({
node: z.object({
name: z.string(),
@ -719,6 +752,11 @@ export const confirmationRequestPayloadSchema = z.object({
.describe(
'Credential flow stage — finalize renders post-verification credential picker with different copy',
),
credentialDestination: credentialDestinationSchema
.optional()
.describe(
'Exact destination that must be approved before a workflow credential setup card opens',
),
setupRequests: z
.array(workflowSetupNodeSchema)
.optional()
@ -1428,35 +1466,10 @@ export interface InstanceAiSendMessageResponse {
// Frontend store types (shared so both sides agree on structure)
// ---------------------------------------------------------------------------
export interface InstanceAiConfirmation {
requestId: string;
inputThreadId?: string;
severity: InstanceAiConfirmationSeverity;
message: string;
targetApproval?: InstanceAiTargetApproval;
credentialRequests?: InstanceAiCredentialRequest[];
requireUserSelection?: boolean;
projectId?: string;
inputType?: 'approval' | 'text' | 'questions' | 'plan-review' | 'resource-decision' | 'continue';
domainAccess?: DomainAccessMeta;
webSearch?: WebSearchMeta;
credentialFlow?: InstanceAiCredentialFlow;
setupRequests?: InstanceAiWorkflowSetupNode[];
workflowId?: string;
planItems?: PlannedTaskArg[];
questions?: Array<{
id: string;
question: string;
type: 'single' | 'multi' | 'text';
options?: string[];
}>;
introMessage?: string;
tasks?: TaskList;
resourceDecision?: GatewayConfirmationRequiredPayload;
channelConfig?: InstanceAiChannelConfig;
mcpConnectRequest?: InstanceAiMcpConnectRequest;
expired?: boolean;
}
export type InstanceAiConfirmation = Omit<
InstanceAiConfirmationRequestPayload,
'toolCallId' | 'toolName' | 'args'
> & { expired?: boolean };
export interface InstanceAiToolCallState {
toolCallId: string;

View File

@ -2,3 +2,4 @@ export { SsrfProtectionService } from './ssrf-protection.service';
export type { SsrfBridge } from './ssrf-protection.service';
export { SsrfBlockedIpError } from './ssrf-blocked-ip.error';
export { SsrfBlockedHostnameError } from './ssrf-blocked-hostname.error';
export { passthroughEgressFilter } from './passthrough-egress-filter';

View File

@ -0,0 +1,16 @@
import { createResultOk } from '@n8n/utils/result';
import type { NodeEgressFilter } from 'n8n-workflow';
import { lookup } from 'node:dns';
import type { LookupFunction } from 'node:net';
/**
* Passthrough `NodeEgressFilter` used when no egress policy is configured.
* `validateUrl` always resolves ok, and `createSecureLookup` returns the
* plain system DNS lookup, so callers get an unfiltered transport without
* having to special-case a missing filter.
*/
export const passthroughEgressFilter: NodeEgressFilter = {
validateUrl: async () => await Promise.resolve(createResultOk(undefined)),
// Same function reference every call, so callers can compare it by identity.
createSecureLookup: (): LookupFunction => lookup,
};

View File

@ -61,7 +61,7 @@ export class SecurityConfig {
* Separate multiple patterns with semicolons. Default blocks `.git`. Set to empty to disable pattern-based blocking.
*/
@Env('N8N_BLOCK_FILE_PATTERNS')
blockFilePatterns: string = '^(.*\\/)*\\.git(\\/.*)*$';
blockFilePatterns: string = '^(?:[^/]*/)*\\.git(?:/.*)?$';
/**
* In a [security audit](https://docs.n8n.io/hosting/securing/security-audit/), how many days for a workflow to be considered abandoned if not executed.

View File

@ -507,7 +507,7 @@ describe('GlobalConfig', () => {
security: {
restrictFileAccessTo: '~/.n8n-files',
blockFileAccessToN8nFiles: true,
blockFilePatterns: '^(.*\\/)*\\.git(\\/.*)*$',
blockFilePatterns: '^(?:[^/]*/)*\\.git(?:/.*)?$',
daysAbandonedWorkflow: 90,
contentSecurityPolicy: undefined,
contentSecurityPolicyReportOnly: DEFAULT_CONTENT_SECURITY_POLICY,

View File

@ -1,2 +1,5 @@
/** Packages whose node type definitions are generated to disk at build time. */
/**
* Packages whose node type definitions are generated to disk at build time.
* These names are also reserved and cannot be installed as community packages.
*/
export const BUILTIN_NODES_PACKAGES = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain'] as const;

View File

@ -170,6 +170,24 @@ export class SharedWorkflowRepository extends BaseRepository<SharedWorkflow> {
return [...new Set(projectIds)];
}
/**
* Find the IDs of all the projects where a workflow is shared with one of
* the given sharing roles.
*/
async findProjectIdsByRole(workflowId: string, roles: string[]) {
const rows = await this.find({
where: { workflowId, role: In(roles) },
select: ['projectId'],
});
const projectIds = rows.reduce<string[]>((acc, row) => {
if (row.projectId) acc.push(row.projectId);
return acc;
}, []);
return [...new Set(projectIds)];
}
/**
* Pass `ctx` when calling from inside a transaction — the read then runs on that
* transaction's connection instead of checking out a second one, which would

View File

@ -226,6 +226,35 @@ export class UserRepository extends Repository<User> {
});
}
/**
* IDs of enabled users who either hold one of `globalRoleSlugs` globally,
* or hold one of `projectRoleSlugs` in one of `projectIds`.
*/
async findIdsWithGlobalOrProjectRoles({
projectIds,
projectRoleSlugs,
globalRoleSlugs,
}: {
projectIds: string[];
projectRoleSlugs: string[];
globalRoleSlugs: string[];
}): Promise<string[]> {
const where: Array<FindOptionsWhere<User>> = [];
if (globalRoleSlugs.length > 0) {
where.push({ disabled: false, role: { slug: In(globalRoleSlugs) } });
}
if (projectIds.length > 0 && projectRoleSlugs.length > 0) {
where.push({
disabled: false,
projectRelations: { projectId: In(projectIds), role: { slug: In(projectRoleSlugs) } },
});
}
if (where.length === 0) return [];
const users = await this.find({ where, select: ['id'] });
return [...new Set(users.map(({ id }) => id))];
}
/**
* Find the user that owns the personal project that owns the workflow.
*

View File

@ -146,6 +146,22 @@ describe(`Integration: ExpressionEvaluator (${engineName})`, () => {
expect(result).toBe(150);
});
it('should render the timezone independent of the global JSON.stringify', async () => {
const data = { $json: { x: 'ok' } };
const original = JSON.stringify;
let result: unknown;
try {
JSON.stringify = (() => 'REPLACED_STRINGIFY_OUTPUT') as unknown as typeof JSON.stringify;
result = evaluator.evaluate('{{ $json.x }}', data, caller, {
timezone: 'America/New_York',
});
} finally {
JSON.stringify = original;
}
expect(result).toBe('ok');
});
it('should use provided timezone for DateTime operations', async () => {
const data = {
$json: { ts: 1704067200000 }, // 2024-01-01T00:00:00Z

View File

@ -22,6 +22,10 @@ function getIvm(): IsolatedVm {
const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js');
// Captured at module load so values rendered into generated code stay stable
// even if the global is later replaced.
const safeStringify = JSON.stringify;
/** Check if a value is an error sentinel returned by serializeError. */
function isErrorSentinel(value: unknown): value is ErrorSentinel {
return (
@ -749,7 +753,7 @@ export class IsolatedVmBridge implements RuntimeBridge {
const callHost = this.createCallHostRef(data);
try {
const timezone = options?.timezone ? JSON.stringify(options.timezone) : 'undefined';
const timezone = options?.timezone ? safeStringify(options.timezone) : 'undefined';
// Wrap transformed code so 'this' === the closure-scoped context.
// Tournament generates: this.$json.email, this.$items(), etc.

View File

@ -21,6 +21,10 @@ async function getQuickJSModule(): Promise<QuickJSModule> {
const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js');
// Captured at module load so values rendered into generated code stay stable
// even if the global is later replaced.
const safeStringify = JSON.stringify;
// ============================================================================
// Sentinel helpers
//
@ -265,7 +269,7 @@ function hostValueToJson(value: unknown): string {
if (value === undefined) return 'undefined';
if (value === null) return 'null';
try {
return JSON.stringify(wrapSpecialValuesForGuest(value));
return safeStringify(wrapSpecialValuesForGuest(value));
} catch {
return 'undefined';
}
@ -1156,7 +1160,7 @@ export class QuickJsBridge implements RuntimeBridge {
// nested call can't leave the outer budget extended.
this.deadlines.push(Date.now() + timeout);
try {
const timezone = options?.timezone ? JSON.stringify(options.timezone) : 'undefined';
const timezone = options?.timezone ? safeStringify(options.timezone) : 'undefined';
// The callback impls arrive as function arguments (scoped to this call),
// never as globals — see createCallbackHandles(). The runtime calls them

View File

@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import { arrayExtensions } from '../array-extensions';
const smartJoin = arrayExtensions.functions.smartJoin as (
value: unknown[],
extraArgs: string[],
) => Record<string, unknown>;
const renameKeys = arrayExtensions.functions.renameKeys as (
value: unknown[],
extraArgs: string[],
) => Array<Record<string, unknown>>;
const unique = arrayExtensions.functions.unique as (
value: unknown[],
extraArgs: string[],
) => unknown[];
const merge = arrayExtensions.functions.merge as (
value: unknown[],
extraArgs: unknown[][],
) => Record<string, unknown>;
const mergeIntoObject = arrayExtensions.functions.mergeIntoObject as (
value: unknown[],
extraArgs: unknown[][],
) => Record<string, unknown>;
describe('smartJoin', () => {
it('should join own key and value fields', () => {
expect(
smartJoin(
[
{ field: 'age', value: 2 },
{ field: 'city', value: 'Berlin' },
],
['field', 'value'],
),
).toEqual({ age: 2, city: 'Berlin' });
});
it('should ignore inherited key and value fields', () => {
const source = Object.create({ field: 'age', value: 2 }) as object;
expect(smartJoin([source], ['field', 'value'])).toEqual({});
});
it('should read key and value fields served by a proxy without a getOwnPropertyDescriptor trap', () => {
const fields: Record<string, unknown> = { field: 'age', value: 2 };
const source = new Proxy(
{},
{
has: (_target, key) => typeof key === 'string' && Object.hasOwn(fields, key),
get: (_target, key) => (typeof key === 'string' ? fields[key] : undefined),
},
);
expect(smartJoin([source], ['field', 'value'])).toEqual({ age: 2 });
});
it('should treat a key named __proto__ as an ordinary field', () => {
const result = smartJoin(
[{ field: '__proto__', value: { marker: 'set' } }],
['field', 'value'],
);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true);
expect(({} as Record<string, unknown>).marker).toBeUndefined();
});
});
describe('renameKeys', () => {
it('should rename nothing when the source name is not an own field', () => {
const [result] = renameKeys([{ name: 'test' }], ['toString', 'renamed']);
expect(result).toEqual({ name: 'test' });
expect(Object.prototype.hasOwnProperty.call(result, 'renamed')).toBe(false);
});
it('should treat a target name of __proto__ as an ordinary field', () => {
const [result] = renameKeys([{ name: { marker: 'set' } }], ['name', '__proto__']);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true);
expect(({} as Record<string, unknown>).marker).toBeUndefined();
});
});
describe('unique', () => {
it('should keep an element that does not own the compared field', () => {
const inherited = Object.create({ name: 'Nathan' }) as object;
expect(unique([{ name: 'Nathan' }, inherited], ['name'])).toHaveLength(2);
});
it('should read a compared field served by a proxy without a getOwnPropertyDescriptor trap', () => {
const proxyFor = (name: string) => {
const fields: Record<string, unknown> = { name };
return new Proxy(
{},
{
has: (_target, key) => typeof key === 'string' && Object.hasOwn(fields, key),
get: (_target, key) => (typeof key === 'string' ? fields[key] : undefined),
},
);
};
expect(unique([proxyFor('Nathan'), proxyFor('Jan')], ['name'])).toHaveLength(2);
});
});
describe.each([
['merge', merge],
['mergeIntoObject', mergeIntoObject],
])('%s', (_name, mergeFn) => {
it('should merge an incoming field named toString', () => {
const result = mergeFn([{ a: 1 }], [[{ toString: 'value' }]]);
expect(Object.prototype.hasOwnProperty.call(result, 'toString')).toBe(true);
expect(result.toString).toBe('value');
});
it('should treat an own __proto__ field of a source object as an ordinary field', () => {
const source = JSON.parse('{"__proto__": {"marker": "set"}}') as Record<string, unknown>;
const result = mergeFn([source], [[{ a: 1 }]]);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true);
expect(({} as Record<string, unknown>).marker).toBeUndefined();
});
});

View File

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { extendedFunctions } from '../function-extensions';
const zip = extendedFunctions.zip;
describe('zip', () => {
it('should pair keys with values', () => {
expect(zip(['test1', 'test2', 'test3'], [1, 2, 3])).toEqual({
test1: 1,
test2: 2,
test3: 3,
});
});
it('should treat a key named __proto__ as an ordinary field', () => {
const result = zip(['__proto__'], [{ marker: 'set' }]) as Record<string, unknown>;
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true);
expect(({} as Record<string, unknown>).marker).toBeUndefined();
});
});

View File

@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { objectExtensions } from '../object-extensions';
const compact = objectExtensions.functions.compact as (value: object) => object;
const hasField = objectExtensions.functions.hasField as (
value: object,
extraArgs: string[],
) => boolean;
describe('hasField', () => {
it('should return true for a key served through has/get traps without an own property descriptor', () => {
const target = { test1: 1 };
const proxy = new Proxy(target, {
has(_target, key) {
return key === 'test2';
},
get(_target, key) {
return key === 'test2' ? 'proxied value' : undefined;
},
});
expect(hasField(proxy, ['test2'])).toBe(true);
});
});
describe('compact', () => {
it('should remove empty values', () => {
expect(compact({ test1: 1, test2: '2', test3: undefined, test4: null })).toEqual({
test1: 1,
test2: '2',
});
});
it('should keep an own __proto__ field as an ordinary field', () => {
const value = JSON.parse('{"test1": 1, "__proto__": {"marker": "set"}}') as object;
const result = compact(value);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true);
expect(({} as Record<string, unknown>).marker).toBeUndefined();
});
});

View File

@ -14,6 +14,23 @@ function randomInt(max: number): number {
return random(0, max - 1);
}
// Define an own data field rather than assigning through an inherited setter.
function defineField(target: Record<string, unknown>, key: PropertyKey, value: unknown): void {
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}
// Report whether a field resolves on the object itself and not through its prototype chain.
function hasReadableField(value: object, field: string): boolean {
if (Object.hasOwn(value, field)) return true;
const proto = Object.getPrototypeOf(value) as object | null;
return field in value && (proto === null || !(field in proto));
}
function first(value: unknown[]): unknown {
return value[0];
}
@ -75,7 +92,8 @@ function unique(value: unknown[], extraArgs: string[]): unknown[] {
const mapForEqualityCheck = (item: unknown): unknown => {
if (extraArgs.length > 0 && item && typeof item === 'object') {
return extraArgs.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = (item as Record<string, unknown>)[key];
const source = item as Record<string, unknown>;
defineField(acc, key, hasReadableField(source, key) ? source[key] : undefined);
return acc;
}, {});
}
@ -169,13 +187,16 @@ function smartJoin(value: unknown[], extraArgs: string[]): object {
'smartJoin(): expected two string args, e.g. .smartJoin("name", "value")',
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
return value.reduce<any>((o, v) => {
if (typeof v === 'object' && v !== null && keyField in v && valueField in v) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
o[(v as any)[keyField]] = (v as any)[valueField];
return value.reduce<Record<string, unknown>>((o, v) => {
if (
typeof v === 'object' &&
v !== null &&
hasReadableField(v, keyField) &&
hasReadableField(v, valueField)
) {
const entry = v as Record<string, unknown>;
defineField(o, entry[keyField] as PropertyKey, entry[valueField]);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return o;
}, {});
}
@ -202,18 +223,14 @@ function renameKeys(value: unknown[], extraArgs: string[]): unknown[] {
if (typeof v !== 'object' || v === null) {
return v;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
const newObj = { ...(v as any) };
const newObj: Record<string, unknown> = { ...(v as Record<string, unknown>) };
const chunkedArgs = chunk(extraArgs, [2]) as string[][];
chunkedArgs.forEach(([from, to]) => {
if (from in newObj) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
newObj[to] = newObj[from];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (Object.hasOwn(newObj, from)) {
defineField(newObj, to, newObj[from]);
delete newObj[from];
}
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return newObj;
});
}
@ -231,8 +248,8 @@ function mergeObjects(value: Record<string, unknown>, extraArgs: unknown[]): unk
const newObject = { ...value };
for (const [key, val] of Object.entries(other)) {
if (!(key in newObject)) {
newObject[key] = val;
if (!Object.hasOwn(newObject, key)) {
defineField(newObject, key, val);
}
}
return newObject;
@ -258,14 +275,17 @@ function merge(value: unknown[], extraArgs: unknown[][]): unknown {
);
}
const listLength = value.length > others.length ? value.length : others.length;
let merged = {};
const merged: Record<string, unknown> = {};
for (let i = 0; i < listLength; i++) {
if (value[i] !== undefined) {
if (typeof value[i] === 'object' && typeof others[i] === 'object') {
merged = Object.assign(
merged,
mergeObjects(value[i] as Record<string, unknown>, [others[i]]),
);
const pair = mergeObjects(value[i] as Record<string, unknown>, [others[i]]) as Record<
string,
unknown
>;
for (const [key, val] of Object.entries(pair)) {
defineField(merged, key, val);
}
}
}
}
@ -281,17 +301,23 @@ function mergeIntoObject(value: unknown[], extraArgs: unknown[][]): unknown {
);
}
const listLength = value.length > others.length ? value.length : others.length;
let merged = {};
const merged: Record<string, unknown> = {};
for (let i = 0; i < listLength; i++) {
const baseIsObject = value[i] !== null && typeof value[i] === 'object';
const otherIsObject = others[i] !== null && typeof others[i] === 'object';
let source: Record<string, unknown> | undefined;
if (baseIsObject) {
merged = Object.assign(
merged,
mergeObjects(value[i] as Record<string, unknown>, otherIsObject ? [others[i]] : []),
);
source = mergeObjects(
value[i] as Record<string, unknown>,
otherIsObject ? [others[i]] : [],
) as Record<string, unknown>;
} else if (otherIsObject) {
merged = Object.assign(merged, others[i] as Record<string, unknown>);
source = others[i] as Record<string, unknown>;
}
if (source) {
for (const [key, val] of Object.entries(source)) {
defineField(merged, key, val);
}
}
}
return merged;

View File

@ -1,6 +1,16 @@
import { average as aAverage } from './array-extensions';
import { ExpressionExtensionError } from './expression-extension-error';
// Define an own data field rather than assigning through an inherited setter.
function defineField(target: Record<string, unknown>, key: PropertyKey, value: unknown): void {
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}
const min = Math.min;
const max = Math.max;
@ -29,7 +39,7 @@ const zip = (keys: unknown[], values: unknown[]): unknown => {
}
const result: Record<string, unknown> = {};
for (let i = 0; i < keys.length; i++) {
result[keys[i] as string] = values[i];
defineField(result, keys[i] as PropertyKey, values[i]);
}
return result;
};

View File

@ -1,6 +1,16 @@
import type { ExtensionMap } from './extensions';
import { ExpressionExtensionError } from './expression-extension-error';
// Define an own data field rather than assigning through an inherited setter.
function defineField(target: Record<string, unknown>, key: PropertyKey, value: unknown): void {
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
}
function isEmpty(value: object): boolean {
return Object.keys(value).length === 0;
}
@ -66,21 +76,17 @@ function keepFieldsContaining(value: object, extraArgs: string[]): object {
}
export function compact(value: object): object {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newObj: any = {};
for (const [key, val] of Object.entries(value)) {
const newObj: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value) as Array<[string, unknown]>) {
if (val !== null && val !== undefined && val !== 'nil' && val !== '') {
if (typeof val === 'object') {
if (Object.keys(val as object).length === 0) continue;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
newObj[key] = compact(val);
if (Object.keys(val).length === 0) continue;
defineField(newObj, key, compact(val));
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
newObj[key] = val;
defineField(newObj, key, val);
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return newObj;
}

View File

@ -25,6 +25,7 @@ const baseHint: InstanceAiCredentialSetupHint = {
{ name: 'workspace_id', title: 'Workspace ID', type: 'plain', optional: true },
],
serviceHost: 'api.example.com',
serviceOrigin: 'https://api.example.com',
docsUrl: 'https://example.com/docs',
testUrl: 'https://api.example.com/me',
acceptedStatusCodes: [401],
@ -71,6 +72,7 @@ describe('createOneCredential — httpTemplatedCustomAuth', () => {
expect(Object.keys(parsedValues)).toHaveLength(baseHint.placeholders.length);
expect(data.serviceHost).toBe(baseHint.serviceHost);
expect(data.serviceOrigin).toBe(baseHint.serviceOrigin);
expect(typeof data.acceptedStatusCodes).toBe('string');
expect(jsonParse(data.acceptedStatusCodes as string)).toEqual(baseHint.acceptedStatusCodes);

View File

@ -223,6 +223,7 @@ async function createTemplatedCustomAuthCredential(
placeholderDefs: JSON.stringify(hint.placeholders),
placeholderValues: JSON.stringify(placeholderValues),
serviceHost: hint.serviceHost ?? '',
serviceOrigin: hint.serviceOrigin ?? '',
docsUrl: hint.docsUrl ?? '',
testUrl: hint.testUrl ?? '',
acceptedStatusCodes: hint.acceptedStatusCodes ? JSON.stringify(hint.acceptedStatusCodes) : '',

View File

@ -129,6 +129,15 @@ describe('confirmation payload → tool resume schema contract', () => {
request: { kind: 'credentialAutoSetup', credentialType: 'firecrawlApi', attemptId: 'a-1' },
targets: [['credentials', credentialsResumeSchema]],
},
{
label: 'credential destination approval',
request: {
kind: 'credentialDestination',
approved: true,
origin: 'https://api.example.com',
},
targets: [['workflows (setup wizard)', workflowsResumeSchema]],
},
{
label: 'domain access approval',
request: { kind: 'domainAccessApprove', domainAccessAction: 'allow_domain' },
@ -196,6 +205,7 @@ describe('confirmation payload → tool resume schema contract', () => {
questions: true,
credentialSelection: true,
credentialAutoSetup: true,
credentialDestination: true,
domainAccessApprove: true,
domainAccessDeny: true,
planDeny: true,

View File

@ -44,6 +44,11 @@ export function toConfirmationData(request: InstanceAiConfirmRequest): Confirmat
approved: true,
autoSetup: { credentialType: request.credentialType, attemptId: request.attemptId },
};
case 'credentialDestination':
return {
approved: request.approved,
credentialDestination: { origin: request.origin },
};
case 'resourceDecision':
return { approved: true, resourceDecision: request.resourceDecision };
case 'mcpConnect':
@ -88,6 +93,7 @@ export function buildResumeData(data: ConfirmationData): Record<string, unknown>
...(data.resourceDecision ? { resourceDecision: data.resourceDecision } : {}),
...(data.scope ? { scope: data.scope } : {}),
...(data.autoSetup ? { autoSetup: data.autoSetup } : {}),
...(data.credentialDestination ? { credentialDestination: data.credentialDestination } : {}),
...(data.denied ? { denied: true } : {}),
...(data.connectedSlugs ? { connectedSlugs: data.connectedSlugs } : {}),
};

View File

@ -1,4 +1,7 @@
import type { InstanceAiThreadStatusResponse } from '@n8n/api-types';
import type {
InstanceAiCredentialDestinationDecision,
InstanceAiThreadStatusResponse,
} from '@n8n/api-types';
import { nanoid } from 'nanoid';
import type { InstanceAiTraceContext, ModelConfig, OrchestrationContext } from '../types';
@ -84,6 +87,7 @@ export interface ConfirmationData {
* persist a thread-level grant so the same action isn't re-asked. */
scope?: 'once' | 'session';
autoSetup?: { credentialType: string; attemptId?: string };
credentialDestination?: InstanceAiCredentialDestinationDecision;
connectedSlugs?: string[];
}

View File

@ -335,6 +335,10 @@ describe('mapAgentChunkToEvent', () => {
],
domainAccess: { url: 'https://example.com/api', host: 'example.com' },
credentialFlow: { stage: 'generic' },
credentialDestination: {
origin: 'https://api.example.com',
nodeNames: ['Fetch account'],
},
setupRequests: [validSetupNode],
workflowId: 'wf-1',
resourceDecision: {
@ -369,6 +373,10 @@ describe('mapAgentChunkToEvent', () => {
inputType: 'plan-review',
domainAccess: { url: 'https://example.com/api', host: 'example.com' },
credentialFlow: { stage: 'generic' },
credentialDestination: {
origin: 'https://api.example.com',
nodeNames: ['Fetch account'],
},
setupRequests: [validSetupNode],
workflowId: 'wf-1',
questions: [
@ -432,6 +440,10 @@ describe('mapAgentChunkToEvent', () => {
domainAccess: { url: 'https://example.com' },
webSearch: { invalid: true },
credentialFlow: { stage: 'unknown' },
credentialDestination: {
origin: 'https://api.example.com/path',
nodeNames: ['Fetch account'],
},
setupRequests: [{ invalid: true }],
workflowId: 42,
},

View File

@ -8,6 +8,7 @@ import {
webSearchMetaSchema,
channelConfigSchema,
mcpConnectRequestSchema,
credentialDestinationSchema,
} from '@n8n/api-types';
import type { InstanceAiEvent } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
@ -339,6 +340,10 @@ function mapSuspendedChunk(
const domainAccess = parseDomainAccess(suspendPayload.domainAccess);
const webSearch = parseSchemaRecord(suspendPayload.webSearch, webSearchMetaSchema);
const credentialFlow = parseCredentialFlow(suspendPayload.credentialFlow);
const credentialDestination = parseSchemaRecord(
suspendPayload.credentialDestination,
credentialDestinationSchema,
);
const setupRequests = parseSchemaArray(suspendPayload.setupRequests, workflowSetupNodeSchema);
const workflowId = presentString(suspendPayload.workflowId);
const resourceDecision = parseSchemaRecord(
@ -384,6 +389,7 @@ function mapSuspendedChunk(
...(domainAccess ? { domainAccess } : {}),
...(webSearch ? { webSearch } : {}),
...(credentialFlow ? { credentialFlow } : {}),
...(credentialDestination ? { credentialDestination } : {}),
...(setupRequests ? { setupRequests } : {}),
...(workflowId ? { workflowId } : {}),
...(questions ? { questions } : {}),

View File

@ -943,7 +943,7 @@ describe('credentials tool', () => {
);
});
it('should include setupHint in credentialRequests when provided', async () => {
it('should omit automatic test destinations from standalone setup', async () => {
const context = createMockContext();
(context.credentialService.list as Mock).mockResolvedValue([]);
@ -981,12 +981,17 @@ describe('credentials tool', () => {
);
expect(suspendFn).toHaveBeenCalledTimes(1);
// The service identity is stamped from the recipe's test endpoint —
// this card has no node context to derive it from.
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
credentialRequests: [
expect.objectContaining({ setupHint: { ...setupHint, serviceHost: 'fal.run' } }),
expect.objectContaining({
setupHint: {
template: setupHint.template,
placeholders: setupHint.placeholders,
docsUrl: setupHint.docsUrl,
suggestedName: setupHint.suggestedName,
},
}),
],
}),
);

View File

@ -1,4 +1,9 @@
import type { InstanceAiPermissions } from '@n8n/api-types';
import { zodToJsonSchema } from '@n8n/agents';
import {
buildCredentialDestinationGrantKey,
type InstanceAiCredentialSetupHint,
type InstanceAiPermissions,
} from '@n8n/api-types';
import { generateWorkflowCode } from '@n8n/workflow-sdk';
import type { Mock } from 'vitest';
@ -17,7 +22,7 @@ import {
refreshWorkflowSourceFileBindingFromSave,
saveWorkflowSourceFileBinding,
} from '../workflows/workflow-file-bindings';
import { createWorkflowsTool, type WorkflowAction } from '../workflows.tool';
import { createWorkflowsTool, type WorkflowAction, workflowsResumeSchema } from '../workflows.tool';
// Mock the setup-workflow.service module to avoid pulling in heavy dependencies
vi.mock('../workflows/setup-workflow.service', () => ({
@ -40,6 +45,45 @@ vi.mock('@n8n/workflow-sdk', async (importOriginal) => {
const emptyList = { workflows: [], total: 0, totalInScope: 0 };
function templatedSetupFixture(
options: {
nodeUrl?: string;
testUrl?: string;
serviceOrigin?: string | null;
} = {},
) {
const nodeUrl = options.nodeUrl ?? 'https://api.example.com/v1/account';
const serviceOrigin =
options.serviceOrigin === undefined ? 'https://api.example.com' : options.serviceOrigin;
const recipe: InstanceAiCredentialSetupHint = {
template: { headers: { Authorization: 'Bearer {{api_key}}' } },
placeholders: [{ name: 'api_key', title: 'API key' }],
...(options.testUrl ? { testUrl: options.testUrl } : {}),
};
const request = {
node: {
name: 'Fetch account',
type: 'n8n-nodes-base.httpRequest',
parameters: { url: nodeUrl },
},
credentialType: 'httpTemplatedCustomAuth',
needsAction: true,
setupHint: {
...recipe,
...(serviceOrigin ? { serviceHost: new URL(serviceOrigin).hostname, serviceOrigin } : {}),
},
};
return {
recipe,
request,
input: {
action: 'setup' as const,
workflowId: 'wf1',
credentialHints: [{ ...recipe, nodeName: request.node.name }],
},
};
}
function createMockContext(
overrides: Partial<Omit<InstanceAiContext, 'permissions'>> & {
permissions?: Partial<InstanceAiPermissions>;
@ -125,6 +169,10 @@ describe('workflows tool', () => {
vi.clearAllMocks();
});
it('exports the resume schema without unsupported URI formats', () => {
expect(JSON.stringify(zodToJsonSchema(workflowsResumeSchema))).not.toContain('"format":"uri"');
});
describe('surface filtering', () => {
const builderWorkflowActions = [
'list',
@ -2270,6 +2318,155 @@ describe('workflows tool', () => {
expect(suspend).toHaveBeenCalled();
});
it('should accept a credential test URL on the workflow service origin', async () => {
const fixture = templatedSetupFixture({ testUrl: 'https://api.example.com/me' });
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request]);
const suspend = vi.fn();
const tool = createWorkflowsTool(createMockContext(), 'full');
await executeTool(tool, fixture.input, { suspend, resumeData: undefined } as never);
expect(suspend).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'wf1',
credentialDestination: {
origin: 'https://api.example.com',
nodeNames: ['Fetch account'],
},
}),
);
});
it('should remember an approved credential destination and open setup', async () => {
const first = templatedSetupFixture({ testUrl: 'https://api.example.com/me' });
const second = templatedSetupFixture({ testUrl: 'https://api.example.com/me' });
(analyzeWorkflow as Mock)
.mockResolvedValueOnce([first.request])
.mockResolvedValueOnce([second.request]);
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
const context = createMockContext({ grantSessionToolApproval });
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, first.input, { suspend, resumeData: undefined } as never);
suspend.mockClear();
await executeTool(tool, first.input, {
suspend,
resumeData: {
approved: true,
credentialDestination: { origin: 'https://api.example.com' },
},
} as never);
expect(grantSessionToolApproval).toHaveBeenCalledWith(
buildCredentialDestinationGrantKey('wf1', 'https://api.example.com'),
);
expect(suspend).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Configure credentials for your workflow',
setupRequests: [second.request],
}),
);
});
it('should reuse approval only for the same workflow credential destination', async () => {
const fixture = templatedSetupFixture({ testUrl: 'https://api.example.com/me' });
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request]);
const context = createMockContext({
sessionApprovedToolKeys: new Set([
buildCredentialDestinationGrantKey('wf1', 'https://api.example.com'),
]),
});
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, fixture.input, { suspend, resumeData: undefined } as never);
expect(suspend).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Configure credentials for your workflow',
setupRequests: [fixture.request],
}),
);
});
it('should require review again when the credential destination changes', async () => {
const fixture = templatedSetupFixture({
nodeUrl: 'https://api-v2.example.com/v1/account',
testUrl: 'https://api-v2.example.com/me',
serviceOrigin: 'https://api-v2.example.com',
});
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request]);
const grantSessionToolApproval = vi.fn();
const suspend = vi.fn();
const tool = createWorkflowsTool(createMockContext({ grantSessionToolApproval }), 'full');
const result = await executeTool(tool, fixture.input, {
suspend,
resumeData: {
approved: true,
credentialDestination: { origin: 'https://api.example.com' },
},
} as never);
expect(result).toMatchObject({ error: 'credential_destination_changed' });
expect(grantSessionToolApproval).not.toHaveBeenCalled();
expect(suspend).not.toHaveBeenCalled();
});
it('should stop setup when the credential destination is declined', async () => {
const tool = createWorkflowsTool(createMockContext(), 'full');
const result = await executeTool(tool, { action: 'setup', workflowId: 'wf1' }, {
resumeData: {
approved: false,
credentialDestination: { origin: 'https://api.example.com' },
},
} as never);
expect(result).toMatchObject({
success: false,
denied: true,
reason: 'User did not approve credential use with https://api.example.com.',
});
expect(analyzeWorkflow).not.toHaveBeenCalled();
});
it('should reject a credential test URL on a different service origin', async () => {
const fixture = templatedSetupFixture({ testUrl: 'https://status.example.net/me' });
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request]);
const suspend = vi.fn();
const tool = createWorkflowsTool(createMockContext(), 'full');
const result = await executeTool(tool, fixture.input, {
suspend,
resumeData: undefined,
} as never);
expect(result).toMatchObject({ error: 'invalid_credential_hints' });
expect(suspend).not.toHaveBeenCalled();
});
it('should reject a templated setup when the workflow service origin is unavailable', async () => {
const fixture = templatedSetupFixture({
nodeUrl: '={{ $json.url }}',
serviceOrigin: null,
});
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request]);
const suspend = vi.fn();
const tool = createWorkflowsTool(createMockContext(), 'full');
const result = await executeTool(tool, fixture.input, {
suspend,
resumeData: undefined,
} as never);
expect(result).toMatchObject({
error: 'invalid_credential_hints',
problems: [expect.stringContaining('no statically derivable HTTP origin')],
});
expect(suspend).not.toHaveBeenCalled();
});
it('should allow a plain generic type when credentials of it already exist', async () => {
(analyzeWorkflow as Mock).mockResolvedValue([
{
@ -2744,6 +2941,62 @@ describe('workflows tool', () => {
expect(suspend.mock.calls[0][0]).toMatchObject({ setupRequests: [sheetsRequest] });
});
it('revalidates the credential test destination after a trigger test', async () => {
const fixture = templatedSetupFixture({ testUrl: 'https://status.example.net/me' });
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request]);
(applyNodeChanges as Mock).mockResolvedValue({ applied: [], failed: [] });
const context = createGrantAwareContext();
(context.executionService.run as Mock).mockResolvedValue({ status: 'success' });
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(tool, fixture.input, {
suspend,
resumeData: {
approved: true,
action: 'test-trigger',
testTriggerNode: 'Fetch account',
},
} as never);
expect(result).toMatchObject({ error: 'invalid_credential_hints' });
expect(suspend).not.toHaveBeenCalled();
});
it('checks refreshed credential test destinations against skipped node URLs', async () => {
const fixture = templatedSetupFixture({
testUrl: 'https://api.example.com/v1/action',
});
const skippedRequest = {
node: {
name: 'Run action',
type: 'n8n-nodes-base.httpRequest',
parameters: { url: fixture.recipe.testUrl },
},
credentialType: 'httpHeaderAuth',
needsAction: true,
credentialNeedsAction: true,
};
(analyzeWorkflow as Mock).mockResolvedValue([fixture.request, skippedRequest]);
(applyNodeChanges as Mock).mockResolvedValue({ applied: [], failed: [] });
const context = createGrantAwareContext(['workflows:setup-skip:cred:httpHeaderAuth']);
(context.executionService.run as Mock).mockResolvedValue({ status: 'success' });
const suspend = vi.fn();
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(tool, fixture.input, {
suspend,
resumeData: {
approved: true,
action: 'test-trigger',
testTriggerNode: 'Fetch account',
},
} as never);
expect(result).toMatchObject({ error: 'invalid_credential_hints' });
expect(suspend).not.toHaveBeenCalled();
});
it('keeps another node Slack skip when only a parameter was completed', async () => {
// "Alert on Slack" was connected already and only needed a channel. Clearing the
// type-wide record here would re-open the card "Post to Slack" was skipped on.

View File

@ -20,7 +20,6 @@ import {
} from './nodes/preferred-chat-model';
import { CREDENTIALS_TOOL_ID } from './tool-ids';
import {
extractServiceHost,
GENERIC_AUTH_CREDENTIAL_TYPES,
N8N_CONNECT_DISPLAY_NAME,
} from './workflows/credential-utils';
@ -136,6 +135,31 @@ function normalizeUrlForComparison(raw: unknown): string | undefined {
}
}
function extractHttpOrigin(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined;
try {
const url = new URL(raw);
return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : undefined;
} catch {
return undefined;
}
}
export function findSetupHintTestUrlOriginProblem(
hint: InstanceAiCredentialSetupHint,
serviceOrigin: string,
): string | undefined {
if (hint.testUrl === undefined) return undefined;
const testOrigin = extractHttpOrigin(hint.testUrl);
if (!testOrigin) {
return `testUrl "${hint.testUrl}" is not an absolute HTTP URL — omit testUrl if no documented read-only endpoint is available`;
}
if (testOrigin !== serviceOrigin) {
return `testUrl origin "${testOrigin}" does not match the workflow service origin "${serviceOrigin}" — use a documented read-only endpoint on the workflow service or omit testUrl`;
}
return undefined;
}
/**
* Collect recipe problems so the model corrects them instead of the card
* silently degrading. `nodeUrls` additionally rejects a testUrl pointing at
@ -345,6 +369,8 @@ const searchTypesAction = z.object({
),
});
const standaloneSetupHintField = setupHintField.omit({ testUrl: true });
const setupAction = z.object({
action: z
.literal('setup')
@ -372,7 +398,7 @@ const setupAction = z.object({
.describe(
'Set when the user explicitly asked to create a new credential of this type ("create a new Slack credential"), or needs to enter a replacement for one whose secret is invalid or rotated (e.g. pasted a new token in chat, which you cannot store). The card then opens with nothing preselected instead of offering the most recent existing credential — existing ones stay listed in case the user changes their mind.',
),
setupHint: setupHintField.optional(),
setupHint: standaloneSetupHintField.optional(),
}),
)
.describe('List of credentials to set up'),
@ -766,18 +792,13 @@ async function handleSetup(
type: req.credentialType,
...(context.projectId ? { projectId: context.projectId } : {}),
});
// Service identity comes from the recipe's test endpoint here; an
// untagged credential is never offered automatically later.
const serviceHost = req.setupHint ? extractServiceHost(req.setupHint.testUrl) : undefined;
return {
credentialType: req.credentialType,
reason: req.reason ?? `Required for ${req.credentialType}`,
existingCredentials: existing.map((c) => ({ id: c.id, name: c.name })),
...(req.suggestedName ? { suggestedName: req.suggestedName } : {}),
...(req.preferNew ? { preferNew: true } : {}),
...(req.setupHint
? { setupHint: { ...req.setupHint, ...(serviceHost ? { serviceHost } : {}) } }
: {}),
...(req.setupHint ? { setupHint: req.setupHint } : {}),
};
},
),

View File

@ -4,6 +4,11 @@
* update-version.
*/
import { Tool } from '@n8n/agents';
import {
buildCredentialDestinationGrantKey,
credentialDestinationSchema,
TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
} from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import { dropInvalidWorkflowJsonGroups, type WorkflowJSON } from '@n8n/workflow-sdk';
import { makeGetNodeTypeForGrouping } from 'n8n-workflow';
@ -15,6 +20,7 @@ import { WorkflowSaveConflictError } from '../errors/workflow-save-conflict.erro
import type { InstanceAiContext } from '../types';
import {
findSetupHintProblems,
findSetupHintTestUrlOriginProblem,
INVALID_SETUP_HINT_MESSAGE,
setupHintField,
TEMPLATABLE_PLAIN_AUTH_TYPES,
@ -283,7 +289,8 @@ const confirmationSuspendSchema = setupSuspendSchema
severity: true,
workflowId: true,
})
.partial({ workflowId: true });
.partial({ workflowId: true })
.extend({ credentialDestination: credentialDestinationSchema.optional() });
const suspendSchema = z.union([setupSuspendSchema, confirmationSuspendSchema]);
@ -787,6 +794,90 @@ function appliedCredentialIdList(
.flatMap(([, byType]) => Object.values(byType));
}
interface RequiredCredentialDestination {
origin: string;
nodeNames: string[];
grantKey: string;
}
function inspectCredentialDestinations(
workflowId: string,
requests: readonly SetupRequest[],
requestsForNodeUrls: readonly SetupRequest[] = requests,
): { problems: string[]; destinations: RequiredCredentialDestination[] } {
const problems: string[] = [];
const byGrantKey = new Map<string, RequiredCredentialDestination>();
const nodeUrls = requestsForNodeUrls.map((request) => request.node.parameters?.url);
for (const request of requests) {
if (
request.credentialType !== TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE ||
request.needsAction === false ||
!request.setupHint
) {
continue;
}
const origin = request.setupHint.serviceOrigin;
if (!origin) {
problems.push(
`${request.node.name}: credential destination cannot be verified because the workflow node has no statically derivable HTTP origin`,
);
continue;
}
const hintProblems = findSetupHintProblems(request.setupHint, { nodeUrls });
const originProblem = findSetupHintTestUrlOriginProblem(request.setupHint, origin);
problems.push(
...hintProblems.map((problem) => `${request.node.name}: ${problem}`),
...(originProblem ? [`${request.node.name}: ${originProblem}`] : []),
);
const grantKey = buildCredentialDestinationGrantKey(workflowId, origin);
const existing = byGrantKey.get(grantKey);
if (existing) {
existing.nodeNames.push(request.node.name);
continue;
}
byGrantKey.set(grantKey, {
origin,
nodeNames: [request.node.name],
grantKey,
});
}
return { problems, destinations: [...byGrantKey.values()] };
}
function findUnapprovedCredentialDestination(
context: InstanceAiContext,
destinations: readonly RequiredCredentialDestination[],
justApprovedGrantKey?: string,
): RequiredCredentialDestination | undefined {
return destinations.find(
(destination) =>
destination.grantKey !== justApprovedGrantKey &&
context.sessionApprovedToolKeys?.has(destination.grantKey) !== true,
);
}
async function suspendForCredentialDestination(
ctx: WorkflowToolContext,
state: SetupState,
workflowId: string,
destination: RequiredCredentialDestination,
) {
state.currentRequestId = nanoid();
return await ctx.suspend({
requestId: state.currentRequestId,
message: 'Review where this credential will be used',
severity: 'warning' as const,
workflowId,
credentialDestination: {
origin: destination.origin,
nodeNames: destination.nodeNames,
},
});
}
/** Setup state 3: persist setup, run the trigger, and re-suspend with the refreshed requests. */
async function handleSetupTestTrigger(
context: InstanceAiContext,
@ -834,6 +925,26 @@ async function handleSetupTestTrigger(
getSkippedSetupSubjects(context),
);
applyCredentialHints(refreshedPending, input.credentialHints);
const destinationInspection = inspectCredentialDestinations(
input.workflowId,
refreshedPending,
refreshedRequests,
);
if (destinationInspection.problems.length > 0) {
return {
error: 'invalid_credential_hints',
message: INVALID_SETUP_HINT_MESSAGE,
problems: destinationInspection.problems,
};
}
const destination = findUnapprovedCredentialDestination(
context,
destinationInspection.destinations,
);
if (destination) {
return await suspendForCredentialDestination(ctx, state, input.workflowId, destination);
}
// Generate a new requestId so the frontend doesn't filter it
// as already-resolved from the previous suspend cycle
@ -1061,9 +1172,18 @@ async function handleSetup(
}
const resumeData = ctx.resumeData;
const destinationDecision = resumeData?.credentialDestination;
if (destinationDecision && !resumeData.approved) {
return {
success: false,
denied: true,
reason: `User did not approve credential use with ${destinationDecision.origin}.`,
};
}
// State 1: Analyze workflow and suspend for user setup
if (resumeData === undefined || resumeData === null) {
if (resumeData === undefined || resumeData === null || destinationDecision !== undefined) {
const allSetupRequests = await analyzeWorkflow(
context,
input.workflowId,
@ -1110,24 +1230,6 @@ async function handleSetup(
await forgetSkippedSetup(context, subjects);
}
// Setup after a build covers only the nodes that build changed —
// pre-existing, unrelated nodes must not surface in the setup card.
const scopeNodeNames = input.includeAllNodes
? undefined
: await resolveSetupScopeNodeNames(context, input.workflowId);
const scopedRequests = scopeNodeNames
? allSetupRequests.filter((request) => scopeNodeNames.includes(request.node.name))
: allSetupRequests;
// Two reasons a card stays out, applied in order: this build never touched the node, or
// the user declined it. Partitioning the scoped list keeps them apart in the report —
// an out-of-scope card is not something the user passed on.
const { pending: setupRequests, skippedByUser } = partitionSkippedSetupRequests(
scopedRequests,
input.workflowId,
getSkippedSetupSubjects(context),
);
// Validated against the workflow's node URLs so a recipe can't set one of
// the workflow's own (action) endpoints as its probe testUrl. Checked against every
// analyzed node, not just the pending ones — narrowing it to what the card shows would
@ -1146,7 +1248,33 @@ async function handleSetup(
};
}
applyCredentialHints(setupRequests, input.credentialHints);
applyCredentialHints(allSetupRequests, input.credentialHints);
const destinationInspection = inspectCredentialDestinations(input.workflowId, allSetupRequests);
if (destinationInspection.problems.length > 0) {
return {
error: 'invalid_credential_hints',
message: INVALID_SETUP_HINT_MESSAGE,
problems: destinationInspection.problems,
};
}
// Setup after a build covers only the nodes that build changed —
// pre-existing, unrelated nodes must not surface in the setup card.
const scopeNodeNames = input.includeAllNodes
? undefined
: await resolveSetupScopeNodeNames(context, input.workflowId);
const scopedRequests = scopeNodeNames
? allSetupRequests.filter((request) => scopeNodeNames.includes(request.node.name))
: allSetupRequests;
// Two reasons a card stays out, applied in order: this build never touched the node, or
// the user declined it. Partitioning the scoped list keeps them apart in the report —
// an out-of-scope card is not something the user passed on.
const { pending: setupRequests, skippedByUser } = partitionSkippedSetupRequests(
scopedRequests,
input.workflowId,
getSkippedSetupSubjects(context),
);
// A provider documenting `Authorization: Bearer <token>` reliably lures the
// model into httpBearerAuth despite the skill guidance, so new plain generic
@ -1172,6 +1300,36 @@ async function handleSetup(
}
}
const setupNodeNames = new Set(setupRequests.map((request) => request.node.name));
const credentialDestinations = destinationInspection.destinations.flatMap((destination) => {
const nodeNames = destination.nodeNames.filter((name) => setupNodeNames.has(name));
return nodeNames.length > 0 ? [{ ...destination, nodeNames }] : [];
});
let justApprovedGrantKey: string | undefined;
if (destinationDecision) {
const approvedDestination = credentialDestinations.find(
(destination) => destination.origin === destinationDecision.origin,
);
if (!approvedDestination) {
return {
error: 'credential_destination_changed',
message:
'The credential destination changed before approval was applied. Call setup again to review the current destination.',
};
}
await context.grantSessionToolApproval?.(approvedDestination.grantKey);
justApprovedGrantKey = approvedDestination.grantKey;
}
const destination = findUnapprovedCredentialDestination(
context,
credentialDestinations,
justApprovedGrantKey,
);
if (destination) {
return await suspendForCredentialDestination(ctx, state, input.workflowId, destination);
}
if (setupRequests.length === 0) {
// Two different silences, and the agent has to say different things about them: cards
// the user declined, and pre-existing nodes this build never touched. Both can hold at

View File

@ -3,6 +3,7 @@ import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
import type { InstanceAiContext } from '../../../types';
import {
extractServiceHost,
extractServiceOrigin,
resolveCredentialForApply,
serviceHostsMatch,
} from '../credential-utils';
@ -95,6 +96,19 @@ describe('extractServiceHost', () => {
);
});
describe('extractServiceOrigin', () => {
it.each([
['https://api.pexels.com/v1/search?query=x', 'https://api.pexels.com'],
['=https://queue.fal.run:8443/jobs/{{ $json.id }}', 'https://queue.fal.run:8443'],
])('derives the origin of %s', (url, origin) => {
expect(extractServiceOrigin(url)).toBe(origin);
});
it.each([['={{ $json.url }}'], ['not a url'], [undefined]])('returns undefined for %s', (url) => {
expect(extractServiceOrigin(url)).toBeUndefined();
});
});
describe('serviceHostsMatch', () => {
it.each([
['api.pexels.com', 'api.pexels.com'],

View File

@ -2159,6 +2159,7 @@ describe('applyCredentialHints', () => {
applyCredentialHints(requests, [hint()]);
expect(requests[0].setupHint?.serviceHost).toBe('queue.fal.run');
expect(requests[0].setupHint?.serviceOrigin).toBe('https://queue.fal.run');
});
it('stamps each request with its own node host from a shared type-wide hint', () => {
@ -2175,16 +2176,20 @@ describe('applyCredentialHints', () => {
expect(requests[0].setupHint?.serviceHost).toBe('api.pexels.com');
expect(requests[1].setupHint?.serviceHost).toBe('api.apify.com');
expect(shared).not.toHaveProperty('serviceHost');
expect(requests[0].setupHint?.serviceOrigin).toBe('https://api.pexels.com');
expect(requests[1].setupHint?.serviceOrigin).toBe('https://api.apify.com');
expect(shared).not.toHaveProperty('serviceOrigin');
});
it('omits serviceHost when the node URL is not derivable', () => {
it('omits service identity when the node URL is not derivable', () => {
const requests = [request('Call fal.ai', 'httpTemplatedCustomAuth')];
requests[0].node.parameters = { url: '={{ $json.url }}' };
applyCredentialHints(requests, [hint()]);
applyCredentialHints(requests, [hint({ testUrl: 'https://fal.run/v1/models' })]);
expect(requests[0].setupHint).toBeDefined();
expect(requests[0].setupHint).not.toHaveProperty('serviceHost');
expect(requests[0].setupHint).not.toHaveProperty('serviceOrigin');
});
});

View File

@ -0,0 +1,77 @@
import type { WorkflowNode } from '../../../types';
import { summarizeWorkflowStructure } from '../summarize-workflow';
const nodes: WorkflowNode[] = [
{ name: 'A', type: 'n8n-nodes-base.noOp', typeVersion: 1, position: [0, 0], parameters: {} },
{ name: 'B', type: 'n8n-nodes-base.noOp', typeVersion: 1, position: [220, 0], parameters: {} },
];
const edge = [[{ node: 'B', type: 'main', index: 0 }]];
/**
* Adds `key` as an own enumerable property — the shape `JSON.parse` produces for
* a stored `connections` column, which an object literal cannot express for keys
* like `__proto__`.
*/
function withOwnKey(key: string, value: unknown): Record<string, unknown> {
const connections: Record<string, unknown> = {};
Object.defineProperty(connections, key, {
value,
enumerable: true,
writable: true,
configurable: true,
});
return connections;
}
describe('summarizeWorkflowStructure', () => {
it('renders nodes and edges of a normal graph', async () => {
const summary = await summarizeWorkflowStructure('wf', nodes, { A: { main: edge } });
expect(summary).toContain('A');
expect(summary).toContain('B');
});
describe('keys that resolve to object internals', () => {
afterEach(() => {
delete (Object.prototype as Record<string, unknown>).main;
delete (Object as unknown as Record<string, unknown>).main;
});
it('skips a source key that would resolve to the shared prototype', async () => {
const connections = withOwnKey('__proto__', { main: edge });
await summarizeWorkflowStructure('wf', nodes, connections);
expect('main' in {}).toBe(false);
expect(Object.getOwnPropertyNames(Object.prototype)).not.toContain('main');
});
it('skips a source key that would resolve to the Object constructor', async () => {
const connections = withOwnKey('constructor', { main: edge });
await summarizeWorkflowStructure('wf', nodes, connections);
expect((Object as unknown as Record<string, unknown>).main).toBeUndefined();
});
it('skips a connection-type key that would resolve to object internals', async () => {
const connections = { A: withOwnKey('__proto__', edge) };
const summary = await summarizeWorkflowStructure('wf', nodes, connections);
expect(summary).not.toContain('__proto__');
});
it('keeps the rest of the graph when an unsafe source key is skipped', async () => {
const connections = withOwnKey('__proto__', { main: edge });
connections.A = { main: edge };
const summary = await summarizeWorkflowStructure('wf', nodes, connections);
expect(summary).toContain('A');
expect(summary).toContain('B');
expect(summary).not.toContain('__proto__');
});
});
});

View File

@ -107,12 +107,21 @@ export function assignCredentialToNode(
* anything that doesn't parse as an http(s) URL with a host.
*/
export function extractServiceHost(raw: unknown): string | undefined {
return extractServiceUrl(raw)?.hostname.toLowerCase();
}
/** Exact origin of a node's statically derivable HTTP URL. */
export function extractServiceOrigin(raw: unknown): string | undefined {
return extractServiceUrl(raw)?.origin;
}
function extractServiceUrl(raw: unknown): URL | undefined {
if (typeof raw !== 'string') return undefined;
const plain = (raw.startsWith('=') ? raw.slice(1) : raw).split('{{')[0].trim();
if (!/^https?:\/\//i.test(plain)) return undefined;
try {
const host = new URL(plain).hostname.toLowerCase();
return host || undefined;
const url = new URL(plain);
return url.hostname ? url : undefined;
} catch {
return undefined;
}

View File

@ -5,6 +5,7 @@
* The node schema is the canonical `workflowSetupNodeSchema` from @n8n/api-types.
*/
import {
credentialDestinationDecisionSchema,
instanceAiConfirmationSeveritySchema,
workflowSetupNodeSchema,
type InstanceAiWorkflowSetupNode,
@ -24,6 +25,7 @@ export const setupSuspendSchema = z.object({
export const setupResumeSchema = z.object({
approved: z.boolean(),
credentialDestination: credentialDestinationDecisionSchema.optional(),
action: z.enum(['apply', 'test-trigger']).optional(),
credentials: z.record(z.record(z.string())).optional(),
nodeParameters: z.record(z.record(z.unknown())).optional(),

View File

@ -28,6 +28,7 @@ import {
N8N_CONNECT_DISPLAY_NAME,
assignCredentialToNode,
extractServiceHost,
extractServiceOrigin,
isAiGatewayManagedCredential,
resolveCredentialForApply,
serviceHostsMatch,
@ -878,13 +879,15 @@ export function applyCredentialHints(
hints.find((h) => h.nodeName === request.node.name) ?? hints.find((h) => !h.nodeName);
if (!hint) continue;
const { nodeName: _nodeName, ...setupHint } = hint;
// Service identity is derived from the node being set up (falling back
// to the recipe's own test endpoint), never model-supplied. It is
// stamped into the created credential so setup surfaces only offer it
// to same-service nodes later.
const serviceHost =
extractServiceHost(request.node.parameters?.url) ?? extractServiceHost(setupHint.testUrl);
request.setupHint = { ...setupHint, ...(serviceHost ? { serviceHost } : {}) };
// The workflow node is the authority for service identity. The recipe's
// own destinations cannot establish or replace it.
const serviceHost = extractServiceHost(request.node.parameters?.url);
const serviceOrigin = extractServiceOrigin(request.node.parameters?.url);
request.setupHint = {
...setupHint,
...(serviceHost ? { serviceHost } : {}),
...(serviceOrigin ? { serviceOrigin } : {}),
};
}
}

View File

@ -1,6 +1,6 @@
import { isRecord } from '@n8n/utils/is-record';
import type { IConnection, IConnections } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { isSafeObjectProperty, NodeConnectionTypes } from 'n8n-workflow';
import type { WorkflowNode } from '../../types';
@ -31,8 +31,12 @@ function toConnections(connections: Record<string, unknown>): IConnections {
const result: IConnections = {};
for (const [from, byType] of Object.entries(connections)) {
if (!isRecord(byType)) continue;
if (!isSafeObjectProperty(from)) continue;
for (const [connectionType, outputs] of Object.entries(byType)) {
if (!Array.isArray(outputs)) continue;
if (!isSafeObjectProperty(connectionType)) continue;
(result[from] ??= {})[connectionType] = outputs.map((targets) =>
Array.isArray(targets) ? targets.filter(isConnection) : null,
);
@ -46,8 +50,12 @@ function summarizeConnections(connections: Record<string, unknown>): string[] {
const edges: string[] = [];
for (const [from, byType] of Object.entries(connections)) {
if (!isRecord(byType)) continue;
if (!isSafeObjectProperty(from)) continue;
for (const [connectionType, outputs] of Object.entries(byType)) {
if (!Array.isArray(outputs)) continue;
if (!isSafeObjectProperty(connectionType)) continue;
outputs.forEach((targets, outputIndex) => {
if (!Array.isArray(targets)) return;
for (const target of targets) {

View File

@ -3,6 +3,7 @@
import type { BaseCallbackHandler, CallbackHandlerMethods } from '@langchain/core/callbacks/base';
import type { Callbacks } from '@langchain/core/callbacks/manager';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { isRecord } from '@n8n/utils/is-record';
import {
NodeConnectionTypes,
type INodeType,
@ -40,6 +41,20 @@ interface ModeleSelectionRule {
};
}
/**
* Read the header names a tracer declares as redacted. Duck-typed on purpose: a model may
* attach any tracer implementation, so match on the declared list, not on a tracer class.
*/
function getDeclaredRedactedHeaders(tracer: unknown): string[] {
if (!isRecord(tracer) || !isRecord(tracer.options)) return [];
const { redactedHeaders } = tracer.options;
return Array.isArray(redactedHeaders)
? redactedHeaders.filter((name) => typeof name === 'string')
: [];
}
function getCallbacksArray(
callbacks: Callbacks | undefined,
): Array<BaseCallbackHandler | CallbackHandlerMethods> {
@ -200,7 +215,11 @@ export class ModelSelector implements INodeType {
currentCallback.setParentRunIndex(this.getNextRunIndex());
}
}
const modelSelectorTracing = new N8nNonEstimatingTracing(this);
// This node records the same serialized model as the model's own tracer,
// so it masks whatever header names that tracer declares.
const redactedHeaders = originalCallbacks.flatMap(getDeclaredRedactedHeaders);
const modelSelectorTracing = new N8nNonEstimatingTracing(this, { redactedHeaders });
selectedModel.callbacks = [...originalCallbacks, modelSelectorTracing];
return {

View File

@ -0,0 +1,85 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { Serialized } from '@langchain/core/load/serializable';
import { N8nLlmTracing } from '@n8n/ai-utilities';
import type { ISupplyDataFunctions, INode } from 'n8n-workflow';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ModelSelector } from '../ModelSelector.node';
const serializedModel: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'chat_models', 'openai'],
kwargs: {
configuration: {
defaultHeaders: {
'User-Agent': 'n8n',
authorization: 'Bearer My_secret_API_key123456789',
'x-secret-header': 'My_secret_API_key123456789',
},
},
},
};
describe('ModelSelector Node header handling', () => {
let selectorContext: Mocked<ISupplyDataFunctions>;
beforeEach(() => {
selectorContext = mock<ISupplyDataFunctions>({
addInputData: vi.fn().mockReturnValue({ index: 0 }),
getNextRunIndex: vi.fn().mockReturnValue(0),
});
selectorContext.getNode.mockReturnValue({ name: 'Model Selector' } as INode);
selectorContext.getNodeParameter.mockImplementation((parameter) =>
parameter === 'rules.rule' ? [{ modelIndex: 1, conditions: {} }] : true,
);
});
/** Runs the selected model through the node and returns what the node persisted. */
const persistHeadersFor = async (callbacks: unknown[]) => {
const model = { _llmType: () => 'fake-llm', callbacks } as unknown as BaseChatModel;
selectorContext.getInputConnectionData.mockResolvedValue([model]);
const { response } = await new ModelSelector().supplyData.call(selectorContext, 0);
const attached = (response as BaseChatModel).callbacks as Array<{
handleLLMStart: (llm: Serialized, prompts: string[], runId: string) => Promise<void>;
}>;
// the tracer the Model Selector attached on top of the model's own callbacks
await attached[attached.length - 1].handleLLMStart(serializedModel, ['hello'], 'run-123');
const inputArg = selectorContext.addInputData.mock.calls[0][1] as Array<
Array<{ json: { options: { configuration: { defaultHeaders: Record<string, string> } } } }>
>;
return inputArg[0][0].json.options.configuration.defaultHeaders;
};
it('should mask the header values declared by the selected model', async () => {
const modelContext = mock<ISupplyDataFunctions>({
addInputData: vi.fn().mockReturnValue({ index: 0 }),
getNextRunIndex: vi.fn().mockReturnValue(0),
});
const persistedHeaders = await persistHeadersFor([
new N8nLlmTracing(modelContext, { redactedHeaders: ['x-secret-header'] }),
]);
expect(persistedHeaders['x-secret-header']).toBe('**********');
expect(persistedHeaders['User-Agent']).toBe('n8n');
});
it('should mask the header values declared by a model that attaches its own tracer type', async () => {
const persistedHeaders = await persistHeadersFor([
{ handleLLMStart: vi.fn(), options: { redactedHeaders: ['x-secret-header'] } },
]);
expect(persistedHeaders['x-secret-header']).toBe('**********');
});
it('should still mask the always-redacted header names when the model declares none', async () => {
const persistedHeaders = await persistHeadersFor([{ handleLLMStart: vi.fn() }]);
expect(persistedHeaders.authorization).toBe('**********');
});
});

View File

@ -1,7 +1,7 @@
import { AgentExecutor } from '@langchain/classic/agents';
import type { OpenAIToolType } from '@langchain/classic/dist/experimental/openai_assistant/schema';
import { OpenAIAssistantRunnable } from '@langchain/classic/experimental/openai_assistant';
import { assertCredentialAllowsUrl, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import type {
IExecuteFunctions,
INodeExecutionData,
@ -14,6 +14,7 @@ import { getConnectedTools, mergeCustomHeaders } from '@utils/helpers';
import { getTracingConfig } from '@utils/tracing';
import { formatToOpenAIAssistantTool } from './utils';
import { assertOpenAiCredentialAllowsUrl } from '../../vendors/OpenAi/helpers/credentials';
import { Container } from '@n8n/di';
import { AiConfig } from '@n8n/config';
@ -350,13 +351,7 @@ export class OpenAiAssistant implements INodeType {
const defaultHeaders = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {});
if (options.baseURL) {
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: options.baseURL,
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
surface: 'OpenAI',
});
assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, options.baseURL);
}
const client = new OpenAIClient({

View File

@ -1,5 +1,6 @@
import { OllamaEmbeddings } from '@langchain/ollama';
import {
assertCredentialAllowsUrl,
NodeConnectionTypes,
type INodeType,
type INodeTypeDescription,
@ -7,7 +8,7 @@ import {
type SupplyData,
} from 'n8n-workflow';
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
import { logWrapper, getConnectionHintNoticeField, proxyFetch } from '@n8n/ai-utilities';
import { ollamaDescription, ollamaModel } from '../../llms/LMOllama/description';
@ -48,16 +49,29 @@ export class EmbeddingsOllama implements INodeType {
this.logger.debug('Supply data for embeddings Ollama');
const modelName = this.getNodeParameter('model', itemIndex) as string;
const credentials = await this.getCredentials('ollamaApi');
const baseUrl = credentials.baseUrl as string;
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: baseUrl,
surface: 'Ollama',
});
const headers = credentials.apiKey
? {
Authorization: `Bearer ${credentials.apiKey as string}`,
}
: undefined;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const embeddings = new OllamaEmbeddings({
baseUrl: credentials.baseUrl as string,
baseUrl,
model: modelName,
headers,
fetch: async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch({ input, init, lookup }),
});
return {

View File

@ -0,0 +1,126 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { OllamaEmbeddings } from '@langchain/ollama';
import { proxyFetch } from '@n8n/ai-utilities';
import { createResultOk } from '@n8n/utils/result';
import type { INode, ISupplyDataFunctions, NodeEgressFilter } from 'n8n-workflow';
import type { MockedFunction } from 'vitest';
import { mockDeep } from 'vitest-mock-extended';
import { EmbeddingsOllama } from '../EmbeddingsOllama.node';
vi.mock('@langchain/ollama');
vi.mock('@n8n/ai-utilities', async () => {
const actual = await vi.importActual('@n8n/ai-utilities');
return {
...(actual as Record<string, unknown>),
proxyFetch: vi.fn(),
};
});
const MockedOllamaEmbeddings = vi.mocked(OllamaEmbeddings);
const mockedProxyFetch = proxyFetch as MockedFunction<typeof proxyFetch>;
const nodeDef: INode = {
id: '1',
name: 'Embeddings Ollama',
typeVersion: 1,
type: 'n8n-nodes-langchain.embeddingsOllama',
position: [0, 0],
parameters: {},
};
/**
* The lookup may travel to `proxyFetch` as a positional argument or as a field
* of an options object, so accept either shape.
*/
function lookupReachedProxyFetch(lookup: unknown): boolean {
return mockedProxyFetch.mock.calls.some((call) =>
call.some(
(arg) =>
arg === lookup ||
(typeof arg === 'object' &&
arg !== null &&
(arg as { lookup?: unknown }).lookup === lookup),
),
);
}
describe('EmbeddingsOllama', () => {
let node: EmbeddingsOllama;
const setupContext = (credentials: Record<string, unknown>, egressFilter: NodeEgressFilter) => {
const ctx = mockDeep<ISupplyDataFunctions>();
ctx.getNode.mockReturnValue(nodeDef);
ctx.getCredentials.mockResolvedValue(credentials);
ctx.getNodeParameter.mockImplementation((name: string) => {
if (name === 'model') return 'llama3';
return undefined;
});
ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter);
return ctx;
};
beforeEach(() => {
node = new EmbeddingsOllama();
vi.clearAllMocks();
mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));
});
it('routes requests through the egress filter lookup', async () => {
const secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
};
const ctx = setupContext({ baseUrl: 'http://ollama.example.com:11434' }, egressFilter);
await node.supplyData.call(ctx, 0);
expect(egressFilter.createSecureLookup).toHaveBeenCalled();
const embeddingsOptions = MockedOllamaEmbeddings.mock.calls[0][0] as
| { fetch?: typeof fetch }
| undefined;
expect(embeddingsOptions?.fetch).toBeTypeOf('function');
await embeddingsOptions?.fetch?.('http://ollama.example.com:11434/api/embed', {});
expect(mockedProxyFetch).toHaveBeenCalled();
expect(lookupReachedProxyFetch(secureLookup)).toBe(true);
});
it('rejects a base URL the credential does not allow', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
};
const ctx = setupContext(
{
baseUrl: 'http://not-allowed.example.com:11434',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'ollama.example.com',
},
egressFilter,
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Domain not allowed');
expect(MockedOllamaEmbeddings).not.toHaveBeenCalled();
});
it('rejects a credential restricted from use in this node', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
};
const ctx = setupContext(
{
baseUrl: 'http://ollama.example.com:11434',
allowedHttpRequestDomains: 'none',
},
egressFilter,
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('configured to prevent use');
expect(MockedOllamaEmbeddings).not.toHaveBeenCalled();
});
});

View File

@ -2,7 +2,6 @@ import { OpenAIEmbeddings } from '@langchain/openai';
import { AiConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import {
assertCredentialAllowsUrl,
NodeConnectionTypes,
type INodeProperties,
type INodeType,
@ -13,6 +12,8 @@ import {
import type { ClientOptions } from 'openai';
import { mergeCustomHeaders } from '@utils/helpers';
import { assertOpenAiCredentialAllowsUrl } from '../../vendors/OpenAi/helpers/credentials';
import { getProxyAgent, logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
const modelParameter: INodeProperties = {
@ -252,13 +253,7 @@ export class EmbeddingsOpenAi implements INodeType {
defaultHeaders,
};
if (options.baseURL) {
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: options.baseURL,
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
surface: 'OpenAI',
});
assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, options.baseURL);
configuration.baseURL = options.baseURL;
} else if (credentials.url) {
configuration.baseURL = credentials.url as string;

View File

@ -44,10 +44,17 @@ const mockModels = [
describe('searchModels', () => {
let mockContext: Mocked<ILoadOptionsFunctions>;
let fetchSpy: ReturnType<typeof vi.fn>;
const secureLookup = vi.fn();
beforeEach(() => {
mockContext = {
getCredentials: vi.fn().mockResolvedValue({ apiKey: 'test-api-key' }),
helpers: {
getSecureEgressFilter: vi.fn().mockReturnValue({
validateUrl: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
}),
},
} as unknown as Mocked<ILoadOptionsFunctions>;
fetchSpy = vi.fn().mockResolvedValue({
@ -69,15 +76,16 @@ describe('searchModels', () => {
const result = await searchModels.call(mockContext);
expect(mockContext.getCredentials).toHaveBeenCalledWith('anthropicApi');
expect(fetchSpy).toHaveBeenCalledWith(
'https://api.anthropic.com/v1/models',
expect.objectContaining({
expect(fetchSpy).toHaveBeenCalledWith({
input: 'https://api.anthropic.com/v1/models',
init: expect.objectContaining({
headers: expect.objectContaining({
'x-api-key': 'test-api-key',
'anthropic-version': '2023-06-01',
}),
}),
);
lookup: secureLookup,
});
expect(result.results).toHaveLength(5);
});
@ -89,7 +97,9 @@ describe('searchModels', () => {
const result = await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith(`${customUrl}/v1/models`, expect.anything());
expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: `${customUrl}/v1/models` }),
);
expect(result.results).toHaveLength(5);
});
@ -100,7 +110,9 @@ describe('searchModels', () => {
const result = await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith('https://api.anthropic.com/v1/models', expect.anything());
expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://api.anthropic.com/v1/models' }),
);
expect(result.results).toHaveLength(5);
});
@ -154,11 +166,13 @@ describe('searchModels', () => {
await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith(
'https://api.anthropic.com/v1/models',
expect.objectContaining({
headers: expect.objectContaining({
'x-api-key': 'test-api-key',
'X-Gateway-Auth': 'gateway-value',
input: 'https://api.anthropic.com/v1/models',
init: expect.objectContaining({
headers: expect.objectContaining({
'x-api-key': 'test-api-key',
'X-Gateway-Auth': 'gateway-value',
}),
}),
}),
);

View File

@ -9,6 +9,7 @@ export async function searchModels(
filter?: string,
): Promise<INodeListSearchResult> {
const credentials = await this.getCredentials('anthropicApi');
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const baseURL = (credentials.url as string) ?? 'https://api.anthropic.com';
// Shared with the agents model catalog: endpoint, auth, and newest-first
@ -19,7 +20,7 @@ export async function searchModels(
apiKey: (credentials.apiKey as string) ?? '',
baseURL,
headers: mergeCustomHeaders(credentials, {}),
fetch: proxyFetch,
fetch: async (input, init) => await proxyFetch({ input, init, lookup }),
});
return {

View File

@ -7,6 +7,7 @@ import {
getConnectionHintNoticeField,
} from '@n8n/ai-utilities';
import {
assertCredentialAllowsUrl,
NodeConnectionTypes,
type INodeType,
type INodeTypeDescription,
@ -59,6 +60,14 @@ export class LmChatOllama implements INodeType {
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('ollamaApi');
const baseUrl = credentials.baseUrl as string;
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: baseUrl,
surface: 'Ollama',
});
const modelName = this.getNodeParameter('model', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as ChatOllamaInput;
@ -68,12 +77,13 @@ export class LmChatOllama implements INodeType {
}
: undefined;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch(input, init, {});
await proxyFetch({ input, init, lookup });
const model = new ChatOllama({
...options,
baseUrl: credentials.baseUrl as string,
baseUrl,
model: modelName,
format: options.format === 'default' ? undefined : options.format,
callbacks: [new N8nLlmTracing(this)],

View File

@ -0,0 +1,125 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { ChatOllama } from '@langchain/ollama';
import { proxyFetch } from '@n8n/ai-utilities';
import { createResultOk } from '@n8n/utils/result';
import type { INode, ISupplyDataFunctions, NodeEgressFilter } from 'n8n-workflow';
import type { MockedFunction } from 'vitest';
import { mockDeep } from 'vitest-mock-extended';
import { LmChatOllama } from '../LmChatOllama.node';
vi.mock('@langchain/ollama');
vi.mock('@n8n/ai-utilities', async () => {
const actual = await vi.importActual('@n8n/ai-utilities');
return {
...(actual as Record<string, unknown>),
proxyFetch: vi.fn(),
};
});
const MockedChatOllama = vi.mocked(ChatOllama);
const mockedProxyFetch = proxyFetch as MockedFunction<typeof proxyFetch>;
const nodeDef: INode = {
id: '1',
name: 'Ollama Chat Model',
typeVersion: 1,
type: 'n8n-nodes-langchain.lmChatOllama',
position: [0, 0],
parameters: {},
};
/**
* The lookup may travel to `proxyFetch` as a positional argument or as a field
* of an options object, so accept either shape.
*/
function lookupReachedProxyFetch(lookup: unknown): boolean {
return mockedProxyFetch.mock.calls.some((call) =>
call.some(
(arg) =>
arg === lookup ||
(typeof arg === 'object' &&
arg !== null &&
(arg as { lookup?: unknown }).lookup === lookup),
),
);
}
describe('LmChatOllama', () => {
let node: LmChatOllama;
const setupContext = (credentials: Record<string, unknown>, egressFilter: NodeEgressFilter) => {
const ctx = mockDeep<ISupplyDataFunctions>();
ctx.getNode.mockReturnValue(nodeDef);
ctx.getCredentials.mockResolvedValue(credentials);
ctx.getNodeParameter.mockImplementation((name: string) => {
if (name === 'model') return 'llama3';
if (name === 'options') return {};
return undefined;
});
ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter);
return ctx;
};
beforeEach(() => {
node = new LmChatOllama();
vi.clearAllMocks();
mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));
});
it('routes requests through the egress filter lookup', async () => {
const secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
};
const ctx = setupContext({ baseUrl: 'http://ollama.example.com:11434' }, egressFilter);
await node.supplyData.call(ctx, 0);
expect(egressFilter.createSecureLookup).toHaveBeenCalled();
const [modelOptions] = MockedChatOllama.mock.calls[0];
expect(modelOptions?.fetch).toBeTypeOf('function');
await modelOptions?.fetch?.('http://ollama.example.com:11434/api/chat', {});
expect(mockedProxyFetch).toHaveBeenCalled();
expect(lookupReachedProxyFetch(secureLookup)).toBe(true);
});
it('rejects a base URL the credential does not allow', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
};
const ctx = setupContext(
{
baseUrl: 'http://not-allowed.example.com:11434',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'ollama.example.com',
},
egressFilter,
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Domain not allowed');
expect(MockedChatOllama).not.toHaveBeenCalled();
});
it('rejects a credential restricted from use in this node', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
};
const ctx = setupContext(
{
baseUrl: 'http://ollama.example.com:11434',
allowedHttpRequestDomains: 'none',
},
egressFilter,
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('configured to prevent use');
expect(MockedChatOllama).not.toHaveBeenCalled();
});
});

View File

@ -2,7 +2,6 @@ import { ChatOpenAI, type ChatOpenAIFields, type ClientOptions } from '@langchai
import isPlainObject from 'lodash/isPlainObject';
import pick from 'lodash/pick';
import {
assertCredentialAllowsUrl,
jsonParse,
NodeConnectionTypes,
NodeOperationError,
@ -17,6 +16,7 @@ import {
import { wrapChatModelMessageInput } from '@utils/chatModelMessageWrapper';
import { getCustomCredentialHeader, mergeCustomHeaders } from '@utils/helpers';
import { assertOpenAiCredentialAllowsUrl } from '../../vendors/OpenAi/helpers/credentials';
import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling';
import {
makeN8nLlmFailedAttemptHandler,
@ -771,13 +771,7 @@ export class LmChatOpenAi implements INodeType {
};
if (options.baseURL) {
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: options.baseURL,
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
surface: 'OpenAI',
});
assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, options.baseURL);
configuration.baseURL = options.baseURL;
} else if (credentials.url) {
configuration.baseURL = credentials.url as string;

View File

@ -1,5 +1,6 @@
import { proxyFetch } from '@n8n/ai-utilities';
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import type { ILoadOptionsFunctions, INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { Mocked } from 'vitest';
import { searchModels } from '../loadModels';
@ -33,6 +34,7 @@ const OFFICIAL_API_RESULTS = [
describe('searchModels', () => {
let mockContext: Mocked<ILoadOptionsFunctions>;
let fetchSpy: ReturnType<typeof vi.fn>;
const secureLookup = vi.fn();
beforeEach(() => {
mockContext = {
@ -40,6 +42,20 @@ describe('searchModels', () => {
apiKey: 'test-api-key',
}),
getNodeParameter: vi.fn().mockReturnValue(''),
getNode: vi.fn().mockReturnValue({
id: '1',
name: 'Test Node',
type: 'test',
typeVersion: 1,
position: [0, 0],
parameters: {},
} as INode),
helpers: {
getSecureEgressFilter: vi.fn().mockReturnValue({
validateUrl: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
}),
},
} as unknown as Mocked<ILoadOptionsFunctions>;
fetchSpy = vi.fn().mockResolvedValue({
@ -61,12 +77,13 @@ describe('searchModels', () => {
it('should return filtered models if custom API endpoint is not provided', async () => {
const result = await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith(
'https://api.openai.com/v1/models',
expect.objectContaining({
expect(fetchSpy).toHaveBeenCalledWith({
input: 'https://api.openai.com/v1/models',
init: expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer test-api-key' }),
}),
);
lookup: secureLookup,
});
expect(result.results).toEqual(OFFICIAL_API_RESULTS);
});
@ -78,7 +95,9 @@ describe('searchModels', () => {
await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith('https://test-url.com/models', expect.anything());
expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://test-url.com/models' }),
);
});
it('should use default OpenAI URL if no custom URL provided', async () => {
@ -88,7 +107,9 @@ describe('searchModels', () => {
await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith('https://api.openai.com/v1/models', expect.anything());
expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://api.openai.com/v1/models' }),
);
});
it('should include all models for custom API endpoints', async () => {
@ -96,7 +117,9 @@ describe('searchModels', () => {
const result = await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith('https://custom-api.com/models', expect.anything());
expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://custom-api.com/models' }),
);
expect(result.results).toEqual([
{ name: 'computer-use-preview', value: 'computer-use-preview' },
{ name: 'davinci-instruct-beta', value: 'davinci-instruct-beta' },
@ -144,6 +167,75 @@ describe('searchModels', () => {
]);
});
it('should reject a base URL override that the credential does not allow', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
url: 'https://api.openai.com/v1',
allowedHttpRequestDomains: 'none',
});
mockContext.getNodeParameter = vi.fn().mockReturnValue('https://custom-api.com/v1');
await expect(searchModels.call(mockContext)).rejects.toThrow(NodeOperationError);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('should allow a base URL override matching the credential URL', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
url: 'https://api.openai.com/v1',
allowedHttpRequestDomains: 'none',
});
mockContext.getNodeParameter = vi.fn().mockReturnValue('https://api.openai.com/v1');
await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://api.openai.com/v1/models' }),
);
});
it('should reject a base URL override outside the allowed domains list', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'example.com',
});
mockContext.getNodeParameter = vi.fn().mockReturnValue('https://custom-api.com/v1');
await expect(searchModels.call(mockContext)).rejects.toThrow(NodeOperationError);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('should allow a base URL override within the allowed domains list', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'example.com',
});
mockContext.getNodeParameter = vi.fn().mockReturnValue('https://example.com/v1');
await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://example.com/v1/models' }),
);
});
it('should not restrict the credential URL when no override is set', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
url: 'https://my-proxy.internal/v1',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'example.com',
});
await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: 'https://my-proxy.internal/v1/models' }),
);
});
it('should include custom credential headers in the request', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
@ -155,9 +247,11 @@ describe('searchModels', () => {
await searchModels.call(mockContext);
expect(fetchSpy).toHaveBeenCalledWith(
'https://api.openai.com/v1/models',
expect.objectContaining({
headers: expect.objectContaining({ 'X-Custom-Auth': 'custom-value' }),
input: 'https://api.openai.com/v1/models',
init: expect.objectContaining({
headers: expect.objectContaining({ 'X-Custom-Auth': 'custom-value' }),
}),
}),
);
});

View File

@ -5,17 +5,20 @@ import { Container } from '@n8n/di';
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
import { mergeCustomHeaders } from '../../../../utils/helpers';
import { assertOpenAiCredentialAllowsUrl } from '../../../vendors/OpenAi/helpers/credentials';
export async function searchModels(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const credentials = await this.getCredentials('openAiApi');
const baseURL =
(this.getNodeParameter('options.baseURL', '') as string) ||
(credentials.url as string) ||
'https://api.openai.com/v1';
const baseUrlOverride = this.getNodeParameter('options.baseURL', '') as string;
if (baseUrlOverride) {
assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, baseUrlOverride);
}
const baseURL = baseUrlOverride || (credentials.url as string) || 'https://api.openai.com/v1';
const { openAiDefaultHeaders } = Container.get(AiConfig);
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const headers = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {});
// Shared with the agents model catalog: endpoint, auth, chat-model filtering
@ -24,7 +27,7 @@ export async function searchModels(
apiKey: credentials.apiKey as string,
baseURL,
headers,
fetch: proxyFetch,
fetch: async (input, init) => await proxyFetch({ input, init, lookup }),
});
return {

View File

@ -1,5 +1,6 @@
import { Ollama, type OllamaInput } from '@langchain/ollama';
import {
assertCredentialAllowsUrl,
NodeConnectionTypes,
type INodeType,
type INodeTypeDescription,
@ -11,6 +12,7 @@ import { ollamaDescription, ollamaModel, ollamaOptions } from './description';
import {
makeN8nLlmFailedAttemptHandler,
N8nLlmTracing,
proxyFetch,
getConnectionHintNoticeField,
} from '@n8n/ai-utilities';
@ -55,6 +57,14 @@ export class LmOllama implements INodeType {
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('ollamaApi');
const baseUrl = credentials.baseUrl as string;
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: baseUrl,
surface: 'Ollama',
});
const modelName = this.getNodeParameter('model', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as OllamaInput;
@ -64,13 +74,17 @@ export class LmOllama implements INodeType {
}
: undefined;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const model = new Ollama({
baseUrl: credentials.baseUrl as string,
baseUrl,
model: modelName,
...options,
callbacks: [new N8nLlmTracing(this)],
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
headers,
fetch: async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch({ input, init, lookup }),
});
return {

View File

@ -0,0 +1,125 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Ollama } from '@langchain/ollama';
import { proxyFetch } from '@n8n/ai-utilities';
import { createResultOk } from '@n8n/utils/result';
import type { INode, ISupplyDataFunctions, NodeEgressFilter } from 'n8n-workflow';
import type { MockedFunction } from 'vitest';
import { mockDeep } from 'vitest-mock-extended';
import { LmOllama } from '../LmOllama.node';
vi.mock('@langchain/ollama');
vi.mock('@n8n/ai-utilities', async () => {
const actual = await vi.importActual('@n8n/ai-utilities');
return {
...(actual as Record<string, unknown>),
proxyFetch: vi.fn(),
};
});
const MockedOllama = vi.mocked(Ollama);
const mockedProxyFetch = proxyFetch as MockedFunction<typeof proxyFetch>;
const nodeDef: INode = {
id: '1',
name: 'Ollama Model',
typeVersion: 1,
type: 'n8n-nodes-langchain.lmOllama',
position: [0, 0],
parameters: {},
};
/**
* The lookup may travel to `proxyFetch` as a positional argument or as a field
* of an options object, so accept either shape.
*/
function lookupReachedProxyFetch(lookup: unknown): boolean {
return mockedProxyFetch.mock.calls.some((call) =>
call.some(
(arg) =>
arg === lookup ||
(typeof arg === 'object' &&
arg !== null &&
(arg as { lookup?: unknown }).lookup === lookup),
),
);
}
describe('LmOllama', () => {
let node: LmOllama;
const setupContext = (credentials: Record<string, unknown>, egressFilter: NodeEgressFilter) => {
const ctx = mockDeep<ISupplyDataFunctions>();
ctx.getNode.mockReturnValue(nodeDef);
ctx.getCredentials.mockResolvedValue(credentials);
ctx.getNodeParameter.mockImplementation((name: string) => {
if (name === 'model') return 'llama3';
if (name === 'options') return {};
return undefined;
});
ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter);
return ctx;
};
beforeEach(() => {
node = new LmOllama();
vi.clearAllMocks();
mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));
});
it('routes requests through the egress filter lookup', async () => {
const secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
};
const ctx = setupContext({ baseUrl: 'http://ollama.example.com:11434' }, egressFilter);
await node.supplyData.call(ctx, 0);
expect(egressFilter.createSecureLookup).toHaveBeenCalled();
const modelOptions = MockedOllama.mock.calls[0][0] as { fetch?: typeof fetch } | undefined;
expect(modelOptions?.fetch).toBeTypeOf('function');
await modelOptions?.fetch?.('http://ollama.example.com:11434/api/generate', {});
expect(mockedProxyFetch).toHaveBeenCalled();
expect(lookupReachedProxyFetch(secureLookup)).toBe(true);
});
it('rejects a base URL the credential does not allow', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
};
const ctx = setupContext(
{
baseUrl: 'http://not-allowed.example.com:11434',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'ollama.example.com',
},
egressFilter,
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Domain not allowed');
expect(MockedOllama).not.toHaveBeenCalled();
});
it('rejects a credential restricted from use in this node', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
};
const ctx = setupContext(
{
baseUrl: 'http://ollama.example.com:11434',
allowedHttpRequestDomains: 'none',
},
egressFilter,
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('configured to prevent use');
expect(MockedOllama).not.toHaveBeenCalled();
});
});

View File

@ -1,6 +1,6 @@
import { OpenAI, type ClientOptions } from '@langchain/openai';
import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities';
import { assertCredentialAllowsUrl, NodeConnectionTypes } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import type {
INodeType,
INodeTypeDescription,
@ -13,6 +13,8 @@ import { Container } from '@n8n/di';
import { AiConfig } from '@n8n/config';
import { getCustomCredentialHeader, mergeCustomHeaders } from '@utils/helpers';
import { assertOpenAiCredentialAllowsUrl } from '../../vendors/OpenAi/helpers/credentials';
type LmOpenAiOptions = {
baseURL?: string;
frequencyPenalty?: number;
@ -211,13 +213,11 @@ export class LmOpenAi implements INodeType {
if (options.baseURL) {
const credentials = await this.getCredentials('openAiApi');
allowedDomains = assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: options.baseURL,
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
surface: 'OpenAI',
});
allowedDomains = assertOpenAiCredentialAllowsUrl(
this.getNode(),
credentials,
options.baseURL,
);
uri = `${options.baseURL}/models`;
}
@ -274,13 +274,7 @@ export class LmOpenAi implements INodeType {
};
if (options.baseURL) {
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: options.baseURL,
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
surface: 'OpenAI',
});
assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, options.baseURL);
configuration.baseURL = options.baseURL;
}

View File

@ -194,8 +194,9 @@ export class LmChatMistralCloud implements INodeType {
randomSeed: undefined,
}) as Partial<ChatMistralAIInput>;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch(input, init, {});
await proxyFetch({ input, init, timeoutOptions: {}, lookup });
const httpClient = new HTTPClient({ fetcher: fetchWithTimeout });
const model = new ChatMistralAI({

View File

@ -7,7 +7,7 @@ import type {
} from '@langchain/core/load/serializable';
import type { BaseMessage } from '@langchain/core/messages';
import type { LLMResult } from '@langchain/core/outputs';
import { logAiEvent } from '@n8n/ai-utilities';
import { logAiEvent, redactHeaderValues } from '@n8n/ai-utilities';
import pick from 'lodash/pick';
import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow';
import { NodeConnectionTypes, NodeError, NodeOperationError } from 'n8n-workflow';
@ -37,7 +37,10 @@ export class N8nNonEstimatingTracing extends BaseCallbackHandler {
*/
runsMap: Record<string, RunDetail> = {};
options = {
options: {
errorDescriptionMapper: (error: NodeError) => string | null | undefined;
redactedHeaders?: string[];
} = {
// Default(OpenAI format) parser
errorDescriptionMapper: (error: NodeError) => error.description,
};
@ -46,6 +49,7 @@ export class N8nNonEstimatingTracing extends BaseCallbackHandler {
private executionFunctions: ISupplyDataFunctions,
options?: {
errorDescriptionMapper?: (error: NodeError) => string;
redactedHeaders?: string[];
},
) {
super();
@ -110,7 +114,10 @@ export class N8nNonEstimatingTracing extends BaseCallbackHandler {
? this.#parentRunIndex + this.executionFunctions.getNextRunIndex()
: undefined;
const options = llm.type === 'constructor' ? llm.kwargs : llm;
const options = redactHeaderValues(
llm.type === 'constructor' ? llm.kwargs : llm,
this.options.redactedHeaders ?? [],
);
const { index } = this.executionFunctions.addInputData(
this.connectionType,
[

View File

@ -426,6 +426,7 @@ describe('LmChatAnthropic', () => {
let mockLoadContext: ILoadOptionsFunctions;
let mockGetCredentials: Mock;
let fetchSpy: Mock;
const secureLookup = vi.fn();
beforeEach(() => {
mockGetCredentials = vi.fn();
@ -436,6 +437,11 @@ describe('LmChatAnthropic', () => {
mockLoadContext = {
getCredentials: mockGetCredentials,
helpers: {
getSecureEgressFilter: vi.fn().mockReturnValue({
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
}),
},
} as unknown as ILoadOptionsFunctions;
});
@ -481,12 +487,15 @@ describe('LmChatAnthropic', () => {
const result = await searchModels.call(mockLoadContext);
expect(fetchSpy).toHaveBeenCalledWith(
'https://api.anthropic.com/v1/models',
expect.objectContaining({
headers: expect.objectContaining({
'x-api-key': 'test-api-key',
'anthropic-version': '2023-06-01',
input: 'https://api.anthropic.com/v1/models',
init: expect.objectContaining({
headers: expect.objectContaining({
'x-api-key': 'test-api-key',
'anthropic-version': '2023-06-01',
}),
}),
lookup: secureLookup,
}),
);
@ -562,7 +571,9 @@ describe('LmChatAnthropic', () => {
const { searchModels } = lmChatAnthropic.methods.listSearch;
await searchModels.call(mockLoadContext);
expect(fetchSpy).toHaveBeenCalledWith(`${customURL}/v1/models`, expect.anything());
expect(fetchSpy).toHaveBeenCalledWith(
expect.objectContaining({ input: `${customURL}/v1/models` }),
);
});
it('should handle empty model list', async () => {

View File

@ -0,0 +1,89 @@
import type { Serialized } from '@langchain/core/load/serializable';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { N8nNonEstimatingTracing } from '../N8nNonEstimatingTracing';
describe('N8nNonEstimatingTracing', () => {
const executionFunctions = mock<ISupplyDataFunctions>({
addInputData: vi.fn().mockReturnValue({ index: 0 }),
addOutputData: vi.fn(),
getNode: vi.fn().mockReturnValue({ name: 'TestNode' }),
getNextRunIndex: vi.fn().mockReturnValue(0),
});
beforeEach(() => {
vi.clearAllMocks();
executionFunctions.addInputData.mockReturnValue({ index: 0 });
});
describe('handleLLMStart', () => {
const serializedModel: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'chat_models', 'openai'],
kwargs: {
model: 'gpt-4',
configuration: {
baseURL: 'https://api.openai.com/v1',
defaultHeaders: {
'User-Agent': 'n8n',
authorization: 'Bearer My_secret_API_key123456789',
'x-secret-header': 'My_secret_API_key123456789',
},
},
},
};
const getPersistedHeaders = () => {
const inputArg = executionFunctions.addInputData.mock.calls[0][1] as Array<
Array<{ json: { options: { configuration: { defaultHeaders: Record<string, string> } } } }>
>;
return inputArg[0][0].json.options.configuration.defaultHeaders;
};
it('should mask declared header values in persisted input data', async () => {
const tracer = new N8nNonEstimatingTracing(executionFunctions, {
redactedHeaders: ['x-secret-header'],
});
await tracer.handleLLMStart(serializedModel, ['hello'], 'run-123');
const persistedHeaders = getPersistedHeaders();
expect(persistedHeaders['x-secret-header']).toBe('**********');
// non-declared header is untouched
expect(persistedHeaders['User-Agent']).toBe('n8n');
// stored run details are masked the same way
const storedOptions = tracer.runsMap['run-123'].options as {
configuration: { defaultHeaders: Record<string, string> };
};
expect(storedOptions.configuration.defaultHeaders['x-secret-header']).toBe('**********');
// the original serialized object is not mutated
expect(
(serializedModel.kwargs.configuration as { defaultHeaders: Record<string, string> })
.defaultHeaders['x-secret-header'],
).toBe('My_secret_API_key123456789');
});
it('should mask the always-redacted header names without being asked to', async () => {
const tracer = new N8nNonEstimatingTracing(executionFunctions);
await tracer.handleLLMStart(serializedModel, ['hello'], 'run-123');
// the floor every model gets: ALWAYS_REDACTED_HEADERS in redact-headers.ts, which
// covers the nodes that declare no header names of their own
expect(getPersistedHeaders().authorization).toBe('**********');
});
it('should keep header values that are neither declared nor always-redacted', async () => {
const tracer = new N8nNonEstimatingTracing(executionFunctions);
await tracer.handleLLMStart(serializedModel, ['hello'], 'run-123');
// masking is opt-in per header name, so a name the model never declared is kept
expect(getPersistedHeaders()['x-secret-header']).toBe('My_secret_API_key123456789');
});
});
});

View File

@ -3,6 +3,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { McpError, ErrorCode, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import { proxyFetch } from '@n8n/ai-utilities';
import { Container } from '@n8n/di';
import { createResultOk } from '@n8n/utils/result';
import { StructuredToolkit } from 'n8n-core';
import {
type IExecuteFunctions,
@ -11,6 +12,7 @@ import {
type ILoadOptionsFunctions,
type INode,
type ISupplyDataFunctions,
type NodeEgressFilter,
jsonParse,
} from 'n8n-workflow';
import type { MockedFunction } from 'vitest';
@ -33,6 +35,16 @@ vi.mock('@n8n/ai-utilities', async () => {
const mockedProxyFetch = proxyFetch as MockedFunction<typeof proxyFetch>;
const createTestEgressFilter = (): NodeEgressFilter => ({
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
});
const egressHelpers = <T extends { helpers: unknown }>(): Partial<T> =>
({
helpers: { getSecureEgressFilter: vi.fn(() => createTestEgressFilter()) },
}) as Partial<T>;
describe('McpClientTool', () => {
beforeEach(() => {
vi.resetAllMocks();
@ -54,6 +66,7 @@ describe('McpClientTool', () => {
const result = await getTools.call(
mock<ILoadOptionsFunctions>({
...egressHelpers<ILoadOptionsFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1 })),
getNodeParameter: vi.fn((key: string) => {
const parameters: Record<string, unknown> = {
@ -78,6 +91,7 @@ describe('McpClientTool', () => {
vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error('Fail!'));
const node = mock<INode>({ typeVersion: 1 });
const mockLoadOptionsFunctions = mock<ILoadOptionsFunctions>({
...egressHelpers<ILoadOptionsFunctions>(),
getNode: vi.fn(() => node),
getNodeParameter: vi.fn((key: string) => {
const parameters: Record<string, unknown> = {
@ -111,6 +125,7 @@ describe('McpClientTool', () => {
await getTools.call(
mock<ILoadOptionsFunctions>({
...egressHelpers<ILoadOptionsFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1 })),
getNodeParameter: vi.fn((key: string) => {
const parameters: Record<string, unknown> = {
@ -148,6 +163,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
logger: { debug: vi.fn(), error: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
@ -190,6 +206,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() =>
mock<INode>({
typeVersion: 1,
@ -237,6 +254,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() =>
mock<INode>({
typeVersion: 1,
@ -281,6 +299,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
getNodeParameter: vi.fn((key, _index) => {
const parameters: Record<string, any> = {
@ -312,7 +331,7 @@ describe('McpClientTool', () => {
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
await customFetch?.(url, {} as any);
expect(mockedProxyFetch).toHaveBeenCalledOnce();
const [requestedUrl, requestInit] = mockedProxyFetch.mock.calls[0];
const [{ input: requestedUrl, init: requestInit }] = mockedProxyFetch.mock.calls[0];
expect(requestedUrl).toBe(url);
expect(requestInit?.redirect).toBe('manual');
const sentHeaders = new Headers(requestInit?.headers);
@ -336,6 +355,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
getNodeParameter: vi.fn((key, _index) => {
const parameters: Record<string, any> = {
@ -367,7 +387,7 @@ describe('McpClientTool', () => {
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
await customFetch?.(url, {} as any);
expect(mockedProxyFetch).toHaveBeenCalledOnce();
const [requestedUrl, requestInit] = mockedProxyFetch.mock.calls[0];
const [{ input: requestedUrl, init: requestInit }] = mockedProxyFetch.mock.calls[0];
expect(requestedUrl).toBe(url);
expect(requestInit?.redirect).toBe('manual');
const sentHeaders = new Headers(requestInit?.headers);
@ -393,6 +413,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() =>
mock<INode>({
typeVersion: 1,
@ -437,6 +458,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
logger: { debug: vi.fn(), error: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
@ -474,6 +496,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
logger: { debug: vi.fn(), error: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
@ -511,6 +534,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
logger: { debug: vi.fn(), error: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
@ -547,6 +571,7 @@ describe('McpClientTool', () => {
});
const supplyDataFunctions = mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() =>
mock<INode>({
typeVersion: 1,
@ -596,6 +621,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
logger: { debug: vi.fn(), error: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
@ -638,6 +664,7 @@ describe('McpClientTool', () => {
await expect(
new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
logger: { debug: vi.fn(), error: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
@ -669,6 +696,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
logger: { debug: vi.fn(), error: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
@ -712,6 +740,7 @@ describe('McpClientTool', () => {
const errorLogger = vi.fn();
const supplyDataFunctions = mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
logger: { debug: vi.fn(), error: errorLogger },
addInputData: vi.fn(() => ({ index: 0 })),
@ -747,6 +776,7 @@ describe('McpClientTool', () => {
await expect(
new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
logger: { debug: vi.fn(), error: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
@ -780,6 +810,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'McpClientTool' })),
logger: { debug: vi.fn(), error: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
@ -819,6 +850,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, name: 'MCP Client' });
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mockNode),
getNodeParameter: vi.fn((key, _index) => {
const parameters: Record<string, any> = {
@ -867,6 +899,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -935,6 +968,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1000,6 +1034,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1053,6 +1088,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1.2, type: 'mcpClientTool', name: 'MCP Client' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1106,6 +1142,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1.3, type: 'mcpClientTool', name: 'MCP Client' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1152,6 +1189,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1.3, type: 'mcpClientTool', name: 'MCP Client' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1197,6 +1235,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1230,6 +1269,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1278,6 +1318,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1350,6 +1391,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1402,6 +1444,7 @@ describe('McpClientTool', () => {
name: 'GitHub MCP',
});
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1457,6 +1500,7 @@ describe('McpClientTool', () => {
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.getExecutionCancelSignal.mockReturnValue(abortController.signal);
mockExecuteFunctions.helpers.getSecureEgressFilter.mockReturnValue(createTestEgressFilter());
mockExecuteFunctions.getInputData.mockReturnValue([
{
json: {
@ -1505,6 +1549,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [{ json: { tool: 'get_weather', location: 'Berlin' } }]),
getNodeParameter: vi.fn((key) => {
@ -1546,6 +1591,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1600,6 +1646,7 @@ describe('McpClientTool', () => {
});
const mockSupplyDataFunctions = mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
logger: { debug: vi.fn(), error: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
@ -1641,6 +1688,7 @@ describe('McpClientTool', () => {
});
const mockSupplyDataFunctions = mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
logger: { debug: vi.fn(), error: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
@ -1676,6 +1724,7 @@ describe('McpClientTool', () => {
name: 'McpClientTool',
});
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1719,6 +1768,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1753,6 +1803,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [{ json: { tool: 'get_weather' } }]),
getNodeParameter: vi.fn((key) => {
@ -1790,6 +1841,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [{ json: { location: 'Berlin' } }]),
getNodeParameter: vi.fn((key) => {
@ -1819,6 +1871,7 @@ describe('McpClientTool', () => {
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [{ json: { tool: 'get_weather' } }]),
getNodeParameter: vi.fn((key) => {
@ -1857,6 +1910,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() =>
mock<INode>({
typeVersion: 1,
@ -1896,6 +1950,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() =>
mock<INode>({
typeVersion: 1,
@ -1936,6 +1991,7 @@ describe('McpClientTool', () => {
credentials: Record<string, unknown>;
}) =>
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() =>
mock<INode>({
typeVersion: 1,
@ -2058,6 +2114,7 @@ describe('McpClientTool', () => {
const node = mock<INode>({ typeVersion: 1 });
const ctx = mock<ILoadOptionsFunctions>({
...egressHelpers<ILoadOptionsFunctions>(),
getNode: vi.fn(() => node),
getNodeParameter: vi.fn((key) => {
const params: Record<string, any> = {
@ -2083,6 +2140,7 @@ describe('McpClientTool', () => {
function createCtx(typeVersion: number) {
const mockNode = mock<INode>({ typeVersion, type: 'mcpClientTool', name: 'MCP Client' });
return mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
getExecutionId: vi.fn(() => 'exec-gating'),

View File

@ -35,6 +35,11 @@ vi.mock('@n8n/ai-utilities', async () => {
const MockedClient = Client as MockedClass<typeof Client>;
const createTestEgressFilter = (): NodeEgressFilter => ({
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn(),
});
describe('utils', () => {
afterEach(() => {
vi.restoreAllMocks();
@ -368,6 +373,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -391,6 +397,7 @@ describe('utils', () => {
await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
name: 'test-client',
@ -408,6 +415,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -424,6 +432,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -446,6 +455,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -474,6 +484,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -497,6 +508,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
name: 'test-client',
@ -514,6 +526,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -532,6 +545,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -552,6 +566,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -573,6 +588,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -595,6 +611,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -614,6 +631,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -631,6 +649,7 @@ describe('utils', () => {
await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer my-token' },
name: 'test-client',
@ -644,10 +663,10 @@ describe('utils', () => {
expect(mockedProxyFetch).toHaveBeenCalled();
const call = mockedProxyFetch.mock.calls[0];
const [options] = mockedProxyFetch.mock.calls[0];
expect(call[0]).toBe('https://example.com/mcp');
expect(new Headers(call[1]?.headers).get('authorization')).toBe('Bearer my-token');
expect(options.input).toBe('https://example.com/mcp');
expect(new Headers(options.init?.headers).get('authorization')).toBe('Bearer my-token');
});
it('should preserve SDK headers passed as Headers instance', async () => {
@ -662,6 +681,7 @@ describe('utils', () => {
await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer my-token' },
name: 'test-client',
@ -673,7 +693,7 @@ describe('utils', () => {
const [, opts] = (TransportClass as Mock).mock.calls[0];
await opts.fetch('https://example.com', { headers: sdkHeaders });
const [, callOpts] = mockedProxyFetch.mock.calls[0];
const [{ init: callOpts }] = mockedProxyFetch.mock.calls[0];
expect(new Headers(callOpts?.headers).get('accept')).toBe('text/event-stream');
});
@ -691,6 +711,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer old-token' },
name: 'test-client',
@ -717,6 +738,7 @@ describe('utils', () => {
await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer old-token' },
name: 'test-client',
@ -739,6 +761,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
name: 'test-client',
@ -761,6 +784,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
name: 'test-client',
@ -788,6 +812,7 @@ describe('utils', () => {
ctx.helpers.refreshOAuth2Token.mockResolvedValue({
access_token: 'refreshed-token',
});
ctx.helpers.getSecureEgressFilter.mockReturnValue(createTestEgressFilter());
const connection = {
nodeTypeName: '@n8n/mcp-registry.test',
credentialType: 'testMcpOAuth2Api' as const,

View File

@ -1,5 +1,6 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { Container } from '@n8n/di';
import { createResultOk } from '@n8n/utils/result';
import { mock } from 'vitest-mock-extended';
import { StructuredToolkit } from 'n8n-core';
import {
@ -9,6 +10,7 @@ import {
type ILoadOptionsFunctions,
type INode,
type ISupplyDataFunctions,
type NodeEgressFilter,
} from 'n8n-workflow';
import { McpClientsManager } from './McpClientsManager';
@ -44,6 +46,16 @@ const baseConnectionConfig: McpConnectionConfig = {
timeout: 60000,
};
const createTestEgressFilter = (): NodeEgressFilter => ({
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn(),
});
const egressHelpers = <T extends { helpers: unknown }>(): Partial<T> =>
({
helpers: { getSecureEgressFilter: vi.fn(() => createTestEgressFilter()) },
}) as Partial<T>;
const sampleTool = {
name: 'search',
description: 'Search the workspace',
@ -59,6 +71,7 @@ function createSupplyDataCtx(overrides: Record<string, unknown> = {}) {
logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
addOutputData: vi.fn(),
...egressHelpers<ISupplyDataFunctions>(),
...overrides,
} as Partial<ISupplyDataFunctions>);
}
@ -71,6 +84,7 @@ function createExecuteCtx(
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP', type: 'mcp' })),
logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
getInputData: vi.fn(() => inputItems),
...egressHelpers<IExecuteFunctions>(),
...overrides,
} as Partial<IExecuteFunctions>);
}
@ -428,6 +442,7 @@ describe('runtime', () => {
getExecutionId: vi.fn(() => executionId),
getExecutionCancelSignal: vi.fn(() => undefined),
onExecutionCancellation: vi.fn(),
...egressHelpers<IExecuteFunctions>(),
...overrides,
} as Partial<IExecuteFunctions>);
}
@ -538,6 +553,7 @@ describe('runtime', () => {
vi.spyOn(Client.prototype, 'close').mockResolvedValue();
const ctx = mock<ILoadOptionsFunctions>({
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP' })),
...egressHelpers<ILoadOptionsFunctions>(),
});
const result = await loadMcpToolOptions(ctx, baseConnectionConfig);
@ -556,6 +572,7 @@ describe('runtime', () => {
vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error('boom'));
const ctx = mock<ILoadOptionsFunctions>({
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP' })),
...egressHelpers<ILoadOptionsFunctions>(),
});
await expect(loadMcpToolOptions(ctx, baseConnectionConfig)).rejects.toThrow(
@ -569,6 +586,7 @@ describe('runtime', () => {
const closeSpy = vi.spyOn(Client.prototype, 'close').mockResolvedValue();
const ctx = mock<ILoadOptionsFunctions>({
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP' })),
...egressHelpers<ILoadOptionsFunctions>(),
});
await expect(loadMcpToolOptions(ctx, baseConnectionConfig)).rejects.toThrow('list-failed');

View File

@ -168,11 +168,11 @@ export async function connectMcpClient({
*/
allowedDomains?: string;
/**
* Instance egress filter. When set, every request (including redirect hops)
* is validated against the configured egress policy, and the connection is
* Instance egress filter. Every request (including redirect hops) is
* validated against the configured egress policy, and the connection is
* pinned to the validated address.
*/
secureEgressFilter?: NodeEgressFilter;
secureEgressFilter: NodeEgressFilter;
}): Promise<Result<Client, ConnectMcpClientError>> {
const endpoint = normalizeAndValidateUrl(endpointUrl);
@ -180,7 +180,7 @@ export async function connectMcpClient({
return createResultError({ type: 'invalid_url', error: endpoint.error });
}
const authFetch = createAuthFetch(headers, onUnauthorized, allowedDomains, secureEgressFilter);
const authFetch = createAuthFetch(headers, secureEgressFilter, onUnauthorized, allowedDomains);
const client = new Client({ name, version: version.toString() }, { capabilities: {} });
let onAbort: (() => void) | undefined;
@ -301,13 +301,13 @@ function headersToRecord(headers: HeadersInit | undefined): Record<string, strin
*/
function createAuthFetch(
initialHeaders: Record<string, string> | undefined,
secureEgressFilter: NodeEgressFilter,
onUnauthorized?: OnUnauthorizedHandler,
allowedDomains?: string,
secureEgressFilter?: NodeEgressFilter,
): typeof fetch {
const secureLookup = secureEgressFilter?.createSecureLookup();
const secureLookup = secureEgressFilter.createSecureLookup();
return createRefreshingAuthFetch({
baseFetch: async (input, init) => await proxyFetch(input, init, undefined, secureLookup),
baseFetch: async (input, init) => await proxyFetch({ input, init, lookup: secureLookup }),
initialHeaders,
...(onUnauthorized
? {
@ -317,10 +317,8 @@ function createAuthFetch(
: {}),
assertAllowedUrl: async (hopUrl) => {
assertUrlAllowed({ url: hopUrl, allowedDomains });
if (secureEgressFilter) {
const result = await secureEgressFilter.validateUrl(hopUrl);
if (!result.ok) throw result.error;
}
const result = await secureEgressFilter.validateUrl(hopUrl);
if (!result.ok) throw result.error;
},
});
}
@ -482,7 +480,7 @@ export async function connectMcpClientForCredential(
endpointUrl,
headers: authHeaders,
allowedDomains,
secureEgressFilter: ctx.helpers.getSecureEgressFilter?.(),
secureEgressFilter: ctx.helpers.getSecureEgressFilter(),
name: node.type,
version: node.typeVersion,
onUnauthorized: async (h) => await tryRefreshOAuth2Token(ctx, config.authentication, h),

View File

@ -1,4 +1,4 @@
import type { IWebhookFunctions } from 'n8n-workflow';
import type { IWebhookFunctions, NodeEgressFilter } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
@ -126,6 +126,8 @@ import {
import { AgenticAuthenticationService } from '@microsoft/agents-a365-runtime';
import { proxyFetch } from '@n8n/ai-utilities';
const testEgressFilter = mock<NodeEgressFilter>();
describe('microsoft-utils', () => {
beforeAll(async () => {
const actualMcpUtils = await vi.hoisted(
@ -211,6 +213,9 @@ describe('microsoft-utils', () => {
nodeContext = mock<IWebhookFunctions>({
getNodeParameter: vi.fn(),
getNode: vi.fn().mockReturnValue({ name: 'Test Node' }),
helpers: {
getSecureEgressFilter: () => testEgressFilter,
} as unknown as IWebhookFunctions['helpers'],
});
agent = {
@ -644,6 +649,7 @@ describe('microsoft-utils', () => {
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
expect(result).toBeUndefined();
@ -680,6 +686,7 @@ describe('microsoft-utils', () => {
mockAuthorization,
'test-token',
selectedTools,
testEgressFilter,
);
expect(result).toBeDefined();
@ -699,7 +706,13 @@ describe('microsoft-utils', () => {
(getAllTools as Mock).mockResolvedValue([]);
await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined);
await getMicrosoftMcpTools(
mockTurnContext,
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
expect(connectMcpClient).toHaveBeenCalledWith({
serverTransport: 'httpStreamable',
@ -710,6 +723,7 @@ describe('microsoft-utils', () => {
},
name: 'Microsoft-Agent-365',
version: 1,
secureEgressFilter: testEgressFilter,
});
});
@ -739,7 +753,13 @@ describe('microsoft-utils', () => {
(getAllTools as Mock).mockResolvedValue([]);
await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined);
await getMicrosoftMcpTools(
mockTurnContext,
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
expect(connectMcpClient).toHaveBeenCalledWith(
expect.objectContaining({
@ -783,13 +803,19 @@ describe('microsoft-utils', () => {
});
(getAllTools as Mock).mockResolvedValue([]);
await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', [
'mcp_CalendarTools',
]);
await getMicrosoftMcpTools(
mockTurnContext,
mockAuthorization,
'test-token',
['mcp_CalendarTools'],
testEgressFilter,
);
expect(proxyFetch).toHaveBeenCalledWith(
'https://agent365.svc.cloud.microsoft/agents/v2/agent-identity/mcpServers',
expect.any(Object),
expect.objectContaining({
input: 'https://agent365.svc.cloud.microsoft/agents/v2/agent-identity/mcpServers',
init: expect.any(Object),
}),
);
// mcp_CalendarTools has no audience → V1 server → uses shared token directly,
// no OBO exchange needed
@ -821,7 +847,13 @@ describe('microsoft-utils', () => {
try {
await expect(
getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined),
getMicrosoftMcpTools(
mockTurnContext,
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
),
).rejects.toThrow(
'Failed to read MCP servers from endpoint: response is not a server list',
);
@ -861,7 +893,13 @@ describe('microsoft-utils', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined);
await getMicrosoftMcpTools(
mockTurnContext,
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
expect(consoleSpy).toHaveBeenCalledWith(
'Skipping MCP server mcp_CalendarTools: failed to connect',
@ -898,6 +936,7 @@ describe('microsoft-utils', () => {
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
expect(result).toBeDefined();
@ -934,6 +973,7 @@ describe('microsoft-utils', () => {
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
expect(result).toBeUndefined();
@ -967,6 +1007,7 @@ describe('microsoft-utils', () => {
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
await result?.client.close();
@ -1000,6 +1041,7 @@ describe('microsoft-utils', () => {
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
expect(connectMcpClient).toHaveBeenCalledWith(
@ -1040,6 +1082,7 @@ describe('microsoft-utils', () => {
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
expect(result?.toolkits).toHaveLength(2);
@ -1069,6 +1112,7 @@ describe('microsoft-utils', () => {
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
// mcpToolToDynamicTool gets server-prefixed names, avoiding collision
@ -1102,7 +1146,13 @@ describe('microsoft-utils', () => {
name: 'mcp_Calendar_Tools__v2__create_event',
});
await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined);
await getMicrosoftMcpTools(
mockTurnContext,
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
// Special chars (dash, dot, space, parens) all replaced with underscores
expect(mcpToolToDynamicTool).toHaveBeenCalledWith(
@ -1125,7 +1175,13 @@ describe('microsoft-utils', () => {
(createCallTool as Mock).mockReturnValue(mockCallTool);
(mcpToolToDynamicTool as Mock).mockReturnValue({ name: 'trimmed' });
await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined);
await getMicrosoftMcpTools(
mockTurnContext,
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
const calledWith = (mcpToolToDynamicTool as Mock).mock.calls[0][0];
// Tool name is always preserved; only the prefix is trimmed
@ -1145,7 +1201,13 @@ describe('microsoft-utils', () => {
(mcpToolToDynamicTool as Mock).mockReturnValue({ name: toolName });
await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined);
await getMicrosoftMcpTools(
mockTurnContext,
mockAuthorization,
'test-token',
undefined,
testEgressFilter,
);
expect(mcpToolToDynamicTool).toHaveBeenCalledWith(
expect.objectContaining({ name: toolName }),
@ -1200,6 +1262,7 @@ describe('microsoft-utils', () => {
mockAuthorizationLogging,
'test-token',
undefined,
testEgressFilter,
);
expect(result).toBeDefined();
@ -1225,6 +1288,7 @@ describe('microsoft-utils', () => {
mockAuthorizationLogging,
'test-token',
undefined,
testEgressFilter,
);
await capturedToolFunc!({ maxResults: 5 });
@ -1263,6 +1327,7 @@ describe('microsoft-utils', () => {
mockAuthorizationLogging,
'test-token',
undefined,
testEgressFilter,
);
await capturedToolFunc!({ maxResults: 5 });
@ -1289,6 +1354,7 @@ describe('microsoft-utils', () => {
mockAuthorizationLogging,
'test-token',
undefined,
testEgressFilter,
);
// createCallTool is not called at setup — only when the tool is actually invoked
@ -1324,6 +1390,7 @@ describe('microsoft-utils', () => {
mockAuthorizationLogging,
'test-token',
undefined,
testEgressFilter,
);
await capturedFuncs[0]({ query: 'today' });
@ -1349,6 +1416,9 @@ describe('microsoft-utils', () => {
nodeContext = mock<IWebhookFunctions>({
getNodeParameter: vi.fn(),
getNode: vi.fn().mockReturnValue({ name: 'Test Node' }),
helpers: {
getSecureEgressFilter: () => testEgressFilter,
} as unknown as IWebhookFunctions['helpers'],
});
credentials = {

View File

@ -13,6 +13,7 @@ import {
type IDataObject,
type IWebhookFunctions,
type INodePropertyOptions,
type NodeEgressFilter,
} from 'n8n-workflow';
import {
@ -223,17 +224,22 @@ function normalizeMcpServerConfig(rawServer: unknown): MCPServerConfig | undefin
async function getMcpServerConfigsWithoutAudienceTokens(
turnContext: TurnContext,
mcpAuthToken: string,
secureEgressFilter: NodeEgressFilter,
) {
MicrosoftToolingUtility.ValidateAuthToken(mcpAuthToken);
const agenticAppId = MicrosoftRuntimeUtility.ResolveAgentIdentity(turnContext, mcpAuthToken);
const endpoint = getToolingGatewayUrl(agenticAppId);
const response = await proxyFetch(endpoint, {
headers: MicrosoftToolingUtility.GetToolRequestHeaders(
mcpAuthToken,
turnContext,
MICROSOFT_TOOL_OPTIONS,
),
const response = await proxyFetch({
input: endpoint,
init: {
headers: MicrosoftToolingUtility.GetToolRequestHeaders(
mcpAuthToken,
turnContext,
MICROSOFT_TOOL_OPTIONS,
),
},
lookup: secureEgressFilter.createSecureLookup(),
});
if (!response.ok) {
@ -361,6 +367,7 @@ export async function getMicrosoftMcpTools(
authorization: Authorization,
mcpAuthToken: string,
selectedTools: string[] | undefined,
secureEgressFilter: NodeEgressFilter,
) {
const configService: McpToolServerConfigurationService = new McpToolServerConfigurationService();
@ -376,7 +383,11 @@ export async function getMicrosoftMcpTools(
);
} catch (error) {
console.warn('Microsoft SDK listToolServers failed, falling back to direct discovery');
servers = await getMcpServerConfigsWithoutAudienceTokens(turnContext, mcpAuthToken);
servers = await getMcpServerConfigsWithoutAudienceTokens(
turnContext,
mcpAuthToken,
secureEgressFilter,
);
shouldAttachServerAuthorization = true;
}
@ -418,6 +429,7 @@ export async function getMicrosoftMcpTools(
headers,
name: 'Microsoft-Agent-365',
version: 1,
secureEgressFilter,
});
if (!clientResult.ok) {
@ -558,6 +570,7 @@ export const configureActivityCallback = (
authorization,
mcpTokenRef.token,
selectedTools,
nodeContext.helpers.getSecureEgressFilter(),
);
mcpClient = result?.client;

View File

@ -0,0 +1,95 @@
import type { ICredentialDataDecryptedObject, INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { assertOpenAiCredentialAllowsUrl } from './credentials';
describe('assertOpenAiCredentialAllowsUrl', () => {
const node = {
id: '1',
name: 'Test Node',
type: 'test',
typeVersion: 1,
position: [0, 0],
parameters: {},
} as INode;
it('should return undefined when the credential has no restriction mode', () => {
const result = assertOpenAiCredentialAllowsUrl(node, { apiKey: 'key' }, 'https://any.host/v1');
expect(result).toBeUndefined();
});
it('should allow the credential URL in "none" mode', () => {
const credentials = {
apiKey: 'key',
url: 'https://api.openai.com/v1',
allowedHttpRequestDomains: 'none',
};
expect(() =>
assertOpenAiCredentialAllowsUrl(node, credentials, 'https://api.openai.com/v1'),
).not.toThrow();
});
it('should reject other URLs in "none" mode when the credential has a URL', () => {
const credentials = {
apiKey: 'key',
url: 'https://api.openai.com/v1',
allowedHttpRequestDomains: 'none',
};
expect(() =>
assertOpenAiCredentialAllowsUrl(node, credentials, 'https://other.host/v1'),
).toThrow(NodeOperationError);
});
it.each([undefined, '', null])(
'should fall back to the default URL in "none" mode when the credential URL is %p',
(url) => {
const credentials = {
apiKey: 'key',
url,
allowedHttpRequestDomains: 'none',
} as unknown as ICredentialDataDecryptedObject;
expect(() =>
assertOpenAiCredentialAllowsUrl(node, credentials, 'https://api.openai.com/v1'),
).not.toThrow();
expect(() =>
assertOpenAiCredentialAllowsUrl(node, credentials, 'https://any.host/v1'),
).toThrow(NodeOperationError);
},
);
it('should allow a listed domain in "domains" mode and return the list', () => {
const credentials = {
apiKey: 'key',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'example.com, *.example.org',
};
const result = assertOpenAiCredentialAllowsUrl(node, credentials, 'https://example.com/v1');
expect(result).toBe('example.com, *.example.org');
});
it('should reject an unlisted domain in "domains" mode', () => {
const credentials = {
apiKey: 'key',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'example.com',
};
expect(() =>
assertOpenAiCredentialAllowsUrl(node, credentials, 'https://other.host/v1'),
).toThrow(NodeOperationError);
});
it('should throw in "domains" mode when no domains are listed', () => {
const credentials = { apiKey: 'key', allowedHttpRequestDomains: 'domains' };
expect(() =>
assertOpenAiCredentialAllowsUrl(node, credentials, 'https://example.com/v1'),
).toThrow(NodeOperationError);
});
});

View File

@ -0,0 +1,30 @@
import type { ICredentialDataDecryptedObject, INode } from 'n8n-workflow';
import { assertCredentialAllowsUrl } from 'n8n-workflow';
/** Mirrors the fallback every OpenAI call site applies when the credential has no URL. */
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
/**
* Enforces the credential's domain restrictions against a caller-supplied
* OpenAI-compatible base URL, treating the credential's effective endpoint —
* its own `url`, or the OpenAI default when that is unset — as the pinned URL
* for `'none'` mode.
*
* @returns {string | undefined} the allowed-domains list for per-redirect-hop checks; `undefined` means allow-all
* @throws {NodeOperationError} when the URL is outside the credential's allowed domains
*/
export function assertOpenAiCredentialAllowsUrl(
node: INode,
credentials: ICredentialDataDecryptedObject,
url: string,
): string | undefined {
const credentialUrl = typeof credentials.url === 'string' ? credentials.url : '';
return assertCredentialAllowsUrl({
node,
credentialData: credentials,
url,
pinnedUrl: credentialUrl || DEFAULT_BASE_URL,
surface: 'OpenAI',
});
}

View File

@ -11,7 +11,6 @@ import type {
INodeProperties,
} from 'n8n-workflow';
import {
assertCredentialAllowsUrl,
BaseError,
NodeConnectionTypes,
NodeOperationError,
@ -23,6 +22,7 @@ import { promptTypeOptionsDeprecated } from '@utils/descriptions';
import { getConnectedTools, getPromptInputByType, mergeCustomHeaders } from '@utils/helpers';
import { getTracingConfig } from '@utils/tracing';
import { assertOpenAiCredentialAllowsUrl } from '../../../helpers/credentials';
import { formatToOpenAIAssistantTool, getChatMessages } from '../../../helpers/utils';
import { assistantRLC } from '../descriptions';
import { getProxyAgent } from '@n8n/ai-utilities';
@ -182,13 +182,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
};
if (options.baseURL) {
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: options.baseURL,
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
surface: 'OpenAI',
});
assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, options.baseURL);
}
const baseURL = (options.baseURL ?? credentials.url) as string;

View File

@ -1,5 +1,5 @@
import type { RESOURCES } from '@/constants.ee';
import type { Scope } from '@/types.ee';
import type { RESOURCES } from '../constants.ee';
import type { Scope } from '../types.ee';
/**
* UI-visible operations per resource for the project role editor.

View File

@ -8,7 +8,7 @@ import type { TournamentHooks } from './ast';
import { splitExpression } from './ExpressionSplitter';
import type { ExpressionCode, ExpressionText } from './ExpressionSplitter';
import { parseWithEsprimaNext } from './Parser';
import { globalIdentifier, jsVariablePolyfill } from './VariablePolyfill';
import { globalIdentifier, jsVariablePolyfill, rawStringLiteral } from './VariablePolyfill';
import type { DataNode } from './VariablePolyfill';
export interface ExpressionAnalysis {
@ -123,7 +123,7 @@ const buildFunctionBody = (expr: ExpressionKind) => {
b.binaryExpression('===', v, b.literal(false)),
),
v,
b.literal(''),
rawStringLiteral(''),
),
),
]);
@ -179,20 +179,16 @@ export const getExpressionCode = (
b.variableDeclaration('var', [b.variableDeclarator(globalIdentifier, b.objectExpression([]))]),
]);
// This is what's used to access that's passed in. For compatibility we us
// `this` unless the expression contains a function. If it contains an
// expression we instead assign a different variable to hold onto the contents
// of `this` (default: `___n8n_data`) since functions aren't compatibility
// anyway.
let dataNode: DataNode = b.thisExpression();
// Hold the passed-in `this` in a lexically-bound variable (default:
// `___n8n_data`) and route all data access and the sanitizer through it.
// A lexical binding cannot be reached by member shadowing the way `this`
// can, and the reserved-name guards already block rebinding it.
const dataNode: DataNode = b.identifier(dataNodeName);
newProg.body.push(
b.variableDeclaration('var', [b.variableDeclarator(dataNode, b.thisExpression())]),
);
// Reported back to callers, no longer used to shape the emitted code.
const hasFn = chunks.filter((c) => c.type === 'code').some((c) => hasFunction(c.parsed));
if (hasFn) {
dataNode = b.identifier(dataNodeName);
newProg.body.push(
b.variableDeclaration('var', [b.variableDeclarator(dataNode, b.thisExpression())]),
);
}
const hasTempString = chunks
.filter((c) => c.type === 'code')
.some((c) => hasTemplateString(c.parsed));
@ -211,7 +207,7 @@ export const getExpressionCode = (
for (const chunk of chunks) {
// This is just a text chunks, push it up as a literal.
if (chunk.type === 'text') {
parts.push(b.literal(chunk.text));
parts.push(rawStringLiteral(chunk.text));
// This is a code chunk so do some magic
} else {
const fixed = fixStringNewLines(chunk.parsed);
@ -264,7 +260,7 @@ export const getExpressionCode = (
newProg.body.push(
b.returnStatement(
b.callExpression(b.memberExpression(b.arrayExpression(parts), b.identifier('join')), [
b.literal(''),
rawStringLiteral(''),
]),
),
);

View File

@ -13,6 +13,26 @@ function assertNever(_value: never): _value is never {
return true;
}
// Captured at module load so printing stays stable even if the global is later replaced.
const safeStringify = JSON.stringify;
// A string literal carrying its own printed form. recast's printer emits `extra.raw`
// verbatim (when it matches the value) instead of stringifying at print time.
export const rawStringLiteral = (value: string) => {
const literal = b.literal(value);
// JSON.stringify leaves U+2028/U+2029 unescaped; since the raw form is printed
// verbatim, escape them so the emitted literal stays single-line and valid.
const raw = safeStringify(value)
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
// Attach `extra` in place so the ast-types node identity is preserved.
(literal as namedTypes.Literal & { extra?: { raw: string; rawValue: string } }).extra = {
raw,
rawValue: value,
};
return literal;
};
export const globalIdentifier = b.identifier(
// @ts-expect-error window not in lib target
typeof window !== 'object' ? 'global' : 'window',
@ -21,7 +41,7 @@ export const globalIdentifier = b.identifier(
const buildGlobalSwitch = (node: types.namedTypes.Identifier, dataNode: DataNode) => {
return b.memberExpression(
b.conditionalExpression(
b.binaryExpression('in', b.literal(node.name), dataNode),
b.binaryExpression('in', rawStringLiteral(node.name), dataNode),
dataNode,
globalIdentifier,
),

View File

@ -105,3 +105,52 @@ describe('jsVariablePolyfill', () => {
});
});
});
describe('generated code', () => {
it('generates identifier code independent of the global JSON.stringify', () => {
const [pristine] = evaluator.getExpressionCode('{{ someFreeName }}');
expect(pristine).toContain('"someFreeName"');
const bogus = 'BOGUS_STRINGIFY_OUTPUT';
const originalStringify = JSON.stringify;
let generated: string;
try {
JSON.stringify = (() => bogus) as unknown as typeof JSON.stringify;
[generated] = evaluator.getExpressionCode('{{ anotherFreeName }}');
} finally {
JSON.stringify = originalStringify;
}
expect(generated).not.toContain(bogus);
expect(generated).toBe(pristine.replace(/someFreeName/g, 'anotherFreeName'));
});
it('renders text chunks and the join separator independent of the global JSON.stringify', () => {
const [pristine] = evaluator.getExpressionCode('prefix {{ value }}');
expect(pristine).toContain('["prefix "');
expect(pristine).toContain('].join("")');
const bogus = 'BOGUS_STRINGIFY_OUTPUT';
const originalStringify = JSON.stringify;
let generated: string;
try {
JSON.stringify = (() => bogus) as unknown as typeof JSON.stringify;
[generated] = evaluator.getExpressionCode('prefix {{ value }}');
} finally {
JSON.stringify = originalStringify;
}
// The text chunk and join separator render verbatim; neither is routed
// through the global.
expect(generated).toContain('["prefix "');
expect(generated).toContain('].join("")');
});
it.each([
['a line separator', 'a\u2028b'],
['a paragraph separator', 'a\u2029b'],
['quotes, a backslash and a newline', 'he said "hi"\\\n\ttab'],
])('round-trips text chunks containing %s', (_, text) => {
expect(evaluator.execute(`${text} {{ 1 }}`, {})).toBe(`${text} 1`);
});
});

View File

@ -101,3 +101,18 @@ export class UnknownIdentifierError extends InterpreterError {
this.identifier = identifier;
}
}
/**
* Error thrown when interpretation exceeds a bound on statement count,
* total produced data, or expression nesting depth.
*/
export class ResourceLimitError extends InterpreterError {
readonly limitType: string;
/** Pass `detail` for sentence-style messages to avoid double-wrapping `limitType`. */
constructor(limitType: string, location?: SourceLocation, sourceCode?: string, detail?: string) {
super(detail ?? `Resource limit exceeded: '${limitType}'`, location, sourceCode);
this.name = 'ResourceLimitError';
this.limitType = limitType;
}
}

View File

@ -28,6 +28,7 @@ export {
UnsupportedNodeError,
SecurityError,
UnknownIdentifierError,
ResourceLimitError,
} from './errors';
export { parseSDKCode } from './parser';
export {

View File

@ -8,6 +8,7 @@ import {
SecurityError,
UnsupportedNodeError,
UnknownIdentifierError,
ResourceLimitError,
} from './errors';
import { expr } from '../expression';
import type { SDKFunctions } from './interpreter';
@ -1066,6 +1067,288 @@ describe('AST Interpreter', () => {
});
});
describe('Resource limits', () => {
let sdkFunctions: SDKFunctions;
beforeEach(() => {
sdkFunctions = createMockSDKFunctions();
});
it('should reject exponential array-spread doubling before completing', () => {
const doublings = 25; // 2^25 elements if unbounded — must abort far earlier
const lines = ['const a0 = [1];'];
for (let i = 1; i <= doublings; i++) {
lines.push(`const a${i} = [...a${i - 1}, ...a${i - 1}];`);
}
lines.push(`export default a${doublings};`);
const code = lines.join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should reject cumulative object-spread growth across many statements', () => {
// Object spread merges by key (duplicate keys overwrite rather than
// duplicate), so it can't blow up exponentially the way array spread
// does — but copying a large object's keys over and over still adds
// up, so the shared budget must catch it across statements.
const keyCount = 2000;
const props = Array.from({ length: keyCount }, (_, i) => `k${i}: ${i}`).join(', ');
// Each merge copies keyCount slots but adds only ~23 characters of
// source, so the cost outgrows the source-derived budget.
const mergeCount = 500;
const lines = [`const big = { ${props} };`];
for (let i = 0; i < mergeCount; i++) {
lines.push(`const m${i} = { ...big };`);
}
lines.push('export default m0;');
const code = lines.join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should reject a huge String.repeat count', () => {
const code = 'export default "a".repeat(100000000);';
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should reject a huge String.repeat count passed as a numeric string', () => {
// Native String.prototype.repeat coerces its argument, so a string-typed
// count must be charged the same as a numeric one.
const code = 'export default "a".repeat("100000000");';
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should reject exponential string growth from repeated + concatenation', () => {
const doublings = 25; // 2^25 characters if unbounded — must abort far earlier
const lines = ['const s0 = "a";'];
for (let i = 1; i <= doublings; i++) {
lines.push(`const s${i} = s${i - 1} + s${i - 1};`);
}
lines.push(`export default s${doublings};`);
const code = lines.join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should reject a reference DAG (Directed Acyclic Graph) that doubles without ever spreading', () => {
// [a, a] is two references, no copy — must still cost what `a` holds.
const doublings = 25;
const lines = ['const a0 = [1];'];
for (let i = 1; i <= doublings; i++) {
lines.push(`const a${i} = [a${i - 1}, a${i - 1}];`);
}
lines.push(`export default a${doublings};`);
const code = lines.join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should count empty containers when sizing repeated references', () => {
const doublings = 18;
const lines = ['const a0 = [];'];
for (let i = 1; i <= doublings; i++) {
lines.push(`const a${i} = [a${i - 1}, a${i - 1}];`);
}
lines.push(`export default a${doublings};`);
const code = lines.join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should reject chained JSON.stringify that doubles its own output each round', () => {
// Each round wraps the previous string in a 2-element array and
// re-serializes it. Serializing writes both references out in full,
// so the output doubles while the structure stays tiny.
const rounds = 15;
const lines = ['const s0 = "x".repeat(1000);'];
for (let i = 1; i <= rounds; i++) {
lines.push(`const a${i} = [s${i - 1}, s${i - 1}];`, `const s${i} = JSON.stringify(a${i});`);
}
lines.push(`export default s${rounds};`);
const code = lines.join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should reject a DAG whose members were filled in by property assignment', () => {
// Nothing is serialized here, so only the recorded size of `holder` can
// catch the doubling: left at the size it had when it was built, the
// whole chain measures empty.
const code = [
'const big = "x".repeat(50000);',
'const holder = {};',
'holder.a = big;',
'const a0 = [holder, holder];',
'const a1 = [a0, a0];',
'const a2 = [a1, a1];',
'export default a2;',
].join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should reject a DAG that repeats a long property name', () => {
// The name is written out once per occurrence, so it counts towards the
// output even though it is not part of any value. Sized like the case
// above, so a late limit would surface as a RangeError instead.
const doublings = 14;
const lines = [`const k = { "${'K'.repeat(20_000)}": 1 };`, 'const a0 = [k, k];'];
for (let i = 1; i <= doublings; i++) {
lines.push(`const a${i} = [a${i - 1}, a${i - 1}];`);
}
lines.push(`export default JSON.stringify(a${doublings});`);
const code = lines.join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should still allow ordinary property assignment', () => {
const code = [
'const cfg = {};',
'cfg.name = "hello";',
'cfg.items = [1, 2, 3];',
'export default JSON.stringify(cfg);',
].join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow();
});
it('should reject deep nesting serialized with a wide indent', () => {
// Indentation grows with depth, so it has to be counted per node: left
// out, this reaches the engine's string ceiling and raises a RangeError
// while building instead.
const depth = 4900;
const lines = ['const d0 = [1];'];
for (let i = 1; i <= depth; i++) {
lines.push(`const d${i} = [d${i - 1}];`);
}
lines.push(`export default JSON.stringify(d${depth}, null, 10);`);
const code = lines.join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should reject JSON.stringify traversing an externally produced oversized structure', () => {
// Untracked value (no tracked size) must still trip the JSON.stringify guard.
const bigArray = Array.from({ length: 200_001 }, (_, i) => i); // above the floor budget
const funcs: SDKFunctions = {
...sdkFunctions,
getBig: vi.fn(() => bigArray),
};
const code = 'export default JSON.stringify(getBig());';
expect(() => interpretSDKCode(code, funcs)).toThrow(ResourceLimitError);
});
it('should accumulate JSON.stringify traversal across separate calls', () => {
// Each call stays under the limit on its own; together they must not.
const bigArray = Array.from({ length: 50_000 }, (_, i) => i);
const funcs: SDKFunctions = {
...sdkFunctions,
getBig: vi.fn(() => bigArray),
};
const code = [
'const s1 = JSON.stringify(getBig());',
'const s2 = JSON.stringify(getBig());',
'const s3 = JSON.stringify(getBig());',
'const s4 = JSON.stringify(getBig());',
'const s5 = JSON.stringify(getBig());',
'export default s5;',
].join('\n');
expect(() => interpretSDKCode(code, funcs)).toThrow(ResourceLimitError);
});
it('should reject a JSON.stringify replacer, which would displace the size guard', () => {
const code = 'export default JSON.stringify({ a: 1, b: 2 }, ["a"]);';
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(UnsupportedNodeError);
});
it('should still allow JSON.stringify on a reasonably large legitimate structure', () => {
const code = [
'const items = [1, 2, 3, 4, 5];',
'export default JSON.stringify({ items, name: "test" });',
].join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow();
});
it('should allow a large embedded literal to be serialized into a parameter', () => {
// The documented way to embed a page in a Code node. Passing the same
// string along must not be charged again for every hop it makes.
const html = 'y'.repeat(150_000);
const code = [
`const html = "${html}";`,
'const jsCode = JSON.stringify(html);',
'export default { parameters: { jsCode } };',
].join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow();
});
it('should allow a multi-part concatenation around a large embedded literal', () => {
// Charging each step its whole result would make this cost O(n^2).
const html = 'y'.repeat(120_000);
const code = [
`const html = "${html}";`,
'const jsCode = "var h=" + JSON.stringify(html) + "; return h;";',
'export default { parameters: { jsCode } };',
].join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow();
});
it('should reject unbounded template-literal growth from repeated big values', () => {
const code = [
'const big = "x".repeat(150000);',
'const t1 = `${big}${big}`;',
'const t2 = `${t1}${t1}`;',
'export default t2;',
].join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should reject programs with too many top-level statements', () => {
const lines: string[] = [];
for (let i = 0; i < 6000; i++) {
lines.push(`const v${i} = ${i};`);
}
lines.push('export default v0;');
const code = lines.join('\n');
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
it('should still allow reasonably large legitimate literals and spreads', () => {
const items = Array.from({ length: 500 }, (_, i) => i).join(',');
const code = `const items = [${items}]; export default { ...{ items } };`;
expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow();
});
it('should cap unbounded recursion reached via template-literal runtime-variable detection', () => {
// Builds a deeply nested $-prefixed member chain inside a template literal,
// which is resolved via isN8nRuntimeVariable/expressionToString, not evaluate().
let expression = '$json';
for (let i = 0; i < 600; i++) {
expression += '.a';
}
const code = `export default \`\${${expression}}\`;`;
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
});
});
describe('expr(placeholder(...)) round-trip', () => {
it('prepends = to the placeholder marker so it parses as an n8n expression', () => {
const funcs: SDKFunctions = {

View File

@ -9,6 +9,7 @@ import {
UnsupportedNodeError,
UnknownIdentifierError,
SecurityError,
ResourceLimitError,
} from './errors';
import { parseSDKCode } from './parser';
import {
@ -37,17 +38,32 @@ export type SDKFunctions = Record<string, (...args: any[]) => unknown>;
*/
class SDKInterpreter {
private static readonly MAX_EVAL_DEPTH = 500;
private static readonly MAX_STATEMENT_COUNT = 5_000;
/** Smallest data budget any program gets, however short it is. */
private static readonly MIN_ALLOCATED_UNITS = 200_000;
/** Extra data budget per character of source, on top of that MIN_ALLOCATED_UNITS minimum. */
private static readonly ALLOCATION_AMPLIFICATION = 20;
private sdkFunctions: Map<string, (...args: unknown[]) => unknown>;
private variables: Map<string, unknown>;
private renamedVariables: Map<string, string> = new Map();
private sourceCode: string;
private evalDepth = 0;
/** Ceiling on the data this program may produce, derived in constructor from the input program length. */
private readonly maxAllocatedUnits: number;
/** How much of that ceiling has been used so far. */
private allocatedUnits = 0;
/** What each array/object holds, so referencing one can be sized without walking it again. */
private valueWeights = new WeakMap<object, number>();
constructor(sdkFunctions: SDKFunctions, sourceCode: string) {
this.sdkFunctions = new Map(Object.entries(sdkFunctions));
this.variables = new Map();
this.sourceCode = sourceCode;
this.maxAllocatedUnits = Math.max(
SDKInterpreter.MIN_ALLOCATED_UNITS,
sourceCode.length * SDKInterpreter.ALLOCATION_AMPLIFICATION,
);
}
private generateSafeName(name: string): string {
@ -65,6 +81,8 @@ class SDKInterpreter {
* Interpret the AST program and return the result.
*/
interpret(ast: ESTree.Program): unknown {
this.assertAstBodyLengthWithinLimits(ast);
let result: unknown;
for (const stmt of ast.body) {
@ -87,6 +105,24 @@ class SDKInterpreter {
return result;
}
/**
* Assert that the AST body length is within the allowed limits.
* Throws a ResourceLimitError if the limit is exceeded.
* @param ast
*/
private assertAstBodyLengthWithinLimits(ast: ESTree.Program): void {
if (ast.body.length <= SDKInterpreter.MAX_STATEMENT_COUNT) {
return;
}
throw new ResourceLimitError(
'statement-count',
ast.loc ?? undefined,
this.sourceCode,
`Program has ${ast.body.length} top-level statements; the limit is ${SDKInterpreter.MAX_STATEMENT_COUNT}.`,
);
}
/**
* Process a variable declaration.
*/
@ -140,16 +176,12 @@ class SDKInterpreter {
* Evaluate an expression and return its value.
*/
private evaluate(node: ESTree.Expression | ESTree.SpreadElement | null): unknown {
if (node === null) return undefined;
if (this.evalDepth >= SDKInterpreter.MAX_EVAL_DEPTH) {
throw new InterpreterError(
'Expression nesting too deep (possible cycle in method chain)',
node.loc ?? undefined,
this.sourceCode,
);
if (node === null) {
return undefined;
}
this.assertDepthWithinLimits(node);
this.evalDepth++;
try {
return this.evaluateNode(node);
@ -158,6 +190,142 @@ class SDKInterpreter {
}
}
/**
* Run a recursive helper under the same nesting-depth guard as `evaluate()`,
* for helpers that walk the AST directly instead of calling `evaluate()`.
*/
private withDepthGuard<T>(node: { loc?: ESTree.SourceLocation | null }, fn: () => T): T {
// Assert the depth on every node visit, not just on the initial evaluate() call, to catch cycles in method chains.
this.assertDepthWithinLimits(node);
this.evalDepth++;
try {
return fn();
} finally {
this.evalDepth--;
}
}
/**
* Assert that the current evaluation depth is within the allowed limits.
* Throws a ResourceLimitError if the limit is exceeded.
* @param node
*/
private assertDepthWithinLimits(node: { loc?: ESTree.SourceLocation | null }): void {
if (this.evalDepth < SDKInterpreter.MAX_EVAL_DEPTH) {
return;
}
throw new ResourceLimitError(
'nesting-depth',
node.loc ?? undefined,
this.sourceCode,
'Expression nesting too deep (possible cycle in method chain)',
);
}
private chargeBudget(units: number, location?: ESTree.SourceLocation): void {
if (units <= 0) {
return;
}
if (this.allocatedUnits + units <= this.maxAllocatedUnits) {
this.allocatedUnits += units;
return;
}
throw new ResourceLimitError(
'allocation-budget',
location,
this.sourceCode,
'SDK code produced or traversed too much data. Limit is ' +
`${this.maxAllocatedUnits} total elements/characters.`,
);
}
/** How much data a value holds: a string's length, an array/object's tracked total, otherwise 1. */
private sizeOf(value: unknown): number {
if (value !== null && typeof value === 'object') {
return Math.max(this.valueWeights.get(value) ?? 1, 1);
}
if (typeof value === 'string') {
return Math.max(value.length, 1);
}
return 1;
}
private assertValueSizeWithinLimits(size: number, location?: ESTree.SourceLocation): void {
if (size <= this.maxAllocatedUnits) {
return;
}
throw new ResourceLimitError(
'allocation-budget',
location,
this.sourceCode,
`A single value grew to ${size} elements/characters; the limit is ${this.maxAllocatedUnits}.`,
);
}
/**
* Spaces JSON.stringify adds per level of nesting, which it never passes to
* the replacer. It caps both forms at ten, so charging has to cap them too.
*/
private calculateIndentationWidth(space: unknown): number {
const maxCap = 10;
if (typeof space === 'number') {
return Math.max(Math.min(Math.trunc(space), maxCap), 0);
}
if (typeof space === 'string') {
return Math.min(space.length, maxCap);
}
return 0;
}
/**
* Serializing a POJO into JSON writes every repeated reference out in full, so the output can
* be much bigger than its input.
*/
private safeJSONStringify(
value: unknown,
space: unknown,
location?: ESTree.SourceLocation,
): string | undefined {
this.assertValueSizeWithinLimits(this.sizeOf(value), location);
const indentWidth = this.calculateIndentationWidth(space);
const depths = new WeakMap<object, number>();
const charge = (units: number) => this.chargeBudget(units, location);
/**
* The replacer is the only hook into the walk, so it is where an oversized value
* can be stopped before the whole string exists.
*/
const replacer = function (this: unknown, key: string, val: unknown) {
// `this` is the array or object holding this node, and a holder is always
// reached before its members, so its depth is already recorded.
const depth = key === '' ? 0 : (depths.get(this as object) ?? 0) + 1;
if (val !== null && typeof val === 'object') {
depths.set(val, depth);
}
charge(key.length + depth * indentWidth + (typeof val === 'string' ? val.length : 1));
return val;
};
const result = JSON.stringify(value, replacer, space as string | number | undefined);
if (typeof result === 'string') {
this.chargeBudget(result.length, location);
}
return result;
}
private evaluateNode(node: ESTree.Expression | ESTree.SpreadElement): unknown {
validateNodeType(node, this.sourceCode);
@ -255,6 +423,17 @@ class SDKInterpreter {
const safeMethod = getSafeJSONMethod(memberExpr.object.name, methodName);
if (safeMethod) {
const args = node.arguments.map((arg) => this.evaluate(arg));
if (methodName === 'stringify') {
// The replacer slot is reserved for the size guard below.
if (args[1] !== undefined && args[1] !== null) {
throw new UnsupportedNodeError(
'JSON.stringify replacer',
node.loc ?? undefined,
this.sourceCode,
);
}
return this.safeJSONStringify(args[0], args[2], node.loc ?? undefined);
}
return safeMethod(...args);
}
}
@ -265,6 +444,13 @@ class SDKInterpreter {
const safeStringMethod = getSafeStringMethod(thisArg, methodName);
if (safeStringMethod) {
const args = node.arguments.map((arg) => this.evaluate(arg));
if (methodName === 'repeat' && typeof thisArg === 'string') {
// Coerce like native String.prototype.repeat does, so a numeric
// string (e.g. "1e9") can't dodge the charge below.
const coercedCount = Number(args[0]);
const count = Number.isFinite(coercedCount) && coercedCount > 0 ? coercedCount : 0;
this.chargeBudget(thisArg.length * count, node.loc ?? undefined);
}
return safeStringMethod(...args);
}
@ -354,13 +540,20 @@ class SDKInterpreter {
*/
private visitObjectExpression(node: ESTree.ObjectExpression): Record<string, unknown> {
const result: Record<string, unknown> = {};
let weight = 0;
for (const prop of node.properties) {
if (prop.type === 'SpreadElement') {
// Handle spread: { ...obj }
const spreadValue = this.evaluate(prop.argument);
if (spreadValue && typeof spreadValue === 'object') {
Object.assign(result, spreadValue);
for (const key of Object.keys(spreadValue)) {
const value = (spreadValue as Record<string, unknown>)[key];
this.chargeBudget(1, prop.loc ?? undefined);
result[key] = value;
weight += this.sizeOf(value);
}
}
} else if (prop.type === 'Property') {
// Get key
@ -381,10 +574,14 @@ class SDKInterpreter {
// Get value (handle shorthand: { name } === { name: name })
const value = this.evaluate(prop.value as ESTree.Expression);
this.chargeBudget(1, prop.loc ?? undefined);
result[key] = value;
weight += this.sizeOf(value);
}
}
this.assertValueSizeWithinLimits(weight, node.loc ?? undefined);
this.valueWeights.set(result, weight);
return result;
}
@ -393,22 +590,31 @@ class SDKInterpreter {
*/
private visitArrayExpression(node: ESTree.ArrayExpression): unknown[] {
const result: unknown[] = [];
let weight = 0;
for (const element of node.elements) {
if (element === null) {
result.push(undefined);
weight += 1;
} else if (element.type === 'SpreadElement') {
const spreadValue = this.evaluate(element.argument);
if (Array.isArray(spreadValue)) {
for (const item of spreadValue) {
this.chargeBudget(1, element.loc ?? undefined);
result.push(item);
weight += this.sizeOf(item);
}
}
} else {
result.push(this.evaluate(element));
const value = this.evaluate(element);
this.chargeBudget(1, element.loc ?? undefined);
result.push(value);
weight += this.sizeOf(value);
}
}
this.assertValueSizeWithinLimits(weight, node.loc ?? undefined);
this.valueWeights.set(result, weight);
return result;
}
@ -461,6 +667,8 @@ class SDKInterpreter {
if (i < node.expressions.length) {
const expr = node.expressions[i];
const value = this.evaluateTemplateExpression(expr);
// charge 1 unit to the budget for each character in the evaluated expression to avoid huge template literal output
this.chargeBudget(value.length, expr.loc ?? undefined);
result += value;
}
}
@ -490,21 +698,27 @@ class SDKInterpreter {
* These start with $ like $json, $today, $input, etc.
*/
private isN8nRuntimeVariable(expr: ESTree.Expression): boolean {
if (expr.type === 'Identifier') {
return expr.name.startsWith('$');
}
if (expr.type === 'MemberExpression') {
return this.isN8nRuntimeVariable(expr.object as ESTree.Expression);
}
if (expr.type === 'CallExpression') {
if (expr.callee.type === 'Identifier') {
return expr.callee.name.startsWith('$');
return this.withDepthGuard(expr, () => {
if (expr.type === 'Identifier') {
return expr.name.startsWith('$');
}
if (expr.callee.type === 'MemberExpression') {
return this.isN8nRuntimeVariable(expr.callee.object as ESTree.Expression);
if (expr.type === 'MemberExpression') {
return this.isN8nRuntimeVariable(expr.object as ESTree.Expression);
}
}
return false;
if (expr.type === 'CallExpression') {
if (expr.callee.type === 'Identifier') {
return expr.callee.name.startsWith('$');
}
if (expr.callee.type === 'MemberExpression') {
return this.isN8nRuntimeVariable(expr.callee.object as ESTree.Expression);
}
}
return false;
});
}
/**
@ -512,67 +726,81 @@ class SDKInterpreter {
* Used for preserving n8n runtime variables in template literals.
*/
private expressionToString(expr: ESTree.Expression): string {
if (expr.type === 'Identifier') {
return expr.name;
}
if (expr.type === 'MemberExpression') {
const obj = this.expressionToString(expr.object as ESTree.Expression);
if (expr.computed) {
if (expr.property.type === 'Literal') {
return `${obj}[${JSON.stringify(expr.property.value)}]`;
}
return `${obj}[${this.expressionToString(expr.property as ESTree.Expression)}]`;
return this.withDepthGuard(expr, () => {
if (expr.type === 'Identifier') {
return expr.name;
}
const prop = expr.property.type === 'Identifier' ? expr.property.name : '';
return `${obj}.${prop}`;
}
if (expr.type === 'CallExpression') {
const callee = this.expressionToString(expr.callee as ESTree.Expression);
const args = expr.arguments
.map((arg) => {
if (arg.type === 'SpreadElement') {
return '...' + this.expressionToString(arg.argument);
if (expr.type === 'MemberExpression') {
const obj = this.expressionToString(expr.object as ESTree.Expression);
if (expr.computed) {
if (expr.property.type === 'Literal') {
return `${obj}[${JSON.stringify(expr.property.value)}]`;
}
return this.expressionToString(arg);
})
.join(', ');
return `${callee}(${args})`;
}
if (expr.type === 'Literal') {
if (typeof expr.value === 'string') {
return JSON.stringify(expr.value);
return `${obj}[${this.expressionToString(expr.property as ESTree.Expression)}]`;
}
const prop = expr.property.type === 'Identifier' ? expr.property.name : '';
return `${obj}.${prop}`;
}
return String(expr.value);
}
if (expr.type === 'BinaryExpression' || expr.type === 'LogicalExpression') {
const left = this.expressionToString(expr.left as ESTree.Expression);
const right = this.expressionToString(expr.right);
return `${left} ${expr.operator} ${right}`;
}
if (expr.type === 'ConditionalExpression') {
const test = this.expressionToString(expr.test);
const consequent = this.expressionToString(expr.consequent);
const alternate = this.expressionToString(expr.alternate);
return `${test} ? ${consequent} : ${alternate}`;
}
if (expr.type === 'UnaryExpression') {
const unary = expr;
const arg = this.expressionToString(unary.argument);
return unary.prefix ? `${unary.operator}${arg}` : `${arg}${unary.operator}`;
}
// Fallback: extract from source code if we have location info
if (expr.loc && this.sourceCode) {
const lines = this.sourceCode.split('\n');
const startLine = expr.loc.start.line - 1;
const endLine = expr.loc.end.line - 1;
if (expr.type === 'CallExpression') {
const callee = this.expressionToString(expr.callee as ESTree.Expression);
const args = expr.arguments
.map((arg) => {
if (arg.type === 'SpreadElement') {
return '...' + this.expressionToString(arg.argument);
}
return this.expressionToString(arg);
})
.join(', ');
if (startLine === endLine) {
return lines[startLine].slice(expr.loc.start.column, expr.loc.end.column);
return `${callee}(${args})`;
}
}
return '';
if (expr.type === 'Literal') {
if (typeof expr.value === 'string') {
return JSON.stringify(expr.value);
}
return String(expr.value);
}
if (expr.type === 'BinaryExpression' || expr.type === 'LogicalExpression') {
const left = this.expressionToString(expr.left as ESTree.Expression);
const right = this.expressionToString(expr.right);
return `${left} ${expr.operator} ${right}`;
}
if (expr.type === 'ConditionalExpression') {
const test = this.expressionToString(expr.test);
const consequent = this.expressionToString(expr.consequent);
const alternate = this.expressionToString(expr.alternate);
return `${test} ? ${consequent} : ${alternate}`;
}
if (expr.type === 'UnaryExpression') {
const unary = expr;
const arg = this.expressionToString(unary.argument);
return unary.prefix ? `${unary.operator}${arg}` : `${arg}${unary.operator}`;
}
// Fallback: extract from source code if we have location info
if (expr.loc && this.sourceCode) {
const lines = this.sourceCode.split('\n');
const startLine = expr.loc.start.line - 1;
const endLine = expr.loc.end.line - 1;
if (startLine === endLine) {
return lines[startLine].slice(expr.loc.start.column, expr.loc.end.column);
}
}
return '';
});
}
/**
@ -618,6 +846,9 @@ class SDKInterpreter {
switch (node.operator) {
case '+':
if (typeof left === 'string' || typeof right === 'string') {
// Only the appended part is new; charging the whole result would
// make an n-piece chain cost O(n^2) for linear output.
this.chargeBudget(String(right).length, node.loc ?? undefined);
return String(left) + String(right);
}
return (left as number) + (right as number);
@ -744,7 +975,16 @@ class SDKInterpreter {
}
const value = this.evaluate(node.right);
this.chargeBudget(1, node.loc ?? undefined);
(obj as Record<string | number, unknown>)[propName] = value;
// Assigning grows the target, so its recorded size has to grow with it or
// every later check reads the size it had when it was built. An overwrite
// is counted as an addition, which overstates it in the safe direction.
const grown = this.sizeOf(obj) + this.sizeOf(value);
this.assertValueSizeWithinLimits(grown, node.loc ?? undefined);
this.valueWeights.set(obj as object, grown);
return value;
}

View File

@ -421,4 +421,5 @@ export const SDK_INLINE_CONSTRAINTS: readonly string[] = [
'No destructuring in declarations — declare each value with its own `const`.',
'No variable reassignment — only property assignment (`obj.prop = value`) is allowed.',
"No computed/dynamic member access (`obj[expr]`); use static names. Literal keys like `obj['a']` or `arr[0]` are fine.",
'`JSON.stringify(value)` takes no replacer argument — pass `null` if you need the indentation argument.',
];

View File

@ -176,4 +176,24 @@ describe('parseWorkflowCodeToBuilder', () => {
);
});
});
describe('resource limits', () => {
it('should surface a resource-limit violation as a SyntaxError', () => {
const doublings = 25;
const lines = ['const a0 = [1];'];
for (let i = 1; i <= doublings; i++) {
lines.push(`const a${i} = [...a${i - 1}, ...a${i - 1}];`);
}
lines.push(`export default a${doublings};`);
const code = lines.join('\n');
// Match the specific message, not just SyntaxError: exporting a bare
// array is also invalid on its own (wrong export type), so asserting
// only the error class would pass even if the resource limit were
// never enforced.
expect(() => parseWorkflowCodeToBuilder(code)).toThrow(/too much data/);
});
});
});

View File

@ -5,6 +5,7 @@ import type { GlobalConfig, WorkflowsConfig } from '@n8n/config';
import type { Project, WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db';
import type { UpdateResult } from '@n8n/typeorm';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { sleep } from '@n8n/utils/sleep';
import type { ErrorReporter, InstanceSettings } from 'n8n-core';
import {
ActiveWorkflowTriggers,
@ -26,7 +27,6 @@ import type {
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { sleep } from '@n8n/utils/sleep';
import { Workflow, WorkflowActivationError } from 'n8n-workflow';
import { mock, type MockProxy } from 'vitest-mock-extended';
@ -49,6 +49,8 @@ import type { OwnershipService } from '@/services/ownership.service';
import type { PollCursorService } from '@/workflows/triggers/poll-cursor.service';
import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory';
import type { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import type { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
describe('ActiveWorkflowManager', () => {
@ -85,11 +87,11 @@ describe('ActiveWorkflowManager', () => {
instanceSettings,
mock(),
workflowsConfig,
mock(),
mock<TriggerExecutionContextFactory>(),
mock(),
mock(), // scheduleTriggerJobRegistrar
mock(), // pollTriggerJobRegistrar
mock(), // workflowPushNotifier
policyEnforcementService,
ownershipService,
);
@ -223,9 +225,9 @@ describe('ActiveWorkflowManager', () => {
instanceSettings,
mock(),
policyWorkflowsConfig,
mock(),
mock<TriggerExecutionContextFactory>(),
mock(),
mock(),
mock(), // scheduleTriggerJobRegistrar
mock(), // pollTriggerJobRegistrar
policyEnforcementService,
@ -414,8 +416,13 @@ describe('ActiveWorkflowManager', () => {
describe('handleAddWebhooksAndNonWebhookTriggers', () => {
const push = mock<Push>();
const publisher = mock<Publisher>();
const workflowSharingService = mock<WorkflowSharingService>();
const workflowPushNotifier = new WorkflowPushNotifier(push, workflowSharingService);
const sharedUserIds = ['user-1', 'user-2'];
beforeEach(() => {
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(sharedUserIds);
publisher.publishCommand.mockResolvedValue(undefined);
activeWorkflowManager = new ActiveWorkflowManager(
mockLogger(),
mock(),
@ -430,11 +437,11 @@ describe('ActiveWorkflowManager', () => {
instanceSettings,
publisher,
mock(),
push,
mock<TriggerExecutionContextFactory>(),
mock(),
mock(), // scheduleTriggerJobRegistrar
mock(), // pollTriggerJobRegistrar
workflowPushNotifier,
policyEnforcementService,
ownershipService,
);
@ -460,8 +467,8 @@ describe('ActiveWorkflowManager', () => {
expect(clearWebhooksSpy).not.toHaveBeenCalled();
expect(removeTriggersSpy).not.toHaveBeenCalled();
// The user is still told why it did not start.
expect(push.broadcast).toHaveBeenCalledWith(
expect.objectContaining({ type: 'workflowFailedToActivate' }),
expect(push.sendToUsers.mock.calls.map(([msg]) => msg.type)).toContain(
'workflowFailedToActivate',
);
});
@ -479,14 +486,20 @@ describe('ActiveWorkflowManager', () => {
activationMode: 'activate',
});
expect(push.broadcast).toHaveBeenCalledWith({
type: 'workflowFailedToActivate',
data: {
workflowId: 'wf-1',
errorMessage: 'Invalid role: admin required',
nodeId: 'node-123',
expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith(
'wf-1',
);
expect(push.sendToUsers).toHaveBeenCalledWith(
{
type: 'workflowFailedToActivate',
data: {
workflowId: 'wf-1',
errorMessage: 'Invalid role: admin required',
nodeId: 'node-123',
},
},
});
sharedUserIds,
);
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'display-workflow-activation-error',
@ -507,10 +520,13 @@ describe('ActiveWorkflowManager', () => {
activationMode: 'activate',
});
expect(push.broadcast).toHaveBeenCalledWith({
type: 'workflowFailedToActivate',
data: { workflowId: 'wf-1', errorMessage: 'Some error' },
});
expect(push.sendToUsers).toHaveBeenCalledWith(
{
type: 'workflowFailedToActivate',
data: { workflowId: 'wf-1', errorMessage: 'Some error' },
},
sharedUserIds,
);
});
test('should tear down partial registrations before deactivating when activation fails', async () => {
@ -580,6 +596,180 @@ describe('ActiveWorkflowManager', () => {
activeVersionId: null,
});
});
test('should push workflowActivated to accessible users and relay it on successful activation', async () => {
vi.spyOn(activeWorkflowManager, 'add').mockResolvedValue({
webhooks: true,
triggersAndPollers: true,
});
await activeWorkflowManager.handleAddWebhooksAndNonWebhookTriggers({
workflowId: 'wf-1',
activeVersionId: 'v1',
activationMode: 'activate',
});
expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith(
'wf-1',
);
expect(push.sendToUsers).toHaveBeenCalledWith(
{ type: 'workflowActivated', data: { workflowId: 'wf-1', activeVersionId: 'v1' } },
sharedUserIds,
);
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'display-workflow-activation',
payload: { workflowId: 'wf-1', activeVersionId: 'v1' },
});
expect(workflowRepository.update).not.toHaveBeenCalled();
});
test('should not roll back a successfully activated workflow when nobody can be resolved to notify', async () => {
vi.spyOn(activeWorkflowManager, 'add').mockResolvedValue({
webhooks: true,
triggersAndPollers: true,
});
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValueOnce([]);
await activeWorkflowManager.handleAddWebhooksAndNonWebhookTriggers({
workflowId: 'wf-1',
activeVersionId: 'v1',
activationMode: 'activate',
});
expect(push.sendToUsers).toHaveBeenCalledWith(
{ type: 'workflowActivated', data: { workflowId: 'wf-1', activeVersionId: 'v1' } },
[],
);
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'display-workflow-activation',
payload: { workflowId: 'wf-1', activeVersionId: 'v1' },
});
expect(workflowRepository.update).not.toHaveBeenCalled();
});
test('should not roll back a successfully activated workflow even if the recipient lookup itself throws', async () => {
vi.spyOn(activeWorkflowManager, 'add').mockResolvedValue({
webhooks: true,
triggersAndPollers: true,
});
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockRejectedValueOnce(
new Error('db unavailable'),
);
await expect(
activeWorkflowManager.handleAddWebhooksAndNonWebhookTriggers({
workflowId: 'wf-1',
activeVersionId: 'v1',
activationMode: 'activate',
}),
).rejects.toThrow('db unavailable');
expect(workflowRepository.update).not.toHaveBeenCalled();
expect(push.sendToUsers).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'workflowFailedToActivate' }),
expect.anything(),
);
});
});
describe('display and removal pubsub handlers', () => {
const push = mock<Push>();
const publisher = mock<Publisher>();
const workflowSharingService = mock<WorkflowSharingService>();
const workflowPushNotifier = new WorkflowPushNotifier(push, workflowSharingService);
const sharedUserIds = ['user-1', 'user-2'];
beforeEach(() => {
vi.clearAllMocks();
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(sharedUserIds);
activeWorkflowManager = new ActiveWorkflowManager(
mockLogger(),
mock(),
mock(),
mock(),
nodeTypes,
mock(),
workflowRepository,
mock(),
mock(),
mock(),
instanceSettings,
publisher,
mock(),
mock<TriggerExecutionContextFactory>(),
mock(),
mock(), // scheduleTriggerJobRegistrar
mock(), // pollTriggerJobRegistrar
workflowPushNotifier,
policyEnforcementService,
ownershipService,
);
});
test.each([
{
name: 'handleDisplayWorkflowActivation',
call: async () =>
await activeWorkflowManager.handleDisplayWorkflowActivation({
workflowId: 'wf-1',
activeVersionId: 'v1',
}),
expectedMsg: {
type: 'workflowActivated',
data: { workflowId: 'wf-1', activeVersionId: 'v1' },
},
},
{
name: 'handleDisplayWorkflowDeactivation',
call: async () =>
await activeWorkflowManager.handleDisplayWorkflowDeactivation({ workflowId: 'wf-1' }),
expectedMsg: { type: 'workflowDeactivated', data: { workflowId: 'wf-1' } },
},
{
name: 'handleDisplayWorkflowActivationError',
call: async () =>
await activeWorkflowManager.handleDisplayWorkflowActivationError({
workflowId: 'wf-1',
errorMessage: 'boom',
nodeId: 'node-1',
}),
expectedMsg: {
type: 'workflowFailedToActivate',
data: {
workflowId: 'wf-1',
errorMessage: 'boom',
errorDescription: undefined,
nodeId: 'node-1',
},
},
},
])('$name sends its push message only to accessible users', async ({ call, expectedMsg }) => {
await call();
expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith(
'wf-1',
);
expect(push.sendToUsers).toHaveBeenCalledWith(expectedMsg, sharedUserIds);
});
test('handleRemoveNonWebhookTriggers sends workflowDeactivated only to accessible users and relays it', async () => {
vi.spyOn(activeWorkflowManager, 'removeActivationError').mockResolvedValue();
vi.spyOn(activeWorkflowManager, 'removeNonWebhookTriggers').mockResolvedValue();
await activeWorkflowManager.handleRemoveNonWebhookTriggers({ workflowId: 'wf-1' });
expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith(
'wf-1',
);
expect(push.sendToUsers).toHaveBeenCalledWith(
{ type: 'workflowDeactivated', data: { workflowId: 'wf-1' } },
sharedUserIds,
);
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'display-workflow-deactivation',
payload: { workflowId: 'wf-1' },
});
});
});
describe('activateWorkflow', () => {
@ -715,11 +905,11 @@ describe('ActiveWorkflowManager', () => {
instanceSettings,
mock(), // publisher
workflowsConfig,
mock(), // push
factory,
mock(), // eventBus
mock(), // scheduleTriggerJobRegistrar
mock(), // pollTriggerJobRegistrar
mock(), // workflowPushNotifier
policyEnforcementService,
ownershipService,
);
@ -1168,11 +1358,11 @@ describe('ActiveWorkflowManager', () => {
instanceSettings,
mock(),
workflowsConfig,
mock(),
mock<TriggerExecutionContextFactory>(),
mock(),
scheduleTriggerJobRegistrar,
pollTriggerJobRegistrar,
mock(), // workflowPushNotifier
policyEnforcementService,
ownershipService,
);
@ -1356,11 +1546,11 @@ describe('ActiveWorkflowManager', () => {
mock<InstanceSettings>({ isMultiMain: true }),
publisher,
workflowsConfig,
mock(),
mock<TriggerExecutionContextFactory>(),
mock(),
scheduleTriggerJobRegistrar,
pollTriggerJobRegistrar,
mock(), // workflowPushNotifier
policyEnforcementService,
ownershipService,
);
@ -1435,11 +1625,11 @@ describe('ActiveWorkflowManager', () => {
instanceSettings,
mock(),
workflowsConfig,
mock(),
mock<TriggerExecutionContextFactory>(),
mock(),
scheduleTriggerJobRegistrar,
mock(), // pollTriggerJobRegistrar
mock(), // workflowPushNotifier
policyEnforcementService,
ownershipService,
);

View File

@ -1,16 +1,11 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import {
STARTING_NODES,
TRIGGER_COUNT_EXCLUDED_NODES,
WORKFLOW_REACTIVATE_INITIAL_TIMEOUT,
WORKFLOW_REACTIVATE_MAX_TIMEOUT,
} from '@/constants';
import { Logger } from '@n8n/backend-common';
import { WorkflowsConfig } from '@n8n/config';
import type { WorkflowEntity, IWorkflowDb } from '@n8n/db';
import { WorkflowRepository } from '@n8n/db';
import { OnLeaderStepdown, OnLeaderTakeover, OnPubSubEvent, OnShutdown } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import chunk from 'lodash/chunk';
import {
ActiveWorkflowTriggers,
@ -29,7 +24,6 @@ import type {
INodeType,
WorkflowId,
} from 'n8n-workflow';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import {
Workflow,
WorkflowActivationError,
@ -41,25 +35,31 @@ import {
import { strict } from 'node:assert';
import { ActivationErrorsService } from '@/activation-errors.service';
import {
STARTING_NODES,
TRIGGER_COUNT_EXCLUDED_NODES,
WORKFLOW_REACTIVATE_INITIAL_TIMEOUT,
WORKFLOW_REACTIVATE_MAX_TIMEOUT,
} from '@/constants';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { ExternalHooks } from '@/external-hooks';
import { NodeTypes } from '@/node-types';
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { isPolicyRefusal } from '@/policy/policy-violation.error';
import { Push } from '@/push';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
import type { ScheduleTriggerCollectionSession } from '@/scheduling/schedule-trigger-node/schedule-trigger-job-registrar';
import { PollTriggerJobRegistrar } from '@/scheduling/poll-trigger-node/poll-trigger-job-registrar';
import type { ScheduleTriggerCollectionSession } from '@/scheduling/schedule-trigger-node/schedule-trigger-job-registrar';
import { ScheduleTriggerJobRegistrar } from '@/scheduling/schedule-trigger-node/schedule-trigger-job-registrar';
import { ActiveWorkflowsService } from '@/services/active-workflows.service';
import { OwnershipService } from '@/services/ownership.service';
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
import { WebhookService } from '@/webhooks/webhook.service';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory';
import { getErrorDescription, getErrorNodeId } from '@/workflows/utils';
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
import { formatWorkflow } from '@/workflows/workflow.formatter';
interface QueuedActivation {
@ -87,11 +87,11 @@ export class ActiveWorkflowManager {
private readonly instanceSettings: InstanceSettings,
private readonly publisher: Publisher,
private readonly workflowsConfig: WorkflowsConfig,
private readonly push: Push,
private readonly triggerExecutionContextFactory: TriggerExecutionContextFactory,
private readonly eventBus: MessageEventBus,
private readonly scheduleTriggerJobRegistrar: ScheduleTriggerJobRegistrar,
private readonly pollTriggerJobRegistrar: PollTriggerJobRegistrar,
private readonly workflowPushNotifier: WorkflowPushNotifier,
private readonly policyEnforcementService: PolicyEnforcementService,
private readonly ownershipService: OwnershipService,
) {
@ -699,20 +699,26 @@ export class ActiveWorkflowManager {
}
@OnPubSubEvent('display-workflow-activation', { instanceType: 'main' })
handleDisplayWorkflowActivation({
async handleDisplayWorkflowActivation({
workflowId,
activeVersionId,
}: PubSubCommandMap['display-workflow-activation']) {
this.push.broadcast({ type: 'workflowActivated', data: { workflowId, activeVersionId } });
await this.workflowPushNotifier.notify(workflowId, {
type: 'workflowActivated',
data: { workflowId, activeVersionId },
});
}
@OnPubSubEvent('display-workflow-deactivation', { instanceType: 'main' })
handleDisplayWorkflowDeactivation({ workflowId }: { workflowId: string }) {
this.push.broadcast({ type: 'workflowDeactivated', data: { workflowId } });
async handleDisplayWorkflowDeactivation({ workflowId }: { workflowId: string }) {
await this.workflowPushNotifier.notify(workflowId, {
type: 'workflowDeactivated',
data: { workflowId },
});
}
@OnPubSubEvent('display-workflow-activation-error', { instanceType: 'main' })
handleDisplayWorkflowActivationError({
async handleDisplayWorkflowActivationError({
workflowId,
errorMessage,
errorDescription,
@ -723,7 +729,7 @@ export class ActiveWorkflowManager {
errorDescription?: string;
nodeId?: string;
}) {
this.push.broadcast({
await this.workflowPushNotifier.notify(workflowId, {
type: 'workflowFailedToActivate',
data: { workflowId, errorMessage, errorDescription, nodeId },
});
@ -742,13 +748,6 @@ export class ActiveWorkflowManager {
await this.add(workflowId, activationMode, undefined, {
shouldPublish: false, // prevent leader from re-publishing message
});
this.push.broadcast({ type: 'workflowActivated', data: { workflowId, activeVersionId } });
await this.publisher.publishCommand({
command: 'display-workflow-activation',
payload: { workflowId, activeVersionId },
}); // instruct followers to show activation in UI
} catch (e) {
const error = ensureError(e);
const { message } = error;
@ -811,7 +810,7 @@ export class ActiveWorkflowManager {
}
}
this.push.broadcast({
await this.workflowPushNotifier.notify(workflowId, {
type: 'workflowFailedToActivate',
data: { workflowId, errorMessage: message, nodeId, errorDescription },
});
@ -820,7 +819,24 @@ export class ActiveWorkflowManager {
command: 'display-workflow-activation-error',
payload: { workflowId, errorMessage: message, nodeId, errorDescription },
}); // instruct followers to show activation error in UI
return;
}
// Activation already succeeded above; notifying about it happens
// outside the try/catch so it can't be mistaken for an activation failure.
await this.workflowPushNotifier.notify(workflowId, {
type: 'workflowActivated',
data: { workflowId, activeVersionId },
});
// Not awaited: a relay failure must not fail this pubsub handler.
void this.publisher
.publishCommand({
command: 'display-workflow-activation',
payload: { workflowId, activeVersionId },
}) // instruct followers to show activation in UI
.catch((error) => this.errorReporter.error(ensureError(error), { shouldBeLogged: true }));
}
/**
@ -1010,7 +1026,10 @@ export class ActiveWorkflowManager {
await this.removeActivationError(workflowId);
await this.removeNonWebhookTriggers(workflowId);
this.push.broadcast({ type: 'workflowDeactivated', data: { workflowId } });
await this.workflowPushNotifier.notify(workflowId, {
type: 'workflowDeactivated',
data: { workflowId },
});
// instruct followers to show workflow deactivation in UI
await this.publisher.publishCommand({

View File

@ -44,6 +44,7 @@ describe('ChatExecutionManager', () => {
const execution = { id: '1', workflowData: {}, data: {} } as IExecutionResponse;
const message = { sessionId: '123', action: 'sendMessage', chatInput: 'input' } as ChatMessage;
vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(true);
vi.spyOn(chatExecutionManager as any, 'getRunData').mockRejectedValue(new Error('Test error'));
await expect(chatExecutionManager.runWorkflow(execution, message)).rejects.toThrow(
@ -61,6 +62,7 @@ describe('ChatExecutionManager', () => {
} as ChatMessage;
const runData = { executionMode: 'manual', executionData: {}, workflowData: {} } as any;
vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(true);
vi.spyOn(chatExecutionManager as any, 'getRunData').mockResolvedValue(runData);
await chatExecutionManager.runWorkflow(execution, message);
@ -70,9 +72,34 @@ describe('ChatExecutionManager', () => {
expectedStatus: 'waiting',
});
});
it('refuses to run when the suspended node is not chat-driven', async () => {
const execution = { id: '1', workflowData: {}, data: {} } as IExecutionResponse;
const message = {
sessionId: '123',
action: 'sendMessage',
chatInput: 'input',
} as ChatMessage;
vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(false);
const getRunDataSpy = vi.spyOn(chatExecutionManager as any, 'getRunData');
await expect(chatExecutionManager.runWorkflow(execution, message)).rejects.toThrow(
'Refusing to resume a non-chat node over chat',
);
expect(getRunDataSpy).not.toHaveBeenCalled();
expect(workflowRunner.run).not.toHaveBeenCalled();
});
});
describe('cancelExecution', () => {
beforeEach(() => {
// The status-transition tests below exercise the cancel logic itself; the
// chat-ownership gate is covered by its own tests. Treat the execution as
// chat-driven so these focus on status handling.
vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(true);
});
it('should update execution status to canceled if it is running', async () => {
const executionId = '1';
const execution = { id: executionId, status: 'running' } as any;
@ -125,6 +152,44 @@ describe('ChatExecutionManager', () => {
expect(executionRepository.update).not.toHaveBeenCalled();
});
it('should not cancel a waiting execution the chat socket does not drive', async () => {
// A socket attached to a non-chat execution (e.g. a Send-and-Wait gate via
// a leaked token) must not cancel it by going idle.
vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(false);
const executionId = '9';
const execution = { id: executionId, status: 'waiting' } as any;
executionPersistence.findSingleExecution.mockResolvedValue(execution);
await chatExecutionManager.cancelExecution(executionId);
expect(executionRepository.update).not.toHaveBeenCalled();
});
it('does not throw or cancel when the parked node type is unresolvable', async () => {
// Use the real isChatDrivenExecution (the describe's beforeEach stubs it) so
// the resolution failure is exercised: it must be swallowed, so cancelExecution
// returns and the heartbeat loop can still clean up the session.
vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockRestore();
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockImplementation(() => {
throw new Error('Unrecognized node type: some.uninstalled.node');
});
const execution = {
id: '9',
status: 'waiting',
workflowData: { id: 'wf', nodes: [] },
data: {
executionData: {
nodeExecutionStack: [{ node: { name: 'X', type: 'x' }, data: { main: [[]] } }],
},
},
} as any;
executionPersistence.findSingleExecution.mockResolvedValue(execution);
await expect(chatExecutionManager.cancelExecution('9')).resolves.toBeUndefined();
expect(executionRepository.update).not.toHaveBeenCalled();
});
});
describe('findExecution', () => {
@ -424,7 +489,9 @@ describe('ChatExecutionManager', () => {
workflowData: { id: 'workflowId' },
data: {
resultData: { lastNodeExecuted: 'nodeId' },
executionData: { nodeExecutionStack: [{ data: { main: [[]] } }] },
executionData: {
nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[]] } }],
},
},
mode: 'manual',
} as any;
@ -474,7 +541,9 @@ describe('ChatExecutionManager', () => {
workflowData: { id: 'workflowId' },
data: {
resultData: { lastNodeExecuted: 'nodeId' },
executionData: { nodeExecutionStack: [{ data: { main: [[{}]] } }] },
executionData: {
nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[{}]] } }],
},
},
mode: 'manual',
} as any;
@ -510,7 +579,9 @@ describe('ChatExecutionManager', () => {
workflowData: { id: 'workflowId' },
data: {
resultData: { lastNodeExecuted: 'nodeId' },
executionData: { nodeExecutionStack: [{ data: { main: [[{}]] } }] },
executionData: {
nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[{}]] } }],
},
},
mode: 'manual',
} as any;
@ -551,7 +622,9 @@ describe('ChatExecutionManager', () => {
workflowData: { id: 'workflowId' },
data: {
resultData: { lastNodeExecuted: 'nodeId' },
executionData: { nodeExecutionStack: [{ data: { main: [[{}]] } }] },
executionData: {
nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[{}]] } }],
},
},
mode: 'manual',
} as any;
@ -703,4 +776,204 @@ describe('ChatExecutionManager', () => {
});
});
});
describe('canResumeOverChat', () => {
// A chat message may only resume a node whose resolved type implements the
// onMessage hook. Everything else (Send-and-Wait gates, non-chat HITL tools,
// plain Wait) must be refused regardless of the token presented.
function makeExecution(
nodeType: string,
parameters: Record<string, unknown> = {},
nodeName = 'parkedNode',
): IExecutionResponse {
const node = { name: nodeName, type: nodeType, typeVersion: 1, parameters };
return {
id: '1',
workflowData: { id: 'wf', nodes: [node] },
data: {
resultData: { lastNodeExecuted: nodeName, runData: {} },
executionData: { nodeExecutionStack: [{ node, data: { main: [[]] } }] },
},
} as unknown as IExecutionResponse;
}
function mockWorkflow(execution: IExecutionResponse, hasOnMessage: boolean) {
const node = execution.data.executionData!.nodeExecutionStack[0].node;
const workflow = {
id: 'wf',
getNode: (name: string) => (name === node.name ? node : null),
nodeTypes: {
getByNameAndVersion: () => (hasOnMessage ? { onMessage: vi.fn() } : {}),
},
};
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
}
it('allows a chat node that implements onMessage', () => {
const execution = makeExecution(CHAT_NODE_TYPE);
mockWorkflow(execution, true);
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(true);
});
it('returns false (does not throw) when node type resolution fails', () => {
const execution = makeExecution(CHAT_NODE_TYPE);
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockImplementation(() => {
throw new Error('Unrecognized node type: some.uninstalled.node');
});
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
});
it('allows RespondToWebhook (implements onMessage)', () => {
const execution = makeExecution(RESPOND_TO_WEBHOOK_NODE_TYPE);
mockWorkflow(execution, true);
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(true);
});
it('allows a chat-based HITL tool (inherits onMessage)', () => {
const execution = makeExecution('@n8n/n8n-nodes-langchain.chatHitlTool');
mockWorkflow(execution, true);
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(true);
});
it('refuses a chat node that opted out of user input', () => {
const execution = makeExecution(CHAT_NODE_TYPE, { blockUserInput: true });
mockWorkflow(execution, true);
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
});
it('applies the blockUserInput carve-out to a chat-based HITL tool', () => {
// chatHitlTool inherits onMessage and retains blockUserInput; the carve-out
// must reach it even though its type is not literally CHAT_(TOOL_)NODE_TYPE.
const execution = makeExecution('@n8n/n8n-nodes-langchain.chatHitlTool', {
blockUserInput: true,
});
mockWorkflow(execution, true);
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
});
it('refuses a Send-and-Wait node (no onMessage)', () => {
const execution = makeExecution('n8n-nodes-base.telegram', { operation: 'sendAndWait' });
mockWorkflow(execution, false);
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
});
it('refuses a non-chat HITL tool (no onMessage)', () => {
const execution = makeExecution('n8n-nodes-base.telegramHitlTool');
mockWorkflow(execution, false);
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
});
it('refuses a plain Wait node (no onMessage)', () => {
const execution = makeExecution('n8n-nodes-base.wait', { resume: 'timeInterval' });
mockWorkflow(execution, false);
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
});
it('keys off the parked stack node, not lastNodeExecuted', () => {
// Confused-deputy guard: lastNodeExecuted names a chat decoy, but the node
// the engine would resume (nodeExecutionStack[0]) is a Telegram gate. The
// decision must follow the parked stack node and refuse.
const decoy = { name: 'Decoy', type: CHAT_NODE_TYPE, typeVersion: 1, parameters: {} };
const gate = {
name: 'Manager approval',
type: 'n8n-nodes-base.telegram',
typeVersion: 1,
parameters: { operation: 'sendAndWait' },
};
const execution = {
id: '1',
workflowData: { id: 'wf', nodes: [decoy, gate] },
data: {
resultData: { lastNodeExecuted: 'Decoy', runData: {} },
executionData: { nodeExecutionStack: [{ node: gate, data: { main: [[]] } }] },
},
} as unknown as IExecutionResponse;
const workflow = {
getNode: (name: string) => (name === 'Decoy' ? decoy : name === gate.name ? gate : null),
nodeTypes: {
getByNameAndVersion: (type: string) =>
type === CHAT_NODE_TYPE ? { onMessage: vi.fn() } : {},
},
};
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
});
it('refuses when there is no parked execution data', () => {
const execution = {
id: '1',
workflowData: { id: 'wf', nodes: [] },
data: { resultData: { lastNodeExecuted: 'x', runData: {} }, executionData: undefined },
} as unknown as IExecutionResponse;
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue({
getNode: () => null,
nodeTypes: { getByNameAndVersion: () => ({ onMessage: vi.fn() }) },
});
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
});
});
describe('resolveResumeNodeType', () => {
it('reports the wrapped tool type for a tool-executor entry, not the executor', () => {
const toolNode = {
name: 'My Tool',
type: 'n8n-nodes-base.telegramHitlTool',
typeVersion: 1,
parameters: {},
};
const execution = {
id: '1',
workflowData: { id: 'wf', nodes: [toolNode] },
data: {
resultData: { lastNodeExecuted: TOOL_EXECUTOR_NODE_NAME, runData: {} },
executionData: {
nodeExecutionStack: [
{
node: {
name: TOOL_EXECUTOR_NODE_NAME,
type: '@n8n/n8n-nodes-langchain.toolExecutor',
parameters: { node: 'My Tool' },
},
data: { main: [[]] },
},
],
},
},
} as unknown as IExecutionResponse;
const workflow = {
getNode: (name: string) => (name === 'My Tool' ? toolNode : null),
nodeTypes: { getByNameAndVersion: () => ({}) },
};
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
expect(chatExecutionManager.resolveResumeNodeType(execution)).toBe(
'n8n-nodes-base.telegramHitlTool',
);
});
it('returns undefined when the node type cannot be resolved', () => {
const execution = {
id: '1',
data: {
executionData: { nodeExecutionStack: [{ node: { name: 'x', type: 'x' }, data: {} }] },
},
} as unknown as IExecutionResponse;
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockImplementation(() => {
throw new Error('Unrecognized node type');
});
expect(chatExecutionManager.resolveResumeNodeType(execution)).toBeUndefined();
});
});
});

View File

@ -0,0 +1,216 @@
import type { ExecutionRepository, IExecutionResponse } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import path from 'node:path';
import { readFileSync } from 'node:fs';
import type { INodeType, IVersionedNodeType, LoadedClass, NodeLoadingDetails } from 'n8n-workflow';
import { CHAT_NODE_TYPE, CHAT_TOOL_NODE_TYPE, RESPOND_TO_WEBHOOK_NODE_TYPE } from 'n8n-workflow';
import type { ExecutionPersistence } from '@/executions/execution-persistence';
import { NodeTypes } from '../../node-types';
import { ChatExecutionManager } from '../chat-execution-manager';
import type { ChatMessage } from '../chat-service.types';
/**
* Guards the security-critical fact the `/chat` resume allowlist depends on:
* *which real node types implement the `onMessage` hook*. The unit tests stub
* `getByNameAndVersion`, so they can't catch a registry change (e.g. a non-chat
* node gaining `onMessage`, or the chat tool suffix chain breaking). This test
* resolves the actual node classes from dist through the real `NodeTypes`
* registry — including the synthetic `*HitlTool` variants — and asserts both the
* `onMessage` inventory and the resulting `canResumeOverChat` decision.
*/
const TELEGRAM = 'n8n-nodes-base.telegram';
const WAIT = 'n8n-nodes-base.wait';
const TELEGRAM_HITL = 'n8n-nodes-base.telegramHitlTool';
const CHAT_HITL = '@n8n/n8n-nodes-langchain.chatHitlTool';
/** Loads a real node class from a built package's dist using its known-nodes manifest. */
function loadRealNode(fullType: string): LoadedClass<INodeType | IVersionedNodeType> {
const [pkg, shortName] = fullType.startsWith('@n8n/')
? ['@n8n/n8n-nodes-langchain', fullType.replace('@n8n/n8n-nodes-langchain.', '')]
: ['n8n-nodes-base', fullType.replace('n8n-nodes-base.', '')];
const pkgRoot = path.dirname(require.resolve(`${pkg}/package.json`));
const known = JSON.parse(
readFileSync(path.join(pkgRoot, 'dist/known/nodes.json'), 'utf-8'),
) as Record<string, NodeLoadingDetails>;
const info = known[shortName];
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require(path.join(pkgRoot, info.sourcePath));
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const type = new mod[info.className]();
return { type, sourcePath: '' };
}
/** Real NodeTypes backed by a thin loader over dist-loaded base nodes, so
* `getByNameAndVersion` runs its actual tool-suffix resolution + synthesis. */
function makeRealNodeTypes(): NodeTypes {
const baseTypes: Record<string, LoadedClass<INodeType | IVersionedNodeType>> = {
[CHAT_NODE_TYPE]: loadRealNode(CHAT_NODE_TYPE),
[RESPOND_TO_WEBHOOK_NODE_TYPE]: loadRealNode(RESPOND_TO_WEBHOOK_NODE_TYPE),
[TELEGRAM]: loadRealNode(TELEGRAM),
[WAIT]: loadRealNode(WAIT),
};
const loader = {
recognizesNode: (name: string) => name in baseTypes,
getNode: (name: string) => baseTypes[name],
loadedNodes: {} as Record<string, LoadedClass<INodeType>>,
};
return new NodeTypes(mock(), loader as never);
}
function makeExecution(nodeType: string, parameters: Record<string, unknown> = {}) {
const node = { name: 'parked', type: nodeType, typeVersion: 1, parameters };
return {
id: '1',
workflowData: { id: 'wf', nodes: [node], connections: {} },
data: {
resultData: { lastNodeExecuted: 'parked', runData: {} },
executionData: { nodeExecutionStack: [{ node, data: { main: [[]] } }] },
},
} as unknown as IExecutionResponse;
}
describe('chat resume — real node-type onMessage mapping', () => {
let nodeTypes: NodeTypes;
let manager: ChatExecutionManager;
let executionRepository: ReturnType<typeof mock<ExecutionRepository>>;
let executionPersistence: ReturnType<typeof mock<ExecutionPersistence>>;
beforeAll(() => {
nodeTypes = makeRealNodeTypes();
executionRepository = mock<ExecutionRepository>();
executionPersistence = mock<ExecutionPersistence>();
manager = new ChatExecutionManager(
executionRepository,
executionPersistence,
mock(),
mock(),
nodeTypes,
);
});
describe('onMessage inventory (resolved through the real registry)', () => {
it.each([
[CHAT_NODE_TYPE, true],
[RESPOND_TO_WEBHOOK_NODE_TYPE, true],
[CHAT_HITL, true], // synthetic tool inherits onMessage from the base Chat node
[TELEGRAM, false],
[WAIT, false],
[TELEGRAM_HITL, false], // synthetic tool of a base node that has no onMessage
])('%s implements onMessage: %s', (type, expected) => {
const resolved = nodeTypes.getByNameAndVersion(type);
expect(typeof resolved.onMessage === 'function').toBe(expected);
});
});
describe('canResumeOverChat against real node types', () => {
it('allows the Chat node', () => {
expect(manager.canResumeOverChat(makeExecution(CHAT_NODE_TYPE))).toBe(true);
});
it('allows a chat-based HITL tool', () => {
expect(manager.canResumeOverChat(makeExecution(CHAT_HITL))).toBe(true);
});
it('refuses a Telegram Send-and-Wait gate', () => {
expect(manager.canResumeOverChat(makeExecution(TELEGRAM, { operation: 'sendAndWait' }))).toBe(
false,
);
});
it('refuses a non-chat HITL tool (telegramHitlTool)', () => {
expect(manager.canResumeOverChat(makeExecution(TELEGRAM_HITL))).toBe(false);
});
it('refuses a plain Wait node', () => {
expect(manager.canResumeOverChat(makeExecution(WAIT, { resume: 'timeInterval' }))).toBe(
false,
);
});
it('refuses a chat node that opted out of user input', () => {
// blockUserInput only applies (and survives Workflow parameter resolution)
// when responseType is 'approval' — mirror a real approval chat node.
expect(
manager.canResumeOverChat(
makeExecution(CHAT_NODE_TYPE, { responseType: 'approval', blockUserInput: true }),
),
).toBe(false);
});
it('refuses a chat-based HITL tool that opted out of user input', () => {
expect(
manager.canResumeOverChat(
makeExecution(CHAT_HITL, { responseType: 'approval', blockUserInput: true }),
),
).toBe(false);
});
});
describe('cancelExecution heartbeat gate against real node types', () => {
function waitingExecution(nodeType: string, parameters: Record<string, unknown> = {}) {
const execution = makeExecution(nodeType, parameters);
(execution as unknown as { status: string }).status = 'waiting';
return execution;
}
beforeEach(() => {
executionRepository.update.mockClear();
});
it('cancels an abandoned chat execution', async () => {
executionPersistence.findSingleExecution.mockResolvedValue(waitingExecution(CHAT_NODE_TYPE));
await manager.cancelExecution('1');
expect(executionRepository.update).toHaveBeenCalledWith({ id: '1' }, { status: 'canceled' });
});
it('does not cancel a Telegram Send-and-Wait execution the socket does not drive', async () => {
executionPersistence.findSingleExecution.mockResolvedValue(
waitingExecution(TELEGRAM, { operation: 'sendAndWait' }),
);
await manager.cancelExecution('1');
expect(executionRepository.update).not.toHaveBeenCalled();
});
it('does not cancel a plain Wait execution the socket does not drive', async () => {
executionPersistence.findSingleExecution.mockResolvedValue(
waitingExecution(WAIT, { resume: 'timeInterval' }),
);
await manager.cancelExecution('1');
expect(executionRepository.update).not.toHaveBeenCalled();
});
});
describe('runWorkflow sink guard against real node types', () => {
const message: ChatMessage = { sessionId: 's', action: 'sendMessage', chatInput: 'x' };
it('refuses to resume a Telegram Send-and-Wait gate', async () => {
await expect(
manager.runWorkflow(makeExecution(TELEGRAM, { operation: 'sendAndWait' }), message),
).rejects.toThrow('Refusing to resume a non-chat node over chat');
});
it('refuses to resume a plain Wait node', async () => {
await expect(
manager.runWorkflow(makeExecution(WAIT, { resume: 'timeInterval' }), message),
).rejects.toThrow('Refusing to resume a non-chat node over chat');
});
});
it('keeps CHAT_TOOL_NODE_TYPE resolvable to an onMessage-bearing type', () => {
// chatTool resolves to the base Chat node, so it must also carry onMessage.
expect(typeof nodeTypes.getByNameAndVersion(CHAT_TOOL_NODE_TYPE).onMessage).toBe('function');
});
});

View File

@ -25,6 +25,9 @@ describe('ChatService', () => {
beforeEach(() => {
mockExecutionManager = mock<ChatExecutionManager>();
// Default: the parked node is chat-resumable. Node-type gating is covered in
// chat-execution-manager.test.ts; individual tests override to false.
mockExecutionManager.canResumeOverChat.mockReturnValue(true);
mockLogger = mock<Logger>();
mockErrorReporter = mock<ErrorReporter>();
chatService = new ChatService(mockExecutionManager, mockLogger, mockErrorReporter);
@ -375,7 +378,7 @@ describe('ChatService', () => {
expect(mockLogger.error).toHaveBeenCalled();
});
it('should not resume execution if blockUserInput is true', async () => {
it('should not resume execution when the parked node is not chat-resumable', async () => {
const sessionKey = 'abc|123|public';
const session = {
executionId: '123',
@ -391,12 +394,17 @@ describe('ChatService', () => {
nodes: [{ name: 'node1', type: CHAT_NODE_TYPE, parameters: { blockUserInput: true } }],
},
} as any);
// The guard refuses this node type, so the message must not resume it and
// the node must stay marked as waiting.
mockExecutionManager.canResumeOverChat.mockReturnValue(false);
const incomingMessageHandler = (chatService as any).incomingMessageHandler(sessionKey);
await incomingMessageHandler(data);
expect(session.nodeWaitingForChatResponse).toBe('node1');
expect(mockExecutionManager.runWorkflow).not.toHaveBeenCalled();
// The refusal is logged rather than silent.
expect(mockLogger.warn).toHaveBeenCalled();
});
});

View File

@ -12,6 +12,7 @@ import {
import {
getMessage,
getLastNodeExecuted,
findResumeNode,
redirectIfToolExecutor,
getLastNodeMessage,
shouldResumeImmediately,
@ -511,23 +512,23 @@ describe('redirectIfToolExecutor', () => {
disabled: false,
};
function makeExecution(lastNodeExecuted: string, startData?: object): IExecutionResponse {
function makeExecution(startData?: object): IExecutionResponse {
return {
id: 'exec-1',
data: {
resultData: { lastNodeExecuted, runData: {} },
resultData: { lastNodeExecuted: TOOL_EXECUTOR_NODE_NAME, runData: {} },
startData: startData ?? { runNodeFilter: ['SomeNode'], destinationNode: 'SomeNode' },
},
workflowData: { nodes: [] },
} as unknown as IExecutionResponse;
}
function makeExecutionData(toolNodeName: string): IExecuteData {
function executorData(toolNodeName?: string): IExecuteData {
return {
node: {
name: TOOL_EXECUTOR_NODE_NAME,
type: '@n8n/n8n-nodes-langchain.toolExecutor',
parameters: { node: toolNodeName },
parameters: toolNodeName ? { node: toolNodeName } : {},
typeVersion: 1,
position: [0, 0],
id: '',
@ -538,9 +539,17 @@ describe('redirectIfToolExecutor', () => {
} as unknown as IExecuteData;
}
it('returns null when lastNodeExecuted is not TOOL_EXECUTOR_NODE_NAME', () => {
const execution = makeExecution('SomeOtherNode');
const executionData = makeExecutionData('My Tool');
function plainData(name: string): IExecuteData {
return {
node: { name, type: 'n8n-nodes-base.noOp', parameters: {}, typeVersion: 1 },
data: { main: [[]] },
source: null,
} as unknown as IExecuteData;
}
it('returns null when the parked node is not the executor', () => {
const execution = makeExecution();
const executionData = plainData('SomeOtherNode');
const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow;
const result = redirectIfToolExecutor(execution, executionData, workflow);
@ -550,32 +559,24 @@ describe('redirectIfToolExecutor', () => {
});
it('returns null when the referenced tool node is not found in the workflow', () => {
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME);
const executionData = makeExecutionData('My Tool');
const execution = makeExecution();
const executionData = executorData('My Tool');
const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow;
const result = redirectIfToolExecutor(execution, executionData, workflow);
expect(result).toBeNull();
expect(redirectIfToolExecutor(execution, executionData, workflow)).toBeNull();
});
it('returns null when executionData.node.parameters.node is missing', () => {
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME);
const executionData = {
node: { name: TOOL_EXECUTOR_NODE_NAME, parameters: {} },
data: { main: [[]] },
source: null,
} as unknown as IExecuteData;
const execution = makeExecution();
const executionData = executorData();
const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow;
const result = redirectIfToolExecutor(execution, executionData, workflow);
expect(result).toBeNull();
expect(redirectIfToolExecutor(execution, executionData, workflow)).toBeNull();
});
it('redirects executionData.node to the actual tool node', () => {
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME);
const executionData = makeExecutionData('My Tool');
const execution = makeExecution();
const executionData = executorData('My Tool');
const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow;
redirectIfToolExecutor(execution, executionData, workflow);
@ -584,8 +585,8 @@ describe('redirectIfToolExecutor', () => {
});
it('updates lastNodeExecuted to the tool node name', () => {
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME);
const executionData = makeExecutionData('My Tool');
const execution = makeExecution();
const executionData = executorData('My Tool');
const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow;
redirectIfToolExecutor(execution, executionData, workflow);
@ -594,8 +595,8 @@ describe('redirectIfToolExecutor', () => {
});
it('resets runIndex to 0', () => {
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME);
const executionData = makeExecutionData('My Tool');
const execution = makeExecution();
const executionData = executorData('My Tool');
(executionData as any).runIndex = 5;
const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow;
@ -605,11 +606,11 @@ describe('redirectIfToolExecutor', () => {
});
it('clears runNodeFilter and destinationNode from startData', () => {
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME, {
const execution = makeExecution({
runNodeFilter: ['SomeNode'],
destinationNode: 'SomeNode',
});
const executionData = makeExecutionData('My Tool');
const executionData = executorData('My Tool');
const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow;
redirectIfToolExecutor(execution, executionData, workflow);
@ -619,13 +620,114 @@ describe('redirectIfToolExecutor', () => {
});
it('returns the tool node', () => {
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME);
const executionData = makeExecutionData('My Tool');
const execution = makeExecution();
const executionData = executorData('My Tool');
const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow;
const result = redirectIfToolExecutor(execution, executionData, workflow);
expect(redirectIfToolExecutor(execution, executionData, workflow)).toBe(toolNode);
});
expect(result).toBe(toolNode);
it('leaves the execution unmodified when the parked node is not the executor', () => {
const execution = makeExecution();
execution.data.resultData.lastNodeExecuted = 'SomeOtherNode';
const executionData = plainData('SomeOtherNode');
const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow;
redirectIfToolExecutor(execution, executionData, workflow);
// The mutating redirect must not fire for a non-executor parked node.
expect(execution.data.resultData.lastNodeExecuted).toBe('SomeOtherNode');
expect(executionData.node.name).toBe('SomeOtherNode');
});
});
describe('findResumeNode', () => {
const toolNode: INode = {
name: 'My Tool',
type: 'n8n-nodes-base.myTool',
typeVersion: 1,
position: [0, 0],
parameters: {},
id: 'tool-id',
disabled: false,
};
function executorData(toolNodeName?: string): IExecuteData {
return {
node: {
name: TOOL_EXECUTOR_NODE_NAME,
type: '@n8n/n8n-nodes-langchain.toolExecutor',
parameters: toolNodeName ? { node: toolNodeName } : {},
typeVersion: 1,
position: [0, 0],
id: '',
disabled: false,
},
data: { main: [[]] },
source: null,
} as unknown as IExecuteData;
}
function parkedData(node: Partial<INode>): IExecuteData {
return { node, data: { main: [[]] }, source: null } as unknown as IExecuteData;
}
it('resolves the parked stack node from the workflow', () => {
const parkedNode = { ...toolNode, name: 'Gate' };
const executionData = parkedData({ name: 'Gate' });
const workflow = { getNode: vi.fn().mockReturnValue(parkedNode) } as unknown as Workflow;
const result = findResumeNode(executionData, workflow);
expect(result).toBe(parkedNode);
expect(workflow.getNode).toHaveBeenCalledWith('Gate');
});
it('resolves the wrapped tool node when the parked node is the virtual executor', () => {
const executionData = executorData('My Tool');
const getNode = vi
.fn()
.mockImplementation((name: string) => (name === 'My Tool' ? toolNode : null));
const workflow = { getNode } as unknown as Workflow;
expect(findResumeNode(executionData, workflow)).toBe(toolNode);
});
it('does not mutate the execution data while resolving the executor target', () => {
const executionData = executorData('My Tool');
const workflow = {
getNode: vi.fn().mockImplementation((n: string) => (n === 'My Tool' ? toolNode : null)),
} as unknown as Workflow;
findResumeNode(executionData, workflow);
expect(executionData.node.name).toBe(TOOL_EXECUTOR_NODE_NAME);
});
it('returns null when the executor references an unknown tool node', () => {
const executionData = executorData('Missing');
const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow;
expect(findResumeNode(executionData, workflow)).toBeNull();
});
it('returns null when the parked node is not in the workflow', () => {
const executionData = parkedData({ name: 'Ghost' });
const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow;
expect(findResumeNode(executionData, workflow)).toBeNull();
});
it('follows the parked stack node, ignoring a divergent lastNodeExecuted', () => {
// The engine resumes nodeExecutionStack[0]; if resultData.lastNodeExecuted
// named a different node, findResumeNode must still follow the stack entry.
const gate = { name: 'Gate', type: 'n8n-nodes-base.telegram', typeVersion: 1, parameters: {} };
const executionData = parkedData(gate);
const getNode = vi.fn().mockImplementation((name: string) => (name === 'Gate' ? gate : null));
const workflow = { getNode } as unknown as Workflow;
expect(findResumeNode(executionData, workflow)).toBe(gate);
expect(workflow.getNode).toHaveBeenCalledWith('Gate');
});
});

View File

@ -11,6 +11,7 @@ import {
Workflow,
BINARY_ENCODING,
UnexpectedError,
CHAT_NODE_TYPE,
CHAT_TOOL_NODE_TYPE,
NodeConnectionTypes,
isHitlToolType,
@ -22,9 +23,10 @@ import * as WorkflowExecuteAdditionalData from '../workflow-execute-additional-d
import { preserveInputOverride } from '../workflow-helpers';
import { WorkflowRunner } from '../workflow-runner';
import type { ChatMessage } from './chat-service.types';
import { redirectIfToolExecutor } from './utils';
import { findResumeNode, redirectIfToolExecutor } from './utils';
import { NodeTypes } from '../node-types';
import { OwnershipService } from '../services/ownership.service';
import { stripToolSuffix } from '../utils';
@Service()
export class ChatExecutionManager {
@ -37,6 +39,20 @@ export class ChatExecutionManager {
) {}
async runWorkflow(execution: IExecutionResponse, message: ChatMessage) {
// Sink guard shared by every chat resume caller (chat websocket, Chat Hub,
// the auto-resume watcher): only a node a chat mechanism drives — one that
// implements onMessage — may be resumed here. This makes the allowlist
// un-skippable so no caller can resume, e.g., a Send-and-Wait gate. Callers
// are expected to refuse earlier with a user-facing message; reaching this
// throw means a caller skipped its check. blockUserInput is a per-message
// policy enforced by the resume-path callers, not here, so auto-resume of
// chat nodes keeps working.
if (!this.isChatDrivenExecution(execution)) {
throw new UnexpectedError('Refusing to resume a non-chat node over chat', {
extra: { executionId: execution.id },
});
}
await this.workflowRunner.run(await this.getRunData(execution, message), true, true, {
executionId: execution.id,
expectedStatus: 'waiting',
@ -51,6 +67,8 @@ export class ChatExecutionManager {
if (!execution) return;
if (!this.isChatDrivenExecution(execution)) return;
if (['running', 'waiting', 'unknown'].includes(execution.status)) {
await this.executionRepository.update({ id: executionId }, { status: 'canceled' });
}
@ -91,26 +109,101 @@ export class ChatExecutionManager {
return binary;
}
private async runNode(execution: IExecutionResponse, message: ChatMessage) {
const workflow = this.getWorkflow(execution);
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string;
let node = workflow.getNode(lastNodeExecuted);
const additionalData = await WorkflowExecuteAdditionalData.getBase({ workflowId: workflow.id });
/**
* Resolves the node a chat message would resume and its (version-resolved)
* node type, without mutating the execution. Shared by the resume guard and
* `runNode` so the authorization decision can never drift from what runs.
*/
private resolveResumeTarget(execution: IExecutionResponse, workflow: Workflow) {
const executionData = execution.data.executionData?.nodeExecutionStack[0];
if (!executionData) return null;
// PartialExecutionToolExecutor is a virtual node not present in the workflow —
// redirect to the real tool and wraps so onMessage() if present runs on the right node.
if (!node) {
node = redirectIfToolExecutor(execution, executionData, workflow);
}
const node = findResumeNode(executionData, workflow);
if (!node) return null;
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
return { node, nodeType, executionData };
}
/**
* `resolveResumeTarget` wrapped to fail closed. Building the workflow / resolving
* the parked node's type throws if a node type is unavailable (e.g. an
* uninstalled community node). The guards below must never throw — a resume
* decision that blows up would, on the heartbeat path, abort the whole
* `checkHeartbeats` loop and leave the session uncleaned — so an unresolvable
* node is simply treated as not chat-resumable.
*/
private tryResolveResumeTarget(execution: IExecutionResponse) {
try {
return this.resolveResumeTarget(execution, this.getWorkflow(execution));
} catch {
return null;
}
}
/**
* A chat message may only resume a node that implements the `onMessage` hook:
* the Chat node and the tools that inherit it (chatTool, chatHitlTool), plus
* RespondToWebhook. Send-and-Wait gates, non-chat HITL tools and Wait nodes
* have no `onMessage` and must not be resumable over the socket, whatever
* token is presented.
*/
canResumeOverChat(execution: IExecutionResponse): boolean {
const target = this.tryResolveResumeTarget(execution);
if (!target) return false;
const { node, nodeType } = target;
if (typeof nodeType.onMessage !== 'function') return false;
// A chat node is chat-resumable by type but still refuses input when the
// builder opted out via blockUserInput. This covers the Chat node and its
// tool variants (chatTool, chatHitlTool) — all resolve to the base chat type
// and carry the same parameter.
const isChatBasedNode = stripToolSuffix(node.type) === CHAT_NODE_TYPE;
if (isChatBasedNode && node.parameters?.blockUserInput === true) return false;
return true;
}
/**
* The resolved type of the node a chat message would resume, for diagnostics.
* Uses the same resolution as canResumeOverChat — so a PartialExecutionToolExecutor
* entry reports the wrapped tool node, not the virtual executor — and fails closed
* (undefined) when the node type can't be resolved.
*/
resolveResumeNodeType(execution: IExecutionResponse): string | undefined {
return this.tryResolveResumeTarget(execution)?.node.type;
}
/**
* Whether the parked node is one a chat socket legitimately drives.
* Unlike canResumeOverChat this ignores blockUserInput: a chat node that
* opted out of input is still a chat execution owned by its socket, so an
* abandoned one should still be cleaned up. Used to decide whether losing the
* socket should cancel the execution.
*/
private isChatDrivenExecution(execution: IExecutionResponse): boolean {
const target = this.tryResolveResumeTarget(execution);
return !!target && typeof target.nodeType.onMessage === 'function';
}
private async runNode(execution: IExecutionResponse, message: ChatMessage) {
const workflow = this.getWorkflow(execution);
const target = this.resolveResumeTarget(execution, workflow);
if (!target) return null;
const { node, nodeType, executionData } = target;
const additionalData = await WorkflowExecuteAdditionalData.getBase({ workflowId: workflow.id });
// PartialExecutionToolExecutor is a virtual node not present in the workflow.
// `node`/`nodeType` already point at the real tool via findResumeNode; this
// applies the execution-state redirect so onMessage() runs on it (no-op for
// an ordinary parked node).
redirectIfToolExecutor(execution, executionData, workflow);
const inputData = executionData.data;
const connectionInputData = executionData.data.main[0];
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
const context = new ExecuteContext(
workflow,
node,
@ -160,10 +253,14 @@ export class ChatExecutionManager {
// The chat-based HITL tool resumes here too, but carries the generated
// `chatHitlTool` type rather than `chatTool`. Without this its output stays on
// the `main` channel instead of `ai_tool`, so the agent never sees the approval
// and the gated tool is never executed. The webhook resume path keys off the
// same `HitlTool` suffix.
// and the gated tool is never executed. Scope this to chat-based tools only:
// non-chat HITL tools (e.g. telegramHitlTool) are not chat-resumable and must
// not receive the approval fix-up, so the suffix check is paired with a base
// type check.
const resumingNode = runExecutionData.executionData!.nodeExecutionStack[0].node;
if (resumingNode.type === CHAT_TOOL_NODE_TYPE || isHitlToolType(resumingNode.type)) {
const isChatBasedHitlTool =
isHitlToolType(resumingNode.type) && stripToolSuffix(resumingNode.type) === CHAT_NODE_TYPE;
if (resumingNode.type === CHAT_TOOL_NODE_TYPE || isChatBasedHitlTool) {
runExecutionData.waitTill = undefined;
resumingNode.disabled = true;
resumingNode.rewireOutputLogTo = NodeConnectionTypes.AiTool;

View File

@ -5,7 +5,7 @@ import { Service } from '@n8n/di';
import { timingSafeEqual } from 'crypto';
import { ErrorReporter } from 'n8n-core';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import { jsonParse, UnexpectedError, CHAT_NODE_TYPE, CHAT_TOOL_NODE_TYPE } from 'n8n-workflow';
import { jsonParse, UnexpectedError } from 'n8n-workflow';
import { type RawData, WebSocket } from 'ws';
import { z } from 'zod';
@ -166,8 +166,8 @@ export class ChatService {
chatInput: getLastNodeMessage(execution, lastNode),
sessionId: session.sessionId,
};
await this.resumeExecution(session.executionId, data, sessionKey);
session.nodeWaitingForChatResponse = undefined;
const resumed = await this.resumeExecution(session.executionId, data, sessionKey);
if (resumed) session.nodeWaitingForChatResponse = undefined;
} else {
session.nodeWaitingForChatResponse = lastNode?.name;
}
@ -249,10 +249,12 @@ export class ChatService {
}
const executionId = session.executionId;
if (await this.shouldResumeOnMessage(executionId)) {
await this.resumeExecution(executionId, this.parseChatMessage(message), sessionKey);
session.nodeWaitingForChatResponse = undefined;
}
const resumed = await this.resumeExecution(
executionId,
this.parseChatMessage(message),
sessionKey,
);
if (resumed) session.nodeWaitingForChatResponse = undefined;
} catch (e) {
const error = ensureError(e);
this.errorReporter.error(error);
@ -263,10 +265,30 @@ export class ChatService {
};
}
private async resumeExecution(executionId: string, message: ChatMessage, sessionKey: string) {
private async resumeExecution(
executionId: string,
message: ChatMessage,
sessionKey: string,
): Promise<boolean> {
const execution = await this.getExecutionOrCleanupSession(executionId, sessionKey);
if (!execution || execution.status !== 'waiting') return;
if (!execution || execution.status !== 'waiting') return false;
// A chat message may only resume nodes designed to accept one (see
// ChatExecutionManager.canResumeOverChat); refuse everything else,
// whatever token is presented. Log the refusal so the denial is visible
// (probing / "why won't my chat resume") rather than silent.
if (!this.executionManager.canResumeOverChat(execution)) {
// Resolve the same node the gate checked (a tool-executor entry redirects to
// the wrapped tool) so the diagnostic names the node that was refused.
const nodeType = this.executionManager.resolveResumeNodeType(execution);
this.logger.warn(
`Refused chat resume for execution ${executionId}: suspended node (type: ${nodeType ?? 'unknown'}) is not resumable over chat`,
);
return false;
}
await this.executionManager.runWorkflow(execution, message);
return true;
}
private async getExecutionOrCleanupSession(executionId: string, sessionKey: string) {
@ -360,21 +382,6 @@ export class ChatService {
}
}
private async shouldResumeOnMessage(executionId: string) {
const execution = await this.executionManager.findExecution(executionId);
if (!execution) {
return true;
}
const lastNode = getLastNodeExecuted(execution);
const isChatNode = lastNode?.type === CHAT_NODE_TYPE || lastNode?.type === CHAT_TOOL_NODE_TYPE;
if (isChatNode && lastNode?.parameters?.blockUserInput === true) {
return false;
}
return true;
}
@OnShutdown()
shutdown() {
for (const [key, session] of this.sessions.entries()) {

View File

@ -11,6 +11,43 @@ import {
const AI_TOOL = 'ai_tool';
/**
* When the parked node is the virtual PartialExecutionToolExecutor, resolves the
* real tool node it wraps (stored in the executor's `node` parameter). Pure — does
* not mutate the execution. Returns null when the parked node isn't the executor.
*
* Keyed off `executionData.node` (the parked stack entry) rather than
* `resultData.lastNodeExecuted` so it matches the node the engine resumes.
*/
function getToolExecutorTargetNode(executionData: IExecuteData, workflow: Workflow): INode | null {
if (executionData.node.name !== TOOL_EXECUTOR_NODE_NAME) return null;
const toolNodeName = executionData.node.parameters?.node as string | undefined;
return toolNodeName ? workflow.getNode(toolNodeName) : null;
}
/**
* Resolves the node a chat message would resume — the parked node, or the tool node
* behind the virtual PartialExecutionToolExecutor. Pure: does not mutate the
* execution, so callers deciding *whether* to resume can share it with the resume
* path without side effects. Returns null when no resumable node can be resolved.
*
* Resolves from `executionData.node` — the top of `nodeExecutionStack`, i.e. the
* node the engine actually resumes — not `resultData.lastNodeExecuted` (a report of
* what last ran). The two agree in every real waiting state, but reading the
* authoritative field keeps the resume decision from ever authorizing a different
* node than the one that runs.
*/
export function findResumeNode(executionData: IExecuteData, workflow: Workflow): INode | null {
const parkedNode = executionData.node;
if (parkedNode.name === TOOL_EXECUTOR_NODE_NAME) {
return getToolExecutorTargetNode(executionData, workflow);
}
return workflow.getNode(parkedNode.name);
}
/**
* Redirects execution from PartialExecutionToolExecutor to the actual tool node
* it wraps (stored in the executor's `node` parameter).
@ -27,11 +64,7 @@ export function redirectIfToolExecutor(
executionData: IExecuteData,
workflow: Workflow,
): INode | null {
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted;
if (lastNodeExecuted !== TOOL_EXECUTOR_NODE_NAME) return null;
const toolNodeName = executionData.node.parameters?.node as string | undefined;
const toolNode = toolNodeName ? workflow.getNode(toolNodeName) : null;
const toolNode = getToolExecutorTargetNode(executionData, workflow);
if (!toolNode) return null;
executionData.node = toolNode;

View File

@ -1,8 +1,10 @@
import type { AuthenticatedRequest } from '@n8n/db';
import type { AuthenticatedRequest, Project } from '@n8n/db';
import type { Role } from '@n8n/permissions';
import { mock } from 'vitest-mock-extended';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { EventService } from '@/events/event.service';
import type { ProjectService } from '@/services/project.service.ee';
import type { RoleService } from '@/services/role.service';
import { RoleController } from '../role.controller';
@ -10,7 +12,8 @@ import { RoleController } from '../role.controller';
describe('RoleController', () => {
const eventService = mock<EventService>();
const roleService = mock<RoleService>();
const controller = new RoleController(roleService, eventService);
const projectService = mock<ProjectService>();
const controller = new RoleController(roleService, eventService, projectService);
const managerRequest = () =>
mock<AuthenticatedRequest>({
@ -19,6 +22,7 @@ describe('RoleController', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe('emits action events', () => {
@ -147,4 +151,93 @@ describe('RoleController', () => {
});
});
});
describe('getRoleProjectMembers', () => {
const projectRole = { slug: 'project:admin', roleType: 'project' } as Role;
it('should return the members when the caller can list the project', async () => {
const request = managerRequest();
roleService.getRole.mockResolvedValue(projectRole);
projectService.getProjectWithScope.mockResolvedValue(mock<Project>({ id: 'project-1' }));
roleService.getRoleProjectMembers.mockResolvedValue({
members: [
{
userId: 'user-1',
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
role: 'project:admin',
},
],
});
const result = await controller.getRoleProjectMembers(
request,
mock(),
'project:admin',
'project-1',
);
expect(projectService.getProjectWithScope).toHaveBeenCalledWith(request.user, 'project-1', [
'project:list',
]);
expect(result.members).toHaveLength(1);
});
it('should not return the members when the caller cannot list the project', async () => {
const request = managerRequest();
roleService.getRole.mockResolvedValue(projectRole);
projectService.getProjectWithScope.mockResolvedValue(null);
await expect(
controller.getRoleProjectMembers(request, mock(), 'project:admin', 'project-1'),
).rejects.toThrow(NotFoundError);
expect(roleService.getRoleProjectMembers).not.toHaveBeenCalled();
});
});
describe('getRoleAssignments', () => {
const projectRole = { slug: 'project:admin', roleType: 'project' } as Role;
const assignment = (projectId: string, projectName: string) => ({
projectId,
projectName,
projectIcon: null,
memberCount: 2,
lastAssigned: null,
});
it('should only return the projects the caller can list, keeping the total intact', async () => {
const request = managerRequest();
roleService.getRole.mockResolvedValue(projectRole);
roleService.getRoleAssignments.mockResolvedValue({
projects: [assignment('project-1', 'Mine'), assignment('project-2', 'Not mine')],
totalProjects: 2,
});
projectService.getProjectIdsWithScope.mockResolvedValue(['project-1']);
const result = await controller.getRoleAssignments(request, mock(), 'project:admin');
expect(projectService.getProjectIdsWithScope).toHaveBeenCalledWith(
request.user,
['project:list'],
['project-1', 'project-2'],
);
expect(result.projects).toEqual([assignment('project-1', 'Mine')]);
// the count stays instance-wide so the delete-impact signal is not silently wrong
expect(result.totalProjects).toBe(2);
});
it('should not query visibility when the role has no assignments', async () => {
const request = managerRequest();
roleService.getRole.mockResolvedValue(projectRole);
roleService.getRoleAssignments.mockResolvedValue({ projects: [], totalProjects: 0 });
const result = await controller.getRoleAssignments(request, mock(), 'project:admin');
expect(projectService.getProjectIdsWithScope).not.toHaveBeenCalled();
expect(result.projects).toEqual([]);
});
});
});

View File

@ -29,7 +29,9 @@ import {
} from '@n8n/decorators';
import { Role as RoleDTO } from '@n8n/permissions';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
import { ProjectService } from '@/services/project.service.ee';
import { assertCanManageRoleType, canReassignUsers } from '@/services/role-authorization';
import { RoleService } from '@/services/role.service';
@ -38,6 +40,7 @@ export class RoleController {
constructor(
private readonly roleService: RoleService,
private readonly eventService: EventService,
private readonly projectService: ProjectService,
) {}
@Get('/')
@ -67,6 +70,17 @@ export class RoleController {
roleType: role.roleType,
user: req.user,
});
// Managing a role is an instance-wide capability, but the identities of a project's
// members are project data. Gate them on the same `project:list` check the sibling
// user and project routes use, so a role manager only sees projects it can already see.
const project = await this.projectService.getProjectWithScope(req.user, projectId, [
'project:list',
]);
if (!project) {
throw new NotFoundError('Project not found');
}
const result = await this.roleService.getRoleProjectMembers(slug, projectId);
return RoleProjectMembersResponseDto.parse(result);
}
@ -83,7 +97,27 @@ export class RoleController {
user: req.user,
});
const result = await this.roleService.getRoleAssignments(slug);
return RoleAssignmentsResponseDto.parse(result);
if (result.projects.length === 0) {
return RoleAssignmentsResponseDto.parse(result);
}
// `projects` only names the projects the caller can already see. `totalProjects` stays
// the instance-wide count so the assignments tab can say "showing 1 of 3" rather than
// claim the role is unassigned; a bare number names no project and no member. The
// delete-impact warning is a different field — `usedByProjects`, served by `getRole`.
const visibleProjectIds = new Set(
await this.projectService.getProjectIdsWithScope(
req.user,
['project:list'],
result.projects.map((project) => project.projectId),
),
);
return RoleAssignmentsResponseDto.parse({
...result,
projects: result.projects.filter((project) => visibleProjectIds.has(project.projectId)),
});
}
@Get('/:slug/members')

View File

@ -4197,10 +4197,21 @@ describe('CredentialsService', () => {
expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled();
});
it('should refuse a test URL without a stored service origin', async () => {
credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
mockDecryptedData({ testUrl: 'https://api.example.com/me' });
await expect(service.probeById(ownerUser, 'cred-id')).rejects.toThrow(
'The credential test URL is not bound to its service origin',
);
expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled();
});
it('should probe the persisted test URL with parsed accepted status codes', async () => {
credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
const data: ICredentialDataDecryptedObject = {
testUrl: 'https://api.example.com/me',
serviceOrigin: 'https://api.example.com',
acceptedStatusCodes: '[401]',
};
mockDecryptedData(data);
@ -4217,13 +4228,30 @@ describe('CredentialsService', () => {
'httpTemplatedCustomAuth',
{ id: 'cred-id', name: 'Templated cred', type: 'httpTemplatedCustomAuth', data },
'https://api.example.com/me',
{ acceptedStatusCodes: [401] },
{ acceptedStatusCodes: [401], allowedDomains: 'api.example.com' },
);
});
it('should refuse a test URL outside the stored service origin', async () => {
credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
mockDecryptedData({
testUrl: 'https://status.example.net/me',
serviceOrigin: 'https://api.example.com',
});
await expect(service.probeById(ownerUser, 'cred-id')).rejects.toThrow(
'The credential test URL is not bound to its service origin',
);
expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled();
});
it('should ignore malformed accepted status codes', async () => {
credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
mockDecryptedData({ testUrl: 'https://api.example.com/me', acceptedStatusCodes: 'nope' });
mockDecryptedData({
testUrl: 'https://api.example.com/me',
serviceOrigin: 'https://api.example.com',
acceptedStatusCodes: 'nope',
});
credentialsTester.probeCredentialAuth.mockResolvedValue({
status: 'OK',
message: 'Connection successful!',
@ -4236,7 +4264,7 @@ describe('CredentialsService', () => {
expect.anything(),
expect.anything(),
expect.anything(),
{ acceptedStatusCodes: undefined },
{ acceptedStatusCodes: undefined, allowedDomains: 'api.example.com' },
);
});
});

View File

@ -88,6 +88,16 @@ import {
/** Sentinel placed at every leaf of a redacted httpCustomAuth JSON shape */
const CUSTOM_AUTH_JSON_REDACTED_VALUE = '***';
function parseHttpUrl(value: unknown): URL | undefined {
if (typeof value !== 'string') return undefined;
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:' ? url : undefined;
} catch {
return undefined;
}
}
export type CredentialsGetSharedOptions =
| { allowGlobalScope: true; globalScope: Scope }
| { allowGlobalScope: false };
@ -1301,12 +1311,22 @@ export class CredentialsService {
const data = await this.decrypt(storedCredential, true);
// Expressions (leading '=') and non-http values are refused, not resolved.
const testUrl = data.testUrl;
if (typeof testUrl !== 'string' || !/^https?:\/\//i.test(testUrl)) {
// Expressions and non-HTTP values are refused, not resolved.
const testTarget = parseHttpUrl(data.testUrl);
if (!testTarget) {
throw new BadRequestError('The credential has no test URL to probe');
}
const storedOrigin = typeof data.serviceOrigin === 'string' ? data.serviceOrigin.trim() : '';
const serviceOrigin = parseHttpUrl(storedOrigin);
if (
!serviceOrigin ||
serviceOrigin.origin !== storedOrigin ||
serviceOrigin.origin !== testTarget.origin
) {
throw new BadRequestError('The credential test URL is not bound to its service origin');
}
return await this.credentialsTester.probeCredentialAuth(
user.id,
storedCredential.type,
@ -1316,8 +1336,11 @@ export class CredentialsService {
type: storedCredential.type,
data,
},
testUrl,
{ acceptedStatusCodes: parseAcceptedStatusCodes(data.acceptedStatusCodes) },
testTarget.href,
{
acceptedStatusCodes: parseAcceptedStatusCodes(data.acceptedStatusCodes),
allowedDomains: testTarget.hostname,
},
);
}

View File

@ -2,6 +2,8 @@ import type { Project } from '@n8n/db';
import { WorkflowOperationError } from 'n8n-workflow';
import type { INode } from 'n8n-workflow';
type CallerType = 'workflow' | 'agent';
type Options = {
/** ID of the subworkflow whose execution was denied. */
subworkflowId: string;
@ -20,10 +22,29 @@ type Options = {
/** Node that triggered the execution of the subworkflow whose execution was denied. */
node?: INode;
callerType?: CallerType;
};
export const SUBWORKFLOW_DENIAL_BASE_DESCRIPTION =
'The sub-workflow you’re trying to execute limits which workflows it can be called by.';
export const SUBWORKFLOW_AGENT_DENIAL_BASE_DESCRIPTION =
'The sub-workflow you’re trying to execute doesn’t allow this agent to call it.';
function getCallerDescription(callerType: CallerType) {
return {
agent: {
caller: 'agent',
accessibleCaller: 'this agent',
baseDescription: SUBWORKFLOW_AGENT_DENIAL_BASE_DESCRIPTION,
},
workflow: {
caller: 'workflow',
accessibleCaller: 'other workflows',
baseDescription: SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
},
}[callerType];
}
export class SubworkflowPolicyDenialError extends WorkflowOperationError {
constructor({
@ -33,20 +54,22 @@ export class SubworkflowPolicyDenialError extends WorkflowOperationError {
hasReadAccess,
ownerName,
node,
callerType = 'workflow',
}: Options) {
const { caller, accessibleCaller, baseDescription } = getCallerDescription(callerType);
const descriptions = {
default: SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
default: baseDescription,
accessible: [
SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
`<a href="${instanceUrl}/workflow/${subworkflowId}" target="_blank">Update sub-workflow settings</a> to allow other workflows to call it.`,
baseDescription,
`<a href="${instanceUrl}/workflow/${subworkflowId}" target="_blank">Update sub-workflow settings</a> to allow ${accessibleCaller} to call it.`,
].join(' '),
inaccessiblePersonalProject: [
SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
`You will need ${ownerName} to update the sub-workflow (${subworkflowId}) settings to allow this workflow to call it.`,
baseDescription,
`You will need ${ownerName} to update the sub-workflow (${subworkflowId}) settings to allow this ${caller} to call it.`,
].join(' '),
inaccesibleTeamProject: [
SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
`You will need an admin from the ${subworkflowProject.name} project to update the sub-workflow (${subworkflowId}) settings to allow this workflow to call it.`,
baseDescription,
`You will need an admin from the ${subworkflowProject.name} project to update the sub-workflow (${subworkflowId}) settings to allow this ${caller} to call it.`,
].join(' '),
};
@ -59,7 +82,7 @@ export class SubworkflowPolicyDenialError extends WorkflowOperationError {
};
super(
`The sub-workflow (${subworkflowId}) cannot be called by this workflow`,
`The sub-workflow (${subworkflowId}) cannot be called by this ${caller}`,
node,
description(),
);

View File

@ -34,6 +34,8 @@ import { ExternalHooks } from '@/external-hooks';
import { Push } from '@/push';
import { OwnershipService } from '@/services/ownership.service';
import { WorkflowPublicationNotifier } from '@/workflows/publication/workflow-publication-notifier';
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { createExecution } from '@test-integration/db/executions';
import { IN_PROGRESS_EXECUTION_DATA, OOM_WORKFLOW } from './constants';
@ -46,6 +48,8 @@ describe('ExecutionRecoveryService', () => {
const projectRelationRepository = mockInstance(ProjectRelationRepository);
const externalHooks = mockInstance(ExternalHooks);
const activeWorkflowManager = mockInstance(ActiveWorkflowManager);
const workflowSharingService = mockInstance(WorkflowSharingService);
const workflowPushNotifier = new WorkflowPushNotifier(push, workflowSharingService);
mockInstance(WorkflowPublicationNotifier);
let executionRecoveryService: ExecutionRecoveryService;
@ -72,11 +76,13 @@ describe('ExecutionRecoveryService', () => {
mock(),
ownershipService,
projectRelationRepository,
workflowPushNotifier,
);
});
beforeEach(() => {
instanceSettings.markAsLeader();
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue([]);
});
afterEach(async () => {
@ -185,6 +191,44 @@ describe('ExecutionRecoveryService', () => {
expect(amendedExecution.status).toBe('crashed');
expect(amendedExecution.stoppedAt).not.toBe(execution.stoppedAt);
});
test('pushes `executionRecovered` only to users with workflow access, once a client connects', async () => {
/**
* Arrange
*/
const workflow = await createWorkflow(OOM_WORKFLOW);
const execution = await createExecution(
{
status: 'running',
data: stringify(IN_PROGRESS_EXECUTION_DATA),
},
workflow,
);
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(['user-1']);
let editorUiConnectedCallback: (() => Promise<void>) | undefined;
push.once.mockImplementation((event: string, callback: () => Promise<void>) => {
if (event === 'editorUiConnected') editorUiConnectedCallback = callback;
return push;
});
/**
* Act
*/
await executionRecoveryService.recoverFromLogs(execution.id, []);
expect(editorUiConnectedCallback).toBeDefined();
await editorUiConnectedCallback?.();
/**
* Assert
*/
expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith(
workflow.id,
);
expect(push.sendToUsers).toHaveBeenCalledWith(
{ type: 'executionRecovered', data: { executionId: execution.id } },
['user-1'],
);
});
});
describe('if leader, with 1+ messages', () => {
@ -526,6 +570,40 @@ describe('ExecutionRecoveryService', () => {
expect(updatedWorkflow.activeVersionId).toBeNull();
});
test('pushes `workflowAutoDeactivated` only to users with workflow access, once a client connects', async () => {
/**
* Arrange
*/
globalConfig.executions.recovery.workflowDeactivationEnabled = true;
const workflow = await createCrashedActiveWorkflow();
mockOwnershipForDeactivation();
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(['user-1']);
let editorUiConnectedCallback: (() => Promise<void>) | undefined;
push.once.mockImplementation((event: string, callback: () => Promise<void>) => {
if (event === 'editorUiConnected') editorUiConnectedCallback = callback;
return push;
});
/**
* Act
*/
await executionRecoveryService.autoDeactivateWorkflowsIfNeeded(new Set([workflow.id]));
expect(editorUiConnectedCallback).toBeDefined();
await editorUiConnectedCallback?.();
/**
* Assert
*/
expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith(
workflow.id,
);
expect(push.sendToUsers).toHaveBeenCalledWith(
{ type: 'workflowAutoDeactivated', data: { workflowId: workflow.id } },
['user-1'],
);
});
test('should unpublish via outbox and record publish history on auto-deactivation', async () => {
/**
* Arrange

View File

@ -11,8 +11,8 @@ import { ExecutionRepository, WorkflowRepository } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import { PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import type { DateTime } from 'luxon';
import { sleep } from '@n8n/utils/sleep';
import type { DateTime } from 'luxon';
import { InstanceSettings } from 'n8n-core';
import { createEmptyRunExecutionData } from 'n8n-workflow';
import { ExecutionStatus, type IRun, type ITaskData } from 'n8n-workflow';
@ -25,6 +25,7 @@ import { ExecutionPersistence } from '@/executions/execution-persistence';
import { Push } from '@/push';
import { OwnershipService } from '@/services/ownership.service';
import { UserManagementMailer } from '@/user-management/email/user-management-mailer';
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
import { isNodeEventMessage, type EventMessageTypes } from '../eventbus/event-message-classes';
@ -44,6 +45,7 @@ export class ExecutionRecoveryService {
private readonly userManagementMailer: UserManagementMailer,
private readonly ownershipService: OwnershipService,
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly workflowPushNotifier: WorkflowPushNotifier,
) {}
async autoDeactivateWorkflowsIfNeeded(workflowIds: Set<string>) {
@ -88,7 +90,10 @@ export class ExecutionRecoveryService {
this.push.once('editorUiConnected', async () => {
await sleep(1000);
this.push.broadcast({ type: 'workflowAutoDeactivated', data: { workflowId } });
await this.workflowPushNotifier.notify(workflowId, {
type: 'workflowAutoDeactivated',
data: { workflowId },
});
});
} catch (error) {
// A throw here would abort startup recovery for the remaining
@ -127,9 +132,14 @@ export class ExecutionRecoveryService {
await this.runHooks(amendedExecution);
const { workflowId } = amendedExecution;
this.push.once('editorUiConnected', async () => {
await sleep(1000);
this.push.broadcast({ type: 'executionRecovered', data: { executionId } });
await this.workflowPushNotifier.notify(workflowId, {
type: 'executionRecovered',
data: { executionId },
});
});
return amendedExecution;

View File

@ -6,6 +6,7 @@ import { v4 as uuid } from 'uuid';
import { mock } from 'vitest-mock-extended';
import {
SUBWORKFLOW_AGENT_DENIAL_BASE_DESCRIPTION,
SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
SubworkflowPolicyDenialError,
} from '@/errors/subworkflow-policy-denial.error';
@ -118,6 +119,51 @@ describe('SubworkflowPolicyChecker', () => {
});
});
describe('project-scoped caller', () => {
it('should allow workflows owned by the caller project', async () => {
const projectId = uuid();
const subworkflow = mock<Workflow>({
id: 'subworkflow-id',
settings: { callerPolicy: 'workflowsFromSameOwner' },
});
ownershipService.getWorkflowProjectCached.mockResolvedValue(mock<Project>({ id: projectId }));
await expect(checker.checkForProject(subworkflow, projectId)).resolves.not.toThrow();
});
it('should deny workflows owned by a different project', async () => {
const subworkflow = mock<Workflow>({
id: 'subworkflow-id',
settings: { callerPolicy: 'workflowsFromSameOwner' },
});
ownershipService.getWorkflowProjectCached.mockResolvedValue(
mock<Project>({ id: uuid(), name: 'Engineering', type: 'team' }),
);
await expect(checker.checkForProject(subworkflow, uuid())).rejects.toMatchObject({
message: 'The sub-workflow (subworkflow-id) cannot be called by this agent',
description: `${SUBWORKFLOW_AGENT_DENIAL_BASE_DESCRIPTION} You will need an admin from the Engineering project to update the sub-workflow (subworkflow-id) settings to allow this agent to call it.`,
});
});
it.each(['none', 'workflowsFromAList'] as const)(
'should deny the `%s` caller policy',
async (callerPolicy) => {
const subworkflow = mock<Workflow>({
id: 'subworkflow-id',
settings: { callerPolicy },
});
ownershipService.getWorkflowProjectCached.mockResolvedValue(
mock<Project>({ id: uuid(), type: 'team' }),
);
await expect(checker.checkForProject(subworkflow, uuid())).rejects.toThrowError(
SubworkflowPolicyDenialError,
);
},
);
});
describe('`workflows-from-same-owner` caller policy', () => {
it('should deny if the two workflows are owned by different projects', async () => {
const parentWorkflowProject = mock<Project>({ id: uuid() });

Some files were not shown because too many files have changed in this diff Show More