Compare commits

...

7 Commits

Author SHA1 Message Date
n8n-assistant[bot]
8567e74a2b
🚀 Release 2.32.3 (#34760)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-07-23 07:16:46 +00:00
n8n-assistant[bot]
53e48ee8b0
fix(editor): Restore agent channel credential setup (backport to release-candidate/2.32.x) (#34757)
Co-authored-by: Michael Drury <me@michaeldrury.co.uk>
2026-07-23 06:56:53 +00:00
n8n-assistant[bot]
e985cbac2a
🚀 Release 2.32.2 (#34728)
Co-authored-by: mike12345567 <4407001+mike12345567@users.noreply.github.com>
2026-07-22 14:29:35 +00:00
n8n-assistant[bot]
d9464241d7
fix(editor): Rename preview AI Agent node to V1 (backport to release-candidate/2.32.x) (#34718)
Co-authored-by: Michael Drury <me@michaeldrury.co.uk>
2026-07-22 13:01:20 +00:00
n8n-assistant[bot]
7d43cce195
🚀 Release 2.32.1 (#34701)
Co-authored-by: CharlieKolb <13814565+CharlieKolb@users.noreply.github.com>
2026-07-22 09:59:20 +00:00
n8n-assistant[bot]
f69dfc6dd2
chore: Bundle/2.x (backport to release-candidate/2.32.x) (#34692)
Co-authored-by: n8n-assistant[bot] <100856346+n8n-assistant[bot]@users.noreply.github.com>
Co-authored-by: Charlie Kolb <charlie@n8n.io>
2026-07-22 11:39:04 +02:00
n8n-assistant[bot]
db5e2d068b
fix(core): Correct agent node saving, agent creation flows and sub-agent language (no-changelog) (backport to release-candidate/2.32.x) (#34693)
Co-authored-by: Michael Drury <me@michaeldrury.co.uk>
2026-07-22 09:03:37 +00:00
141 changed files with 5219 additions and 1153 deletions

View File

@ -1,3 +1,22 @@
## [2.32.3](https://github.com/n8n-io/n8n/compare/n8n@2.32.2...n8n@2.32.3) (2026-07-23)
### Bug Fixes
* **editor:** Restore agent channel credential setup ([#34757](https://github.com/n8n-io/n8n/issues/34757)) ([53e48ee](https://github.com/n8n-io/n8n/commit/53e48ee8b0ed802277fc94ee9a77e331a4e36788))
## [2.32.2](https://github.com/n8n-io/n8n/compare/n8n@2.32.1...n8n@2.32.2) (2026-07-22)
### Bug Fixes
* **editor:** Rename preview AI Agent node to V1 ([#34718](https://github.com/n8n-io/n8n/issues/34718)) ([d946424](https://github.com/n8n-io/n8n/commit/d9464241d7245830aa6fd97a78fbe99b0c548fa6))
## [2.32.1](https://github.com/n8n-io/n8n/compare/n8n@2.32.0...n8n@2.32.1) (2026-07-22)
# [2.32.0](https://github.com/n8n-io/n8n/compare/n8n@2.31.0...n8n@2.32.0) (2026-07-21) # [2.32.0](https://github.com/n8n-io/n8n/compare/n8n@2.31.0...n8n@2.32.0) (2026-07-21)

View File

@ -1,6 +1,6 @@
{ {
"name": "n8n-monorepo", "name": "n8n-monorepo",
"version": "2.32.0", "version": "2.32.3",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=22.22", "node": ">=22.22",
@ -218,7 +218,8 @@
"@lezer/highlight": "patches/@lezer__highlight.patch", "@lezer/highlight": "patches/@lezer__highlight.patch",
"v-code-diff": "patches/v-code-diff.patch", "v-code-diff": "patches/v-code-diff.patch",
"vuedraggable@4.1.0": "patches/vuedraggable@4.1.0.patch", "vuedraggable@4.1.0": "patches/vuedraggable@4.1.0.patch",
"assert@2.1.0": "patches/assert@2.1.0.patch" "assert@2.1.0": "patches/assert@2.1.0.patch",
"lodash@4.18.1": "patches/lodash@4.18.1.patch"
} }
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/agents", "name": "@n8n/agents",
"version": "0.17.0", "version": "0.17.1",
"description": "AI agent SDK for n8n's code-first execution engine", "description": "AI agent SDK for n8n's code-first execution engine",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",

View File

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

View File

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

View File

@ -245,6 +245,32 @@ describe('getProxyAgent', () => {
expect(Agent).toHaveBeenCalled(); expect(Agent).toHaveBeenCalled();
}); });
}); });
describe('secure lookup', () => {
it('should build an Agent with the lookup on connect when no proxy is configured', () => {
const lookup = vi.fn();
const agent = getProxyAgent('https://api.openai.com/v1', undefined, lookup);
expect(Agent).toHaveBeenCalledWith(expect.objectContaining({ connect: { lookup } }));
expect(agent).toEqual(expect.objectContaining({ type: 'Agent' }));
expect(ProxyAgent).not.toHaveBeenCalled();
});
it('should not attach the lookup to a ProxyAgent when a proxy is configured', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const lookup = vi.fn();
getProxyAgent('https://api.openai.com/v1', undefined, lookup);
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(ProxyAgent).not.toHaveBeenCalledWith(
expect.objectContaining({ connect: expect.anything() }),
);
expect(Agent).not.toHaveBeenCalled();
});
});
}); });
describe('proxyFetch', () => { describe('proxyFetch', () => {

View File

@ -18,6 +18,7 @@
* raw dispatcher). See CAT-3377 for the consolidation this completes. * raw dispatcher). See CAT-3377 for the consolidation this completes.
*/ */
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 { 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 type { LookupFunction } from 'node:net';
/* eslint-disable n8n-local-rules/no-uncentralized-http -- langchain consumers pin undici v6, incompatible with backend-network's v7 dispatchers; see block comment below */ /* eslint-disable n8n-local-rules/no-uncentralized-http -- langchain consumers pin undici v6, incompatible with backend-network's v7 dispatchers; see block comment below */
import { Agent, ProxyAgent } from 'undici'; import { Agent, ProxyAgent } from 'undici';
@ -49,15 +50,21 @@ const PROXY_FALLBACK_TARGET = 'https://example.nonexistent/';
* @param targetUrl - The target URL to check proxy configuration for (optional) * @param targetUrl - The target URL to check proxy configuration for (optional)
* @param timeoutOptions - Optional timeout configuration to override defaults. When provided, * @param timeoutOptions - Optional timeout configuration to override defaults. When provided,
* always returns an Agent/ProxyAgent (even without proxy) to ensure timeouts are applied. * always returns an Agent/ProxyAgent (even without proxy) to ensure timeouts are applied.
* @returns An Agent (no proxy with timeout options) or ProxyAgent (with proxy) configured with timeouts, * @param lookup - Optional DNS lookup to pin the resolved address at connect time (e.g. an egress
* or undefined if no proxy is configured and no timeout options are provided (backward compatible behavior). * filter's secure lookup). When provided (without a proxy) an Agent is always returned.
* @returns An Agent (no proxy with timeout options or a lookup) or ProxyAgent (with proxy) configured with timeouts,
* or undefined if no proxy, timeout options, nor lookup are provided (backward compatible behavior).
* *
* @remarks * @remarks
* When timeoutOptions are provided, this function always returns an agent to ensure timeouts are properly configured. * 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. * The default undici timeouts (5 minutes) are too short for many AI operations.
* When timeoutOptions are NOT provided, returns undefined if no proxy is configured (backward compatible). * When timeoutOptions are NOT provided, returns undefined if no proxy is configured (backward compatible).
*/ */
export function getProxyAgent(targetUrl?: string, timeoutOptions?: AgentTimeoutOptions) { export function getProxyAgent(
targetUrl?: string,
timeoutOptions?: AgentTimeoutOptions,
lookup?: LookupFunction,
) {
const proxyUrl = resolveProxyUrl(targetUrl, PROXY_FALLBACK_TARGET); const proxyUrl = resolveProxyUrl(targetUrl, PROXY_FALLBACK_TARGET);
const agentOptions = { const agentOptions = {
@ -69,6 +76,9 @@ export function getProxyAgent(targetUrl?: string, timeoutOptions?: AgentTimeoutO
}; };
if (!proxyUrl) { if (!proxyUrl) {
if (lookup) {
return new Agent({ ...agentOptions, connect: { lookup } });
}
if (timeoutOptions) { if (timeoutOptions) {
return new Agent(agentOptions); return new Agent(agentOptions);
} }
@ -85,14 +95,16 @@ export function getProxyAgent(targetUrl?: string, timeoutOptions?: AgentTimeoutO
* @param input - The URL to fetch * @param input - The URL to fetch
* @param init - Standard fetch RequestInit options * @param init - Standard fetch RequestInit options
* @param timeoutOptions - Optional timeout configuration to override defaults * @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( export async function proxyFetch(
input: RequestInfo | URL, input: RequestInfo | URL,
init?: RequestInit, init?: RequestInit,
timeoutOptions?: AgentTimeoutOptions, timeoutOptions?: AgentTimeoutOptions,
lookup?: LookupFunction,
): Promise<Response> { ): Promise<Response> {
const targetUrl = input instanceof Request ? input.url : input.toString(); const targetUrl = input instanceof Request ? input.url : input.toString();
const dispatcher = getProxyAgent(targetUrl, timeoutOptions); const dispatcher = getProxyAgent(targetUrl, timeoutOptions, lookup);
return await fetch(input, { return await fetch(input, {
...init, ...init,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/computer-use", "name": "@n8n/computer-use",
"version": "0.16.0", "version": "0.16.1",
"description": "Local AI gateway for n8n AI Assistant — filesystem, shell, screenshots, mouse/keyboard, and browser automation", "description": "Local AI gateway for n8n AI Assistant — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
"publishConfig": { "publishConfig": {
"bin": { "bin": {

View File

@ -1,3 +1,4 @@
import fastGlob from 'fast-glob';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as os from 'node:os'; import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
@ -5,6 +6,11 @@ import * as path from 'node:path';
import { textOf } from '../test-utils'; import { textOf } from '../test-utils';
import { searchFilesTool } from './search-files'; import { searchFilesTool } from './search-files';
vi.mock('fast-glob', async (importOriginal) => {
const actual = await importOriginal<{ default: typeof fastGlob }>();
return { default: vi.fn(actual.default) };
});
type FileMatch = { path: string }; type FileMatch = { path: string };
type ContentMatch = { path: string; lineNumber: number; line: string }; type ContentMatch = { path: string; lineNumber: number; line: string };
@ -252,6 +258,10 @@ describe('searchFilesTool', () => {
}); });
describe('execute — security', () => { describe('execute — security', () => {
afterEach(() => {
vi.mocked(fastGlob).mockClear();
});
it.each(['/etc/passwd', '../../etc/passwd', '../../../etc/**'])( it.each(['/etc/passwd', '../../etc/passwd', '../../../etc/**'])(
'rejects pattern that escapes the base directory: %s', 'rejects pattern that escapes the base directory: %s',
async (pattern) => { async (pattern) => {
@ -260,6 +270,73 @@ describe('searchFilesTool', () => {
); );
}, },
); );
it('drops matches that resolve outside the base even when the pattern passes', async () => {
// The pattern contains no literal `..`, but glob expansion produces a match
// that resolves outside the base directory.
vi.mocked(fastGlob).mockResolvedValueOnce(['../SECRET.txt']);
const result = await searchFilesTool.execute({ name: '{a,..}/SECRET.txt' }, context);
const data = parseResult<NameOnlyResult>(result);
expect(data.matches).toEqual([]);
expect(data.totalMatches).toBe(0);
});
it('does not read the contents of a match that resolves outside the base', async () => {
// `query` is set, so a match that slipped through would be read and returned.
vi.mocked(fastGlob).mockResolvedValueOnce(['../SECRET.txt']);
const result = await searchFilesTool.execute(
{ name: '{a,..}/SECRET.txt', query: 'SECRET' },
context,
);
const data = parseResult<ContentResult>(result);
expect(data.matches).toEqual([]);
expect(data.totalMatches).toBe(0);
});
it('keeps in-base matches while dropping ones outside the base', async () => {
await writeFile(baseDir, 'inside.txt', 'value');
// Glob expansion can yield both an in-base and an out-of-base match.
vi.mocked(fastGlob).mockResolvedValueOnce(['inside.txt', '../SECRET.txt']);
const result = await searchFilesTool.execute({ name: '{.,..}/inside.txt' }, context);
const data = parseResult<NameOnlyResult>(result);
expect(data.matches).toEqual([{ path: 'inside.txt' }]);
expect(data.totalMatches).toBe(1);
});
it('does not return a file that lives outside the base directory', async () => {
// The target file sits one level above the sandbox base.
const outerDir = await fs.mkdtemp(path.join(os.tmpdir(), 'search-files-outer-'));
const sandbox = path.join(outerDir, 'sandbox');
await fs.mkdir(sandbox);
await fs.writeFile(path.join(outerDir, 'SECRET.txt'), 'SECRET=topsecret');
// The tool either rejects the pattern or, on glob versions that expand it
// differently, returns no match — never the outside file's content.
const runSearch = async (): Promise<string> => {
try {
const result = await searchFilesTool.execute(
{ name: '{a,..}/SECRET.txt', query: 'SECRET' },
{ dir: sandbox },
);
return textOf(result);
} catch (error) {
expect(String(error)).toContain('escapes the base directory');
return '';
}
};
try {
expect(await runSearch()).not.toContain('topsecret');
} finally {
await fs.rm(outerDir, { recursive: true, force: true });
}
});
}); });
describe('CallToolResult shape', () => { describe('CallToolResult shape', () => {

View File

@ -1,6 +1,5 @@
import fastGlob from 'fast-glob'; import fastGlob from 'fast-glob';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { z } from 'zod'; import { z } from 'zod';
import type { ToolDefinition } from '../types'; import type { ToolDefinition } from '../types';
@ -62,7 +61,7 @@ export const searchFilesTool: ToolDefinition<typeof inputSchema> = {
const resolvedDir = await resolveReadablePath(dir, '.'); const resolvedDir = await resolveReadablePath(dir, '.');
const limit = maxResults ?? 50; const limit = maxResults ?? 50;
const files = await fastGlob(name, { const globbed = await fastGlob(name, {
cwd: resolvedDir, cwd: resolvedDir,
ignore: IGNORE_PATTERNS, ignore: IGNORE_PATTERNS,
onlyFiles: true, onlyFiles: true,
@ -70,63 +69,77 @@ export const searchFilesTool: ToolDefinition<typeof inputSchema> = {
suppressErrors: true, suppressErrors: true,
}); });
// The literal-pattern check above cannot see paths produced by glob expansion,
// so re-resolve every match through the shared resolver, which re-checks
// containment (following symlinks safely) and drops anything outside the base.
const resolved = await Promise.all(
globbed.map(async (path) => {
const absolutePath = await resolveReadablePath(dir, path).catch(() => null);
return absolutePath ? { path, absolutePath } : null;
}),
);
const files = resolved.filter((file): file is ResolvedFile => file !== null);
if (!query) { if (!query) {
const matches = files.slice(0, limit).map((p) => ({ path: p }));
return formatCallToolResult({ return formatCallToolResult({
name, name,
matches, matches: files.slice(0, limit).map(({ path }) => ({ path })),
truncated: files.length > limit, truncated: files.length > limit,
totalMatches: files.length, totalMatches: files.length,
}); });
} }
const regex = new RegExp(escapeRegex(query), ignoreCase ? 'gi' : 'g'); const regex = new RegExp(escapeRegex(query), ignoreCase ? 'i' : '');
const matches: Array<{ path: string; lineNumber: number; line: string }> = []; const matches = await Promise.all(files.map(async (file) => await grepFile(file, regex))).then(
let totalMatches = 0; (results) => results.flat(),
);
for (const fp of files) {
if (matches.length >= limit) break;
try {
const fullPath = path.join(resolvedDir, fp);
const stat = await fs.stat(fullPath);
if (stat.size > MAX_FILE_SIZE) continue;
const fileContent = await fs.readFile(fullPath);
const buffer = Buffer.isBuffer(fileContent) ? fileContent : Buffer.from(fileContent);
if (isLikelyBinaryContent(buffer)) continue;
const lines = buffer.toString('utf-8').split('\n');
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i])) {
totalMatches++;
if (matches.length < limit) {
matches.push({ path: fp, lineNumber: i + 1, line: lines[i].substring(0, 200) });
}
}
regex.lastIndex = 0;
}
} catch {
// Skip unreadable files
}
}
return formatCallToolResult({ return formatCallToolResult({
name, name,
query, query,
matches, matches: matches.slice(0, limit),
truncated: totalMatches > limit, truncated: matches.length > limit,
totalMatches, totalMatches: matches.length,
}); });
}, },
}; };
interface ResolvedFile {
path: string;
absolutePath: string;
}
function assertPatternStaysInside(pattern: string): void { function assertPatternStaysInside(pattern: string): void {
if (pattern.startsWith('/') || pattern.split('/').includes('..')) { if (pattern.startsWith('/') || pattern.split('/').includes('..')) {
throw new Error(`Pattern "${pattern}" escapes the base directory`); throw new Error(`Pattern "${pattern}" escapes the base directory`);
} }
} }
async function grepFile(
file: ResolvedFile,
regex: RegExp,
): Promise<Array<{ path: string; lineNumber: number; line: string }>> {
try {
const stat = await fs.stat(file.absolutePath);
if (stat.size > MAX_FILE_SIZE) return [];
const buffer = await fs.readFile(file.absolutePath);
if (isLikelyBinaryContent(buffer)) return [];
const lines = buffer.toString('utf-8').split('\n');
const hits: Array<{ path: string; lineNumber: number; line: string }> = [];
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i])) {
hits.push({ path: file.path, lineNumber: i + 1, line: lines[i].substring(0, 200) });
}
}
return hits;
} catch {
// Unreadable file
return [];
}
}
function escapeRegex(str: string): string { function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/db", "name": "@n8n/db",
"version": "1.32.0", "version": "1.32.1",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo .tmp-schema-docs", "clean": "rimraf dist .turbo .tmp-schema-docs",
"dev": "pnpm watch", "dev": "pnpm watch",

View File

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

View File

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

View File

@ -377,6 +377,72 @@ describe('Integration: ExpressionEvaluator + IsolatedVmBridge', () => {
expect(result).toBe('name,age,city'); expect(result).toBe('name,age,city');
}); });
it('should not leak host references through non-index array access', () => {
// Reading a non-index key like $json.a.constructor must return undefined,
// so a guest can't walk from an array to the host Object prototype.
const data = { $json: { a: [1337] } };
const expression = `{{
(function() {
const hostLookupParams = (() => eval)()(\`(args, res) => {
const _copy = {copy: true};
const _reference = {reference: true};
const params = {};
params.arguments = args === 'copy' ? _copy : _reference;
params.result = res === 'copy' ? _copy : _reference;
return params;
}\`);
const refs = (() => eval)()(\`
(function () { return arguments.callee.caller.caller.caller.arguments })()
\`);
const getArrayElement = refs[1];
var log = [];
function t(label, fn) {
try {
const result = fn();
log.push(label + '=' + result);
return result;
} catch (e) {
log.push(label + ':ERR ' + String(e && e.message ? e.message : e));
}
}
const hostArrayPath = ['$json', 'a'];
const HostArray = t('host array', function() {
return getArrayElement.applySync(null, [hostArrayPath, 'constructor'], hostLookupParams('copy', 'reference'));
});
const HostLookupGetter = t('host lookup getter', function() {
return getArrayElement.applySync(null, [hostArrayPath, '__lookupGetter__'], hostLookupParams('copy', 'reference'));
});
const hostArrayInstance = t('host array instance', function() {
return HostArray.applySync(null, [1], hostLookupParams('copy', 'reference'));
});
const HostProtoGetter = t('host __proto__ getter', function() {
return HostLookupGetter.applySync(hostArrayInstance.derefInto(), ['__proto__'], hostLookupParams('copy', 'reference'));
});
const arrProto = t('host Array prototype', function() {
return HostProtoGetter.applySync(hostArrayInstance.derefInto(), [], hostLookupParams('copy', 'reference'));
});
const objProto = t('host Object prototype', function() {
return HostProtoGetter.applySync(arrProto.derefInto(), [], hostLookupParams('copy', 'reference'));
});
objProto.setSync('win', 1337);
return log;
})();
}}`;
try {
// The bridge swallows the TypeError; what matters is the host Object
// prototype was never mutated.
evaluator.evaluate(expression, data, caller);
expect((Object.prototype as Record<string, unknown>).win).toBeUndefined();
} finally {
delete (Object.prototype as Record<string, unknown>).win;
}
});
it('should preserve error name, message, and custom properties across isolate boundary', () => { it('should preserve error name, message, and custom properties across isolate boundary', () => {
const data = { $json: {} }; const data = { $json: {} };

View File

@ -400,8 +400,21 @@ export class IsolatedVmBridge implements RuntimeBridge {
return undefined; return undefined;
} }
// Only genuine array indices are reachable; anything else (e.g.
// 'constructor', '__lookupGetter__') would read off the prototype
// chain and could leak a host function reference across the boundary.
if (!Number.isInteger(index) || index < 0) {
return undefined;
}
const element = arr[index]; const element = arr[index];
// Functions are never reachable through the data surface — mirror the
// guard in getValueAtPath so a host callable can't cross the boundary.
if (typeof element === 'function') {
return undefined;
}
// Dates have no enumerable own keys; pass through instead of // Dates have no enumerable own keys; pass through instead of
// marshaling as an empty object. // marshaling as an empty object.
if (element instanceof Date) { if (element instanceof Date) {

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/instance-ai", "name": "@n8n/instance-ai",
"version": "1.17.0", "version": "1.17.1",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",

View File

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

View File

@ -2,6 +2,7 @@ import type { InferenceProviderOrPolicy } from '@huggingface/inference';
import { PROVIDERS_OR_POLICIES } from '@huggingface/inference'; import { PROVIDERS_OR_POLICIES } from '@huggingface/inference';
import { HuggingFaceInferenceEmbeddings } from '@langchain/community/embeddings/hf'; import { HuggingFaceInferenceEmbeddings } from '@langchain/community/embeddings/hf';
import { import {
assertCredentialAllowsUrl,
NodeConnectionTypes, NodeConnectionTypes,
NodeOperationError, NodeOperationError,
type INodeType, type INodeType,
@ -104,6 +105,18 @@ export class EmbeddingsHuggingFaceInference implements INodeType {
throw new NodeOperationError(this.getNode(), 'Unsupported provider'); throw new NodeOperationError(this.getNode(), 'Unsupported provider');
} }
if (
'endpointUrl' in options &&
typeof options.endpointUrl === 'string' &&
options.endpointUrl
) {
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: options.endpointUrl,
});
}
const embeddings = new HuggingFaceInferenceEmbeddings({ const embeddings = new HuggingFaceInferenceEmbeddings({
apiKey: credentials.apiKey as string, apiKey: credentials.apiKey as string,
model, model,

View File

@ -0,0 +1,72 @@
import { HuggingFaceInferenceEmbeddings } from '@langchain/community/embeddings/hf';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
import type { Mocked } from 'vitest';
import { EmbeddingsHuggingFaceInference } from '../EmbeddingsHuggingFaceInference/EmbeddingsHuggingFaceInference.node';
vi.mock('@huggingface/inference', () => ({ PROVIDERS_OR_POLICIES: ['auto'] }));
vi.mock('@langchain/community/embeddings/hf');
vi.mock('@n8n/ai-utilities');
describe('EmbeddingsHuggingFaceInference', () => {
let node: EmbeddingsHuggingFaceInference;
const mockNode: INode = {
id: '1',
name: 'Embeddings HuggingFace Inference',
typeVersion: 1,
type: 'n8n-nodes-langchain.embeddingsHuggingFaceInference',
position: [0, 0],
parameters: {},
};
const setup = (credentials: Record<string, unknown>, options: Record<string, unknown>) => {
const ctx = createMockExecuteFunction<ISupplyDataFunctions>(
{},
mockNode,
) as Mocked<ISupplyDataFunctions>;
ctx.getCredentials = vi.fn().mockResolvedValue(credentials);
ctx.getNode = vi.fn().mockReturnValue(mockNode);
ctx.logger = { debug: vi.fn() } as unknown as ISupplyDataFunctions['logger'];
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'modelName') return 'sentence-transformers/distilbert-base-nli-mean-tokens';
if (paramName === 'options') return options;
return undefined;
});
return ctx;
};
beforeEach(() => {
node = new EmbeddingsHuggingFaceInference();
vi.clearAllMocks();
});
it('should reject a custom endpoint URL the credential domain restriction disallows', async () => {
const ctx = setup(
{
apiKey: 'k',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'api-inference.huggingface.co',
},
{ endpointUrl: 'http://127.0.0.1:9099' },
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Domain not allowed');
expect(HuggingFaceInferenceEmbeddings).not.toHaveBeenCalled();
});
it('should allow a custom endpoint URL the credential domain restriction permits', async () => {
const ctx = setup(
{
apiKey: 'k',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'my-endpoint.example.com',
},
{ endpointUrl: 'https://my-endpoint.example.com' },
);
await node.supplyData.call(ctx, 0);
expect(HuggingFaceInferenceEmbeddings).toHaveBeenCalled();
});
});

View File

@ -1,6 +1,6 @@
import { OpenAI, type ClientOptions } from '@langchain/openai'; import { OpenAI, type ClientOptions } from '@langchain/openai';
import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities';
import { NodeConnectionTypes } from 'n8n-workflow'; import { assertCredentialAllowsUrl, NodeConnectionTypes } from 'n8n-workflow';
import type { import type {
INodeType, INodeType,
INodeTypeDescription, INodeTypeDescription,
@ -207,8 +207,17 @@ export class LmOpenAi implements INodeType {
const options = this.getNodeParameter('options', {}) as LmOpenAiOptions; const options = this.getNodeParameter('options', {}) as LmOpenAiOptions;
let uri = 'https://api.openai.com/v1/models'; let uri = 'https://api.openai.com/v1/models';
let allowedDomains: string | undefined;
if (options.baseURL) { 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',
});
uri = `${options.baseURL}/models`; uri = `${options.baseURL}/models`;
} }
@ -216,6 +225,7 @@ export class LmOpenAi implements INodeType {
method: 'GET', method: 'GET',
uri, uri,
json: true, json: true,
allowedDomains,
})) as { data: Array<{ owned_by: string; id: string }> }; })) as { data: Array<{ owned_by: string; id: string }> };
for (const model of data) { for (const model of data) {
@ -264,6 +274,13 @@ export class LmOpenAi implements INodeType {
}; };
if (options.baseURL) { if (options.baseURL) {
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: options.baseURL,
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
surface: 'OpenAI',
});
configuration.baseURL = options.baseURL; configuration.baseURL = options.baseURL;
} }

View File

@ -1,5 +1,6 @@
import { HuggingFaceInference } from '@langchain/community/llms/hf'; import { HuggingFaceInference } from '@langchain/community/llms/hf';
import { import {
assertCredentialAllowsUrl,
NodeConnectionTypes, NodeConnectionTypes,
type INodeType, type INodeType,
type INodeTypeDescription, type INodeTypeDescription,
@ -138,7 +139,15 @@ export class LmOpenHuggingFaceInference implements INodeType {
const credentials = await this.getCredentials('huggingFaceApi'); const credentials = await this.getCredentials('huggingFaceApi');
const modelName = this.getNodeParameter('model', itemIndex) as string; const modelName = this.getNodeParameter('model', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as object; const options = this.getNodeParameter('options', itemIndex, {}) as { endpointUrl?: string };
if (options.endpointUrl) {
assertCredentialAllowsUrl({
node: this.getNode(),
credentialData: credentials,
url: options.endpointUrl,
});
}
// LangChain does not yet support specifying Provider // LangChain does not yet support specifying Provider
// That's why mistral's model is the default value // That's why mistral's model is the default value

View File

@ -2,7 +2,7 @@
import { OpenAI } from '@langchain/openai'; import { OpenAI } from '@langchain/openai';
import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities'; import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import type { INode, ISupplyDataFunctions } from 'n8n-workflow'; import type { ILoadOptionsFunctions, INode, ISupplyDataFunctions } from 'n8n-workflow';
import type { Mocked } from 'vitest'; import type { Mocked } from 'vitest';
import { LmOpenAi } from '../LMOpenAi/LmOpenAi.node'; import { LmOpenAi } from '../LMOpenAi/LmOpenAi.node';
@ -91,5 +91,102 @@ describe('LmOpenAi', () => {
redactedHeaders: ['x-custom-header'], redactedHeaders: ['x-custom-header'],
}); });
}); });
it('should reject a baseURL override that the credential domain restriction disallows', async () => {
const mockContext = setupMockContext({
apiKey: 'test-api-key',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'api.openai.com',
});
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'model') return 'gpt-3.5-turbo-instruct';
if (paramName === 'options') return { baseURL: 'http://127.0.0.1:9099/v1' };
return undefined;
});
await expect(lmOpenAi.supplyData.call(mockContext, 0)).rejects.toThrow('Domain not allowed');
expect(OpenAI).not.toHaveBeenCalled();
});
it('should allow a baseURL override that the credential domain restriction permits', async () => {
const mockContext = setupMockContext({
apiKey: 'test-api-key',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'api.openai.com',
});
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'model') return 'gpt-3.5-turbo-instruct';
if (paramName === 'options') return { baseURL: 'https://api.openai.com/v1' };
return undefined;
});
await lmOpenAi.supplyData.call(mockContext, 0);
expect(OpenAI).toHaveBeenCalled();
});
});
describe('openAiModelSearch', () => {
const setupMockLoadContext = (
credentials: Record<string, unknown>,
options: Record<string, unknown>,
) => {
const requestWithAuthentication = vi.fn().mockResolvedValue({ data: [] });
return {
getCredentials: vi.fn().mockResolvedValue(credentials),
getNode: vi.fn().mockReturnValue(mockNode),
getNodeParameter: vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'options') return options;
return undefined;
}),
helpers: {
requestWithAuthentication,
},
} as unknown as Mocked<ILoadOptionsFunctions> & {
helpers: { requestWithAuthentication: typeof requestWithAuthentication };
};
};
it('should not send credentials to a domain the credential restricts', async () => {
const mockLoadContext = setupMockLoadContext(
{
apiKey: 'test-api-key',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'api.openai.com',
},
{ baseURL: 'http://127.0.0.1:9099/v1' },
);
await expect(
lmOpenAi.methods.listSearch.openAiModelSearch.call(mockLoadContext),
).rejects.toThrow('Domain not allowed');
expect(mockLoadContext.helpers.requestWithAuthentication).not.toHaveBeenCalled();
});
it('should forward the allowed domains to the request when the base URL is on the allowlist', async () => {
const mockLoadContext = setupMockLoadContext(
{
apiKey: 'test-api-key',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'api.openai.com',
},
{ baseURL: 'https://api.openai.com/v1' },
);
mockLoadContext.helpers.requestWithAuthentication.mockResolvedValue({
data: [{ id: 'gpt-4', owned_by: 'system' }],
});
await expect(
lmOpenAi.methods.listSearch.openAiModelSearch.call(mockLoadContext),
).resolves.toBeDefined();
expect(mockLoadContext.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'openAiApi',
expect.objectContaining({
uri: 'https://api.openai.com/v1/models',
allowedDomains: 'api.openai.com',
}),
);
});
}); });
}); });

