diff --git a/packages/@n8n/config/src/configs/nodes.config.ts b/packages/@n8n/config/src/configs/nodes.config.ts index 8d1faf835fc..e4a9905a73a 100644 --- a/packages/@n8n/config/src/configs/nodes.config.ts +++ b/packages/@n8n/config/src/configs/nodes.config.ts @@ -31,8 +31,9 @@ export class NodesConfig { /** * Node types to exclude from loading. Default excludes `ExecuteCommand` and `LocalFileTrigger` for security. * Set to an empty array to allow all node types. + * Generated tool variants are accepted too. The base node stays available. * - * @example '["n8n-nodes-base.hackerNews"]' + * @example '["n8n-nodes-base.hackerNews", "n8n-nodes-base.dateTimeTool"]' */ @Env('NODES_EXCLUDE') exclude: JsonStringArray = ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger']; diff --git a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts index 3952639b019..297be970da8 100644 --- a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts +++ b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts @@ -10,6 +10,7 @@ import type { ICredentialType, INodeProperties, INodeTypeDescription, + KnownNodesAndCredentials, NodeLoader, } from 'n8n-workflow'; import { UserError } from 'n8n-workflow'; @@ -873,6 +874,7 @@ describe('LoadNodesAndCredentials', () => { createHitlTools.mockClear(); instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock()); + instance.excludeNodes = []; }); it('should keep types in memory after post-processing for post-processors to read', async () => { @@ -926,6 +928,53 @@ describe('LoadNodesAndCredentials', () => { expect(createHitlTools).toHaveBeenCalledWith(instance.types, expectedKnown); }); + it('should drop generated tool types that NODES_EXCLUDE lists', async () => { + createAiTools.mockImplementationOnce( + (types: { nodes: INodeTypeDescription[] }, known: KnownNodesAndCredentials) => { + for (const name of ['test-package.testNodeTool', 'test-package.otherNodeTool']) { + types.nodes.push({ name } as INodeTypeDescription); + known.nodes[name] = { className: 'TestNode', sourcePath: 'Test.node.js' }; + } + known.credentials.testApi = { + className: 'TestApi', + sourcePath: 'TestApi.credentials.js', + supportedNodes: [ + 'test-package.testNode', + 'test-package.testNodeTool', + 'test-package.otherNodeTool', + ], + }; + }, + ); + instance.excludeNodes = ['test-package.testNodeTool']; + instance.loaders = { + 'test-package': mock({ + packageName: 'test-package', + known: { + nodes: { testNode: { className: 'TestNode', sourcePath: 'Test.node.js' } }, + credentials: {}, + }, + types: { nodes: [{ name: 'testNode' }], credentials: [] }, + ensureTypesLoaded: vi.fn().mockResolvedValue(undefined), + }), + }; + + await instance.postProcessLoaders(); + + expect(instance.types.nodes.map(({ name }) => name)).toEqual([ + 'test-package.testNode', + 'test-package.otherNodeTool', + ]); + expect(Object.keys(instance.knownNodes)).toEqual([ + 'test-package.testNode', + 'test-package.otherNodeTool', + ]); + expect(instance.knownCredentials.testApi.supportedNodes).toEqual([ + 'test-package.testNode', + 'test-package.otherNodeTool', + ]); + }); + describe('atomic registry swap (known, loaded, types)', () => { const createLoader = () => mock({ @@ -975,6 +1024,7 @@ describe('LoadNodesAndCredentials', () => { beforeEach(() => { instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock()); + instance.excludeNodes = []; }); it('should return a snapshot of types with package-namespaced node names', async () => { diff --git a/packages/cli/src/__tests__/node-types.test.ts b/packages/cli/src/__tests__/node-types.test.ts index 473f471f968..f438be149f6 100644 --- a/packages/cli/src/__tests__/node-types.test.ts +++ b/packages/cli/src/__tests__/node-types.test.ts @@ -15,7 +15,7 @@ import { NodeTypes } from '@/node-types'; describe('NodeTypes', () => { const logger = mock(); - const loadNodesAndCredentials = mock(); + const loadNodesAndCredentials = mock({ excludeNodes: [] }); const nodeTypes: NodeTypes = new NodeTypes(logger, loadNodesAndCredentials); @@ -433,6 +433,43 @@ describe('NodeTypes', () => { }); }); + describe('tool variants listed in NODES_EXCLUDE', () => { + beforeEach(() => { + loadNodesAndCredentials.excludeNodes = ['n8n-nodes-base.testNodeTool']; + }); + + afterEach(() => { + loadNodesAndCredentials.excludeNodes = []; + }); + + it('should not resolve the excluded tool variant', () => { + expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-base.testNodeTool')).toThrow( + UnrecognizedNodeTypeError, + ); + }); + + it('should still resolve the base node', () => { + expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-base.testNode')).not.toThrow(); + }); + + it('should not list versions for the excluded tool variant', () => { + expect(nodeTypes.getSupportedVersions('n8n-nodes-base.testNodeTool')).toBeUndefined(); + }); + + it('should not describe the excluded tool variant', () => { + expect(() => + nodeTypes.getNodeTypeDescriptions([{ name: 'n8n-nodes-base.testNodeTool', version: 1 }]), + ).toThrow(UnrecognizedNodeTypeError); + }); + + it('should resolve the excluded tool variant to itself', () => { + expect(nodeTypes.resolveBaseName('n8n-nodes-base.testNodeTool')).toEqual({ + baseName: 'n8n-nodes-base.testNodeTool', + isSyntheticTool: false, + }); + }); + }); + describe('getSupportedVersions', () => { it('should return the single version of a plain node type', () => { expect(nodeTypes.getSupportedVersions('n8n-nodes-base.hitlNode')).toEqual([1]); diff --git a/packages/cli/src/chat/__tests__/chat-resume-node-types.integration.test.ts b/packages/cli/src/chat/__tests__/chat-resume-node-types.integration.test.ts index 4f0943e8a01..9bfaa9b4cc7 100644 --- a/packages/cli/src/chat/__tests__/chat-resume-node-types.integration.test.ts +++ b/packages/cli/src/chat/__tests__/chat-resume-node-types.integration.test.ts @@ -59,6 +59,7 @@ function makeRealNodeTypes(): NodeTypes { recognizesNode: (name: string) => name in baseTypes, getNode: (name: string) => baseTypes[name], loadedNodes: {} as Record>, + excludeNodes: [] as string[], }; return new NodeTypes(mock(), loader as never); diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index fa048cb9f69..ed22b467a8f 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -631,6 +631,23 @@ export class LoadNodesAndCredentials { createAiTools(this.types, this.known); createHitlTools(this.types, this.known); + // Loaders filter base nodes only. Generated tool variants are filtered here. + this.types.nodes = this.types.nodes.filter(({ name }) => !this.excludeNodes.includes(name)); + this.known.nodes = Object.fromEntries( + Object.entries(this.known.nodes).filter(([name]) => !this.excludeNodes.includes(name)), + ); + this.known.credentials = Object.fromEntries( + Object.entries(this.known.credentials).map(([name, credential]) => [ + name, + { + ...credential, + supportedNodes: credential.supportedNodes?.filter( + (node) => !this.excludeNodes.includes(node), + ), + }, + ]), + ); + this.injectCustomApiCallOptions(); this.injectContextEstablishmentHooks(); diff --git a/packages/cli/src/node-types.ts b/packages/cli/src/node-types.ts index 1f382e6cf5f..5d465b3451b 100644 --- a/packages/cli/src/node-types.ts +++ b/packages/cli/src/node-types.ts @@ -28,10 +28,15 @@ export class NodeTypes implements INodeTypes { * A "synthetic tool" has no implementation of its own: workflows persist * names like `gmailTool`, and the registry fabricates that node on demand by * converting the `gmail` base node into an agent tool. + * + * A tool name listed in `NODES_EXCLUDE` is never synthetic. It resolves to + * itself, so loading it fails as an unrecognized type. */ resolveBaseName(nodeTypeName: string): { baseName: string; isSyntheticTool: boolean } { const isSyntheticTool = - nodeTypeName.endsWith('Tool') && !this.loadNodesAndCredentials.recognizesNode(nodeTypeName); + nodeTypeName.endsWith('Tool') && + !this.loadNodesAndCredentials.recognizesNode(nodeTypeName) && + !this.loadNodesAndCredentials.excludeNodes.includes(nodeTypeName); return { baseName: isSyntheticTool ? stripToolSuffix(nodeTypeName) : nodeTypeName, isSyntheticTool, diff --git a/packages/cli/src/services/__tests__/ai-workflow-builder.service.test.ts b/packages/cli/src/services/__tests__/ai-workflow-builder.service.test.ts index 02155174920..a2ee89c2e07 100644 --- a/packages/cli/src/services/__tests__/ai-workflow-builder.service.test.ts +++ b/packages/cli/src/services/__tests__/ai-workflow-builder.service.test.ts @@ -867,6 +867,7 @@ describe('WorkflowBuilderService - node type loading', () => { mock(), mock(), ); + loadNodesAndCredentials.excludeNodes = []; // Real LazyPackageDirectoryLoader reading from the mocked filesystem const loader = new LazyPackageDirectoryLoader(packageDir); diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/Modes/NodesMode.vue b/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/Modes/NodesMode.vue index abb8db63f0f..826091ad227 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/Modes/NodesMode.vue +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/Modes/NodesMode.vue @@ -1,41 +1,25 @@