mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 08:46:56 +02:00
Compare commits
3 Commits
master
...
n8n@2.38.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bd63f41c2 | ||
|
|
8c8b059e03 | ||
|
|
603668834c |
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.38.1",
|
||||
"version": "2.38.2",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.38.0",
|
||||
"version": "1.38.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "1.38.0",
|
||||
"version": "1.38.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-network",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat-hub",
|
||||
"version": "1.31.0",
|
||||
"version": "1.31.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "1.20.0",
|
||||
"version": "1.20.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "2.36.0",
|
||||
"version": "2.36.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/constants",
|
||||
"version": "0.37.0",
|
||||
"version": "0.37.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "1.38.0",
|
||||
"version": "1.38.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) : '',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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__');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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('**********');
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
95
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/credentials.test.ts
vendored
Normal file
95
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/credentials.test.ts
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
30
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/credentials.ts
vendored
Normal file
30
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/credentials.ts
vendored
Normal 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',
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "2.38.0",
|
||||
"version": "2.38.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/permissions",
|
||||
"version": "0.75.0",
|
||||
"version": "0.75.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user