View File

@ -0,0 +1,71 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { HuggingFaceInference } from '@langchain/community/llms/hf';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
import type { Mocked } from 'vitest';
import { LmOpenHuggingFaceInference } from '../LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node';
vi.mock('@langchain/community/llms/hf');
vi.mock('@n8n/ai-utilities');
describe('LmOpenHuggingFaceInference', () => {
let node: LmOpenHuggingFaceInference;
const mockNode: INode = {
id: '1',
name: 'Hugging Face Inference Model',
typeVersion: 1,
type: 'n8n-nodes-langchain.lmOpenHuggingFaceInference',
position: [0, 0],
parameters: {},
};
const setup = (credentials: Record<string, unknown>, options: Record<string, unknown>) => {
const ctx = createMockExecuteFunction<ISupplyDataFunctions>(
{},
mockNode,
) as Mocked<ISupplyDataFunctions>;
ctx.getCredentials = vi.fn().mockResolvedValue(credentials);
ctx.getNode = vi.fn().mockReturnValue(mockNode);
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'model') return 'mistralai/Mistral-Nemo-Base-2407';
if (paramName === 'options') return options;
return undefined;
});
return ctx;
};
beforeEach(() => {
node = new LmOpenHuggingFaceInference();
vi.clearAllMocks();
});
it('should reject a custom endpoint URL the credential domain restriction disallows', async () => {
const ctx = setup(
{
apiKey: 'k',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'api-inference.huggingface.co',
},
{ endpointUrl: 'http://127.0.0.1:9099' },
);
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Domain not allowed');
expect(HuggingFaceInference).not.toHaveBeenCalled();
});
it('should allow a custom endpoint URL the credential domain restriction permits', async () => {
const ctx = setup(
{
apiKey: 'k',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'my-endpoint.example.com',
},
{ endpointUrl: 'https://my-endpoint.example.com' },
);
await node.supplyData.call(ctx, 0);
expect(HuggingFaceInference).toHaveBeenCalled();
});
});

View File

@ -311,10 +311,15 @@ describe('McpClientTool', () => {
// Verify the eventSourceInit fetch injects auth headers and Accept header // Verify the eventSourceInit fetch injects auth headers and Accept header
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch; const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
await customFetch?.(url, {} as any); await customFetch?.(url, {} as any);
expect(mockedProxyFetch).toHaveBeenCalledWith(url, { expect(mockedProxyFetch).toHaveBeenCalledWith(
headers: { Accept: 'text/event-stream', 'my-header': 'header-value' }, url,
redirect: 'manual', {
}); headers: { Accept: 'text/event-stream', 'my-header': 'header-value' },
redirect: 'manual',
},
undefined,
undefined,
);
}); });
it('should support bearer auth', async () => { it('should support bearer auth', async () => {
@ -363,10 +368,15 @@ describe('McpClientTool', () => {
// Verify the eventSourceInit fetch injects auth headers and Accept header // Verify the eventSourceInit fetch injects auth headers and Accept header
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch; const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
await customFetch?.(url, {} as any); await customFetch?.(url, {} as any);
expect(mockedProxyFetch).toHaveBeenCalledWith(url, { expect(mockedProxyFetch).toHaveBeenCalledWith(
headers: { Accept: 'text/event-stream', Authorization: 'Bearer my-token' }, url,
redirect: 'manual', {
}); headers: { Accept: 'text/event-stream', Authorization: 'Bearer my-token' },
redirect: 'manual',
},
undefined,
undefined,
);
}); });
it('should successfully execute a tool', async () => { it('should successfully execute a tool', async () => {

View File

@ -2,7 +2,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { proxyFetch } from '@n8n/ai-utilities'; import { proxyFetch } from '@n8n/ai-utilities';
import type { IExecuteFunctions, INode } from 'n8n-workflow'; import { createResultError, createResultOk } from '@n8n/utils/result';
import type { IExecuteFunctions, INode, NodeEgressFilter } from 'n8n-workflow';
import type { Mock, MockedClass, MockedFunction } from 'vitest'; import type { Mock, MockedClass, MockedFunction } from 'vitest';
import { mockDeep } from 'vitest-mock-extended'; import { mockDeep } from 'vitest-mock-extended';
import { expect } from 'vitest'; import { expect } from 'vitest';
@ -10,6 +11,7 @@ import { expect } from 'vitest';
import type { McpAuthenticationOption } from '../types'; import type { McpAuthenticationOption } from '../types';
import { import {
connectMcpClient, connectMcpClient,
connectMcpClientForCredential,
getAuthHeaders, getAuthHeaders,
mapToNodeOperationError, mapToNodeOperationError,
tryRefreshOAuth2Token, tryRefreshOAuth2Token,
@ -675,6 +677,69 @@ describe('utils', () => {
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(mockedProxyFetch).toHaveBeenCalledTimes(1); expect(mockedProxyFetch).toHaveBeenCalledTimes(1);
}); });
it('should block requests to a target rejected by the instance egress filter', async () => {
mockClient.connect.mockResolvedValue(undefined);
mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));
const secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultError(new Error('Egress blocked'))),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
};
const ctx = mockDeep<IExecuteFunctions>();
ctx.getNode.mockReturnValue({ type: 'test-client', typeVersion: 1 } as unknown as INode);
ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter);
const result = await connectMcpClientForCredential(ctx, {
authentication: 'none',
serverTransport: transport,
endpointUrl: 'https://blocked.example.com/',
surface: 'MCP Client Tool',
});
expect(result.ok).toBe(true);
const [, opts] = (TransportClass as Mock).mock.calls[0];
// The egress filter must reject the target before any request is sent.
await expect(opts.fetch('https://blocked.example.com/', {})).rejects.toThrow(
'Egress blocked',
);
expect(egressFilter.validateUrl).toHaveBeenCalledWith('https://blocked.example.com/');
expect(mockedProxyFetch).not.toHaveBeenCalled();
});
it('should allow requests to a target accepted by the instance egress filter', async () => {
mockClient.connect.mockResolvedValue(undefined);
mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));
const secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
};
const ctx = mockDeep<IExecuteFunctions>();
ctx.getNode.mockReturnValue({ type: 'test-client', typeVersion: 1 } as unknown as INode);
ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter);
const result = await connectMcpClientForCredential(ctx, {
authentication: 'none',
serverTransport: transport,
endpointUrl: 'https://mcp.example.com/',
surface: 'MCP Client Tool',
});
expect(result.ok).toBe(true);
const [, opts] = (TransportClass as Mock).mock.calls[0];
await opts.fetch('https://mcp.example.com/', {});
expect(egressFilter.validateUrl).toHaveBeenCalledWith('https://mcp.example.com/');
expect(mockedProxyFetch).toHaveBeenCalledTimes(1);
});
}); });
}); });

View File

