mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 11:35:03 +02:00
Compare commits
14 Commits
master
...
n8n@2.32.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87db96e99d | ||
|
|
b0aac90ecc | ||
|
|
95ad6f6eae | ||
|
|
4f0e37a8f5 | ||
|
|
f63d6648ae | ||
|
|
14e48bacb9 | ||
|
|
ebcaa7225a | ||
|
|
8567e74a2b | ||
|
|
53e48ee8b0 | ||
|
|
e985cbac2a | ||
|
|
d9464241d7 | ||
|
|
7d43cce195 | ||
|
|
f69dfc6dd2 | ||
|
|
db5e2d068b |
|
|
@ -1,7 +1,18 @@
|
|||
import semver from 'semver';
|
||||
|
||||
import { getMonorepoProjects } from './pnpm-utils.mjs';
|
||||
|
||||
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
||||
|
||||
// Standalone packages release from master out-of-sync with the main pipeline
|
||||
// (release-standalone-package.yml), so their `latest` follows the newest beta
|
||||
// on npm instead of the version recorded in the stable checkout.
|
||||
const STANDALONE_PACKAGES_FOLLOWING_BETA = new Set([
|
||||
'@n8n/create-node',
|
||||
'@n8n/eslint-plugin-community-nodes',
|
||||
'@n8n/scan-community-package',
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} version
|
||||
|
|
@ -23,6 +34,30 @@ async function setDistTag(name, version, tag, token) {
|
|||
});
|
||||
}
|
||||
|
||||
/** @param {string} name */
|
||||
async function getDistTags(name) {
|
||||
const res = await fetch(`${NPM_REGISTRY}/-/package/${encodeURIComponent(name)}/dist-tags`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch dist-tags for ${name}: HTTP ${res.status}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the version `latest` should point at, or null if no move is needed.
|
||||
* Standalone packages follow the npm `beta` dist-tag; a standalone release
|
||||
* from master may already have pushed `latest` past beta, so never downgrade.
|
||||
* @param {import('./pnpm-utils.mjs').PnpmPackage} pkg
|
||||
*/
|
||||
export async function resolveLatestVersion(pkg) {
|
||||
if (!STANDALONE_PACKAGES_FOLLOWING_BETA.has(pkg.name)) return pkg.version;
|
||||
|
||||
const tags = await getDistTags(pkg.name);
|
||||
const target = tags.beta ?? pkg.version;
|
||||
if (tags.latest && semver.gte(tags.latest, target)) return null;
|
||||
return target;
|
||||
}
|
||||
|
||||
async function setLatestForMonorepoPackages() {
|
||||
const token = process.env.NPM_TOKEN;
|
||||
if (!token) {
|
||||
|
|
@ -39,10 +74,15 @@ async function setLatestForMonorepoPackages() {
|
|||
const failures = [];
|
||||
|
||||
for (const pkg of publishedPackages) {
|
||||
const versionName = `${pkg.name}@${pkg.version}`;
|
||||
|
||||
try {
|
||||
const res = await setDistTag(pkg.name, pkg.version, 'latest', token);
|
||||
const version = await resolveLatestVersion(pkg);
|
||||
if (version === null) {
|
||||
console.log(`Skipped ${pkg.name}: latest is already at or ahead of beta`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const versionName = `${pkg.name}@${version}`;
|
||||
const res = await setDistTag(pkg.name, version, 'latest', token);
|
||||
|
||||
if (res.ok) {
|
||||
console.log(`Set ${versionName} as latest`);
|
||||
|
|
@ -53,8 +93,8 @@ async function setLatestForMonorepoPackages() {
|
|||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Failed to set ${versionName} as latest: ${message}`);
|
||||
failures.push(versionName);
|
||||
console.error(`Failed to set latest for ${pkg.name}: ${message}`);
|
||||
failures.push(pkg.name);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
53
.github/scripts/set-latest-for-monorepo-packages.test.mjs
vendored
Normal file
53
.github/scripts/set-latest-for-monorepo-packages.test.mjs
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, it, before, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
/**
|
||||
* Run these tests by running
|
||||
*
|
||||
* node --test --experimental-test-module-mocks ./.github/scripts/set-latest-for-monorepo-packages.test.mjs
|
||||
* */
|
||||
|
||||
let resolveLatestVersion;
|
||||
before(async () => {
|
||||
({ resolveLatestVersion } = await import('./set-latest-for-monorepo-packages.mjs'));
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function mockDistTags(tags) {
|
||||
globalThis.fetch = async () => ({ ok: true, json: async () => tags });
|
||||
}
|
||||
|
||||
describe('resolveLatestVersion', () => {
|
||||
it('uses the checkout version for regular monorepo packages', async () => {
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error('should not fetch dist-tags for regular packages');
|
||||
};
|
||||
const version = await resolveLatestVersion({ name: '@n8n/config', version: '1.2.3' });
|
||||
assert.equal(version, '1.2.3');
|
||||
});
|
||||
|
||||
it('pins standalone packages to the beta dist-tag', async () => {
|
||||
mockDistTags({ beta: '0.40.1', latest: '0.39.3' });
|
||||
const version = await resolveLatestVersion({ name: '@n8n/create-node', version: '0.39.3' });
|
||||
assert.equal(version, '0.40.1');
|
||||
});
|
||||
|
||||
it('never downgrades latest below a newer standalone release', async () => {
|
||||
mockDistTags({ beta: '0.40.1', latest: '0.41.0' });
|
||||
const version = await resolveLatestVersion({ name: '@n8n/create-node', version: '0.39.3' });
|
||||
assert.equal(version, null);
|
||||
});
|
||||
|
||||
it('skips standalone packages without a beta tag when latest is current', async () => {
|
||||
mockDistTags({ latest: '0.25.0' });
|
||||
const version = await resolveLatestVersion({
|
||||
name: '@n8n/eslint-plugin-community-nodes',
|
||||
version: '0.25.0',
|
||||
});
|
||||
assert.equal(version, null);
|
||||
});
|
||||
});
|
||||
|
|
@ -80,9 +80,12 @@ jobs:
|
|||
|
||||
ensure-correct-latest-version-on-npm:
|
||||
name: Ensure correct latest version on npm
|
||||
# Also runs on beta patches: standalone packages pin `latest` to the newest
|
||||
# beta, so every beta publish needs a re-pin, not just stable promotions.
|
||||
if: |
|
||||
inputs.bump == 'minor' ||
|
||||
(inputs.track == 'stable' && inputs.release_type != 'rc')
|
||||
(inputs.track == 'stable' && inputs.release_type != 'rc') ||
|
||||
(inputs.track == 'beta' && inputs.release_type != 'rc')
|
||||
uses: ./.github/workflows/release-set-stable-npm-packages-to-latest.yml
|
||||
secrets: inherit
|
||||
|
||||
|
|
|
|||
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -1,3 +1,39 @@
|
|||
## [2.32.5](https://github.com/n8n-io/n8n/compare/n8n@2.32.4...n8n@2.32.5) (2026-07-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Sign S3 object paths with strict RFC 3986 encoding ([#34871](https://github.com/n8n-io/n8n/issues/34871)) ([95ad6f6](https://github.com/n8n-io/n8n/commit/95ad6f6eae274dcd0898128f3a23a90a9d69fafe))
|
||||
|
||||
|
||||
## [2.32.4](https://github.com/n8n-io/n8n/compare/n8n@2.32.3...n8n@2.32.4) (2026-07-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Fall back to the run's resolved model for AI Assistant verification LLM calls ([#34776](https://github.com/n8n-io/n8n/issues/34776)) ([14e48ba](https://github.com/n8n-io/n8n/commit/14e48bacb974a76fbc73f385e25ad1540e47933a))
|
||||
* **core:** Stop Instance AI follow-up runs from looping when they keep failing before the agent starts ([#34772](https://github.com/n8n-io/n8n/issues/34772)) ([ebcaa72](https://github.com/n8n-io/n8n/commit/ebcaa7225ac4b7cf282cbabbacf94e768ecfc6c5))
|
||||
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.32.0",
|
||||
"version": "2.32.5",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.22",
|
||||
|
|
@ -218,7 +218,8 @@
|
|||
"@lezer/highlight": "patches/@lezer__highlight.patch",
|
||||
"v-code-diff": "patches/v-code-diff.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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/agents",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.2",
|
||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -54,7 +55,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -68,7 +70,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
@ -168,7 +171,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -186,7 +190,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -200,7 +205,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -54,7 +55,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -68,7 +70,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
@ -169,7 +172,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -187,7 +191,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -201,7 +206,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -54,7 +55,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -68,7 +70,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
@ -169,7 +172,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -187,7 +191,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -201,7 +206,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
@ -275,7 +281,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -293,7 +300,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -307,7 +315,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
@ -408,7 +417,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -426,7 +436,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -440,7 +451,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -70,7 +71,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -84,7 +86,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
@ -201,7 +204,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -219,7 +223,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -233,7 +238,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -54,7 +55,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -68,7 +70,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
@ -174,7 +177,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -192,7 +196,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -206,7 +211,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -54,7 +55,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -68,7 +70,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_add",
|
||||
|
|
@ -54,7 +55,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "tools_image",
|
||||
|
|
@ -68,7 +70,8 @@
|
|||
}
|
||||
},
|
||||
"required": ["caption"]
|
||||
}
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
],
|
||||
"tool_choice": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { McpClient } from '../../sdk/mcp-client';
|
||||
import { McpConnection } from '../mcp/mcp-connection';
|
||||
import { executeTool } from '../tools/tool-adapter';
|
||||
|
||||
const sseCtor = vi.fn();
|
||||
const streamableHttpCtor = vi.fn();
|
||||
|
|
@ -150,6 +151,28 @@ describe('McpClient — connection error formatting', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('McpClient — tool name normalization', () => {
|
||||
beforeEach(() => {
|
||||
clientConnect.mockReset().mockResolvedValue(undefined);
|
||||
clientListTools.mockReset().mockResolvedValue({
|
||||
tools: [{ name: 'read', description: '', inputSchema: { type: 'object' } }],
|
||||
});
|
||||
clientClose.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('keeps model-facing names unique across normalized server prefixes', async () => {
|
||||
const client = new McpClient([
|
||||
{ name: 'foo bar', url: 'https://example.test/first' },
|
||||
{ name: 'foo_bar', url: 'https://example.test/second' },
|
||||
]);
|
||||
|
||||
const tools = await client.listTools();
|
||||
|
||||
expect(new Set(tools.map((tool) => tool.name)).size).toBe(2);
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpConnection - tool call settled callback', () => {
|
||||
beforeEach(() => {
|
||||
clientConnect.mockReset().mockResolvedValue(undefined);
|
||||
|
|
@ -198,6 +221,7 @@ describe('McpConnection — tool filtering', () => {
|
|||
beforeEach(() => {
|
||||
clientConnect.mockClear();
|
||||
clientListTools.mockClear();
|
||||
clientCallTool.mockReset().mockResolvedValue({ content: [] });
|
||||
clientListTools.mockResolvedValue({
|
||||
tools: [
|
||||
{ name: 'echo', description: '', inputSchema: { type: 'object' } },
|
||||
|
|
@ -220,6 +244,100 @@ describe('McpConnection — tool filtering', () => {
|
|||
expect(tools.map((tool) => tool.name)).toEqual(['s1_echo', 's1_add', 's1_subtract']);
|
||||
});
|
||||
|
||||
it('normalizes the model-facing prefix while preserving the MCP server name', async () => {
|
||||
const conn = new McpConnection({
|
||||
name: 'Linear Prod',
|
||||
url: 'https://example.test/mcp',
|
||||
transport: 'streamableHttp',
|
||||
requireApproval: ['echo'],
|
||||
});
|
||||
|
||||
await conn.connect();
|
||||
const tools = await conn.listTools();
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual([
|
||||
'Linear_Prod_echo',
|
||||
'Linear_Prod_add',
|
||||
'Linear_Prod_subtract',
|
||||
]);
|
||||
expect(tools.every((tool) => tool.mcpServerName === 'Linear Prod')).toBe(true);
|
||||
expect(tools).toEqual([
|
||||
expect.objectContaining({ mcpToolName: 'echo' }),
|
||||
expect.objectContaining({ mcpToolName: 'add' }),
|
||||
expect.objectContaining({ mcpToolName: 'subtract' }),
|
||||
]);
|
||||
expect(tools[0]?.suspendSchema).toBeDefined();
|
||||
expect(tools[1]?.suspendSchema).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps model-facing names unique when tool names normalize identically', async () => {
|
||||
const onToolCallSettled = vi.fn();
|
||||
const rawTools = [
|
||||
{ name: 'read file', description: '', inputSchema: { type: 'object' } },
|
||||
{ name: 'read_file', description: '', inputSchema: { type: 'object' } },
|
||||
];
|
||||
clientListTools
|
||||
.mockResolvedValueOnce({ tools: rawTools })
|
||||
.mockResolvedValueOnce({ tools: [...rawTools].reverse() });
|
||||
|
||||
const conn = new McpConnection({
|
||||
name: 's1',
|
||||
url: 'https://example.test/mcp',
|
||||
transport: 'streamableHttp',
|
||||
onToolCallSettled,
|
||||
});
|
||||
|
||||
await conn.connect();
|
||||
const tools = await conn.listTools();
|
||||
const reversedTools = await conn.listTools();
|
||||
|
||||
expect(new Set(tools.map((tool) => tool.name)).size).toBe(2);
|
||||
expect(tools.map((tool) => tool.name)).toEqual([
|
||||
reversedTools[1]?.name,
|
||||
reversedTools[0]?.name,
|
||||
]);
|
||||
await executeTool({}, tools[0]);
|
||||
await executeTool({}, tools[1]);
|
||||
expect(onToolCallSettled).toHaveBeenNthCalledWith(1, {
|
||||
toolName: 'read file',
|
||||
modelToolName: tools[0].name,
|
||||
success: true,
|
||||
});
|
||||
expect(onToolCallSettled).toHaveBeenNthCalledWith(2, {
|
||||
toolName: 'read_file',
|
||||
modelToolName: tools[1].name,
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps model-facing names unique when truncation removes the differing suffix', async () => {
|
||||
const sharedPrefix = 'a'.repeat(80);
|
||||
const rawTools = [
|
||||
{ name: `${sharedPrefix}x`, description: '', inputSchema: { type: 'object' } },
|
||||
{ name: `${sharedPrefix}y`, description: '', inputSchema: { type: 'object' } },
|
||||
];
|
||||
clientListTools
|
||||
.mockResolvedValueOnce({ tools: rawTools })
|
||||
.mockResolvedValueOnce({ tools: [...rawTools].reverse() });
|
||||
|
||||
const conn = new McpConnection({
|
||||
name: 's1',
|
||||
url: 'https://example.test/mcp',
|
||||
transport: 'streamableHttp',
|
||||
});
|
||||
|
||||
await conn.connect();
|
||||
const tools = await conn.listTools();
|
||||
const reversedTools = await conn.listTools();
|
||||
|
||||
expect(new Set(tools.map((tool) => tool.name)).size).toBe(2);
|
||||
expect(tools.every((tool) => tool.name.length <= 64)).toBe(true);
|
||||
expect(tools.map((tool) => tool.name)).toEqual([
|
||||
reversedTools[1]?.name,
|
||||
reversedTools[0]?.name,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps only allowed tools when allow filter is configured', async () => {
|
||||
const conn = new McpConnection({
|
||||
name: 's1',
|
||||
|
|
|
|||
|
|
@ -175,6 +175,24 @@ describe('toAiSdkTools — JSON Schema / fixSchema', () => {
|
|||
expect(firstCall.type).toBe('object');
|
||||
expect(secondCall.type).toBe('object');
|
||||
});
|
||||
|
||||
it('disables provider strict mode for MCP tools without changing optional properties', () => {
|
||||
const rawSchema: JSONSchema7 = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
team: { type: 'string' },
|
||||
query: { type: 'string' },
|
||||
priority: { type: 'number' },
|
||||
},
|
||||
required: ['team'],
|
||||
};
|
||||
|
||||
const result = toAiSdkTools([makeJsonSchemaTool(rawSchema, { mcpTool: true })]);
|
||||
|
||||
expect(result.testTool).toMatchObject({ strict: false });
|
||||
expect(jsonSchemaMock).toHaveBeenCalledWith(rawSchema);
|
||||
expect(jsonSchemaMock.mock.calls[0][0].required).toEqual(['team']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -197,6 +215,21 @@ describe('toAiSdkTools — description forwarding', () => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('executeTool — context propagation', () => {
|
||||
it('passes MCP tool input to the handler unchanged', async () => {
|
||||
const handler = vi.fn().mockResolvedValue('ok');
|
||||
const tool: BuiltTool = {
|
||||
name: 'linear_list_issues',
|
||||
description: 'List Linear issues',
|
||||
mcpTool: true,
|
||||
handler,
|
||||
};
|
||||
const input = { team: 'Agent', query: '', priority: 0 };
|
||||
|
||||
await executeTool(input, tool);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toBe(input);
|
||||
});
|
||||
|
||||
it('passes the run abort signal to the tool handler', async () => {
|
||||
const handler = vi.fn().mockResolvedValue('ok');
|
||||
const tool: BuiltTool = { name: 'cancellable', description: 'd', handler };
|
||||
|
|
|
|||
|
|
@ -170,10 +170,7 @@ export class McpConnection {
|
|||
if (requireApproval === true) return true;
|
||||
|
||||
if (Array.isArray(requireApproval) && requireApproval.length > 0) {
|
||||
const prefix = `${this.config.name}_`;
|
||||
const originalName = tool.name.startsWith(prefix)
|
||||
? tool.name.slice(prefix.length)
|
||||
: tool.name;
|
||||
const originalName = tool.mcpToolName ?? tool.name;
|
||||
return requireApproval.includes(originalName);
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +180,7 @@ export class McpConnection {
|
|||
async callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
options?: { abortSignal?: AbortSignal },
|
||||
options?: { abortSignal?: AbortSignal; modelToolName?: string },
|
||||
): Promise<McpCallToolResult> {
|
||||
if (!this.client) throw new Error('MCP client not initialized; connect() must be called first');
|
||||
const { CallToolResultSchema } = await loadMcpSdk();
|
||||
|
|
@ -193,10 +190,18 @@ export class McpConnection {
|
|||
CallToolResultSchema,
|
||||
options?.abortSignal ? { signal: options.abortSignal } : undefined,
|
||||
)) as McpCallToolResult;
|
||||
await this.notifyToolCallSettled({ toolName: name, success: result.isError !== true });
|
||||
await this.notifyToolCallSettled({
|
||||
toolName: name,
|
||||
...(options?.modelToolName !== undefined && { modelToolName: options.modelToolName }),
|
||||
success: result.isError !== true,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
await this.notifyToolCallSettled({ toolName: name, success: false });
|
||||
await this.notifyToolCallSettled({
|
||||
toolName: name,
|
||||
...(options?.modelToolName !== undefined && { modelToolName: options.modelToolName }),
|
||||
success: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,23 @@
|
|||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { McpCallToolResult, McpConnection } from './mcp-connection';
|
||||
import { sanitizeToolName } from '../../sdk/tool';
|
||||
import type { AgentMessage, ContentFile, ContentText } from '../../types/sdk/message';
|
||||
import type { BuiltTool, InterruptibleToolContext, ToolContext } from '../../types/sdk/tool';
|
||||
|
||||
type McpContentBlock = McpCallToolResult['content'][number];
|
||||
|
||||
const MAX_TOOL_NAME_LENGTH = 64;
|
||||
const TOOL_NAME_HASH_LENGTH = 8;
|
||||
|
||||
type ToolNameCandidate = {
|
||||
index: number;
|
||||
normalizedName: string;
|
||||
stableIdentity: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert raw MCP tool definitions into BuiltTool instances.
|
||||
* Tool names are prefixed with the server name to prevent collisions.
|
||||
|
|
@ -14,11 +25,11 @@ type McpContentBlock = McpCallToolResult['content'][number];
|
|||
*/
|
||||
export class McpToolResolver {
|
||||
resolve(connection: McpConnection, tools: Tool[]): BuiltTool[] {
|
||||
return tools.map((tool) => this.resolveTool(connection, tool));
|
||||
return ensureUniqueMcpToolNames(tools.map((tool) => this.resolveTool(connection, tool)));
|
||||
}
|
||||
|
||||
private resolveTool(connection: McpConnection, tool: Tool): BuiltTool {
|
||||
const prefixedName = `${connection.name}_${tool.name}`;
|
||||
const prefixedName = sanitizeToolName(`${connection.name}_${tool.name}`);
|
||||
const originalName = tool.name;
|
||||
|
||||
const handler = async (
|
||||
|
|
@ -28,6 +39,7 @@ export class McpToolResolver {
|
|||
const args = (input ?? {}) as Record<string, unknown>;
|
||||
return await connection.callTool(originalName, args, {
|
||||
abortSignal: ctx.abortSignal,
|
||||
modelToolName: ctx.toolName ?? prefixedName,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -43,12 +55,74 @@ export class McpToolResolver {
|
|||
toMessage,
|
||||
mcpTool: true,
|
||||
mcpServerName: connection.name,
|
||||
mcpToolName: originalName,
|
||||
};
|
||||
|
||||
return builtTool;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureUniqueMcpToolNames(tools: BuiltTool[]): BuiltTool[] {
|
||||
const candidates = tools.map<ToolNameCandidate>((tool, index) => {
|
||||
const serverName = tool.mcpServerName;
|
||||
const originalName = tool.mcpToolName;
|
||||
const normalizedName =
|
||||
serverName !== undefined && originalName !== undefined
|
||||
? sanitizeToolName(`${serverName}_${originalName}`)
|
||||
: sanitizeToolName(tool.name);
|
||||
const stableIdentity = JSON.stringify([serverName ?? null, originalName ?? tool.name]);
|
||||
return { index, stableIdentity, normalizedName };
|
||||
});
|
||||
const candidatesByName = new Map<string, ToolNameCandidate[]>();
|
||||
for (const candidate of candidates) {
|
||||
const group = candidatesByName.get(candidate.normalizedName);
|
||||
if (group) {
|
||||
group.push(candidate);
|
||||
} else {
|
||||
candidatesByName.set(candidate.normalizedName, [candidate]);
|
||||
}
|
||||
}
|
||||
const resolvedNames = candidates.map(({ normalizedName }) => normalizedName);
|
||||
const assignedNames = new Set(resolvedNames);
|
||||
|
||||
const collisionGroups = [...candidatesByName.entries()]
|
||||
.filter(([, group]) => group.length > 1)
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
|
||||
for (const [normalizedName, group] of collisionGroups) {
|
||||
const sortedGroup = [...group].sort((left, right) => {
|
||||
const nameComparison = left.stableIdentity.localeCompare(right.stableIdentity);
|
||||
return nameComparison !== 0 ? nameComparison : left.index - right.index;
|
||||
});
|
||||
|
||||
for (const candidate of sortedGroup) {
|
||||
let attempt = 0;
|
||||
let uniqueName: string;
|
||||
do {
|
||||
uniqueName = appendStableSuffix(normalizedName, candidate.stableIdentity, attempt++);
|
||||
} while (assignedNames.has(uniqueName));
|
||||
|
||||
resolvedNames[candidate.index] = uniqueName;
|
||||
assignedNames.add(uniqueName);
|
||||
}
|
||||
}
|
||||
|
||||
return tools.map((tool, index) =>
|
||||
tool.name === resolvedNames[index] ? tool : { ...tool, name: resolvedNames[index] },
|
||||
);
|
||||
}
|
||||
|
||||
function appendStableSuffix(name: string, stableIdentity: string, attempt: number): string {
|
||||
const hashSource = attempt === 0 ? stableIdentity : `${stableIdentity}:${attempt}`;
|
||||
const suffix = createHash('sha256')
|
||||
.update(hashSource)
|
||||
.digest('hex')
|
||||
.slice(0, TOOL_NAME_HASH_LENGTH);
|
||||
const prefixLength = MAX_TOOL_NAME_LENGTH - TOOL_NAME_HASH_LENGTH - 1;
|
||||
const prefix = name.slice(0, prefixLength).replace(/[_-]+$/, '');
|
||||
return `${prefix}_${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an MCP CallToolResult into a rich AgentMessage containing text and image content parts.
|
||||
* Returns undefined if the result contains only text (the tool-result JSON is sufficient for the LLM).
|
||||
|
|
|
|||
|
|
@ -73,17 +73,21 @@ export function toAiSdkTools(tools?: BuiltTool[]): Record<string, AiSdkTool> {
|
|||
if (t.inputSchema) {
|
||||
const ai = loadAi();
|
||||
const providerOptions = applyToolProviderOptionDefaults(t.providerOptions);
|
||||
// Responses otherwise normalizes omitted strict schemas and makes optional MCP fields required.
|
||||
const strict = t.mcpTool ? false : undefined;
|
||||
if (isZodSchema(t.inputSchema)) {
|
||||
result[t.name] = ai.tool({
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
providerOptions,
|
||||
strict,
|
||||
});
|
||||
} else {
|
||||
result[t.name] = ai.tool({
|
||||
description: t.description,
|
||||
inputSchema: ai.jsonSchema(fixSchema(t.inputSchema)),
|
||||
providerOptions,
|
||||
strict,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -118,6 +122,7 @@ export async function executeTool(
|
|||
cancellation: isCancelled ? { message: resumeData.message } : undefined,
|
||||
parentTelemetry,
|
||||
toolCallId,
|
||||
toolName: builtTool.name,
|
||||
runId: executionContext.runId,
|
||||
persistence: executionContext.persistence,
|
||||
emitEvent: executionContext.emitEvent,
|
||||
|
|
@ -131,6 +136,7 @@ export async function executeTool(
|
|||
const ctx: ToolContext = {
|
||||
parentTelemetry,
|
||||
toolCallId,
|
||||
toolName: builtTool.name,
|
||||
runId: executionContext.runId,
|
||||
persistence: executionContext.persistence,
|
||||
emitEvent: executionContext.emitEvent,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,35 @@
|
|||
import type { LanguageModel } from 'ai';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AgentRuntimeConfig } from '../../runtime/loop/agent-runtime';
|
||||
import type { BuiltTool } from '../../types';
|
||||
import { Agent } from '../agent';
|
||||
import { McpClient } from '../mcp-client';
|
||||
|
||||
const fakeModel = { doGenerate: vi.fn() } as unknown as LanguageModel;
|
||||
|
||||
function makeMcpTool(serverName: string): BuiltTool {
|
||||
return {
|
||||
name: 'foo_bar_read',
|
||||
description: 'Read data',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async () => await Promise.resolve({ ok: true }),
|
||||
mcpTool: true,
|
||||
mcpServerName: serverName,
|
||||
mcpToolName: 'read',
|
||||
};
|
||||
}
|
||||
|
||||
function makeMcpClient(serverName: string): McpClient {
|
||||
const client = new McpClient([]);
|
||||
vi.spyOn(client, 'listTools').mockResolvedValue([makeMcpTool(serverName)]);
|
||||
return client;
|
||||
}
|
||||
|
||||
async function buildAgentConfig(agent: Agent): Promise<AgentRuntimeConfig> {
|
||||
return await (agent as unknown as { build(): Promise<AgentRuntimeConfig> }).build();
|
||||
}
|
||||
|
||||
describe('Agent MCP validation', () => {
|
||||
it('throws when requireApproval: true is set without a checkpoint store', async () => {
|
||||
const client = new McpClient([
|
||||
|
|
@ -65,4 +89,16 @@ describe('Agent MCP validation', () => {
|
|||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps model-facing names unique across separate MCP clients', async () => {
|
||||
const config = await buildAgentConfig(
|
||||
new Agent('normalized-prefixes')
|
||||
.model(fakeModel)
|
||||
.instructions('test')
|
||||
.mcp(makeMcpClient('foo bar'))
|
||||
.mcp(makeMcpClient('foo_bar')),
|
||||
);
|
||||
|
||||
expect(new Set(config.tools?.map((tool) => tool.name)).size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Telemetry } from './telemetry';
|
|||
import { wrapToolForApproval } from './tool';
|
||||
import type { VectorStore } from './vector-store';
|
||||
import { AgentRuntime, type AgentRuntimeConfig } from '../runtime/loop/agent-runtime';
|
||||
import { ensureUniqueMcpToolNames } from '../runtime/mcp/mcp-tool-resolver';
|
||||
import { RECALL_MEMORY_TOOL_NAME } from '../runtime/memory/episodic-memory';
|
||||
import type { ScopedMemoryTaskEvent } from '../runtime/memory/scoped-memory-task-runner';
|
||||
import type { FetchFn } from '../runtime/model/model-factory';
|
||||
|
|
@ -906,7 +907,7 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
|
||||
// Resolve tools from all MCP clients.
|
||||
const mcpToolLists = await Promise.all(this.mcpClients.map(async (c) => await c.listTools()));
|
||||
const mcpTools = mcpToolLists.flat();
|
||||
const mcpTools = ensureUniqueMcpToolNames(mcpToolLists.flat());
|
||||
|
||||
// Detect collisions between direct, deferred, and MCP tools.
|
||||
const staticCollisions = findDuplicateToolNames(finalStaticTools);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { McpConnection } from '../runtime/mcp/mcp-connection';
|
||||
import { ensureUniqueMcpToolNames } from '../runtime/mcp/mcp-tool-resolver';
|
||||
import type { McpServerConfig, McpVerifyResult } from '../types/sdk/mcp';
|
||||
import type { BuiltTool } from '../types/sdk/tool';
|
||||
|
||||
|
|
@ -211,24 +212,7 @@ export class McpClient {
|
|||
}
|
||||
|
||||
const tools = settled.flatMap((r) => (r.status === 'fulfilled' ? r.value : []));
|
||||
|
||||
const seen = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
for (const tool of tools) {
|
||||
if (seen.has(tool.name)) {
|
||||
duplicates.push(tool.name);
|
||||
}
|
||||
seen.add(tool.name);
|
||||
}
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
await Promise.allSettled(connectedConnections.map(async (c) => await c.disconnect()));
|
||||
throw new Error(
|
||||
`MCP tool name collision — the following tool names resolve to duplicates: ${duplicates.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return tools;
|
||||
return ensureUniqueMcpToolNames(tools);
|
||||
}
|
||||
|
||||
private async doClose(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ export type McpVerifyResult =
|
|||
| { ok: false; errors: Array<{ server: string; error: string }> };
|
||||
|
||||
export interface McpToolCallSettledEvent {
|
||||
/** Original, unprefixed name reported by the MCP server. */
|
||||
toolName: string;
|
||||
/** Exact normalized name exposed to the model. */
|
||||
modelToolName?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ export interface ToolExecutionContext {
|
|||
export interface ToolContext {
|
||||
/** AI SDK tool call ID for the current local tool execution. */
|
||||
toolCallId?: string;
|
||||
/** Exact model-facing name of the tool being executed. */
|
||||
toolName?: string;
|
||||
/** Agent run ID and persistence scope for the current execution. */
|
||||
runId?: string;
|
||||
/** Current persisted thread scope when the run is backed by memory. */
|
||||
|
|
@ -67,6 +69,8 @@ export interface InterruptibleToolContext<S = unknown, R = unknown> {
|
|||
cancellation?: { message: string };
|
||||
/** AI SDK tool call ID for the current local tool execution. */
|
||||
toolCallId?: string;
|
||||
/** Exact model-facing name of the tool being executed. */
|
||||
toolName?: string;
|
||||
/** Agent run ID for the current execution. */
|
||||
runId?: string;
|
||||
/** Current persisted thread scope when the run is backed by memory. */
|
||||
|
|
@ -122,6 +126,8 @@ export interface BuiltTool {
|
|||
readonly mcpTool?: boolean;
|
||||
/** Name of the MCP server this tool belongs to. Set when mcpTool is true. */
|
||||
readonly mcpServerName?: string;
|
||||
/** Original, unprefixed tool name reported by the MCP server. */
|
||||
readonly mcpToolName?: string;
|
||||
/**
|
||||
* Provider-specific options forwarded to the AI SDK's `tool()` call.
|
||||
* Keyed by provider name (e.g. `anthropic`, `openai`).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-node-sdk",
|
||||
"version": "0.22.0",
|
||||
"version": "0.22.2",
|
||||
"description": "SDK for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-utilities",
|
||||
"version": "0.25.0",
|
||||
"version": "0.25.2",
|
||||
"description": "Utilities for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -245,6 +245,32 @@ describe('getProxyAgent', () => {
|
|||
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', () => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
* 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 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 */
|
||||
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 timeoutOptions - Optional timeout configuration to override defaults. When provided,
|
||||
* 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,
|
||||
* or undefined if no proxy is configured and no timeout options are provided (backward compatible behavior).
|
||||
* @param lookup - Optional DNS lookup to pin the resolved address at connect time (e.g. an egress
|
||||
* filter's secure lookup). When provided (without a proxy) an Agent is always returned.
|
||||
* @returns An Agent (no proxy with timeout options 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
|
||||
* When timeoutOptions are provided, this function always returns an agent to ensure timeouts are properly configured.
|
||||
* The default undici timeouts (5 minutes) are too short for many AI operations.
|
||||
* When timeoutOptions are NOT provided, 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 agentOptions = {
|
||||
|
|
@ -69,6 +76,9 @@ export function getProxyAgent(targetUrl?: string, timeoutOptions?: AgentTimeoutO
|
|||
};
|
||||
|
||||
if (!proxyUrl) {
|
||||
if (lookup) {
|
||||
return new Agent({ ...agentOptions, connect: { lookup } });
|
||||
}
|
||||
if (timeoutOptions) {
|
||||
return new Agent(agentOptions);
|
||||
}
|
||||
|
|
@ -85,14 +95,16 @@ export function getProxyAgent(targetUrl?: string, timeoutOptions?: AgentTimeoutO
|
|||
* @param input - The URL to fetch
|
||||
* @param init - Standard fetch RequestInit options
|
||||
* @param timeoutOptions - Optional timeout configuration to override defaults
|
||||
* @param lookup - Optional connect-time DNS lookup (e.g. an egress filter's secure lookup)
|
||||
*/
|
||||
export async function proxyFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
timeoutOptions?: AgentTimeoutOptions,
|
||||
lookup?: LookupFunction,
|
||||
): Promise<Response> {
|
||||
const targetUrl = input instanceof Request ? input.url : input.toString();
|
||||
const dispatcher = getProxyAgent(targetUrl, timeoutOptions);
|
||||
const dispatcher = getProxyAgent(targetUrl, timeoutOptions, lookup);
|
||||
|
||||
return await fetch(input, {
|
||||
...init,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-workflow-builder",
|
||||
"version": "1.32.0",
|
||||
"version": "1.32.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.32.0",
|
||||
"version": "1.32.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -227,10 +227,8 @@ export const McpServerConfigSchema = z
|
|||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/)
|
||||
.describe(
|
||||
'Unique server name, also used as the SDK tool-name prefix (e.g. github -> github_create_issue)',
|
||||
),
|
||||
.refine((name) => name.trim().length > 0, 'MCP server name cannot be blank')
|
||||
.describe('Unique display name. The SDK normalizes it when building model-facing tool names'),
|
||||
description: z.string().max(512).optional().describe('Human-readable server description'),
|
||||
url: z.string().describe('MCP server endpoint URL. Empty string means setup is incomplete'),
|
||||
transport: z
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "1.32.0",
|
||||
"version": "1.32.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-network",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-test-utils",
|
||||
"version": "1.32.0",
|
||||
"version": "1.32.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat-hub",
|
||||
"version": "1.25.0",
|
||||
"version": "1.25.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "1.14.0",
|
||||
"version": "1.14.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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",
|
||||
"publishConfig": {
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import fastGlob from 'fast-glob';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
|
@ -5,6 +6,11 @@ import * as path from 'node:path';
|
|||
import { textOf } from '../test-utils';
|
||||
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 ContentMatch = { path: string; lineNumber: number; line: string };
|
||||
|
||||
|
|
@ -252,6 +258,10 @@ describe('searchFilesTool', () => {
|
|||
});
|
||||
|
||||
describe('execute — security', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(fastGlob).mockClear();
|
||||
});
|
||||
|
||||
it.each(['/etc/passwd', '../../etc/passwd', '../../../etc/**'])(
|
||||
'rejects pattern that escapes the base directory: %s',
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import fastGlob from 'fast-glob';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { ToolDefinition } from '../types';
|
||||
|
|
@ -62,7 +61,7 @@ export const searchFilesTool: ToolDefinition<typeof inputSchema> = {
|
|||
const resolvedDir = await resolveReadablePath(dir, '.');
|
||||
const limit = maxResults ?? 50;
|
||||
|
||||
const files = await fastGlob(name, {
|
||||
const globbed = await fastGlob(name, {
|
||||
cwd: resolvedDir,
|
||||
ignore: IGNORE_PATTERNS,
|
||||
onlyFiles: true,
|
||||
|
|
@ -70,63 +69,77 @@ export const searchFilesTool: ToolDefinition<typeof inputSchema> = {
|
|||
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) {
|
||||
const matches = files.slice(0, limit).map((p) => ({ path: p }));
|
||||
return formatCallToolResult({
|
||||
name,
|
||||
matches,
|
||||
matches: files.slice(0, limit).map(({ path }) => ({ path })),
|
||||
truncated: files.length > limit,
|
||||
totalMatches: files.length,
|
||||
});
|
||||
}
|
||||
|
||||
const regex = new RegExp(escapeRegex(query), ignoreCase ? 'gi' : 'g');
|
||||
const matches: Array<{ path: string; lineNumber: number; line: string }> = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
const regex = new RegExp(escapeRegex(query), ignoreCase ? 'i' : '');
|
||||
const matches = await Promise.all(files.map(async (file) => await grepFile(file, regex))).then(
|
||||
(results) => results.flat(),
|
||||
);
|
||||
|
||||
return formatCallToolResult({
|
||||
name,
|
||||
query,
|
||||
matches,
|
||||
truncated: totalMatches > limit,
|
||||
totalMatches,
|
||||
matches: matches.slice(0, limit),
|
||||
truncated: matches.length > limit,
|
||||
totalMatches: matches.length,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
interface ResolvedFile {
|
||||
path: string;
|
||||
absolutePath: string;
|
||||
}
|
||||
|
||||
function assertPatternStaysInside(pattern: string): void {
|
||||
if (pattern.startsWith('/') || pattern.split('/').includes('..')) {
|
||||
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 {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/create-node",
|
||||
"version": "0.40.0",
|
||||
"version": "0.40.2",
|
||||
"description": "Official CLI to create new community nodes for n8n",
|
||||
"bin": {
|
||||
"create-node": "bin/create-node.cjs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/db",
|
||||
"version": "1.32.0",
|
||||
"version": "1.32.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "1.32.0",
|
||||
"version": "1.32.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/expression-runtime",
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"description": "Secure, isolated expression evaluation runtime for n8n",
|
||||
"main": "dist/cjs/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -377,6 +377,72 @@ describe('Integration: ExpressionEvaluator + IsolatedVmBridge', () => {
|
|||
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', () => {
|
||||
const data = { $json: {} };
|
||||
|
||||
|
|
|
|||
|
|
@ -400,8 +400,21 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
|||
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];
|
||||
|
||||
// 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
|
||||
// marshaling as an empty object.
|
||||
if (element instanceof Date) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/instance-ai",
|
||||
"version": "1.17.0",
|
||||
"version": "1.17.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -18,8 +18,17 @@ export async function reconcileStaleCredentialPlan(args: {
|
|||
domainContext: NonNullable<OrchestrationContext['domainContext']>;
|
||||
workflowTaskService: WorkflowTaskService;
|
||||
logger: OrchestrationContext['logger'];
|
||||
/** Host-resolved model used when no eval model API key is configured in the environment. */
|
||||
fallbackModelConfig?: OrchestrationContext['modelId'];
|
||||
}): Promise<WorkflowBuildOutcome> {
|
||||
const { buildOutcome, workflowId, domainContext, workflowTaskService, logger } = args;
|
||||
const {
|
||||
buildOutcome,
|
||||
workflowId,
|
||||
domainContext,
|
||||
workflowTaskService,
|
||||
logger,
|
||||
fallbackModelConfig,
|
||||
} = args;
|
||||
// Reconciliation can change something only when the outcome holds mocked
|
||||
// credentials or an AI root simulated for missing model credentials.
|
||||
const hasMockedCredentials = Object.keys(buildOutcome.mockedCredentialsByNode ?? {}).length > 0;
|
||||
|
|
@ -32,7 +41,12 @@ export async function reconcileStaleCredentialPlan(args: {
|
|||
try {
|
||||
const workflow = await domainContext.workflowService.getAsWorkflowJSON(workflowId);
|
||||
const availableCredentials = await buildCredentialMap(domainContext.credentialService);
|
||||
const patch = await reconcileSimulationPlan({ buildOutcome, workflow, availableCredentials });
|
||||
const patch = await reconcileSimulationPlan({
|
||||
buildOutcome,
|
||||
workflow,
|
||||
availableCredentials,
|
||||
fallbackModelConfig,
|
||||
});
|
||||
if (!patch) return buildOutcome;
|
||||
|
||||
await workflowTaskService.updateBuildOutcome(buildOutcome.workItemId, patch);
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ export function createVerifyBuiltWorkflowTool(context: OrchestrationContext) {
|
|||
domainContext: target.domainContext,
|
||||
workflowTaskService,
|
||||
logger: context.logger,
|
||||
fallbackModelConfig: context.modelId,
|
||||
});
|
||||
|
||||
if (buildOutcome.nodeSimulationPlan === undefined) {
|
||||
|
|
|
|||
|
|
@ -201,6 +201,34 @@ describe('classifyNodesForSimulation', () => {
|
|||
expect(mockCreateEvalAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forwards the fallback model config to the LLM call', async () => {
|
||||
setupAgentMock(
|
||||
JSON.stringify({
|
||||
Search: { verdict: 'execute', reason: 'POST to a search endpoint', confidence: 'high' },
|
||||
}),
|
||||
);
|
||||
const fallbackModelConfig = {
|
||||
id: 'anthropic/claude-opus-4-8' as const,
|
||||
url: 'https://proxy.example.com/anthropic/v1',
|
||||
apiKey: 'proxy-token',
|
||||
};
|
||||
await classifyNodesForSimulation({
|
||||
workflow: chainWorkflow([
|
||||
trigger,
|
||||
{
|
||||
name: 'Search',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
parameters: { method: 'POST', url: 'https://api.example.com/search' },
|
||||
},
|
||||
]),
|
||||
fallbackModelConfig,
|
||||
});
|
||||
expect(mockCreateEvalAgent).toHaveBeenCalledWith(
|
||||
'verification-destructiveness-classifier',
|
||||
expect.objectContaining({ fallbackModelConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies IO-free Code nodes as execute deterministically', async () => {
|
||||
const verdicts = await classify([
|
||||
trigger,
|
||||
|
|
|
|||
|
|
@ -120,6 +120,42 @@ describe('generateSimulationFixtures', () => {
|
|||
expect(result).toEqual({ A: [{}] });
|
||||
});
|
||||
|
||||
it('warns on generation failure so the empty-fixture degrade is visible', async () => {
|
||||
mockCreateEvalAgent.mockImplementation(() => {
|
||||
throw new Error('Missing API key');
|
||||
});
|
||||
const warn = vi.fn();
|
||||
const logger = { info: vi.fn(), warn, error: vi.fn(), debug: vi.fn() };
|
||||
const result = await generateSimulationFixtures({
|
||||
workflow: wf([{ name: 'A', type: 'n8n-nodes-base.slack' }]),
|
||||
plan: [simulateVerdict('A')],
|
||||
logger,
|
||||
});
|
||||
expect(result).toEqual({ A: [{}] });
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('fixture generation failed'),
|
||||
expect.objectContaining({ reason: 'generation_failed', nodeCount: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the fallback model config to the LLM call', async () => {
|
||||
setupAgentMock(JSON.stringify({ A: [{ json: { ok: true } }] }));
|
||||
const fallbackModelConfig = {
|
||||
id: 'anthropic/claude-opus-4-8' as const,
|
||||
url: 'https://proxy.example.com/anthropic/v1',
|
||||
apiKey: 'proxy-token',
|
||||
};
|
||||
await generateSimulationFixtures({
|
||||
workflow: wf([{ name: 'A', type: 'n8n-nodes-base.slack' }]),
|
||||
plan: [simulateVerdict('A')],
|
||||
fallbackModelConfig,
|
||||
});
|
||||
expect(mockCreateEvalAgent).toHaveBeenCalledWith(
|
||||
'verification-simulation-fixtures',
|
||||
expect.objectContaining({ fallbackModelConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it('strips markdown fences around the JSON output', async () => {
|
||||
setupAgentMock('```json\n{"A":[{"json":{"ok":true}}]}\n```');
|
||||
const result = await generateSimulationFixtures({
|
||||
|
|
|
|||
|
|
@ -62,6 +62,34 @@ describe('planVerificationSimulation — simulated trigger verdicts', () => {
|
|||
mockGenerateFixtures.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('forwards the fallback model config to classification and fixture generation', async () => {
|
||||
const fallbackModelConfig = {
|
||||
id: 'anthropic/claude-opus-4-8' as const,
|
||||
url: 'https://proxy.example.com/anthropic/v1',
|
||||
apiKey: 'proxy-token',
|
||||
};
|
||||
mockClassify.mockResolvedValue([
|
||||
{
|
||||
nodeName: 'Send It',
|
||||
verdict: 'simulate',
|
||||
reason: 'Sends a message',
|
||||
confidence: 'high',
|
||||
source: 'deterministic',
|
||||
},
|
||||
]);
|
||||
|
||||
await planVerificationSimulation({
|
||||
workflow: wf([{ name: 'Send It', type: 'n8n-nodes-base.slack' }]),
|
||||
workflowId: 'wf-1',
|
||||
fallbackModelConfig,
|
||||
});
|
||||
|
||||
expect(mockClassify).toHaveBeenCalledWith(expect.objectContaining({ fallbackModelConfig }));
|
||||
expect(mockGenerateFixtures).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fallbackModelConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it('injects a deterministic simulate verdict for non-deterministic triggers', async () => {
|
||||
mockClassify.mockResolvedValue([executeVerdict('Fetch Rows')]);
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export function createApplyWorkflowCredentialsTool(context: OrchestrationContext
|
|||
buildOutcome,
|
||||
workflow: json,
|
||||
availableCredentials,
|
||||
fallbackModelConfig: context.modelId,
|
||||
});
|
||||
if (patch) {
|
||||
await context.workflowTaskService.updateBuildOutcome(input.workItemId, {
|
||||
|
|
|
|||
|
|
@ -761,6 +761,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
|||
declaredOutputFixtures: compiled.declaredOutputFixtures,
|
||||
workflowId: saved.id,
|
||||
outputSchemaLookup: context.outputSchemaLookup,
|
||||
fallbackModelConfig: context.modelId,
|
||||
logger: context.logger,
|
||||
});
|
||||
const runId = buildContext?.runId ?? context.runId;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import type { IConnections } from 'n8n-workflow';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { isTriggerNodeType } from './workflow-json-utils';
|
||||
import type { ModelConfig } from '../../types';
|
||||
import { HAIKU_MODEL } from '../../utils/eval-agents';
|
||||
import { generateValidatedJson } from '../../utils/generate-validated-json';
|
||||
import type { NodeSimulationVerdict } from '../../workflow-loop/workflow-loop-state';
|
||||
|
|
@ -32,6 +33,8 @@ export interface ClassifyNodesForSimulationInput {
|
|||
workflow: WorkflowJSON;
|
||||
/** Node names whose credentials were mocked — always simulated. */
|
||||
mockedNodeNames?: string[];
|
||||
/** Host-resolved model used when no eval model API key is configured in the environment. */
|
||||
fallbackModelConfig?: ModelConfig;
|
||||
}
|
||||
|
||||
const STICKY_NOTE_TYPE = 'n8n-nodes-base.stickyNote';
|
||||
|
|
@ -354,6 +357,7 @@ function formatNodeBlock(node: WorkflowNode & { name: string }): string {
|
|||
|
||||
async function classifyAmbiguousNodes(
|
||||
nodes: Array<WorkflowNode & { name: string }>,
|
||||
fallbackModelConfig?: ModelConfig,
|
||||
): Promise<NodeSimulationVerdict[]> {
|
||||
const userText = [
|
||||
'Classify the following n8n workflow nodes.',
|
||||
|
|
@ -368,6 +372,7 @@ async function classifyAmbiguousNodes(
|
|||
instructions: SYSTEM_INSTRUCTIONS,
|
||||
userText,
|
||||
schema: LlmVerdictSchema,
|
||||
fallbackModelConfig,
|
||||
});
|
||||
const parsed = result.ok ? result.data : undefined;
|
||||
|
||||
|
|
@ -426,7 +431,7 @@ export async function classifyNodesForSimulation(
|
|||
// retired, the plan is the only source of verification pin data, so a
|
||||
// throw here would leave every node executing for real. Fail destructive.
|
||||
try {
|
||||
for (const verdict of await classifyAmbiguousNodes(ambiguous)) {
|
||||
for (const verdict of await classifyAmbiguousNodes(ambiguous, input.fallbackModelConfig)) {
|
||||
verdictByName.set(verdict.nodeName, verdict);
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import { getParentNodes, mapConnectionsByDestination, type IConnections } from '
|
|||
import { z } from 'zod';
|
||||
|
||||
import { isTriggerNodeType } from './workflow-json-utils';
|
||||
import type { Logger } from '../../logger';
|
||||
import type { ModelConfig } from '../../types';
|
||||
import { SONNET_MODEL } from '../../utils/eval-agents';
|
||||
import { generateValidatedJson } from '../../utils/generate-validated-json';
|
||||
import type { NodeSimulationVerdict } from '../../workflow-loop/workflow-loop-state';
|
||||
|
|
@ -53,6 +55,9 @@ export interface GenerateSimulationFixturesInput {
|
|||
* structure instead of the model's guess at the service's response shape.
|
||||
*/
|
||||
outputSchemaLookup?: OutputSchemaLookup;
|
||||
/** Host-resolved model used when no eval model API key is configured in the environment. */
|
||||
fallbackModelConfig?: ModelConfig;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
// Loose on purpose: items may arrive `{json: {...}}`-wrapped, flat, or as an
|
||||
|
|
@ -196,8 +201,15 @@ export async function generateSimulationFixtures(
|
|||
instructions: SYSTEM_INSTRUCTIONS,
|
||||
userText,
|
||||
schema: FixturesResponseSchema,
|
||||
fallbackModelConfig: input.fallbackModelConfig,
|
||||
});
|
||||
if (!result.ok) return emptyFixtures(nodeNames);
|
||||
if (!result.ok) {
|
||||
input.logger?.warn('Simulation fixture generation failed; simulated nodes get empty items', {
|
||||
reason: result.reason,
|
||||
nodeCount: nodeNames.length,
|
||||
});
|
||||
return emptyFixtures(nodeNames);
|
||||
}
|
||||
|
||||
// Shared normalization + envelope repair, matching the eval pin-data paths:
|
||||
// wrap-or-passthrough items, then mechanically fix the two known LLM
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
} from './generate-simulation-fixtures.service';
|
||||
import { isMockableTriggerNodeType, isTriggerNodeType } from './workflow-json-utils';
|
||||
import type { Logger } from '../../logger';
|
||||
import type { ModelConfig } from '../../types';
|
||||
import type { NodeSimulationVerdict } from '../../workflow-loop/workflow-loop-state';
|
||||
|
||||
export interface PlanVerificationSimulationInput {
|
||||
|
|
@ -37,6 +38,8 @@ export interface PlanVerificationSimulationInput {
|
|||
workflowId: string;
|
||||
/** Node output `__schema__` lookup used to shape generated fixtures. */
|
||||
outputSchemaLookup?: OutputSchemaLookup;
|
||||
/** Host-resolved model used when no eval model API key is configured in the environment. */
|
||||
fallbackModelConfig?: ModelConfig;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
|
|
@ -255,13 +258,18 @@ export async function planVerificationSimulation({
|
|||
declaredOutputFixtures,
|
||||
workflowId,
|
||||
outputSchemaLookup,
|
||||
fallbackModelConfig,
|
||||
logger,
|
||||
}: PlanVerificationSimulationInput): Promise<VerificationSimulationPlan> {
|
||||
let nodeSimulationPlan: NodeSimulationVerdict[] | undefined;
|
||||
let simulationFixtures: SimulationFixtures | undefined;
|
||||
const declaredFixtures = nonEmptyDeclaredFixtures(declaredOutputFixtures);
|
||||
try {
|
||||
nodeSimulationPlan = await classifyNodesForSimulation({ workflow, mockedNodeNames });
|
||||
nodeSimulationPlan = await classifyNodesForSimulation({
|
||||
workflow,
|
||||
mockedNodeNames,
|
||||
fallbackModelConfig,
|
||||
});
|
||||
nodeSimulationPlan = withDeclaredOutputVerdicts(nodeSimulationPlan, declaredFixtures);
|
||||
nodeSimulationPlan = withSimulatedTriggerVerdicts(nodeSimulationPlan, workflow);
|
||||
nodeSimulationPlan = withSimulatedCredentiallessAiRootVerdicts(
|
||||
|
|
@ -280,6 +288,8 @@ export async function planVerificationSimulation({
|
|||
workflow,
|
||||
plan: planNeedingGeneratedFixtures,
|
||||
outputSchemaLookup,
|
||||
fallbackModelConfig,
|
||||
logger,
|
||||
})
|
||||
: {};
|
||||
const fixtures = { ...generatedFixtures, ...declaredFixtures };
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
findCredentiallessAiRoots,
|
||||
} from './plan-verification-simulation';
|
||||
import type { CredentialMap } from './resolve-credentials';
|
||||
import type { ModelConfig } from '../../types';
|
||||
import type {
|
||||
NodeSimulationVerdict,
|
||||
WorkflowBuildOutcome,
|
||||
|
|
@ -75,8 +76,10 @@ export async function reconcileSimulationPlan(args: {
|
|||
buildOutcome: WorkflowBuildOutcome;
|
||||
workflow: WorkflowJSON;
|
||||
availableCredentials: CredentialMap;
|
||||
/** Host-resolved model used when no eval model API key is configured in the environment. */
|
||||
fallbackModelConfig?: ModelConfig;
|
||||
}): Promise<SimulationPlanPatch | undefined> {
|
||||
const { buildOutcome, workflow, availableCredentials } = args;
|
||||
const { buildOutcome, workflow, availableCredentials, fallbackModelConfig } = args;
|
||||
|
||||
const mockedEntries = Object.entries(buildOutcome.mockedCredentialsByNode ?? {});
|
||||
const satisfiedNames = new Set(
|
||||
|
|
@ -131,6 +134,7 @@ export async function reconcileSimulationPlan(args: {
|
|||
const freshVerdicts = await classifyNodesForSimulation({
|
||||
workflow: scopedWorkflow,
|
||||
mockedNodeNames: remainingMockedNames,
|
||||
fallbackModelConfig,
|
||||
});
|
||||
freshVerdictByName = new Map(freshVerdicts.map((verdict) => [verdict.nodeName, verdict]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -951,6 +951,13 @@ export interface InstanceAiContext {
|
|||
*/
|
||||
tracing?: InstanceAiTraceContext;
|
||||
projectId?: string;
|
||||
/**
|
||||
* Host-resolved model for the current run (proxy-managed on cloud). Domain
|
||||
* tools pass it as the fallback for utility LLM calls (simulation fixtures,
|
||||
* destructiveness classification), which otherwise resolve an eval model
|
||||
* from environment API keys that proxy-managed deployments don't have.
|
||||
*/
|
||||
modelId?: ModelConfig;
|
||||
workflowService: InstanceAiWorkflowService;
|
||||
executionService: InstanceAiExecutionService;
|
||||
credentialService: InstanceAiCredentialService;
|
||||
|
|
|
|||
|
|
@ -81,4 +81,43 @@ describe('eval agent model config', () => {
|
|||
reasoningEffort: 'high',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws without env keys or a fallback model config', () => {
|
||||
expect(() => createEvalAgent('test-agent', { instructions: 'Do the task.' })).toThrow(
|
||||
/Missing API key/,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the fallback model config when no env API key is configured', () => {
|
||||
const fallbackModelConfig = {
|
||||
id: 'anthropic/claude-opus-4-8' as const,
|
||||
url: 'https://proxy.example.com/anthropic/v1',
|
||||
apiKey: 'proxy-token',
|
||||
};
|
||||
|
||||
createEvalAgent('test-agent', {
|
||||
instructions: 'Do the task.',
|
||||
fallbackModelConfig,
|
||||
});
|
||||
|
||||
expect(mockAgentInstances[0]?.model).toHaveBeenCalledWith(fallbackModelConfig);
|
||||
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('anthropic', {
|
||||
mode: 'adaptive',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers env-based model resolution over the fallback', () => {
|
||||
process.env.N8N_AI_ANTHROPIC_KEY = 'env-key';
|
||||
|
||||
createEvalAgent('test-agent', {
|
||||
instructions: 'Do the task.',
|
||||
fallbackModelConfig: { id: 'anthropic/claude-opus-4-8' as const, url: '', apiKey: 'jwt' },
|
||||
});
|
||||
|
||||
expect(mockAgentInstances[0]?.model).toHaveBeenCalledWith({
|
||||
id: 'anthropic/claude-sonnet-4-6',
|
||||
apiKey: 'env-key',
|
||||
url: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,4 +79,23 @@ describe('generateValidatedJson', () => {
|
|||
});
|
||||
expect(await generate()).toEqual({ ok: false, reason: 'generation_failed' });
|
||||
});
|
||||
|
||||
it('forwards the fallback model config to agent creation', async () => {
|
||||
setupAgentMock('{"ok": true}');
|
||||
const fallbackModelConfig = {
|
||||
id: 'anthropic/claude-opus-4-8' as const,
|
||||
url: 'https://proxy.example.com/anthropic/v1',
|
||||
apiKey: 'proxy-token',
|
||||
};
|
||||
await generateValidatedJson('test-agent', {
|
||||
instructions: 'instructions',
|
||||
userText: 'do it',
|
||||
schema,
|
||||
fallbackModelConfig,
|
||||
});
|
||||
expect(mockCreateEvalAgent).toHaveBeenCalledWith(
|
||||
'test-agent',
|
||||
expect.objectContaining({ fallbackModelConfig }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/** Shared agent factory + helpers for eval LLM calls (hint generation, mock responses, pin data). */
|
||||
|
||||
import { Agent, Tool, type GenerateResult } from '@n8n/agents';
|
||||
import { Agent, Tool, type GenerateResult, type ModelConfig } from '@n8n/agents';
|
||||
|
||||
import { applyAgentThinking } from '../agent/apply-agent-thinking';
|
||||
|
||||
|
|
@ -100,20 +100,34 @@ const CACHE_PROVIDER_OPTS = {
|
|||
providerOptions: EPHEMERAL_CACHE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Env-based tiered model when configured, otherwise the caller's fallback.
|
||||
* Deployments where the model is managed outside the environment (e.g. the
|
||||
* cloud AI service proxy) have no eval API key, so without a fallback every
|
||||
* in-product eval call would fail before reaching the LLM.
|
||||
*/
|
||||
function resolveAgentModel(model?: string, fallbackModelConfig?: ModelConfig): ModelConfig {
|
||||
try {
|
||||
const { modelId, apiKey, url } = resolveEvalModelConfig(model);
|
||||
return { id: modelId, apiKey, url };
|
||||
} catch (error) {
|
||||
if (fallbackModelConfig) return fallbackModelConfig;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function createEvalAgent(
|
||||
name: string,
|
||||
options: {
|
||||
model?: string;
|
||||
instructions: string;
|
||||
cache?: boolean;
|
||||
/** Host-resolved model used when no eval model API key is configured in the environment. */
|
||||
fallbackModelConfig?: ModelConfig;
|
||||
},
|
||||
): Agent {
|
||||
const { modelId, apiKey, url } = resolveEvalModelConfig(options.model);
|
||||
const agent = new Agent(name).model({
|
||||
id: modelId,
|
||||
apiKey,
|
||||
url,
|
||||
});
|
||||
const model = resolveAgentModel(options.model, options.fallbackModelConfig);
|
||||
const agent = new Agent(name).model(model);
|
||||
|
||||
if (options.cache) {
|
||||
agent.instructions(options.instructions, CACHE_PROVIDER_OPTS);
|
||||
|
|
@ -121,7 +135,7 @@ export function createEvalAgent(
|
|||
agent.instructions(options.instructions);
|
||||
}
|
||||
|
||||
applyAgentThinking(agent, modelId);
|
||||
applyAgentThinking(agent, model);
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
* helper's own tests mock `eval-agents`.
|
||||
*/
|
||||
|
||||
import type { ModelConfig } from '@n8n/agents';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { createEvalAgent, extractText } from './eval-agents';
|
||||
|
|
@ -25,6 +26,8 @@ export interface GenerateValidatedJsonOptions<T> {
|
|||
instructions: string;
|
||||
userText: string;
|
||||
schema: z.ZodType<T>;
|
||||
/** Host-resolved model used when no eval model API key is configured in the environment. */
|
||||
fallbackModelConfig?: ModelConfig;
|
||||
}
|
||||
|
||||
function stripMarkdownFences(text: string): string {
|
||||
|
|
@ -42,6 +45,7 @@ export async function generateValidatedJson<T>(
|
|||
const llm = createEvalAgent(agentName, {
|
||||
model: options.model,
|
||||
instructions: options.instructions,
|
||||
fallbackModelConfig: options.fallbackModelConfig,
|
||||
});
|
||||
const result = await llm.generate([
|
||||
{ role: 'user' as const, content: [{ type: 'text' as const, text: options.userText }] },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/node-cli",
|
||||
"version": "0.41.0",
|
||||
"version": "0.41.2",
|
||||
"description": "Official CLI for developing community nodes for n8n",
|
||||
"bin": {
|
||||
"n8n-node": "bin/n8n-node.mjs"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { InferenceProviderOrPolicy } from '@huggingface/inference';
|
|||
import { PROVIDERS_OR_POLICIES } from '@huggingface/inference';
|
||||
import { HuggingFaceInferenceEmbeddings } from '@langchain/community/embeddings/hf';
|
||||
import {
|
||||
assertCredentialAllowsUrl,
|
||||
NodeConnectionTypes,
|
||||
NodeOperationError,
|
||||
type INodeType,
|
||||
|
|
@ -104,6 +105,18 @@ export class EmbeddingsHuggingFaceInference implements INodeType {
|
|||
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({
|
||||
apiKey: credentials.apiKey as string,
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { OpenAI, type ClientOptions } from '@langchain/openai';
|
||||
import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import { assertCredentialAllowsUrl, NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type {
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
|
|
@ -207,8 +207,17 @@ export class LmOpenAi implements INodeType {
|
|||
const options = this.getNodeParameter('options', {}) as LmOpenAiOptions;
|
||||
|
||||
let uri = 'https://api.openai.com/v1/models';
|
||||
let allowedDomains: string | undefined;
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
|
|
@ -216,6 +225,7 @@ export class LmOpenAi implements INodeType {
|
|||
method: 'GET',
|
||||
uri,
|
||||
json: true,
|
||||
allowedDomains,
|
||||
})) as { data: Array<{ owned_by: string; id: string }> };
|
||||
|
||||
for (const model of data) {
|
||||
|
|
@ -264,6 +274,13 @@ export class LmOpenAi implements INodeType {
|
|||
};
|
||||
|
||||
if (options.baseURL) {
|
||||
assertCredentialAllowsUrl({
|
||||
node: this.getNode(),
|
||||
credentialData: credentials,
|
||||
url: options.baseURL,
|
||||
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
|
||||
surface: 'OpenAI',
|
||||
});
|
||||
configuration.baseURL = options.baseURL;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { HuggingFaceInference } from '@langchain/community/llms/hf';
|
||||
import {
|
||||
assertCredentialAllowsUrl,
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
|
|
@ -138,7 +139,15 @@ export class LmOpenHuggingFaceInference implements INodeType {
|
|||
const credentials = await this.getCredentials('huggingFaceApi');
|
||||
|
||||
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
|
||||
// That's why mistral's model is the default value
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { OpenAI } from '@langchain/openai';
|
||||
import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities';
|
||||
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 { LmOpenAi } from '../LMOpenAi/LmOpenAi.node';
|
||||
|
|
@ -91,5 +91,102 @@ describe('LmOpenAi', () => {
|
|||
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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -311,10 +311,15 @@ describe('McpClientTool', () => {
|
|||
// Verify the eventSourceInit fetch injects auth headers and Accept header
|
||||
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
|
||||
await customFetch?.(url, {} as any);
|
||||
expect(mockedProxyFetch).toHaveBeenCalledWith(url, {
|
||||
headers: { Accept: 'text/event-stream', 'my-header': 'header-value' },
|
||||
redirect: 'manual',
|
||||
});
|
||||
expect(mockedProxyFetch).toHaveBeenCalledWith(
|
||||
url,
|
||||
{
|
||||
headers: { Accept: 'text/event-stream', 'my-header': 'header-value' },
|
||||
redirect: 'manual',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should support bearer auth', async () => {
|
||||
|
|
@ -363,10 +368,15 @@ describe('McpClientTool', () => {
|
|||
// Verify the eventSourceInit fetch injects auth headers and Accept header
|
||||
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
|
||||
await customFetch?.(url, {} as any);
|
||||
expect(mockedProxyFetch).toHaveBeenCalledWith(url, {
|
||||
headers: { Accept: 'text/event-stream', Authorization: 'Bearer my-token' },
|
||||
redirect: 'manual',
|
||||
});
|
||||
expect(mockedProxyFetch).toHaveBeenCalledWith(
|
||||
url,
|
||||
{
|
||||
headers: { Accept: 'text/event-stream', Authorization: 'Bearer my-token' },
|
||||
redirect: 'manual',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should successfully execute a tool', async () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
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 { mockDeep } from 'vitest-mock-extended';
|
||||
import { expect } from 'vitest';
|
||||
|
|
@ -10,6 +11,7 @@ import { expect } from 'vitest';
|
|||
import type { McpAuthenticationOption } from '../types';
|
||||
import {
|
||||
connectMcpClient,
|
||||
connectMcpClientForCredential,
|
||||
getAuthHeaders,
|
||||
mapToNodeOperationError,
|
||||
tryRefreshOAuth2Token,
|
||||
|
|
@ -675,6 +677,69 @@ describe('utils', () => {
|
|||
expect(result.ok).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
ILoadOptionsFunctions,
|
||||
INode,
|
||||
ISupplyDataFunctions,
|
||||
NodeEgressFilter,
|
||||
} from 'n8n-workflow';
|
||||
import { assertCredentialAllowsUrl, assertUrlAllowed, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
|
|
@ -137,6 +138,7 @@ export async function connectMcpClient({
|
|||
onUnauthorized,
|
||||
signal,
|
||||
allowedDomains,
|
||||
secureEgressFilter,
|
||||
}: {
|
||||
serverTransport: McpServerTransport;
|
||||
endpointUrl: string;
|
||||
|
|
@ -150,6 +152,12 @@ export async function connectMcpClient({
|
|||
* (including redirect hops) is validated against it via `assertUrlAllowed`.
|
||||
*/
|
||||
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>> {
|
||||
const endpoint = normalizeAndValidateUrl(endpointUrl);
|
||||
|
||||
|
|
@ -157,7 +165,7 @@ export async function connectMcpClient({
|
|||
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: {} });
|
||||
|
||||
let onAbort: (() => void) | undefined;
|
||||
|
|
@ -276,23 +284,30 @@ function headersToRecord(headers: HeadersInit | undefined): Record<string, strin
|
|||
* - injects auth headers into every request,
|
||||
* - retries once on 401 after refreshing the token via onUnauthorized,
|
||||
* - 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(
|
||||
initialHeaders: Record<string, string> | undefined,
|
||||
onUnauthorized?: OnUnauthorizedHandler,
|
||||
allowedDomains?: string,
|
||||
secureEgressFilter?: NodeEgressFilter,
|
||||
): typeof fetch {
|
||||
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 response = await proxyFetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToRecord(init?.headers),
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
const response = await doFetch(input, init);
|
||||
|
||||
if (response.status !== 401 || !onUnauthorized) {
|
||||
return response;
|
||||
|
|
@ -304,13 +319,7 @@ function createAuthFetch(
|
|||
}
|
||||
|
||||
headers = refreshedHeaders;
|
||||
return await proxyFetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToRecord(init?.headers),
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
return await doFetch(input, init);
|
||||
};
|
||||
|
||||
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.
|
||||
const startUrl = input instanceof Request ? input.url : input;
|
||||
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,
|
||||
headers,
|
||||
allowedDomains,
|
||||
secureEgressFilter: ctx.helpers.getSecureEgressFilter?.(),
|
||||
name: node.type,
|
||||
version: node.typeVersion,
|
||||
onUnauthorized: async (h) => await tryRefreshOAuth2Token(ctx, config.authentication, h),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
helpers.httpRequest.mockResolvedValue({
|
||||
body: 'Hello\0World',
|
||||
|
|
|
|||
|
|
@ -13,8 +13,14 @@ import type {
|
|||
ExecutionError,
|
||||
NodeApiError,
|
||||
ISupplyDataFunctions,
|
||||
ICredentialDataDecryptedObject,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
assertCredentialAllowsUrl,
|
||||
NodeConnectionTypes,
|
||||
NodeOperationError,
|
||||
jsonParse,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError, jsonParse } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type {
|
||||
|
|
@ -27,6 +33,24 @@ import type {
|
|||
} from './interfaces';
|
||||
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 genericType = ctx.getNodeParameter('genericAuthType', itemIndex) as string;
|
||||
|
||||
|
|
@ -35,6 +59,7 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
|
|||
const sendImmediately = genericType === 'httpDigestAuth' ? false : undefined;
|
||||
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
assertCredentialUrlAllowed(ctx, basicAuth, options);
|
||||
options.auth = {
|
||||
username: basicAuth.user 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);
|
||||
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
assertCredentialUrlAllowed(ctx, headerAuth, options);
|
||||
if (!options.headers) options.headers = {};
|
||||
options.headers[headerAuth.name as string] = headerAuth.value;
|
||||
return await ctx.helpers.httpRequest(options);
|
||||
|
|
@ -58,6 +84,7 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
|
|||
const queryAuth = await ctx.getCredentials('httpQueryAuth', itemIndex);
|
||||
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
assertCredentialUrlAllowed(ctx, queryAuth, options);
|
||||
if (!options.qs) options.qs = {};
|
||||
options.qs[queryAuth.name as string] = queryAuth.value;
|
||||
return await ctx.helpers.httpRequest(options);
|
||||
|
|
@ -68,6 +95,7 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
|
|||
const customAuth = await ctx.getCredentials('httpCustomAuth', itemIndex);
|
||||
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
assertCredentialUrlAllowed(ctx, customAuth, options);
|
||||
const auth = jsonParse<IRequestOptionsSimplified>((customAuth.json as string) || '{}', {
|
||||
errorMessage: 'Invalid Custom Auth JSON',
|
||||
});
|
||||
|
|
@ -85,13 +113,17 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
|
|||
}
|
||||
|
||||
if (genericType === 'oAuth1Api') {
|
||||
const oAuth1 = await ctx.getCredentials('oAuth1Api', itemIndex);
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
assertCredentialUrlAllowed(ctx, oAuth1, options);
|
||||
return await ctx.helpers.requestOAuth1.call(ctx, 'oAuth1Api', options);
|
||||
};
|
||||
}
|
||||
|
||||
if (genericType === 'oAuth2Api') {
|
||||
const oAuth2 = await ctx.getCredentials('oAuth2Api', itemIndex);
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
assertCredentialUrlAllowed(ctx, oAuth2, options);
|
||||
return await ctx.helpers.requestOAuth2.call(ctx, 'oAuth2Api', options, {
|
||||
tokenType: 'Bearer',
|
||||
});
|
||||
|
|
@ -106,8 +138,10 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
|
|||
const predefinedCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: number) => {
|
||||
const predefinedType = ctx.getNodeParameter('nodeCredentialType', itemIndex) as string;
|
||||
const additionalOptions = getOAuth2AdditionalParameters(predefinedType);
|
||||
const credentials = await ctx.getCredentials(predefinedType, itemIndex);
|
||||
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
assertCredentialUrlAllowed(ctx, credentials, options);
|
||||
return await ctx.helpers.httpRequestWithAuthentication.call(
|
||||
ctx,
|
||||
predefinedType,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "2.32.0",
|
||||
"version": "2.32.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/scan-community-package",
|
||||
"version": "0.29.0",
|
||||
"version": "0.29.1",
|
||||
"description": "Static code analyser for n8n community packages",
|
||||
"license": "LicenseRef-n8n-sustainable-use",
|
||||
"bin": "scanner/cli.mjs",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/scheduler",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/task-runner",
|
||||
"version": "2.32.0",
|
||||
"version": "2.32.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"start": "node dist/start.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import type * as nodeCrypto from 'node:crypto';
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
it('should wrap DisallowedModuleError in ExecutionError', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ export class JsTaskRunner extends TaskRunner {
|
|||
this.requireResolver = createRequireResolver({
|
||||
allowedBuiltInModules,
|
||||
allowedExternalModules,
|
||||
secureModules: this.mode === 'secure',
|
||||
});
|
||||
|
||||
if (this.mode === 'secure') this.preventPrototypePollution(allowedExternalModules);
|
||||
|
|
|
|||
|
|
@ -15,13 +15,105 @@ export type RequireResolverOpts = {
|
|||
* execution sandbox. `"*"` means all are allowed.
|
||||
*/
|
||||
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;
|
||||
|
||||
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({
|
||||
allowedBuiltInModules,
|
||||
allowedExternalModules,
|
||||
secureModules = false,
|
||||
}: RequireResolverOpts) {
|
||||
return (request: string) => {
|
||||
const checkIsAllowed = (allowList: Set<string> | '*', moduleName: string) => {
|
||||
|
|
@ -37,6 +129,9 @@ export function createRequireResolver({
|
|||
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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/tournament",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"description": "Output compatible rewrite of riot tmpl",
|
||||
"main": "dist/index.js",
|
||||
"module": "src/index.ts",
|
||||
|
|
|
|||
|
|
@ -109,6 +109,15 @@ const customPatches: Partial<Record<ParentKind['type'], CustomPatcher>> = {
|
|||
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 = (
|
||||
|
|
@ -134,6 +143,7 @@ export const jsVariablePolyfill = (
|
|||
case 'MemberExpression':
|
||||
case 'OptionalMemberExpression':
|
||||
case 'VariableDeclarator':
|
||||
case 'ArrowFunctionExpression':
|
||||
if (!customPatches[parent.type]) {
|
||||
throw new Error(`Couldn't find custom patcher for parent type: ${parent.type}`);
|
||||
}
|
||||
|
|
@ -174,7 +184,6 @@ export const jsVariablePolyfill = (
|
|||
// Do nothing
|
||||
case 'Super':
|
||||
case 'Identifier':
|
||||
case 'ArrowFunctionExpression':
|
||||
case 'FunctionDeclaration':
|
||||
case 'FunctionExpression':
|
||||
case 'ThisExpression':
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/workflow-sdk",
|
||||
"version": "0.25.0",
|
||||
"version": "0.25.1",
|
||||
"description": "TypeScript SDK for programmatically creating n8n workflows",
|
||||
"exports": {
|
||||
".": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "2.32.0",
|
||||
"version": "2.32.5",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
mockInstance(Telemetry);
|
||||
const workflowRepository = mockInstance(WorkflowRepository);
|
||||
const activeExecutions = mockInstance(ActiveExecutions);
|
||||
mockInstance(CredentialsPermissionChecker);
|
||||
const credentialsPermissionChecker = mockInstance(CredentialsPermissionChecker);
|
||||
mockInstance(SubworkflowPolicyChecker);
|
||||
mockInstance(WorkflowStatisticsService);
|
||||
mockInstance(WorkflowPublishHistoryRepository);
|
||||
|
|
@ -273,6 +273,100 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
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', () => {
|
||||
const getMockRunWithCredentialFlags = (task: Partial<ITaskData>, executedByUserId?: string) =>
|
||||
mock<IRun>({
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import {
|
|||
type SharedCredentialsRepository,
|
||||
type CredentialsRepository,
|
||||
type CredentialsEntity,
|
||||
type UserRepository,
|
||||
GLOBAL_OWNER_ROLE,
|
||||
GLOBAL_MEMBER_ROLE,
|
||||
} from '@n8n/db';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import type { OwnershipService } from '@/services/ownership.service';
|
||||
import type { ProjectService } from '@/services/project.service.ee';
|
||||
|
|
@ -21,12 +24,16 @@ describe('CredentialsPermissionChecker', () => {
|
|||
const ownershipService = mock<OwnershipService>();
|
||||
const projectService = mock<ProjectService>();
|
||||
const nodeTypes = mock<NodeTypes>();
|
||||
const userRepository = mock<UserRepository>();
|
||||
const credentialsFinderService = mock<CredentialsFinderService>();
|
||||
const permissionChecker = new CredentialsPermissionChecker(
|
||||
sharedCredentialsRepository,
|
||||
credentialsRepository,
|
||||
ownershipService,
|
||||
projectService,
|
||||
nodeTypes,
|
||||
userRepository,
|
||||
credentialsFinderService,
|
||||
);
|
||||
|
||||
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 () => {
|
||||
nodeTypes.getByNameAndVersion.mockImplementation(() => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
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 { hasGlobalScope } from '@n8n/permissions';
|
||||
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 { OwnershipService } from '@/services/ownership.service';
|
||||
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 {
|
||||
override description =
|
||||
this.project.type === 'personal'
|
||||
|
|
@ -39,8 +49,48 @@ export class CredentialsPermissionChecker {
|
|||
private readonly ownershipService: OwnershipService,
|
||||
private readonly projectService: ProjectService,
|
||||
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.
|
||||
*/
|
||||
|
|
@ -141,6 +191,10 @@ export class CredentialsPermissionChecker {
|
|||
// the active credential is specified by the nodeCredentialType parameter
|
||||
const { nodeCredentialType } = node.parameters;
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +203,7 @@ export class CredentialsPermissionChecker {
|
|||
// specified by the genericAuthType parameter
|
||||
const { genericAuthType } = node.parameters;
|
||||
if (typeof genericAuthType === 'string' && genericAuthType) {
|
||||
if (isExpression(genericAuthType)) return null;
|
||||
activeTypes.add(genericAuthType);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1819,11 +1819,20 @@ describe('AgentJsonConfigSchema', () => {
|
|||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects names with invalid characters', () => {
|
||||
it('preserves human-readable MCP server names', () => {
|
||||
const parsed = AgentJsonConfigSchema.parse({
|
||||
...base,
|
||||
mcpServers: [{ name: 'Linear production (EU)', url: 'https://a.example.test/mcp' }],
|
||||
});
|
||||
|
||||
expect(parsed.mcpServers?.[0].name).toBe('Linear production (EU)');
|
||||
});
|
||||
|
||||
it('rejects whitespace-only MCP server names', () => {
|
||||
expect(() =>
|
||||
AgentJsonConfigSchema.parse({
|
||||
...base,
|
||||
mcpServers: [{ name: 'has spaces', url: 'https://a.example.test/mcp' }],
|
||||
mcpServers: [{ name: ' ', url: 'https://a.example.test/mcp' }],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,17 @@ describe('buildVerifyMcpServerTool', () => {
|
|||
buildMcpClientForServerMock.mockReset();
|
||||
});
|
||||
|
||||
it('accepts a human-readable MCP server name', () => {
|
||||
const tool = buildVerifyMcpServerTool(makeDeps());
|
||||
const result = (
|
||||
tool.inputSchema as unknown as {
|
||||
safeParse: (input: unknown) => { success: boolean };
|
||||
}
|
||||
).safeParse({ name: 'Linear production (EU)', url: 'https://example.test/mcp' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns { ok: true, tools } with name and description on success', async () => {
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: vi.fn().mockResolvedValue([
|
||||
|
|
@ -72,6 +83,30 @@ describe('buildVerifyMcpServerTool', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns original unprefixed MCP tool names', async () => {
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'Linear_Prod_list_issues',
|
||||
mcpToolName: 'list_issues',
|
||||
description: 'List issues',
|
||||
},
|
||||
]),
|
||||
});
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(makeDeps());
|
||||
const result = await tool.handler!(
|
||||
{ name: 'Linear Prod', url: 'https://example.test/mcp' },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
tools: [{ name: 'list_issues', description: 'List issues' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses an empty string when a tool has no description', async () => {
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: vi.fn().mockResolvedValue([{ name: 'silent-tool', description: undefined }]),
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import { getBuilderRuntimeSkills } from '../index';
|
||||
|
||||
describe('getBuilderRuntimeSkills', () => {
|
||||
it('includes the integrations skill', () => {
|
||||
const skills = getBuilderRuntimeSkills();
|
||||
|
||||
expect(skills.some((skill) => skill.id === 'agent-builder-integrations')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -71,8 +71,8 @@ const verifyMcpServerInputSchema = z.object({
|
|||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/)
|
||||
.describe('The server name (used as the tool-name prefix)'),
|
||||
.refine((name) => name.trim().length > 0, 'MCP server name cannot be blank')
|
||||
.describe('The user-facing server name; it is normalized for model-facing tool names'),
|
||||
url: z.string().min(1).describe('The MCP server endpoint URL'),
|
||||
transport: z
|
||||
.enum(['sse', 'streamableHttp'])
|
||||
|
|
@ -108,6 +108,7 @@ export function buildVerifyMcpServerTool(deps: VerifyMcpServerDeps): BuiltTool {
|
|||
'Establishes a temporary connection, lists the available tools, then closes the connection. ' +
|
||||
'Returns { ok: true, tools: [{ name, description }] } on success, or ' +
|
||||
'{ ok: false, error: string } on failure. ' +
|
||||
'Tool names are the original MCP names without the model-facing server prefix. ' +
|
||||
'Call this after ask_credential (when authentication is not "none") and before patch_config.',
|
||||
)
|
||||
.input(verifyMcpServerInputSchema)
|
||||
|
|
@ -130,7 +131,7 @@ export function buildVerifyMcpServerTool(deps: VerifyMcpServerDeps): BuiltTool {
|
|||
return {
|
||||
ok: true,
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
name: t.mcpToolName ?? t.name,
|
||||
description: t.description ?? '',
|
||||
})),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -211,9 +211,10 @@ describe('buildMcpClientForServer — SDK config mapping', () => {
|
|||
proxyFetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('forwards toolFilter, approval, transport, and connectionTimeoutMs to the SDK config', async () => {
|
||||
it('forwards tool and lifecycle options to the SDK config', async () => {
|
||||
const credentialProvider = mock<CredentialProvider>();
|
||||
const oauthService = mock<OauthService>();
|
||||
const onToolCallSettled = vi.fn();
|
||||
|
||||
await buildMcpClientForServer(
|
||||
makeServer({
|
||||
|
|
@ -222,7 +223,13 @@ describe('buildMcpClientForServer — SDK config mapping', () => {
|
|||
approval: { mode: 'selected', tools: ['create'] },
|
||||
connectionTimeoutMs: 5_000,
|
||||
}),
|
||||
{ credentialProvider, oauthService, projectId: 'proj-1', proxyFetch },
|
||||
{
|
||||
credentialProvider,
|
||||
oauthService,
|
||||
projectId: 'proj-1',
|
||||
proxyFetch,
|
||||
onToolCallSettled,
|
||||
},
|
||||
);
|
||||
|
||||
const [configs] = mcpClientCtor.mock.calls[0] as [Array<Record<string, unknown>>];
|
||||
|
|
@ -234,6 +241,7 @@ describe('buildMcpClientForServer — SDK config mapping', () => {
|
|||
toolFilter: { mode: 'allow', tools: ['echo'] },
|
||||
requireApproval: ['create'],
|
||||
connectionTimeoutMs: 5_000,
|
||||
onToolCallSettled,
|
||||
});
|
||||
expect(typeof configs[0].fetch).toBe('function');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ export interface BuildMcpClientDeps {
|
|||
oauthService: OauthService;
|
||||
projectId: string;
|
||||
proxyFetch: CustomFetch;
|
||||
onToolCallSettled?: McpServerConfig['onToolCallSettled'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -171,7 +172,7 @@ export async function buildMcpClientForServer(
|
|||
server: AgentJsonMcpServerConfig,
|
||||
deps: BuildMcpClientDeps,
|
||||
): Promise<McpClient> {
|
||||
const { credentialProvider, oauthService, projectId, proxyFetch } = deps;
|
||||
const { credentialProvider, oauthService, projectId, proxyFetch, onToolCallSettled } = deps;
|
||||
const { McpClient } = await import('@n8n/agents');
|
||||
|
||||
const { headers: initialHeaders, credentialData } = await deriveAuthHeaders(
|
||||
|
|
@ -205,6 +206,7 @@ export async function buildMcpClientForServer(
|
|||
fetch: authFetch,
|
||||
toolFilter: server.toolFilter,
|
||||
requireApproval: mapApprovalToSdk(server.approval),
|
||||
...(onToolCallSettled !== undefined && { onToolCallSettled }),
|
||||
...(server.connectionTimeoutMs !== undefined && {
|
||||
connectionTimeoutMs: server.connectionTimeoutMs,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -3767,3 +3767,36 @@ describe('createContext — builder delegate telemetry', () => {
|
|||
expect(delegate.listAgents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createContext — run model wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createContext — run model wiring', () => {
|
||||
const mockUser = { id: 'user-1', role: { slug: 'global:member' } } as unknown as User;
|
||||
|
||||
// Guards the one link of the INS-948 fix that instance-ai's own unit tests
|
||||
// cannot see: the run's resolved (proxy-aware) model must land on the domain
|
||||
// context, where simulation fixture/classifier LLM calls read it as their
|
||||
// fallback on deployments without env model keys (cloud). Dropping this
|
||||
// wiring regresses silently — every simulated node degrades back to a
|
||||
// single empty pinned item.
|
||||
it('copies the host-resolved modelId onto the context', () => {
|
||||
const service = createAdapterWithGatewayMock(vi.fn());
|
||||
const modelId = {
|
||||
id: 'anthropic/claude-opus-4-8' as const,
|
||||
url: 'https://proxy.example.com/anthropic/v1',
|
||||
apiKey: 'proxy-token',
|
||||
};
|
||||
|
||||
const context = service.createContext(mockUser, { modelId });
|
||||
|
||||
expect(context.modelId).toEqual(modelId);
|
||||
});
|
||||
|
||||
it('leaves modelId undefined when the host does not resolve one', () => {
|
||||
const service = createAdapterWithGatewayMock(vi.fn());
|
||||
|
||||
expect(service.createContext(mockUser).modelId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -660,6 +660,7 @@ type TerminalGuardOrderServiceInternals = {
|
|||
backgroundTasks: { getRunningTasks: Mock };
|
||||
temporaryWorkflowService: { reapForRun: Mock };
|
||||
creditService: { claimRunUsage: Mock };
|
||||
failedInternalFollowUpStreaks: Map<string, number>;
|
||||
schedulePlannedTasks: Mock;
|
||||
drainPendingCheckpointReentries: Mock;
|
||||
taskProjector: { syncFromWorkflowLoop: Mock };
|
||||
|
|
@ -756,6 +757,7 @@ function createTerminalGuardOrderService(): TerminalGuardOrderServiceInternals {
|
|||
service.backgroundTasks = { getRunningTasks: vi.fn(() => []) };
|
||||
service.temporaryWorkflowService = { reapForRun: vi.fn(async () => []) };
|
||||
service.creditService = { claimRunUsage: vi.fn(async () => {}) };
|
||||
service.failedInternalFollowUpStreaks = new Map();
|
||||
service.schedulePlannedTasks = vi.fn(async () => {});
|
||||
service.drainPendingCheckpointReentries = vi.fn(async () => {});
|
||||
service.preserveHitlOnShutdown = new Set();
|
||||
|
|
@ -3910,6 +3912,7 @@ describe('InstanceAiService — clearThreadState agent-builder cleanup', () => {
|
|||
runState: { clearThread: Mock };
|
||||
backgroundTasks: { cancelThread: Mock };
|
||||
schedulerLocks: Map<string, unknown>;
|
||||
failedInternalFollowUpStreaks: Map<string, number>;
|
||||
liveness: { clearThreadState: Mock };
|
||||
domainAccessTrackersByThread: Map<string, unknown>;
|
||||
evalCredentialAllowlists: EvalThreadCredentialAllowlistService;
|
||||
|
|
@ -3938,6 +3941,7 @@ describe('InstanceAiService — clearThreadState agent-builder cleanup', () => {
|
|||
service.runState = { clearThread: vi.fn(() => ({ active: undefined, suspended: undefined })) };
|
||||
service.backgroundTasks = { cancelThread: vi.fn(() => []) };
|
||||
service.schedulerLocks = new Map();
|
||||
service.failedInternalFollowUpStreaks = new Map();
|
||||
service.liveness = { clearThreadState: vi.fn() };
|
||||
service.domainAccessTrackersByThread = new Map();
|
||||
service.evalCredentialAllowlists = new EvalThreadCredentialAllowlistService();
|
||||
|
|
@ -4065,3 +4069,126 @@ describe('createAgentMemoryOptions', () => {
|
|||
expect(service.creditService.claimRunUsage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
type FollowUpStreakServiceInternals = {
|
||||
failedInternalFollowUpStreaks: Map<string, number>;
|
||||
updateInternalFollowUpFailureStreak: (
|
||||
threadId: string,
|
||||
status: 'completed' | 'cancelled' | 'error' | 'suspended' | undefined,
|
||||
isInternalFollowUp: boolean,
|
||||
) => void;
|
||||
startInternalFollowUpRun: (user: User, threadId: string, message: string) => Promise<string>;
|
||||
startExecuteRun: Mock;
|
||||
defaultTimeZone: string;
|
||||
runState: {
|
||||
hasLiveRun: Mock;
|
||||
startRun: Mock;
|
||||
getTimeZone: Mock;
|
||||
};
|
||||
logger: { warn: Mock; debug: Mock; error: Mock };
|
||||
};
|
||||
|
||||
function createFollowUpStreakService(): FollowUpStreakServiceInternals {
|
||||
// Bypass the constructor — we only exercise the follow-up circuit breaker
|
||||
// and the run-start path it gates.
|
||||
const service = Object.create(
|
||||
InstanceAiService.prototype,
|
||||
) as unknown as FollowUpStreakServiceInternals;
|
||||
|
||||
service.failedInternalFollowUpStreaks = new Map();
|
||||
service.startExecuteRun = vi.fn();
|
||||
service.defaultTimeZone = 'UTC';
|
||||
service.runState = {
|
||||
hasLiveRun: vi.fn(() => false),
|
||||
startRun: vi.fn(() => ({ runId: 'follow-up-run', abortController: new AbortController() })),
|
||||
getTimeZone: vi.fn(() => undefined),
|
||||
};
|
||||
service.logger = { warn: vi.fn(), debug: vi.fn(), error: vi.fn() };
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
describe('InstanceAiService — internal follow-up failure streak', () => {
|
||||
describe('updateInternalFollowUpFailureStreak', () => {
|
||||
it('counts consecutive errored internal follow-up runs per thread', () => {
|
||||
const service = createFollowUpStreakService();
|
||||
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'error', true);
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'error', true);
|
||||
|
||||
expect(service.failedInternalFollowUpStreaks.get('thread-a')).toBe(2);
|
||||
expect(service.failedInternalFollowUpStreaks.get('thread-b')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not count errored runs that were not internal follow-ups', () => {
|
||||
const service = createFollowUpStreakService();
|
||||
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'error', false);
|
||||
|
||||
expect(service.failedInternalFollowUpStreaks.get('thread-a')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resets the streak when a run completes or suspends', () => {
|
||||
const service = createFollowUpStreakService();
|
||||
|
||||
service.failedInternalFollowUpStreaks.set('thread-a', 3);
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'completed', false);
|
||||
expect(service.failedInternalFollowUpStreaks.get('thread-a')).toBeUndefined();
|
||||
|
||||
service.failedInternalFollowUpStreaks.set('thread-a', 3);
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'suspended', true);
|
||||
expect(service.failedInternalFollowUpStreaks.get('thread-a')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the streak unchanged on cancelled runs and missing terminal status', () => {
|
||||
const service = createFollowUpStreakService();
|
||||
service.failedInternalFollowUpStreaks.set('thread-a', 2);
|
||||
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'cancelled', true);
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', undefined, true);
|
||||
|
||||
expect(service.failedInternalFollowUpStreaks.get('thread-a')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startInternalFollowUpRun circuit breaker', () => {
|
||||
it('starts the follow-up while the streak is below the cap', async () => {
|
||||
const service = createFollowUpStreakService();
|
||||
service.failedInternalFollowUpStreaks.set('thread-a', 2);
|
||||
|
||||
const runId = await service.startInternalFollowUpRun(fakeUser, 'thread-a', 'verify');
|
||||
|
||||
expect(runId).toBe('follow-up-run');
|
||||
expect(service.startExecuteRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the follow-up once the streak reaches the cap', async () => {
|
||||
const service = createFollowUpStreakService();
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'error', true);
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'error', true);
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'error', true);
|
||||
|
||||
const runId = await service.startInternalFollowUpRun(fakeUser, 'thread-a', 'verify');
|
||||
|
||||
expect(runId).toBe('');
|
||||
expect(service.startExecuteRun).not.toHaveBeenCalled();
|
||||
expect(service.logger.warn).toHaveBeenCalledWith(
|
||||
'Skipping internal follow-up: consecutive follow-up runs keep failing',
|
||||
expect.objectContaining({ threadId: 'thread-a', failedStreak: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows follow-ups again after a healthy run resets the streak', async () => {
|
||||
const service = createFollowUpStreakService();
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'error', true);
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'error', true);
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'error', true);
|
||||
service.updateInternalFollowUpFailureStreak('thread-a', 'completed', false);
|
||||
|
||||
const runId = await service.startInternalFollowUpRun(fakeUser, 'thread-a', 'verify');
|
||||
|
||||
expect(runId).toBe('follow-up-run');
|
||||
expect(service.startExecuteRun).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ describe('InstanceAiService — threadPushRef lifetime', () => {
|
|||
runState: { clearThread: Mock };
|
||||
backgroundTasks: { cancelThread: Mock };
|
||||
schedulerLocks: Map<string, unknown>;
|
||||
failedInternalFollowUpStreaks: Map<string, number>;
|
||||
liveness: { clearThreadState: Mock };
|
||||
domainAccessTrackersByThread: Map<string, unknown>;
|
||||
evalCredentialAllowlists: EvalThreadCredentialAllowlistService;
|
||||
|
|
@ -100,6 +101,7 @@ describe('InstanceAiService — threadPushRef lifetime', () => {
|
|||
};
|
||||
service.backgroundTasks = { cancelThread: vi.fn(() => []) };
|
||||
service.schedulerLocks = new Map();
|
||||
service.failedInternalFollowUpStreaks = new Map();
|
||||
service.liveness = { clearThreadState: vi.fn() };
|
||||
service.domainAccessTrackersByThread = new Map();
|
||||
service.evalCredentialAllowlists = new EvalThreadCredentialAllowlistService();
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import type {
|
|||
EvaluationConfigSummary,
|
||||
UpsertEvaluationConfigInput,
|
||||
InstanceAiBuilderDelegate,
|
||||
ModelConfig,
|
||||
} from '@n8n/instance-ai';
|
||||
import { braveSearch, searxngSearch, type WebSearchResponse } from '@n8n/ai-utilities';
|
||||
import {
|
||||
|
|
@ -296,6 +297,9 @@ export class InstanceAiAdapterService {
|
|||
/** Per-user config-evals gate (via `isConfigEvalsEnabled`). Falsy →
|
||||
* eval-config service/tool not wired. */
|
||||
configEvalsEnabled?: boolean;
|
||||
/** Host-resolved model for the run — fallback for utility LLM calls
|
||||
* (simulation fixtures, destructiveness classification). */
|
||||
modelId?: ModelConfig;
|
||||
},
|
||||
): InstanceAiContext {
|
||||
const {
|
||||
|
|
@ -306,6 +310,7 @@ export class InstanceAiAdapterService {
|
|||
credentialIdAllowlist,
|
||||
agentId,
|
||||
configEvalsEnabled,
|
||||
modelId,
|
||||
} = options ?? {};
|
||||
|
||||
// Record gateway availability once per context. Fire-and-forget: the
|
||||
|
|
@ -317,6 +322,7 @@ export class InstanceAiAdapterService {
|
|||
return {
|
||||
userId: user.id,
|
||||
projectId,
|
||||
modelId,
|
||||
workflowService: this.createWorkflowAdapter(user, threadId, projectId),
|
||||
executionService: this.createExecutionAdapter(user, pushRef, threadId),
|
||||
credentialService: this.createCredentialAdapter(user, projectId, credentialIdAllowlist),
|
||||
|
|
|
|||
|
|
@ -360,6 +360,14 @@ type RunFinishErrorInfo = {
|
|||
|
||||
const MAX_CONCURRENT_BACKGROUND_TASKS_PER_THREAD = 5;
|
||||
|
||||
/**
|
||||
* Circuit breaker for machine-started follow-up runs (verification, synthesize,
|
||||
* replan, …). A follow-up that dies before the agent can settle its trigger
|
||||
* (e.g. sandbox setup fails on an exhausted quota) would otherwise be re-armed
|
||||
* by its own post-run scheduler tick, producing an unbounded error loop.
|
||||
*/
|
||||
const MAX_CONSECUTIVE_FAILED_INTERNAL_FOLLOW_UPS = 3;
|
||||
|
||||
const TITLE_REFINE_HISTORY_LIMIT = 50;
|
||||
|
||||
/** Collapse the frontend's typed confirmation union into the flat payload
|
||||
|
|
@ -454,6 +462,14 @@ export class InstanceAiService {
|
|||
/** Per-thread promise chain that serializes schedulePlannedTasks calls. */
|
||||
private readonly schedulerLocks = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Consecutive machine-started follow-up runs that errored, per thread.
|
||||
* Gates `startInternalFollowUpRun` so a follow-up whose run keeps failing
|
||||
* (its trigger left unsettled) cannot re-arm itself forever; reset by any
|
||||
* run that completes or suspends, i.e. proves the thread is healthy again.
|
||||
*/
|
||||
private readonly failedInternalFollowUpStreaks = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Checkpoint re-entries that could not fire when their parent-tagged child
|
||||
* settled (an orchestrator run was live, or other parent siblings were
|
||||
|
|
@ -1397,6 +1413,7 @@ export class InstanceAiService {
|
|||
});
|
||||
|
||||
this.schedulerLocks.delete(threadId);
|
||||
this.failedInternalFollowUpStreaks.delete(threadId);
|
||||
this.domainAccessTrackersByThread.delete(threadId);
|
||||
this.evalCredentialAllowlists.clearThread(threadId);
|
||||
this.threadPushRef.delete(threadId);
|
||||
|
|
@ -1978,6 +1995,11 @@ export class InstanceAiService {
|
|||
const { searchProxyConfig, tracingProxyConfig, tokenManager, proxyBaseUrl } =
|
||||
proxyRunConfig ?? (await this.createProxyRunConfig(user));
|
||||
|
||||
const modelId =
|
||||
proxyBaseUrl && tokenManager
|
||||
? await this.modelService.resolveProxyModel(user, proxyBaseUrl, tokenManager)
|
||||
: await this.modelService.resolveAgentModelConfig(user);
|
||||
|
||||
const configEvalsEnabled = await this.adapterService.isConfigEvalsEnabled(user);
|
||||
const context = this.adapterService.createContext(user, {
|
||||
searchProxyConfig,
|
||||
|
|
@ -1986,6 +2008,7 @@ export class InstanceAiService {
|
|||
projectId: boundProjectId,
|
||||
credentialIdAllowlist: this.evalCredentialAllowlists.get(threadId),
|
||||
configEvalsEnabled,
|
||||
modelId,
|
||||
});
|
||||
|
||||
// Merge both local gateway and direct browser-use into a single
|
||||
|
|
@ -2080,11 +2103,6 @@ export class InstanceAiService {
|
|||
};
|
||||
}
|
||||
|
||||
const modelId =
|
||||
proxyBaseUrl && tokenManager
|
||||
? await this.modelService.resolveProxyModel(user, proxyBaseUrl, tokenManager)
|
||||
: await this.modelService.resolveAgentModelConfig(user);
|
||||
|
||||
const taskStorage = new ThreadTaskStorage(memory);
|
||||
const iterationLog = this.dbIterationLogStorage;
|
||||
const snapshotStorage = this.dbSnapshotStorage;
|
||||
|
|
@ -2767,6 +2785,29 @@ export class InstanceAiService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed the follow-up circuit breaker from a run's terminal status. Errored
|
||||
* machine-started follow-ups (`isInternalFollowUp`) extend the streak; any
|
||||
* run that completes or suspends proves the thread executes again and
|
||||
* resets it. Cancelled runs carry no signal either way.
|
||||
*/
|
||||
private updateInternalFollowUpFailureStreak(
|
||||
threadId: string,
|
||||
status: MessageTraceFinalization['status'] | undefined,
|
||||
isInternalFollowUp: boolean,
|
||||
): void {
|
||||
if (status === 'completed' || status === 'suspended') {
|
||||
this.failedInternalFollowUpStreaks.delete(threadId);
|
||||
return;
|
||||
}
|
||||
if (status === 'error' && isInternalFollowUp) {
|
||||
this.failedInternalFollowUpStreaks.set(
|
||||
threadId,
|
||||
(this.failedInternalFollowUpStreaks.get(threadId) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async startInternalFollowUpRun(
|
||||
user: User,
|
||||
threadId: string,
|
||||
|
|
@ -2782,6 +2823,16 @@ export class InstanceAiService {
|
|||
return '';
|
||||
}
|
||||
|
||||
const failedStreak = this.failedInternalFollowUpStreaks.get(threadId) ?? 0;
|
||||
if (failedStreak >= MAX_CONSECUTIVE_FAILED_INTERNAL_FOLLOW_UPS) {
|
||||
this.logger.warn('Skipping internal follow-up: consecutive follow-up runs keep failing', {
|
||||
threadId,
|
||||
failedStreak,
|
||||
resumeReason: resumeReasonOverride,
|
||||
});
|
||||
return '';
|
||||
}
|
||||
|
||||
const { runId, abortController } = this.runState.startRun({
|
||||
threadId,
|
||||
user,
|
||||
|
|
@ -3883,6 +3934,13 @@ export class InstanceAiService {
|
|||
this.liveness.consumeRunTimeout(runId);
|
||||
}
|
||||
}
|
||||
// Must precede the reschedule below: the next tick consults the streak
|
||||
// before re-arming another follow-up for the same unsettled trigger.
|
||||
this.updateInternalFollowUpFailureStreak(
|
||||
threadId,
|
||||
messageTraceFinalization?.status,
|
||||
resumeReason !== undefined,
|
||||
);
|
||||
// Post-run planned-task wiring (only when the run is actually ending,
|
||||
// not when it merely suspended for HITL):
|
||||
// 1. Checkpoint deadlock fallback — if this run was a checkpoint
|
||||
|
|
@ -5029,6 +5087,13 @@ export class InstanceAiService {
|
|||
this.liveness.consumeRunTimeout(opts.runId);
|
||||
}
|
||||
}
|
||||
// Resumed runs are user-driven, so they never extend the failure
|
||||
// streak — but a healthy one resets it before the reschedule below.
|
||||
this.updateInternalFollowUpFailureStreak(
|
||||
opts.threadId,
|
||||
messageTraceFinalization?.status,
|
||||
false,
|
||||
);
|
||||
// Post-run planned-task wiring — mirror the executeRun finally.
|
||||
// Resumed ordinary-chat runs also need to drive the scheduler in case
|
||||
// a background task settled while they were active or suspended and
|
||||
|
|
|
|||
|
|
@ -561,6 +561,25 @@ describe('InstanceAiMcpRegistryService', () => {
|
|||
expect(mcpClientCloseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the original MCP name for collision-suffixed tools', async () => {
|
||||
const deps = createService();
|
||||
const { service } = deps;
|
||||
arrangeResolvableConnection(deps);
|
||||
mcpClientListToolsMock.mockResolvedValue([
|
||||
{
|
||||
name: 'mcp_linear_read_file_12345678',
|
||||
description: 'Read a file',
|
||||
mcpTool: true,
|
||||
mcpServerName: 'mcp_linear',
|
||||
mcpToolName: 'read file',
|
||||
},
|
||||
] satisfies BuiltTool[]);
|
||||
|
||||
const result = await service.listConnectionTools(user, 'conn-1');
|
||||
|
||||
expect(result).toEqual([{ name: 'read file', description: 'Read a file' }]);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when the connection does not belong to the user', async () => {
|
||||
const { service, connectionRepository, mcpRegistryService } = createService();
|
||||
connectionRepository.findOneBy.mockResolvedValue(null);
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ function stripMcpServerPrefix(toolName: string, serverName: string): string {
|
|||
|
||||
function toToolResponse(tool: BuiltTool, serverName: string): InstanceAiMcpConnectionToolResponse {
|
||||
const response: InstanceAiMcpConnectionToolResponse = {
|
||||
name: stripMcpServerPrefix(tool.name, serverName),
|
||||
name: tool.mcpToolName ?? stripMcpServerPrefix(tool.name, serverName),
|
||||
};
|
||||
if (tool.description) response.description = tool.description;
|
||||
return response;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user