Compare commits

...

3 Commits

Author SHA1 Message Date
n8n-assistant[bot]
7bd63f41c2
🚀 Release 2.38.2 (#37608)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-09-02 10:14:01 +03:00
Matsu
8c8b059e03
chore: Bundle 2.x (backport to release-candidate/2.38.x) (#37593) 2026-09-02 10:04:47 +03:00
n8n-assistant[bot]
603668834c
ci: Pin sandbox service test images (no-changelog) (backport to release-candidate/2.38.x) (#37597)
Co-authored-by: Matsu <matias.huhta@n8n.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 09:35:53 +03:00
260 changed files with 11295 additions and 1662 deletions

View File

@ -1,3 +1,6 @@
## [2.38.2](https://github.com/n8n-io/n8n/compare/n8n@2.38.1...n8n@2.38.2) (2026-09-02)
# [2.38.1](https://github.com/n8n-io/n8n/compare/n8n@2.37.0...n8n@2.38.1) (2026-09-01)

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "2.38.1",
"version": "2.38.2",
"private": true,
"engines": {
"node": ">=24.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/agents",
"version": "0.23.0",
"version": "0.23.1",
"description": "AI agent SDK for n8n's code-first execution engine",
"engines": {
"node": ">=24.0.0"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-node-sdk",
"version": "0.28.0",
"version": "0.28.1",
"description": "SDK for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-utilities",
"version": "0.31.0",
"version": "0.31.1",
"description": "Utilities for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

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

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-workflow-builder",
"version": "1.38.0",
"version": "1.38.1",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "1.38.0",
"version": "1.38.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

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

@ -304,6 +304,7 @@ export {
export {
buildRunWorkflowSessionGrantKey,
buildUpdateWorkflowSessionGrantKey,
buildCredentialDestinationGrantKey,
buildDataTablesSessionGrantKey,
buildSetupSkipGrantKey,
parseSetupSkipGrants,
@ -365,6 +366,8 @@ export {
instanceAiApprovalResumeSchema,
webSearchMetaSchema,
credentialFlowSchema,
credentialDestinationSchema,
credentialDestinationDecisionSchema,
instanceAiCredentialHandoffContextSchema,
instanceAiAgentPreviewHandoffContextSchema,
instanceAiHandoffContextSchema,
@ -532,6 +535,8 @@ export type {
DomainAccessMeta,
WebSearchMeta,
InstanceAiCredentialFlow,
InstanceAiCredentialDestination,
InstanceAiCredentialDestinationDecision,
InstanceAiCredentialHandoffContext,
InstanceAiAgentPreviewHandoffContext,
InstanceAiHandoffContext,

View File

@ -755,6 +755,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,
@ -240,6 +241,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', () => {
@ -475,6 +500,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

@ -470,29 +470,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
@ -412,6 +417,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
@ -438,6 +455,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>;
@ -457,6 +477,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(),
@ -718,6 +751,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()
@ -1381,35 +1419,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

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-common",
"version": "1.38.0",
"version": "1.38.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-network",
"version": "1.12.0",
"version": "1.12.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

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

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-test-utils",
"version": "1.38.0",
"version": "1.38.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/blob-storage",
"version": "1.6.0",
"version": "1.6.1",
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
"scripts": {
"clean": "rimraf dist .turbo",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/chat-hub",
"version": "1.31.0",
"version": "1.31.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/client-oauth2",
"version": "1.20.0",
"version": "1.20.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "2.36.0",
"version": "2.36.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

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

@ -506,7 +506,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,6 +1,6 @@
{
"name": "@n8n/constants",
"version": "0.37.0",
"version": "0.37.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

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

@ -1,6 +1,6 @@
{
"name": "@n8n/create-node",
"version": "0.46.0",
"version": "0.46.1",
"description": "Official CLI to create new community nodes for n8n",
"bin": {
"create-node": "bin/create-node.cjs"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/db",
"version": "1.38.0",
"version": "1.38.1",
"scripts": {
"clean": "rimraf dist .turbo .tmp-schema-docs",
"gen:migration-index": "node scripts/generate-migration-index.mjs",

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

@ -1,6 +1,6 @@
{
"name": "@n8n/decorators",
"version": "1.38.0",
"version": "1.38.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/engine",
"version": "0.18.0",
"version": "0.18.1",
"description": "n8n workflow execution engine (v2)",
"scripts": {
"clean": "rimraf dist .turbo compiled",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/expression-runtime",
"version": "0.29.0",
"version": "0.29.1",
"description": "Secure, isolated expression evaluation runtime for n8n",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",

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

@ -1,6 +1,6 @@
{
"name": "@n8n/instance-ai",
"version": "1.23.0",
"version": "1.23.1",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",

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

@ -1,6 +1,6 @@
{
"name": "@n8n/mcp-apps",
"version": "0.15.0",
"version": "0.15.1",
"description": "MCP Apps UI resources and server helpers for n8n",
"main": "dist/server/index.js",
"types": "dist/server/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/node-cli",
"version": "0.47.0",
"version": "0.47.1",
"description": "Official CLI for developing community nodes for n8n",
"bin": {
"n8n-node": "bin/n8n-node.mjs"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/node-engine-compatibility",
"version": "0.6.0",
"version": "0.6.1",
"description": "Adapts v1 nodes and workflows to the Engine 2.0 execution model",
"scripts": {
"clean": "rimraf dist .turbo",

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> = {
@ -311,15 +330,13 @@ describe('McpClientTool', () => {
// Verify the eventSourceInit fetch injects auth headers and Accept header
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
await customFetch?.(url, {} as any);
expect(mockedProxyFetch).toHaveBeenCalledWith(
url,
{
headers: { Accept: 'text/event-stream', 'my-header': 'header-value' },
redirect: 'manual',
},
undefined,
undefined,
);
expect(mockedProxyFetch).toHaveBeenCalledOnce();
const [{ input: requestedUrl, init: requestInit }] = mockedProxyFetch.mock.calls[0];
expect(requestedUrl).toBe(url);
expect(requestInit?.redirect).toBe('manual');
const sentHeaders = new Headers(requestInit?.headers);
expect(sentHeaders.get('accept')).toBe('text/event-stream');
expect(sentHeaders.get('my-header')).toBe('header-value');
});
it('should support bearer auth', async () => {
@ -338,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> = {
@ -368,15 +386,13 @@ describe('McpClientTool', () => {
// Verify the eventSourceInit fetch injects auth headers and Accept header
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
await customFetch?.(url, {} as any);
expect(mockedProxyFetch).toHaveBeenCalledWith(
url,
{
headers: { Accept: 'text/event-stream', Authorization: 'Bearer my-token' },
redirect: 'manual',
},
undefined,
undefined,
);
expect(mockedProxyFetch).toHaveBeenCalledOnce();
const [{ input: requestedUrl, init: requestInit }] = mockedProxyFetch.mock.calls[0];
expect(requestedUrl).toBe(url);
expect(requestInit?.redirect).toBe('manual');
const sentHeaders = new Headers(requestInit?.headers);
expect(sentHeaders.get('accept')).toBe('text/event-stream');
expect(sentHeaders.get('authorization')).toBe('Bearer my-token');
});
it('should successfully execute a tool', async () => {
@ -397,6 +413,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() =>
mock<INode>({
typeVersion: 1,
@ -441,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 })),
@ -478,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 })),
@ -515,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 })),
@ -551,6 +571,7 @@ describe('McpClientTool', () => {
});
const supplyDataFunctions = mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() =>
mock<INode>({
typeVersion: 1,
@ -600,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 })),
@ -642,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 })),
@ -673,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 })),
@ -716,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 })),
@ -751,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 })),
@ -784,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 })),
@ -823,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> = {
@ -871,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(() => [
{
@ -939,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(() => [
{
@ -1004,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(() => [
{
@ -1057,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(() => [
{
@ -1110,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(() => [
{
@ -1156,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(() => [
{
@ -1201,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(() => [
{
@ -1234,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(() => [
{
@ -1282,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(() => [
{
@ -1354,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(() => [
{
@ -1406,6 +1444,7 @@ describe('McpClientTool', () => {
name: 'GitHub MCP',
});
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1461,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: {
@ -1509,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) => {
@ -1550,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(() => [
{
@ -1604,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 })),
@ -1645,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 })),
@ -1680,6 +1724,7 @@ describe('McpClientTool', () => {
name: 'McpClientTool',
});
const mockExecuteFunctions = mock<any>({
...egressHelpers<IExecuteFunctions>(),
getNode: vi.fn(() => mockNode),
getInputData: vi.fn(() => [
{
@ -1723,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(() => [
{
@ -1757,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) => {
@ -1794,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) => {
@ -1823,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) => {
@ -1861,6 +1910,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() =>
mock<INode>({
typeVersion: 1,
@ -1900,6 +1950,7 @@ describe('McpClientTool', () => {
const supplyDataResult = await new McpClientTool().supplyData.call(
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() =>
mock<INode>({
typeVersion: 1,
@ -1940,6 +1991,7 @@ describe('McpClientTool', () => {
credentials: Record<string, unknown>;
}) =>
mock<ISupplyDataFunctions>({
...egressHelpers<ISupplyDataFunctions>(),
getNode: vi.fn(() =>
mock<INode>({
typeVersion: 1,
@ -2062,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> = {
@ -2087,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

@ -30,6 +30,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();
@ -363,6 +368,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -386,6 +392,7 @@ describe('utils', () => {
await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
name: 'test-client',
@ -403,6 +410,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -419,6 +427,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -441,6 +450,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -469,6 +479,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -492,6 +503,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
name: 'test-client',
@ -509,6 +521,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -527,6 +540,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -547,6 +561,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -568,6 +583,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -590,6 +606,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -609,6 +626,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
name: 'test-client',
version: 1,
@ -626,6 +644,7 @@ describe('utils', () => {
await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer my-token' },
name: 'test-client',
@ -639,16 +658,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(call[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: '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 () => {
@ -663,6 +676,7 @@ describe('utils', () => {
await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer my-token' },
name: 'test-client',
@ -674,7 +688,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];
// @ts-expect-error - Mocking
expect(callOpts.headers).toEqual(
@ -697,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',
@ -723,6 +738,7 @@ describe('utils', () => {
await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer old-token' },
name: 'test-client',
@ -745,6 +761,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
name: 'test-client',
@ -767,6 +784,7 @@ describe('utils', () => {
const result = await connectMcpClient({
serverTransport: transport,
secureEgressFilter: createTestEgressFilter(),
endpointUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
name: 'test-client',

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

@ -160,11 +160,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);
@ -172,7 +172,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;
@ -297,21 +297,20 @@ 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 {
let headers = initialHeaders;
const secureLookup = secureEgressFilter?.createSecureLookup();
const secureLookup = secureEgressFilter.createSecureLookup();
const doFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
await proxyFetch(
await proxyFetch({
input,
{ ...init, headers: { ...headersToRecord(init?.headers), ...headers } },
undefined,
secureLookup,
);
init: { ...init, headers: { ...headersToRecord(init?.headers), ...headers } },
lookup: secureLookup,
});
const authedFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const response = await doFetch(input, init);
@ -336,10 +335,8 @@ function createAuthFetch(
return await fetchFollowingRedirects(authedFetch, startUrl, init, {
onBeforeHop: 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;
},
});
};
@ -520,7 +517,7 @@ export async function connectMcpClientForCredential(
endpointUrl: config.endpointUrl,
headers,
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,6 +1,6 @@
{
"name": "@n8n/n8n-nodes-langchain",
"version": "2.38.0",
"version": "2.38.1",
"description": "",
"main": "index.js",
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/permissions",
"version": "0.75.0",
"version": "0.75.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

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.

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