@ -10,6 +10,7 @@ import type {
ILoadOptionsFunctions, ILoadOptionsFunctions,
INode, INode,
ISupplyDataFunctions, ISupplyDataFunctions,
NodeEgressFilter,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { assertCredentialAllowsUrl, assertUrlAllowed, NodeOperationError } from 'n8n-workflow'; import { assertCredentialAllowsUrl, assertUrlAllowed, NodeOperationError } from 'n8n-workflow';
@ -137,6 +138,7 @@ export async function connectMcpClient({
onUnauthorized, onUnauthorized,
signal, signal,
allowedDomains, allowedDomains,
secureEgressFilter,
}: { }: {
serverTransport: McpServerTransport; serverTransport: McpServerTransport;
endpointUrl: string; endpointUrl: string;
@ -150,6 +152,12 @@ export async function connectMcpClient({
* (including redirect hops) is validated against it via `assertUrlAllowed`. * (including redirect hops) is validated against it via `assertUrlAllowed`.
*/ */
allowedDomains?: string; allowedDomains?: string;
/**
* Instance egress filter. When set, every request (including redirect hops)
* is validated against the configured egress policy, and the connection is
* pinned to the validated address.
*/
secureEgressFilter?: NodeEgressFilter;
}): Promise<Result<Client, ConnectMcpClientError>> { }): Promise<Result<Client, ConnectMcpClientError>> {
const endpoint = normalizeAndValidateUrl(endpointUrl); const endpoint = normalizeAndValidateUrl(endpointUrl);
@ -157,7 +165,7 @@ export async function connectMcpClient({
return createResultError({ type: 'invalid_url', error: endpoint.error }); return createResultError({ type: 'invalid_url', error: endpoint.error });
} }
const authFetch = createAuthFetch(headers, onUnauthorized, allowedDomains); const authFetch = createAuthFetch(headers, onUnauthorized, allowedDomains, secureEgressFilter);
const client = new Client({ name, version: version.toString() }, { capabilities: {} }); const client = new Client({ name, version: version.toString() }, { capabilities: {} });
let onAbort: (() => void) | undefined; let onAbort: (() => void) | undefined;
@ -276,23 +284,30 @@ function headersToRecord(headers: HeadersInit | undefined): Record<string, strin
* - injects auth headers into every request, * - injects auth headers into every request,
* - retries once on 401 after refreshing the token via onUnauthorized, * - retries once on 401 after refreshing the token via onUnauthorized,
* - validates the initial URL and every redirect hop against `allowedDomains` * - validates the initial URL and every redirect hop against `allowedDomains`
* so credentials are never sent to a host the credential doesn't allow. * so credentials are never sent to a host the credential doesn't allow,
* - validates the initial URL and every redirect hop against the instance
* `secureEgressFilter`, and pins the connection to the validated address.
*/ */
function createAuthFetch( function createAuthFetch(
initialHeaders: Record<string, string> | undefined, initialHeaders: Record<string, string> | undefined,
onUnauthorized?: OnUnauthorizedHandler, onUnauthorized?: OnUnauthorizedHandler,
allowedDomains?: string, allowedDomains?: string,
secureEgressFilter?: NodeEgressFilter,
): typeof fetch { ): typeof fetch {
let headers = initialHeaders; let headers = initialHeaders;
const secureLookup = secureEgressFilter?.createSecureLookup();
const doFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
await proxyFetch(
input,
{ ...init, headers: { ...headersToRecord(init?.headers), ...headers } },
undefined,
secureLookup,
);
const authedFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { const authedFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const response = await proxyFetch(input, { const response = await doFetch(input, init);
...init,
headers: {
...headersToRecord(init?.headers),
...headers,
},
});
if (response.status !== 401 || !onUnauthorized) { if (response.status !== 401 || !onUnauthorized) {
return response; return response;
@ -304,13 +319,7 @@ function createAuthFetch(
} }
headers = refreshedHeaders; headers = refreshedHeaders;
return await proxyFetch(input, { return await doFetch(input, init);
...init,
headers: {
...headersToRecord(init?.headers),
...headers,
},
});
}; };
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
@ -318,7 +327,13 @@ function createAuthFetch(
// unwrapped to their URL so the redirect loop can carry a stable input. // unwrapped to their URL so the redirect loop can carry a stable input.
const startUrl = input instanceof Request ? input.url : input; const startUrl = input instanceof Request ? input.url : input;
return await fetchFollowingRedirects(authedFetch, startUrl, init, { return await fetchFollowingRedirects(authedFetch, startUrl, init, {
onBeforeHop: (hopUrl) => assertUrlAllowed({ url: hopUrl, allowedDomains }), onBeforeHop: async (hopUrl) => {
assertUrlAllowed({ url: hopUrl, allowedDomains });
if (secureEgressFilter) {
const result = await secureEgressFilter.validateUrl(hopUrl);
if (!result.ok) throw result.error;
}
},
}); });
}; };
} }
@ -473,6 +488,7 @@ export async function connectMcpClientForCredential(
endpointUrl: config.endpointUrl, endpointUrl: config.endpointUrl,
headers, headers,
allowedDomains, allowedDomains,
secureEgressFilter: ctx.helpers.getSecureEgressFilter?.(),
name: node.type, name: node.type,
version: node.typeVersion, version: node.typeVersion,
onUnauthorized: async (h) => await tryRefreshOAuth2Token(ctx, config.authentication, h), onUnauthorized: async (h) => await tryRefreshOAuth2Token(ctx, config.authentication, h),

View File

@ -238,6 +238,81 @@ describe('ToolHttpRequest', () => {
); );
}); });
it('should not send generic credentials to a domain the credential restricts', async () => {
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return 'http://attacker.example/exfil';
case 'authentication':
return 'genericCredentialType';
case 'genericAuthType':
return 'httpHeaderAuth';
case 'options':
return {};
case 'placeholderDefinitions.values':
return [];
default:
return undefined;
}
});
executeFunctions.getCredentials.mockResolvedValue({
name: 'X-Secret-Token',
value: 'SECRET-TOOLHTTP-CANARY',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'api.example.com',
});
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
const res = await (response as N8nTool).invoke({});
expect(helpers.httpRequest).not.toHaveBeenCalled();
expect(res).toContain('Domain not allowed');
});
it('should send generic credentials to a domain the credential allows', async () => {
helpers.httpRequest.mockResolvedValue({
body: 'Hello World',
headers: { 'content-type': 'text/plain' },
});
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return 'https://api.example.com/data';
case 'authentication':
return 'genericCredentialType';
case 'genericAuthType':
return 'httpHeaderAuth';
case 'options':
return {};
case 'placeholderDefinitions.values':
return [];
default:
return undefined;
}
});
executeFunctions.getCredentials.mockResolvedValue({
name: 'X-Secret-Token',
value: 'SECRET-TOOLHTTP-CANARY',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'api.example.com',
});
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
const res = await (response as N8nTool).invoke({});
expect(helpers.httpRequest).toHaveBeenCalledWith(
expect.objectContaining({ allowedDomains: 'api.example.com' }),
);
expect(res).toEqual('Hello World');
});
it('should return the error when receiving text that contains a null character', async () => { it('should return the error when receiving text that contains a null character', async () => {
helpers.httpRequest.mockResolvedValue({ helpers.httpRequest.mockResolvedValue({
body: 'Hello\0World', body: 'Hello\0World',

View File

@ -13,8 +13,14 @@ import type {
ExecutionError, ExecutionError,
NodeApiError, NodeApiError,
ISupplyDataFunctions, ISupplyDataFunctions,
ICredentialDataDecryptedObject,
} from 'n8n-workflow';
import {
assertCredentialAllowsUrl,
NodeConnectionTypes,
NodeOperationError,
jsonParse,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError, jsonParse } from 'n8n-workflow';
import { z } from 'zod'; import { z } from 'zod';
import type { import type {
@ -27,6 +33,24 @@ import type {
} from './interfaces'; } from './interfaces';
import type { DynamicZodObject } from '../../../types/zod.types'; import type { DynamicZodObject } from '../../../types/zod.types';
// Enforce the credential's "Allowed HTTP Request Domains" restriction against the
// request URL before the secret is attached, so a tool-controlled URL can't leak it.
// The resulting allowlist is forwarded onto the options so the HTTP layer re-checks
// every redirect hop too, not just the initial URL.
const assertCredentialUrlAllowed = (
ctx: ISupplyDataFunctions,
credentials: ICredentialDataDecryptedObject | undefined,
options: IHttpRequestOptions,
) => {
if (!credentials) return;
const allowedDomains = assertCredentialAllowsUrl({
node: ctx.getNode(),
credentialData: credentials,
url: options.url,
});
if (allowedDomains) options.allowedDomains = allowedDomains;
};
const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: number) => { const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: number) => {
const genericType = ctx.getNodeParameter('genericAuthType', itemIndex) as string; const genericType = ctx.getNodeParameter('genericAuthType', itemIndex) as string;
@ -35,6 +59,7 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
const sendImmediately = genericType === 'httpDigestAuth' ? false : undefined; const sendImmediately = genericType === 'httpDigestAuth' ? false : undefined;
return async (options: IHttpRequestOptions) => { return async (options: IHttpRequestOptions) => {
assertCredentialUrlAllowed(ctx, basicAuth, options);
options.auth = { options.auth = {
username: basicAuth.user as string, username: basicAuth.user as string,
password: basicAuth.password as string, password: basicAuth.password as string,
@ -48,6 +73,7 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
const headerAuth = await ctx.getCredentials('httpHeaderAuth', itemIndex); const headerAuth = await ctx.getCredentials('httpHeaderAuth', itemIndex);
return async (options: IHttpRequestOptions) => { return async (options: IHttpRequestOptions) => {
assertCredentialUrlAllowed(ctx, headerAuth, options);
if (!options.headers) options.headers = {}; if (!options.headers) options.headers = {};
options.headers[headerAuth.name as string] = headerAuth.value; options.headers[headerAuth.name as string] = headerAuth.value;
return await ctx.helpers.httpRequest(options); return await ctx.helpers.httpRequest(options);
@ -58,6 +84,7 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
const queryAuth = await ctx.getCredentials('httpQueryAuth', itemIndex); const queryAuth = await ctx.getCredentials('httpQueryAuth', itemIndex);
return async (options: IHttpRequestOptions) => { return async (options: IHttpRequestOptions) => {
assertCredentialUrlAllowed(ctx, queryAuth, options);
if (!options.qs) options.qs = {}; if (!options.qs) options.qs = {};
options.qs[queryAuth.name as string] = queryAuth.value; options.qs[queryAuth.name as string] = queryAuth.value;
return await ctx.helpers.httpRequest(options); return await ctx.helpers.httpRequest(options);
@ -68,6 +95,7 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
const customAuth = await ctx.getCredentials('httpCustomAuth', itemIndex); const customAuth = await ctx.getCredentials('httpCustomAuth', itemIndex);
return async (options: IHttpRequestOptions) => { return async (options: IHttpRequestOptions) => {
assertCredentialUrlAllowed(ctx, customAuth, options);
const auth = jsonParse<IRequestOptionsSimplified>((customAuth.json as string) || '{}', { const auth = jsonParse<IRequestOptionsSimplified>((customAuth.json as string) || '{}', {
errorMessage: 'Invalid Custom Auth JSON', errorMessage: 'Invalid Custom Auth JSON',
}); });
@ -85,13 +113,17 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
} }
if (genericType === 'oAuth1Api') { if (genericType === 'oAuth1Api') {
const oAuth1 = await ctx.getCredentials('oAuth1Api', itemIndex);
return async (options: IHttpRequestOptions) => { return async (options: IHttpRequestOptions) => {
assertCredentialUrlAllowed(ctx, oAuth1, options);
return await ctx.helpers.requestOAuth1.call(ctx, 'oAuth1Api', options); return await ctx.helpers.requestOAuth1.call(ctx, 'oAuth1Api', options);
}; };
} }
if (genericType === 'oAuth2Api') { if (genericType === 'oAuth2Api') {
const oAuth2 = await ctx.getCredentials('oAuth2Api', itemIndex);
return async (options: IHttpRequestOptions) => { return async (options: IHttpRequestOptions) => {
assertCredentialUrlAllowed(ctx, oAuth2, options);
return await ctx.helpers.requestOAuth2.call(ctx, 'oAuth2Api', options, { return await ctx.helpers.requestOAuth2.call(ctx, 'oAuth2Api', options, {
tokenType: 'Bearer', tokenType: 'Bearer',
}); });
@ -106,8 +138,10 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
const predefinedCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: number) => { const predefinedCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: number) => {
const predefinedType = ctx.getNodeParameter('nodeCredentialType', itemIndex) as string; const predefinedType = ctx.getNodeParameter('nodeCredentialType', itemIndex) as string;
const additionalOptions = getOAuth2AdditionalParameters(predefinedType); const additionalOptions = getOAuth2AdditionalParameters(predefinedType);
const credentials = await ctx.getCredentials(predefinedType, itemIndex);
return async (options: IHttpRequestOptions) => { return async (options: IHttpRequestOptions) => {
assertCredentialUrlAllowed(ctx, credentials, options);
return await ctx.helpers.httpRequestWithAuthentication.call( return await ctx.helpers.httpRequestWithAuthentication.call(
ctx, ctx,
predefinedType, predefinedType,

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/n8n-nodes-langchain", "name": "@n8n/n8n-nodes-langchain",
"version": "2.32.0", "version": "2.32.2",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"exports": { "exports": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/scan-community-package", "name": "@n8n/scan-community-package",
"version": "0.29.0", "version": "0.29.1",
"description": "Static code analyser for n8n community packages", "description": "Static code analyser for n8n community packages",
"license": "LicenseRef-n8n-sustainable-use", "license": "LicenseRef-n8n-sustainable-use",
"bin": "scanner/cli.mjs", "bin": "scanner/cli.mjs",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/scheduler", "name": "@n8n/scheduler",
"version": "1.1.0", "version": "1.1.1",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/task-runner", "name": "@n8n/task-runner",
"version": "2.32.0", "version": "2.32.1",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"start": "node dist/start.js", "start": "node dist/start.js",

View File

@ -1,6 +1,12 @@
import type * as nodeCrypto from 'node:crypto';
import { ExecutionError } from '@/js-task-runner/errors/execution-error'; import { ExecutionError } from '@/js-task-runner/errors/execution-error';
import { createRequireResolver, type RequireResolverOpts } from '../require-resolver'; import {
createRequireResolver,
secureModuleExport,
type RequireResolverOpts,
} from '../require-resolver';
describe('require resolver', () => { describe('require resolver', () => {
let defaultOpts: RequireResolverOpts; let defaultOpts: RequireResolverOpts;
@ -59,6 +65,194 @@ describe('require resolver', () => {
}); });
}); });
describe('module securing', () => {
it('should not let one resolution mutate the shared module for another', () => {
const resolver = createRequireResolver({ ...defaultOpts, secureModules: true });
const first = resolver('path') as { normalize: unknown };
const original = first.normalize;
// Own-property reassignment on the shared cached module must not stick.
expect(() => {
'use strict';
first.normalize = () => 'poisoned';
}).toThrow();
const second = resolver('path') as { normalize: unknown };
expect(second.normalize).toBe(original);
expect((second.normalize as (p: string) => string)('a//b')).toBe('a/b');
});
it('should return the same view for repeated resolutions (stable identity)', () => {
const resolver = createRequireResolver({ ...defaultOpts, secureModules: true });
expect(resolver('path')).toBe(resolver('path'));
});
it('should not secure modules by default', () => {
const resolver = createRequireResolver(defaultOpts);
const fs = resolver('fs') as Record<string, unknown>;
const marker = () => 'noop';
fs.__probe = marker;
expect(fs.__probe).toBe(marker);
delete fs.__probe;
});
});
describe('secureModuleExport', () => {
it('should not throw when securing a non-freezable Buffer/TypedArray export', () => {
// Object.freeze throws on these ("Cannot freeze array buffer views with elements").
const buf = Buffer.from([1, 2, 3]);
const view = secureModuleExport(buf) as Buffer;
expect(view[0]).toBe(1); // reads pass through
expect(() => {
'use strict';
view[0] = 9;
}).toThrow(); // writes are blocked
expect(buf[0]).toBe(1); // underlying buffer untouched
});
it('should block assignment through accessor setters', () => {
let sideEffect = 0;
const mod = {};
Object.defineProperty(mod, 'danger', {
get: () => sideEffect,
set: (v: number) => {
sideEffect = v;
},
configurable: true,
});
const secured = secureModuleExport(mod) as { danger: number };
// Strict-mode module code: the blocked write throws instead of the setter running.
expect(() => {
secured.danger = 99;
}).toThrow();
expect(sideEffect).toBe(0); // the real setter never ran
});
it('should block adding, redefining, deleting properties and swapping the prototype', () => {
const mod: Record<string, unknown> = { existing: 1 };
const secured = secureModuleExport(mod) as Record<string, unknown>;
expect(() => Object.defineProperty(secured, 'added', { value: 1 })).toThrow();
expect(Reflect.deleteProperty(secured, 'existing')).toBe(false);
expect(Reflect.setPrototypeOf(secured, {})).toBe(false);
expect(mod.existing).toBe(1);
});
it('should keep a real module callable and usable through the view', () => {
const resolver = createRequireResolver({
allowedBuiltInModules: new Set(['crypto']),
allowedExternalModules: new Set(),
secureModules: true,
});
const crypto = resolver('crypto') as typeof nodeCrypto;
expect(crypto.randomBytes(8)).toHaveLength(8);
expect(crypto.createHash('sha256').update('x').digest('hex')).toHaveLength(64);
// poisoning attempts must not stick
expect(() => {
(crypto as { randomBytes: unknown }).randomBytes = () => Buffer.from('PWNED');
}).toThrow();
expect(crypto.randomBytes(8)).toHaveLength(8);
});
});
describe('secureModuleExport (nested membrane)', () => {
it('should wrap nested objects and block writes to them', () => {
const shared = { count: 0 };
const secured = secureModuleExport({ nested: shared }) as { nested: { count: number } };
expect(() => {
secured.nested.count = 5;
}).toThrow();
expect(shared.count).toBe(0); // shared nested state untouched
});
it('should return a stable wrapped view for the same nested object', () => {
const secured = secureModuleExport({ nested: {} }) as { nested: object };
expect(secured.nested).toBe(secured.nested);
});
it('should freeze (not wrap) non-configurable non-writable data properties', () => {
const mod = {};
Object.defineProperty(mod, 'FIXED', {
value: { a: 1 },
writable: false,
configurable: false,
});
const secured = secureModuleExport(mod) as { FIXED: { a: number } };
// Proxy invariant forbids wrapping, so it's returned raw — but frozen,
// so it still can't be mutated for other tasks.
expect(secured.FIXED.a).toBe(1);
expect(Object.isFrozen(secured.FIXED)).toBe(true);
expect(() => {
(secured.FIXED as Record<string, unknown>).evil = 1;
}).toThrow();
});
it('should wrap values reached through descriptor reflection', () => {
const shared = { count: 0 };
const secured = secureModuleExport({ nested: shared });
const descriptor = Object.getOwnPropertyDescriptor(secured, 'nested');
const reflected = descriptor?.value as { count: number };
expect(() => {
reflected.count = 5;
}).toThrow();
expect(shared.count).toBe(0); // reflection can't bypass the membrane
});
it('should construct classes exposed by a module', () => {
const secured = secureModuleExport({
widget: class {
constructor(public v: number) {}
},
}) as { widget: new (v: number) => { v: number } };
expect(new secured.widget(7).v).toBe(7);
});
it('should preserve new.target when subclassing a module constructor', () => {
const secured = secureModuleExport({
base: class {
kind: string;
constructor() {
this.kind = new.target.name;
}
},
}) as { base: new () => { kind: string } };
class Derived extends secured.base {}
expect(new Derived().kind).toBe('Derived');
});
it('should keep nested real-module state readable but not mutable', () => {
const resolver = createRequireResolver({
allowedBuiltInModules: new Set(['crypto']),
allowedExternalModules: new Set(),
secureModules: true,
});
const crypto = resolver('crypto') as typeof nodeCrypto;
// nested reads and receiver-sensitive method calls still work
expect(typeof crypto.constants.RSA_PKCS1_PADDING).toBe('number');
expect(crypto.webcrypto.getRandomValues(new Uint8Array(4))).toHaveLength(4);
// nested writes are blocked
expect(() => {
(crypto.webcrypto as { getRandomValues: unknown }).getRandomValues = () => null;
}).toThrow();
expect(crypto.webcrypto.getRandomValues(new Uint8Array(2))).toHaveLength(2);
});
});
describe('error handling', () => { describe('error handling', () => {
it('should wrap DisallowedModuleError in ExecutionError', () => { it('should wrap DisallowedModuleError in ExecutionError', () => {
const resolver = createRequireResolver(defaultOpts); const resolver = createRequireResolver(defaultOpts);

View File

@ -157,6 +157,7 @@ export class JsTaskRunner extends TaskRunner {
this.requireResolver = createRequireResolver({ this.requireResolver = createRequireResolver({
allowedBuiltInModules, allowedBuiltInModules,
allowedExternalModules, allowedExternalModules,
secureModules: this.mode === 'secure',
}); });
if (this.mode === 'secure') this.preventPrototypePollution(allowedExternalModules); if (this.mode === 'secure') this.preventPrototypePollution(allowedExternalModules);

View File

@ -15,13 +15,105 @@ export type RequireResolverOpts = {
* execution sandbox. `"*"` means all are allowed. * execution sandbox. `"*"` means all are allowed.
*/ */
allowedExternalModules: Set<string> | '*'; allowedExternalModules: Set<string> | '*';
/**
* When true, return a write-blocking view of each resolved module. The
* module cache is shared across every task in the runner process, so an
* unprotected module object lets one task's changes leak into other tasks.
*/
secureModules?: boolean;
}; };
export type RequireResolver = (request: string) => unknown; export type RequireResolver = (request: string) => unknown;
type Constructor = new (...args: unknown[]) => object;
const isWrappable = (value: unknown): value is object =>
value !== null && (typeof value === 'object' || typeof value === 'function');
// Views (write-blocking proxies) keyed by their real target, plus the reverse
// lookup used to unwrap `this`/arguments back to the real object before a
// wrapped function runs. One view per target keeps identity stable across reads.
const viewByTarget = new WeakMap<object, unknown>();
const targetByView = new WeakMap<object, object>();
const unwrap = (value: unknown): unknown =>
isWrappable(value) ? (targetByView.get(value) ?? value) : value;
// A non-configurable, non-writable data property: a Proxy must hand back its
// exact value (invariant), so it can't be wrapped.
const isFixedData = (descriptor: PropertyDescriptor | undefined): boolean =>
!!descriptor && 'value' in descriptor && !descriptor.configurable && !descriptor.writable;
// Secure a value read off the module. Normally wrap it in the membrane; when an
// invariant forces us to return the exact object, freeze it best-effort instead
// so it still can't be mutated for other tasks.
function secureReadValue(value: unknown, mustReturnRaw: boolean): unknown {
if (!mustReturnRaw) return secureModuleExport(value);
if (isWrappable(value) && !Object.isFrozen(value)) {
try {
Object.freeze(value);
} catch {
// Non-freezable (e.g. a populated TypedArray) — nothing more we can do.
}
}
return value;
}
// Blocks every write (assignment incl. accessor setters, (re)definition,
// deletion, prototype change) and wraps values read from the module — via both
// property reads and descriptor reflection — so nested objects can't be mutated
// either. Function calls forward to the real module with `this`/arguments
// unwrapped, so internal-slot/brand checks still pass.
const membraneHandler: ProxyHandler<object> = {
set: () => false,
defineProperty: () => false,
deleteProperty: () => false,
setPrototypeOf: () => false,
get(target, prop) {
const value = Reflect.get(target, prop, target) as unknown;
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
return secureReadValue(value, isFixedData(descriptor));
},
getOwnPropertyDescriptor(target, prop) {
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
if (descriptor && 'value' in descriptor) {
descriptor.value = secureReadValue(descriptor.value, isFixedData(descriptor));
}
return descriptor;
},
apply: (target, thisArg, args) =>
Reflect.apply(target as (...a: unknown[]) => unknown, unwrap(thisArg), args.map(unwrap)),
// Return values are left unwrapped so callers keep ownership of what a
// module hands back (e.g. a Buffer they can still mutate).
construct: (target, args, newTarget) =>
Reflect.construct(target as Constructor, args.map(unwrap), unwrap(newTarget) as Constructor),
};
/**
* Wrap a resolved module in a write-blocking membrane. Proxying rather than
* `Object.freeze` avoids two problems: freezing throws on non-freezable
* exports (e.g. a non-empty `Buffer`/`TypedArray`), and it leaves accessor
* setters (e.g. `crypto.fips`) able to mutate shared process state. The
* membrane also wraps nested objects on read, so one task cannot mutate a
* module's nested state (e.g. `http.globalAgent`) for the others.
*/
export function secureModuleExport(resolved: unknown): unknown {
if (!isWrappable(resolved)) return resolved;
const cached = viewByTarget.get(resolved);
if (cached !== undefined) return cached;
const view = new Proxy(resolved, membraneHandler);
viewByTarget.set(resolved, view);
targetByView.set(view, resolved);
return view;
}
export function createRequireResolver({ export function createRequireResolver({
allowedBuiltInModules, allowedBuiltInModules,
allowedExternalModules, allowedExternalModules,
secureModules = false,
}: RequireResolverOpts) { }: RequireResolverOpts) {
return (request: string) => { return (request: string) => {
const checkIsAllowed = (allowList: Set<string> | '*', moduleName: string) => { const checkIsAllowed = (allowList: Set<string> | '*', moduleName: string) => {
@ -37,6 +129,9 @@ export function createRequireResolver({
throw new ExecutionError(error); throw new ExecutionError(error);
} }
return require(request) as unknown; // eslint-disable-next-line @typescript-eslint/no-require-imports
const resolved = require(request) as unknown;
return secureModules ? secureModuleExport(resolved) : resolved;
}; };
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/tournament", "name": "@n8n/tournament",
"version": "1.8.0", "version": "1.8.1",
"description": "Output compatible rewrite of riot tmpl", "description": "Output compatible rewrite of riot tmpl",
"main": "dist/index.js", "main": "dist/index.js",
"module": "src/index.ts", "module": "src/index.ts",

View File

@ -109,6 +109,15 @@ const customPatches: Partial<Record<ParentKind['type'], CustomPatcher>> = {
polyfillVar(path, dataNode); polyfillVar(path, dataNode);
} }
}, },
ArrowFunctionExpression(path, parent: namedTypes.ArrowFunctionExpression, dataNode) {
// A concise arrow body that is a bare identifier (`() => process`) must be
// routed through the data context like any other free read. Params are not
// the body, and body identifiers that reference a param are left alone by
// polyfillVar's in-scope check.
if (parent.body === path.node) {
polyfillVar(path, dataNode);
}
},
}; };
export const jsVariablePolyfill = ( export const jsVariablePolyfill = (
@ -134,6 +143,7 @@ export const jsVariablePolyfill = (
case 'MemberExpression': case 'MemberExpression':
case 'OptionalMemberExpression': case 'OptionalMemberExpression':
case 'VariableDeclarator': case 'VariableDeclarator':
case 'ArrowFunctionExpression':
if (!customPatches[parent.type]) { if (!customPatches[parent.type]) {
throw new Error(`Couldn't find custom patcher for parent type: ${parent.type}`); throw new Error(`Couldn't find custom patcher for parent type: ${parent.type}`);
} }
@ -174,7 +184,6 @@ export const jsVariablePolyfill = (
// Do nothing // Do nothing
case 'Super': case 'Super':
case 'Identifier': case 'Identifier':
case 'ArrowFunctionExpression':
case 'FunctionDeclaration': case 'FunctionDeclaration':
case 'FunctionExpression': case 'FunctionExpression':
case 'ThisExpression': case 'ThisExpression':

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/workflow-sdk", "name": "@n8n/workflow-sdk",
"version": "0.25.0", "version": "0.25.1",
"description": "TypeScript SDK for programmatically creating n8n workflows", "description": "TypeScript SDK for programmatically creating n8n workflows",
"exports": { "exports": {
".": { ".": {

View File

@ -1,6 +1,6 @@
{ {
"name": "n8n", "name": "n8n",
"version": "2.32.0", "version": "2.32.3",
"description": "n8n Workflow Automation Tool", "description": "n8n Workflow Automation Tool",
"main": "dist/index", "main": "dist/index",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -124,7 +124,7 @@ describe('WorkflowExecuteAdditionalData', () => {
mockInstance(Telemetry); mockInstance(Telemetry);
const workflowRepository = mockInstance(WorkflowRepository); const workflowRepository = mockInstance(WorkflowRepository);
const activeExecutions = mockInstance(ActiveExecutions); const activeExecutions = mockInstance(ActiveExecutions);
mockInstance(CredentialsPermissionChecker); const credentialsPermissionChecker = mockInstance(CredentialsPermissionChecker);
mockInstance(SubworkflowPolicyChecker); mockInstance(SubworkflowPolicyChecker);
mockInstance(WorkflowStatisticsService); mockInstance(WorkflowStatisticsService);
mockInstance(WorkflowPublishHistoryRepository); mockInstance(WorkflowPublishHistoryRepository);
@ -273,6 +273,100 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(getVariablesSpy).toHaveBeenCalledWith(workflowId, undefined); expect(getVariablesSpy).toHaveBeenCalledWith(workflowId, undefined);
}); });
describe('credential permission check routing', () => {
const subWorkflowData = () =>
mock<IWorkflowBase>({
id: 'sub-id',
name: 'Sub Workflow',
nodes: [],
connections: {},
staticData: {},
settings: {},
});
beforeEach(() => {
vi.mocked(credentialsPermissionChecker.check).mockClear();
vi.mocked(credentialsPermissionChecker.checkForUser).mockClear();
vi.mocked(WorkflowExecute).mockClear();
});
it('checks credentials against the triggering user for an inline sub-workflow', async () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
mock<ExecuteWorkflowOptions>({
loadedWorkflowData: subWorkflowData(),
doNotWaitToFinish: false,
parentWorkflowId: 'parent-1',
}),
);
expect(credentialsPermissionChecker.checkForUser).toHaveBeenCalledTimes(1);
expect(vi.mocked(credentialsPermissionChecker.checkForUser).mock.calls[0][0]).toBe(
'user-1',
);
expect(credentialsPermissionChecker.check).not.toHaveBeenCalled();
});
it('checks credentials against the project for a database sub-workflow', async () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>({ id: 'db-id', code: undefined }),
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
mock<ExecuteWorkflowOptions>({
loadedWorkflowData: subWorkflowData(),
doNotWaitToFinish: false,
}),
);
expect(credentialsPermissionChecker.check).toHaveBeenCalled();
expect(credentialsPermissionChecker.checkForUser).not.toHaveBeenCalled();
});
it('falls back to the project check for an inline sub-workflow without a triggering user', async () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
mock<IWorkflowExecuteAdditionalData>({ userId: undefined }),
mock<ExecuteWorkflowOptions>({
loadedWorkflowData: subWorkflowData(),
doNotWaitToFinish: false,
parentWorkflowId: 'parent-1',
}),
);
expect(credentialsPermissionChecker.check).toHaveBeenCalled();
expect(credentialsPermissionChecker.checkForUser).not.toHaveBeenCalled();
});
it('preserves the triggering user in the sub-workflow additional data for inline sub-workflows so nested inline calls stay scoped to that user', async () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
mock<ExecuteWorkflowOptions>({
loadedWorkflowData: subWorkflowData(),
doNotWaitToFinish: false,
parentWorkflowId: 'parent-1',
}),
);
const integratedAdditionalData = vi.mocked(WorkflowExecute).mock.calls[0][0];
expect(integratedAdditionalData.userId).toBe('user-1');
});
it('does not carry the triggering user into a database sub-workflow (runs under its own project scope)', async () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>({ id: 'db-id', code: undefined }),
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
mock<ExecuteWorkflowOptions>({
loadedWorkflowData: subWorkflowData(),
doNotWaitToFinish: false,
}),
);
const integratedAdditionalData = vi.mocked(WorkflowExecute).mock.calls[0][0];
expect(integratedAdditionalData.userId).toBeUndefined();
});
});
describe('sub-workflow dynamic credential reporting', () => { describe('sub-workflow dynamic credential reporting', () => {
const getMockRunWithCredentialFlags = (task: Partial<ITaskData>, executedByUserId?: string) => const getMockRunWithCredentialFlags = (task: Partial<ITaskData>, executedByUserId?: string) =>
mock<IRun>({ mock<IRun>({

View File

@ -4,11 +4,14 @@ import {
type SharedCredentialsRepository, type SharedCredentialsRepository,
type CredentialsRepository, type CredentialsRepository,
type CredentialsEntity, type CredentialsEntity,
type UserRepository,
GLOBAL_OWNER_ROLE, GLOBAL_OWNER_ROLE,
GLOBAL_MEMBER_ROLE,
} from '@n8n/db'; } from '@n8n/db';
import type { INode } from 'n8n-workflow'; import type { INode } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import type { NodeTypes } from '@/node-types'; import type { NodeTypes } from '@/node-types';
import type { OwnershipService } from '@/services/ownership.service'; import type { OwnershipService } from '@/services/ownership.service';
import type { ProjectService } from '@/services/project.service.ee'; import type { ProjectService } from '@/services/project.service.ee';
@ -21,12 +24,16 @@ describe('CredentialsPermissionChecker', () => {
const ownershipService = mock<OwnershipService>(); const ownershipService = mock<OwnershipService>();
const projectService = mock<ProjectService>(); const projectService = mock<ProjectService>();
const nodeTypes = mock<NodeTypes>(); const nodeTypes = mock<NodeTypes>();
const userRepository = mock<UserRepository>();
const credentialsFinderService = mock<CredentialsFinderService>();
const permissionChecker = new CredentialsPermissionChecker( const permissionChecker = new CredentialsPermissionChecker(
sharedCredentialsRepository, sharedCredentialsRepository,
credentialsRepository, credentialsRepository,
ownershipService, ownershipService,
projectService, projectService,
nodeTypes, nodeTypes,
userRepository,
credentialsFinderService,
); );
const workflowId = 'workflow123'; const workflowId = 'workflow123';
@ -302,6 +309,82 @@ describe('CredentialsPermissionChecker', () => {
); );
}); });
it('should check the credential when genericAuthType is an expression', async () => {
const victimCredentialId = 'victim-cred';
const httpRequestNodeWithExpressionAuth: INode = {
id: 'node-3',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [0, 0],
parameters: {
authentication: 'genericCredentialType',
// Resolves to "httpHeaderAuth" only at execution time
genericAuthType: '={{ "httpHeaderAuth" }}',
},
credentials: {
httpHeaderAuth: {
id: victimCredentialId,
name: 'Victim Header Auth',
},
},
};
nodeTypes.getByNameAndVersion.mockReturnValue({
description: { credentials: [] },
} as never);
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValue([]);
credentialsRepository.find.mockResolvedValue([]);
await expect(
permissionChecker.check(workflowId, [httpRequestNodeWithExpressionAuth]),
).rejects.toThrow('Node "HTTP Request" does not have access to the credential');
// The unresolved expression must not let the credential bypass the check
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).toHaveBeenCalledWith(
[teamProject.id],
[victimCredentialId],
);
});
it('should check the credential when nodeCredentialType is an expression', async () => {
const victimCredentialId = 'victim-cred';
const httpRequestNodeWithExpressionAuth: INode = {
id: 'node-4',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.3,
position: [0, 0],
parameters: {
authentication: 'predefinedCredentialType',
nodeCredentialType: '={{ "googleOAuth2Api" }}',
},
credentials: {
googleOAuth2Api: {
id: victimCredentialId,
name: 'Victim OAuth2',
},
},
};
nodeTypes.getByNameAndVersion.mockReturnValue({
description: { credentials: [] },
} as never);
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValue([]);
credentialsRepository.find.mockResolvedValue([]);
await expect(
permissionChecker.check(workflowId, [httpRequestNodeWithExpressionAuth]),
).rejects.toThrow('Node "HTTP Request" does not have access to the credential');
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).toHaveBeenCalledWith(
[teamProject.id],
[victimCredentialId],
);
});
it('should fall back to checking all credentials if node type cannot be resolved', async () => { it('should fall back to checking all credentials if node type cannot be resolved', async () => {
nodeTypes.getByNameAndVersion.mockImplementation(() => { nodeTypes.getByNameAndVersion.mockImplementation(() => {
throw new Error('Unknown node type'); throw new Error('Unknown node type');
@ -322,4 +405,56 @@ describe('CredentialsPermissionChecker', () => {
); );
}); });
}); });
describe('checkForUser', () => {
const userId = 'user-123';
it('should not throw when the workflow has no credentials', async () => {
await expect(permissionChecker.checkForUser(userId, [])).resolves.not.toThrow();
expect(userRepository.findOne).not.toHaveBeenCalled();
expect(credentialsFinderService.findCredentialsForUser).not.toHaveBeenCalled();
});
it('should throw when the triggering user cannot be resolved', async () => {
userRepository.findOne.mockResolvedValueOnce(null);
await expect(permissionChecker.checkForUser(userId, [node])).rejects.toThrow(
'Node "Test Node" uses a credential you do not have access to',
);
});
it('should throw when the user does not have access to the credential', async () => {
userRepository.findOne.mockResolvedValueOnce(
mock<User>({ id: userId, role: GLOBAL_MEMBER_ROLE }),
);
credentialsFinderService.findCredentialsForUser.mockResolvedValueOnce([]);
await expect(permissionChecker.checkForUser(userId, [node])).rejects.toThrow(
'Node "Test Node" uses a credential you do not have access to',
);
expect(credentialsFinderService.findCredentialsForUser).toHaveBeenCalledWith(
expect.objectContaining({ id: userId }),
['credential:read'],
);
});
it('should not throw when the user has access to the credential', async () => {
userRepository.findOne.mockResolvedValueOnce(
mock<User>({ id: userId, role: GLOBAL_MEMBER_ROLE }),
);
credentialsFinderService.findCredentialsForUser.mockResolvedValueOnce([
mock<CredentialsEntity>({ id: credentialId }),
]);
await expect(permissionChecker.checkForUser(userId, [node])).resolves.not.toThrow();
});
it('should skip the check for a user with instance-wide credential listing', async () => {
userRepository.findOne.mockResolvedValueOnce(mock<User>({ role: GLOBAL_OWNER_ROLE }));
await expect(permissionChecker.checkForUser(userId, [node])).resolves.not.toThrow();
expect(credentialsFinderService.findCredentialsForUser).not.toHaveBeenCalled();
});
});
}); });

View File

@ -1,10 +1,11 @@
import type { Project } from '@n8n/db'; import type { Project } from '@n8n/db';
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db'; import { CredentialsRepository, SharedCredentialsRepository, UserRepository } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions'; import { hasGlobalScope } from '@n8n/permissions';
import type { INode } from 'n8n-workflow'; import type { INode } from 'n8n-workflow';
import { displayParameter, UserError } from 'n8n-workflow'; import { displayParameter, isExpression, UserError } from 'n8n-workflow';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { NodeTypes } from '@/node-types'; import { NodeTypes } from '@/node-types';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
import { ProjectService } from '@/services/project.service.ee'; import { ProjectService } from '@/services/project.service.ee';
@ -17,6 +18,15 @@ class InvalidCredentialError extends UserError {
} }
} }
class InaccessibleCredentialForUserError extends UserError {
override description =
'This node uses a credential you do not have access to. Ask its owner to share it with you.';
constructor(readonly node: INode) {
super(`Node "${node.name}" uses a credential you do not have access to`);
}
}
class InaccessibleCredentialError extends UserError { class InaccessibleCredentialError extends UserError {
override description = override description =
this.project.type === 'personal' this.project.type === 'personal'
@ -39,8 +49,48 @@ export class CredentialsPermissionChecker {
private readonly ownershipService: OwnershipService, private readonly ownershipService: OwnershipService,
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly nodeTypes: NodeTypes, private readonly nodeTypes: NodeTypes,
private readonly userRepository: UserRepository,
private readonly credentialsFinderService: CredentialsFinderService,
) {} ) {}
/**
* Check that a specific user can use every credential referenced by the given
* nodes. Used for sub-workflows whose source is not a stored database workflow
* (inline/parameter, file, url): these carry no project of their own, so their
* credentials must be evaluated against the user triggering the run rather than
* against the parent workflow's project.
*/
async checkForUser(userId: string, nodes: INode[]) {
const credIdsToNodes = this.mapCredIdsToNodes(nodes);
const workflowCredIds = Object.keys(credIdsToNodes);
if (workflowCredIds.length === 0) return;
// Load the role relation (scopes are eager) so hasGlobalScope can resolve.
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['role'],
});
if (!user) {
// Cannot resolve the triggering user - fail closed.
throw new InaccessibleCredentialForUserError(credIdsToNodes[workflowCredIds[0]][0]);
}
// A user with instance-wide credential listing can use any credential.
if (hasGlobalScope(user, 'credential:list')) return;
const accessibleCredentials = await this.credentialsFinderService.findCredentialsForUser(user, [
'credential:read',
]);
const accessibleSet = new Set(accessibleCredentials.map((cred) => cred.id));
for (const credentialsId of workflowCredIds) {
if (!accessibleSet.has(credentialsId)) {
throw new InaccessibleCredentialForUserError(credIdsToNodes[credentialsId][0]);
}
}
}
/** /**
* Check if a workflow has the ability to execute based on the projects it's apart of. * Check if a workflow has the ability to execute based on the projects it's apart of.
*/ */
@ -141,6 +191,10 @@ export class CredentialsPermissionChecker {
// the active credential is specified by the nodeCredentialType parameter // the active credential is specified by the nodeCredentialType parameter
const { nodeCredentialType } = node.parameters; const { nodeCredentialType } = node.parameters;
if (typeof nodeCredentialType === 'string' && nodeCredentialType) { if (typeof nodeCredentialType === 'string' && nodeCredentialType) {
// An expression only resolves to its real type at execution time, so the
// static filter can't know which credential it activates. Check all
// referenced credentials rather than trust the unresolved literal.
if (isExpression(nodeCredentialType)) return null;
activeTypes.add(nodeCredentialType); activeTypes.add(nodeCredentialType);
} }
@ -149,6 +203,7 @@ export class CredentialsPermissionChecker {
// specified by the genericAuthType parameter // specified by the genericAuthType parameter
const { genericAuthType } = node.parameters; const { genericAuthType } = node.parameters;
if (typeof genericAuthType === 'string' && genericAuthType) { if (typeof genericAuthType === 'string' && genericAuthType) {
if (isExpression(genericAuthType)) return null;
activeTypes.add(genericAuthType); activeTypes.add(genericAuthType);
} }

View File

@ -440,6 +440,92 @@ describe('FullItemRedactionStrategy', () => {
}); });
}); });
describe('executionData subtrees', () => {
const stackNode = {
id: 'node-1',
name: 'Test Node',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
};
it('clears item json and binary in nodeExecutionStack', async () => {
const execution = makeExecution({});
execution.data.executionData!.nodeExecutionStack = [
{
node: stackNode,
source: null,
data: {
main: [
[
{
json: { secret: 'stack-value' },
binary: { file: { mimeType: 'text/plain', data: 'abc' } },
},
],
],
},
},
];
await strategy.apply(execution, makeContext());
const item = execution.data.executionData!.nodeExecutionStack[0].data.main[0]![0];
expect(item.json).toEqual({});
expect(item.binary).toBeUndefined();
expect(item.redaction).toEqual({ redacted: true, reason: 'workflow_redaction_policy' });
});
it('moves item error to redaction.error in nodeExecutionStack', async () => {
const error = new NodeApiError(stackNode, { message: 'Bad Gateway' }, { httpCode: '502' });
const execution = makeExecution({});
execution.data.executionData!.nodeExecutionStack = [
{
node: stackNode,
source: null,
data: { main: [[{ json: { secret: 'stack-value' }, error }]] },
},
];
await strategy.apply(execution, makeContext());
const item = execution.data.executionData!.nodeExecutionStack[0].data.main[0]![0];
expect(item.error).toBeUndefined();
expect(item.redaction?.error).toEqual({ type: 'NodeApiError', httpCode: '502' });
});
it('clears item json and binary in waitingExecution', async () => {
const execution = makeExecution({});
execution.data.executionData!.waitingExecution = {
'Test Node': {
0: {
main: [
[
{
json: { secret: 'waiting-value' },
binary: { file: { mimeType: 'text/plain', data: 'abc' } },
},
],
],
},
},
};
await strategy.apply(execution, makeContext());
const item = execution.data.executionData!.waitingExecution['Test Node'][0].main[0]![0];
expect(item.json).toEqual({});
expect(item.binary).toBeUndefined();
expect(item.redaction).toEqual({ redacted: true, reason: 'workflow_redaction_policy' });
});
it('does nothing when executionData has no stack or waiting items', async () => {
const execution = makeExecution({});
await expect(strategy.apply(execution, makeContext())).resolves.toBeUndefined();
});
});
describe('redactionInfo', () => { describe('redactionInfo', () => {
it('sets isRedacted: true with canReveal from context', async () => { it('sets isRedacted: true with canReveal from context', async () => {
const execution = makeExecution({ const execution = makeExecution({

View File

@ -53,6 +53,22 @@ export class FullItemRedactionStrategy implements IExecutionRedactionStrategy {
delete resultData.error; delete resultData.error;
} }
const executionData = execution.data.executionData;
if (executionData) {
for (const executeData of executionData.nodeExecutionStack ?? []) {
if (executeData.data) {
this.redactConnections(executeData.data, reason);
}
}
for (const runIndexMap of Object.values(executionData.waitingExecution ?? {})) {
for (const waitingExecution of Object.values(runIndexMap)) {
this.redactConnections(waitingExecution, reason);
}
}
}
execution.data.redactionInfo = { execution.data.redactionInfo = {
...execution.data.redactionInfo, ...execution.data.redactionInfo,
isRedacted: true, isRedacted: true,

View File

@ -13,8 +13,10 @@ import type { EventService } from '@/events/event.service';
import type { RoleService } from '@/services/role.service'; import type { RoleService } from '@/services/role.service';
import type { UserService } from '@/services/user.service'; import type { UserService } from '@/services/user.service';
import type { TokenExchangeConfig } from '../../token-exchange.config';
import { TokenExchangeAuthError } from '../../token-exchange.errors'; import { TokenExchangeAuthError } from '../../token-exchange.errors';
import type { ExternalTokenClaims } from '../../token-exchange.schemas'; import type { ExternalTokenClaims } from '../../token-exchange.schemas';
import { TokenExchangeFailureReason } from '../../token-exchange.types';
import { IdentityResolutionService } from '../identity-resolution.service'; import { IdentityResolutionService } from '../identity-resolution.service';
import type { TrustedKeyService } from '../trusted-key.service'; import type { TrustedKeyService } from '../trusted-key.service';
@ -26,6 +28,7 @@ const eventService = mock<EventService>();
const userService = mock<UserService>(); const userService = mock<UserService>();
const trustedKeyService = mock<TrustedKeyService>(); const trustedKeyService = mock<TrustedKeyService>();
const roleService = mock<RoleService>(); const roleService = mock<RoleService>();
const config = mock<TokenExchangeConfig>();
const service = new IdentityResolutionService( const service = new IdentityResolutionService(
logger, logger,
@ -35,10 +38,20 @@ const service = new IdentityResolutionService(
userService, userService,
trustedKeyService, trustedKeyService,
roleService, roleService,
config,
); );
const CUSTOM_ROLE = 'global:custom-abc123'; const CUSTOM_ROLE = 'global:custom-abc123';
function makeUser(roleSlug: string): User {
return {
...mock<User>(),
id: 'user-id',
email: 'user@example.com',
role: { ...mock<User['role']>(), slug: roleSlug },
} as User;
}
function makeClaims(overrides: Partial<ExternalTokenClaims> = {}): ExternalTokenClaims { function makeClaims(overrides: Partial<ExternalTokenClaims> = {}): ExternalTokenClaims {
return { return {
sub: 'external-user-1', sub: 'external-user-1',
@ -52,11 +65,19 @@ function makeClaims(overrides: Partial<ExternalTokenClaims> = {}): ExternalToken
}; };
} }
function ctx(requireVerifiedEmail = false) {
return { kid: 'kid-1', issuer: 'https://issuer.example.com', requireVerifiedEmail };
}
describe('IdentityResolutionService', () => { describe('IdentityResolutionService', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
roleService.isGlobalRole.mockResolvedValue(true); roleService.isGlobalRole.mockResolvedValue(true);
roleService.isRoleLicensed.mockReturnValue(true); roleService.isRoleLicensed.mockReturnValue(true);
config.excludeOwner = false;
// Default: no linked identity, no existing user (JIT territory unless overridden).
authIdentityRepository.findOne.mockResolvedValue(null);
userRepository.findOne.mockResolvedValue(null);
}); });
describe('JIT provisioning (new user)', () => { describe('JIT provisioning (new user)', () => {
@ -78,7 +99,7 @@ describe('IdentityResolutionService', () => {
}); });
it('provisions a licensed custom global role', async () => { it('provisions a licensed custom global role', async () => {
const user = await service.resolve(makeClaims({ role: CUSTOM_ROLE })); const user = await service.resolve(makeClaims({ role: CUSTOM_ROLE }), undefined, ctx());
expect(user.id).toBe('new-user-1'); expect(user.id).toBe('new-user-1');
expect(userRepository.createUserWithProject).toHaveBeenCalledWith( expect(userRepository.createUserWithProject).toHaveBeenCalledWith(
@ -92,7 +113,7 @@ describe('IdentityResolutionService', () => {
}); });
it('provisions a built-in global role without a license check', async () => { it('provisions a built-in global role without a license check', async () => {
await service.resolve(makeClaims({ role: 'global:member' })); await service.resolve(makeClaims({ role: 'global:member' }), undefined, ctx());
expect(roleService.isRoleLicensed).not.toHaveBeenCalled(); expect(roleService.isRoleLicensed).not.toHaveBeenCalled();
expect(userRepository.createUserWithProject).toHaveBeenCalledWith( expect(userRepository.createUserWithProject).toHaveBeenCalledWith(
@ -102,7 +123,7 @@ describe('IdentityResolutionService', () => {
}); });
it('defaults to global:member when no role claim is present', async () => { it('defaults to global:member when no role claim is present', async () => {
await service.resolve(makeClaims()); await service.resolve(makeClaims(), undefined, ctx());
expect(userRepository.createUserWithProject).toHaveBeenCalledWith( expect(userRepository.createUserWithProject).toHaveBeenCalledWith(
expect.objectContaining({ role: GLOBAL_MEMBER_ROLE }), expect.objectContaining({ role: GLOBAL_MEMBER_ROLE }),
@ -117,35 +138,35 @@ describe('IdentityResolutionService', () => {
it('throws when the role is not an existing global role', async () => { it('throws when the role is not an existing global role', async () => {
roleService.isGlobalRole.mockResolvedValue(false); roleService.isGlobalRole.mockResolvedValue(false);
await expect(service.resolve(makeClaims({ role: 'global:nonexistent' }))).rejects.toThrow( await expect(
TokenExchangeAuthError, service.resolve(makeClaims({ role: 'global:nonexistent' }), undefined, ctx()),
); ).rejects.toThrow(TokenExchangeAuthError);
expect(userRepository.createUserWithProject).not.toHaveBeenCalled(); expect(userRepository.createUserWithProject).not.toHaveBeenCalled();
}); });
it('throws when a custom role is unlicensed', async () => { it('throws when a custom role is unlicensed', async () => {
roleService.isRoleLicensed.mockReturnValue(false); roleService.isRoleLicensed.mockReturnValue(false);
await expect(service.resolve(makeClaims({ role: CUSTOM_ROLE }))).rejects.toThrow( await expect(
TokenExchangeAuthError, service.resolve(makeClaims({ role: CUSTOM_ROLE }), undefined, ctx()),
); ).rejects.toThrow(TokenExchangeAuthError);
expect(userRepository.createUserWithProject).not.toHaveBeenCalled(); expect(userRepository.createUserWithProject).not.toHaveBeenCalled();
}); });
it('throws on a global:owner role claim', async () => { it('throws on a global:owner role claim', async () => {
await expect(service.resolve(makeClaims({ role: 'global:owner' }))).rejects.toThrow( await expect(
TokenExchangeAuthError, service.resolve(makeClaims({ role: 'global:owner' }), undefined, ctx()),
); ).rejects.toThrow(TokenExchangeAuthError);
}); });
it('throws when the role is not in the key allowedRoles', async () => { it('throws when the role is not in the key allowedRoles', async () => {
await expect( await expect(
service.resolve(makeClaims({ role: CUSTOM_ROLE }), ['global:member']), service.resolve(makeClaims({ role: CUSTOM_ROLE }), ['global:member'], ctx()),
).rejects.toThrow(TokenExchangeAuthError); ).rejects.toThrow(TokenExchangeAuthError);
}); });
it('provisions when the role is in the key allowedRoles', async () => { it('provisions when the role is in the key allowedRoles', async () => {
await service.resolve(makeClaims({ role: CUSTOM_ROLE }), [CUSTOM_ROLE]); await service.resolve(makeClaims({ role: CUSTOM_ROLE }), [CUSTOM_ROLE], ctx());
expect(userRepository.createUserWithProject).toHaveBeenCalledWith( expect(userRepository.createUserWithProject).toHaveBeenCalledWith(
expect.objectContaining({ role: { slug: CUSTOM_ROLE } }), expect.objectContaining({ role: { slug: CUSTOM_ROLE } }),
@ -165,7 +186,7 @@ describe('IdentityResolutionService', () => {
it('changes to a licensed custom role and emits an update event', async () => { it('changes to a licensed custom role and emits an update event', async () => {
mockLinkedUser('global:member'); mockLinkedUser('global:member');
await service.resolve(makeClaims({ role: CUSTOM_ROLE })); await service.resolve(makeClaims({ role: CUSTOM_ROLE }), undefined, ctx());
expect(userService.changeUserRole).toHaveBeenCalledWith(expect.anything(), { expect(userService.changeUserRole).toHaveBeenCalledWith(expect.anything(), {
newRoleName: CUSTOM_ROLE, newRoleName: CUSTOM_ROLE,
@ -180,7 +201,7 @@ describe('IdentityResolutionService', () => {
mockLinkedUser('global:member'); mockLinkedUser('global:member');
roleService.isGlobalRole.mockResolvedValue(false); roleService.isGlobalRole.mockResolvedValue(false);
await service.resolve(makeClaims({ role: 'global:nonexistent' })); await service.resolve(makeClaims({ role: 'global:nonexistent' }), undefined, ctx());
expect(userService.changeUserRole).not.toHaveBeenCalled(); expect(userService.changeUserRole).not.toHaveBeenCalled();
}); });
@ -189,16 +210,16 @@ describe('IdentityResolutionService', () => {
mockLinkedUser('global:member'); mockLinkedUser('global:member');
roleService.isRoleLicensed.mockReturnValue(false); roleService.isRoleLicensed.mockReturnValue(false);
await expect(service.resolve(makeClaims({ role: CUSTOM_ROLE }))).rejects.toThrow( await expect(
TokenExchangeAuthError, service.resolve(makeClaims({ role: CUSTOM_ROLE }), undefined, ctx()),
); ).rejects.toThrow(TokenExchangeAuthError);
expect(userService.changeUserRole).not.toHaveBeenCalled(); expect(userService.changeUserRole).not.toHaveBeenCalled();
}); });
it('never changes the role of an existing owner', async () => { it('never changes the role of an existing owner', async () => {
mockLinkedUser('global:owner'); mockLinkedUser('global:owner');
await service.resolve(makeClaims({ role: CUSTOM_ROLE })); await service.resolve(makeClaims({ role: CUSTOM_ROLE }), undefined, ctx());
expect(userService.changeUserRole).not.toHaveBeenCalled(); expect(userService.changeUserRole).not.toHaveBeenCalled();
}); });
@ -206,7 +227,7 @@ describe('IdentityResolutionService', () => {
it('ignores a global:owner role claim', async () => { it('ignores a global:owner role claim', async () => {
mockLinkedUser('global:member'); mockLinkedUser('global:member');
await service.resolve(makeClaims({ role: 'global:owner' })); await service.resolve(makeClaims({ role: 'global:owner' }), undefined, ctx());
expect(userService.changeUserRole).not.toHaveBeenCalled(); expect(userService.changeUserRole).not.toHaveBeenCalled();
}); });
@ -215,9 +236,89 @@ describe('IdentityResolutionService', () => {
mockLinkedUser('global:member'); mockLinkedUser('global:member');
await expect( await expect(
service.resolve(makeClaims({ role: CUSTOM_ROLE }), ['global:member']), service.resolve(makeClaims({ role: CUSTOM_ROLE }), ['global:member'], ctx()),
).rejects.toThrow(TokenExchangeAuthError); ).rejects.toThrow(TokenExchangeAuthError);
expect(userService.changeUserRole).not.toHaveBeenCalled(); expect(userService.changeUserRole).not.toHaveBeenCalled();
}); });
}); });
describe('allowedRoles authority ceiling', () => {
it('rejects when a key authenticates as an existing user whose role exceeds allowedRoles (email match)', async () => {
userRepository.findOne.mockResolvedValue(makeUser('global:owner'));
await expect(service.resolve(makeClaims(), ['global:member'], ctx())).rejects.toMatchObject({
reason: TokenExchangeFailureReason.RoleNotAllowed,
});
});
it('rejects when an already-linked identity resolves to a user whose role exceeds allowedRoles', async () => {
authIdentityRepository.findOne.mockResolvedValueOnce(
mock<AuthIdentity>({ user: makeUser('global:owner') }),
);
await expect(service.resolve(makeClaims(), ['global:member'], ctx())).rejects.toBeInstanceOf(
TokenExchangeAuthError,
);
});
it('resolves the existing user when its role is within allowedRoles', async () => {
const member = makeUser('global:member');
userRepository.findOne.mockResolvedValue(member);
await expect(service.resolve(makeClaims(), ['global:member'], ctx())).resolves.toBe(member);
});
it('resolves any existing user when allowedRoles is undefined (unrestricted)', async () => {
const owner = makeUser('global:owner');
userRepository.findOne.mockResolvedValue(owner);
await expect(service.resolve(makeClaims(), undefined, ctx())).resolves.toBe(owner);
});
});
describe('excludeOwner lockout', () => {
it('rejects an owner even when allowedRoles explicitly permits it', async () => {
config.excludeOwner = true;
userRepository.findOne.mockResolvedValue(makeUser('global:owner'));
await expect(service.resolve(makeClaims(), ['global:owner'], ctx())).rejects.toMatchObject({
reason: TokenExchangeFailureReason.RoleNotAllowed,
});
});
});
describe('requireVerifiedEmail', () => {
it('rejects email-fallback linking when email_verified is missing', async () => {
userRepository.findOne.mockResolvedValue(makeUser('global:member'));
await expect(
service.resolve(makeClaims(), ['global:member'], ctx(true)),
).rejects.toMatchObject({ reason: TokenExchangeFailureReason.EmailNotVerified });
});
it('rejects JIT provisioning when email_verified is missing', async () => {
// No existing identity and no existing user → JIT path.
await expect(
service.resolve(makeClaims(), ['global:member'], ctx(true)),
).rejects.toMatchObject({ reason: TokenExchangeFailureReason.EmailNotVerified });
});
it('links an existing user when email_verified is true', async () => {
const member = makeUser('global:member');
userRepository.findOne.mockResolvedValue(member);
await expect(
service.resolve(makeClaims({ email_verified: true }), ['global:member'], ctx(true)),
).resolves.toBe(member);
});
it('links without an email_verified claim when the key does not require it', async () => {
const member = makeUser('global:member');
userRepository.findOne.mockResolvedValue(member);
await expect(service.resolve(makeClaims(), ['global:member'], ctx(false))).resolves.toBe(
member,
);
});
});
}); });

View File

@ -36,6 +36,7 @@ const resolvedKey: ResolvedTrustedKey = {
key: 'test-public-key', key: 'test-public-key',
issuer: 'https://issuer.example.com', issuer: 'https://issuer.example.com',
allowedRoles: ['global:member', 'global:admin'], allowedRoles: ['global:member', 'global:admin'],
requireVerifiedEmail: false,
}; };
const mockUser = mock<User>({ const mockUser = mock<User>({
@ -94,7 +95,11 @@ describe('TokenExchangeService', () => {
expect(identityResolutionService.resolve).toHaveBeenCalledWith( expect(identityResolutionService.resolve).toHaveBeenCalledWith(
validClaims, validClaims,
resolvedKey.allowedRoles, resolvedKey.allowedRoles,
{ kid: resolvedKey.kid, issuer: resolvedKey.issuer }, {
kid: resolvedKey.kid,
issuer: resolvedKey.issuer,
requireVerifiedEmail: resolvedKey.requireVerifiedEmail,
},
); );
}); });

View File

@ -7,13 +7,14 @@ import {
type User, type User,
} from '@n8n/db'; } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { isBuiltInRole } from '@n8n/permissions'; import { GLOBAL_OWNER_ROLE_SLUG, isBuiltInRole } from '@n8n/permissions';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
import { RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
import { UserService } from '@/services/user.service'; import { UserService } from '@/services/user.service';
import { TokenExchangeConfig } from '../token-exchange.config';
import { TokenExchangeAuthError } from '../token-exchange.errors'; import { TokenExchangeAuthError } from '../token-exchange.errors';
import type { ExternalTokenClaims } from '../token-exchange.schemas'; import type { ExternalTokenClaims } from '../token-exchange.schemas';
import { TokenExchangeFailureReason } from '../token-exchange.types'; import { TokenExchangeFailureReason } from '../token-exchange.types';
@ -49,10 +50,38 @@ export class IdentityResolutionService {
private readonly userService: UserService, private readonly userService: UserService,
private readonly trustedKeyService: TrustedKeyService, private readonly trustedKeyService: TrustedKeyService,
private readonly roleService: RoleService, private readonly roleService: RoleService,
private readonly config: TokenExchangeConfig,
) { ) {
this.logger = logger.scoped('token-exchange'); this.logger = logger.scoped('token-exchange');
} }
private assertKeyMayActAsUser(user: User, allowedRoles?: string[]) {
if (this.config.excludeOwner && user.role?.slug === GLOBAL_OWNER_ROLE_SLUG) {
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.RoleNotAllowed,
'User role is not allowed for this key',
);
}
if (allowedRoles?.length && !allowedRoles.includes(user.role?.slug ?? '')) {
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.RoleNotAllowed,
'User role is not allowed for this key',
);
}
}
private assertEmailVerified(
claims: ExternalTokenClaims,
tokenContext: { requireVerifiedEmail: boolean },
) {
if (tokenContext?.requireVerifiedEmail && !claims.email_verified) {
throw new TokenExchangeAuthError(
TokenExchangeFailureReason.EmailNotVerified,
'Email is not verified',
);
}
}
/** /**
* Map external identity claims to a local n8n user, creating one if necessary. * Map external identity claims to a local n8n user, creating one if necessary.
* *
@ -67,8 +96,8 @@ export class IdentityResolutionService {
*/ */
async resolve( async resolve(
claims: ExternalTokenClaims, claims: ExternalTokenClaims,
allowedRoles?: string[], allowedRoles: string[] | undefined,
tokenContext?: { kid: string; issuer: string }, tokenContext: { kid: string; issuer: string; requireVerifiedEmail: boolean },
): Promise<User> { ): Promise<User> {
const email = claims.email?.toLowerCase(); const email = claims.email?.toLowerCase();
@ -97,8 +126,8 @@ export class IdentityResolutionService {
this.eventService.emit('token-exchange-identity-rebound', { this.eventService.emit('token-exchange-identity-rebound', {
userId: identity.user.id, userId: identity.user.id,
sub: claims.sub, sub: claims.sub,
kid: tokenContext?.kid ?? '', kid: tokenContext.kid,
issuer: tokenContext?.issuer ?? claims.iss, issuer: tokenContext.issuer,
}); });
return await this.resolveByIdentity(claims, identity, allowedRoles, tokenContext); return await this.resolveByIdentity(claims, identity, allowedRoles, tokenContext);
} }
@ -131,8 +160,10 @@ export class IdentityResolutionService {
claims: ExternalTokenClaims, claims: ExternalTokenClaims,
identity: AuthIdentity, identity: AuthIdentity,
allowedRoles: string[] | undefined, allowedRoles: string[] | undefined,
tokenContext: { kid: string; issuer: string } | undefined, tokenContext: { kid: string; issuer: string; requireVerifiedEmail: boolean } | undefined,
): Promise<User> { ): Promise<User> {
this.assertKeyMayActAsUser(identity.user, allowedRoles);
this.logger.debug('Resolved user by auth identity', { sub: claims.sub }); this.logger.debug('Resolved user by auth identity', { sub: claims.sub });
const resolvedRole = await this.resolveRoleForExistingUser( const resolvedRole = await this.resolveRoleForExistingUser(
claims.role, claims.role,
@ -148,12 +179,14 @@ export class IdentityResolutionService {
email: string, email: string,
existingUser: User, existingUser: User,
allowedRoles: string[] | undefined, allowedRoles: string[] | undefined,
tokenContext: { kid: string; issuer: string } | undefined, tokenContext: { kid: string; issuer: string; requireVerifiedEmail: boolean },
): Promise<User> { ): Promise<User> {
this.logger.debug('Linking external identity to existing user by email', { this.logger.debug('Linking external identity to existing user by email', {
sub: claims.sub, sub: claims.sub,
email, email,
}); });
this.assertKeyMayActAsUser(existingUser, allowedRoles);
this.assertEmailVerified(claims, tokenContext);
const resolvedRole = await this.resolveRoleForExistingUser( const resolvedRole = await this.resolveRoleForExistingUser(
claims.role, claims.role,
allowedRoles, allowedRoles,
@ -178,8 +211,9 @@ export class IdentityResolutionService {
claims: ExternalTokenClaims, claims: ExternalTokenClaims,
email: string, email: string,
allowedRoles: string[] | undefined, allowedRoles: string[] | undefined,
tokenContext: { kid: string; issuer: string } | undefined, tokenContext: { kid: string; issuer: string; requireVerifiedEmail: boolean },
): Promise<User> { ): Promise<User> {
this.assertEmailVerified(claims, tokenContext);
this.logger.debug('JIT provisioning new user', { sub: claims.sub, email }); this.logger.debug('JIT provisioning new user', { sub: claims.sub, email });
const jitRole = await this.resolveRoleForNewUser(claims.role, allowedRoles); const jitRole = await this.resolveRoleForNewUser(claims.role, allowedRoles);
@ -334,7 +368,7 @@ export class IdentityResolutionService {
user: User, user: User,
claims: ExternalTokenClaims, claims: ExternalTokenClaims,
resolvedRole?: string, resolvedRole?: string,
tokenContext?: { kid: string; issuer: string }, tokenContext?: { kid: string; issuer: string; requireVerifiedEmail: boolean },
): Promise<User> { ): Promise<User> {
let needsReload = false; let needsReload = false;

View File

@ -149,6 +149,7 @@ export class TokenExchangeService {
const user = await this.identityResolutionService.resolve(claims, resolvedKey.allowedRoles, { const user = await this.identityResolutionService.resolve(claims, resolvedKey.allowedRoles, {
kid: resolvedKey.kid, kid: resolvedKey.kid,
issuer: resolvedKey.issuer, issuer: resolvedKey.issuer,
requireVerifiedEmail: resolvedKey.requireVerifiedEmail,
}); });
return { user, subject: claims.sub, issuer: resolvedKey.issuer, kid: resolvedKey.kid }; return { user, subject: claims.sub, issuer: resolvedKey.issuer, kid: resolvedKey.kid };
} }

View File

@ -192,6 +192,7 @@ export class TrustedKeyService {
issuer: data.issuer, issuer: data.issuer,
expectedAudience: data.expectedAudience, expectedAudience: data.expectedAudience,
allowedRoles: data.allowedRoles, allowedRoles: data.allowedRoles,
requireVerifiedEmail: data.requireVerifiedEmail ?? true,
}; };
} }
@ -512,6 +513,7 @@ export class TrustedKeyService {
issuer: key.issuer, issuer: key.issuer,
expectedAudience: key.expectedAudience, expectedAudience: key.expectedAudience,
allowedRoles: key.allowedRoles, allowedRoles: key.allowedRoles,
requireVerifiedEmail: jwksConfig.requireVerifiedEmail ?? true,
expiresAt: new Date(Date.now() + result.ttlSeconds * 1000).toISOString(), expiresAt: new Date(Date.now() + result.ttlSeconds * 1000).toISOString(),
}, },
})), })),
@ -550,7 +552,15 @@ export class TrustedKeyService {
const seenKids = new Set<string>(); const seenKids = new Set<string>();
for (const config of configs) { for (const config of configs) {
const { kid, algorithms, key: pemString, issuer, expectedAudience, allowedRoles } = config; const {
kid,
algorithms,
key: pemString,
issuer,
expectedAudience,
allowedRoles,
requireVerifiedEmail,
} = config;
if (seenKids.has(kid)) { if (seenKids.has(kid)) {
throw new UnexpectedError(`Trusted key "${kid}": duplicate kid`); throw new UnexpectedError(`Trusted key "${kid}": duplicate kid`);
@ -567,6 +577,7 @@ export class TrustedKeyService {
issuer, issuer,
expectedAudience, expectedAudience,
allowedRoles, allowedRoles,
requireVerifiedEmail,
}, },
}); });
} }

View File

@ -14,6 +14,9 @@ export class TokenExchangeConfig {
@Env('N8N_TOKEN_EXCHANGE_MAX_TOKEN_TTL') @Env('N8N_TOKEN_EXCHANGE_MAX_TOKEN_TTL')
maxTokenTtl: number = 900; maxTokenTtl: number = 900;
@Env('N8N_TOKEN_EXCHANGE_EXCLUDE_OWNER')
excludeOwner: boolean = true;
/** /**
* JSON array of trusted key sources for JWT verification. * JSON array of trusted key sources for JWT verification.
* Each entry is validated against `TrustedKeySourceSchema`. * Each entry is validated against `TrustedKeySourceSchema`.

View File

@ -38,6 +38,10 @@ export const ExternalTokenClaimsSchema = z.object({
given_name: z.string().optional(), given_name: z.string().optional(),
family_name: z.string().optional(), family_name: z.string().optional(),
role: z.string().optional(), role: z.string().optional(),
email_verified: z
.union([z.boolean(), z.enum(['true', 'false'])])
.transform((v) => v === true || v === 'true')
.optional(),
}); });
export type ExternalTokenClaims = z.infer<typeof ExternalTokenClaimsSchema>; export type ExternalTokenClaims = z.infer<typeof ExternalTokenClaimsSchema>;
@ -55,6 +59,7 @@ export const TrustedKeySourceSchema = z.discriminatedUnion('type', [
algorithms: z.array(JwtAlgorithmSchema).min(1), algorithms: z.array(JwtAlgorithmSchema).min(1),
key: z.string().min(1), key: z.string().min(1),
issuer: z.string().min(1), issuer: z.string().min(1),
requireVerifiedEmail: z.boolean().optional(),
expectedAudience: z.string().optional(), expectedAudience: z.string().optional(),
allowedRoles: z.array(z.string()).optional(), allowedRoles: z.array(z.string()).optional(),
}), }),
@ -62,6 +67,7 @@ export const TrustedKeySourceSchema = z.discriminatedUnion('type', [
type: z.literal('jwks'), type: z.literal('jwks'),
url: z.string().url(), url: z.string().url(),
issuer: z.string().min(1), issuer: z.string().min(1),
requireVerifiedEmail: z.boolean().optional(),
expectedAudience: z.string().optional(), expectedAudience: z.string().optional(),
allowedRoles: z.array(z.string()).optional(), allowedRoles: z.array(z.string()).optional(),
cacheTtlSeconds: z.number().int().positive().optional(), cacheTtlSeconds: z.number().int().positive().optional(),
@ -88,6 +94,7 @@ export const TrustedKeyDataSchema = z.object({
expectedAudience: z.string().optional(), expectedAudience: z.string().optional(),
allowedRoles: z.array(z.string()).optional(), allowedRoles: z.array(z.string()).optional(),
expiresAt: z.string().optional(), expiresAt: z.string().optional(),
requireVerifiedEmail: z.boolean().optional(),
}); });
export type TrustedKeyData = z.infer<typeof TrustedKeyDataSchema>; export type TrustedKeyData = z.infer<typeof TrustedKeyDataSchema>;
@ -116,6 +123,9 @@ export interface ResolvedTrustedKey {
/** Roles allowed for tokens signed with this key, if restricted. */ /** Roles allowed for tokens signed with this key, if restricted. */
allowedRoles?: string[]; allowedRoles?: string[];
/** Flag indicating that the token's `email_verified` claim must be true, for email linking. */
requireVerifiedEmail: boolean;
} }
/** /**

View File

@ -12,6 +12,7 @@ export const TokenExchangeFailureReason = {
InvalidClaims: 'invalid_claims', InvalidClaims: 'invalid_claims',
InternalError: 'internal_error', InternalError: 'internal_error',
RoleNotAllowed: 'role_not_allowed', RoleNotAllowed: 'role_not_allowed',
EmailNotVerified: 'email_not_verified',
Other: 'other', Other: 'other',
} as const; } as const;

View File

@ -335,12 +335,18 @@ export async function executeWorkflow(
source: 'integrated', source: 'integrated',
}); });
// A sub-workflow loaded from inline JSON / file / URL (source other than a
// stored database workflow) carries no project of its own. Its credentials
// must be evaluated against the triggering user, not the parent's project.
const isInlineSubworkflow = workflowInfo.code !== undefined && workflowInfo.id === undefined;
const executionPromise = startExecution( const executionPromise = startExecution(
additionalData, additionalData,
options, options,
executionId, executionId,
runData, runData,
workflowData, workflowData,
isInlineSubworkflow,
); );
if (options.doNotWaitToFinish) { if (options.doNotWaitToFinish) {
@ -493,6 +499,7 @@ async function startExecution(
executionId: string, executionId: string,
runData: IWorkflowExecutionDataProcess, runData: IWorkflowExecutionDataProcess,
workflowData: IWorkflowBase, workflowData: IWorkflowBase,
isInlineSubworkflow = false,
): Promise<ExecuteWorkflowData> { ): Promise<ExecuteWorkflowData> {
const nodeTypes = Container.get(NodeTypes); const nodeTypes = Container.get(NodeTypes);
const activeExecutions = Container.get(ActiveExecutions); const activeExecutions = Container.get(ActiveExecutions);
@ -521,7 +528,17 @@ async function startExecution(
let data; let data;
try { try {
await Container.get(CredentialsPermissionChecker).check(workflowData.id, workflowData.nodes); if (isInlineSubworkflow && additionalData.userId) {
// Inline sub-workflow triggered by a specific user: its credentials were
// never vetted against that user (they live only in the parameter JSON),
// so validate them against the user rather than the parent's project.
await Container.get(CredentialsPermissionChecker).checkForUser(
additionalData.userId,
workflowData.nodes,
);
} else {
await Container.get(CredentialsPermissionChecker).check(workflowData.id, workflowData.nodes);
}
await Container.get(SubworkflowPolicyChecker).check( await Container.get(SubworkflowPolicyChecker).check(
workflow, workflow,
options.parentWorkflowId, options.parentWorkflowId,
@ -533,6 +550,11 @@ async function startExecution(
// different webhooks // different webhooks
const workflowSettings = workflowData.settings; const workflowSettings = workflowData.settings;
const additionalDataIntegrated = await getBase({ const additionalDataIntegrated = await getBase({
// Inline sub-workflows carry no project, so the triggering user must be
// preserved for nested inline calls to be validated against that user too.
// Stored sub-workflows run under their own project scope, so their userId
// stays unset (their credentials are validated against the project instead).
userId: isInlineSubworkflow ? additionalData.userId : undefined,
workflowId: workflowData.id, workflowId: workflowData.id,
workflowSettings, workflowSettings,
}); });

View File

@ -22,8 +22,15 @@ import { hasGlobalScope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, type EntityManager } from '@n8n/typeorm'; import { In, type EntityManager } from '@n8n/typeorm';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import type { IWorkflowBase, WorkflowId } from 'n8n-workflow'; import type { INode, IWorkflowBase, WorkflowId } from 'n8n-workflow';
import { NodeOperationError, PROJECT_ROOT, UserError, WorkflowActivationError } from 'n8n-workflow'; import {
EXECUTE_WORKFLOW_NODE_TYPE,
jsonParse,
NodeOperationError,
PROJECT_ROOT,
UserError,
WorkflowActivationError,
} from 'n8n-workflow';
import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
@ -263,17 +270,62 @@ export class EnterpriseWorkflowService {
return []; return [];
} }
return workflow.nodes.filter((node) => { return workflow.nodes.filter((node) => {
if (!node.credentials) return false; const usedCredentialIds = this.getCredentialIdsUsedByNode(node);
return usedCredentialIds.some((credId) => !userCredIds.includes(credId));
const allUsedCredentials = Object.values(node.credentials);
const allUsedCredentialIds = allUsedCredentials.map((nodeCred) => nodeCred.id?.toString());
return allUsedCredentialIds.some(
(nodeCredId) => nodeCredId && !userCredIds.includes(nodeCredId),
);
}); });
} }
/**
* Collect every credential id a node references. Besides the node's own
* `credentials`, an Execute Sub-workflow node with an inline source embeds a
* whole workflow including its nodes' credentials inside a single string
* parameter, so those references are parsed out and returned as well.
*/
private getCredentialIdsUsedByNode(node: INode): string[] {
const credentialIds: string[] = [];
if (node.credentials) {
for (const nodeCred of Object.values(node.credentials)) {
const id = nodeCred.id?.toString();
if (id) credentialIds.push(id);
}
}
if (node.type === EXECUTE_WORKFLOW_NODE_TYPE) {
credentialIds.push(...this.getCredentialIdsFromInlineWorkflow(node.parameters?.workflowJson));
}
return credentialIds;
}
/**
* Extract the credential ids referenced by the nodes of an inline sub-workflow
* passed as the `workflowJson` parameter of an Execute Sub-workflow node.
* Non-string or unparseable values reference no resolvable credentials and are
* ignored (the node would fail at run time).
*/
private getCredentialIdsFromInlineWorkflow(workflowJson: unknown): string[] {
if (typeof workflowJson !== 'string') return [];
let parsed: Partial<IWorkflowBase>;
try {
parsed = jsonParse<Partial<IWorkflowBase>>(workflowJson);
} catch {
return [];
}
if (!parsed || !Array.isArray(parsed.nodes)) return [];
const credentialIds: string[] = [];
for (const subNode of parsed.nodes) {
if (!subNode?.credentials) continue;
for (const nodeCred of Object.values(subNode.credentials)) {
const id = nodeCred?.id?.toString();
if (id) credentialIds.push(id);
}
}
return credentialIds;
}
/** /**
* Get workflow IDs that use at least one resolvable credential. * Get workflow IDs that use at least one resolvable credential.
* Used to populate `hasResolvableCredentials` in workflow list responses. * Used to populate `hasResolvableCredentials` in workflow list responses.

View File

@ -7,7 +7,14 @@ import {
} from '@n8n/backend-test-utils'; } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import { stringify } from 'flatted'; import { stringify } from 'flatted';
import type { INode, IRunExecutionData, IWorkflowBase, WorkflowExecuteMode } from 'n8n-workflow'; import type {
IBinaryKeyData,
IDataObject,
INode,
IRunExecutionData,
IWorkflowBase,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { createRunExecutionData, NodeApiError, NodeOperationError } from 'n8n-workflow'; import { createRunExecutionData, NodeApiError, NodeOperationError } from 'n8n-workflow';
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
@ -60,10 +67,25 @@ const BINARY_DATA = {
fileName: 'secret.txt', fileName: 'secret.txt',
}; };
const STACK_NODE: INode = {
id: 'node-1',
name: 'Test Node',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
type SensitiveItem = { json: IDataObject; binary?: IBinaryKeyData };
function buildRunExecutionData(opts: { function buildRunExecutionData(opts: {
policy?: 'none' | 'non-manual' | 'all'; policy?: 'none' | 'non-manual' | 'all';
channels?: { production: boolean; manual: boolean }; channels?: { production: boolean; manual: boolean };
mode?: WorkflowExecuteMode; mode?: WorkflowExecuteMode;
/** Item to plant in runData (and, with `withStack`, the executionData subtrees). */
item?: SensitiveItem;
/** Also plant the item in executionData.nodeExecutionStack and waitingExecution. */
withStack?: boolean;
}): IRunExecutionData { }): IRunExecutionData {
const redaction = opts.channels const redaction = opts.channels
? { ? {
@ -75,6 +97,27 @@ function buildRunExecutionData(opts: {
? { version: 1 as const, policy: opts.policy } ? { version: 1 as const, policy: opts.policy }
: undefined; : undefined;
const item: SensitiveItem = opts.item ?? {
json: { ...SENSITIVE_JSON },
binary: { file: { ...BINARY_DATA } },
};
const runtimeData = redaction
? {
version: 1 as const,
establishedAt: Date.now(),
source: opts.mode ?? 'trigger',
redaction,
}
: undefined;
const stackSubtrees = opts.withStack
? {
nodeExecutionStack: [{ node: STACK_NODE, source: null, data: { main: [[{ ...item }]] } }],
waitingExecution: { 'Test Node': { 0: { main: [[{ ...item }]] } } },
}
: undefined;
return createRunExecutionData({ return createRunExecutionData({
resultData: { resultData: {
runData: { runData: {
@ -85,30 +128,15 @@ function buildRunExecutionData(opts: {
executionTime: 0, executionTime: 0,
executionStatus: 'success', executionStatus: 'success',
source: [], source: [],
data: { data: { main: [[{ ...item }]] },
main: [
[
{
json: { ...SENSITIVE_JSON },
binary: { file: { ...BINARY_DATA } },
},
],
],
},
}, },
], ],
}, },
}, },
executionData: redaction executionData:
? { runtimeData || stackSubtrees
runtimeData: { ? { ...(runtimeData && { runtimeData }), ...stackSubtrees }
version: 1 as const, : undefined,
establishedAt: Date.now(),
source: opts.mode ?? 'trigger',
redaction,
},
}
: undefined,
}); });
} }
@ -117,11 +145,15 @@ async function createExecutionWithRedaction(opts: {
mode?: WorkflowExecuteMode; mode?: WorkflowExecuteMode;
policy?: 'none' | 'non-manual' | 'all'; policy?: 'none' | 'non-manual' | 'all';
channels?: { production: boolean; manual: boolean }; channels?: { production: boolean; manual: boolean };
item?: SensitiveItem;
withStack?: boolean;
}) { }) {
const runData = buildRunExecutionData({ const runData = buildRunExecutionData({
policy: opts.policy, policy: opts.policy,
channels: opts.channels, channels: opts.channels,
mode: opts.mode, mode: opts.mode,
item: opts.item,
withStack: opts.withStack,
}); });
return await createExecution( return await createExecution(
{ data: stringify(runData), mode: opts.mode ?? 'trigger' }, { data: stringify(runData), mode: opts.mode ?? 'trigger' },
@ -163,6 +195,21 @@ function assertNotRedacted(data: IRunExecutionData) {
expect(data.redactionInfo).toBeUndefined(); expect(data.redactionInfo).toBeUndefined();
} }
// Asserts the executionData subtrees (nodeExecutionStack, waitingExecution) had
// their item data cleared. Requires the execution to have been built with
// `withStack: true`.
function assertStackRedacted(data: IRunExecutionData) {
const stackItem = data.executionData!.nodeExecutionStack[0].data.main[0]![0];
expect(stackItem.json).toEqual({});
expect(stackItem.binary).toBeUndefined();
expect(stackItem.redaction).toMatchObject({ redacted: true });
const waitItem = data.executionData!.waitingExecution['Test Node'][0].main[0]![0];
expect(waitItem.json).toEqual({});
expect(waitItem.binary).toBeUndefined();
expect(waitItem.redaction).toMatchObject({ redacted: true });
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1017,3 +1064,163 @@ describe('GET /api/v1/executions — Execution Redaction', () => {
}); });
}); });
}); });
// ---------------------------------------------------------------------------
// executionData subtrees (nodeExecutionStack / waitingExecution) redaction
// ---------------------------------------------------------------------------
describe('executionData subtrees — redaction through DB round-trip', () => {
test('REST GET /executions/:id — stack and waiting items are redacted', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecutionWithRedaction({
workflow,
mode: 'trigger',
policy: 'all',
withStack: true,
});
const response = await testServer
.authAgentFor(owner)
.get(`/executions/${execution.id}`)
.expect(200);
const data = parseResponseData(response.body);
assertRedacted(data);
assertStackRedacted(data);
});
test('Public API GET /api/v1/executions/:id — stack and waiting items are redacted', async () => {
const workflow = await createWorkflow({}, publicApiMember);
const execution = await createExecutionWithRedaction({
workflow,
mode: 'trigger',
policy: 'all',
withStack: true,
});
const response = await testServer
.publicApiAgentFor(publicApiMember)
.get(`/executions/${execution.id}?includeData=true`)
.expect(200);
const data = response.body.data as IRunExecutionData;
assertRedacted(data, 'workflow_redaction_policy', true);
assertStackRedacted(data);
});
});
// ---------------------------------------------------------------------------
// Canary: a sensitive item value planted in every item-bearing subtree must
// not appear anywhere in a redacted response, on any surface that serves data.
// ---------------------------------------------------------------------------
describe('sensitive item values are not exposed across surfaces', () => {
const SENTINEL_JSON = 'IAM950-SENTINEL-JSON-8f3a2b';
const SENTINEL_BINARY = 'IAM950-SENTINEL-BINARY-8f3a2b';
const item: SensitiveItem = {
json: { deep: SENTINEL_JSON },
binary: { file: { mimeType: 'text/plain', fileName: 'x.txt', data: SENTINEL_BINARY } },
};
// Search the whole serialized response — surface-agnostic, so it catches the
// value regardless of which subtree (runData, stack, waiting) or encoding
// (flatted string vs. nested object) it hides in.
const exposesSentinel = (body: unknown): boolean => {
const serialized = JSON.stringify(body);
return serialized.includes(SENTINEL_JSON) || serialized.includes(SENTINEL_BINARY);
};
test('REST GET /executions/:id', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecutionWithRedaction({
workflow,
mode: 'trigger',
policy: 'all',
item,
withStack: true,
});
const response = await testServer
.authAgentFor(owner)
.get(`/executions/${execution.id}`)
.expect(200);
expect(exposesSentinel(response.body)).toBe(false);
});
test('REST GET /workflows/:workflowId/executions/last-successful', async () => {
const workflow = await createWorkflow({}, owner);
await createExecutionWithRedaction({
workflow,
mode: 'trigger',
policy: 'all',
item,
withStack: true,
});
const response = await testServer
.authAgentFor(owner)
.get(`/workflows/${workflow.id}/executions/last-successful`)
.expect(200);
expect(exposesSentinel(response.body)).toBe(false);
});
test('Public API GET /api/v1/executions/:id', async () => {
const workflow = await createWorkflow({}, publicApiOwner);
const execution = await createExecutionWithRedaction({
workflow,
mode: 'trigger',
policy: 'all',
item,
withStack: true,
});
const response = await testServer
.publicApiAgentFor(publicApiOwner)
.get(`/executions/${execution.id}?includeData=true`)
.expect(200);
expect(exposesSentinel(response.body)).toBe(false);
});
test('Public API GET /api/v1/executions', async () => {
const workflow = await createWorkflow({}, publicApiOwner);
await createExecutionWithRedaction({
workflow,
mode: 'trigger',
policy: 'all',
item,
withStack: true,
});
const response = await testServer
.publicApiAgentFor(publicApiOwner)
.get('/executions?includeData=true')
.expect(200);
expect(exposesSentinel(response.body)).toBe(false);
});
// Guards against a vacuously-green canary: on the reveal path the value must
// still be observable, proving the search would catch a real exposure.
test('control: revealed (unredacted) response does expose the sentinel', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecutionWithRedaction({
workflow,
mode: 'trigger',
policy: 'all',
item,
withStack: true,
});
const response = await testServer
.authAgentFor(owner)
.get(`/executions/${execution.id}`)
.query({ redactExecutionData: 'false' })
.expect(200);
expect(exposesSentinel(response.body)).toBe(true);
});
});

View File

@ -1,5 +1,5 @@
import { WorkflowEntity, WorkflowHistory } from '@n8n/db'; import { WorkflowEntity, WorkflowHistory } from '@n8n/db';
import type { INode } from 'n8n-workflow'; import { EXECUTE_WORKFLOW_NODE_TYPE, type INode } from 'n8n-workflow';
export const FIRST_CREDENTIAL_ID = '1'; export const FIRST_CREDENTIAL_ID = '1';
export const SECOND_CREDENTIAL_ID = '2'; export const SECOND_CREDENTIAL_ID = '2';
@ -8,6 +8,7 @@ export const THIRD_CREDENTIAL_ID = '3';
const NODE_WITH_NO_CRED = '0133467b-df4a-473d-9295-fdd9d01fa45a'; const NODE_WITH_NO_CRED = '0133467b-df4a-473d-9295-fdd9d01fa45a';
const NODE_WITH_ONE_CRED = '4673f869-f2dc-4a33-b053-ca3193bc5226'; const NODE_WITH_ONE_CRED = '4673f869-f2dc-4a33-b053-ca3193bc5226';
const NODE_WITH_TWO_CRED = '9b4208bd-8f10-4a6a-ad3b-da47a326f7da'; const NODE_WITH_TWO_CRED = '9b4208bd-8f10-4a6a-ad3b-da47a326f7da';
const NODE_WITH_INLINE_SUBWORKFLOW_CRED = 'a1f8c2e0-1b2c-4d3e-9f0a-1234567890ab';
const nodeWithNoCredentials: INode = { const nodeWithNoCredentials: INode = {
id: NODE_WITH_NO_CRED, id: NODE_WITH_NO_CRED,
@ -53,10 +54,39 @@ const nodeWithTwoCredentials: INode = {
parameters: {}, parameters: {},
}; };
// Execute Sub-workflow node whose inline `workflowJson` embeds a sub-workflow
// referencing a credential (FIRST_CREDENTIAL_ID) that is not exposed via the
// node's own top-level `credentials`.
const nodeWithInlineSubworkflowCredential: INode = {
id: NODE_WITH_INLINE_SUBWORKFLOW_CRED,
name: 'Execute Sub-workflow',
typeVersion: 1.2,
type: EXECUTE_WORKFLOW_NODE_TYPE,
position: [0, 0],
parameters: {
source: 'parameter',
workflowJson: JSON.stringify({
nodes: [
{
name: 'Steal',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
parameters: {},
credentials: {
httpHeaderAuth: { id: FIRST_CREDENTIAL_ID, name: 'First fake credential' },
},
},
],
connections: {},
}),
},
};
export function getWorkflow(options?: { export function getWorkflow(options?: {
addNodeWithoutCreds?: boolean; addNodeWithoutCreds?: boolean;
addNodeWithOneCred?: boolean; addNodeWithOneCred?: boolean;
addNodeWithTwoCreds?: boolean; addNodeWithTwoCreds?: boolean;
addNodeWithInlineSubworkflowCred?: boolean;
}) { }) {
const workflow = new WorkflowEntity(); const workflow = new WorkflowEntity();
@ -74,6 +104,10 @@ export function getWorkflow(options?: {
workflow.nodes.push(nodeWithTwoCredentials); workflow.nodes.push(nodeWithTwoCredentials);
} }
if (options?.addNodeWithInlineSubworkflowCred) {
workflow.nodes.push(nodeWithInlineSubworkflowCredential);
}
return workflow; return workflow;
} }

View File

@ -62,6 +62,7 @@ function signEmbedToken(overrides: Record<string, unknown> = {}): string {
exp: now + 30, exp: now + 30,
jti: randomUUID(), jti: randomUUID(),
email: `embed-${randomUUID()}@test.example.com`, email: `embed-${randomUUID()}@test.example.com`,
email_verified: true,
given_name: 'Test', given_name: 'Test',
family_name: 'User', family_name: 'User',
...overrides, ...overrides,

View File

@ -14,6 +14,7 @@ import {
qualifiedProviderId, qualifiedProviderId,
} from '@/modules/token-exchange/services/identity-resolution.service'; } from '@/modules/token-exchange/services/identity-resolution.service';
import { TrustedKeyService } from '@/modules/token-exchange/services/trusted-key.service'; import { TrustedKeyService } from '@/modules/token-exchange/services/trusted-key.service';
import { TokenExchangeConfig } from '@/modules/token-exchange/token-exchange.config';
import type { ExternalTokenClaims } from '@/modules/token-exchange/token-exchange.schemas'; import type { ExternalTokenClaims } from '@/modules/token-exchange/token-exchange.schemas';
import { createOwner, createUser } from '../shared/db/users'; import { createOwner, createUser } from '../shared/db/users';
@ -63,6 +64,11 @@ const baseClaims: ExternalTokenClaims = {
/** The issuer-scoped provider id a token-exchange identity is stored under. */ /** The issuer-scoped provider id a token-exchange identity is stored under. */
const providerIdFor = (sub: string, iss: string = baseClaims.iss) => qualifiedProviderId(iss, sub); const providerIdFor = (sub: string, iss: string = baseClaims.iss) => qualifiedProviderId(iss, sub);
/** Key context passed to resolve(); requireVerifiedEmail defaults off for these tests. */
function ctx(requireVerifiedEmail = false) {
return { kid: 'kid-1', issuer: baseClaims.iss, requireVerifiedEmail };
}
describe('IdentityResolutionService (integration)', () => { describe('IdentityResolutionService (integration)', () => {
describe('Path 1 — known sub', () => { describe('Path 1 — known sub', () => {
it('should resolve user by auth identity and return role with scopes', async () => { it('should resolve user by auth identity and return role with scopes', async () => {
@ -71,11 +77,15 @@ describe('IdentityResolutionService (integration)', () => {
AuthIdentity.create(user, providerIdFor('ext-known'), 'token-exchange'), AuthIdentity.create(user, providerIdFor('ext-known'), 'token-exchange'),
); );
const result = await service.resolve({ const result = await service.resolve(
...baseClaims, {
sub: 'ext-known', ...baseClaims,
email: 'known@example.com', sub: 'ext-known',
}); email: 'known@example.com',
},
undefined,
ctx(),
);
expect(result.id).toBe(user.id); expect(result.id).toBe(user.id);
expect(result.email).toBe('known@example.com'); expect(result.email).toBe('known@example.com');
@ -94,13 +104,17 @@ describe('IdentityResolutionService (integration)', () => {
AuthIdentity.create(user, providerIdFor('ext-unchanged'), 'token-exchange'), AuthIdentity.create(user, providerIdFor('ext-unchanged'), 'token-exchange'),
); );
const result = await service.resolve({ const result = await service.resolve(
...baseClaims, {
sub: 'ext-unchanged', ...baseClaims,
email: 'unchanged@example.com', sub: 'ext-unchanged',
given_name: 'Same', email: 'unchanged@example.com',
family_name: 'Name', given_name: 'Same',
}); family_name: 'Name',
},
undefined,
ctx(),
);
expect(result.id).toBe(user.id); expect(result.id).toBe(user.id);
@ -120,12 +134,12 @@ describe('IdentityResolutionService (integration)', () => {
}; };
// First call: email fallback creates the identity link // First call: email fallback creates the identity link
const result = await service.resolve(claims); const result = await service.resolve(claims, undefined, ctx());
expect(result.id).toBe(user.id); expect(result.id).toBe(user.id);
// Second call: should now resolve via Path 1 (known sub), // Second call: should now resolve via Path 1 (known sub),
// proving the identity was correctly linked to the user // proving the identity was correctly linked to the user
const secondResult = await service.resolve(claims); const secondResult = await service.resolve(claims, undefined, ctx());
expect(secondResult.id).toBe(user.id); expect(secondResult.id).toBe(user.id);
expect(secondResult.role).toBeDefined(); expect(secondResult.role).toBeDefined();
expect(secondResult.role.slug).toBe('global:member'); expect(secondResult.role.slug).toBe('global:member');
@ -141,7 +155,8 @@ describe('IdentityResolutionService (integration)', () => {
email: 'fallback-role@example.com', email: 'fallback-role@example.com',
role: 'global:admin', role: 'global:admin',
}, },
['global:admin'], ['global:member', 'global:admin'],
ctx(),
); );
expect(result.id).toBe(user.id); expect(result.id).toBe(user.id);
@ -157,11 +172,15 @@ describe('IdentityResolutionService (integration)', () => {
it('should match existing user when email claim has different casing', async () => { it('should match existing user when email claim has different casing', async () => {
const user = await createUser({ email: 'casetest@example.com' }); const user = await createUser({ email: 'casetest@example.com' });
const result = await service.resolve({ const result = await service.resolve(
...baseClaims, {
sub: 'ext-case', ...baseClaims,
email: 'CaseTest@Example.COM', sub: 'ext-case',
}); email: 'CaseTest@Example.COM',
},
undefined,
ctx(),
);
expect(result.id).toBe(user.id); expect(result.id).toBe(user.id);
@ -183,7 +202,7 @@ describe('IdentityResolutionService (integration)', () => {
family_name: 'Tee', family_name: 'Tee',
}; };
const result = await service.resolve(claims); const result = await service.resolve(claims, undefined, ctx());
expect(result.email).toBe('jit@example.com'); expect(result.email).toBe('jit@example.com');
expect(result.firstName).toBe('Jay'); expect(result.firstName).toBe('Jay');
@ -225,6 +244,7 @@ describe('IdentityResolutionService (integration)', () => {
service.resolve( service.resolve(
{ ...baseClaims, sub: 'ext-rejected', email: 'rejected@example.com', role }, { ...baseClaims, sub: 'ext-rejected', email: 'rejected@example.com', role },
allowedRoles, allowedRoles,
ctx(),
), ),
).rejects.toThrow(errorMsg); ).rejects.toThrow(errorMsg);
}); });
@ -232,19 +252,23 @@ describe('IdentityResolutionService (integration)', () => {
it('should throw when email is missing and no identity match exists', async () => { it('should throw when email is missing and no identity match exists', async () => {
const claimsWithoutEmail = { ...baseClaims, sub: 'ext-no-email', email: undefined }; const claimsWithoutEmail = { ...baseClaims, sub: 'ext-no-email', email: undefined };
await expect(service.resolve(claimsWithoutEmail)).rejects.toThrow( await expect(service.resolve(claimsWithoutEmail, undefined, ctx())).rejects.toThrow(
'Email claim is required for user provisioning', 'Email claim is required for user provisioning',
); );
}); });
it('should throw on unknown role claim for new user', async () => { it('should throw on unknown role claim for new user', async () => {
await expect( await expect(
service.resolve({ service.resolve(
...baseClaims, {
sub: 'ext-jit-unknown-role', ...baseClaims,
email: 'jit-unknown-role@example.com', sub: 'ext-jit-unknown-role',
role: 'global:nonsense', email: 'jit-unknown-role@example.com',
}), role: 'global:nonsense',
},
undefined,
ctx(),
),
).rejects.toThrow("Unrecognized role 'global:nonsense' cannot be assigned to new user"); ).rejects.toThrow("Unrecognized role 'global:nonsense' cannot be assigned to new user");
}); });
@ -257,6 +281,7 @@ describe('IdentityResolutionService (integration)', () => {
role: 'global:admin', role: 'global:admin',
}, },
['global:admin', 'global:member'], ['global:admin', 'global:member'],
ctx(),
); );
expect(result.role.slug).toBe('global:admin'); expect(result.role.slug).toBe('global:admin');
@ -270,33 +295,31 @@ describe('IdentityResolutionService (integration)', () => {
}); });
describe('profile and role sync', () => { describe('profile and role sync', () => {
it('should allow global:owner user to log in without changing their role', async () => { it('rejects a global:owner user by default (excludeOwner)', async () => {
Container.get(TokenExchangeConfig).excludeOwner = true;
const owner = await createOwner(); const owner = await createOwner();
await authIdentityRepository.save( await authIdentityRepository.save(
AuthIdentity.create(owner, providerIdFor('ext-owner'), 'token-exchange'), AuthIdentity.create(owner, providerIdFor('ext-owner'), 'token-exchange'),
); );
const result = await service.resolve({ await expect(
...baseClaims, service.resolve(
sub: 'ext-owner', {
email: owner.email, ...baseClaims,
role: 'global:owner', sub: 'ext-owner',
}); email: owner.email,
role: 'global:owner',
expect(result.id).toBe(owner.id); },
expect(result.role.slug).toBe('global:owner'); undefined,
ctx(),
const dbUser = await userRepository.findOne({ ),
where: { id: owner.id }, ).rejects.toThrow('User role is not allowed for this key');
relations: ['role'],
});
expect(dbUser!.role.slug).toBe('global:owner');
}); });
it('should throw when claimed role is not in allowedRoles for known identity', async () => { it('should throw when claimed role is not in allowedRoles for known identity', async () => {
const admin = await createUser({ email: 'admin-keep@example.com', role: GLOBAL_ADMIN_ROLE }); const user = await createUser({ email: 'admin-keep@example.com' });
await authIdentityRepository.save( await authIdentityRepository.save(
AuthIdentity.create(admin, providerIdFor('ext-admin-keep'), 'token-exchange'), AuthIdentity.create(user, providerIdFor('ext-admin-keep'), 'token-exchange'),
); );
await expect( await expect(
@ -308,12 +331,13 @@ describe('IdentityResolutionService (integration)', () => {
role: 'global:admin', role: 'global:admin',
}, },
['global:member'], ['global:member'],
ctx(),
), ),
).rejects.toThrow("Role 'global:admin' is not allowed for this token exchange key"); ).rejects.toThrow("Role 'global:admin' is not allowed for this token exchange key");
}); });
it('should throw when claimed role is not in allowedRoles for email fallback', async () => { it('should throw when claimed role is not in allowedRoles for email fallback', async () => {
await createUser({ email: 'admin-email@example.com', role: GLOBAL_ADMIN_ROLE }); await createUser({ email: 'admin-email@example.com' });
await expect( await expect(
service.resolve( service.resolve(
@ -324,6 +348,7 @@ describe('IdentityResolutionService (integration)', () => {
role: 'global:admin', role: 'global:admin',
}, },
['global:member'], ['global:member'],
ctx(),
), ),
).rejects.toThrow("Role 'global:admin' is not allowed for this token exchange key"); ).rejects.toThrow("Role 'global:admin' is not allowed for this token exchange key");
@ -340,12 +365,16 @@ describe('IdentityResolutionService (integration)', () => {
AuthIdentity.create(user, providerIdFor('ext-unknown-role'), 'token-exchange'), AuthIdentity.create(user, providerIdFor('ext-unknown-role'), 'token-exchange'),
); );
const result = await service.resolve({ const result = await service.resolve(
...baseClaims, {
sub: 'ext-unknown-role', ...baseClaims,
email: 'unknown-role@example.com', sub: 'ext-unknown-role',
role: 'global:nonsense', email: 'unknown-role@example.com',
}); role: 'global:nonsense',
},
undefined,
ctx(),
);
expect(result.id).toBe(user.id); expect(result.id).toBe(user.id);
expect(result.role.slug).toBe('global:member'); expect(result.role.slug).toBe('global:member');
@ -357,12 +386,16 @@ describe('IdentityResolutionService (integration)', () => {
AuthIdentity.create(user, providerIdFor('ext-escalation'), 'token-exchange'), AuthIdentity.create(user, providerIdFor('ext-escalation'), 'token-exchange'),
); );
const result = await service.resolve({ const result = await service.resolve(
...baseClaims, {
sub: 'ext-escalation', ...baseClaims,
email: 'escalation@example.com', sub: 'ext-escalation',
role: 'global:owner', email: 'escalation@example.com',
}); role: 'global:owner',
},
undefined,
ctx(),
);
expect(result.id).toBe(user.id); expect(result.id).toBe(user.id);
expect(result.role.slug).toBe('global:member'); expect(result.role.slug).toBe('global:member');
@ -385,6 +418,7 @@ describe('IdentityResolutionService (integration)', () => {
role: 'global:member', role: 'global:member',
}, },
['global:admin', 'global:member'], ['global:admin', 'global:member'],
ctx(),
); );
expect(result.id).toBe(admin.id); expect(result.id).toBe(admin.id);
@ -416,7 +450,8 @@ describe('IdentityResolutionService (integration)', () => {
family_name: 'Last', family_name: 'Last',
role: 'global:admin', role: 'global:admin',
}, },
['global:admin'], ['global:member', 'global:admin'],
ctx(),
); );
expect(result.firstName).toBe('New'); expect(result.firstName).toBe('New');
@ -440,18 +475,26 @@ describe('IdentityResolutionService (integration)', () => {
const issuerA = 'https://issuer-a.example.com'; const issuerA = 'https://issuer-a.example.com';
const issuerB = 'https://issuer-b.example.com'; const issuerB = 'https://issuer-b.example.com';
const userA = await service.resolve({ const userA = await service.resolve(
...baseClaims, {
sub: sharedSub, ...baseClaims,
iss: issuerA, sub: sharedSub,
email: 'a@example.com', iss: issuerA,
}); email: 'a@example.com',
const userB = await service.resolve({ },
...baseClaims, undefined,
sub: sharedSub, ctx(),
iss: issuerB, );
email: 'b@example.com', const userB = await service.resolve(
}); {
...baseClaims,
sub: sharedSub,
iss: issuerB,
email: 'b@example.com',
},
undefined,
ctx(),
);
expect(userB.id).not.toBe(userA.id); expect(userB.id).not.toBe(userA.id);
@ -470,7 +513,11 @@ describe('IdentityResolutionService (integration)', () => {
const emitSpy = vi.spyOn(eventService, 'emit'); const emitSpy = vi.spyOn(eventService, 'emit');
// No email claim — the rebind must work for email-less integrations. // No email claim — the rebind must work for email-less integrations.
const result = await service.resolve({ ...baseClaims, sub: 'legacy-sub', email: undefined }); const result = await service.resolve(
{ ...baseClaims, sub: 'legacy-sub', email: undefined },
undefined,
ctx(),
);
expect(result.id).toBe(user.id); expect(result.id).toBe(user.id);
@ -493,7 +540,11 @@ describe('IdentityResolutionService (integration)', () => {
// An email-less token sharing a subject cannot be safely attributed to one issuer. // An email-less token sharing a subject cannot be safely attributed to one issuer.
await expect( await expect(
service.resolve({ ...baseClaims, sub: 'legacy-multi-sub', email: undefined }), service.resolve(
{ ...baseClaims, sub: 'legacy-multi-sub', email: undefined },
undefined,
ctx(),
),
).rejects.toThrow('Email claim is required for user provisioning'); ).rejects.toThrow('Email claim is required for user provisioning');
const identities = await authIdentityRepository.findBy({ providerType: 'token-exchange' }); const identities = await authIdentityRepository.findBy({ providerType: 'token-exchange' });

View File

@ -43,6 +43,7 @@ function makeExternalJwt(
exp: number; exp: number;
jti: string; jti: string;
email: string; email: string;
email_verified: boolean;
given_name: string; given_name: string;
family_name: string; family_name: string;
role: string; role: string;
@ -58,6 +59,7 @@ function makeExternalJwt(
iat: now, iat: now,
exp: now + 300, exp: now + 300,
jti: randomUUID(), jti: randomUUID(),
email_verified: true,
...overrides, ...overrides,
}, },
privateKey, privateKey,

View File

@ -108,6 +108,14 @@ describe('EnterpriseWorkflowService', () => {
service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []); service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []);
}).toThrow(); }).toThrow();
}); });
it('Should throw error saving a workflow adding an Execute Sub-workflow node whose inline JSON uses an inaccessible credential', () => {
const newWorkflowVersion = getWorkflow({ addNodeWithInlineSubworkflowCred: true });
const previousWorkflowVersion = getWorkflow();
expect(() => {
service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []);
}).toThrow();
});
}); });
describe('getNodesWithInaccessibleCreds', () => { describe('getNodesWithInaccessibleCreds', () => {
@ -188,5 +196,19 @@ describe('EnterpriseWorkflowService', () => {
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []); const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []);
expect(nodesWithInaccessibleCreds).toHaveLength(2); expect(nodesWithInaccessibleCreds).toHaveLength(2);
}); });
test('Should flag an Execute Sub-workflow node referencing an inaccessible credential inside its inline workflow JSON', () => {
const workflow = getWorkflow({ addNodeWithInlineSubworkflowCred: true });
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []);
expect(nodesWithInaccessibleCreds).toHaveLength(1);
});
test('Should not flag an Execute Sub-workflow node when the inline credential is accessible', () => {
const workflow = getWorkflow({ addNodeWithInlineSubworkflowCred: true });
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [
FIRST_CREDENTIAL_ID,
]);
expect(nodesWithInaccessibleCreds).toHaveLength(0);
});
}); });
}); });

View File

@ -1,6 +1,6 @@
{ {
"name": "n8n-core", "name": "n8n-core",
"version": "2.32.0", "version": "2.32.1",
"description": "Core functionality of n8n", "description": "Core functionality of n8n",
"main": "dist/index", "main": "dist/index",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -779,6 +779,78 @@ describe('RoutingNode', () => {
expect(result).toEqual(testData.output); expect(result).toEqual(testData.output);
}); });
} }
describe('when a routed property name is an inherited object member', () => {
// Guard the shared prototype so a regression here cannot cascade to other tests.
const hadOwnCall = Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call');
afterEach(() => {
if (!hadOwnCall) {
delete (Object.prototype.toString as unknown as { call?: unknown }).call;
}
});
it('keeps built-in object prototypes intact', () => {
const nodeTypeProperties: INodeProperties = {
displayName: 'Value',
name: 'value',
type: 'string',
routing: {
send: {
property: 'toString.call',
type: 'body',
value: 'x',
},
},
default: '',
};
node.parameters = {};
nodeType.description.properties = [nodeTypeProperties];
const workflow = new Workflow({
nodes: workflowData.nodes,
connections: workflowData.connections,
active: false,
nodeTypes,
});
const executeFunctions = mock<executionContexts.ExecuteContext>();
Object.assign(executeFunctions, {
runIndex,
additionalData,
workflow,
node,
mode,
connectionInputData,
runExecutionData,
nodeType,
});
const routingNode = new RoutingNode(executeFunctions, nodeType);
const executeSingleFunctions = getExecuteSingleFunctions(
workflow,
runExecutionData,
runIndex,
node,
itemIndex,
);
const result = routingNode.getRequestOptionsFromParameters(
executeSingleFunctions,
nodeTypeProperties,
itemIndex,
runIndex,
path,
{},
);
// Prototype untouched; the value lands as an own property on the request body.
expect(Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call')).toBe(false);
expect(Object.prototype.toString.call([])).toBe('[object Array]');
expect((result?.options.body as { toString?: { call?: unknown } }).toString?.call).toBe(
'x',
);
});
});
}); });
describe('runNode', () => { describe('runNode', () => {

View File

@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "@n8n/frontend-module-sdk", "name": "@n8n/frontend-module-sdk",
"version": "0.2.0", "version": "0.2.1",
"main": "src/index.ts", "main": "src/index.ts",
"import": "src/index.ts", "import": "src/index.ts",
"exports": { "exports": {

View File

@ -1,7 +1,7 @@
{ {
"name": "@n8n/i18n", "name": "@n8n/i18n",
"type": "module", "type": "module",
"version": "2.32.0", "version": "2.32.2",
"files": [ "files": [
"dist" "dist"
], ],

View File

@ -2166,7 +2166,6 @@
"nodeCreator.nodeItem.triggerIconTitle": "Trigger Node", "nodeCreator.nodeItem.triggerIconTitle": "Trigger Node",
"nodeCreator.nodeItem.aiIconTitle": "LangChain AI Node", "nodeCreator.nodeItem.aiIconTitle": "LangChain AI Node",
"nodeCreator.nodeItem.deprecated": "Deprecated", "nodeCreator.nodeItem.deprecated": "Deprecated",
"nodeCreator.nodeItem.deprecatingSoon": "Deprecating soon",
"nodeCreator.nodeItem.earlyPreview": "Early preview", "nodeCreator.nodeItem.earlyPreview": "Early preview",
"nodeCreator.nodeItem.beta": "Beta", "nodeCreator.nodeItem.beta": "Beta",
"nodeCredentials.createNew": "Create new credential", "nodeCredentials.createNew": "Create new credential",
@ -7186,11 +7185,11 @@
"agents.builder.vectorStores.useWhen.label": "When should this agent search this store?", "agents.builder.vectorStores.useWhen.label": "When should this agent search this store?",
"agents.builder.vectorStores.useWhen.hint": "Tell the agent when to search this vector store.", "agents.builder.vectorStores.useWhen.hint": "Tell the agent when to search this vector store.",
"agents.builder.vectorStores.useWhen.placeholder": "Search for product documentation when the user asks about features, setup, or troubleshooting.", "agents.builder.vectorStores.useWhen.placeholder": "Search for product documentation when the user asks about features, setup, or troubleshooting.",
"agents.builder.subAgents.title": "Agents", "agents.builder.subAgents.title": "Sub-agents",
"agents.builder.subAgents.description": "This agent can delegate focused subtasks. Add published agents from this project when you want reusable specialists.", "agents.builder.subAgents.description": "This agent can delegate focused subtasks. Add published agents from this project when you want reusable specialists.",
"agents.builder.subAgents.settings.title": "Sub-agent settings", "agents.builder.subAgents.settings.title": "Sub-agent settings",
"agents.builder.subAgents.settings.description": "Configure delegation limits and inline sub-agent model choices.", "agents.builder.subAgents.settings.description": "Configure delegation limits and inline sub-agent model choices.",
"agents.builder.subAgents.add": "Add agent", "agents.builder.subAgents.add": "Add sub-agent",
"agents.builder.subAgents.loadError": "Could not load project agents", "agents.builder.subAgents.loadError": "Could not load project agents",
"agents.builder.subAgents.remove": "Remove {name}", "agents.builder.subAgents.remove": "Remove {name}",
"agents.builder.subAgents.useWhen.label": "When should this agent be used?", "agents.builder.subAgents.useWhen.label": "When should this agent be used?",

View File

@ -1,7 +1,7 @@
{ {
"name": "@n8n/rest-api-client", "name": "@n8n/rest-api-client",
"type": "module", "type": "module",
"version": "2.32.0", "version": "2.32.1",
"files": [ "files": [
"dist" "dist"
], ],

View File

@ -1,7 +1,7 @@
{ {
"name": "@n8n/stores", "name": "@n8n/stores",
"type": "module", "type": "module",
"version": "2.32.0", "version": "2.32.1",
"files": [ "files": [
"dist" "dist"
], ],

View File

@ -1,6 +1,6 @@
{ {
"name": "n8n-editor-ui", "name": "n8n-editor-ui",
"version": "2.32.0", "version": "2.32.3",
"description": "Workflow Editor UI for n8n", "description": "Workflow Editor UI for n8n",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View File

@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import type { INodeParameters } from 'n8n-workflow';
import { setParameterValue } from './parameterUtils'; import { setParameterValue } from './parameterUtils';
describe('parameterUtils', () => { describe('parameterUtils', () => {
@ -62,4 +63,38 @@ describe('parameterUtils', () => {
expect(target).toEqual({ foo: 'bar' }); expect(target).toEqual({ foo: 'bar' });
}); });
}); });
describe('dot-notation paths that name inherited members', () => {
// Guard the shared prototype so a regression in one test cannot cascade to others.
const hadOwnCall = Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call');
afterEach(() => {
if (!hadOwnCall) {
delete (Object.prototype.toString as unknown as { call?: unknown }).call;
}
});
it('keeps built-in object prototypes intact for a top-level inherited key', () => {
const target: INodeParameters = {};
setParameterValue(target, 'toString.call', 'x');
// The shared prototype member must be untouched.
expect(Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call')).toBe(false);
expect(Object.prototype.toString.call([])).toBe('[object Array]');
// The value lands as a plain own property on the target instead.
expect(Object.prototype.hasOwnProperty.call(target, 'toString')).toBe(true);
expect((target as { toString: { call: unknown } }).toString.call).toBe('x');
});
it('keeps built-in object prototypes intact for a nested inherited key', () => {
const target: INodeParameters = {};
setParameterValue(target, 'a.toString.call', 'y');
expect(Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call')).toBe(false);
expect(Object.prototype.toString.call({})).toBe('[object Object]');
expect((target as { a: { toString: { call: unknown } } }).a.toString.call).toBe('y');
});
});
}); });

View File

@ -284,8 +284,12 @@ vi.mock('@/features/ai/instanceAi/composables/useInstanceAiAvailability', () =>
})); }));
const startInstanceAiThread = vi.fn(); const startInstanceAiThread = vi.fn();
const openAgentArtifactThread = vi.fn();
vi.mock('@/features/ai/instanceAi/composables/useInstanceAiHandoff', () => ({ vi.mock('@/features/ai/instanceAi/composables/useInstanceAiHandoff', () => ({
useInstanceAiHandoff: () => ({ startThread: startInstanceAiThread }), useInstanceAiHandoff: () => ({
startThread: startInstanceAiThread,
openAgentArtifactThread,
}),
})); }));
const baseTextFn = (key: string) => { const baseTextFn = (key: string) => {
@ -532,6 +536,7 @@ function resetViewMocks() {
favoritesStoreMock.isFavorite.mockReturnValue(false); favoritesStoreMock.isFavorite.mockReturnValue(false);
instanceAiAvailableRef.value = true; instanceAiAvailableRef.value = true;
startInstanceAiThread.mockReset(); startInstanceAiThread.mockReset();
openAgentArtifactThread.mockReset();
} }
describe('AgentBuilderView — preview routing', () => { describe('AgentBuilderView — preview routing', () => {
@ -1165,19 +1170,18 @@ describe('AgentBuilderView — three-column shell', () => {
expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(false); expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(false);
}); });
it('starts an Instance AI thread with the agent attached on click', async () => { it('opens the agent artifact without sending an opening message', async () => {
const wrapper = await renderView(); const wrapper = await renderView();
await wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').trigger('click'); await wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').trigger('click');
await flushPromises(); await flushPromises();
expect(startInstanceAiThread).toHaveBeenCalledWith('p1', '', [ expect(openAgentArtifactThread).toHaveBeenCalledWith({
{ type: 'agent',
type: 'agent', id: 'a1',
id: 'a1', name: 'Agent One',
name: 'Agent One', projectId: 'p1',
projectId: 'p1', });
}, expect(startInstanceAiThread).not.toHaveBeenCalled();
]);
}); });
it('renders artifact mode with the editor and without the build chat', async () => { it('renders artifact mode with the editor and without the build chat', async () => {

View File

@ -0,0 +1,126 @@
import { flushPromises, mount } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import NewAgentView from '../views/NewAgentView.vue';
import { INSTANCE_AI_THREAD_VIEW } from '@/features/ai/instanceAi/constants';
import { AGENTS_LIST_VIEW, PROJECT_AGENTS } from '../constants';
import type { AgentResource } from '../types';
const mocks = vi.hoisted(() => ({
route: { query: { projectId: 'project-1' } as Record<string, string> },
replace: vi.fn(),
createAgent: vi.fn(),
upsertProjectAgentsListCache: vi.fn(),
track: vi.fn(),
showError: vi.fn(),
syncThread: vi.fn(),
updateThreadMetadata: vi.fn(),
getOrCreateRuntime: vi.fn(() => ({ sendMessage: vi.fn() })),
stashPendingAgentAttachment: vi.fn(),
}));
vi.mock('vue-router', () => ({
useRoute: () => mocks.route,
useRouter: () => ({ replace: mocks.replace }),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: { baseUrl: '/rest', pushRef: 'push-ref' } }),
}));
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (key: string) => key }),
}));
vi.mock('@/app/composables/useTelemetry', () => ({
useTelemetry: () => ({ track: mocks.track }),
}));
vi.mock('@/app/composables/useToast', () => ({
useToast: () => ({ showError: mocks.showError }),
}));
vi.mock('@/features/ai/instanceAi/instanceAi.store', () => ({
useInstanceAiStore: () => ({
syncThread: mocks.syncThread,
updateThreadMetadata: mocks.updateThreadMetadata,
getOrCreateRuntime: mocks.getOrCreateRuntime,
}),
}));
vi.mock('@/features/ai/instanceAi/composables/useInstanceAiHandoff', () => ({
stashPendingAgentAttachment: mocks.stashPendingAgentAttachment,
}));
vi.mock('uuid', () => ({ v4: () => 'thread-1' }));
vi.mock('../composables/useAgentApi', () => ({ createAgent: mocks.createAgent }));
vi.mock('../composables/useProjectAgentsList', () => ({
upsertProjectAgentsListCache: mocks.upsertProjectAgentsListCache,
}));
describe('NewAgentView', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.route.query = { projectId: 'project-1' };
});
it('creates a blank agent and opens it in an empty Instance AI thread', async () => {
const agent = { id: 'agent-1', name: 'New agent' } as AgentResource;
mocks.createAgent.mockResolvedValue(agent);
mount(NewAgentView);
await flushPromises();
expect(mocks.createAgent).toHaveBeenCalledOnce();
expect(mocks.createAgent).toHaveBeenCalledWith(
{ baseUrl: '/rest', pushRef: 'push-ref' },
'project-1',
'agents.new.defaultName',
);
expect(mocks.upsertProjectAgentsListCache).toHaveBeenCalledWith('project-1', agent);
expect(mocks.track).toHaveBeenCalledWith('User created agent', {
agent_id: 'agent-1',
source: 'create_blank',
});
expect(mocks.syncThread).toHaveBeenCalledWith('thread-1', 'project-1');
expect(mocks.updateThreadMetadata).toHaveBeenCalledWith('thread-1', {
instanceAiAgentBuilderTarget: {
agentId: 'agent-1',
projectId: 'project-1',
name: 'New agent',
},
});
expect(mocks.stashPendingAgentAttachment).toHaveBeenCalledWith('thread-1', {
type: 'agent',
id: 'agent-1',
name: 'New agent',
projectId: 'project-1',
});
expect(mocks.getOrCreateRuntime).not.toHaveBeenCalled();
expect(mocks.replace).toHaveBeenCalledWith({
name: INSTANCE_AI_THREAD_VIEW,
params: { threadId: 'thread-1' },
});
});
it('returns to the agents list when no project was provided', async () => {
mocks.route.query = {};
mount(NewAgentView);
await flushPromises();
expect(mocks.createAgent).not.toHaveBeenCalled();
expect(mocks.showError).toHaveBeenCalledWith(
expect.any(Error),
'agentSelector.createAgentFailed',
);
expect(mocks.replace).toHaveBeenCalledWith({ name: AGENTS_LIST_VIEW });
});
it('returns to the project agents list when creation fails', async () => {
const error = new Error('create failed');
mocks.createAgent.mockRejectedValue(error);
mount(NewAgentView);
await flushPromises();
expect(mocks.showError).toHaveBeenCalledWith(error, 'agentSelector.createAgentFailed');
expect(mocks.replace).toHaveBeenCalledWith({
name: PROJECT_AGENTS,
params: { projectId: 'project-1' },
});
});
});

View File

@ -5,6 +5,7 @@ import {
AGENT_PREVIEW_VIEW, AGENT_PREVIEW_VIEW,
PROJECT_AGENTS, PROJECT_AGENTS,
} from '../constants'; } from '../constants';
import { AgentsModule } from '../module.descriptor';
describe('Agent constants', () => { describe('Agent constants', () => {
it('exports all required route names', () => { it('exports all required route names', () => {
@ -13,4 +14,13 @@ describe('Agent constants', () => {
expect(AGENT_PREVIEW_VIEW).toBe('AgentPreviewView'); expect(AGENT_PREVIEW_VIEW).toBe('AgentPreviewView');
expect(PROJECT_AGENTS).toBe('ProjectAgents'); expect(PROJECT_AGENTS).toBe('ProjectAgents');
}); });
it('registers the new-agent transition route', () => {
expect(AgentsModule.routes).toContainEqual(
expect.objectContaining({
name: 'NewAgentView',
path: '/new-agent',
}),
);
});
}); });

View File

@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAgentChannelSetup } from '../composables/useAgentChannelSetup';
const {
fetchAllCredentialsForWorkflowMock,
fetchProjectMock,
projectsStoreMock,
setCredentialsMock,
} = vi.hoisted(() => ({
fetchAllCredentialsForWorkflowMock: vi.fn(),
fetchProjectMock: vi.fn(),
projectsStoreMock: {
currentProject: null as { id: string; scopes?: string[] } | null,
personalProject: null as { id: string; scopes?: string[] } | null,
myProjects: [] as Array<{ id: string; scopes?: string[] }>,
fetchProject: vi.fn(),
},
setCredentialsMock: vi.fn(),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: {} }),
}));
vi.mock('@/app/stores/ui.store', () => ({
useUIStore: () => ({
isModalActiveById: {},
openNewCredential: vi.fn(),
openExistingCredential: vi.fn(),
}),
}));
vi.mock('@/features/credentials/credentials.store', () => ({
useCredentialsStore: () => ({
setCredentials: setCredentialsMock,
fetchAllCredentialsForWorkflow: fetchAllCredentialsForWorkflowMock,
getCredentialTypeByName: vi.fn(),
}),
}));
vi.mock('@/features/collaboration/projects/projects.store', () => ({
useProjectsStore: () => projectsStoreMock,
}));
function createChannelSetup() {
return useAgentChannelSetup({
projectId: () => 'artifact-project',
agentId: () => 'agent-1',
currentIntegration: null,
connectedCredentials: {},
fetchStatus: vi.fn().mockResolvedValue(undefined),
isIntegrationConnected: () => false,
});
}
describe('useAgentChannelSetup', () => {
beforeEach(() => {
vi.clearAllMocks();
projectsStoreMock.currentProject = null;
projectsStoreMock.personalProject = null;
projectsStoreMock.myProjects = [];
projectsStoreMock.fetchProject = fetchProjectMock;
fetchAllCredentialsForWorkflowMock.mockResolvedValue([]);
fetchProjectMock.mockResolvedValue({
id: 'artifact-project',
name: 'Artifact project',
icon: null,
type: 'team',
description: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
relations: [],
scopes: ['credential:create'],
rolesManaged: false,
});
});
it('uses project scopes already available in the store', async () => {
projectsStoreMock.myProjects = [{ id: 'artifact-project', scopes: ['credential:create'] }];
const { credentialPermissions, loadChannelState } = createChannelSetup();
await loadChannelState([]);
expect(credentialPermissions.value.create).toBe(true);
expect(fetchProjectMock).not.toHaveBeenCalled();
});
it('loads scopes when an artifact project is missing from the store', async () => {
// AGENT-443: artifact mode supplies its project through props rather than the route.
const { credentialPermissions, loadChannelState } = createChannelSetup();
expect(credentialPermissions.value.create).toBe(false);
await loadChannelState([]);
expect(fetchProjectMock).toHaveBeenCalledWith('artifact-project');
expect(credentialPermissions.value.create).toBe(true);
});
});

View File

@ -857,7 +857,7 @@ function handleChannelDisconnected(channelType: string) {
} }
.rowLabel { .rowLabel {
flex: 0 0 var(--spacing--3xl); flex: 0 0 calc(var(--spacing--3xl) + var(--spacing--sm));
line-height: var(--height--lg); line-height: var(--height--lg);
font-size: var(--font-size--sm); font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium); font-weight: var(--font-weight--medium);

View File

@ -11,6 +11,7 @@ import {
} from '@n8n/design-system'; } from '@n8n/design-system';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons'; import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { FocusScope } from 'reka-ui';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useAgentChannelSetup } from '../composables/useAgentChannelSetup'; import { useAgentChannelSetup } from '../composables/useAgentChannelSetup';
import { useAgentIntegrationStatus } from '../composables/useAgentIntegrationStatus'; import { useAgentIntegrationStatus } from '../composables/useAgentIntegrationStatus';
@ -245,6 +246,15 @@ watch(
@interact-outside="(e) => e.preventDefault()" @interact-outside="(e) => e.preventDefault()"
@update:open="$emit('update:open', $event)" @update:open="$emit('update:open', $event)"
> >
<FocusScope
v-if="credentialModalOpen"
as-child
@mount-auto-focus.prevent
@unmount-auto-focus.prevent
>
<span hidden aria-hidden="true" />
</FocusScope>
<N8nDialogHeader :class="$style.customHeader"> <N8nDialogHeader :class="$style.customHeader">
<Transition name="channel-header-fade" mode="out-in"> <Transition name="channel-header-fade" mode="out-in">
<div v-if="currentView === 'list'" key="list" :class="$style.headerContent"> <div v-if="currentView === 'list'" key="list" :class="$style.headerContent">
@ -277,7 +287,7 @@ watch(
</Transition> </Transition>
</N8nDialogHeader> </N8nDialogHeader>
<div :class="$style.container"> <div data-testid="agent-channel-modal" :class="$style.container">
<Transition name="channel-view-fade" mode="out-in"> <Transition name="channel-view-fade" mode="out-in">
<div v-if="currentView === 'list'" key="list" :class="$style.listView"> <div v-if="currentView === 'list'" key="list" :class="$style.listView">
<ul :class="$style.channelList"> <ul :class="$style.channelList">
@ -467,6 +477,13 @@ watch(
</N8nDialog> </N8nDialog>
</template> </template>
<style lang="scss">
body:has([data-testid='agent-channel-modal'])
.el-overlay:has([data-test-id='editCredential-modal']) {
pointer-events: auto;
}
</style>
<style module lang="scss"> <style module lang="scss">
@use '@n8n/design-system/css/mixins/motion'; @use '@n8n/design-system/css/mixins/motion';

View File

@ -7,6 +7,7 @@ import { useUIStore } from '@/app/stores/ui.store';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants'; import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
import { useCredentialsStore } from '@/features/credentials/credentials.store'; import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import type { Project } from '@/features/collaboration/projects/projects.types';
import type { AgentCredentialOption } from '../components/AgentCredentialSelect.vue'; import type { AgentCredentialOption } from '../components/AgentCredentialSelect.vue';
import { createSlackAgentApp } from './useAgentApi'; import { createSlackAgentApp } from './useAgentApi';
@ -42,6 +43,7 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
const pendingNewCredentialType = ref<string | null>(null); const pendingNewCredentialType = ref<string | null>(null);
const channelSetupRef = ref<ChannelSetupComponent>(); const channelSetupRef = ref<ChannelSetupComponent>();
const loadedIntegrations = ref<ChatIntegrationDescriptor[]>([]); const loadedIntegrations = ref<ChatIntegrationDescriptor[]>([]);
const fetchedProjectForPermissions = ref<Project | null>(null);
const projectId = computed(() => toValue(options.projectId)); const projectId = computed(() => toValue(options.projectId));
const agentId = computed(() => toValue(options.agentId)); const agentId = computed(() => toValue(options.agentId));
@ -49,9 +51,17 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
const connectedCredentials = computed(() => toValue(options.connectedCredentials)); const connectedCredentials = computed(() => toValue(options.connectedCredentials));
const projectForPermissions = computed(() => { const projectForPermissions = computed(() => {
if (projectsStore.currentProject?.id === projectId.value) return projectsStore.currentProject; const storedProject =
if (projectsStore.personalProject?.id === projectId.value) return projectsStore.personalProject; [
return projectsStore.myProjects.find((project) => project.id === projectId.value) ?? null; projectsStore.currentProject,
projectsStore.personalProject,
...projectsStore.myProjects,
].find((project) => project?.id === projectId.value) ?? null;
if (storedProject?.scopes !== undefined) return storedProject;
if (fetchedProjectForPermissions.value?.id === projectId.value) {
return fetchedProjectForPermissions.value;
}
return storedProject;
}); });
const credentialPermissions = computed(() => { const credentialPermissions = computed(() => {
@ -81,6 +91,17 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
} }
} }
async function ensureProjectPermissions() {
if (!projectId.value || projectForPermissions.value?.scopes !== undefined) return;
try {
const project = await projectsStore.fetchProject(projectId.value);
if (project.id === projectId.value) fetchedProjectForPermissions.value = project;
} catch {
// Keep permissions fail-closed when the project cannot be loaded.
}
}
async function fetchCredentials(integrations: ChatIntegrationDescriptor[]) { async function fetchCredentials(integrations: ChatIntegrationDescriptor[]) {
loadedIntegrations.value = integrations; loadedIntegrations.value = integrations;
credentialsLoading.value = true; credentialsLoading.value = true;
@ -111,6 +132,7 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
async function loadChannelState(integrations: ChatIntegrationDescriptor[]) { async function loadChannelState(integrations: ChatIntegrationDescriptor[]) {
await Promise.all([ await Promise.all([
ensureProjectPermissions(),
options.fetchStatus(integrations.map((integration) => integration.type)), options.fetchStatus(integrations.map((integration) => integration.type)),
fetchCredentials(integrations), fetchCredentials(integrations),
]); ]);

View File

@ -1,4 +1,5 @@
export const AGENTS_LIST_VIEW = 'AgentsListView'; export const AGENTS_LIST_VIEW = 'AgentsListView';
export const NEW_AGENT_VIEW = 'NewAgentView';
export const AGENT_BUILDER_VIEW = 'AgentBuilderView'; export const AGENT_BUILDER_VIEW = 'AgentBuilderView';
export const AGENT_PREVIEW_VIEW = 'AgentPreviewView'; export const AGENT_PREVIEW_VIEW = 'AgentPreviewView';
export const AGENT_VIEW = 'AgentView'; export const AGENT_VIEW = 'AgentView';

View File

@ -15,6 +15,7 @@ import {
AGENT_VECTOR_STORES_MODAL_KEY, AGENT_VECTOR_STORES_MODAL_KEY,
AGENT_JSON_IMPORT_MODAL_KEY, AGENT_JSON_IMPORT_MODAL_KEY,
AGENT_MODEL_CREDENTIAL_MODAL_KEY, AGENT_MODEL_CREDENTIAL_MODAL_KEY,
NEW_AGENT_VIEW,
AGENT_VIEW, AGENT_VIEW,
AGENT_SESSIONS_LIST_VIEW, AGENT_SESSIONS_LIST_VIEW,
AGENT_SESSION_DETAIL_VIEW, AGENT_SESSION_DETAIL_VIEW,
@ -27,6 +28,8 @@ const AgentView = async (): Promise<unknown> =>
await import('@/features/agents/views/AgentView.vue'); await import('@/features/agents/views/AgentView.vue');
const AgentBuilderView = async (): Promise<unknown> => const AgentBuilderView = async (): Promise<unknown> =>
await import('@/features/agents/views/AgentBuilderView.vue'); await import('@/features/agents/views/AgentBuilderView.vue');
const NewAgentView = async (): Promise<unknown> =>
await import('@/features/agents/views/NewAgentView.vue');
const AgentSessionsListView = async (): Promise<unknown> => const AgentSessionsListView = async (): Promise<unknown> =>
await import('@/features/agents/views/AgentSessionsListView.vue'); await import('@/features/agents/views/AgentSessionsListView.vue');
const AgentSessionTimelineView = async (): Promise<unknown> => const AgentSessionTimelineView = async (): Promise<unknown> =>
@ -155,6 +158,14 @@ export const AgentsModule: FrontendModuleDescription = {
middleware: ['authenticated', 'custom'], middleware: ['authenticated', 'custom'],
}, },
}, },
{
name: NEW_AGENT_VIEW,
path: '/new-agent',
component: NewAgentView,
meta: {
middleware: ['authenticated', 'custom'],
},
},
{ {
name: AGENT_VIEW, name: AGENT_VIEW,
path: 'agents/:agentId', path: 'agents/:agentId',

View File

@ -96,7 +96,7 @@ const locale = useI18n();
const rootStore = useRootStore(); const rootStore = useRootStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const { startThread: startInstanceAiThread } = useInstanceAiHandoff(); const { openAgentArtifactThread } = useInstanceAiHandoff();
const instanceAiAvailable = useInstanceAiAvailable(); const instanceAiAvailable = useInstanceAiAvailable();
const { canSendPreviewToInstanceAi, sendPreviewSessionToInstanceAi } = const { canSendPreviewToInstanceAi, sendPreviewSessionToInstanceAi } =
useInstanceAiAgentPreviewHandoff(); useInstanceAiAgentPreviewHandoff();
@ -700,7 +700,7 @@ async function refreshValidationBeforePublish(): Promise<boolean> {
return configValidation.value?.status === 'valid'; return configValidation.value?.status === 'valid';
} }
/** Hand the current agent off to a new Instance AI thread (mirrors the canvas hand-off). */ /** Open the current agent in Instance AI without sending an opening message. */
async function onOpenInstanceAi() { async function onOpenInstanceAi() {
// Flush pending edits first so the assistant sees the latest config. // Flush pending edits first so the assistant sees the latest config.
await flushAutosave(); await flushAutosave();
@ -710,14 +710,12 @@ async function onOpenInstanceAi() {
workflow_id: null, workflow_id: null,
execution_id: null, execution_id: null,
}); });
await startInstanceAiThread(projectId.value, '', [ await openAgentArtifactThread({
{ type: 'agent',
type: 'agent', id: agentId.value,
id: agentId.value, name: agent.value?.name,
name: agent.value?.name, projectId: projectId.value,
projectId: projectId.value, });
},
]);
} }
function normalizeAgentMemoryConfig(config: AgentJsonConfig): AgentJsonConfig { function normalizeAgentMemoryConfig(config: AgentJsonConfig): AgentJsonConfig {

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { v4 as uuidv4 } from 'uuid';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
import { stashPendingAgentAttachment } from '@/features/ai/instanceAi/composables/useInstanceAiHandoff';
import {
INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY,
INSTANCE_AI_THREAD_VIEW,
} from '@/features/ai/instanceAi/constants';
import { useInstanceAiStore } from '@/features/ai/instanceAi/instanceAi.store';
import { createAgent } from '../composables/useAgentApi';
import { upsertProjectAgentsListCache } from '../composables/useProjectAgentsList';
import { AGENTS_LIST_VIEW, PROJECT_AGENTS } from '../constants';
const route = useRoute();
const router = useRouter();
const i18n = useI18n();
const rootStore = useRootStore();
const telemetry = useTelemetry();
const toast = useToast();
const instanceAiStore = useInstanceAiStore();
onMounted(async () => {
const projectId = route.query.projectId;
if (typeof projectId !== 'string' || !projectId) {
const errorMessage = i18n.baseText('agentSelector.createAgentFailed');
toast.showError(new Error(errorMessage), errorMessage);
await router.replace({ name: AGENTS_LIST_VIEW });
return;
}
try {
const agent = await createAgent(
rootStore.restApiContext,
projectId,
i18n.baseText('agents.new.defaultName'),
);
upsertProjectAgentsListCache(projectId, agent);
telemetry.track('User created agent', {
agent_id: agent.id,
source: 'create_blank',
});
const threadId = uuidv4();
await instanceAiStore.syncThread(threadId, projectId);
await instanceAiStore.updateThreadMetadata(threadId, {
[INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY]: {
agentId: agent.id,
projectId,
name: agent.name,
},
});
stashPendingAgentAttachment(threadId, {
type: 'agent',
id: agent.id,
name: agent.name,
projectId,
});
await router.replace({
name: INSTANCE_AI_THREAD_VIEW,
params: { threadId },
});
} catch (error) {
toast.showError(error, i18n.baseText('agentSelector.createAgentFailed'));
await router.replace({ name: PROJECT_AGENTS, params: { projectId } });
}
});
</script>
<template>
<div></div>
</template>

View File

@ -23,7 +23,11 @@ import {
} from '@n8n/design-system'; } from '@n8n/design-system';
import { onClickOutside, useElementSize, useScroll, useWindowSize } from '@vueuse/core'; import { onClickOutside, useElementSize, useScroll, useWindowSize } from '@vueuse/core';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import type { InstanceAiAttachment, InstanceAiHandoffContext } from '@n8n/api-types'; import type {
InstanceAiAgentAttachment,
InstanceAiAttachment,
InstanceAiHandoffContext,
} from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { COLLAPSED_MAIN_SIDEBAR_WIDTH, useSidebarLayout } from '@/app/composables/useSidebarLayout'; import { COLLAPSED_MAIN_SIDEBAR_WIDTH, useSidebarLayout } from '@/app/composables/useSidebarLayout';
@ -35,8 +39,10 @@ import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
import { useCanvasPreview } from './useCanvasPreview'; import { useCanvasPreview } from './useCanvasPreview';
import { useCreditWarningBanner } from './composables/useCreditWarningBanner'; import { useCreditWarningBanner } from './composables/useCreditWarningBanner';
import { import {
clearPendingAgentAttachment,
consumePendingFirstMessage, consumePendingFirstMessage,
consumePendingHandoffContext, consumePendingHandoffContext,
getPendingAgentAttachment,
} from './composables/useInstanceAiHandoff'; } from './composables/useInstanceAiHandoff';
import { useTransitionGate } from './useTransitionGate'; import { useTransitionGate } from './useTransitionGate';
import { INSTANCE_AI_VIEW, NEW_CONVERSATION_TITLE } from './constants'; import { INSTANCE_AI_VIEW, NEW_CONVERSATION_TITLE } from './constants';
@ -86,6 +92,7 @@ const { width: windowWidth } = useWindowSize();
const { isCollapsed: isMainSidebarCollapsed, sidebarWidth: mainSidebarWidth } = useSidebarLayout(); const { isCollapsed: isMainSidebarCollapsed, sidebarWidth: mainSidebarWidth } = useSidebarLayout();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const pendingComposerContext = ref<InstanceAiHandoffContext | null>(null); const pendingComposerContext = ref<InstanceAiHandoffContext | null>(null);
const pendingAgentAttachment = ref<InstanceAiAgentAttachment | null>(null);
// Running builders render in a dedicated bottom section of the conversation. // Running builders render in a dedicated bottom section of the conversation.
// Once a builder finishes it falls out of this list and AgentTimeline renders // Once a builder finishes it falls out of this list and AgentTimeline renders
@ -536,6 +543,11 @@ function reconnectThreadAfterHydration(): void {
// Apply preview/credential composer context before hydration so a quick first // Apply preview/credential composer context before hydration so a quick first
// submit cannot race past attachment while the composer is already enabled. // submit cannot race past attachment while the composer is already enabled.
pendingComposerContext.value = consumePendingHandoffContext(props.threadId); pendingComposerContext.value = consumePendingHandoffContext(props.threadId);
const agentAttachment = getPendingAgentAttachment(props.threadId);
if (agentAttachment) {
pendingAgentAttachment.value = agentAttachment;
preview.openAgentPreview(agentAttachment.id, agentAttachment.projectId);
}
void thread.loadHistoricalMessages().then(async (hydrationStatus) => { void thread.loadHistoricalMessages().then(async (hydrationStatus) => {
if (hydrationStatus === 'stale') return; if (hydrationStatus === 'stale') return;
await thread.loadThreadStatus(); await thread.loadThreadStatus();
@ -646,12 +658,23 @@ function handleSubmit(message: string, attachments?: InstanceAiAttachment[]) {
} }
const handoffContext = pendingComposerContext.value ?? undefined; const handoffContext = pendingComposerContext.value ?? undefined;
const agentAttachment = pendingAgentAttachment.value;
const submittedAttachments = agentAttachment
? [...(attachments ?? []), agentAttachment]
: attachments;
void thread.sendMessage(message, attachments, rootStore.pushRef, handoffContext).then((sent) => { void thread
if (sent && handoffContext && pendingComposerContext.value === handoffContext) { .sendMessage(message, submittedAttachments, rootStore.pushRef, handoffContext)
pendingComposerContext.value = null; .then((sent) => {
} if (!sent) return;
}); if (handoffContext && pendingComposerContext.value === handoffContext) {
pendingComposerContext.value = null;
}
if (agentAttachment && pendingAgentAttachment.value === agentAttachment) {
clearPendingAgentAttachment(props.threadId);
pendingAgentAttachment.value = null;
}
});
} }
function handleStop() { function handleStop() {

View File

@ -17,6 +17,10 @@ import type {
InstanceAiAgentNode, InstanceAiAgentNode,
InstanceAiMessage, InstanceAiMessage,
} from '@n8n/api-types'; } from '@n8n/api-types';
import {
getPendingAgentAttachment,
stashPendingAgentAttachment,
} from '../composables/useInstanceAiHandoff';
const mockWindowSizeState = vi.hoisted(() => ({ const mockWindowSizeState = vi.hoisted(() => ({
width: { value: 1200 }, width: { value: 1200 },
@ -599,6 +603,65 @@ describe('InstanceAiThreadView', () => {
}); });
}); });
it('opens a pending agent and attaches it only to the first submitted message', async () => {
thread.sseState = 'disconnected';
vi.mocked(thread.loadHistoricalMessages).mockResolvedValue('skipped');
thread.producedArtifacts = new Map([
[
'agent-1',
{
type: 'agent',
id: 'agent-1',
projectId: 'project-1',
name: 'New agent',
},
],
]) as typeof thread.producedArtifacts;
stashPendingAgentAttachment('thread-1', {
type: 'agent',
id: 'agent-1',
name: 'New agent',
projectId: 'project-1',
});
const { findByTestId, getByTestId } = renderView({ props: { threadId: 'thread-1' } });
const preview = await findByTestId('instance-ai-agent-preview-stub');
expect(preview).toHaveAttribute('data-agent-id', 'agent-1');
expect(preview).toHaveAttribute('data-project-id', 'project-1');
expect(thread.sendMessage).not.toHaveBeenCalled();
await userEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenNthCalledWith(
1,
'Normal message',
[
{
type: 'agent',
id: 'agent-1',
name: 'New agent',
projectId: 'project-1',
},
],
expect.any(String),
undefined,
);
await vi.waitFor(() => {
expect(getPendingAgentAttachment('thread-1')).toBeNull();
});
await userEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenNthCalledWith(
2,
'Normal message',
undefined,
expect.any(String),
undefined,
);
});
it('dismisses a pending preview-context chip without sending it', async () => { it('dismisses a pending preview-context chip without sending it', async () => {
thread.sseState = 'disconnected'; thread.sseState = 'disconnected';
vi.mocked(thread.loadHistoricalMessages).mockResolvedValue('skipped'); vi.mocked(thread.loadHistoricalMessages).mockResolvedValue('skipped');

View File

@ -1,13 +1,56 @@
import { describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
routerPush: vi.fn(),
syncThread: vi.fn(),
updateThreadMetadata: vi.fn(),
getOrCreateRuntime: vi.fn(),
sendMessage: vi.fn(),
showError: vi.fn(),
}));
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal<typeof import('vue-router')>()),
useRouter: () => ({
push: mocks.routerPush,
resolve: vi.fn(),
}),
}));
vi.mock('uuid', () => ({ v4: () => 'thread-1' }));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: {}, pushRef: 'push-ref' }),
}));
vi.mock('@/app/composables/useToast', () => ({
useToast: () => ({ showError: mocks.showError }),
}));
vi.mock('../instanceAi.store', () => ({
useInstanceAiStore: () => ({
syncThread: mocks.syncThread,
updateThreadMetadata: mocks.updateThreadMetadata,
getOrCreateRuntime: mocks.getOrCreateRuntime,
}),
}));
import { import {
buildInstanceAiAgentPreviewHandoffContext, buildInstanceAiAgentPreviewHandoffContext,
buildInstanceAiCredentialHandoffContext, buildInstanceAiCredentialHandoffContext,
clearPendingAgentAttachment,
consumePendingHandoffContext, consumePendingHandoffContext,
getPendingAgentAttachment,
stashPendingAgentAttachment,
stashPendingHandoffContext, stashPendingHandoffContext,
useInstanceAiHandoff,
} from '../composables/useInstanceAiHandoff'; } from '../composables/useInstanceAiHandoff';
describe('useInstanceAiHandoff', () => { describe('useInstanceAiHandoff', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mocks.syncThread.mockResolvedValue(undefined);
mocks.updateThreadMetadata.mockResolvedValue(undefined);
mocks.getOrCreateRuntime.mockReturnValue({ sendMessage: mocks.sendMessage });
});
it('builds credential modal handoff context without empty optional fields', () => { it('builds credential modal handoff context without empty optional fields', () => {
expect( expect(
buildInstanceAiCredentialHandoffContext({ buildInstanceAiCredentialHandoffContext({
@ -76,4 +119,53 @@ describe('useInstanceAiHandoff', () => {
expect(consumePendingHandoffContext('thread-1')).toEqual(context); expect(consumePendingHandoffContext('thread-1')).toEqual(context);
expect(consumePendingHandoffContext('thread-1')).toBeNull(); expect(consumePendingHandoffContext('thread-1')).toBeNull();
}); });
it('keeps a pending agent attachment until it is explicitly cleared', () => {
const attachment = {
type: 'agent' as const,
id: 'agent-1',
name: 'New agent',
projectId: 'project-1',
};
stashPendingAgentAttachment('thread-1', attachment);
expect(getPendingAgentAttachment('thread-1')).toEqual(attachment);
expect(getPendingAgentAttachment('thread-1')).toEqual(attachment);
clearPendingAgentAttachment('thread-1');
expect(getPendingAgentAttachment('thread-1')).toBeNull();
});
it('opens an agent artifact thread without sending a message', async () => {
const { openAgentArtifactThread } = useInstanceAiHandoff();
const opened = await openAgentArtifactThread({
type: 'agent',
id: 'agent-1',
name: 'Agent One',
projectId: 'project-1',
});
expect(opened).toBe(true);
expect(mocks.syncThread).toHaveBeenCalledWith('thread-1', 'project-1');
expect(mocks.updateThreadMetadata).toHaveBeenCalledWith('thread-1', {
instanceAiAgentBuilderTarget: {
agentId: 'agent-1',
projectId: 'project-1',
name: 'Agent One',
},
});
expect(getPendingAgentAttachment('thread-1')).toEqual({
type: 'agent',
id: 'agent-1',
name: 'Agent One',
projectId: 'project-1',
});
expect(mocks.getOrCreateRuntime).not.toHaveBeenCalled();
expect(mocks.sendMessage).not.toHaveBeenCalled();
expect(mocks.routerPush).toHaveBeenCalledWith({
name: 'InstanceAiThread',
params: { threadId: 'thread-1' },
});
});
}); });

View File

@ -1,10 +1,12 @@
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import type { import {
InstanceAiHandoffContext, instanceAiAgentAttachmentSchema,
InstanceAiThreadOrigin, type InstanceAiAgentAttachment,
InstanceAiThreadSource, type InstanceAiHandoffContext,
InstanceAiResourceAttachment, type InstanceAiThreadOrigin,
type InstanceAiThreadSource,
type InstanceAiResourceAttachment,
} from '@n8n/api-types'; } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
@ -12,7 +14,10 @@ import type { InstanceAiCredentialContext } from '@/app/composables/useInstanceA
import { useToast } from '@/app/composables/useToast'; import { useToast } from '@/app/composables/useToast';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { INSTANCE_AI_THREAD_VIEW } from '../constants'; import {
INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY,
INSTANCE_AI_THREAD_VIEW,
} from '../constants';
import { useInstanceAiStore } from '../instanceAi.store'; import { useInstanceAiStore } from '../instanceAi.store';
/** The existing credential id, when known, so the agent can act on it directly. */ /** The existing credential id, when known, so the agent can act on it directly. */
@ -44,6 +49,8 @@ export function buildInstanceAiArtifactCredentialQuestion(
const pendingFirstMessageKey = (threadId: string) => `n8n-instance-ai-first-message:${threadId}`; const pendingFirstMessageKey = (threadId: string) => `n8n-instance-ai-first-message:${threadId}`;
const pendingHandoffContextKey = (threadId: string) => const pendingHandoffContextKey = (threadId: string) =>
`n8n-instance-ai-handoff-context:${threadId}`; `n8n-instance-ai-handoff-context:${threadId}`;
const pendingAgentAttachmentKey = (threadId: string) =>
`n8n-instance-ai-agent-attachment:${threadId}`;
export interface PendingFirstMessage { export interface PendingFirstMessage {
message: string; message: string;
@ -134,6 +141,28 @@ export function consumePendingHandoffContext(threadId: string): InstanceAiHandof
} }
} }
export function stashPendingAgentAttachment(
threadId: string,
attachment: InstanceAiAgentAttachment,
): void {
localStorage.setItem(pendingAgentAttachmentKey(threadId), JSON.stringify(attachment));
}
export function getPendingAgentAttachment(threadId: string): InstanceAiAgentAttachment | null {
const raw = localStorage.getItem(pendingAgentAttachmentKey(threadId));
if (!raw) return null;
try {
const parsed = instanceAiAgentAttachmentSchema.safeParse(JSON.parse(raw));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
export function clearPendingAgentAttachment(threadId: string): void {
localStorage.removeItem(pendingAgentAttachmentKey(threadId));
}
/** Resolve the personal project a launched thread binds to, loading it on first use. */ /** Resolve the personal project a launched thread binds to, loading it on first use. */
export async function ensurePersonalProjectId(): Promise<string | null> { export async function ensurePersonalProjectId(): Promise<string | null> {
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
@ -196,6 +225,32 @@ export function useInstanceAiHandoff() {
const router = useRouter(); const router = useRouter();
const toast = useToast(); const toast = useToast();
async function openAgentArtifactThread(attachment: InstanceAiAgentAttachment): Promise<boolean> {
if (handoffInFlight) return false;
handoffInFlight = true;
try {
const threadId = uuidv4();
try {
await instanceAiStore.syncThread(threadId, attachment.projectId);
await instanceAiStore.updateThreadMetadata(threadId, {
[INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY]: {
agentId: attachment.id,
projectId: attachment.projectId,
...(attachment.name ? { name: attachment.name } : {}),
},
});
} catch {
toast.showError(new Error('Failed to start a new thread. Try again.'), 'Open failed');
return false;
}
stashPendingAgentAttachment(threadId, attachment);
await router.push({ name: INSTANCE_AI_THREAD_VIEW, params: { threadId } });
return true;
} finally {
handoffInFlight = false;
}
}
async function openThreadWithContext( async function openThreadWithContext(
projectId: string, projectId: string,
context: InstanceAiHandoffContext, context: InstanceAiHandoffContext,
@ -278,5 +333,5 @@ export function useInstanceAiHandoff() {
} }
} }
return { startThread, openThreadWithContext }; return { startThread, openThreadWithContext, openAgentArtifactThread };
} }

View File

@ -3,6 +3,7 @@ export const INSTANCE_AI_THREAD_VIEW = 'InstanceAiThread';
export const INSTANCE_AI_SETTINGS_VIEW = 'InstanceAiSettings'; export const INSTANCE_AI_SETTINGS_VIEW = 'InstanceAiSettings';
export const INSTANCE_AI_PROJECT_ID_QUERY = 'projectId'; export const INSTANCE_AI_PROJECT_ID_QUERY = 'projectId';
export const INSTANCE_AI_NEW_VIEW = 'InstanceAiNew'; export const INSTANCE_AI_NEW_VIEW = 'InstanceAiNew';
export const INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY = 'instanceAiAgentBuilderTarget';
export const NEW_CONVERSATION_TITLE = 'New conversation'; export const NEW_CONVERSATION_TITLE = 'New conversation';
export { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types'; export { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
export const BROWSER_USE_CONNECTION_TYPE = 'browser-use'; export const BROWSER_USE_CONNECTION_TYPE = 'browser-use';

View File

@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { instanceAiCreateAgentRoute } from './createAgentRoute';
describe('instanceAiCreateAgentRoute', () => {
it('routes agent creation directly to the new-agent view', () => {
expect(instanceAiCreateAgentRoute('project-1')).toEqual({
name: 'NewAgentView',
query: { projectId: 'project-1' },
});
});
});

View File

@ -1,9 +1,10 @@
import type { RouteLocationRaw } from 'vue-router'; import type { RouteLocationRaw } from 'vue-router';
import { INSTANCE_AI_VIEW, INSTANCE_AI_PROJECT_ID_QUERY } from './constants'; import { NEW_AGENT_VIEW } from '@/features/agents/constants';
import { INSTANCE_AI_PROJECT_ID_QUERY } from './constants';
export function instanceAiCreateAgentRoute(projectId: string): RouteLocationRaw { export function instanceAiCreateAgentRoute(projectId: string): RouteLocationRaw {
return { return {
name: INSTANCE_AI_VIEW, name: NEW_AGENT_VIEW,
query: { query: {
[INSTANCE_AI_PROJECT_ID_QUERY]: projectId, [INSTANCE_AI_PROJECT_ID_QUERY]: projectId,
}, },

View File

@ -41,6 +41,7 @@ import { handleEvent as reduceEvent, createRunStateFromTree } from './instanceAi
import { getLatestBuildResult, type RememberedManualExecution } from './canvasPreview.utils'; import { getLatestBuildResult, type RememberedManualExecution } from './canvasPreview.utils';
import { useResourceRegistry } from './useResourceRegistry'; import { useResourceRegistry } from './useResourceRegistry';
import { useResponseFeedback } from './useResponseFeedback'; import { useResponseFeedback } from './useResponseFeedback';
import { INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY } from './constants';
import { import {
findToolCallInTree, findToolCallInTree,
isOrchestratorLive, isOrchestratorLive,
@ -98,12 +99,10 @@ export interface ThreadRuntimeHooks {
getThreadMetadata?: (threadId: string) => Record<string, unknown> | undefined; getThreadMetadata?: (threadId: string) => Record<string, unknown> | undefined;
} }
const AGENT_BUILDER_TARGET_METADATA_KEY = 'instanceAiAgentBuilderTarget';
export function getAgentBuilderTargetFromThreadMetadata( export function getAgentBuilderTargetFromThreadMetadata(
metadata: Record<string, unknown> | undefined, metadata: Record<string, unknown> | undefined,
) { ) {
const raw = metadata?.[AGENT_BUILDER_TARGET_METADATA_KEY]; const raw = metadata?.[INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY];
if (!raw || typeof raw !== 'object') return undefined; if (!raw || typeof raw !== 'object') return undefined;
const target = raw as Record<string, unknown>; const target = raw as Record<string, unknown>;
if (typeof target.agentId !== 'string' || typeof target.projectId !== 'string') return undefined; if (typeof target.agentId !== 'string' || typeof target.projectId !== 'string') return undefined;

View File

@ -1,4 +1,4 @@
import { describe, it, expect, afterAll } from 'vitest'; import { describe, it, expect, afterAll, afterEach } from 'vitest';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import type { import type {
IConnections, IConnections,
@ -775,3 +775,39 @@ describe('setValue', () => {
expect(nodeValues.value.customTelemetryTags).toEqual({}); expect(nodeValues.value.customTelemetryTags).toEqual({});
}); });
}); });
describe('setValue with dot-notation paths that name inherited members', () => {
// Guard the shared prototype so a regression in one test cannot cascade to others.
const hadOwnCall = Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call');
afterEach(() => {
if (!hadOwnCall) {
delete (Object.prototype.toString as unknown as { call?: unknown }).call;
}
});
it('keeps built-in object prototypes intact when a path names an inherited member', () => {
const nodeValues: Ref<INodeParameters> = ref({ parameters: {} });
setValue(nodeValues, 'parameters.toString.call', 'x');
expect(Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call')).toBe(false);
expect(Object.prototype.toString.call([])).toBe('[object Array]');
// The value lands as a plain own property on the parameters object.
expect((nodeValues.value.parameters as { toString?: { call?: unknown } }).toString?.call).toBe(
'x',
);
});
it('keeps built-in object prototypes intact for a nested inherited key', () => {
const nodeValues: Ref<INodeParameters> = ref({ parameters: {} });
setValue(nodeValues, 'parameters.a.toString.call', 'y');
expect(Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call')).toBe(false);
expect(Object.prototype.toString.call({})).toBe('[object Object]');
expect(
(nodeValues.value.parameters as { a?: { toString?: { call?: unknown } } }).a?.toString?.call,
).toBe('y');
});
});

View File

@ -116,20 +116,13 @@ export function setValue(
} }
} }
} else { } else {
// Value should be set // Value should be set. Write from the root via the full path so the setter
if (typeof value === 'object') { // builds own-property intermediates rather than reading an inherited member
set( // of an object along the path.
get(nodeValues.value, nameParts.join('.')) as Record<string, unknown>, const fullPath = isArray
lastNamePart as string, ? `${nameParts.join('.')}[${lastNamePart}]`
deepCopy(value), : `${nameParts.join('.')}.${lastNamePart}`;
); set(nodeValues.value, fullPath, typeof value === 'object' ? deepCopy(value) : value);
} else {
set(
get(nodeValues.value, nameParts.join('.')) as Record<string, unknown>,
lastNamePart as string,
value,
);
}
} }
} }

View File

@ -76,7 +76,7 @@ describe('NodeItem', () => {
props: { props: {
nodeType: mockSimplifiedNodeType({ nodeType: mockSimplifiedNodeType({
name: MESSAGE_AN_AGENT_NODE_TYPE, name: MESSAGE_AN_AGENT_NODE_TYPE,
displayName: 'AI Agent', displayName: 'AI Agent V1',
group: ['transform'], group: ['transform'],
}), }),
}, },

View File

@ -61,7 +61,7 @@ const render = createComponentRenderer(AgentsMode);
function pushAgentsViewStack() { function pushAgentsViewStack() {
useViewStacks().pushViewStack({ useViewStacks().pushViewStack({
title: 'AI Agent', title: 'AI Agent V1',
hasSearch: true, hasSearch: true,
mode: 'agents', mode: 'agents',
items: [], items: [],

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