mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
parent
d8194d4e66
commit
cae5e093d3
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -306,6 +306,7 @@ export {
|
|||
export {
|
||||
buildRunWorkflowSessionGrantKey,
|
||||
buildUpdateWorkflowSessionGrantKey,
|
||||
buildCredentialDestinationGrantKey,
|
||||
buildDataTablesSessionGrantKey,
|
||||
buildSetupSkipGrantKey,
|
||||
parseSetupSkipGrants,
|
||||
|
|
@ -369,6 +370,8 @@ export {
|
|||
instanceAiApprovalResumeSchema,
|
||||
webSearchMetaSchema,
|
||||
credentialFlowSchema,
|
||||
credentialDestinationSchema,
|
||||
credentialDestinationDecisionSchema,
|
||||
instanceAiCredentialHandoffContextSchema,
|
||||
instanceAiAgentPreviewHandoffContextSchema,
|
||||
instanceAiHandoffContextSchema,
|
||||
|
|
@ -538,6 +541,8 @@ export type {
|
|||
DomainAccessMeta,
|
||||
WebSearchMeta,
|
||||
InstanceAiCredentialFlow,
|
||||
InstanceAiCredentialDestination,
|
||||
InstanceAiCredentialDestinationDecision,
|
||||
InstanceAiCredentialHandoffContext,
|
||||
InstanceAiAgentPreviewHandoffContext,
|
||||
InstanceAiHandoffContext,
|
||||
|
|
|
|||
|
|
@ -778,6 +778,39 @@ describe('agent-run-reducer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('confirmation-request passes through credential destination metadata when present', () => {
|
||||
const state = stateWithRun('run-1', 'root');
|
||||
reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'workflows'));
|
||||
reduceEvent(state, {
|
||||
type: 'confirmation-request',
|
||||
runId: 'run-1',
|
||||
agentId: 'root',
|
||||
payload: {
|
||||
requestId: 'req-destination',
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'workflows',
|
||||
args: { action: 'setup' },
|
||||
severity: 'warning',
|
||||
message: 'Review where this credential will be used',
|
||||
credentialDestination: {
|
||||
origin: 'https://api.example.com',
|
||||
nodeNames: ['Fetch data'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tc = state.toolCallsById['tc-1'];
|
||||
expect(tc.confirmation).toEqual({
|
||||
requestId: 'req-destination',
|
||||
severity: 'warning',
|
||||
message: 'Review where this credential will be used',
|
||||
credentialDestination: {
|
||||
origin: 'https://api.example.com',
|
||||
nodeNames: ['Fetch data'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('confirmation-request passes through projectId when present', () => {
|
||||
const state = stateWithRun('run-1', 'root');
|
||||
reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'setup-credentials'));
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
instanceAiFileAttachmentSchema,
|
||||
MAX_ATTACHMENT_BASE64_BYTES,
|
||||
applyBranchReadOnlyOverrides,
|
||||
buildCredentialDestinationGrantKey,
|
||||
buildDataTablesSessionGrantKey,
|
||||
buildUpdateWorkflowSessionGrantKey,
|
||||
buildSetupSkipGrantKey,
|
||||
|
|
@ -280,6 +281,30 @@ describe('confirmationRequestPayloadSchema', () => {
|
|||
|
||||
expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('preserves a credential destination requiring approval', () => {
|
||||
const payload = makeConfirmation({
|
||||
credentialDestination: {
|
||||
origin: 'https://api.example.com',
|
||||
nodeNames: ['Fetch account'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('requires a credential destination to be an exact HTTP origin', () => {
|
||||
const result = confirmationRequestPayloadSchema.safeParse(
|
||||
makeConfirmation({
|
||||
credentialDestination: {
|
||||
origin: 'https://api.example.com/v1',
|
||||
nodeNames: ['Fetch account'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDisplayableConfirmationRequest', () => {
|
||||
|
|
@ -515,6 +540,14 @@ describe('workflow update session grant keys', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('credential destination session grant keys', () => {
|
||||
it('encodes the workflow and exact origin', () => {
|
||||
expect(buildCredentialDestinationGrantKey('workflow:1', 'https://api.example.com:8443')).toBe(
|
||||
'credential-destination:workflow%3A1:https%3A%2F%2Fapi.example.com%3A8443',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow-setup skip keys', () => {
|
||||
it('round-trips credential types and ignores unrelated keys', () => {
|
||||
const keys = new Set([
|
||||
|
|
|
|||
|
|
@ -471,29 +471,13 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
|
|||
if (!isSafeObjectKey(event.payload.toolCallId)) break;
|
||||
const tc = state.toolCallsById[event.payload.toolCallId];
|
||||
if (tc) {
|
||||
tc.confirmation = {
|
||||
requestId: event.payload.requestId,
|
||||
inputThreadId: event.payload.inputThreadId,
|
||||
severity: event.payload.severity,
|
||||
message: event.payload.message,
|
||||
targetApproval: event.payload.targetApproval,
|
||||
credentialRequests: event.payload.credentialRequests,
|
||||
requireUserSelection: event.payload.requireUserSelection,
|
||||
projectId: event.payload.projectId,
|
||||
inputType: event.payload.inputType,
|
||||
domainAccess: event.payload.domainAccess,
|
||||
webSearch: event.payload.webSearch,
|
||||
credentialFlow: event.payload.credentialFlow,
|
||||
setupRequests: event.payload.setupRequests,
|
||||
workflowId: event.payload.workflowId,
|
||||
planItems: event.payload.planItems,
|
||||
questions: event.payload.questions,
|
||||
introMessage: event.payload.introMessage,
|
||||
tasks: event.payload.tasks,
|
||||
resourceDecision: event.payload.resourceDecision,
|
||||
channelConfig: event.payload.channelConfig,
|
||||
mcpConnectRequest: event.payload.mcpConnectRequest,
|
||||
};
|
||||
const {
|
||||
toolCallId: _toolCallId,
|
||||
toolName: _toolName,
|
||||
args: _args,
|
||||
...confirmation
|
||||
} = event.payload;
|
||||
tc.confirmation = confirmation;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ export function buildUpdateWorkflowSessionGrantKey(workflowId: string): string {
|
|||
return `workflows:update:${workflowId}`;
|
||||
}
|
||||
|
||||
/** Builds the thread-level grant for using a credential with one exact service origin. */
|
||||
export function buildCredentialDestinationGrantKey(workflowId: string, origin: string): string {
|
||||
return `credential-destination:${encodeURIComponent(workflowId)}:${encodeURIComponent(origin)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the thread-level "always allow" grant key for a data-tables action
|
||||
* (e.g. `create`, `insert-rows`). Must match the frontend key
|
||||
|
|
@ -413,6 +418,18 @@ export const credentialPlaceholderDefSchema = z.object({
|
|||
});
|
||||
export type InstanceAiCredentialPlaceholderDef = z.infer<typeof credentialPlaceholderDefSchema>;
|
||||
|
||||
const exactHttpOriginSchema = z
|
||||
.string()
|
||||
.max(300)
|
||||
.refine((value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ['http:', 'https:'].includes(url.protocol) && url.origin === value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'Expected an exact HTTP origin');
|
||||
|
||||
/**
|
||||
* Agent-supplied recipe for creating a Templated Custom Auth credential: the
|
||||
* auth request parts with `{{placeholder}}` markers where user-provided values
|
||||
|
|
@ -439,6 +456,9 @@ export const credentialSetupHintSchema = z.object({
|
|||
* being set up (never model-supplied). Stamped into the created credential
|
||||
* so setup surfaces only offer it to nodes calling the same service. */
|
||||
serviceHost: z.string().optional(),
|
||||
/** Exact origin of the API the recipe targets, derived server-side from the
|
||||
* node being set up. Used to bind automatic credential tests to that API. */
|
||||
serviceOrigin: exactHttpOriginSchema.optional(),
|
||||
});
|
||||
export type InstanceAiCredentialSetupHint = z.infer<typeof credentialSetupHintSchema>;
|
||||
|
||||
|
|
@ -458,6 +478,19 @@ export const credentialFlowSchema = z.object({
|
|||
});
|
||||
export type InstanceAiCredentialFlow = z.infer<typeof credentialFlowSchema>;
|
||||
|
||||
export const credentialDestinationSchema = z.object({
|
||||
origin: exactHttpOriginSchema,
|
||||
nodeNames: z.array(z.string().trim().min(1).max(255)).min(1),
|
||||
});
|
||||
export type InstanceAiCredentialDestination = z.infer<typeof credentialDestinationSchema>;
|
||||
|
||||
export const credentialDestinationDecisionSchema = credentialDestinationSchema.pick({
|
||||
origin: true,
|
||||
});
|
||||
export type InstanceAiCredentialDestinationDecision = z.infer<
|
||||
typeof credentialDestinationDecisionSchema
|
||||
>;
|
||||
|
||||
export const workflowSetupNodeSchema = z.object({
|
||||
node: z.object({
|
||||
name: z.string(),
|
||||
|
|
@ -719,6 +752,11 @@ export const confirmationRequestPayloadSchema = z.object({
|
|||
.describe(
|
||||
'Credential flow stage — finalize renders post-verification credential picker with different copy',
|
||||
),
|
||||
credentialDestination: credentialDestinationSchema
|
||||
.optional()
|
||||
.describe(
|
||||
'Exact destination that must be approved before a workflow credential setup card opens',
|
||||
),
|
||||
setupRequests: z
|
||||
.array(workflowSetupNodeSchema)
|
||||
.optional()
|
||||
|
|
@ -1428,35 +1466,10 @@ export interface InstanceAiSendMessageResponse {
|
|||
// Frontend store types (shared so both sides agree on structure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InstanceAiConfirmation {
|
||||
requestId: string;
|
||||
inputThreadId?: string;
|
||||
severity: InstanceAiConfirmationSeverity;
|
||||
message: string;
|
||||
targetApproval?: InstanceAiTargetApproval;
|
||||
credentialRequests?: InstanceAiCredentialRequest[];
|
||||
requireUserSelection?: boolean;
|
||||
projectId?: string;
|
||||
inputType?: 'approval' | 'text' | 'questions' | 'plan-review' | 'resource-decision' | 'continue';
|
||||
domainAccess?: DomainAccessMeta;
|
||||
webSearch?: WebSearchMeta;
|
||||
credentialFlow?: InstanceAiCredentialFlow;
|
||||
setupRequests?: InstanceAiWorkflowSetupNode[];
|
||||
workflowId?: string;
|
||||
planItems?: PlannedTaskArg[];
|
||||
questions?: Array<{
|
||||
id: string;
|
||||
question: string;
|
||||
type: 'single' | 'multi' | 'text';
|
||||
options?: string[];
|
||||
}>;
|
||||
introMessage?: string;
|
||||
tasks?: TaskList;
|
||||
resourceDecision?: GatewayConfirmationRequiredPayload;
|
||||
channelConfig?: InstanceAiChannelConfig;
|
||||
mcpConnectRequest?: InstanceAiMcpConnectRequest;
|
||||
expired?: boolean;
|
||||
}
|
||||
export type InstanceAiConfirmation = Omit<
|
||||
InstanceAiConfirmationRequestPayload,
|
||||
'toolCallId' | 'toolName' | 'args'
|
||||
> & { expired?: boolean };
|
||||
|
||||
export interface InstanceAiToolCallState {
|
||||
toolCallId: string;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -507,7 +507,7 @@ describe('GlobalConfig', () => {
|
|||
security: {
|
||||
restrictFileAccessTo: '~/.n8n-files',
|
||||
blockFileAccessToN8nFiles: true,
|
||||
blockFilePatterns: '^(.*\\/)*\\.git(\\/.*)*$',
|
||||
blockFilePatterns: '^(?:[^/]*/)*\\.git(?:/.*)?$',
|
||||
daysAbandonedWorkflow: 90,
|
||||
contentSecurityPolicy: undefined,
|
||||
contentSecurityPolicyReportOnly: DEFAULT_CONTENT_SECURITY_POLICY,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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) : '',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
@ -312,7 +331,7 @@ describe('McpClientTool', () => {
|
|||
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
|
||||
await customFetch?.(url, {} as any);
|
||||
expect(mockedProxyFetch).toHaveBeenCalledOnce();
|
||||
const [requestedUrl, requestInit] = mockedProxyFetch.mock.calls[0];
|
||||
const [{ input: requestedUrl, init: requestInit }] = mockedProxyFetch.mock.calls[0];
|
||||
expect(requestedUrl).toBe(url);
|
||||
expect(requestInit?.redirect).toBe('manual');
|
||||
const sentHeaders = new Headers(requestInit?.headers);
|
||||
|
|
@ -336,6 +355,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
getNodeParameter: vi.fn((key, _index) => {
|
||||
const parameters: Record<string, any> = {
|
||||
|
|
@ -367,7 +387,7 @@ describe('McpClientTool', () => {
|
|||
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
|
||||
await customFetch?.(url, {} as any);
|
||||
expect(mockedProxyFetch).toHaveBeenCalledOnce();
|
||||
const [requestedUrl, requestInit] = mockedProxyFetch.mock.calls[0];
|
||||
const [{ input: requestedUrl, init: requestInit }] = mockedProxyFetch.mock.calls[0];
|
||||
expect(requestedUrl).toBe(url);
|
||||
expect(requestInit?.redirect).toBe('manual');
|
||||
const sentHeaders = new Headers(requestInit?.headers);
|
||||
|
|
@ -393,6 +413,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
|
|
@ -437,6 +458,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: vi.fn(), error: vi.fn() },
|
||||
addInputData: vi.fn(() => ({ index: 0 })),
|
||||
|
|
@ -474,6 +496,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: vi.fn(), error: vi.fn() },
|
||||
addInputData: vi.fn(() => ({ index: 0 })),
|
||||
|
|
@ -511,6 +534,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: vi.fn(), error: vi.fn() },
|
||||
addInputData: vi.fn(() => ({ index: 0 })),
|
||||
|
|
@ -547,6 +571,7 @@ describe('McpClientTool', () => {
|
|||
});
|
||||
|
||||
const supplyDataFunctions = mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
|
|
@ -596,6 +621,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: vi.fn(), error: vi.fn() },
|
||||
addInputData: vi.fn(() => ({ index: 0 })),
|
||||
|
|
@ -638,6 +664,7 @@ describe('McpClientTool', () => {
|
|||
await expect(
|
||||
new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: vi.fn(), error: vi.fn() },
|
||||
addInputData: vi.fn(() => ({ index: 0 })),
|
||||
|
|
@ -669,6 +696,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: vi.fn(), error: vi.fn() },
|
||||
addInputData: vi.fn(() => ({ index: 0 })),
|
||||
|
|
@ -712,6 +740,7 @@ describe('McpClientTool', () => {
|
|||
const errorLogger = vi.fn();
|
||||
|
||||
const supplyDataFunctions = mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: vi.fn(), error: errorLogger },
|
||||
addInputData: vi.fn(() => ({ index: 0 })),
|
||||
|
|
@ -747,6 +776,7 @@ describe('McpClientTool', () => {
|
|||
await expect(
|
||||
new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: vi.fn(), error: vi.fn() },
|
||||
addInputData: vi.fn(() => ({ index: 0 })),
|
||||
|
|
@ -780,6 +810,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'McpClientTool' })),
|
||||
logger: { debug: vi.fn(), error: vi.fn() },
|
||||
addInputData: vi.fn(() => ({ index: 0 })),
|
||||
|
|
@ -819,6 +850,7 @@ describe('McpClientTool', () => {
|
|||
const mockNode = mock<INode>({ typeVersion: 1, name: 'MCP Client' });
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getNodeParameter: vi.fn((key, _index) => {
|
||||
const parameters: Record<string, any> = {
|
||||
|
|
@ -867,6 +899,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -935,6 +968,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1000,6 +1034,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1053,6 +1088,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1.2, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1106,6 +1142,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1.3, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1152,6 +1189,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1.3, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1197,6 +1235,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1230,6 +1269,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1278,6 +1318,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1350,6 +1391,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1402,6 +1444,7 @@ describe('McpClientTool', () => {
|
|||
name: 'GitHub MCP',
|
||||
});
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1457,6 +1500,7 @@ describe('McpClientTool', () => {
|
|||
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getExecutionCancelSignal.mockReturnValue(abortController.signal);
|
||||
mockExecuteFunctions.helpers.getSecureEgressFilter.mockReturnValue(createTestEgressFilter());
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([
|
||||
{
|
||||
json: {
|
||||
|
|
@ -1505,6 +1549,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [{ json: { tool: 'get_weather', location: 'Berlin' } }]),
|
||||
getNodeParameter: vi.fn((key) => {
|
||||
|
|
@ -1546,6 +1591,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1600,6 +1646,7 @@ describe('McpClientTool', () => {
|
|||
});
|
||||
|
||||
const mockSupplyDataFunctions = mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: vi.fn(), error: vi.fn() },
|
||||
addInputData: vi.fn(() => ({ index: 0 })),
|
||||
|
|
@ -1641,6 +1688,7 @@ describe('McpClientTool', () => {
|
|||
});
|
||||
|
||||
const mockSupplyDataFunctions = mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'MCP Client' })),
|
||||
logger: { debug: vi.fn(), error: vi.fn() },
|
||||
addInputData: vi.fn(() => ({ index: 0 })),
|
||||
|
|
@ -1676,6 +1724,7 @@ describe('McpClientTool', () => {
|
|||
name: 'McpClientTool',
|
||||
});
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1719,6 +1768,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [
|
||||
{
|
||||
|
|
@ -1753,6 +1803,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [{ json: { tool: 'get_weather' } }]),
|
||||
getNodeParameter: vi.fn((key) => {
|
||||
|
|
@ -1790,6 +1841,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [{ json: { location: 'Berlin' } }]),
|
||||
getNodeParameter: vi.fn((key) => {
|
||||
|
|
@ -1819,6 +1871,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
|
||||
const mockExecuteFunctions = mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
getInputData: vi.fn(() => [{ json: { tool: 'get_weather' } }]),
|
||||
getNodeParameter: vi.fn((key) => {
|
||||
|
|
@ -1857,6 +1910,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
|
|
@ -1896,6 +1950,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const supplyDataResult = await new McpClientTool().supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
|
|
@ -1936,6 +1991,7 @@ describe('McpClientTool', () => {
|
|||
credentials: Record<string, unknown>;
|
||||
}) =>
|
||||
mock<ISupplyDataFunctions>({
|
||||
...egressHelpers<ISupplyDataFunctions>(),
|
||||
getNode: vi.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 1,
|
||||
|
|
@ -2058,6 +2114,7 @@ describe('McpClientTool', () => {
|
|||
|
||||
const node = mock<INode>({ typeVersion: 1 });
|
||||
const ctx = mock<ILoadOptionsFunctions>({
|
||||
...egressHelpers<ILoadOptionsFunctions>(),
|
||||
getNode: vi.fn(() => node),
|
||||
getNodeParameter: vi.fn((key) => {
|
||||
const params: Record<string, any> = {
|
||||
|
|
@ -2083,6 +2140,7 @@ describe('McpClientTool', () => {
|
|||
function createCtx(typeVersion: number) {
|
||||
const mockNode = mock<INode>({ typeVersion, type: 'mcpClientTool', name: 'MCP Client' });
|
||||
return mock<any>({
|
||||
...egressHelpers<IExecuteFunctions>(),
|
||||
getNode: vi.fn(() => mockNode),
|
||||
logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
getExecutionId: vi.fn(() => 'exec-gating'),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ vi.mock('@n8n/ai-utilities', async () => {
|
|||
|
||||
const MockedClient = Client as MockedClass<typeof Client>;
|
||||
|
||||
const createTestEgressFilter = (): NodeEgressFilter => ({
|
||||
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
|
||||
createSecureLookup: vi.fn(),
|
||||
});
|
||||
|
||||
describe('utils', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
|
@ -368,6 +373,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
|
|
@ -391,6 +397,7 @@ describe('utils', () => {
|
|||
|
||||
await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
name: 'test-client',
|
||||
|
|
@ -408,6 +415,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
|
|
@ -424,6 +432,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
|
|
@ -446,6 +455,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
|
|
@ -474,6 +484,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
|
|
@ -497,6 +508,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
name: 'test-client',
|
||||
|
|
@ -514,6 +526,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
|
|
@ -532,6 +545,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
|
|
@ -552,6 +566,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
|
|
@ -573,6 +588,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
|
|
@ -595,6 +611,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
|
|
@ -614,6 +631,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
|
|
@ -631,6 +649,7 @@ describe('utils', () => {
|
|||
|
||||
await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer my-token' },
|
||||
name: 'test-client',
|
||||
|
|
@ -644,10 +663,10 @@ describe('utils', () => {
|
|||
|
||||
expect(mockedProxyFetch).toHaveBeenCalled();
|
||||
|
||||
const call = mockedProxyFetch.mock.calls[0];
|
||||
const [options] = mockedProxyFetch.mock.calls[0];
|
||||
|
||||
expect(call[0]).toBe('https://example.com/mcp');
|
||||
expect(new Headers(call[1]?.headers).get('authorization')).toBe('Bearer my-token');
|
||||
expect(options.input).toBe('https://example.com/mcp');
|
||||
expect(new Headers(options.init?.headers).get('authorization')).toBe('Bearer my-token');
|
||||
});
|
||||
|
||||
it('should preserve SDK headers passed as Headers instance', async () => {
|
||||
|
|
@ -662,6 +681,7 @@ describe('utils', () => {
|
|||
|
||||
await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer my-token' },
|
||||
name: 'test-client',
|
||||
|
|
@ -673,7 +693,7 @@ describe('utils', () => {
|
|||
const [, opts] = (TransportClass as Mock).mock.calls[0];
|
||||
await opts.fetch('https://example.com', { headers: sdkHeaders });
|
||||
|
||||
const [, callOpts] = mockedProxyFetch.mock.calls[0];
|
||||
const [{ init: callOpts }] = mockedProxyFetch.mock.calls[0];
|
||||
|
||||
expect(new Headers(callOpts?.headers).get('accept')).toBe('text/event-stream');
|
||||
});
|
||||
|
|
@ -691,6 +711,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer old-token' },
|
||||
name: 'test-client',
|
||||
|
|
@ -717,6 +738,7 @@ describe('utils', () => {
|
|||
|
||||
await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer old-token' },
|
||||
name: 'test-client',
|
||||
|
|
@ -739,6 +761,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
name: 'test-client',
|
||||
|
|
@ -761,6 +784,7 @@ describe('utils', () => {
|
|||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
secureEgressFilter: createTestEgressFilter(),
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
name: 'test-client',
|
||||
|
|
@ -788,6 +812,7 @@ describe('utils', () => {
|
|||
ctx.helpers.refreshOAuth2Token.mockResolvedValue({
|
||||
access_token: 'refreshed-token',
|
||||
});
|
||||
ctx.helpers.getSecureEgressFilter.mockReturnValue(createTestEgressFilter());
|
||||
const connection = {
|
||||
nodeTypeName: '@n8n/mcp-registry.test',
|
||||
credentialType: 'testMcpOAuth2Api' as const,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -168,11 +168,11 @@ export async function connectMcpClient({
|
|||
*/
|
||||
allowedDomains?: string;
|
||||
/**
|
||||
* Instance egress filter. When set, every request (including redirect hops)
|
||||
* is validated against the configured egress policy, and the connection is
|
||||
* Instance egress filter. Every request (including redirect hops) is
|
||||
* validated against the configured egress policy, and the connection is
|
||||
* pinned to the validated address.
|
||||
*/
|
||||
secureEgressFilter?: NodeEgressFilter;
|
||||
secureEgressFilter: NodeEgressFilter;
|
||||
}): Promise<Result<Client, ConnectMcpClientError>> {
|
||||
const endpoint = normalizeAndValidateUrl(endpointUrl);
|
||||
|
||||
|
|
@ -180,7 +180,7 @@ export async function connectMcpClient({
|
|||
return createResultError({ type: 'invalid_url', error: endpoint.error });
|
||||
}
|
||||
|
||||
const authFetch = createAuthFetch(headers, onUnauthorized, allowedDomains, secureEgressFilter);
|
||||
const authFetch = createAuthFetch(headers, secureEgressFilter, onUnauthorized, allowedDomains);
|
||||
const client = new Client({ name, version: version.toString() }, { capabilities: {} });
|
||||
|
||||
let onAbort: (() => void) | undefined;
|
||||
|
|
@ -301,13 +301,13 @@ function headersToRecord(headers: HeadersInit | undefined): Record<string, strin
|
|||
*/
|
||||
function createAuthFetch(
|
||||
initialHeaders: Record<string, string> | undefined,
|
||||
secureEgressFilter: NodeEgressFilter,
|
||||
onUnauthorized?: OnUnauthorizedHandler,
|
||||
allowedDomains?: string,
|
||||
secureEgressFilter?: NodeEgressFilter,
|
||||
): typeof fetch {
|
||||
const secureLookup = secureEgressFilter?.createSecureLookup();
|
||||
const secureLookup = secureEgressFilter.createSecureLookup();
|
||||
return createRefreshingAuthFetch({
|
||||
baseFetch: async (input, init) => await proxyFetch(input, init, undefined, secureLookup),
|
||||
baseFetch: async (input, init) => await proxyFetch({ input, init, lookup: secureLookup }),
|
||||
initialHeaders,
|
||||
...(onUnauthorized
|
||||
? {
|
||||
|
|
@ -317,10 +317,8 @@ function createAuthFetch(
|
|||
: {}),
|
||||
assertAllowedUrl: async (hopUrl) => {
|
||||
assertUrlAllowed({ url: hopUrl, allowedDomains });
|
||||
if (secureEgressFilter) {
|
||||
const result = await secureEgressFilter.validateUrl(hopUrl);
|
||||
if (!result.ok) throw result.error;
|
||||
}
|
||||
const result = await secureEgressFilter.validateUrl(hopUrl);
|
||||
if (!result.ok) throw result.error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -482,7 +480,7 @@ export async function connectMcpClientForCredential(
|
|||
endpointUrl,
|
||||
headers: authHeaders,
|
||||
allowedDomains,
|
||||
secureEgressFilter: ctx.helpers.getSecureEgressFilter?.(),
|
||||
secureEgressFilter: ctx.helpers.getSecureEgressFilter(),
|
||||
name: node.type,
|
||||
version: node.typeVersion,
|
||||
onUnauthorized: async (h) => await tryRefreshOAuth2Token(ctx, config.authentication, h),
|
||||
|
|
|
|||
|
|
@ -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,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.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { TournamentHooks } from './ast';
|
|||
import { splitExpression } from './ExpressionSplitter';
|
||||
import type { ExpressionCode, ExpressionText } from './ExpressionSplitter';
|
||||
import { parseWithEsprimaNext } from './Parser';
|
||||
import { globalIdentifier, jsVariablePolyfill } from './VariablePolyfill';
|
||||
import { globalIdentifier, jsVariablePolyfill, rawStringLiteral } from './VariablePolyfill';
|
||||
import type { DataNode } from './VariablePolyfill';
|
||||
|
||||
export interface ExpressionAnalysis {
|
||||
|
|
@ -123,7 +123,7 @@ const buildFunctionBody = (expr: ExpressionKind) => {
|
|||
b.binaryExpression('===', v, b.literal(false)),
|
||||
),
|
||||
v,
|
||||
b.literal(''),
|
||||
rawStringLiteral(''),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
|
@ -179,20 +179,16 @@ export const getExpressionCode = (
|
|||
b.variableDeclaration('var', [b.variableDeclarator(globalIdentifier, b.objectExpression([]))]),
|
||||
]);
|
||||
|
||||
// This is what's used to access that's passed in. For compatibility we us
|
||||
// `this` unless the expression contains a function. If it contains an
|
||||
// expression we instead assign a different variable to hold onto the contents
|
||||
// of `this` (default: `___n8n_data`) since functions aren't compatibility
|
||||
// anyway.
|
||||
let dataNode: DataNode = b.thisExpression();
|
||||
// Hold the passed-in `this` in a lexically-bound variable (default:
|
||||
// `___n8n_data`) and route all data access and the sanitizer through it.
|
||||
// A lexical binding cannot be reached by member shadowing the way `this`
|
||||
// can, and the reserved-name guards already block rebinding it.
|
||||
const dataNode: DataNode = b.identifier(dataNodeName);
|
||||
newProg.body.push(
|
||||
b.variableDeclaration('var', [b.variableDeclarator(dataNode, b.thisExpression())]),
|
||||
);
|
||||
// Reported back to callers, no longer used to shape the emitted code.
|
||||
const hasFn = chunks.filter((c) => c.type === 'code').some((c) => hasFunction(c.parsed));
|
||||
if (hasFn) {
|
||||
dataNode = b.identifier(dataNodeName);
|
||||
newProg.body.push(
|
||||
b.variableDeclaration('var', [b.variableDeclarator(dataNode, b.thisExpression())]),
|
||||
);
|
||||
}
|
||||
|
||||
const hasTempString = chunks
|
||||
.filter((c) => c.type === 'code')
|
||||
.some((c) => hasTemplateString(c.parsed));
|
||||
|
|
@ -211,7 +207,7 @@ export const getExpressionCode = (
|
|||
for (const chunk of chunks) {
|
||||
// This is just a text chunks, push it up as a literal.
|
||||
if (chunk.type === 'text') {
|
||||
parts.push(b.literal(chunk.text));
|
||||
parts.push(rawStringLiteral(chunk.text));
|
||||
// This is a code chunk so do some magic
|
||||
} else {
|
||||
const fixed = fixStringNewLines(chunk.parsed);
|
||||
|
|
@ -264,7 +260,7 @@ export const getExpressionCode = (
|
|||
newProg.body.push(
|
||||
b.returnStatement(
|
||||
b.callExpression(b.memberExpression(b.arrayExpression(parts), b.identifier('join')), [
|
||||
b.literal(''),
|
||||
rawStringLiteral(''),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,26 @@ function assertNever(_value: never): _value is never {
|
|||
return true;
|
||||
}
|
||||
|
||||
// Captured at module load so printing stays stable even if the global is later replaced.
|
||||
const safeStringify = JSON.stringify;
|
||||
|
||||
// A string literal carrying its own printed form. recast's printer emits `extra.raw`
|
||||
// verbatim (when it matches the value) instead of stringifying at print time.
|
||||
export const rawStringLiteral = (value: string) => {
|
||||
const literal = b.literal(value);
|
||||
// JSON.stringify leaves U+2028/U+2029 unescaped; since the raw form is printed
|
||||
// verbatim, escape them so the emitted literal stays single-line and valid.
|
||||
const raw = safeStringify(value)
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029');
|
||||
// Attach `extra` in place so the ast-types node identity is preserved.
|
||||
(literal as namedTypes.Literal & { extra?: { raw: string; rawValue: string } }).extra = {
|
||||
raw,
|
||||
rawValue: value,
|
||||
};
|
||||
return literal;
|
||||
};
|
||||
|
||||
export const globalIdentifier = b.identifier(
|
||||
// @ts-expect-error window not in lib target
|
||||
typeof window !== 'object' ? 'global' : 'window',
|
||||
|
|
@ -21,7 +41,7 @@ export const globalIdentifier = b.identifier(
|
|||
const buildGlobalSwitch = (node: types.namedTypes.Identifier, dataNode: DataNode) => {
|
||||
return b.memberExpression(
|
||||
b.conditionalExpression(
|
||||
b.binaryExpression('in', b.literal(node.name), dataNode),
|
||||
b.binaryExpression('in', rawStringLiteral(node.name), dataNode),
|
||||
dataNode,
|
||||
globalIdentifier,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -105,3 +105,52 @@ describe('jsVariablePolyfill', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generated code', () => {
|
||||
it('generates identifier code independent of the global JSON.stringify', () => {
|
||||
const [pristine] = evaluator.getExpressionCode('{{ someFreeName }}');
|
||||
expect(pristine).toContain('"someFreeName"');
|
||||
|
||||
const bogus = 'BOGUS_STRINGIFY_OUTPUT';
|
||||
const originalStringify = JSON.stringify;
|
||||
let generated: string;
|
||||
try {
|
||||
JSON.stringify = (() => bogus) as unknown as typeof JSON.stringify;
|
||||
[generated] = evaluator.getExpressionCode('{{ anotherFreeName }}');
|
||||
} finally {
|
||||
JSON.stringify = originalStringify;
|
||||
}
|
||||
|
||||
expect(generated).not.toContain(bogus);
|
||||
expect(generated).toBe(pristine.replace(/someFreeName/g, 'anotherFreeName'));
|
||||
});
|
||||
|
||||
it('renders text chunks and the join separator independent of the global JSON.stringify', () => {
|
||||
const [pristine] = evaluator.getExpressionCode('prefix {{ value }}');
|
||||
expect(pristine).toContain('["prefix "');
|
||||
expect(pristine).toContain('].join("")');
|
||||
|
||||
const bogus = 'BOGUS_STRINGIFY_OUTPUT';
|
||||
const originalStringify = JSON.stringify;
|
||||
let generated: string;
|
||||
try {
|
||||
JSON.stringify = (() => bogus) as unknown as typeof JSON.stringify;
|
||||
[generated] = evaluator.getExpressionCode('prefix {{ value }}');
|
||||
} finally {
|
||||
JSON.stringify = originalStringify;
|
||||
}
|
||||
|
||||
// The text chunk and join separator render verbatim; neither is routed
|
||||
// through the global.
|
||||
expect(generated).toContain('["prefix "');
|
||||
expect(generated).toContain('].join("")');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a line separator', 'a\u2028b'],
|
||||
['a paragraph separator', 'a\u2029b'],
|
||||
['quotes, a backslash and a newline', 'he said "hi"\\\n\ttab'],
|
||||
])('round-trips text chunks containing %s', (_, text) => {
|
||||
expect(evaluator.execute(`${text} {{ 1 }}`, {})).toBe(`${text} 1`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -101,3 +101,18 @@ export class UnknownIdentifierError extends InterpreterError {
|
|||
this.identifier = identifier;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when interpretation exceeds a bound on statement count,
|
||||
* total produced data, or expression nesting depth.
|
||||
*/
|
||||
export class ResourceLimitError extends InterpreterError {
|
||||
readonly limitType: string;
|
||||
|
||||
/** Pass `detail` for sentence-style messages to avoid double-wrapping `limitType`. */
|
||||
constructor(limitType: string, location?: SourceLocation, sourceCode?: string, detail?: string) {
|
||||
super(detail ?? `Resource limit exceeded: '${limitType}'`, location, sourceCode);
|
||||
this.name = 'ResourceLimitError';
|
||||
this.limitType = limitType;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export {
|
|||
UnsupportedNodeError,
|
||||
SecurityError,
|
||||
UnknownIdentifierError,
|
||||
ResourceLimitError,
|
||||
} from './errors';
|
||||
export { parseSDKCode } from './parser';
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
SecurityError,
|
||||
UnsupportedNodeError,
|
||||
UnknownIdentifierError,
|
||||
ResourceLimitError,
|
||||
} from './errors';
|
||||
import { expr } from '../expression';
|
||||
import type { SDKFunctions } from './interpreter';
|
||||
|
|
@ -1066,6 +1067,288 @@ describe('AST Interpreter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Resource limits', () => {
|
||||
let sdkFunctions: SDKFunctions;
|
||||
|
||||
beforeEach(() => {
|
||||
sdkFunctions = createMockSDKFunctions();
|
||||
});
|
||||
|
||||
it('should reject exponential array-spread doubling before completing', () => {
|
||||
const doublings = 25; // 2^25 elements if unbounded — must abort far earlier
|
||||
const lines = ['const a0 = [1];'];
|
||||
for (let i = 1; i <= doublings; i++) {
|
||||
lines.push(`const a${i} = [...a${i - 1}, ...a${i - 1}];`);
|
||||
}
|
||||
lines.push(`export default a${doublings};`);
|
||||
const code = lines.join('\n');
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should reject cumulative object-spread growth across many statements', () => {
|
||||
// Object spread merges by key (duplicate keys overwrite rather than
|
||||
// duplicate), so it can't blow up exponentially the way array spread
|
||||
// does — but copying a large object's keys over and over still adds
|
||||
// up, so the shared budget must catch it across statements.
|
||||
const keyCount = 2000;
|
||||
const props = Array.from({ length: keyCount }, (_, i) => `k${i}: ${i}`).join(', ');
|
||||
// Each merge copies keyCount slots but adds only ~23 characters of
|
||||
// source, so the cost outgrows the source-derived budget.
|
||||
const mergeCount = 500;
|
||||
const lines = [`const big = { ${props} };`];
|
||||
|
||||
for (let i = 0; i < mergeCount; i++) {
|
||||
lines.push(`const m${i} = { ...big };`);
|
||||
}
|
||||
|
||||
lines.push('export default m0;');
|
||||
const code = lines.join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should reject a huge String.repeat count', () => {
|
||||
const code = 'export default "a".repeat(100000000);';
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should reject a huge String.repeat count passed as a numeric string', () => {
|
||||
// Native String.prototype.repeat coerces its argument, so a string-typed
|
||||
// count must be charged the same as a numeric one.
|
||||
const code = 'export default "a".repeat("100000000");';
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should reject exponential string growth from repeated + concatenation', () => {
|
||||
const doublings = 25; // 2^25 characters if unbounded — must abort far earlier
|
||||
const lines = ['const s0 = "a";'];
|
||||
for (let i = 1; i <= doublings; i++) {
|
||||
lines.push(`const s${i} = s${i - 1} + s${i - 1};`);
|
||||
}
|
||||
|
||||
lines.push(`export default s${doublings};`);
|
||||
const code = lines.join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should reject a reference DAG (Directed Acyclic Graph) that doubles without ever spreading', () => {
|
||||
// [a, a] is two references, no copy — must still cost what `a` holds.
|
||||
const doublings = 25;
|
||||
const lines = ['const a0 = [1];'];
|
||||
for (let i = 1; i <= doublings; i++) {
|
||||
lines.push(`const a${i} = [a${i - 1}, a${i - 1}];`);
|
||||
}
|
||||
|
||||
lines.push(`export default a${doublings};`);
|
||||
const code = lines.join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should count empty containers when sizing repeated references', () => {
|
||||
const doublings = 18;
|
||||
const lines = ['const a0 = [];'];
|
||||
for (let i = 1; i <= doublings; i++) {
|
||||
lines.push(`const a${i} = [a${i - 1}, a${i - 1}];`);
|
||||
}
|
||||
|
||||
lines.push(`export default a${doublings};`);
|
||||
const code = lines.join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should reject chained JSON.stringify that doubles its own output each round', () => {
|
||||
// Each round wraps the previous string in a 2-element array and
|
||||
// re-serializes it. Serializing writes both references out in full,
|
||||
// so the output doubles while the structure stays tiny.
|
||||
const rounds = 15;
|
||||
const lines = ['const s0 = "x".repeat(1000);'];
|
||||
for (let i = 1; i <= rounds; i++) {
|
||||
lines.push(`const a${i} = [s${i - 1}, s${i - 1}];`, `const s${i} = JSON.stringify(a${i});`);
|
||||
}
|
||||
|
||||
lines.push(`export default s${rounds};`);
|
||||
const code = lines.join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should reject a DAG whose members were filled in by property assignment', () => {
|
||||
// Nothing is serialized here, so only the recorded size of `holder` can
|
||||
// catch the doubling: left at the size it had when it was built, the
|
||||
// whole chain measures empty.
|
||||
const code = [
|
||||
'const big = "x".repeat(50000);',
|
||||
'const holder = {};',
|
||||
'holder.a = big;',
|
||||
'const a0 = [holder, holder];',
|
||||
'const a1 = [a0, a0];',
|
||||
'const a2 = [a1, a1];',
|
||||
'export default a2;',
|
||||
].join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should reject a DAG that repeats a long property name', () => {
|
||||
// The name is written out once per occurrence, so it counts towards the
|
||||
// output even though it is not part of any value. Sized like the case
|
||||
// above, so a late limit would surface as a RangeError instead.
|
||||
const doublings = 14;
|
||||
const lines = [`const k = { "${'K'.repeat(20_000)}": 1 };`, 'const a0 = [k, k];'];
|
||||
for (let i = 1; i <= doublings; i++) {
|
||||
lines.push(`const a${i} = [a${i - 1}, a${i - 1}];`);
|
||||
}
|
||||
|
||||
lines.push(`export default JSON.stringify(a${doublings});`);
|
||||
const code = lines.join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should still allow ordinary property assignment', () => {
|
||||
const code = [
|
||||
'const cfg = {};',
|
||||
'cfg.name = "hello";',
|
||||
'cfg.items = [1, 2, 3];',
|
||||
'export default JSON.stringify(cfg);',
|
||||
].join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject deep nesting serialized with a wide indent', () => {
|
||||
// Indentation grows with depth, so it has to be counted per node: left
|
||||
// out, this reaches the engine's string ceiling and raises a RangeError
|
||||
// while building instead.
|
||||
const depth = 4900;
|
||||
const lines = ['const d0 = [1];'];
|
||||
for (let i = 1; i <= depth; i++) {
|
||||
lines.push(`const d${i} = [d${i - 1}];`);
|
||||
}
|
||||
|
||||
lines.push(`export default JSON.stringify(d${depth}, null, 10);`);
|
||||
const code = lines.join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should reject JSON.stringify traversing an externally produced oversized structure', () => {
|
||||
// Untracked value (no tracked size) must still trip the JSON.stringify guard.
|
||||
const bigArray = Array.from({ length: 200_001 }, (_, i) => i); // above the floor budget
|
||||
const funcs: SDKFunctions = {
|
||||
...sdkFunctions,
|
||||
getBig: vi.fn(() => bigArray),
|
||||
};
|
||||
const code = 'export default JSON.stringify(getBig());';
|
||||
|
||||
expect(() => interpretSDKCode(code, funcs)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should accumulate JSON.stringify traversal across separate calls', () => {
|
||||
// Each call stays under the limit on its own; together they must not.
|
||||
const bigArray = Array.from({ length: 50_000 }, (_, i) => i);
|
||||
const funcs: SDKFunctions = {
|
||||
...sdkFunctions,
|
||||
getBig: vi.fn(() => bigArray),
|
||||
};
|
||||
const code = [
|
||||
'const s1 = JSON.stringify(getBig());',
|
||||
'const s2 = JSON.stringify(getBig());',
|
||||
'const s3 = JSON.stringify(getBig());',
|
||||
'const s4 = JSON.stringify(getBig());',
|
||||
'const s5 = JSON.stringify(getBig());',
|
||||
'export default s5;',
|
||||
].join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, funcs)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should reject a JSON.stringify replacer, which would displace the size guard', () => {
|
||||
const code = 'export default JSON.stringify({ a: 1, b: 2 }, ["a"]);';
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(UnsupportedNodeError);
|
||||
});
|
||||
|
||||
it('should still allow JSON.stringify on a reasonably large legitimate structure', () => {
|
||||
const code = [
|
||||
'const items = [1, 2, 3, 4, 5];',
|
||||
'export default JSON.stringify({ items, name: "test" });',
|
||||
].join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should allow a large embedded literal to be serialized into a parameter', () => {
|
||||
// The documented way to embed a page in a Code node. Passing the same
|
||||
// string along must not be charged again for every hop it makes.
|
||||
const html = 'y'.repeat(150_000);
|
||||
const code = [
|
||||
`const html = "${html}";`,
|
||||
'const jsCode = JSON.stringify(html);',
|
||||
'export default { parameters: { jsCode } };',
|
||||
].join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should allow a multi-part concatenation around a large embedded literal', () => {
|
||||
// Charging each step its whole result would make this cost O(n^2).
|
||||
const html = 'y'.repeat(120_000);
|
||||
const code = [
|
||||
`const html = "${html}";`,
|
||||
'const jsCode = "var h=" + JSON.stringify(html) + "; return h;";',
|
||||
'export default { parameters: { jsCode } };',
|
||||
].join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject unbounded template-literal growth from repeated big values', () => {
|
||||
const code = [
|
||||
'const big = "x".repeat(150000);',
|
||||
'const t1 = `${big}${big}`;',
|
||||
'const t2 = `${t1}${t1}`;',
|
||||
'export default t2;',
|
||||
].join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should reject programs with too many top-level statements', () => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 6000; i++) {
|
||||
lines.push(`const v${i} = ${i};`);
|
||||
}
|
||||
|
||||
lines.push('export default v0;');
|
||||
const code = lines.join('\n');
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
|
||||
it('should still allow reasonably large legitimate literals and spreads', () => {
|
||||
const items = Array.from({ length: 500 }, (_, i) => i).join(',');
|
||||
const code = `const items = [${items}]; export default { ...{ items } };`;
|
||||
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should cap unbounded recursion reached via template-literal runtime-variable detection', () => {
|
||||
// Builds a deeply nested $-prefixed member chain inside a template literal,
|
||||
// which is resolved via isN8nRuntimeVariable/expressionToString, not evaluate().
|
||||
let expression = '$json';
|
||||
for (let i = 0; i < 600; i++) {
|
||||
expression += '.a';
|
||||
}
|
||||
|
||||
const code = `export default \`\${${expression}}\`;`;
|
||||
expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expr(placeholder(...)) round-trip', () => {
|
||||
it('prepends = to the placeholder marker so it parses as an n8n expression', () => {
|
||||
const funcs: SDKFunctions = {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
UnsupportedNodeError,
|
||||
UnknownIdentifierError,
|
||||
SecurityError,
|
||||
ResourceLimitError,
|
||||
} from './errors';
|
||||
import { parseSDKCode } from './parser';
|
||||
import {
|
||||
|
|
@ -37,17 +38,32 @@ export type SDKFunctions = Record<string, (...args: any[]) => unknown>;
|
|||
*/
|
||||
class SDKInterpreter {
|
||||
private static readonly MAX_EVAL_DEPTH = 500;
|
||||
private static readonly MAX_STATEMENT_COUNT = 5_000;
|
||||
/** Smallest data budget any program gets, however short it is. */
|
||||
private static readonly MIN_ALLOCATED_UNITS = 200_000;
|
||||
/** Extra data budget per character of source, on top of that MIN_ALLOCATED_UNITS minimum. */
|
||||
private static readonly ALLOCATION_AMPLIFICATION = 20;
|
||||
|
||||
private sdkFunctions: Map<string, (...args: unknown[]) => unknown>;
|
||||
private variables: Map<string, unknown>;
|
||||
private renamedVariables: Map<string, string> = new Map();
|
||||
private sourceCode: string;
|
||||
private evalDepth = 0;
|
||||
/** Ceiling on the data this program may produce, derived in constructor from the input program length. */
|
||||
private readonly maxAllocatedUnits: number;
|
||||
/** How much of that ceiling has been used so far. */
|
||||
private allocatedUnits = 0;
|
||||
/** What each array/object holds, so referencing one can be sized without walking it again. */
|
||||
private valueWeights = new WeakMap<object, number>();
|
||||
|
||||
constructor(sdkFunctions: SDKFunctions, sourceCode: string) {
|
||||
this.sdkFunctions = new Map(Object.entries(sdkFunctions));
|
||||
this.variables = new Map();
|
||||
this.sourceCode = sourceCode;
|
||||
this.maxAllocatedUnits = Math.max(
|
||||
SDKInterpreter.MIN_ALLOCATED_UNITS,
|
||||
sourceCode.length * SDKInterpreter.ALLOCATION_AMPLIFICATION,
|
||||
);
|
||||
}
|
||||
|
||||
private generateSafeName(name: string): string {
|
||||
|
|
@ -65,6 +81,8 @@ class SDKInterpreter {
|
|||
* Interpret the AST program and return the result.
|
||||
*/
|
||||
interpret(ast: ESTree.Program): unknown {
|
||||
this.assertAstBodyLengthWithinLimits(ast);
|
||||
|
||||
let result: unknown;
|
||||
|
||||
for (const stmt of ast.body) {
|
||||
|
|
@ -87,6 +105,24 @@ class SDKInterpreter {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the AST body length is within the allowed limits.
|
||||
* Throws a ResourceLimitError if the limit is exceeded.
|
||||
* @param ast
|
||||
*/
|
||||
private assertAstBodyLengthWithinLimits(ast: ESTree.Program): void {
|
||||
if (ast.body.length <= SDKInterpreter.MAX_STATEMENT_COUNT) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ResourceLimitError(
|
||||
'statement-count',
|
||||
ast.loc ?? undefined,
|
||||
this.sourceCode,
|
||||
`Program has ${ast.body.length} top-level statements; the limit is ${SDKInterpreter.MAX_STATEMENT_COUNT}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a variable declaration.
|
||||
*/
|
||||
|
|
@ -140,16 +176,12 @@ class SDKInterpreter {
|
|||
* Evaluate an expression and return its value.
|
||||
*/
|
||||
private evaluate(node: ESTree.Expression | ESTree.SpreadElement | null): unknown {
|
||||
if (node === null) return undefined;
|
||||
|
||||
if (this.evalDepth >= SDKInterpreter.MAX_EVAL_DEPTH) {
|
||||
throw new InterpreterError(
|
||||
'Expression nesting too deep (possible cycle in method chain)',
|
||||
node.loc ?? undefined,
|
||||
this.sourceCode,
|
||||
);
|
||||
if (node === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.assertDepthWithinLimits(node);
|
||||
|
||||
this.evalDepth++;
|
||||
try {
|
||||
return this.evaluateNode(node);
|
||||
|
|
@ -158,6 +190,142 @@ class SDKInterpreter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a recursive helper under the same nesting-depth guard as `evaluate()`,
|
||||
* for helpers that walk the AST directly instead of calling `evaluate()`.
|
||||
*/
|
||||
private withDepthGuard<T>(node: { loc?: ESTree.SourceLocation | null }, fn: () => T): T {
|
||||
// Assert the depth on every node visit, not just on the initial evaluate() call, to catch cycles in method chains.
|
||||
this.assertDepthWithinLimits(node);
|
||||
|
||||
this.evalDepth++;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
this.evalDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current evaluation depth is within the allowed limits.
|
||||
* Throws a ResourceLimitError if the limit is exceeded.
|
||||
* @param node
|
||||
*/
|
||||
private assertDepthWithinLimits(node: { loc?: ESTree.SourceLocation | null }): void {
|
||||
if (this.evalDepth < SDKInterpreter.MAX_EVAL_DEPTH) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ResourceLimitError(
|
||||
'nesting-depth',
|
||||
node.loc ?? undefined,
|
||||
this.sourceCode,
|
||||
'Expression nesting too deep (possible cycle in method chain)',
|
||||
);
|
||||
}
|
||||
|
||||
private chargeBudget(units: number, location?: ESTree.SourceLocation): void {
|
||||
if (units <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.allocatedUnits + units <= this.maxAllocatedUnits) {
|
||||
this.allocatedUnits += units;
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ResourceLimitError(
|
||||
'allocation-budget',
|
||||
location,
|
||||
this.sourceCode,
|
||||
'SDK code produced or traversed too much data. Limit is ' +
|
||||
`${this.maxAllocatedUnits} total elements/characters.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** How much data a value holds: a string's length, an array/object's tracked total, otherwise 1. */
|
||||
private sizeOf(value: unknown): number {
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Math.max(this.valueWeights.get(value) ?? 1, 1);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return Math.max(value.length, 1);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private assertValueSizeWithinLimits(size: number, location?: ESTree.SourceLocation): void {
|
||||
if (size <= this.maxAllocatedUnits) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ResourceLimitError(
|
||||
'allocation-budget',
|
||||
location,
|
||||
this.sourceCode,
|
||||
`A single value grew to ${size} elements/characters; the limit is ${this.maxAllocatedUnits}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spaces JSON.stringify adds per level of nesting, which it never passes to
|
||||
* the replacer. It caps both forms at ten, so charging has to cap them too.
|
||||
*/
|
||||
private calculateIndentationWidth(space: unknown): number {
|
||||
const maxCap = 10;
|
||||
if (typeof space === 'number') {
|
||||
return Math.max(Math.min(Math.trunc(space), maxCap), 0);
|
||||
}
|
||||
|
||||
if (typeof space === 'string') {
|
||||
return Math.min(space.length, maxCap);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializing a POJO into JSON writes every repeated reference out in full, so the output can
|
||||
* be much bigger than its input.
|
||||
*/
|
||||
private safeJSONStringify(
|
||||
value: unknown,
|
||||
space: unknown,
|
||||
location?: ESTree.SourceLocation,
|
||||
): string | undefined {
|
||||
this.assertValueSizeWithinLimits(this.sizeOf(value), location);
|
||||
|
||||
const indentWidth = this.calculateIndentationWidth(space);
|
||||
const depths = new WeakMap<object, number>();
|
||||
const charge = (units: number) => this.chargeBudget(units, location);
|
||||
|
||||
/**
|
||||
* The replacer is the only hook into the walk, so it is where an oversized value
|
||||
* can be stopped before the whole string exists.
|
||||
*/
|
||||
const replacer = function (this: unknown, key: string, val: unknown) {
|
||||
// `this` is the array or object holding this node, and a holder is always
|
||||
// reached before its members, so its depth is already recorded.
|
||||
const depth = key === '' ? 0 : (depths.get(this as object) ?? 0) + 1;
|
||||
if (val !== null && typeof val === 'object') {
|
||||
depths.set(val, depth);
|
||||
}
|
||||
|
||||
charge(key.length + depth * indentWidth + (typeof val === 'string' ? val.length : 1));
|
||||
return val;
|
||||
};
|
||||
|
||||
const result = JSON.stringify(value, replacer, space as string | number | undefined);
|
||||
|
||||
if (typeof result === 'string') {
|
||||
this.chargeBudget(result.length, location);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private evaluateNode(node: ESTree.Expression | ESTree.SpreadElement): unknown {
|
||||
validateNodeType(node, this.sourceCode);
|
||||
|
||||
|
|
@ -255,6 +423,17 @@ class SDKInterpreter {
|
|||
const safeMethod = getSafeJSONMethod(memberExpr.object.name, methodName);
|
||||
if (safeMethod) {
|
||||
const args = node.arguments.map((arg) => this.evaluate(arg));
|
||||
if (methodName === 'stringify') {
|
||||
// The replacer slot is reserved for the size guard below.
|
||||
if (args[1] !== undefined && args[1] !== null) {
|
||||
throw new UnsupportedNodeError(
|
||||
'JSON.stringify replacer',
|
||||
node.loc ?? undefined,
|
||||
this.sourceCode,
|
||||
);
|
||||
}
|
||||
return this.safeJSONStringify(args[0], args[2], node.loc ?? undefined);
|
||||
}
|
||||
return safeMethod(...args);
|
||||
}
|
||||
}
|
||||
|
|
@ -265,6 +444,13 @@ class SDKInterpreter {
|
|||
const safeStringMethod = getSafeStringMethod(thisArg, methodName);
|
||||
if (safeStringMethod) {
|
||||
const args = node.arguments.map((arg) => this.evaluate(arg));
|
||||
if (methodName === 'repeat' && typeof thisArg === 'string') {
|
||||
// Coerce like native String.prototype.repeat does, so a numeric
|
||||
// string (e.g. "1e9") can't dodge the charge below.
|
||||
const coercedCount = Number(args[0]);
|
||||
const count = Number.isFinite(coercedCount) && coercedCount > 0 ? coercedCount : 0;
|
||||
this.chargeBudget(thisArg.length * count, node.loc ?? undefined);
|
||||
}
|
||||
return safeStringMethod(...args);
|
||||
}
|
||||
|
||||
|
|
@ -354,13 +540,20 @@ class SDKInterpreter {
|
|||
*/
|
||||
private visitObjectExpression(node: ESTree.ObjectExpression): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
let weight = 0;
|
||||
|
||||
for (const prop of node.properties) {
|
||||
if (prop.type === 'SpreadElement') {
|
||||
// Handle spread: { ...obj }
|
||||
const spreadValue = this.evaluate(prop.argument);
|
||||
if (spreadValue && typeof spreadValue === 'object') {
|
||||
Object.assign(result, spreadValue);
|
||||
for (const key of Object.keys(spreadValue)) {
|
||||
const value = (spreadValue as Record<string, unknown>)[key];
|
||||
this.chargeBudget(1, prop.loc ?? undefined);
|
||||
result[key] = value;
|
||||
|
||||
weight += this.sizeOf(value);
|
||||
}
|
||||
}
|
||||
} else if (prop.type === 'Property') {
|
||||
// Get key
|
||||
|
|
@ -381,10 +574,14 @@ class SDKInterpreter {
|
|||
|
||||
// Get value (handle shorthand: { name } === { name: name })
|
||||
const value = this.evaluate(prop.value as ESTree.Expression);
|
||||
this.chargeBudget(1, prop.loc ?? undefined);
|
||||
result[key] = value;
|
||||
weight += this.sizeOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
this.assertValueSizeWithinLimits(weight, node.loc ?? undefined);
|
||||
this.valueWeights.set(result, weight);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -393,22 +590,31 @@ class SDKInterpreter {
|
|||
*/
|
||||
private visitArrayExpression(node: ESTree.ArrayExpression): unknown[] {
|
||||
const result: unknown[] = [];
|
||||
let weight = 0;
|
||||
|
||||
for (const element of node.elements) {
|
||||
if (element === null) {
|
||||
result.push(undefined);
|
||||
weight += 1;
|
||||
} else if (element.type === 'SpreadElement') {
|
||||
const spreadValue = this.evaluate(element.argument);
|
||||
if (Array.isArray(spreadValue)) {
|
||||
for (const item of spreadValue) {
|
||||
this.chargeBudget(1, element.loc ?? undefined);
|
||||
result.push(item);
|
||||
weight += this.sizeOf(item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(this.evaluate(element));
|
||||
const value = this.evaluate(element);
|
||||
this.chargeBudget(1, element.loc ?? undefined);
|
||||
result.push(value);
|
||||
weight += this.sizeOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
this.assertValueSizeWithinLimits(weight, node.loc ?? undefined);
|
||||
this.valueWeights.set(result, weight);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -461,6 +667,8 @@ class SDKInterpreter {
|
|||
if (i < node.expressions.length) {
|
||||
const expr = node.expressions[i];
|
||||
const value = this.evaluateTemplateExpression(expr);
|
||||
// charge 1 unit to the budget for each character in the evaluated expression to avoid huge template literal output
|
||||
this.chargeBudget(value.length, expr.loc ?? undefined);
|
||||
result += value;
|
||||
}
|
||||
}
|
||||
|
|
@ -490,21 +698,27 @@ class SDKInterpreter {
|
|||
* These start with $ like $json, $today, $input, etc.
|
||||
*/
|
||||
private isN8nRuntimeVariable(expr: ESTree.Expression): boolean {
|
||||
if (expr.type === 'Identifier') {
|
||||
return expr.name.startsWith('$');
|
||||
}
|
||||
if (expr.type === 'MemberExpression') {
|
||||
return this.isN8nRuntimeVariable(expr.object as ESTree.Expression);
|
||||
}
|
||||
if (expr.type === 'CallExpression') {
|
||||
if (expr.callee.type === 'Identifier') {
|
||||
return expr.callee.name.startsWith('$');
|
||||
return this.withDepthGuard(expr, () => {
|
||||
if (expr.type === 'Identifier') {
|
||||
return expr.name.startsWith('$');
|
||||
}
|
||||
if (expr.callee.type === 'MemberExpression') {
|
||||
return this.isN8nRuntimeVariable(expr.callee.object as ESTree.Expression);
|
||||
|
||||
if (expr.type === 'MemberExpression') {
|
||||
return this.isN8nRuntimeVariable(expr.object as ESTree.Expression);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
||||
if (expr.type === 'CallExpression') {
|
||||
if (expr.callee.type === 'Identifier') {
|
||||
return expr.callee.name.startsWith('$');
|
||||
}
|
||||
|
||||
if (expr.callee.type === 'MemberExpression') {
|
||||
return this.isN8nRuntimeVariable(expr.callee.object as ESTree.Expression);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -512,67 +726,81 @@ class SDKInterpreter {
|
|||
* Used for preserving n8n runtime variables in template literals.
|
||||
*/
|
||||
private expressionToString(expr: ESTree.Expression): string {
|
||||
if (expr.type === 'Identifier') {
|
||||
return expr.name;
|
||||
}
|
||||
if (expr.type === 'MemberExpression') {
|
||||
const obj = this.expressionToString(expr.object as ESTree.Expression);
|
||||
if (expr.computed) {
|
||||
if (expr.property.type === 'Literal') {
|
||||
return `${obj}[${JSON.stringify(expr.property.value)}]`;
|
||||
}
|
||||
return `${obj}[${this.expressionToString(expr.property as ESTree.Expression)}]`;
|
||||
return this.withDepthGuard(expr, () => {
|
||||
if (expr.type === 'Identifier') {
|
||||
return expr.name;
|
||||
}
|
||||
const prop = expr.property.type === 'Identifier' ? expr.property.name : '';
|
||||
return `${obj}.${prop}`;
|
||||
}
|
||||
if (expr.type === 'CallExpression') {
|
||||
const callee = this.expressionToString(expr.callee as ESTree.Expression);
|
||||
const args = expr.arguments
|
||||
.map((arg) => {
|
||||
if (arg.type === 'SpreadElement') {
|
||||
return '...' + this.expressionToString(arg.argument);
|
||||
|
||||
if (expr.type === 'MemberExpression') {
|
||||
const obj = this.expressionToString(expr.object as ESTree.Expression);
|
||||
|
||||
if (expr.computed) {
|
||||
if (expr.property.type === 'Literal') {
|
||||
return `${obj}[${JSON.stringify(expr.property.value)}]`;
|
||||
}
|
||||
return this.expressionToString(arg);
|
||||
})
|
||||
.join(', ');
|
||||
return `${callee}(${args})`;
|
||||
}
|
||||
if (expr.type === 'Literal') {
|
||||
if (typeof expr.value === 'string') {
|
||||
return JSON.stringify(expr.value);
|
||||
return `${obj}[${this.expressionToString(expr.property as ESTree.Expression)}]`;
|
||||
}
|
||||
|
||||
const prop = expr.property.type === 'Identifier' ? expr.property.name : '';
|
||||
return `${obj}.${prop}`;
|
||||
}
|
||||
return String(expr.value);
|
||||
}
|
||||
if (expr.type === 'BinaryExpression' || expr.type === 'LogicalExpression') {
|
||||
const left = this.expressionToString(expr.left as ESTree.Expression);
|
||||
const right = this.expressionToString(expr.right);
|
||||
return `${left} ${expr.operator} ${right}`;
|
||||
}
|
||||
if (expr.type === 'ConditionalExpression') {
|
||||
const test = this.expressionToString(expr.test);
|
||||
const consequent = this.expressionToString(expr.consequent);
|
||||
const alternate = this.expressionToString(expr.alternate);
|
||||
return `${test} ? ${consequent} : ${alternate}`;
|
||||
}
|
||||
if (expr.type === 'UnaryExpression') {
|
||||
const unary = expr;
|
||||
const arg = this.expressionToString(unary.argument);
|
||||
return unary.prefix ? `${unary.operator}${arg}` : `${arg}${unary.operator}`;
|
||||
}
|
||||
|
||||
// Fallback: extract from source code if we have location info
|
||||
if (expr.loc && this.sourceCode) {
|
||||
const lines = this.sourceCode.split('\n');
|
||||
const startLine = expr.loc.start.line - 1;
|
||||
const endLine = expr.loc.end.line - 1;
|
||||
if (expr.type === 'CallExpression') {
|
||||
const callee = this.expressionToString(expr.callee as ESTree.Expression);
|
||||
const args = expr.arguments
|
||||
.map((arg) => {
|
||||
if (arg.type === 'SpreadElement') {
|
||||
return '...' + this.expressionToString(arg.argument);
|
||||
}
|
||||
return this.expressionToString(arg);
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
if (startLine === endLine) {
|
||||
return lines[startLine].slice(expr.loc.start.column, expr.loc.end.column);
|
||||
return `${callee}(${args})`;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
if (expr.type === 'Literal') {
|
||||
if (typeof expr.value === 'string') {
|
||||
return JSON.stringify(expr.value);
|
||||
}
|
||||
return String(expr.value);
|
||||
}
|
||||
|
||||
if (expr.type === 'BinaryExpression' || expr.type === 'LogicalExpression') {
|
||||
const left = this.expressionToString(expr.left as ESTree.Expression);
|
||||
const right = this.expressionToString(expr.right);
|
||||
|
||||
return `${left} ${expr.operator} ${right}`;
|
||||
}
|
||||
|
||||
if (expr.type === 'ConditionalExpression') {
|
||||
const test = this.expressionToString(expr.test);
|
||||
const consequent = this.expressionToString(expr.consequent);
|
||||
const alternate = this.expressionToString(expr.alternate);
|
||||
|
||||
return `${test} ? ${consequent} : ${alternate}`;
|
||||
}
|
||||
|
||||
if (expr.type === 'UnaryExpression') {
|
||||
const unary = expr;
|
||||
const arg = this.expressionToString(unary.argument);
|
||||
|
||||
return unary.prefix ? `${unary.operator}${arg}` : `${arg}${unary.operator}`;
|
||||
}
|
||||
|
||||
// Fallback: extract from source code if we have location info
|
||||
if (expr.loc && this.sourceCode) {
|
||||
const lines = this.sourceCode.split('\n');
|
||||
const startLine = expr.loc.start.line - 1;
|
||||
const endLine = expr.loc.end.line - 1;
|
||||
|
||||
if (startLine === endLine) {
|
||||
return lines[startLine].slice(expr.loc.start.column, expr.loc.end.column);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -618,6 +846,9 @@ class SDKInterpreter {
|
|||
switch (node.operator) {
|
||||
case '+':
|
||||
if (typeof left === 'string' || typeof right === 'string') {
|
||||
// Only the appended part is new; charging the whole result would
|
||||
// make an n-piece chain cost O(n^2) for linear output.
|
||||
this.chargeBudget(String(right).length, node.loc ?? undefined);
|
||||
return String(left) + String(right);
|
||||
}
|
||||
return (left as number) + (right as number);
|
||||
|
|
@ -744,7 +975,16 @@ class SDKInterpreter {
|
|||
}
|
||||
|
||||
const value = this.evaluate(node.right);
|
||||
this.chargeBudget(1, node.loc ?? undefined);
|
||||
(obj as Record<string | number, unknown>)[propName] = value;
|
||||
|
||||
// Assigning grows the target, so its recorded size has to grow with it or
|
||||
// every later check reads the size it had when it was built. An overwrite
|
||||
// is counted as an addition, which overstates it in the safe direction.
|
||||
const grown = this.sizeOf(obj) + this.sizeOf(value);
|
||||
this.assertValueSizeWithinLimits(grown, node.loc ?? undefined);
|
||||
this.valueWeights.set(obj as object, grown);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -421,4 +421,5 @@ export const SDK_INLINE_CONSTRAINTS: readonly string[] = [
|
|||
'No destructuring in declarations — declare each value with its own `const`.',
|
||||
'No variable reassignment — only property assignment (`obj.prop = value`) is allowed.',
|
||||
"No computed/dynamic member access (`obj[expr]`); use static names. Literal keys like `obj['a']` or `arr[0]` are fine.",
|
||||
'`JSON.stringify(value)` takes no replacer argument — pass `null` if you need the indentation argument.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -176,4 +176,24 @@ describe('parseWorkflowCodeToBuilder', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource limits', () => {
|
||||
it('should surface a resource-limit violation as a SyntaxError', () => {
|
||||
const doublings = 25;
|
||||
const lines = ['const a0 = [1];'];
|
||||
|
||||
for (let i = 1; i <= doublings; i++) {
|
||||
lines.push(`const a${i} = [...a${i - 1}, ...a${i - 1}];`);
|
||||
}
|
||||
|
||||
lines.push(`export default a${doublings};`);
|
||||
const code = lines.join('\n');
|
||||
|
||||
// Match the specific message, not just SyntaxError: exporting a bare
|
||||
// array is also invalid on its own (wrong export type), so asserting
|
||||
// only the error class would pass even if the resource limit were
|
||||
// never enforced.
|
||||
expect(() => parseWorkflowCodeToBuilder(code)).toThrow(/too much data/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { GlobalConfig, WorkflowsConfig } from '@n8n/config';
|
|||
import type { Project, WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db';
|
||||
import type { UpdateResult } from '@n8n/typeorm';
|
||||
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
||||
import { sleep } from '@n8n/utils/sleep';
|
||||
import type { ErrorReporter, InstanceSettings } from 'n8n-core';
|
||||
import {
|
||||
ActiveWorkflowTriggers,
|
||||
|
|
@ -26,7 +27,6 @@ import type {
|
|||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import { sleep } from '@n8n/utils/sleep';
|
||||
import { Workflow, WorkflowActivationError } from 'n8n-workflow';
|
||||
import { mock, type MockProxy } from 'vitest-mock-extended';
|
||||
|
||||
|
|
@ -49,6 +49,8 @@ import type { OwnershipService } from '@/services/ownership.service';
|
|||
import type { PollCursorService } from '@/workflows/triggers/poll-cursor.service';
|
||||
import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory';
|
||||
import type { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
|
||||
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
|
||||
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
||||
import type { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
||||
|
||||
describe('ActiveWorkflowManager', () => {
|
||||
|
|
@ -85,11 +87,11 @@ describe('ActiveWorkflowManager', () => {
|
|||
instanceSettings,
|
||||
mock(),
|
||||
workflowsConfig,
|
||||
mock(),
|
||||
mock<TriggerExecutionContextFactory>(),
|
||||
mock(),
|
||||
mock(), // scheduleTriggerJobRegistrar
|
||||
mock(), // pollTriggerJobRegistrar
|
||||
mock(), // workflowPushNotifier
|
||||
policyEnforcementService,
|
||||
ownershipService,
|
||||
);
|
||||
|
|
@ -223,9 +225,9 @@ describe('ActiveWorkflowManager', () => {
|
|||
instanceSettings,
|
||||
mock(),
|
||||
policyWorkflowsConfig,
|
||||
mock(),
|
||||
mock<TriggerExecutionContextFactory>(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(), // scheduleTriggerJobRegistrar
|
||||
mock(), // pollTriggerJobRegistrar
|
||||
policyEnforcementService,
|
||||
|
|
@ -414,8 +416,13 @@ describe('ActiveWorkflowManager', () => {
|
|||
describe('handleAddWebhooksAndNonWebhookTriggers', () => {
|
||||
const push = mock<Push>();
|
||||
const publisher = mock<Publisher>();
|
||||
const workflowSharingService = mock<WorkflowSharingService>();
|
||||
const workflowPushNotifier = new WorkflowPushNotifier(push, workflowSharingService);
|
||||
const sharedUserIds = ['user-1', 'user-2'];
|
||||
|
||||
beforeEach(() => {
|
||||
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(sharedUserIds);
|
||||
publisher.publishCommand.mockResolvedValue(undefined);
|
||||
activeWorkflowManager = new ActiveWorkflowManager(
|
||||
mockLogger(),
|
||||
mock(),
|
||||
|
|
@ -430,11 +437,11 @@ describe('ActiveWorkflowManager', () => {
|
|||
instanceSettings,
|
||||
publisher,
|
||||
mock(),
|
||||
push,
|
||||
mock<TriggerExecutionContextFactory>(),
|
||||
mock(),
|
||||
mock(), // scheduleTriggerJobRegistrar
|
||||
mock(), // pollTriggerJobRegistrar
|
||||
workflowPushNotifier,
|
||||
policyEnforcementService,
|
||||
ownershipService,
|
||||
);
|
||||
|
|
@ -460,8 +467,8 @@ describe('ActiveWorkflowManager', () => {
|
|||
expect(clearWebhooksSpy).not.toHaveBeenCalled();
|
||||
expect(removeTriggersSpy).not.toHaveBeenCalled();
|
||||
// The user is still told why it did not start.
|
||||
expect(push.broadcast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'workflowFailedToActivate' }),
|
||||
expect(push.sendToUsers.mock.calls.map(([msg]) => msg.type)).toContain(
|
||||
'workflowFailedToActivate',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -479,14 +486,20 @@ describe('ActiveWorkflowManager', () => {
|
|||
activationMode: 'activate',
|
||||
});
|
||||
|
||||
expect(push.broadcast).toHaveBeenCalledWith({
|
||||
type: 'workflowFailedToActivate',
|
||||
data: {
|
||||
workflowId: 'wf-1',
|
||||
errorMessage: 'Invalid role: admin required',
|
||||
nodeId: 'node-123',
|
||||
expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith(
|
||||
'wf-1',
|
||||
);
|
||||
expect(push.sendToUsers).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'workflowFailedToActivate',
|
||||
data: {
|
||||
workflowId: 'wf-1',
|
||||
errorMessage: 'Invalid role: admin required',
|
||||
nodeId: 'node-123',
|
||||
},
|
||||
},
|
||||
});
|
||||
sharedUserIds,
|
||||
);
|
||||
|
||||
expect(publisher.publishCommand).toHaveBeenCalledWith({
|
||||
command: 'display-workflow-activation-error',
|
||||
|
|
@ -507,10 +520,13 @@ describe('ActiveWorkflowManager', () => {
|
|||
activationMode: 'activate',
|
||||
});
|
||||
|
||||
expect(push.broadcast).toHaveBeenCalledWith({
|
||||
type: 'workflowFailedToActivate',
|
||||
data: { workflowId: 'wf-1', errorMessage: 'Some error' },
|
||||
});
|
||||
expect(push.sendToUsers).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'workflowFailedToActivate',
|
||||
data: { workflowId: 'wf-1', errorMessage: 'Some error' },
|
||||
},
|
||||
sharedUserIds,
|
||||
);
|
||||
});
|
||||
|
||||
test('should tear down partial registrations before deactivating when activation fails', async () => {
|
||||
|
|
@ -580,6 +596,180 @@ describe('ActiveWorkflowManager', () => {
|
|||
activeVersionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('should push workflowActivated to accessible users and relay it on successful activation', async () => {
|
||||
vi.spyOn(activeWorkflowManager, 'add').mockResolvedValue({
|
||||
webhooks: true,
|
||||
triggersAndPollers: true,
|
||||
});
|
||||
|
||||
await activeWorkflowManager.handleAddWebhooksAndNonWebhookTriggers({
|
||||
workflowId: 'wf-1',
|
||||
activeVersionId: 'v1',
|
||||
activationMode: 'activate',
|
||||
});
|
||||
|
||||
expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith(
|
||||
'wf-1',
|
||||
);
|
||||
expect(push.sendToUsers).toHaveBeenCalledWith(
|
||||
{ type: 'workflowActivated', data: { workflowId: 'wf-1', activeVersionId: 'v1' } },
|
||||
sharedUserIds,
|
||||
);
|
||||
expect(publisher.publishCommand).toHaveBeenCalledWith({
|
||||
command: 'display-workflow-activation',
|
||||
payload: { workflowId: 'wf-1', activeVersionId: 'v1' },
|
||||
});
|
||||
expect(workflowRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not roll back a successfully activated workflow when nobody can be resolved to notify', async () => {
|
||||
vi.spyOn(activeWorkflowManager, 'add').mockResolvedValue({
|
||||
webhooks: true,
|
||||
triggersAndPollers: true,
|
||||
});
|
||||
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValueOnce([]);
|
||||
|
||||
await activeWorkflowManager.handleAddWebhooksAndNonWebhookTriggers({
|
||||
workflowId: 'wf-1',
|
||||
activeVersionId: 'v1',
|
||||
activationMode: 'activate',
|
||||
});
|
||||
|
||||
expect(push.sendToUsers).toHaveBeenCalledWith(
|
||||
{ type: 'workflowActivated', data: { workflowId: 'wf-1', activeVersionId: 'v1' } },
|
||||
[],
|
||||
);
|
||||
expect(publisher.publishCommand).toHaveBeenCalledWith({
|
||||
command: 'display-workflow-activation',
|
||||
payload: { workflowId: 'wf-1', activeVersionId: 'v1' },
|
||||
});
|
||||
expect(workflowRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not roll back a successfully activated workflow even if the recipient lookup itself throws', async () => {
|
||||
vi.spyOn(activeWorkflowManager, 'add').mockResolvedValue({
|
||||
webhooks: true,
|
||||
triggersAndPollers: true,
|
||||
});
|
||||
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockRejectedValueOnce(
|
||||
new Error('db unavailable'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
activeWorkflowManager.handleAddWebhooksAndNonWebhookTriggers({
|
||||
workflowId: 'wf-1',
|
||||
activeVersionId: 'v1',
|
||||
activationMode: 'activate',
|
||||
}),
|
||||
).rejects.toThrow('db unavailable');
|
||||
|
||||
expect(workflowRepository.update).not.toHaveBeenCalled();
|
||||
expect(push.sendToUsers).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'workflowFailedToActivate' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('display and removal pubsub handlers', () => {
|
||||
const push = mock<Push>();
|
||||
const publisher = mock<Publisher>();
|
||||
const workflowSharingService = mock<WorkflowSharingService>();
|
||||
const workflowPushNotifier = new WorkflowPushNotifier(push, workflowSharingService);
|
||||
const sharedUserIds = ['user-1', 'user-2'];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(sharedUserIds);
|
||||
activeWorkflowManager = new ActiveWorkflowManager(
|
||||
mockLogger(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
nodeTypes,
|
||||
mock(),
|
||||
workflowRepository,
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
instanceSettings,
|
||||
publisher,
|
||||
mock(),
|
||||
mock<TriggerExecutionContextFactory>(),
|
||||
mock(),
|
||||
mock(), // scheduleTriggerJobRegistrar
|
||||
mock(), // pollTriggerJobRegistrar
|
||||
workflowPushNotifier,
|
||||
policyEnforcementService,
|
||||
ownershipService,
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: 'handleDisplayWorkflowActivation',
|
||||
call: async () =>
|
||||
await activeWorkflowManager.handleDisplayWorkflowActivation({
|
||||
workflowId: 'wf-1',
|
||||
activeVersionId: 'v1',
|
||||
}),
|
||||
expectedMsg: {
|
||||
type: 'workflowActivated',
|
||||
data: { workflowId: 'wf-1', activeVersionId: 'v1' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'handleDisplayWorkflowDeactivation',
|
||||
call: async () =>
|
||||
await activeWorkflowManager.handleDisplayWorkflowDeactivation({ workflowId: 'wf-1' }),
|
||||
expectedMsg: { type: 'workflowDeactivated', data: { workflowId: 'wf-1' } },
|
||||
},
|
||||
{
|
||||
name: 'handleDisplayWorkflowActivationError',
|
||||
call: async () =>
|
||||
await activeWorkflowManager.handleDisplayWorkflowActivationError({
|
||||
workflowId: 'wf-1',
|
||||
errorMessage: 'boom',
|
||||
nodeId: 'node-1',
|
||||
}),
|
||||
expectedMsg: {
|
||||
type: 'workflowFailedToActivate',
|
||||
data: {
|
||||
workflowId: 'wf-1',
|
||||
errorMessage: 'boom',
|
||||
errorDescription: undefined,
|
||||
nodeId: 'node-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
])('$name sends its push message only to accessible users', async ({ call, expectedMsg }) => {
|
||||
await call();
|
||||
|
||||
expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith(
|
||||
'wf-1',
|
||||
);
|
||||
expect(push.sendToUsers).toHaveBeenCalledWith(expectedMsg, sharedUserIds);
|
||||
});
|
||||
|
||||
test('handleRemoveNonWebhookTriggers sends workflowDeactivated only to accessible users and relays it', async () => {
|
||||
vi.spyOn(activeWorkflowManager, 'removeActivationError').mockResolvedValue();
|
||||
vi.spyOn(activeWorkflowManager, 'removeNonWebhookTriggers').mockResolvedValue();
|
||||
|
||||
await activeWorkflowManager.handleRemoveNonWebhookTriggers({ workflowId: 'wf-1' });
|
||||
|
||||
expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith(
|
||||
'wf-1',
|
||||
);
|
||||
expect(push.sendToUsers).toHaveBeenCalledWith(
|
||||
{ type: 'workflowDeactivated', data: { workflowId: 'wf-1' } },
|
||||
sharedUserIds,
|
||||
);
|
||||
expect(publisher.publishCommand).toHaveBeenCalledWith({
|
||||
command: 'display-workflow-deactivation',
|
||||
payload: { workflowId: 'wf-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('activateWorkflow', () => {
|
||||
|
|
@ -715,11 +905,11 @@ describe('ActiveWorkflowManager', () => {
|
|||
instanceSettings,
|
||||
mock(), // publisher
|
||||
workflowsConfig,
|
||||
mock(), // push
|
||||
factory,
|
||||
mock(), // eventBus
|
||||
mock(), // scheduleTriggerJobRegistrar
|
||||
mock(), // pollTriggerJobRegistrar
|
||||
mock(), // workflowPushNotifier
|
||||
policyEnforcementService,
|
||||
ownershipService,
|
||||
);
|
||||
|
|
@ -1168,11 +1358,11 @@ describe('ActiveWorkflowManager', () => {
|
|||
instanceSettings,
|
||||
mock(),
|
||||
workflowsConfig,
|
||||
mock(),
|
||||
mock<TriggerExecutionContextFactory>(),
|
||||
mock(),
|
||||
scheduleTriggerJobRegistrar,
|
||||
pollTriggerJobRegistrar,
|
||||
mock(), // workflowPushNotifier
|
||||
policyEnforcementService,
|
||||
ownershipService,
|
||||
);
|
||||
|
|
@ -1356,11 +1546,11 @@ describe('ActiveWorkflowManager', () => {
|
|||
mock<InstanceSettings>({ isMultiMain: true }),
|
||||
publisher,
|
||||
workflowsConfig,
|
||||
mock(),
|
||||
mock<TriggerExecutionContextFactory>(),
|
||||
mock(),
|
||||
scheduleTriggerJobRegistrar,
|
||||
pollTriggerJobRegistrar,
|
||||
mock(), // workflowPushNotifier
|
||||
policyEnforcementService,
|
||||
ownershipService,
|
||||
);
|
||||
|
|
@ -1435,11 +1625,11 @@ describe('ActiveWorkflowManager', () => {
|
|||
instanceSettings,
|
||||
mock(),
|
||||
workflowsConfig,
|
||||
mock(),
|
||||
mock<TriggerExecutionContextFactory>(),
|
||||
mock(),
|
||||
scheduleTriggerJobRegistrar,
|
||||
mock(), // pollTriggerJobRegistrar
|
||||
mock(), // workflowPushNotifier
|
||||
policyEnforcementService,
|
||||
ownershipService,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import {
|
||||
STARTING_NODES,
|
||||
TRIGGER_COUNT_EXCLUDED_NODES,
|
||||
WORKFLOW_REACTIVATE_INITIAL_TIMEOUT,
|
||||
WORKFLOW_REACTIVATE_MAX_TIMEOUT,
|
||||
} from '@/constants';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { WorkflowsConfig } from '@n8n/config';
|
||||
import type { WorkflowEntity, IWorkflowDb } from '@n8n/db';
|
||||
import { WorkflowRepository } from '@n8n/db';
|
||||
import { OnLeaderStepdown, OnLeaderTakeover, OnPubSubEvent, OnShutdown } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
import chunk from 'lodash/chunk';
|
||||
import {
|
||||
ActiveWorkflowTriggers,
|
||||
|
|
@ -29,7 +24,6 @@ import type {
|
|||
INodeType,
|
||||
WorkflowId,
|
||||
} from 'n8n-workflow';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
import {
|
||||
Workflow,
|
||||
WorkflowActivationError,
|
||||
|
|
@ -41,25 +35,31 @@ import {
|
|||
import { strict } from 'node:assert';
|
||||
|
||||
import { ActivationErrorsService } from '@/activation-errors.service';
|
||||
import {
|
||||
STARTING_NODES,
|
||||
TRIGGER_COUNT_EXCLUDED_NODES,
|
||||
WORKFLOW_REACTIVATE_INITIAL_TIMEOUT,
|
||||
WORKFLOW_REACTIVATE_MAX_TIMEOUT,
|
||||
} from '@/constants';
|
||||
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { isPolicyRefusal } from '@/policy/policy-violation.error';
|
||||
import { Push } from '@/push';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
|
||||
import type { ScheduleTriggerCollectionSession } from '@/scheduling/schedule-trigger-node/schedule-trigger-job-registrar';
|
||||
import { PollTriggerJobRegistrar } from '@/scheduling/poll-trigger-node/poll-trigger-job-registrar';
|
||||
import type { ScheduleTriggerCollectionSession } from '@/scheduling/schedule-trigger-node/schedule-trigger-job-registrar';
|
||||
import { ScheduleTriggerJobRegistrar } from '@/scheduling/schedule-trigger-node/schedule-trigger-job-registrar';
|
||||
import { ActiveWorkflowsService } from '@/services/active-workflows.service';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
|
||||
import { WebhookService } from '@/webhooks/webhook.service';
|
||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
||||
import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory';
|
||||
import { getErrorDescription, getErrorNodeId } from '@/workflows/utils';
|
||||
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
|
||||
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
||||
import { formatWorkflow } from '@/workflows/workflow.formatter';
|
||||
|
||||
interface QueuedActivation {
|
||||
|
|
@ -87,11 +87,11 @@ export class ActiveWorkflowManager {
|
|||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly publisher: Publisher,
|
||||
private readonly workflowsConfig: WorkflowsConfig,
|
||||
private readonly push: Push,
|
||||
private readonly triggerExecutionContextFactory: TriggerExecutionContextFactory,
|
||||
private readonly eventBus: MessageEventBus,
|
||||
private readonly scheduleTriggerJobRegistrar: ScheduleTriggerJobRegistrar,
|
||||
private readonly pollTriggerJobRegistrar: PollTriggerJobRegistrar,
|
||||
private readonly workflowPushNotifier: WorkflowPushNotifier,
|
||||
private readonly policyEnforcementService: PolicyEnforcementService,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
) {
|
||||
|
|
@ -699,20 +699,26 @@ export class ActiveWorkflowManager {
|
|||
}
|
||||
|
||||
@OnPubSubEvent('display-workflow-activation', { instanceType: 'main' })
|
||||
handleDisplayWorkflowActivation({
|
||||
async handleDisplayWorkflowActivation({
|
||||
workflowId,
|
||||
activeVersionId,
|
||||
}: PubSubCommandMap['display-workflow-activation']) {
|
||||
this.push.broadcast({ type: 'workflowActivated', data: { workflowId, activeVersionId } });
|
||||
await this.workflowPushNotifier.notify(workflowId, {
|
||||
type: 'workflowActivated',
|
||||
data: { workflowId, activeVersionId },
|
||||
});
|
||||
}
|
||||
|
||||
@OnPubSubEvent('display-workflow-deactivation', { instanceType: 'main' })
|
||||
handleDisplayWorkflowDeactivation({ workflowId }: { workflowId: string }) {
|
||||
this.push.broadcast({ type: 'workflowDeactivated', data: { workflowId } });
|
||||
async handleDisplayWorkflowDeactivation({ workflowId }: { workflowId: string }) {
|
||||
await this.workflowPushNotifier.notify(workflowId, {
|
||||
type: 'workflowDeactivated',
|
||||
data: { workflowId },
|
||||
});
|
||||
}
|
||||
|
||||
@OnPubSubEvent('display-workflow-activation-error', { instanceType: 'main' })
|
||||
handleDisplayWorkflowActivationError({
|
||||
async handleDisplayWorkflowActivationError({
|
||||
workflowId,
|
||||
errorMessage,
|
||||
errorDescription,
|
||||
|
|
@ -723,7 +729,7 @@ export class ActiveWorkflowManager {
|
|||
errorDescription?: string;
|
||||
nodeId?: string;
|
||||
}) {
|
||||
this.push.broadcast({
|
||||
await this.workflowPushNotifier.notify(workflowId, {
|
||||
type: 'workflowFailedToActivate',
|
||||
data: { workflowId, errorMessage, errorDescription, nodeId },
|
||||
});
|
||||
|
|
@ -742,13 +748,6 @@ export class ActiveWorkflowManager {
|
|||
await this.add(workflowId, activationMode, undefined, {
|
||||
shouldPublish: false, // prevent leader from re-publishing message
|
||||
});
|
||||
|
||||
this.push.broadcast({ type: 'workflowActivated', data: { workflowId, activeVersionId } });
|
||||
|
||||
await this.publisher.publishCommand({
|
||||
command: 'display-workflow-activation',
|
||||
payload: { workflowId, activeVersionId },
|
||||
}); // instruct followers to show activation in UI
|
||||
} catch (e) {
|
||||
const error = ensureError(e);
|
||||
const { message } = error;
|
||||
|
|
@ -811,7 +810,7 @@ export class ActiveWorkflowManager {
|
|||
}
|
||||
}
|
||||
|
||||
this.push.broadcast({
|
||||
await this.workflowPushNotifier.notify(workflowId, {
|
||||
type: 'workflowFailedToActivate',
|
||||
data: { workflowId, errorMessage: message, nodeId, errorDescription },
|
||||
});
|
||||
|
|
@ -820,7 +819,24 @@ export class ActiveWorkflowManager {
|
|||
command: 'display-workflow-activation-error',
|
||||
payload: { workflowId, errorMessage: message, nodeId, errorDescription },
|
||||
}); // instruct followers to show activation error in UI
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Activation already succeeded above; notifying about it happens
|
||||
// outside the try/catch so it can't be mistaken for an activation failure.
|
||||
await this.workflowPushNotifier.notify(workflowId, {
|
||||
type: 'workflowActivated',
|
||||
data: { workflowId, activeVersionId },
|
||||
});
|
||||
|
||||
// Not awaited: a relay failure must not fail this pubsub handler.
|
||||
void this.publisher
|
||||
.publishCommand({
|
||||
command: 'display-workflow-activation',
|
||||
payload: { workflowId, activeVersionId },
|
||||
}) // instruct followers to show activation in UI
|
||||
.catch((error) => this.errorReporter.error(ensureError(error), { shouldBeLogged: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1010,7 +1026,10 @@ export class ActiveWorkflowManager {
|
|||
await this.removeActivationError(workflowId);
|
||||
await this.removeNonWebhookTriggers(workflowId);
|
||||
|
||||
this.push.broadcast({ type: 'workflowDeactivated', data: { workflowId } });
|
||||
await this.workflowPushNotifier.notify(workflowId, {
|
||||
type: 'workflowDeactivated',
|
||||
data: { workflowId },
|
||||
});
|
||||
|
||||
// instruct followers to show workflow deactivation in UI
|
||||
await this.publisher.publishCommand({
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ describe('ChatExecutionManager', () => {
|
|||
const execution = { id: '1', workflowData: {}, data: {} } as IExecutionResponse;
|
||||
const message = { sessionId: '123', action: 'sendMessage', chatInput: 'input' } as ChatMessage;
|
||||
|
||||
vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(true);
|
||||
vi.spyOn(chatExecutionManager as any, 'getRunData').mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(chatExecutionManager.runWorkflow(execution, message)).rejects.toThrow(
|
||||
|
|
@ -61,6 +62,7 @@ describe('ChatExecutionManager', () => {
|
|||
} as ChatMessage;
|
||||
const runData = { executionMode: 'manual', executionData: {}, workflowData: {} } as any;
|
||||
|
||||
vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(true);
|
||||
vi.spyOn(chatExecutionManager as any, 'getRunData').mockResolvedValue(runData);
|
||||
|
||||
await chatExecutionManager.runWorkflow(execution, message);
|
||||
|
|
@ -70,9 +72,34 @@ describe('ChatExecutionManager', () => {
|
|||
expectedStatus: 'waiting',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to run when the suspended node is not chat-driven', async () => {
|
||||
const execution = { id: '1', workflowData: {}, data: {} } as IExecutionResponse;
|
||||
const message = {
|
||||
sessionId: '123',
|
||||
action: 'sendMessage',
|
||||
chatInput: 'input',
|
||||
} as ChatMessage;
|
||||
|
||||
vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(false);
|
||||
const getRunDataSpy = vi.spyOn(chatExecutionManager as any, 'getRunData');
|
||||
|
||||
await expect(chatExecutionManager.runWorkflow(execution, message)).rejects.toThrow(
|
||||
'Refusing to resume a non-chat node over chat',
|
||||
);
|
||||
expect(getRunDataSpy).not.toHaveBeenCalled();
|
||||
expect(workflowRunner.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelExecution', () => {
|
||||
beforeEach(() => {
|
||||
// The status-transition tests below exercise the cancel logic itself; the
|
||||
// chat-ownership gate is covered by its own tests. Treat the execution as
|
||||
// chat-driven so these focus on status handling.
|
||||
vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should update execution status to canceled if it is running', async () => {
|
||||
const executionId = '1';
|
||||
const execution = { id: executionId, status: 'running' } as any;
|
||||
|
|
@ -125,6 +152,44 @@ describe('ChatExecutionManager', () => {
|
|||
|
||||
expect(executionRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not cancel a waiting execution the chat socket does not drive', async () => {
|
||||
// A socket attached to a non-chat execution (e.g. a Send-and-Wait gate via
|
||||
// a leaked token) must not cancel it by going idle.
|
||||
vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(false);
|
||||
const executionId = '9';
|
||||
const execution = { id: executionId, status: 'waiting' } as any;
|
||||
|
||||
executionPersistence.findSingleExecution.mockResolvedValue(execution);
|
||||
|
||||
await chatExecutionManager.cancelExecution(executionId);
|
||||
|
||||
expect(executionRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not throw or cancel when the parked node type is unresolvable', async () => {
|
||||
// Use the real isChatDrivenExecution (the describe's beforeEach stubs it) so
|
||||
// the resolution failure is exercised: it must be swallowed, so cancelExecution
|
||||
// returns and the heartbeat loop can still clean up the session.
|
||||
vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockRestore();
|
||||
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockImplementation(() => {
|
||||
throw new Error('Unrecognized node type: some.uninstalled.node');
|
||||
});
|
||||
const execution = {
|
||||
id: '9',
|
||||
status: 'waiting',
|
||||
workflowData: { id: 'wf', nodes: [] },
|
||||
data: {
|
||||
executionData: {
|
||||
nodeExecutionStack: [{ node: { name: 'X', type: 'x' }, data: { main: [[]] } }],
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
executionPersistence.findSingleExecution.mockResolvedValue(execution);
|
||||
|
||||
await expect(chatExecutionManager.cancelExecution('9')).resolves.toBeUndefined();
|
||||
expect(executionRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findExecution', () => {
|
||||
|
|
@ -424,7 +489,9 @@ describe('ChatExecutionManager', () => {
|
|||
workflowData: { id: 'workflowId' },
|
||||
data: {
|
||||
resultData: { lastNodeExecuted: 'nodeId' },
|
||||
executionData: { nodeExecutionStack: [{ data: { main: [[]] } }] },
|
||||
executionData: {
|
||||
nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[]] } }],
|
||||
},
|
||||
},
|
||||
mode: 'manual',
|
||||
} as any;
|
||||
|
|
@ -474,7 +541,9 @@ describe('ChatExecutionManager', () => {
|
|||
workflowData: { id: 'workflowId' },
|
||||
data: {
|
||||
resultData: { lastNodeExecuted: 'nodeId' },
|
||||
executionData: { nodeExecutionStack: [{ data: { main: [[{}]] } }] },
|
||||
executionData: {
|
||||
nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[{}]] } }],
|
||||
},
|
||||
},
|
||||
mode: 'manual',
|
||||
} as any;
|
||||
|
|
@ -510,7 +579,9 @@ describe('ChatExecutionManager', () => {
|
|||
workflowData: { id: 'workflowId' },
|
||||
data: {
|
||||
resultData: { lastNodeExecuted: 'nodeId' },
|
||||
executionData: { nodeExecutionStack: [{ data: { main: [[{}]] } }] },
|
||||
executionData: {
|
||||
nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[{}]] } }],
|
||||
},
|
||||
},
|
||||
mode: 'manual',
|
||||
} as any;
|
||||
|
|
@ -551,7 +622,9 @@ describe('ChatExecutionManager', () => {
|
|||
workflowData: { id: 'workflowId' },
|
||||
data: {
|
||||
resultData: { lastNodeExecuted: 'nodeId' },
|
||||
executionData: { nodeExecutionStack: [{ data: { main: [[{}]] } }] },
|
||||
executionData: {
|
||||
nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[{}]] } }],
|
||||
},
|
||||
},
|
||||
mode: 'manual',
|
||||
} as any;
|
||||
|
|
@ -703,4 +776,204 @@ describe('ChatExecutionManager', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('canResumeOverChat', () => {
|
||||
// A chat message may only resume a node whose resolved type implements the
|
||||
// onMessage hook. Everything else (Send-and-Wait gates, non-chat HITL tools,
|
||||
// plain Wait) must be refused regardless of the token presented.
|
||||
function makeExecution(
|
||||
nodeType: string,
|
||||
parameters: Record<string, unknown> = {},
|
||||
nodeName = 'parkedNode',
|
||||
): IExecutionResponse {
|
||||
const node = { name: nodeName, type: nodeType, typeVersion: 1, parameters };
|
||||
return {
|
||||
id: '1',
|
||||
workflowData: { id: 'wf', nodes: [node] },
|
||||
data: {
|
||||
resultData: { lastNodeExecuted: nodeName, runData: {} },
|
||||
executionData: { nodeExecutionStack: [{ node, data: { main: [[]] } }] },
|
||||
},
|
||||
} as unknown as IExecutionResponse;
|
||||
}
|
||||
|
||||
function mockWorkflow(execution: IExecutionResponse, hasOnMessage: boolean) {
|
||||
const node = execution.data.executionData!.nodeExecutionStack[0].node;
|
||||
const workflow = {
|
||||
id: 'wf',
|
||||
getNode: (name: string) => (name === node.name ? node : null),
|
||||
nodeTypes: {
|
||||
getByNameAndVersion: () => (hasOnMessage ? { onMessage: vi.fn() } : {}),
|
||||
},
|
||||
};
|
||||
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
|
||||
}
|
||||
|
||||
it('allows a chat node that implements onMessage', () => {
|
||||
const execution = makeExecution(CHAT_NODE_TYPE);
|
||||
mockWorkflow(execution, true);
|
||||
|
||||
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false (does not throw) when node type resolution fails', () => {
|
||||
const execution = makeExecution(CHAT_NODE_TYPE);
|
||||
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockImplementation(() => {
|
||||
throw new Error('Unrecognized node type: some.uninstalled.node');
|
||||
});
|
||||
|
||||
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows RespondToWebhook (implements onMessage)', () => {
|
||||
const execution = makeExecution(RESPOND_TO_WEBHOOK_NODE_TYPE);
|
||||
mockWorkflow(execution, true);
|
||||
|
||||
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a chat-based HITL tool (inherits onMessage)', () => {
|
||||
const execution = makeExecution('@n8n/n8n-nodes-langchain.chatHitlTool');
|
||||
mockWorkflow(execution, true);
|
||||
|
||||
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a chat node that opted out of user input', () => {
|
||||
const execution = makeExecution(CHAT_NODE_TYPE, { blockUserInput: true });
|
||||
mockWorkflow(execution, true);
|
||||
|
||||
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
|
||||
});
|
||||
|
||||
it('applies the blockUserInput carve-out to a chat-based HITL tool', () => {
|
||||
// chatHitlTool inherits onMessage and retains blockUserInput; the carve-out
|
||||
// must reach it even though its type is not literally CHAT_(TOOL_)NODE_TYPE.
|
||||
const execution = makeExecution('@n8n/n8n-nodes-langchain.chatHitlTool', {
|
||||
blockUserInput: true,
|
||||
});
|
||||
mockWorkflow(execution, true);
|
||||
|
||||
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a Send-and-Wait node (no onMessage)', () => {
|
||||
const execution = makeExecution('n8n-nodes-base.telegram', { operation: 'sendAndWait' });
|
||||
mockWorkflow(execution, false);
|
||||
|
||||
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a non-chat HITL tool (no onMessage)', () => {
|
||||
const execution = makeExecution('n8n-nodes-base.telegramHitlTool');
|
||||
mockWorkflow(execution, false);
|
||||
|
||||
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a plain Wait node (no onMessage)', () => {
|
||||
const execution = makeExecution('n8n-nodes-base.wait', { resume: 'timeInterval' });
|
||||
mockWorkflow(execution, false);
|
||||
|
||||
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
|
||||
});
|
||||
|
||||
it('keys off the parked stack node, not lastNodeExecuted', () => {
|
||||
// Confused-deputy guard: lastNodeExecuted names a chat decoy, but the node
|
||||
// the engine would resume (nodeExecutionStack[0]) is a Telegram gate. The
|
||||
// decision must follow the parked stack node and refuse.
|
||||
const decoy = { name: 'Decoy', type: CHAT_NODE_TYPE, typeVersion: 1, parameters: {} };
|
||||
const gate = {
|
||||
name: 'Manager approval',
|
||||
type: 'n8n-nodes-base.telegram',
|
||||
typeVersion: 1,
|
||||
parameters: { operation: 'sendAndWait' },
|
||||
};
|
||||
const execution = {
|
||||
id: '1',
|
||||
workflowData: { id: 'wf', nodes: [decoy, gate] },
|
||||
data: {
|
||||
resultData: { lastNodeExecuted: 'Decoy', runData: {} },
|
||||
executionData: { nodeExecutionStack: [{ node: gate, data: { main: [[]] } }] },
|
||||
},
|
||||
} as unknown as IExecutionResponse;
|
||||
const workflow = {
|
||||
getNode: (name: string) => (name === 'Decoy' ? decoy : name === gate.name ? gate : null),
|
||||
nodeTypes: {
|
||||
getByNameAndVersion: (type: string) =>
|
||||
type === CHAT_NODE_TYPE ? { onMessage: vi.fn() } : {},
|
||||
},
|
||||
};
|
||||
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
|
||||
|
||||
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses when there is no parked execution data', () => {
|
||||
const execution = {
|
||||
id: '1',
|
||||
workflowData: { id: 'wf', nodes: [] },
|
||||
data: { resultData: { lastNodeExecuted: 'x', runData: {} }, executionData: undefined },
|
||||
} as unknown as IExecutionResponse;
|
||||
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue({
|
||||
getNode: () => null,
|
||||
nodeTypes: { getByNameAndVersion: () => ({ onMessage: vi.fn() }) },
|
||||
});
|
||||
|
||||
expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveResumeNodeType', () => {
|
||||
it('reports the wrapped tool type for a tool-executor entry, not the executor', () => {
|
||||
const toolNode = {
|
||||
name: 'My Tool',
|
||||
type: 'n8n-nodes-base.telegramHitlTool',
|
||||
typeVersion: 1,
|
||||
parameters: {},
|
||||
};
|
||||
const execution = {
|
||||
id: '1',
|
||||
workflowData: { id: 'wf', nodes: [toolNode] },
|
||||
data: {
|
||||
resultData: { lastNodeExecuted: TOOL_EXECUTOR_NODE_NAME, runData: {} },
|
||||
executionData: {
|
||||
nodeExecutionStack: [
|
||||
{
|
||||
node: {
|
||||
name: TOOL_EXECUTOR_NODE_NAME,
|
||||
type: '@n8n/n8n-nodes-langchain.toolExecutor',
|
||||
parameters: { node: 'My Tool' },
|
||||
},
|
||||
data: { main: [[]] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as IExecutionResponse;
|
||||
const workflow = {
|
||||
getNode: (name: string) => (name === 'My Tool' ? toolNode : null),
|
||||
nodeTypes: { getByNameAndVersion: () => ({}) },
|
||||
};
|
||||
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
|
||||
|
||||
expect(chatExecutionManager.resolveResumeNodeType(execution)).toBe(
|
||||
'n8n-nodes-base.telegramHitlTool',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when the node type cannot be resolved', () => {
|
||||
const execution = {
|
||||
id: '1',
|
||||
data: {
|
||||
executionData: { nodeExecutionStack: [{ node: { name: 'x', type: 'x' }, data: {} }] },
|
||||
},
|
||||
} as unknown as IExecutionResponse;
|
||||
vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockImplementation(() => {
|
||||
throw new Error('Unrecognized node type');
|
||||
});
|
||||
|
||||
expect(chatExecutionManager.resolveResumeNodeType(execution)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
import type { ExecutionRepository, IExecutionResponse } from '@n8n/db';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import path from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { INodeType, IVersionedNodeType, LoadedClass, NodeLoadingDetails } from 'n8n-workflow';
|
||||
import { CHAT_NODE_TYPE, CHAT_TOOL_NODE_TYPE, RESPOND_TO_WEBHOOK_NODE_TYPE } from 'n8n-workflow';
|
||||
|
||||
import type { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
|
||||
import { NodeTypes } from '../../node-types';
|
||||
import { ChatExecutionManager } from '../chat-execution-manager';
|
||||
import type { ChatMessage } from '../chat-service.types';
|
||||
|
||||
/**
|
||||
* Guards the security-critical fact the `/chat` resume allowlist depends on:
|
||||
* *which real node types implement the `onMessage` hook*. The unit tests stub
|
||||
* `getByNameAndVersion`, so they can't catch a registry change (e.g. a non-chat
|
||||
* node gaining `onMessage`, or the chat tool suffix chain breaking). This test
|
||||
* resolves the actual node classes from dist through the real `NodeTypes`
|
||||
* registry — including the synthetic `*HitlTool` variants — and asserts both the
|
||||
* `onMessage` inventory and the resulting `canResumeOverChat` decision.
|
||||
*/
|
||||
|
||||
const TELEGRAM = 'n8n-nodes-base.telegram';
|
||||
const WAIT = 'n8n-nodes-base.wait';
|
||||
const TELEGRAM_HITL = 'n8n-nodes-base.telegramHitlTool';
|
||||
const CHAT_HITL = '@n8n/n8n-nodes-langchain.chatHitlTool';
|
||||
|
||||
/** Loads a real node class from a built package's dist using its known-nodes manifest. */
|
||||
function loadRealNode(fullType: string): LoadedClass<INodeType | IVersionedNodeType> {
|
||||
const [pkg, shortName] = fullType.startsWith('@n8n/')
|
||||
? ['@n8n/n8n-nodes-langchain', fullType.replace('@n8n/n8n-nodes-langchain.', '')]
|
||||
: ['n8n-nodes-base', fullType.replace('n8n-nodes-base.', '')];
|
||||
|
||||
const pkgRoot = path.dirname(require.resolve(`${pkg}/package.json`));
|
||||
const known = JSON.parse(
|
||||
readFileSync(path.join(pkgRoot, 'dist/known/nodes.json'), 'utf-8'),
|
||||
) as Record<string, NodeLoadingDetails>;
|
||||
|
||||
const info = known[shortName];
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mod = require(path.join(pkgRoot, info.sourcePath));
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const type = new mod[info.className]();
|
||||
return { type, sourcePath: '' };
|
||||
}
|
||||
|
||||
/** Real NodeTypes backed by a thin loader over dist-loaded base nodes, so
|
||||
* `getByNameAndVersion` runs its actual tool-suffix resolution + synthesis. */
|
||||
function makeRealNodeTypes(): NodeTypes {
|
||||
const baseTypes: Record<string, LoadedClass<INodeType | IVersionedNodeType>> = {
|
||||
[CHAT_NODE_TYPE]: loadRealNode(CHAT_NODE_TYPE),
|
||||
[RESPOND_TO_WEBHOOK_NODE_TYPE]: loadRealNode(RESPOND_TO_WEBHOOK_NODE_TYPE),
|
||||
[TELEGRAM]: loadRealNode(TELEGRAM),
|
||||
[WAIT]: loadRealNode(WAIT),
|
||||
};
|
||||
|
||||
const loader = {
|
||||
recognizesNode: (name: string) => name in baseTypes,
|
||||
getNode: (name: string) => baseTypes[name],
|
||||
loadedNodes: {} as Record<string, LoadedClass<INodeType>>,
|
||||
};
|
||||
|
||||
return new NodeTypes(mock(), loader as never);
|
||||
}
|
||||
|
||||
function makeExecution(nodeType: string, parameters: Record<string, unknown> = {}) {
|
||||
const node = { name: 'parked', type: nodeType, typeVersion: 1, parameters };
|
||||
return {
|
||||
id: '1',
|
||||
workflowData: { id: 'wf', nodes: [node], connections: {} },
|
||||
data: {
|
||||
resultData: { lastNodeExecuted: 'parked', runData: {} },
|
||||
executionData: { nodeExecutionStack: [{ node, data: { main: [[]] } }] },
|
||||
},
|
||||
} as unknown as IExecutionResponse;
|
||||
}
|
||||
|
||||
describe('chat resume — real node-type onMessage mapping', () => {
|
||||
let nodeTypes: NodeTypes;
|
||||
let manager: ChatExecutionManager;
|
||||
let executionRepository: ReturnType<typeof mock<ExecutionRepository>>;
|
||||
let executionPersistence: ReturnType<typeof mock<ExecutionPersistence>>;
|
||||
|
||||
beforeAll(() => {
|
||||
nodeTypes = makeRealNodeTypes();
|
||||
executionRepository = mock<ExecutionRepository>();
|
||||
executionPersistence = mock<ExecutionPersistence>();
|
||||
manager = new ChatExecutionManager(
|
||||
executionRepository,
|
||||
executionPersistence,
|
||||
mock(),
|
||||
mock(),
|
||||
nodeTypes,
|
||||
);
|
||||
});
|
||||
|
||||
describe('onMessage inventory (resolved through the real registry)', () => {
|
||||
it.each([
|
||||
[CHAT_NODE_TYPE, true],
|
||||
[RESPOND_TO_WEBHOOK_NODE_TYPE, true],
|
||||
[CHAT_HITL, true], // synthetic tool inherits onMessage from the base Chat node
|
||||
[TELEGRAM, false],
|
||||
[WAIT, false],
|
||||
[TELEGRAM_HITL, false], // synthetic tool of a base node that has no onMessage
|
||||
])('%s implements onMessage: %s', (type, expected) => {
|
||||
const resolved = nodeTypes.getByNameAndVersion(type);
|
||||
expect(typeof resolved.onMessage === 'function').toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canResumeOverChat against real node types', () => {
|
||||
it('allows the Chat node', () => {
|
||||
expect(manager.canResumeOverChat(makeExecution(CHAT_NODE_TYPE))).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a chat-based HITL tool', () => {
|
||||
expect(manager.canResumeOverChat(makeExecution(CHAT_HITL))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a Telegram Send-and-Wait gate', () => {
|
||||
expect(manager.canResumeOverChat(makeExecution(TELEGRAM, { operation: 'sendAndWait' }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a non-chat HITL tool (telegramHitlTool)', () => {
|
||||
expect(manager.canResumeOverChat(makeExecution(TELEGRAM_HITL))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a plain Wait node', () => {
|
||||
expect(manager.canResumeOverChat(makeExecution(WAIT, { resume: 'timeInterval' }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a chat node that opted out of user input', () => {
|
||||
// blockUserInput only applies (and survives Workflow parameter resolution)
|
||||
// when responseType is 'approval' — mirror a real approval chat node.
|
||||
expect(
|
||||
manager.canResumeOverChat(
|
||||
makeExecution(CHAT_NODE_TYPE, { responseType: 'approval', blockUserInput: true }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a chat-based HITL tool that opted out of user input', () => {
|
||||
expect(
|
||||
manager.canResumeOverChat(
|
||||
makeExecution(CHAT_HITL, { responseType: 'approval', blockUserInput: true }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelExecution heartbeat gate against real node types', () => {
|
||||
function waitingExecution(nodeType: string, parameters: Record<string, unknown> = {}) {
|
||||
const execution = makeExecution(nodeType, parameters);
|
||||
(execution as unknown as { status: string }).status = 'waiting';
|
||||
return execution;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
executionRepository.update.mockClear();
|
||||
});
|
||||
|
||||
it('cancels an abandoned chat execution', async () => {
|
||||
executionPersistence.findSingleExecution.mockResolvedValue(waitingExecution(CHAT_NODE_TYPE));
|
||||
|
||||
await manager.cancelExecution('1');
|
||||
|
||||
expect(executionRepository.update).toHaveBeenCalledWith({ id: '1' }, { status: 'canceled' });
|
||||
});
|
||||
|
||||
it('does not cancel a Telegram Send-and-Wait execution the socket does not drive', async () => {
|
||||
executionPersistence.findSingleExecution.mockResolvedValue(
|
||||
waitingExecution(TELEGRAM, { operation: 'sendAndWait' }),
|
||||
);
|
||||
|
||||
await manager.cancelExecution('1');
|
||||
|
||||
expect(executionRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not cancel a plain Wait execution the socket does not drive', async () => {
|
||||
executionPersistence.findSingleExecution.mockResolvedValue(
|
||||
waitingExecution(WAIT, { resume: 'timeInterval' }),
|
||||
);
|
||||
|
||||
await manager.cancelExecution('1');
|
||||
|
||||
expect(executionRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runWorkflow sink guard against real node types', () => {
|
||||
const message: ChatMessage = { sessionId: 's', action: 'sendMessage', chatInput: 'x' };
|
||||
|
||||
it('refuses to resume a Telegram Send-and-Wait gate', async () => {
|
||||
await expect(
|
||||
manager.runWorkflow(makeExecution(TELEGRAM, { operation: 'sendAndWait' }), message),
|
||||
).rejects.toThrow('Refusing to resume a non-chat node over chat');
|
||||
});
|
||||
|
||||
it('refuses to resume a plain Wait node', async () => {
|
||||
await expect(
|
||||
manager.runWorkflow(makeExecution(WAIT, { resume: 'timeInterval' }), message),
|
||||
).rejects.toThrow('Refusing to resume a non-chat node over chat');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps CHAT_TOOL_NODE_TYPE resolvable to an onMessage-bearing type', () => {
|
||||
// chatTool resolves to the base Chat node, so it must also carry onMessage.
|
||||
expect(typeof nodeTypes.getByNameAndVersion(CHAT_TOOL_NODE_TYPE).onMessage).toBe('function');
|
||||
});
|
||||
});
|
||||
|
|
@ -25,6 +25,9 @@ describe('ChatService', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
mockExecutionManager = mock<ChatExecutionManager>();
|
||||
// Default: the parked node is chat-resumable. Node-type gating is covered in
|
||||
// chat-execution-manager.test.ts; individual tests override to false.
|
||||
mockExecutionManager.canResumeOverChat.mockReturnValue(true);
|
||||
mockLogger = mock<Logger>();
|
||||
mockErrorReporter = mock<ErrorReporter>();
|
||||
chatService = new ChatService(mockExecutionManager, mockLogger, mockErrorReporter);
|
||||
|
|
@ -375,7 +378,7 @@ describe('ChatService', () => {
|
|||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not resume execution if blockUserInput is true', async () => {
|
||||
it('should not resume execution when the parked node is not chat-resumable', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
const session = {
|
||||
executionId: '123',
|
||||
|
|
@ -391,12 +394,17 @@ describe('ChatService', () => {
|
|||
nodes: [{ name: 'node1', type: CHAT_NODE_TYPE, parameters: { blockUserInput: true } }],
|
||||
},
|
||||
} as any);
|
||||
// The guard refuses this node type, so the message must not resume it and
|
||||
// the node must stay marked as waiting.
|
||||
mockExecutionManager.canResumeOverChat.mockReturnValue(false);
|
||||
|
||||
const incomingMessageHandler = (chatService as any).incomingMessageHandler(sessionKey);
|
||||
await incomingMessageHandler(data);
|
||||
|
||||
expect(session.nodeWaitingForChatResponse).toBe('node1');
|
||||
expect(mockExecutionManager.runWorkflow).not.toHaveBeenCalled();
|
||||
// The refusal is logged rather than silent.
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import {
|
||||
getMessage,
|
||||
getLastNodeExecuted,
|
||||
findResumeNode,
|
||||
redirectIfToolExecutor,
|
||||
getLastNodeMessage,
|
||||
shouldResumeImmediately,
|
||||
|
|
@ -511,23 +512,23 @@ describe('redirectIfToolExecutor', () => {
|
|||
disabled: false,
|
||||
};
|
||||
|
||||
function makeExecution(lastNodeExecuted: string, startData?: object): IExecutionResponse {
|
||||
function makeExecution(startData?: object): IExecutionResponse {
|
||||
return {
|
||||
id: 'exec-1',
|
||||
data: {
|
||||
resultData: { lastNodeExecuted, runData: {} },
|
||||
resultData: { lastNodeExecuted: TOOL_EXECUTOR_NODE_NAME, runData: {} },
|
||||
startData: startData ?? { runNodeFilter: ['SomeNode'], destinationNode: 'SomeNode' },
|
||||
},
|
||||
workflowData: { nodes: [] },
|
||||
} as unknown as IExecutionResponse;
|
||||
}
|
||||
|
||||
function makeExecutionData(toolNodeName: string): IExecuteData {
|
||||
function executorData(toolNodeName?: string): IExecuteData {
|
||||
return {
|
||||
node: {
|
||||
name: TOOL_EXECUTOR_NODE_NAME,
|
||||
type: '@n8n/n8n-nodes-langchain.toolExecutor',
|
||||
parameters: { node: toolNodeName },
|
||||
parameters: toolNodeName ? { node: toolNodeName } : {},
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
id: '',
|
||||
|
|
@ -538,9 +539,17 @@ describe('redirectIfToolExecutor', () => {
|
|||
} as unknown as IExecuteData;
|
||||
}
|
||||
|
||||
it('returns null when lastNodeExecuted is not TOOL_EXECUTOR_NODE_NAME', () => {
|
||||
const execution = makeExecution('SomeOtherNode');
|
||||
const executionData = makeExecutionData('My Tool');
|
||||
function plainData(name: string): IExecuteData {
|
||||
return {
|
||||
node: { name, type: 'n8n-nodes-base.noOp', parameters: {}, typeVersion: 1 },
|
||||
data: { main: [[]] },
|
||||
source: null,
|
||||
} as unknown as IExecuteData;
|
||||
}
|
||||
|
||||
it('returns null when the parked node is not the executor', () => {
|
||||
const execution = makeExecution();
|
||||
const executionData = plainData('SomeOtherNode');
|
||||
const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow;
|
||||
|
||||
const result = redirectIfToolExecutor(execution, executionData, workflow);
|
||||
|
|
@ -550,32 +559,24 @@ describe('redirectIfToolExecutor', () => {
|
|||
});
|
||||
|
||||
it('returns null when the referenced tool node is not found in the workflow', () => {
|
||||
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME);
|
||||
const executionData = makeExecutionData('My Tool');
|
||||
const execution = makeExecution();
|
||||
const executionData = executorData('My Tool');
|
||||
const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow;
|
||||
|
||||
const result = redirectIfToolExecutor(execution, executionData, workflow);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(redirectIfToolExecutor(execution, executionData, workflow)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when executionData.node.parameters.node is missing', () => {
|
||||
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME);
|
||||
const executionData = {
|
||||
node: { name: TOOL_EXECUTOR_NODE_NAME, parameters: {} },
|
||||
data: { main: [[]] },
|
||||
source: null,
|
||||
} as unknown as IExecuteData;
|
||||
const execution = makeExecution();
|
||||
const executionData = executorData();
|
||||
const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow;
|
||||
|
||||
const result = redirectIfToolExecutor(execution, executionData, workflow);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(redirectIfToolExecutor(execution, executionData, workflow)).toBeNull();
|
||||
});
|
||||
|
||||
it('redirects executionData.node to the actual tool node', () => {
|
||||
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME);
|
||||
const executionData = makeExecutionData('My Tool');
|
||||
const execution = makeExecution();
|
||||
const executionData = executorData('My Tool');
|
||||
const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow;
|
||||
|
||||
redirectIfToolExecutor(execution, executionData, workflow);
|
||||
|
|
@ -584,8 +585,8 @@ describe('redirectIfToolExecutor', () => {
|
|||
});
|
||||
|
||||
it('updates lastNodeExecuted to the tool node name', () => {
|
||||
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME);
|
||||
const executionData = makeExecutionData('My Tool');
|
||||
const execution = makeExecution();
|
||||
const executionData = executorData('My Tool');
|
||||
const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow;
|
||||
|
||||
redirectIfToolExecutor(execution, executionData, workflow);
|
||||
|
|
@ -594,8 +595,8 @@ describe('redirectIfToolExecutor', () => {
|
|||
});
|
||||
|
||||
it('resets runIndex to 0', () => {
|
||||
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME);
|
||||
const executionData = makeExecutionData('My Tool');
|
||||
const execution = makeExecution();
|
||||
const executionData = executorData('My Tool');
|
||||
(executionData as any).runIndex = 5;
|
||||
const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow;
|
||||
|
||||
|
|
@ -605,11 +606,11 @@ describe('redirectIfToolExecutor', () => {
|
|||
});
|
||||
|
||||
it('clears runNodeFilter and destinationNode from startData', () => {
|
||||
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME, {
|
||||
const execution = makeExecution({
|
||||
runNodeFilter: ['SomeNode'],
|
||||
destinationNode: 'SomeNode',
|
||||
});
|
||||
const executionData = makeExecutionData('My Tool');
|
||||
const executionData = executorData('My Tool');
|
||||
const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow;
|
||||
|
||||
redirectIfToolExecutor(execution, executionData, workflow);
|
||||
|
|
@ -619,13 +620,114 @@ describe('redirectIfToolExecutor', () => {
|
|||
});
|
||||
|
||||
it('returns the tool node', () => {
|
||||
const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME);
|
||||
const executionData = makeExecutionData('My Tool');
|
||||
const execution = makeExecution();
|
||||
const executionData = executorData('My Tool');
|
||||
const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow;
|
||||
|
||||
const result = redirectIfToolExecutor(execution, executionData, workflow);
|
||||
expect(redirectIfToolExecutor(execution, executionData, workflow)).toBe(toolNode);
|
||||
});
|
||||
|
||||
expect(result).toBe(toolNode);
|
||||
it('leaves the execution unmodified when the parked node is not the executor', () => {
|
||||
const execution = makeExecution();
|
||||
execution.data.resultData.lastNodeExecuted = 'SomeOtherNode';
|
||||
const executionData = plainData('SomeOtherNode');
|
||||
const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow;
|
||||
|
||||
redirectIfToolExecutor(execution, executionData, workflow);
|
||||
|
||||
// The mutating redirect must not fire for a non-executor parked node.
|
||||
expect(execution.data.resultData.lastNodeExecuted).toBe('SomeOtherNode');
|
||||
expect(executionData.node.name).toBe('SomeOtherNode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findResumeNode', () => {
|
||||
const toolNode: INode = {
|
||||
name: 'My Tool',
|
||||
type: 'n8n-nodes-base.myTool',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
id: 'tool-id',
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
function executorData(toolNodeName?: string): IExecuteData {
|
||||
return {
|
||||
node: {
|
||||
name: TOOL_EXECUTOR_NODE_NAME,
|
||||
type: '@n8n/n8n-nodes-langchain.toolExecutor',
|
||||
parameters: toolNodeName ? { node: toolNodeName } : {},
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
id: '',
|
||||
disabled: false,
|
||||
},
|
||||
data: { main: [[]] },
|
||||
source: null,
|
||||
} as unknown as IExecuteData;
|
||||
}
|
||||
|
||||
function parkedData(node: Partial<INode>): IExecuteData {
|
||||
return { node, data: { main: [[]] }, source: null } as unknown as IExecuteData;
|
||||
}
|
||||
|
||||
it('resolves the parked stack node from the workflow', () => {
|
||||
const parkedNode = { ...toolNode, name: 'Gate' };
|
||||
const executionData = parkedData({ name: 'Gate' });
|
||||
const workflow = { getNode: vi.fn().mockReturnValue(parkedNode) } as unknown as Workflow;
|
||||
|
||||
const result = findResumeNode(executionData, workflow);
|
||||
|
||||
expect(result).toBe(parkedNode);
|
||||
expect(workflow.getNode).toHaveBeenCalledWith('Gate');
|
||||
});
|
||||
|
||||
it('resolves the wrapped tool node when the parked node is the virtual executor', () => {
|
||||
const executionData = executorData('My Tool');
|
||||
const getNode = vi
|
||||
.fn()
|
||||
.mockImplementation((name: string) => (name === 'My Tool' ? toolNode : null));
|
||||
const workflow = { getNode } as unknown as Workflow;
|
||||
|
||||
expect(findResumeNode(executionData, workflow)).toBe(toolNode);
|
||||
});
|
||||
|
||||
it('does not mutate the execution data while resolving the executor target', () => {
|
||||
const executionData = executorData('My Tool');
|
||||
const workflow = {
|
||||
getNode: vi.fn().mockImplementation((n: string) => (n === 'My Tool' ? toolNode : null)),
|
||||
} as unknown as Workflow;
|
||||
|
||||
findResumeNode(executionData, workflow);
|
||||
|
||||
expect(executionData.node.name).toBe(TOOL_EXECUTOR_NODE_NAME);
|
||||
});
|
||||
|
||||
it('returns null when the executor references an unknown tool node', () => {
|
||||
const executionData = executorData('Missing');
|
||||
const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow;
|
||||
|
||||
expect(findResumeNode(executionData, workflow)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the parked node is not in the workflow', () => {
|
||||
const executionData = parkedData({ name: 'Ghost' });
|
||||
const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow;
|
||||
|
||||
expect(findResumeNode(executionData, workflow)).toBeNull();
|
||||
});
|
||||
|
||||
it('follows the parked stack node, ignoring a divergent lastNodeExecuted', () => {
|
||||
// The engine resumes nodeExecutionStack[0]; if resultData.lastNodeExecuted
|
||||
// named a different node, findResumeNode must still follow the stack entry.
|
||||
const gate = { name: 'Gate', type: 'n8n-nodes-base.telegram', typeVersion: 1, parameters: {} };
|
||||
const executionData = parkedData(gate);
|
||||
const getNode = vi.fn().mockImplementation((name: string) => (name === 'Gate' ? gate : null));
|
||||
const workflow = { getNode } as unknown as Workflow;
|
||||
|
||||
expect(findResumeNode(executionData, workflow)).toBe(gate);
|
||||
expect(workflow.getNode).toHaveBeenCalledWith('Gate');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Workflow,
|
||||
BINARY_ENCODING,
|
||||
UnexpectedError,
|
||||
CHAT_NODE_TYPE,
|
||||
CHAT_TOOL_NODE_TYPE,
|
||||
NodeConnectionTypes,
|
||||
isHitlToolType,
|
||||
|
|
@ -22,9 +23,10 @@ import * as WorkflowExecuteAdditionalData from '../workflow-execute-additional-d
|
|||
import { preserveInputOverride } from '../workflow-helpers';
|
||||
import { WorkflowRunner } from '../workflow-runner';
|
||||
import type { ChatMessage } from './chat-service.types';
|
||||
import { redirectIfToolExecutor } from './utils';
|
||||
import { findResumeNode, redirectIfToolExecutor } from './utils';
|
||||
import { NodeTypes } from '../node-types';
|
||||
import { OwnershipService } from '../services/ownership.service';
|
||||
import { stripToolSuffix } from '../utils';
|
||||
|
||||
@Service()
|
||||
export class ChatExecutionManager {
|
||||
|
|
@ -37,6 +39,20 @@ export class ChatExecutionManager {
|
|||
) {}
|
||||
|
||||
async runWorkflow(execution: IExecutionResponse, message: ChatMessage) {
|
||||
// Sink guard shared by every chat resume caller (chat websocket, Chat Hub,
|
||||
// the auto-resume watcher): only a node a chat mechanism drives — one that
|
||||
// implements onMessage — may be resumed here. This makes the allowlist
|
||||
// un-skippable so no caller can resume, e.g., a Send-and-Wait gate. Callers
|
||||
// are expected to refuse earlier with a user-facing message; reaching this
|
||||
// throw means a caller skipped its check. blockUserInput is a per-message
|
||||
// policy enforced by the resume-path callers, not here, so auto-resume of
|
||||
// chat nodes keeps working.
|
||||
if (!this.isChatDrivenExecution(execution)) {
|
||||
throw new UnexpectedError('Refusing to resume a non-chat node over chat', {
|
||||
extra: { executionId: execution.id },
|
||||
});
|
||||
}
|
||||
|
||||
await this.workflowRunner.run(await this.getRunData(execution, message), true, true, {
|
||||
executionId: execution.id,
|
||||
expectedStatus: 'waiting',
|
||||
|
|
@ -51,6 +67,8 @@ export class ChatExecutionManager {
|
|||
|
||||
if (!execution) return;
|
||||
|
||||
if (!this.isChatDrivenExecution(execution)) return;
|
||||
|
||||
if (['running', 'waiting', 'unknown'].includes(execution.status)) {
|
||||
await this.executionRepository.update({ id: executionId }, { status: 'canceled' });
|
||||
}
|
||||
|
|
@ -91,26 +109,101 @@ export class ChatExecutionManager {
|
|||
return binary;
|
||||
}
|
||||
|
||||
private async runNode(execution: IExecutionResponse, message: ChatMessage) {
|
||||
const workflow = this.getWorkflow(execution);
|
||||
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string;
|
||||
let node = workflow.getNode(lastNodeExecuted);
|
||||
const additionalData = await WorkflowExecuteAdditionalData.getBase({ workflowId: workflow.id });
|
||||
/**
|
||||
* Resolves the node a chat message would resume and its (version-resolved)
|
||||
* node type, without mutating the execution. Shared by the resume guard and
|
||||
* `runNode` so the authorization decision can never drift from what runs.
|
||||
*/
|
||||
private resolveResumeTarget(execution: IExecutionResponse, workflow: Workflow) {
|
||||
const executionData = execution.data.executionData?.nodeExecutionStack[0];
|
||||
|
||||
if (!executionData) return null;
|
||||
|
||||
// PartialExecutionToolExecutor is a virtual node not present in the workflow —
|
||||
// redirect to the real tool and wraps so onMessage() if present runs on the right node.
|
||||
if (!node) {
|
||||
node = redirectIfToolExecutor(execution, executionData, workflow);
|
||||
}
|
||||
|
||||
const node = findResumeNode(executionData, workflow);
|
||||
if (!node) return null;
|
||||
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
return { node, nodeType, executionData };
|
||||
}
|
||||
|
||||
/**
|
||||
* `resolveResumeTarget` wrapped to fail closed. Building the workflow / resolving
|
||||
* the parked node's type throws if a node type is unavailable (e.g. an
|
||||
* uninstalled community node). The guards below must never throw — a resume
|
||||
* decision that blows up would, on the heartbeat path, abort the whole
|
||||
* `checkHeartbeats` loop and leave the session uncleaned — so an unresolvable
|
||||
* node is simply treated as not chat-resumable.
|
||||
*/
|
||||
private tryResolveResumeTarget(execution: IExecutionResponse) {
|
||||
try {
|
||||
return this.resolveResumeTarget(execution, this.getWorkflow(execution));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A chat message may only resume a node that implements the `onMessage` hook:
|
||||
* the Chat node and the tools that inherit it (chatTool, chatHitlTool), plus
|
||||
* RespondToWebhook. Send-and-Wait gates, non-chat HITL tools and Wait nodes
|
||||
* have no `onMessage` and must not be resumable over the socket, whatever
|
||||
* token is presented.
|
||||
*/
|
||||
canResumeOverChat(execution: IExecutionResponse): boolean {
|
||||
const target = this.tryResolveResumeTarget(execution);
|
||||
if (!target) return false;
|
||||
|
||||
const { node, nodeType } = target;
|
||||
if (typeof nodeType.onMessage !== 'function') return false;
|
||||
|
||||
// A chat node is chat-resumable by type but still refuses input when the
|
||||
// builder opted out via blockUserInput. This covers the Chat node and its
|
||||
// tool variants (chatTool, chatHitlTool) — all resolve to the base chat type
|
||||
// and carry the same parameter.
|
||||
const isChatBasedNode = stripToolSuffix(node.type) === CHAT_NODE_TYPE;
|
||||
if (isChatBasedNode && node.parameters?.blockUserInput === true) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved type of the node a chat message would resume, for diagnostics.
|
||||
* Uses the same resolution as canResumeOverChat — so a PartialExecutionToolExecutor
|
||||
* entry reports the wrapped tool node, not the virtual executor — and fails closed
|
||||
* (undefined) when the node type can't be resolved.
|
||||
*/
|
||||
resolveResumeNodeType(execution: IExecutionResponse): string | undefined {
|
||||
return this.tryResolveResumeTarget(execution)?.node.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the parked node is one a chat socket legitimately drives.
|
||||
* Unlike canResumeOverChat this ignores blockUserInput: a chat node that
|
||||
* opted out of input is still a chat execution owned by its socket, so an
|
||||
* abandoned one should still be cleaned up. Used to decide whether losing the
|
||||
* socket should cancel the execution.
|
||||
*/
|
||||
private isChatDrivenExecution(execution: IExecutionResponse): boolean {
|
||||
const target = this.tryResolveResumeTarget(execution);
|
||||
return !!target && typeof target.nodeType.onMessage === 'function';
|
||||
}
|
||||
|
||||
private async runNode(execution: IExecutionResponse, message: ChatMessage) {
|
||||
const workflow = this.getWorkflow(execution);
|
||||
const target = this.resolveResumeTarget(execution, workflow);
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
const { node, nodeType, executionData } = target;
|
||||
const additionalData = await WorkflowExecuteAdditionalData.getBase({ workflowId: workflow.id });
|
||||
|
||||
// PartialExecutionToolExecutor is a virtual node not present in the workflow.
|
||||
// `node`/`nodeType` already point at the real tool via findResumeNode; this
|
||||
// applies the execution-state redirect so onMessage() runs on it (no-op for
|
||||
// an ordinary parked node).
|
||||
redirectIfToolExecutor(execution, executionData, workflow);
|
||||
|
||||
const inputData = executionData.data;
|
||||
const connectionInputData = executionData.data.main[0];
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
const context = new ExecuteContext(
|
||||
workflow,
|
||||
node,
|
||||
|
|
@ -160,10 +253,14 @@ export class ChatExecutionManager {
|
|||
// The chat-based HITL tool resumes here too, but carries the generated
|
||||
// `chatHitlTool` type rather than `chatTool`. Without this its output stays on
|
||||
// the `main` channel instead of `ai_tool`, so the agent never sees the approval
|
||||
// and the gated tool is never executed. The webhook resume path keys off the
|
||||
// same `HitlTool` suffix.
|
||||
// and the gated tool is never executed. Scope this to chat-based tools only:
|
||||
// non-chat HITL tools (e.g. telegramHitlTool) are not chat-resumable and must
|
||||
// not receive the approval fix-up, so the suffix check is paired with a base
|
||||
// type check.
|
||||
const resumingNode = runExecutionData.executionData!.nodeExecutionStack[0].node;
|
||||
if (resumingNode.type === CHAT_TOOL_NODE_TYPE || isHitlToolType(resumingNode.type)) {
|
||||
const isChatBasedHitlTool =
|
||||
isHitlToolType(resumingNode.type) && stripToolSuffix(resumingNode.type) === CHAT_NODE_TYPE;
|
||||
if (resumingNode.type === CHAT_TOOL_NODE_TYPE || isChatBasedHitlTool) {
|
||||
runExecutionData.waitTill = undefined;
|
||||
resumingNode.disabled = true;
|
||||
resumingNode.rewireOutputLogTo = NodeConnectionTypes.AiTool;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Service } from '@n8n/di';
|
|||
import { timingSafeEqual } from 'crypto';
|
||||
import { ErrorReporter } from 'n8n-core';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
import { jsonParse, UnexpectedError, CHAT_NODE_TYPE, CHAT_TOOL_NODE_TYPE } from 'n8n-workflow';
|
||||
import { jsonParse, UnexpectedError } from 'n8n-workflow';
|
||||
import { type RawData, WebSocket } from 'ws';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
|
@ -166,8 +166,8 @@ export class ChatService {
|
|||
chatInput: getLastNodeMessage(execution, lastNode),
|
||||
sessionId: session.sessionId,
|
||||
};
|
||||
await this.resumeExecution(session.executionId, data, sessionKey);
|
||||
session.nodeWaitingForChatResponse = undefined;
|
||||
const resumed = await this.resumeExecution(session.executionId, data, sessionKey);
|
||||
if (resumed) session.nodeWaitingForChatResponse = undefined;
|
||||
} else {
|
||||
session.nodeWaitingForChatResponse = lastNode?.name;
|
||||
}
|
||||
|
|
@ -249,10 +249,12 @@ export class ChatService {
|
|||
}
|
||||
|
||||
const executionId = session.executionId;
|
||||
if (await this.shouldResumeOnMessage(executionId)) {
|
||||
await this.resumeExecution(executionId, this.parseChatMessage(message), sessionKey);
|
||||
session.nodeWaitingForChatResponse = undefined;
|
||||
}
|
||||
const resumed = await this.resumeExecution(
|
||||
executionId,
|
||||
this.parseChatMessage(message),
|
||||
sessionKey,
|
||||
);
|
||||
if (resumed) session.nodeWaitingForChatResponse = undefined;
|
||||
} catch (e) {
|
||||
const error = ensureError(e);
|
||||
this.errorReporter.error(error);
|
||||
|
|
@ -263,10 +265,30 @@ export class ChatService {
|
|||
};
|
||||
}
|
||||
|
||||
private async resumeExecution(executionId: string, message: ChatMessage, sessionKey: string) {
|
||||
private async resumeExecution(
|
||||
executionId: string,
|
||||
message: ChatMessage,
|
||||
sessionKey: string,
|
||||
): Promise<boolean> {
|
||||
const execution = await this.getExecutionOrCleanupSession(executionId, sessionKey);
|
||||
if (!execution || execution.status !== 'waiting') return;
|
||||
if (!execution || execution.status !== 'waiting') return false;
|
||||
|
||||
// A chat message may only resume nodes designed to accept one (see
|
||||
// ChatExecutionManager.canResumeOverChat); refuse everything else,
|
||||
// whatever token is presented. Log the refusal so the denial is visible
|
||||
// (probing / "why won't my chat resume") rather than silent.
|
||||
if (!this.executionManager.canResumeOverChat(execution)) {
|
||||
// Resolve the same node the gate checked (a tool-executor entry redirects to
|
||||
// the wrapped tool) so the diagnostic names the node that was refused.
|
||||
const nodeType = this.executionManager.resolveResumeNodeType(execution);
|
||||
this.logger.warn(
|
||||
`Refused chat resume for execution ${executionId}: suspended node (type: ${nodeType ?? 'unknown'}) is not resumable over chat`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.executionManager.runWorkflow(execution, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getExecutionOrCleanupSession(executionId: string, sessionKey: string) {
|
||||
|
|
@ -360,21 +382,6 @@ export class ChatService {
|
|||
}
|
||||
}
|
||||
|
||||
private async shouldResumeOnMessage(executionId: string) {
|
||||
const execution = await this.executionManager.findExecution(executionId);
|
||||
if (!execution) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastNode = getLastNodeExecuted(execution);
|
||||
const isChatNode = lastNode?.type === CHAT_NODE_TYPE || lastNode?.type === CHAT_TOOL_NODE_TYPE;
|
||||
if (isChatNode && lastNode?.parameters?.blockUserInput === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@OnShutdown()
|
||||
shutdown() {
|
||||
for (const [key, session] of this.sessions.entries()) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,43 @@ import {
|
|||
|
||||
const AI_TOOL = 'ai_tool';
|
||||
|
||||
/**
|
||||
* When the parked node is the virtual PartialExecutionToolExecutor, resolves the
|
||||
* real tool node it wraps (stored in the executor's `node` parameter). Pure — does
|
||||
* not mutate the execution. Returns null when the parked node isn't the executor.
|
||||
*
|
||||
* Keyed off `executionData.node` (the parked stack entry) rather than
|
||||
* `resultData.lastNodeExecuted` so it matches the node the engine resumes.
|
||||
*/
|
||||
function getToolExecutorTargetNode(executionData: IExecuteData, workflow: Workflow): INode | null {
|
||||
if (executionData.node.name !== TOOL_EXECUTOR_NODE_NAME) return null;
|
||||
|
||||
const toolNodeName = executionData.node.parameters?.node as string | undefined;
|
||||
return toolNodeName ? workflow.getNode(toolNodeName) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the node a chat message would resume — the parked node, or the tool node
|
||||
* behind the virtual PartialExecutionToolExecutor. Pure: does not mutate the
|
||||
* execution, so callers deciding *whether* to resume can share it with the resume
|
||||
* path without side effects. Returns null when no resumable node can be resolved.
|
||||
*
|
||||
* Resolves from `executionData.node` — the top of `nodeExecutionStack`, i.e. the
|
||||
* node the engine actually resumes — not `resultData.lastNodeExecuted` (a report of
|
||||
* what last ran). The two agree in every real waiting state, but reading the
|
||||
* authoritative field keeps the resume decision from ever authorizing a different
|
||||
* node than the one that runs.
|
||||
*/
|
||||
export function findResumeNode(executionData: IExecuteData, workflow: Workflow): INode | null {
|
||||
const parkedNode = executionData.node;
|
||||
|
||||
if (parkedNode.name === TOOL_EXECUTOR_NODE_NAME) {
|
||||
return getToolExecutorTargetNode(executionData, workflow);
|
||||
}
|
||||
|
||||
return workflow.getNode(parkedNode.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects execution from PartialExecutionToolExecutor to the actual tool node
|
||||
* it wraps (stored in the executor's `node` parameter).
|
||||
|
|
@ -27,11 +64,7 @@ export function redirectIfToolExecutor(
|
|||
executionData: IExecuteData,
|
||||
workflow: Workflow,
|
||||
): INode | null {
|
||||
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted;
|
||||
if (lastNodeExecuted !== TOOL_EXECUTOR_NODE_NAME) return null;
|
||||
|
||||
const toolNodeName = executionData.node.parameters?.node as string | undefined;
|
||||
const toolNode = toolNodeName ? workflow.getNode(toolNodeName) : null;
|
||||
const toolNode = getToolExecutorTargetNode(executionData, workflow);
|
||||
if (!toolNode) return null;
|
||||
|
||||
executionData.node = toolNode;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import type { AuthenticatedRequest, Project } from '@n8n/db';
|
||||
import type { Role } from '@n8n/permissions';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import type { ProjectService } from '@/services/project.service.ee';
|
||||
import type { RoleService } from '@/services/role.service';
|
||||
|
||||
import { RoleController } from '../role.controller';
|
||||
|
|
@ -10,7 +12,8 @@ import { RoleController } from '../role.controller';
|
|||
describe('RoleController', () => {
|
||||
const eventService = mock<EventService>();
|
||||
const roleService = mock<RoleService>();
|
||||
const controller = new RoleController(roleService, eventService);
|
||||
const projectService = mock<ProjectService>();
|
||||
const controller = new RoleController(roleService, eventService, projectService);
|
||||
|
||||
const managerRequest = () =>
|
||||
mock<AuthenticatedRequest>({
|
||||
|
|
@ -19,6 +22,7 @@ describe('RoleController', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('emits action events', () => {
|
||||
|
|
@ -147,4 +151,93 @@ describe('RoleController', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRoleProjectMembers', () => {
|
||||
const projectRole = { slug: 'project:admin', roleType: 'project' } as Role;
|
||||
|
||||
it('should return the members when the caller can list the project', async () => {
|
||||
const request = managerRequest();
|
||||
roleService.getRole.mockResolvedValue(projectRole);
|
||||
projectService.getProjectWithScope.mockResolvedValue(mock<Project>({ id: 'project-1' }));
|
||||
roleService.getRoleProjectMembers.mockResolvedValue({
|
||||
members: [
|
||||
{
|
||||
userId: 'user-1',
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
email: 'ada@example.com',
|
||||
role: 'project:admin',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await controller.getRoleProjectMembers(
|
||||
request,
|
||||
mock(),
|
||||
'project:admin',
|
||||
'project-1',
|
||||
);
|
||||
|
||||
expect(projectService.getProjectWithScope).toHaveBeenCalledWith(request.user, 'project-1', [
|
||||
'project:list',
|
||||
]);
|
||||
expect(result.members).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not return the members when the caller cannot list the project', async () => {
|
||||
const request = managerRequest();
|
||||
roleService.getRole.mockResolvedValue(projectRole);
|
||||
projectService.getProjectWithScope.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
controller.getRoleProjectMembers(request, mock(), 'project:admin', 'project-1'),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
|
||||
expect(roleService.getRoleProjectMembers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRoleAssignments', () => {
|
||||
const projectRole = { slug: 'project:admin', roleType: 'project' } as Role;
|
||||
|
||||
const assignment = (projectId: string, projectName: string) => ({
|
||||
projectId,
|
||||
projectName,
|
||||
projectIcon: null,
|
||||
memberCount: 2,
|
||||
lastAssigned: null,
|
||||
});
|
||||
|
||||
it('should only return the projects the caller can list, keeping the total intact', async () => {
|
||||
const request = managerRequest();
|
||||
roleService.getRole.mockResolvedValue(projectRole);
|
||||
roleService.getRoleAssignments.mockResolvedValue({
|
||||
projects: [assignment('project-1', 'Mine'), assignment('project-2', 'Not mine')],
|
||||
totalProjects: 2,
|
||||
});
|
||||
projectService.getProjectIdsWithScope.mockResolvedValue(['project-1']);
|
||||
|
||||
const result = await controller.getRoleAssignments(request, mock(), 'project:admin');
|
||||
|
||||
expect(projectService.getProjectIdsWithScope).toHaveBeenCalledWith(
|
||||
request.user,
|
||||
['project:list'],
|
||||
['project-1', 'project-2'],
|
||||
);
|
||||
expect(result.projects).toEqual([assignment('project-1', 'Mine')]);
|
||||
// the count stays instance-wide so the delete-impact signal is not silently wrong
|
||||
expect(result.totalProjects).toBe(2);
|
||||
});
|
||||
|
||||
it('should not query visibility when the role has no assignments', async () => {
|
||||
const request = managerRequest();
|
||||
roleService.getRole.mockResolvedValue(projectRole);
|
||||
roleService.getRoleAssignments.mockResolvedValue({ projects: [], totalProjects: 0 });
|
||||
|
||||
const result = await controller.getRoleAssignments(request, mock(), 'project:admin');
|
||||
|
||||
expect(projectService.getProjectIdsWithScope).not.toHaveBeenCalled();
|
||||
expect(result.projects).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ import {
|
|||
} from '@n8n/decorators';
|
||||
import { Role as RoleDTO } from '@n8n/permissions';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { assertCanManageRoleType, canReassignUsers } from '@/services/role-authorization';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
|
||||
|
|
@ -38,6 +40,7 @@ export class RoleController {
|
|||
constructor(
|
||||
private readonly roleService: RoleService,
|
||||
private readonly eventService: EventService,
|
||||
private readonly projectService: ProjectService,
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
|
|
@ -67,6 +70,17 @@ export class RoleController {
|
|||
roleType: role.roleType,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
// Managing a role is an instance-wide capability, but the identities of a project's
|
||||
// members are project data. Gate them on the same `project:list` check the sibling
|
||||
// user and project routes use, so a role manager only sees projects it can already see.
|
||||
const project = await this.projectService.getProjectWithScope(req.user, projectId, [
|
||||
'project:list',
|
||||
]);
|
||||
if (!project) {
|
||||
throw new NotFoundError('Project not found');
|
||||
}
|
||||
|
||||
const result = await this.roleService.getRoleProjectMembers(slug, projectId);
|
||||
return RoleProjectMembersResponseDto.parse(result);
|
||||
}
|
||||
|
|
@ -83,7 +97,27 @@ export class RoleController {
|
|||
user: req.user,
|
||||
});
|
||||
const result = await this.roleService.getRoleAssignments(slug);
|
||||
return RoleAssignmentsResponseDto.parse(result);
|
||||
|
||||
if (result.projects.length === 0) {
|
||||
return RoleAssignmentsResponseDto.parse(result);
|
||||
}
|
||||
|
||||
// `projects` only names the projects the caller can already see. `totalProjects` stays
|
||||
// the instance-wide count so the assignments tab can say "showing 1 of 3" rather than
|
||||
// claim the role is unassigned; a bare number names no project and no member. The
|
||||
// delete-impact warning is a different field — `usedByProjects`, served by `getRole`.
|
||||
const visibleProjectIds = new Set(
|
||||
await this.projectService.getProjectIdsWithScope(
|
||||
req.user,
|
||||
['project:list'],
|
||||
result.projects.map((project) => project.projectId),
|
||||
),
|
||||
);
|
||||
|
||||
return RoleAssignmentsResponseDto.parse({
|
||||
...result,
|
||||
projects: result.projects.filter((project) => visibleProjectIds.has(project.projectId)),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('/:slug/members')
|
||||
|
|
|
|||
|
|
@ -4197,10 +4197,21 @@ describe('CredentialsService', () => {
|
|||
expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refuse a test URL without a stored service origin', async () => {
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
|
||||
mockDecryptedData({ testUrl: 'https://api.example.com/me' });
|
||||
|
||||
await expect(service.probeById(ownerUser, 'cred-id')).rejects.toThrow(
|
||||
'The credential test URL is not bound to its service origin',
|
||||
);
|
||||
expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should probe the persisted test URL with parsed accepted status codes', async () => {
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
|
||||
const data: ICredentialDataDecryptedObject = {
|
||||
testUrl: 'https://api.example.com/me',
|
||||
serviceOrigin: 'https://api.example.com',
|
||||
acceptedStatusCodes: '[401]',
|
||||
};
|
||||
mockDecryptedData(data);
|
||||
|
|
@ -4217,13 +4228,30 @@ describe('CredentialsService', () => {
|
|||
'httpTemplatedCustomAuth',
|
||||
{ id: 'cred-id', name: 'Templated cred', type: 'httpTemplatedCustomAuth', data },
|
||||
'https://api.example.com/me',
|
||||
{ acceptedStatusCodes: [401] },
|
||||
{ acceptedStatusCodes: [401], allowedDomains: 'api.example.com' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse a test URL outside the stored service origin', async () => {
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
|
||||
mockDecryptedData({
|
||||
testUrl: 'https://status.example.net/me',
|
||||
serviceOrigin: 'https://api.example.com',
|
||||
});
|
||||
|
||||
await expect(service.probeById(ownerUser, 'cred-id')).rejects.toThrow(
|
||||
'The credential test URL is not bound to its service origin',
|
||||
);
|
||||
expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore malformed accepted status codes', async () => {
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential);
|
||||
mockDecryptedData({ testUrl: 'https://api.example.com/me', acceptedStatusCodes: 'nope' });
|
||||
mockDecryptedData({
|
||||
testUrl: 'https://api.example.com/me',
|
||||
serviceOrigin: 'https://api.example.com',
|
||||
acceptedStatusCodes: 'nope',
|
||||
});
|
||||
credentialsTester.probeCredentialAuth.mockResolvedValue({
|
||||
status: 'OK',
|
||||
message: 'Connection successful!',
|
||||
|
|
@ -4236,7 +4264,7 @@ describe('CredentialsService', () => {
|
|||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
{ acceptedStatusCodes: undefined },
|
||||
{ acceptedStatusCodes: undefined, allowedDomains: 'api.example.com' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -88,6 +88,16 @@ import {
|
|||
/** Sentinel placed at every leaf of a redacted httpCustomAuth JSON shape */
|
||||
const CUSTOM_AUTH_JSON_REDACTED_VALUE = '***';
|
||||
|
||||
function parseHttpUrl(value: unknown): URL | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type CredentialsGetSharedOptions =
|
||||
| { allowGlobalScope: true; globalScope: Scope }
|
||||
| { allowGlobalScope: false };
|
||||
|
|
@ -1301,12 +1311,22 @@ export class CredentialsService {
|
|||
|
||||
const data = await this.decrypt(storedCredential, true);
|
||||
|
||||
// Expressions (leading '=') and non-http values are refused, not resolved.
|
||||
const testUrl = data.testUrl;
|
||||
if (typeof testUrl !== 'string' || !/^https?:\/\//i.test(testUrl)) {
|
||||
// Expressions and non-HTTP values are refused, not resolved.
|
||||
const testTarget = parseHttpUrl(data.testUrl);
|
||||
if (!testTarget) {
|
||||
throw new BadRequestError('The credential has no test URL to probe');
|
||||
}
|
||||
|
||||
const storedOrigin = typeof data.serviceOrigin === 'string' ? data.serviceOrigin.trim() : '';
|
||||
const serviceOrigin = parseHttpUrl(storedOrigin);
|
||||
if (
|
||||
!serviceOrigin ||
|
||||
serviceOrigin.origin !== storedOrigin ||
|
||||
serviceOrigin.origin !== testTarget.origin
|
||||
) {
|
||||
throw new BadRequestError('The credential test URL is not bound to its service origin');
|
||||
}
|
||||
|
||||
return await this.credentialsTester.probeCredentialAuth(
|
||||
user.id,
|
||||
storedCredential.type,
|
||||
|
|
@ -1316,8 +1336,11 @@ export class CredentialsService {
|
|||
type: storedCredential.type,
|
||||
data,
|
||||
},
|
||||
testUrl,
|
||||
{ acceptedStatusCodes: parseAcceptedStatusCodes(data.acceptedStatusCodes) },
|
||||
testTarget.href,
|
||||
{
|
||||
acceptedStatusCodes: parseAcceptedStatusCodes(data.acceptedStatusCodes),
|
||||
allowedDomains: testTarget.hostname,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import type { Project } from '@n8n/db';
|
|||
import { WorkflowOperationError } from 'n8n-workflow';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
type CallerType = 'workflow' | 'agent';
|
||||
|
||||
type Options = {
|
||||
/** ID of the subworkflow whose execution was denied. */
|
||||
subworkflowId: string;
|
||||
|
|
@ -20,10 +22,29 @@ type Options = {
|
|||
|
||||
/** Node that triggered the execution of the subworkflow whose execution was denied. */
|
||||
node?: INode;
|
||||
|
||||
callerType?: CallerType;
|
||||
};
|
||||
|
||||
export const SUBWORKFLOW_DENIAL_BASE_DESCRIPTION =
|
||||
'The sub-workflow you’re trying to execute limits which workflows it can be called by.';
|
||||
export const SUBWORKFLOW_AGENT_DENIAL_BASE_DESCRIPTION =
|
||||
'The sub-workflow you’re trying to execute doesn’t allow this agent to call it.';
|
||||
|
||||
function getCallerDescription(callerType: CallerType) {
|
||||
return {
|
||||
agent: {
|
||||
caller: 'agent',
|
||||
accessibleCaller: 'this agent',
|
||||
baseDescription: SUBWORKFLOW_AGENT_DENIAL_BASE_DESCRIPTION,
|
||||
},
|
||||
workflow: {
|
||||
caller: 'workflow',
|
||||
accessibleCaller: 'other workflows',
|
||||
baseDescription: SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
|
||||
},
|
||||
}[callerType];
|
||||
}
|
||||
|
||||
export class SubworkflowPolicyDenialError extends WorkflowOperationError {
|
||||
constructor({
|
||||
|
|
@ -33,20 +54,22 @@ export class SubworkflowPolicyDenialError extends WorkflowOperationError {
|
|||
hasReadAccess,
|
||||
ownerName,
|
||||
node,
|
||||
callerType = 'workflow',
|
||||
}: Options) {
|
||||
const { caller, accessibleCaller, baseDescription } = getCallerDescription(callerType);
|
||||
const descriptions = {
|
||||
default: SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
|
||||
default: baseDescription,
|
||||
accessible: [
|
||||
SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
|
||||
`<a href="${instanceUrl}/workflow/${subworkflowId}" target="_blank">Update sub-workflow settings</a> to allow other workflows to call it.`,
|
||||
baseDescription,
|
||||
`<a href="${instanceUrl}/workflow/${subworkflowId}" target="_blank">Update sub-workflow settings</a> to allow ${accessibleCaller} to call it.`,
|
||||
].join(' '),
|
||||
inaccessiblePersonalProject: [
|
||||
SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
|
||||
`You will need ${ownerName} to update the sub-workflow (${subworkflowId}) settings to allow this workflow to call it.`,
|
||||
baseDescription,
|
||||
`You will need ${ownerName} to update the sub-workflow (${subworkflowId}) settings to allow this ${caller} to call it.`,
|
||||
].join(' '),
|
||||
inaccesibleTeamProject: [
|
||||
SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
|
||||
`You will need an admin from the ${subworkflowProject.name} project to update the sub-workflow (${subworkflowId}) settings to allow this workflow to call it.`,
|
||||
baseDescription,
|
||||
`You will need an admin from the ${subworkflowProject.name} project to update the sub-workflow (${subworkflowId}) settings to allow this ${caller} to call it.`,
|
||||
].join(' '),
|
||||
};
|
||||
|
||||
|
|
@ -59,7 +82,7 @@ export class SubworkflowPolicyDenialError extends WorkflowOperationError {
|
|||
};
|
||||
|
||||
super(
|
||||
`The sub-workflow (${subworkflowId}) cannot be called by this workflow`,
|
||||
`The sub-workflow (${subworkflowId}) cannot be called by this ${caller}`,
|
||||
node,
|
||||
description(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import { ExternalHooks } from '@/external-hooks';
|
|||
import { Push } from '@/push';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { WorkflowPublicationNotifier } from '@/workflows/publication/workflow-publication-notifier';
|
||||
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
|
||||
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
||||
import { createExecution } from '@test-integration/db/executions';
|
||||
|
||||
import { IN_PROGRESS_EXECUTION_DATA, OOM_WORKFLOW } from './constants';
|
||||
|
|
@ -46,6 +48,8 @@ describe('ExecutionRecoveryService', () => {
|
|||
const projectRelationRepository = mockInstance(ProjectRelationRepository);
|
||||
const externalHooks = mockInstance(ExternalHooks);
|
||||
const activeWorkflowManager = mockInstance(ActiveWorkflowManager);
|
||||
const workflowSharingService = mockInstance(WorkflowSharingService);
|
||||
const workflowPushNotifier = new WorkflowPushNotifier(push, workflowSharingService);
|
||||
mockInstance(WorkflowPublicationNotifier);
|
||||
|
||||
let executionRecoveryService: ExecutionRecoveryService;
|
||||
|
|
@ -72,11 +76,13 @@ describe('ExecutionRecoveryService', () => {
|
|||
mock(),
|
||||
ownershipService,
|
||||
projectRelationRepository,
|
||||
workflowPushNotifier,
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
instanceSettings.markAsLeader();
|
||||
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -185,6 +191,44 @@ describe('ExecutionRecoveryService', () => {
|
|||
expect(amendedExecution.status).toBe('crashed');
|
||||
expect(amendedExecution.stoppedAt).not.toBe(execution.stoppedAt);
|
||||
});
|
||||
|
||||
test('pushes `executionRecovered` only to users with workflow access, once a client connects', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
const workflow = await createWorkflow(OOM_WORKFLOW);
|
||||
const execution = await createExecution(
|
||||
{
|
||||
status: 'running',
|
||||
data: stringify(IN_PROGRESS_EXECUTION_DATA),
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(['user-1']);
|
||||
let editorUiConnectedCallback: (() => Promise<void>) | undefined;
|
||||
push.once.mockImplementation((event: string, callback: () => Promise<void>) => {
|
||||
if (event === 'editorUiConnected') editorUiConnectedCallback = callback;
|
||||
return push;
|
||||
});
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
await executionRecoveryService.recoverFromLogs(execution.id, []);
|
||||
expect(editorUiConnectedCallback).toBeDefined();
|
||||
await editorUiConnectedCallback?.();
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith(
|
||||
workflow.id,
|
||||
);
|
||||
expect(push.sendToUsers).toHaveBeenCalledWith(
|
||||
{ type: 'executionRecovered', data: { executionId: execution.id } },
|
||||
['user-1'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('if leader, with 1+ messages', () => {
|
||||
|
|
@ -526,6 +570,40 @@ describe('ExecutionRecoveryService', () => {
|
|||
expect(updatedWorkflow.activeVersionId).toBeNull();
|
||||
});
|
||||
|
||||
test('pushes `workflowAutoDeactivated` only to users with workflow access, once a client connects', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
globalConfig.executions.recovery.workflowDeactivationEnabled = true;
|
||||
|
||||
const workflow = await createCrashedActiveWorkflow();
|
||||
mockOwnershipForDeactivation();
|
||||
workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(['user-1']);
|
||||
let editorUiConnectedCallback: (() => Promise<void>) | undefined;
|
||||
push.once.mockImplementation((event: string, callback: () => Promise<void>) => {
|
||||
if (event === 'editorUiConnected') editorUiConnectedCallback = callback;
|
||||
return push;
|
||||
});
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
await executionRecoveryService.autoDeactivateWorkflowsIfNeeded(new Set([workflow.id]));
|
||||
expect(editorUiConnectedCallback).toBeDefined();
|
||||
await editorUiConnectedCallback?.();
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith(
|
||||
workflow.id,
|
||||
);
|
||||
expect(push.sendToUsers).toHaveBeenCalledWith(
|
||||
{ type: 'workflowAutoDeactivated', data: { workflowId: workflow.id } },
|
||||
['user-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('should unpublish via outbox and record publish history on auto-deactivation', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import { ExecutionRepository, WorkflowRepository } from '@n8n/db';
|
|||
import { Container, Service } from '@n8n/di';
|
||||
import { PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { sleep } from '@n8n/utils/sleep';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import { createEmptyRunExecutionData } from 'n8n-workflow';
|
||||
import { ExecutionStatus, type IRun, type ITaskData } from 'n8n-workflow';
|
||||
|
|
@ -25,6 +25,7 @@ import { ExecutionPersistence } from '@/executions/execution-persistence';
|
|||
import { Push } from '@/push';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { UserManagementMailer } from '@/user-management/email/user-management-mailer';
|
||||
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
|
||||
|
||||
import { isNodeEventMessage, type EventMessageTypes } from '../eventbus/event-message-classes';
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ export class ExecutionRecoveryService {
|
|||
private readonly userManagementMailer: UserManagementMailer,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
private readonly projectRelationRepository: ProjectRelationRepository,
|
||||
private readonly workflowPushNotifier: WorkflowPushNotifier,
|
||||
) {}
|
||||
|
||||
async autoDeactivateWorkflowsIfNeeded(workflowIds: Set<string>) {
|
||||
|
|
@ -88,7 +90,10 @@ export class ExecutionRecoveryService {
|
|||
|
||||
this.push.once('editorUiConnected', async () => {
|
||||
await sleep(1000);
|
||||
this.push.broadcast({ type: 'workflowAutoDeactivated', data: { workflowId } });
|
||||
await this.workflowPushNotifier.notify(workflowId, {
|
||||
type: 'workflowAutoDeactivated',
|
||||
data: { workflowId },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
// A throw here would abort startup recovery for the remaining
|
||||
|
|
@ -127,9 +132,14 @@ export class ExecutionRecoveryService {
|
|||
|
||||
await this.runHooks(amendedExecution);
|
||||
|
||||
const { workflowId } = amendedExecution;
|
||||
|
||||
this.push.once('editorUiConnected', async () => {
|
||||
await sleep(1000);
|
||||
this.push.broadcast({ type: 'executionRecovered', data: { executionId } });
|
||||
await this.workflowPushNotifier.notify(workflowId, {
|
||||
type: 'executionRecovered',
|
||||
data: { executionId },
|
||||
});
|
||||
});
|
||||
|
||||
return amendedExecution;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { v4 as uuid } from 'uuid';
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
SUBWORKFLOW_AGENT_DENIAL_BASE_DESCRIPTION,
|
||||
SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
|
||||
SubworkflowPolicyDenialError,
|
||||
} from '@/errors/subworkflow-policy-denial.error';
|
||||
|
|
@ -118,6 +119,51 @@ describe('SubworkflowPolicyChecker', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('project-scoped caller', () => {
|
||||
it('should allow workflows owned by the caller project', async () => {
|
||||
const projectId = uuid();
|
||||
const subworkflow = mock<Workflow>({
|
||||
id: 'subworkflow-id',
|
||||
settings: { callerPolicy: 'workflowsFromSameOwner' },
|
||||
});
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValue(mock<Project>({ id: projectId }));
|
||||
|
||||
await expect(checker.checkForProject(subworkflow, projectId)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should deny workflows owned by a different project', async () => {
|
||||
const subworkflow = mock<Workflow>({
|
||||
id: 'subworkflow-id',
|
||||
settings: { callerPolicy: 'workflowsFromSameOwner' },
|
||||
});
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValue(
|
||||
mock<Project>({ id: uuid(), name: 'Engineering', type: 'team' }),
|
||||
);
|
||||
|
||||
await expect(checker.checkForProject(subworkflow, uuid())).rejects.toMatchObject({
|
||||
message: 'The sub-workflow (subworkflow-id) cannot be called by this agent',
|
||||
description: `${SUBWORKFLOW_AGENT_DENIAL_BASE_DESCRIPTION} You will need an admin from the Engineering project to update the sub-workflow (subworkflow-id) settings to allow this agent to call it.`,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['none', 'workflowsFromAList'] as const)(
|
||||
'should deny the `%s` caller policy',
|
||||
async (callerPolicy) => {
|
||||
const subworkflow = mock<Workflow>({
|
||||
id: 'subworkflow-id',
|
||||
settings: { callerPolicy },
|
||||
});
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValue(
|
||||
mock<Project>({ id: uuid(), type: 'team' }),
|
||||
);
|
||||
|
||||
await expect(checker.checkForProject(subworkflow, uuid())).rejects.toThrowError(
|
||||
SubworkflowPolicyDenialError,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('`workflows-from-same-owner` caller policy', () => {
|
||||
it('should deny if the two workflows are owned by different projects', async () => {
|
||||
const parentWorkflowProject = mock<Project>({ id: uuid() });
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user