mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 08:46:56 +02:00
Compare commits
27 Commits
master
...
n8n@2.43.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7c54308e4 | ||
|
|
5863179f7f | ||
|
|
ffbb9ec54f | ||
|
|
ea3ee53361 | ||
|
|
4bc22713a2 | ||
|
|
d811f5e87d | ||
|
|
4a09ac3aa1 | ||
|
|
66cece279d | ||
|
|
4bf0ece7ae | ||
|
|
69d73b9b6a | ||
|
|
dc770620e5 | ||
|
|
c00df8c17f | ||
|
|
5639a819f1 | ||
|
|
ea448c6f61 | ||
|
|
3d47f4bfaa | ||
|
|
3f9a95542b | ||
|
|
17f9c4a715 | ||
|
|
3c4f6e3393 | ||
|
|
1830ccfcda | ||
|
|
6ab34a4a42 | ||
|
|
3ee8d7aeef | ||
|
|
509a3f36a8 | ||
|
|
204e2ddf17 | ||
|
|
b553bca31e | ||
|
|
fda8dac7d8 | ||
|
|
9ea78350fa | ||
|
|
345ae0100c |
70
CHANGELOG.md
70
CHANGELOG.md
|
|
@ -1,3 +1,73 @@
|
|||
## [2.43.7](https://github.com/n8n-io/n8n/compare/n8n@2.43.6...n8n@2.43.7) (2026-10-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **core:** Suspend in-flight production executions at worker shutdown ([#40944](https://github.com/n8n-io/n8n/issues/40944)) ([5863179](https://github.com/n8n-io/n8n/commit/5863179f7f44ba6602e2130a133d26b891c6f6b4))
|
||||
|
||||
|
||||
## [2.43.6](https://github.com/n8n-io/n8n/compare/n8n@2.43.5...n8n@2.43.6) (2026-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Create an XML parser for each webhook request body ([#41113](https://github.com/n8n-io/n8n/issues/41113)) ([ea3ee53](https://github.com/n8n-io/n8n/commit/ea3ee53361503ac55478ac146d8474765ea08574))
|
||||
|
||||
|
||||
## [2.43.5](https://github.com/n8n-io/n8n/compare/n8n@2.43.4...n8n@2.43.5) (2026-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Validate the size argument of the chunk() expression extension ([#40911](https://github.com/n8n-io/n8n/issues/40911)) ([4bf0ece](https://github.com/n8n-io/n8n/commit/4bf0ece7ae665034b3bd6409d0ec0beef4384a57))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Show Gemini models by name and list prioritized models first in model pickers ([#41016](https://github.com/n8n-io/n8n/issues/41016)) ([66cece2](https://github.com/n8n-io/n8n/commit/66cece279da3feaab86d773fa0fcefbd9e8b141d))
|
||||
|
||||
|
||||
## [2.43.4](https://github.com/n8n-io/n8n/compare/n8n@2.43.3...n8n@2.43.4) (2026-10-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Handle each Chat Hub segment once in queue mode ([#40816](https://github.com/n8n-io/n8n/issues/40816)) ([dc77062](https://github.com/n8n-io/n8n/commit/dc770620e5e3830e7e41b5b840028f937dedad8d))
|
||||
* **core:** Resume tool calls for deferred tools that a skill loaded ([#40746](https://github.com/n8n-io/n8n/issues/40746)) ([ea448c6](https://github.com/n8n-io/n8n/commit/ea448c6f61fb7cae06cfbeccbd32713a70d112ca))
|
||||
|
||||
|
||||
## [2.43.3](https://github.com/n8n-io/n8n/compare/n8n@2.43.2...n8n@2.43.3) (2026-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **core:** Block create/update of workflows with deprecated nodes ([#40706](https://github.com/n8n-io/n8n/issues/40706)) ([3c4f6e3](https://github.com/n8n-io/n8n/commit/3c4f6e33934adf29d2c76e7723256fa5b69b9ab9))
|
||||
* **Google Gemini Node:** Show Nano Banana 2.1 and 2 Lite in the image model list ([#40655](https://github.com/n8n-io/n8n/issues/40655)) ([1830ccf](https://github.com/n8n-io/n8n/commit/1830ccfcda55fc948197fbb88ac7438211a39940))
|
||||
|
||||
|
||||
## [2.43.2](https://github.com/n8n-io/n8n/compare/n8n@2.43.1...n8n@2.43.2) (2026-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Fix Kimi K3 and other OpenAI-compatible Assistant model providers on self-hosted Assistant ([#40552](https://github.com/n8n-io/n8n/issues/40552)) ([509a3f3](https://github.com/n8n-io/n8n/commit/509a3f36a8443e544714e3874dc55ba14f2c9463))
|
||||
* **editor:** Show the Agent artifact while the builder creates it ([#40550](https://github.com/n8n-io/n8n/issues/40550)) ([204e2dd](https://github.com/n8n-io/n8n/commit/204e2ddf1745095f940659d827b6f8ca000ead49))
|
||||
|
||||
|
||||
## [2.43.1](https://github.com/n8n-io/n8n/compare/n8n@2.43.0...n8n@2.43.1) (2026-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** Polish n8n Assistant and Execute button UI details ([#40438](https://github.com/n8n-io/n8n/issues/40438)) ([345ae01](https://github.com/n8n-io/n8n/commit/345ae0100c073de418cab4eaf3f48926b414e6e1))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **core:** Keep agent builder system prompt static for prompt caching ([#40441](https://github.com/n8n-io/n8n/issues/40441)) ([9ea7835](https://github.com/n8n-io/n8n/commit/9ea78350fa4a3972cbef036ccfd8355b0e127262))
|
||||
* **core:** Prepare Instance AI skill sandbox in the background ([#40442](https://github.com/n8n-io/n8n/issues/40442)) ([fda8dac](https://github.com/n8n-io/n8n/commit/fda8dac7d8bd7a3fe427285de281911d68f8f63b))
|
||||
|
||||
|
||||
# [2.43.0](https://github.com/n8n-io/n8n/compare/n8n@2.42.0...n8n@2.43.0) (2026-10-06)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.43.0",
|
||||
"version": "2.43.7",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/agents",
|
||||
"version": "0.28.0",
|
||||
"version": "0.28.6",
|
||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
|
|
|
|||
|
|
@ -8753,6 +8753,55 @@ describe('AgentRuntime — mid-run observation', () => {
|
|||
expect(JSON.stringify(capturedCall(2))).not.toContain('Old workflow policy.');
|
||||
});
|
||||
|
||||
it('resumes a deferred tool that a skill dependency loaded', async () => {
|
||||
const source = createRuntimeSkillSource([
|
||||
{
|
||||
id: 'planning',
|
||||
name: 'planning',
|
||||
description: 'Plan multi-step work.',
|
||||
instructions: 'Ask the user to approve the plan.',
|
||||
dependencies: { tools: ['approve'] },
|
||||
},
|
||||
]);
|
||||
const checkpointStore = makeClaimingCheckpointStore();
|
||||
const memory = new InMemoryMemory();
|
||||
const options = {
|
||||
skillSource: source,
|
||||
tools: createRuntimeSkillTools(source),
|
||||
deferredTools: [makeInterruptibleTool()],
|
||||
checkpointStorage: checkpointStore,
|
||||
};
|
||||
const first = buildMidRunRuntime(memory, options);
|
||||
generateText
|
||||
.mockResolvedValueOnce(
|
||||
makeGenerateWithToolCall('load-planning', 'load_skill', { skillId: 'planning' }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeGenerateWithToolCall('confirm', 'approve', { question: 'Run the plan?' }),
|
||||
);
|
||||
const result = await first.generate('Plan it', { persistence: PERSISTENCE });
|
||||
await first.dispose();
|
||||
const suspension = result.pendingSuspend?.[0];
|
||||
if (!suspension) throw new Error('Expected a plan confirmation');
|
||||
|
||||
const resumed = buildMidRunRuntime(memory, options);
|
||||
generateText.mockResolvedValueOnce(makeGenerateSuccess('Plan approved.'));
|
||||
const resumeResult = await resumed.resume(
|
||||
'generate',
|
||||
{ approved: true },
|
||||
{ runId: suspension.runId, toolCallId: suspension.toolCallId },
|
||||
);
|
||||
await resumed.dispose();
|
||||
|
||||
expect(resumeResult.error).toBeUndefined();
|
||||
expect(resumeResult.toolCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ tool: 'approve', output: { approved: true } }),
|
||||
]),
|
||||
);
|
||||
expect(capturedCall(2).tools).toHaveProperty('approve');
|
||||
});
|
||||
|
||||
it('merges system messages after compaction for custom OpenAI-compatible endpoints', async () => {
|
||||
const memory = new InMemoryMemory();
|
||||
const runtime = buildMidRunRuntime(memory, {
|
||||
|
|
|
|||
|
|
@ -509,9 +509,11 @@ export class AgentRuntime {
|
|||
|
||||
const list = await this.restoreCheckpointMessages(state);
|
||||
|
||||
const tool = this.context
|
||||
.getCurrentTools(state.persistence)
|
||||
.find((t) => t.name === toolCall.toolName);
|
||||
// A skill dependency loads a deferred tool without a `load_tool` record, and
|
||||
// skills restore only when the loop starts. Accept any registered deferred tool.
|
||||
const tool =
|
||||
this.context.getCurrentTools(state.persistence).find((t) => t.name === toolCall.toolName) ??
|
||||
this.deferredToolManager?.getTool(toolCall.toolName);
|
||||
if (!tool) throw new Error(`Tool ${toolCall.toolName} not found`);
|
||||
|
||||
const resumeSchema = toolCall.suspended ? toolCall.resumeSchema : tool.resumeSchema;
|
||||
|
|
|
|||
|
|
@ -136,6 +136,11 @@ export class DeferredToolManager {
|
|||
return [this.searchTool, this.loadTool];
|
||||
}
|
||||
|
||||
/** A registered deferred tool, loaded or not. */
|
||||
getTool(name: string): BuiltTool | undefined {
|
||||
return this.toolsByName.get(name);
|
||||
}
|
||||
|
||||
getLoadedTools(): BuiltTool[] {
|
||||
return Array.from(this.loadedToolNames)
|
||||
.map((name) => this.toolsByName.get(name))
|
||||
|
|
|
|||
|
|
@ -1099,7 +1099,6 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
|
||||
let instructions = this.instructionsText;
|
||||
if (this.skillSource) {
|
||||
await this.skillSource.prepare?.();
|
||||
instructions = appendSkillCatalogToInstructions(instructions, this.skillSource.registry);
|
||||
}
|
||||
if (this.workspaceInstance) {
|
||||
|
|
|
|||
|
|
@ -490,25 +490,16 @@ Use the workflow SDK.`,
|
|||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prepares the runtime skill source before injecting the agent skill catalog', async () => {
|
||||
it('injects the agent skill catalog without preparing the runtime skill source', async () => {
|
||||
const source = createRuntimeSkillSource([
|
||||
{
|
||||
id: 'summarize_notes',
|
||||
name: 'Summarize notes',
|
||||
description: 'Use for meeting notes.',
|
||||
instructions: 'Extract decisions.',
|
||||
instructions: 'Full private skill body: Extract decisions.',
|
||||
},
|
||||
]);
|
||||
const prepare = vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
source.registry = {
|
||||
...source.registry,
|
||||
skills: source.registry.skills.map((skill) => ({
|
||||
...skill,
|
||||
description: 'Use for materialized meeting notes.',
|
||||
})),
|
||||
};
|
||||
});
|
||||
const prepare = vi.fn(async () => {});
|
||||
source.prepare = prepare;
|
||||
|
||||
const agent = new Agent('assistant')
|
||||
|
|
@ -521,11 +512,10 @@ Use the workflow SDK.`,
|
|||
const { instructions } = runtimeConfig;
|
||||
expect(runtimeConfig.skillSource).toBe(source);
|
||||
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
expect(prepare).not.toHaveBeenCalled();
|
||||
expect(instructions).toContain('name: "Summarize notes"');
|
||||
expect(instructions).toContain('id: "summarize_notes"');
|
||||
expect(instructions).toContain('description: "Use for materialized meeting notes."');
|
||||
expect(instructions).not.toContain('description: "Use for meeting notes."');
|
||||
expect(instructions).toContain('description: "Use for meeting notes."');
|
||||
expect(instructions).not.toContain('Full private skill body');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -162,6 +162,12 @@ export type RuntimeSkillFileLoader = (
|
|||
|
||||
export interface RuntimeSkillSource {
|
||||
registry: RuntimeSkillRegistry;
|
||||
/**
|
||||
* Lazy setup that runs before a skill is loaded, not when the agent is built.
|
||||
* It must not change catalog fields (id, name, description, category,
|
||||
* recommendedTools), because the catalog is rendered before it runs.
|
||||
* Only load_skill calls it, so loadSkill and loadFile must not depend on it.
|
||||
*/
|
||||
prepare?: () => Promise<void>;
|
||||
loadSkill: RuntimeSkillLoader;
|
||||
loadFile?: RuntimeSkillFileLoader;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-node-sdk",
|
||||
"version": "0.33.0",
|
||||
"version": "0.33.5",
|
||||
"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.36.0",
|
||||
"version": "0.36.5",
|
||||
"description": "Utilities for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -153,6 +153,88 @@ describe('model-discovery', () => {
|
|||
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("labels models with the API's displayName, else the id", async () => {
|
||||
const fetch = mockFetch({
|
||||
models: [
|
||||
{
|
||||
name: 'models/gemini-2.5-pro',
|
||||
displayName: 'Gemini 2.5 Pro',
|
||||
supportedGenerationMethods: ['generateContent'],
|
||||
},
|
||||
{
|
||||
name: 'models/gemini-2.5-flash',
|
||||
displayName: ' ',
|
||||
supportedGenerationMethods: ['generateContent'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await listModelsForProvider('google', { apiKey: 'g-key', fetch });
|
||||
|
||||
expect(models).toEqual([
|
||||
{ id: 'models/gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
|
||||
{ id: 'models/gemini-2.5-flash', name: 'models/gemini-2.5-flash' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists models with a priority first and passes the priority on', async () => {
|
||||
const fetch = mockFetch({
|
||||
models: [
|
||||
{ name: 'models/gemini-2.5-pro', supportedGenerationMethods: ['generateContent'] },
|
||||
{
|
||||
name: 'models/gemini-featured',
|
||||
priority: 10,
|
||||
supportedGenerationMethods: ['generateContent'],
|
||||
},
|
||||
{ name: 'models/gemini-2.5-flash', supportedGenerationMethods: ['generateContent'] },
|
||||
],
|
||||
});
|
||||
|
||||
const models = await listModelsForProvider('google', { apiKey: 'g-key', fetch });
|
||||
|
||||
expect(models).toEqual([
|
||||
{ id: 'models/gemini-featured', name: 'models/gemini-featured', priority: 10 },
|
||||
{ id: 'models/gemini-2.5-flash', name: 'models/gemini-2.5-flash' },
|
||||
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('google empty response', () => {
|
||||
it('returns no models when the response has no models list', async () => {
|
||||
const fetch = mockFetch({});
|
||||
|
||||
await expect(listModelsForProvider('google', { apiKey: 'g-key', fetch })).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('google priority', () => {
|
||||
it('drops a priority that is not a finite number', async () => {
|
||||
const fetch = mockFetch({
|
||||
models: [
|
||||
{
|
||||
name: 'models/gemini-2.5-pro',
|
||||
priority: Number.POSITIVE_INFINITY,
|
||||
supportedGenerationMethods: ['generateContent'],
|
||||
},
|
||||
{
|
||||
name: 'models/gemini-2.5-flash',
|
||||
priority: '5',
|
||||
supportedGenerationMethods: ['generateContent'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await listModelsForProvider('google', { apiKey: 'g-key', fetch });
|
||||
|
||||
expect(models).toEqual([
|
||||
{ id: 'models/gemini-2.5-flash', name: 'models/gemini-2.5-flash' },
|
||||
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groq', () => {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,14 @@ export {
|
|||
} from './providers/azure';
|
||||
export { listCohereModels } from './providers/cohere';
|
||||
export { listDeepSeekModels } from './providers/deepseek';
|
||||
export { listGoogleModels, shouldIncludeGoogleModel } from './providers/google';
|
||||
export {
|
||||
getGoogleModelLabel,
|
||||
listGoogleModels,
|
||||
parseGoogleModels,
|
||||
shouldIncludeGoogleModel,
|
||||
type GoogleModel,
|
||||
} from './providers/google';
|
||||
export { pinPrioritizedModels } from '@n8n/api-types';
|
||||
export { listGroqModels } from './providers/groq';
|
||||
export { listMiniMaxModels } from './providers/minimax';
|
||||
export { listMistralModels } from './providers/mistral';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { pinPrioritizedModels } from '@n8n/api-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { baseUrl, byName, getJson } from '../request';
|
||||
import type { ListModelsFn } from '../types';
|
||||
|
||||
|
|
@ -33,22 +36,48 @@ export function shouldIncludeGoogleModel(model: {
|
|||
*/
|
||||
export const listGoogleModels: ListModelsFn = async (options) => {
|
||||
const base = baseUrl(options, 'https://generativelanguage.googleapis.com');
|
||||
const data = (await getJson(
|
||||
const data = await getJson(
|
||||
`${base}/v1beta/models`,
|
||||
{ 'x-goog-api-key': options.apiKey },
|
||||
options,
|
||||
'google',
|
||||
)) as { models?: Array<{ name?: unknown; supportedGenerationMethods?: unknown }> };
|
||||
);
|
||||
|
||||
return (data.models ?? [])
|
||||
.filter(
|
||||
(model): model is { name: string; supportedGenerationMethods?: unknown } =>
|
||||
typeof model.name === 'string' &&
|
||||
shouldIncludeGoogleModel({
|
||||
name: model.name,
|
||||
supportedGenerationMethods: model.supportedGenerationMethods,
|
||||
}),
|
||||
)
|
||||
.map((model) => ({ id: model.name, name: model.name }))
|
||||
const models = parseGoogleModels(data)
|
||||
.filter(shouldIncludeGoogleModel)
|
||||
.map((model) => ({
|
||||
id: model.name,
|
||||
name: getGoogleModelLabel(model.name, model.displayName),
|
||||
...(model.priority !== undefined ? { priority: model.priority } : {}),
|
||||
}))
|
||||
.sort(byName);
|
||||
return pinPrioritizedModels(models, (model) => model.priority);
|
||||
};
|
||||
|
||||
/** A malformed optional field is dropped; a model without a name is skipped. */
|
||||
const googleModelSchema = z.object({
|
||||
name: z.string(),
|
||||
displayName: z.string().optional().catch(undefined),
|
||||
priority: z.number().finite().optional().catch(undefined),
|
||||
supportedGenerationMethods: z.array(z.string()).optional().catch(undefined),
|
||||
});
|
||||
|
||||
const googleModelsResponseSchema = z.object({
|
||||
models: z.array(z.unknown()).optional().catch(undefined),
|
||||
});
|
||||
|
||||
export type GoogleModel = z.infer<typeof googleModelSchema>;
|
||||
|
||||
/** Models of a Google `GET /v1beta/models` response. */
|
||||
export function parseGoogleModels(data: unknown): GoogleModel[] {
|
||||
const response = googleModelsResponseSchema.safeParse(data);
|
||||
return (response.success ? (response.data.models ?? []) : []).flatMap((item) => {
|
||||
const model = googleModelSchema.safeParse(item);
|
||||
return model.success ? [model.data] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Dropdown label of a Google model: the API's `displayName`, else the id. */
|
||||
export function getGoogleModelLabel(modelId: string, displayName?: string): string {
|
||||
return displayName?.trim() || modelId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ export interface ProviderModel {
|
|||
id: string;
|
||||
/** Human-readable name when the provider supplies one, otherwise the id. */
|
||||
name: string;
|
||||
/** Pickers list models with a priority first, highest first. Only the AI Gateway reports one. */
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface ListModelsOptions {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-workflow-builder",
|
||||
"version": "1.43.0",
|
||||
"version": "1.43.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.43.0",
|
||||
"version": "1.43.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { isChatHubToolTypeAllowed } from '../chat-hub';
|
||||
|
||||
describe('isChatHubToolTypeAllowed', () => {
|
||||
it.each(['@n8n/n8n-nodes-langchain.chat', '@n8n/n8n-nodes-langchain.chatHitlTool'])(
|
||||
'blocks the %s variant of the Chat tool for all users',
|
||||
(type) => {
|
||||
expect(isChatHubToolTypeAllowed(type, { isChatUser: false })).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('blocks the Data Table HITL tool for chat users', () => {
|
||||
expect(isChatHubToolTypeAllowed('n8n-nodes-base.dataTableHitlTool', { isChatUser: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks the Chat tool for all users', () => {
|
||||
expect(
|
||||
isChatHubToolTypeAllowed('@n8n/n8n-nodes-langchain.chatTool', { isChatUser: false }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isChatHubToolTypeAllowed('@n8n/n8n-nodes-langchain.chatTool', { isChatUser: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'@n8n/n8n-nodes-langchain.toolWorkflow',
|
||||
'n8n-nodes-base.dataTableTool',
|
||||
'n8n-nodes-base.executeCommand',
|
||||
'n8n-nodes-base.executeCommandTool',
|
||||
'n8n-nodes-base.code',
|
||||
'@n8n/n8n-nodes-langchain.toolCode',
|
||||
'n8n-nodes-base.git',
|
||||
'n8n-nodes-base.gitTool',
|
||||
'n8n-nodes-base.messageAnAgentTool',
|
||||
'n8n-nodes-base.executeCommandHitlTool',
|
||||
'n8n-nodes-base.codeHitlTool',
|
||||
'n8n-nodes-base.readWriteFileHitlTool',
|
||||
'n8n-nodes-base.executeWorkflowHitlTool',
|
||||
'n8n-nodes-base.n8nHitlTool',
|
||||
])('blocks %s for chat users', (type) => {
|
||||
expect(isChatHubToolTypeAllowed(type, { isChatUser: true })).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['n8n-nodes-base.executeCommandTool', '@n8n/n8n-nodes-langchain.toolCode'])(
|
||||
'allows %s for users who can build workflows',
|
||||
(type) => {
|
||||
expect(isChatHubToolTypeAllowed(type, { isChatUser: false })).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'n8n-nodes-base.slackTool',
|
||||
'n8n-nodes-base.gmailTool',
|
||||
'n8n-nodes-base.httpRequestTool',
|
||||
'@n8n/n8n-nodes-langchain.toolHttpRequest',
|
||||
'n8n-nodes-base.graphqlTool',
|
||||
'n8n-nodes-base.rssFeedReadTool',
|
||||
'@n8n/n8n-nodes-langchain.mcpClientTool',
|
||||
'n8n-nodes-base.sshTool',
|
||||
'n8n-nodes-base.ftpHitlTool',
|
||||
'n8n-nodes-base.jenkinsTool',
|
||||
'n8n-nodes-base.rundeckTool',
|
||||
'n8n-nodes-base.awsLambdaTool',
|
||||
])('allows %s for chat users', (type) => {
|
||||
expect(isChatHubToolTypeAllowed(type, { isChatUser: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
37
packages/@n8n/api-types/src/__tests__/model-priority.test.ts
Normal file
37
packages/@n8n/api-types/src/__tests__/model-priority.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { pinPrioritizedModels } from '../model-priority';
|
||||
|
||||
describe('pinPrioritizedModels', () => {
|
||||
it('moves models with a priority to the front, highest first, and keeps the order of the rest', () => {
|
||||
const models = [
|
||||
{ id: 'b' },
|
||||
{ id: 'low', priority: 1 },
|
||||
{ id: 'a' },
|
||||
{ id: 'high', priority: 10 },
|
||||
];
|
||||
|
||||
expect(pinPrioritizedModels(models, (model) => model.priority).map((m) => m.id)).toEqual([
|
||||
'high',
|
||||
'low',
|
||||
'b',
|
||||
'a',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores priorities that are not finite numbers', () => {
|
||||
const models = [
|
||||
{ id: 'a', priority: '5' },
|
||||
{ id: 'b', priority: Number.NaN },
|
||||
{ id: 'c' },
|
||||
{ id: 'd', priority: Number.POSITIVE_INFINITY },
|
||||
{ id: 'valid', priority: 1 },
|
||||
];
|
||||
|
||||
expect(pinPrioritizedModels(models, (model) => model.priority).map((m) => m.id)).toEqual([
|
||||
'valid',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -68,6 +68,8 @@ export interface AgentCatalogModel {
|
|||
toolCall: boolean;
|
||||
cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
|
||||
limits?: { context?: number; output?: number };
|
||||
/** Pickers list models with a priority first, highest first. Reported by the models API. */
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/** Response of `GET /agents/v2/catalog/models/:provider`. */
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import type { Scope } from '@n8n/permissions';
|
|||
import {
|
||||
CHAT_TOOL_NODE_TYPE,
|
||||
type ChunkType,
|
||||
CODE_NODE_TYPE,
|
||||
CODE_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
DATA_TABLE_TOOL_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_NODE_TYPE,
|
||||
type INode,
|
||||
INodeSchema,
|
||||
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
|
|
@ -709,12 +712,46 @@ export interface ChatHubToolDto {
|
|||
/** Tool types blocked for ALL users in the Chat Hub. */
|
||||
export const ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [CHAT_TOOL_NODE_TYPE];
|
||||
|
||||
/** Additional tool types blocked for chat-only users (global:chatUser). */
|
||||
/**
|
||||
* Additional tool types blocked for chat-only users (global:chatUser).
|
||||
* These tools run code or commands on the n8n instance, or access its files,
|
||||
* workflows, data tables or agents. Tools that act on a remote system through
|
||||
* a credential stay allowed: the credential limits what they can do.
|
||||
* Chat users cannot build workflows, so they cannot add these tools either.
|
||||
* A `*Tool` or `*HitlTool` variant of a listed node type is blocked too.
|
||||
* Any node can have a `*HitlTool` variant, so the list also holds node types
|
||||
* that are not usable as tools.
|
||||
*/
|
||||
export const CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [
|
||||
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
DATA_TABLE_TOOL_NODE_TYPE,
|
||||
CODE_NODE_TYPE,
|
||||
CODE_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_NODE_TYPE,
|
||||
'n8n-nodes-base.executeCommand',
|
||||
'n8n-nodes-base.messageAnAgent',
|
||||
'n8n-nodes-base.git',
|
||||
'n8n-nodes-base.readWriteFile',
|
||||
'n8n-nodes-base.n8n',
|
||||
];
|
||||
|
||||
/** Matches the node type registry, which resolves both suffixes to the base node type. */
|
||||
function withoutToolSuffix(type: string): string {
|
||||
return type.replace(/HitlTool$/, '').replace(/Tool$/, '');
|
||||
}
|
||||
|
||||
/** Compares base node types, so an entry also covers the base, `*Tool` and `*HitlTool` forms. */
|
||||
function isListed(list: string[], type: string): boolean {
|
||||
const baseType = withoutToolSuffix(type);
|
||||
return list.some((entry) => withoutToolSuffix(entry) === baseType);
|
||||
}
|
||||
|
||||
/** Returns whether a user can add or run a tool of this type in the Chat Hub. */
|
||||
export function isChatHubToolTypeAllowed(type: string, user: { isChatUser: boolean }): boolean {
|
||||
if (isListed(ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES, type)) return false;
|
||||
return !(user.isChatUser && isListed(CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES, type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Request schema for creating a chat hub tool
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -239,6 +239,11 @@ export { GetWorkflowQueryDto } from './workflows/get-workflow-query.dto';
|
|||
export { UpdateWorkflowPublicDto } from './workflows/update-workflow-public.dto';
|
||||
export { UpdateWorkflowQueryDto } from './workflows/update-workflow-query.dto';
|
||||
export { ListWorkflowsQueryDto } from './workflows/list-workflows-query.dto';
|
||||
export {
|
||||
ListCallableSubworkflowsQueryDto,
|
||||
type CallableSubworkflow,
|
||||
type CallableSubworkflowsResponse,
|
||||
} from './workflows/list-callable-subworkflows-query.dto';
|
||||
export {
|
||||
WorkflowPublicDto,
|
||||
CreatedWorkflowPublicDto,
|
||||
|
|
|
|||
|
|
@ -212,6 +212,11 @@ describe('CreateWorkflowDto', () => {
|
|||
request: { name: 'a'.repeat(129), nodes: [], connections: {} },
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
...['__proto__', 'constructor', 'prototype'].map((id) => ({
|
||||
name: `reserved id ${id}`,
|
||||
request: { id, name: 'My Workflow', nodes: [], connections: {} },
|
||||
expectedErrorPath: ['id'],
|
||||
})),
|
||||
{
|
||||
name: 'missing nodes',
|
||||
request: { name: 'Test', connections: {} },
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { baseWorkflowShape } from './base-workflow.dto';
|
||||
import { isSafeObjectKey } from '../../schemas/instance-ai.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export const workflowIdSchema = z.string();
|
||||
// Workflow ids are used as object keys, so `__proto__`, `constructor` and `prototype` are not allowed
|
||||
export const workflowIdSchema = z
|
||||
.string()
|
||||
.refine(isSafeObjectKey, { message: 'Invalid workflow id' });
|
||||
|
||||
export class CreateWorkflowDto extends Z.class({
|
||||
// Spread base fields (name, nodes, connections, settings, etc.)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
import { paginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
/**
|
||||
* Query for the list of workflows that a parent workflow can call as
|
||||
* sub-workflows. Only name search and pagination are accepted, so callers
|
||||
* cannot probe workflow contents.
|
||||
*/
|
||||
export class ListCallableSubworkflowsQueryDto extends Z.class({
|
||||
...paginationSchema,
|
||||
query: z.string().trim().max(256).optional(),
|
||||
}) {}
|
||||
|
||||
export type CallableSubworkflow = {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
isArchived: boolean;
|
||||
/** Set only when the workflow belongs to a team project. */
|
||||
projectName?: string;
|
||||
};
|
||||
|
||||
export type CallableSubworkflowsResponse = {
|
||||
count: number;
|
||||
data: CallableSubworkflow[];
|
||||
};
|
||||
|
|
@ -95,6 +95,7 @@ export {
|
|||
ChatHubUpdateToolRequest,
|
||||
ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
||||
CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
||||
isChatHubToolTypeAllowed,
|
||||
chatHubVectorStoreProviderSchema,
|
||||
type ChatHubVectorStoreProvider,
|
||||
VECTOR_STORE_PROVIDER_CREDENTIAL_TYPE_MAP,
|
||||
|
|
@ -926,3 +927,4 @@ export type {
|
|||
export { compareExecutionListItems } from './dto/executions/compare-execution-list-items';
|
||||
|
||||
export type * from './workflow-suggestions';
|
||||
export * from './model-priority';
|
||||
|
|
|
|||
23
packages/@n8n/api-types/src/model-priority.ts
Normal file
23
packages/@n8n/api-types/src/model-priority.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Moves models that have a priority to the front, highest first, and keeps the
|
||||
* order of the rest. A models API reports a priority to promote a model, for
|
||||
* example at its launch.
|
||||
*/
|
||||
export function pinPrioritizedModels<T>(models: T[], getPriority: (model: T) => unknown): T[] {
|
||||
const entries = models.map((model) => {
|
||||
const priority = getPriority(model);
|
||||
return {
|
||||
model,
|
||||
priority: typeof priority === 'number' && Number.isFinite(priority) ? priority : null,
|
||||
};
|
||||
});
|
||||
const prioritized = entries
|
||||
.flatMap((entry) =>
|
||||
entry.priority === null ? [] : [{ model: entry.model, priority: entry.priority }],
|
||||
)
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
return [
|
||||
...prioritized.map((entry) => entry.model),
|
||||
...entries.filter((entry) => entry.priority === null).map((entry) => entry.model),
|
||||
];
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "1.43.0",
|
||||
"version": "1.43.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-network",
|
||||
"version": "1.17.0",
|
||||
"version": "1.17.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import type http from 'node:http';
|
||||
import http from 'node:http';
|
||||
|
||||
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||
import { installProxyConnectionGuard } from '../connection-guards';
|
||||
import { installConnectionGuard, installProxyConnectionGuard } from '../connection-guards';
|
||||
|
||||
type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
|
||||
|
||||
|
|
@ -76,3 +76,78 @@ describe('installProxyConnectionGuard', () => {
|
|||
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('installConnectionGuard', () => {
|
||||
function guarded() {
|
||||
const createConnection = vi.fn().mockReturnValue('SOCKET');
|
||||
const target = { createConnection };
|
||||
return { target, createConnection };
|
||||
}
|
||||
|
||||
it('validates the target host and delegates when the policy allows it', () => {
|
||||
const bridge = makeSsrfBridge();
|
||||
const { target, createConnection } = guarded();
|
||||
installConnectionGuard(target, bridge);
|
||||
|
||||
expect(target.createConnection({ host: 'target.example.com' })).toBe('SOCKET');
|
||||
|
||||
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('target.example.com');
|
||||
expect(createConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a connection to a socket path without validating the unused host', () => {
|
||||
const bridge = makeSsrfBridge();
|
||||
const { target, createConnection } = guarded();
|
||||
installConnectionGuard(target, bridge);
|
||||
|
||||
expect(() =>
|
||||
target.createConnection({ host: 'target.example.com', socketPath: '/var/run/docker.sock' }),
|
||||
).toThrow('Cannot open a connection to a socket path');
|
||||
|
||||
expect(createConnection).not.toHaveBeenCalled();
|
||||
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a socket path through the callback when the caller passes one', () => {
|
||||
const bridge = makeSsrfBridge();
|
||||
const { target, createConnection } = guarded();
|
||||
installConnectionGuard(target, bridge);
|
||||
const onConnect = vi.fn();
|
||||
|
||||
expect(
|
||||
target.createConnection({ socketPath: '/var/run/docker.sock' }, onConnect),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(onConnect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Cannot open a connection to a socket path' }),
|
||||
);
|
||||
expect(createConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('installConnectionGuard on a real http.Agent', () => {
|
||||
async function get(agent: http.Agent): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const req = http.get('http://target.example.com/x', { agent, timeout: 3000 }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += String(chunk)));
|
||||
res.on('end', () => resolve(data));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
['ssrf active', () => makeSsrfBridge()],
|
||||
['ssrf disabled', () => 'disabled' as const],
|
||||
])('refuses a socket path Node puts in the connect options (%s)', async (_label, ssrf) => {
|
||||
const agent = new http.Agent({ socketPath: '/tmp/backend-network.sock' } as http.AgentOptions);
|
||||
installConnectionGuard(agent as unknown as { createConnection: never }, ssrf());
|
||||
|
||||
await expect(get(agent)).rejects.toThrow('Cannot open a connection to a socket path');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { installConnectionGuard, UnknownConnectionHostError } from '../connectio
|
|||
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
|
||||
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
|
||||
import { type LocalServer, startServer } from '../local-server';
|
||||
import { buildNodeAgents, type ProxyUrl } from '../node-agents';
|
||||
import { buildNodeAgents, type NodeAgentOptions, type ProxyUrl } from '../node-agents';
|
||||
|
||||
// HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options`
|
||||
// (unlike http.Agent and HttpProxyAgent which use `options`).
|
||||
|
|
@ -32,6 +32,10 @@ function getAgentOptions(agent: http.Agent | https.Agent): { keepAlive?: boolean
|
|||
return (agent as unknown as { options?: { keepAlive?: boolean } }).options ?? {};
|
||||
}
|
||||
|
||||
function withSocketPath(socketPath: string): NodeAgentOptions {
|
||||
return { socketPath } as unknown as NodeAgentOptions;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildNodeAgents — shared builder (single source of truth for the undici
|
||||
// factory, the axios transport layer, and the global proxy agents)
|
||||
|
|
@ -111,6 +115,29 @@ describe('buildNodeAgents', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('rejects a caller-provided socketPath (not a validated connection target)', () => {
|
||||
const socketPath = '/tmp/backend-network.sock';
|
||||
|
||||
it.each([
|
||||
['ssrf disabled', 'disabled' as const],
|
||||
['ssrf active', makeSsrfBridge()],
|
||||
])('throws when agentOptions.socketPath is set (%s)', (_label, ssrf) => {
|
||||
expect(() => buildNodeAgents(false, ssrf, withSocketPath(socketPath))).toThrow(
|
||||
'`agentOptions.socketPath` is not supported',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['direct', false as const],
|
||||
['env proxy', 'env' as const],
|
||||
['explicit proxy', 'http://proxy.internal:3128' as ProxyUrl],
|
||||
])('throws on the %s path too', (_label, proxy) => {
|
||||
expect(() => buildNodeAgents(proxy, makeSsrfBridge(), withSocketPath(socketPath))).toThrow(
|
||||
'`agentOptions.socketPath` is not supported',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSRF lookup placement', () => {
|
||||
const modes: Array<[string, false | 'env' | ProxyUrl]> = [
|
||||
['proxy: false', false],
|
||||
|
|
|
|||
|
|
@ -14,8 +14,18 @@ export class UnknownConnectionHostError extends UnexpectedError {
|
|||
}
|
||||
}
|
||||
|
||||
export class SocketPathConnectionError extends UnexpectedError {
|
||||
constructor() {
|
||||
super('Cannot open a connection to a socket path');
|
||||
}
|
||||
}
|
||||
|
||||
/** Subset of an agent's connection options we read to find the target host. */
|
||||
type ConnectionOptions = { host?: string | null; hostname?: string | null };
|
||||
type ConnectionOptions = {
|
||||
host?: string | null;
|
||||
hostname?: string | null;
|
||||
socketPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Runtime-only `createConnection` method of Node's http(s) agents.
|
||||
|
|
@ -59,6 +69,16 @@ export function installProxyConnectionGuard(agent: ProxyConnectionAgent, ssrf: S
|
|||
};
|
||||
}
|
||||
|
||||
function connectionError(
|
||||
ssrf: SsrfBridge | 'disabled',
|
||||
options: ConnectionOptions,
|
||||
): Error | undefined {
|
||||
if (options.socketPath) {
|
||||
return new SocketPathConnectionError();
|
||||
}
|
||||
return ssrf === 'disabled' ? undefined : connectionHostError(ssrf, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an agent's `createConnection` to validate the connection target before the socket opens.
|
||||
*
|
||||
|
|
@ -68,11 +88,11 @@ export function installProxyConnectionGuard(agent: ProxyConnectionAgent, ssrf: S
|
|||
*/
|
||||
export function installConnectionGuard(
|
||||
target: { createConnection: CreateConnection },
|
||||
ssrf: SsrfBridge,
|
||||
ssrf: SsrfBridge | 'disabled',
|
||||
): void {
|
||||
const createConnection = target.createConnection.bind(target);
|
||||
target.createConnection = (options, onConnect) => {
|
||||
const error = connectionHostError(ssrf, options);
|
||||
const error = connectionError(ssrf, options);
|
||||
if (error) {
|
||||
if (onConnect) {
|
||||
onConnect(error);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
|
|
@ -81,6 +81,10 @@ export type NodeAgentOptions = https.AgentOptions;
|
|||
* therefore has no effect and is rejected to avoid a false sense of control over
|
||||
* DNS resolution.
|
||||
*
|
||||
* `agentOptions.socketPath` is rejected for a related reason: it replaces the
|
||||
* connection target, leaving the request's host and port unused, so nothing the
|
||||
* policy decides applies to the socket that opens.
|
||||
*
|
||||
* `agentOptions` describes the connection to the **target**. Behind a proxy its TLS
|
||||
* options travel with the tunnelled session rather than the proxy handshake
|
||||
* (see `proxy/proxied-agents.ts`).
|
||||
|
|
@ -91,11 +95,17 @@ export function buildNodeAgents(
|
|||
agentOptions?: NodeAgentOptions,
|
||||
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
|
||||
if (agentOptions?.lookup) {
|
||||
throw new UnexpectedError(
|
||||
throw new UserError(
|
||||
'`agentOptions.lookup` is not supported: DNS resolution is managed by the SSRF policy. Remove it from `agentOptions`.',
|
||||
);
|
||||
}
|
||||
|
||||
if (agentOptions !== undefined && 'socketPath' in agentOptions && agentOptions.socketPath) {
|
||||
throw new UserError(
|
||||
'`agentOptions.socketPath` is not supported. Remove it from `agentOptions`.',
|
||||
);
|
||||
}
|
||||
|
||||
const lookup: LookupFunction | undefined =
|
||||
ssrf !== 'disabled' ? ssrf.createSecureLookup() : undefined;
|
||||
|
||||
|
|
@ -132,15 +142,16 @@ export function buildNodeAgents(
|
|||
}
|
||||
|
||||
/**
|
||||
* Installs {@link installConnectionGuard} on a direct-path agent pair when SSRF protection is active.
|
||||
* Installs {@link installConnectionGuard} on a direct-path agent pair.
|
||||
*
|
||||
* It goes on whatever the SSRF setting, because it also refuses a socket path, which
|
||||
* is not an SSRF decision.
|
||||
*/
|
||||
function applyConnectionGuard(
|
||||
agents: { httpAgent: http.Agent; httpsAgent: https.Agent },
|
||||
ssrf: SsrfOption,
|
||||
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
|
||||
if (ssrf !== 'disabled') {
|
||||
installConnectionGuard(agents.httpAgent, ssrf);
|
||||
installConnectionGuard(agents.httpsAgent, ssrf);
|
||||
}
|
||||
installConnectionGuard(agents.httpAgent, ssrf);
|
||||
installConnectionGuard(agents.httpsAgent, ssrf);
|
||||
return agents;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-services",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -338,6 +338,27 @@ for (const backend of ['memory', 'redis'] as const) {
|
|||
|
||||
await expect(cacheService.getHashValue('key', 'field1')).resolves.toBe('value1');
|
||||
});
|
||||
|
||||
if (backend === 'memory') {
|
||||
test('should not return inherited properties as hash field values', async () => {
|
||||
await cacheService.setHash('key', { field1: { id: 'value1' } });
|
||||
|
||||
await expect(cacheService.getHashValue('key', '__proto__')).resolves.toBeUndefined();
|
||||
await expect(cacheService.getHashValue('key', 'constructor')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('should not store a `__proto__` hash field', async () => {
|
||||
await cacheService.setHash('key', { field1: { id: 'value1' } });
|
||||
// A computed key creates an own `__proto__` field, as a hash keyed by a workflow id does
|
||||
await cacheService.setHash('key', { ['__proto__']: { id: 'value2' } });
|
||||
|
||||
expect(Object.getPrototypeOf(await cacheService.get('key'))).toBe(Object.prototype);
|
||||
await expect(cacheService.getHashValue('key', '__proto__')).resolves.toBeUndefined();
|
||||
await expect(cacheService.getHashValue('key', 'field1')).resolves.toEqual({
|
||||
id: 'value1',
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,7 +151,15 @@ export class CacheService extends TypedEmitter<CacheEvents> {
|
|||
|
||||
const hashObject: Hash = (await this.get(key)) ?? {};
|
||||
|
||||
Object.assign(hashObject, hash);
|
||||
for (const [field, value] of Object.entries(hash)) {
|
||||
// Skip `__proto__`. On a plain object, `hash['__proto__'] = value` does not add a field.
|
||||
// It changes the object's prototype.
|
||||
if (field === '__proto__') {
|
||||
continue;
|
||||
}
|
||||
|
||||
hashObject[field] = value;
|
||||
}
|
||||
|
||||
await this.set(key, hashObject);
|
||||
}
|
||||
|
|
@ -309,7 +317,10 @@ export class CacheService extends TypedEmitter<CacheEvents> {
|
|||
} else {
|
||||
const hashObject = await this.cache.store.get<Hash<T>>(cacheKey);
|
||||
|
||||
hashValue = hashObject?.[hashKey] as MaybeHash<T>;
|
||||
hashValue =
|
||||
hashObject && Object.hasOwn(hashObject, hashKey)
|
||||
? (hashObject[hashKey] as MaybeHash<T>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (hashValue !== undefined) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-test-utils",
|
||||
"version": "1.43.0",
|
||||
"version": "1.43.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/blob-storage",
|
||||
"version": "1.11.0",
|
||||
"version": "1.11.5",
|
||||
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat-hub",
|
||||
"version": "1.36.0",
|
||||
"version": "1.36.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "1.25.0",
|
||||
"version": "1.25.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/computer-use",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"description": "Local AI gateway for n8n Assistant — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
|
||||
"publishConfig": {
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "2.41.0",
|
||||
"version": "2.41.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -49,4 +49,14 @@ export class NodesConfig {
|
|||
/** Memory limit in MB for the Merge node's SQL sandbox. */
|
||||
@Env('NODES_MERGE_SQL_SANDBOX_MEMORY_LIMIT_MB', z.coerce.number().int().positive())
|
||||
mergeSqlSandboxMemoryLimitMb: number = 64;
|
||||
|
||||
/**
|
||||
* Refuse deprecated nodes. Saving or importing a workflow fails if it adds or
|
||||
* changes one. A sub-workflow from inline JSON, a URL or a file, or a single
|
||||
* node run by n8n Assistant, fails if it contains one. Saved workflows that
|
||||
* already contain deprecated nodes keep running. Set to `false` to turn this
|
||||
* off, for example to import older backups.
|
||||
*/
|
||||
@Env('N8N_DEPRECATED_NODES_BLOCK')
|
||||
blockDeprecated: boolean = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,4 +182,14 @@ export class ScalingModeConfig {
|
|||
|
||||
@Nested
|
||||
workerPool: WorkerPoolConfig;
|
||||
|
||||
/**
|
||||
* Whether a worker that receives a shutdown signal suspends in-flight
|
||||
* production executions at the next node boundary. Suspended executions are
|
||||
* persisted as 'waiting' and resumed on another worker, instead of running
|
||||
* to completion (or force-kill) on the exiting worker. An execution that
|
||||
* still owes its webhook caller a response is not suspended.
|
||||
*/
|
||||
@Env('N8N_WORKER_SUSPEND_EXECUTIONS_ON_SHUTDOWN')
|
||||
suspendExecutionsOnShutdown: boolean = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@ describe('GlobalConfig', () => {
|
|||
exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'],
|
||||
pythonEnabled: true,
|
||||
mergeSqlSandboxMemoryLimitMb: 64,
|
||||
blockDeprecated: true,
|
||||
},
|
||||
publicApi: {
|
||||
disabled: false,
|
||||
|
|
@ -432,6 +433,7 @@ describe('GlobalConfig', () => {
|
|||
enabled: false,
|
||||
name: '',
|
||||
},
|
||||
suspendExecutionsOnShutdown: false,
|
||||
},
|
||||
taskRunners: {
|
||||
mode: 'internal',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/constants",
|
||||
"version": "0.41.0",
|
||||
"version": "0.41.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,7 @@
|
|||
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = 'https://api.npms.io/v2/';
|
||||
|
||||
/**
|
||||
* Node-authoring API level supported by the runtime that bundles this copy.
|
||||
*
|
||||
* Compatibility rule for community packages:
|
||||
* `package.n8n.n8nNodesApiVersion <= N8N_NODES_API_VERSION`.
|
||||
*
|
||||
* The level mirrors the n8n major for the v3 transition: `1` on master
|
||||
* (which ships as 2.x during the v3 window) and `3` on the `3.x` branch,
|
||||
* where it also serves as the v3 feature flag for node-authoring APIs.
|
||||
*
|
||||
* Lives here, not in `n8n-workflow`, so tooling that cannot depend on the
|
||||
* runtime packages (e.g. `@n8n/node-cli`) can read the same value.
|
||||
* Node-authoring API level this runtime supports, and so the highest
|
||||
* `n8nNodesApiVersion` a node may declare to be installed.
|
||||
*/
|
||||
export const N8N_NODES_API_VERSION = 1;
|
||||
export const N8N_NODES_API_VERSION = '1.0';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/create-node",
|
||||
"version": "0.51.0",
|
||||
"version": "0.51.5",
|
||||
"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.43.0",
|
||||
"version": "1.43.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
||||
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { GlobalConfig } from '@n8n/config';
|
||||
import { In, IsNull, MoreThan, Not, type SelectQueryBuilder } from '@n8n/typeorm';
|
||||
import type { IWorkflowSettings } from 'n8n-workflow';
|
||||
import type { Mock, Mocked } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
|
|
@ -840,4 +841,270 @@ describe('WorkflowRepository', () => {
|
|||
expect(mockExecute).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('caller allowlist purge', () => {
|
||||
let writes: Array<{ id: string; callerIds: string }>;
|
||||
let writtenSettings: Array<IWorkflowSettings | undefined>;
|
||||
let selectQb: { where: Mock; andWhere: Mock; setLock: Mock };
|
||||
|
||||
const stubCandidates = (candidates: Array<Partial<WorkflowEntity>>) => {
|
||||
writes = [];
|
||||
writtenSettings = [];
|
||||
|
||||
const select = {
|
||||
select: vi.fn(),
|
||||
where: vi.fn(),
|
||||
andWhere: vi.fn(),
|
||||
setLock: vi.fn(),
|
||||
getMany: vi.fn().mockResolvedValue(candidates),
|
||||
};
|
||||
select.select.mockReturnValue(select);
|
||||
select.where.mockReturnValue(select);
|
||||
select.andWhere.mockReturnValue(select);
|
||||
select.setLock.mockReturnValue(select);
|
||||
|
||||
let pendingId = '';
|
||||
let pendingCallerIds = '';
|
||||
let pendingSettings: IWorkflowSettings | undefined;
|
||||
const update = {
|
||||
update: vi.fn(),
|
||||
set: vi.fn(),
|
||||
where: vi.fn(),
|
||||
setParameter: vi.fn(),
|
||||
execute: vi.fn(async () => {
|
||||
writes.push({ id: pendingId, callerIds: pendingCallerIds });
|
||||
writtenSettings.push(pendingSettings);
|
||||
}),
|
||||
};
|
||||
update.update.mockReturnValue(update);
|
||||
// Postgres writes the whole document, SQLite the one key through a parameter.
|
||||
update.set.mockImplementation((values: { settings: unknown }) => {
|
||||
if (typeof values.settings === 'object' && values.settings !== null) {
|
||||
pendingSettings = values.settings as IWorkflowSettings;
|
||||
pendingCallerIds = pendingSettings.callerIds ?? '';
|
||||
}
|
||||
return update;
|
||||
});
|
||||
update.where.mockImplementation((_sql: string, parameters: { workflowId: string }) => {
|
||||
pendingId = parameters.workflowId;
|
||||
return update;
|
||||
});
|
||||
update.setParameter.mockImplementation((_name: string, value: string) => {
|
||||
pendingCallerIds = value;
|
||||
return update;
|
||||
});
|
||||
|
||||
vi.mocked(entityManager.createQueryBuilder).mockImplementation(((...args: unknown[]) =>
|
||||
args.length > 0 ? select : update) as never);
|
||||
|
||||
transactionRunner.run.mockImplementation(
|
||||
async (ctx: unknown, fn: (c: unknown) => Promise<unknown>) => await fn(ctx),
|
||||
);
|
||||
|
||||
selectQb = select;
|
||||
return select;
|
||||
};
|
||||
|
||||
it('removes the entry and keeps the rest in order, whatever whitespace separates them', async () => {
|
||||
stubCandidates([
|
||||
{
|
||||
id: 'subworkflow-1',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,\n second , ,\tthird' },
|
||||
},
|
||||
]);
|
||||
|
||||
await workflowRepository.removeFromCallerAllowlists(['second'], {});
|
||||
|
||||
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'first,third' }]);
|
||||
});
|
||||
|
||||
it('rewrites only the allowlists that hold the id as an entry of its own', async () => {
|
||||
stubCandidates([
|
||||
{
|
||||
id: 'subworkflow-1',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'abc,abcdef' },
|
||||
},
|
||||
{
|
||||
id: 'subworkflow-2',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'abcdef' },
|
||||
},
|
||||
{ id: 'subworkflow-3', settings: { callerPolicy: 'workflowsFromAList' } },
|
||||
]);
|
||||
|
||||
await workflowRepository.removeFromCallerAllowlists(['abc'], {});
|
||||
|
||||
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'abcdef' }]);
|
||||
});
|
||||
|
||||
it('leaves an allowlist alone when the id only appears inside another entry', async () => {
|
||||
stubCandidates([
|
||||
{
|
||||
id: 'subworkflow-1',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'abc-1, abc-2' },
|
||||
},
|
||||
]);
|
||||
|
||||
await workflowRepository.removeFromCallerAllowlists(['abc'], {});
|
||||
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('rewrites every workflow that names the id', async () => {
|
||||
stubCandidates([
|
||||
{
|
||||
id: 'subworkflow-1',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second' },
|
||||
},
|
||||
{
|
||||
id: 'subworkflow-2',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'second,third' },
|
||||
},
|
||||
]);
|
||||
|
||||
await workflowRepository.removeFromCallerAllowlists(['second'], {});
|
||||
|
||||
expect(writes).toEqual([
|
||||
{ id: 'subworkflow-1', callerIds: 'first' },
|
||||
{ id: 'subworkflow-2', callerIds: 'third' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes every id out of one allowlist in a single write', async () => {
|
||||
stubCandidates([
|
||||
{
|
||||
id: 'subworkflow-1',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second,third' },
|
||||
},
|
||||
]);
|
||||
|
||||
await workflowRepository.removeFromCallerAllowlists(['first', 'third'], {});
|
||||
|
||||
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'second' }]);
|
||||
});
|
||||
|
||||
it('reads the candidates once, whatever the batch size, and locks only the ones it rewrites', async () => {
|
||||
const select = stubCandidates([
|
||||
{
|
||||
id: 'subworkflow-1',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'one,first' },
|
||||
},
|
||||
{
|
||||
id: 'subworkflow-2',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first' },
|
||||
},
|
||||
]);
|
||||
|
||||
await workflowRepository.removeFromCallerAllowlists(['one', 'two'], {});
|
||||
|
||||
expect(select.where).toHaveBeenCalledTimes(2);
|
||||
expect(select.where).toHaveBeenNthCalledWith(1, expect.stringContaining('callerIds'));
|
||||
expect(select.where).toHaveBeenNthCalledWith(2, 'workflow.id IN (:...ids)', {
|
||||
ids: ['subworkflow-1'],
|
||||
});
|
||||
expect(select.setLock).toHaveBeenCalledTimes(1);
|
||||
expect(select.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'first' }]);
|
||||
});
|
||||
|
||||
it('locks nothing when no allowlist names a removed id', async () => {
|
||||
const select = stubCandidates([
|
||||
{
|
||||
id: 'subworkflow-1',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first' },
|
||||
},
|
||||
]);
|
||||
|
||||
await workflowRepository.removeFromCallerAllowlists(['one'], {});
|
||||
|
||||
expect(select.setLock).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches candidates on the stored document, never on a value read out of it', async () => {
|
||||
const select = stubCandidates([]);
|
||||
|
||||
await workflowRepository.removeFromCallerAllowlists(['one'], {});
|
||||
|
||||
expect(select.where).toHaveBeenCalledWith('workflow.settings::text LIKE \'%"callerIds"%\'');
|
||||
});
|
||||
|
||||
it('keeps a value the binary JSON form has no room for', async () => {
|
||||
const noBinaryJsonForm = String.fromCharCode(0);
|
||||
stubCandidates([
|
||||
{
|
||||
id: 'subworkflow-1',
|
||||
settings: {
|
||||
callerPolicy: 'workflowsFromAList',
|
||||
callerIds: 'first,second',
|
||||
errorWorkflow: `error-workflow${noBinaryJsonForm}`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await workflowRepository.removeFromCallerAllowlists(['second'], {});
|
||||
|
||||
expect(writtenSettings).toEqual([
|
||||
{
|
||||
callerPolicy: 'workflowsFromAList',
|
||||
callerIds: 'first',
|
||||
errorWorkflow: `error-workflow${noBinaryJsonForm}`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('purges a row that exists at an id being removed', async () => {
|
||||
stubCandidates([
|
||||
{
|
||||
id: 'recreated-1',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second' },
|
||||
},
|
||||
]);
|
||||
|
||||
await workflowRepository.removeFromCallerAllowlists(['recreated-1', 'second'], {});
|
||||
|
||||
expect(writes).toEqual([{ id: 'recreated-1', callerIds: 'first' }]);
|
||||
});
|
||||
|
||||
it('writes nothing when given no ids', async () => {
|
||||
stubCandidates([]);
|
||||
|
||||
await workflowRepository.removeFromCallerAllowlists([], {});
|
||||
|
||||
expect(selectQb.where).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes only the entries naming an id without a row', async () => {
|
||||
stubCandidates([
|
||||
{
|
||||
id: 'subworkflow-1',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'deleted,survivor,other' },
|
||||
},
|
||||
]);
|
||||
vi.mocked(entityManager.find).mockResolvedValue([{ id: 'survivor' }, { id: 'other' }]);
|
||||
|
||||
await workflowRepository.removeMissingFromCallerAllowlists({});
|
||||
|
||||
expect(entityManager.find).toHaveBeenCalledWith(WorkflowEntity, {
|
||||
select: ['id'],
|
||||
where: { id: In(['deleted', 'survivor', 'other']) },
|
||||
});
|
||||
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'survivor,other' }]);
|
||||
});
|
||||
|
||||
it('locks and writes nothing when every named id has a row', async () => {
|
||||
const select = stubCandidates([
|
||||
{
|
||||
id: 'subworkflow-1',
|
||||
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second' },
|
||||
},
|
||||
]);
|
||||
vi.mocked(entityManager.find).mockResolvedValue([{ id: 'first' }, { id: 'second' }]);
|
||||
|
||||
await workflowRepository.removeMissingFromCallerAllowlists({});
|
||||
|
||||
expect(select.setLock).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -449,6 +449,136 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
}
|
||||
}
|
||||
|
||||
/** Remove every id in `workflowIds` from the `callerIds` allowlist of every other workflow. */
|
||||
async removeFromCallerAllowlists(workflowIds: string[], ctx: OperationContext): Promise<void> {
|
||||
if (workflowIds.length === 0) return;
|
||||
|
||||
await this.runInTransaction(ctx, async (tx) => {
|
||||
const candidates = await this.findCallerAllowlistCandidates(tx);
|
||||
await this.purgeCallerAllowlists(candidates, new Set(workflowIds), tx);
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove every id that has no workflow row from every `callerIds` allowlist. */
|
||||
async removeMissingFromCallerAllowlists(ctx: OperationContext): Promise<void> {
|
||||
await this.runInTransaction(ctx, async (tx) => {
|
||||
const candidates = await this.findCallerAllowlistCandidates(tx);
|
||||
const namedIds = [
|
||||
...new Set(candidates.flatMap((workflow) => this.callerIdEntries(workflow))),
|
||||
];
|
||||
|
||||
const existingIds = new Set<string>();
|
||||
for (const chunk of chunkIds(namedIds)) {
|
||||
const existing = await tx.find(WorkflowEntity, {
|
||||
select: ['id'],
|
||||
where: { id: In(chunk) },
|
||||
});
|
||||
for (const { id } of existing) existingIds.add(id);
|
||||
}
|
||||
|
||||
const missingIds = new Set(namedIds.filter((id) => !existingIds.has(id)));
|
||||
if (missingIds.size > 0) await this.purgeCallerAllowlists(candidates, missingIds, tx);
|
||||
});
|
||||
}
|
||||
|
||||
/** Rewrite the allowlists among `candidates` that name any of `removedIds`, under a row lock. */
|
||||
private async purgeCallerAllowlists(
|
||||
candidates: WorkflowEntity[],
|
||||
removedIds: Set<string>,
|
||||
tx: EntityManager,
|
||||
): Promise<void> {
|
||||
const affected = candidates.filter(
|
||||
(workflow) => this.callerIdsWithout(workflow, removedIds) !== undefined,
|
||||
);
|
||||
|
||||
for (const workflow of await this.lockCallerAllowlists(affected, tx)) {
|
||||
const remaining = this.callerIdsWithout(workflow, removedIds);
|
||||
if (remaining !== undefined) await this.writeCallerIds(workflow, remaining, tx);
|
||||
}
|
||||
}
|
||||
|
||||
/** The non-empty, trimmed entries of the `callerIds` allowlist of `workflow`. */
|
||||
private callerIdEntries(workflow: WorkflowEntity): string[] {
|
||||
const callerIds = workflow.settings?.callerIds;
|
||||
if (typeof callerIds !== 'string') return [];
|
||||
|
||||
return callerIds
|
||||
.split(',')
|
||||
.map((callerId) => callerId.trim())
|
||||
.filter((callerId) => callerId !== '');
|
||||
}
|
||||
|
||||
/** The allowlist of `workflow` without `removedIds`, or `undefined` when it names none of them. */
|
||||
private callerIdsWithout(workflow: WorkflowEntity, removedIds: Set<string>): string | undefined {
|
||||
const entries = this.callerIdEntries(workflow);
|
||||
if (!entries.some((callerId) => removedIds.has(callerId))) return undefined;
|
||||
|
||||
return entries.filter((callerId) => !removedIds.has(callerId)).join(',');
|
||||
}
|
||||
|
||||
/** Find every workflow that has a `callerIds` allowlist. */
|
||||
private async findCallerAllowlistCandidates(tx: EntityManager): Promise<WorkflowEntity[]> {
|
||||
// On Postgres, match the raw text rather than extract: the `json` column accepts characters,
|
||||
// such as an escaped NUL, that extracting `callerIds` rejects with `unsupported Unicode
|
||||
// escape sequence`, which fails the query for every row.
|
||||
const hasAllowlist =
|
||||
this.globalConfig.database.type === 'postgresdb'
|
||||
? 'workflow.settings::text LIKE \'%"callerIds"%\''
|
||||
: `${this.settingsTextValue('workflow.settings', 'callerIds')} IS NOT NULL`;
|
||||
|
||||
return await tx
|
||||
.createQueryBuilder(WorkflowEntity, 'workflow')
|
||||
.select(['workflow.id', 'workflow.settings'])
|
||||
.where(hasAllowlist)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Lock `workflows` until the transaction ends and re-read their settings under the lock. */
|
||||
private async lockCallerAllowlists(
|
||||
workflows: WorkflowEntity[],
|
||||
tx: EntityManager,
|
||||
): Promise<WorkflowEntity[]> {
|
||||
// SQLite serialises writes, so what it read is already current.
|
||||
if (this.globalConfig.database.type !== 'postgresdb') return workflows;
|
||||
|
||||
const locked: WorkflowEntity[] = [];
|
||||
for (const chunk of chunkIds(workflows.map(({ id }) => id))) {
|
||||
locked.push(
|
||||
...(await tx
|
||||
.createQueryBuilder(WorkflowEntity, 'workflow')
|
||||
.select(['workflow.id', 'workflow.settings'])
|
||||
.where('workflow.id IN (:...ids)', { ids: chunk })
|
||||
.setLock('pessimistic_write')
|
||||
.getMany()),
|
||||
);
|
||||
}
|
||||
return locked;
|
||||
}
|
||||
|
||||
/** Store `callerIds` back on one workflow, keeping its other settings. */
|
||||
private async writeCallerIds(
|
||||
workflow: WorkflowEntity,
|
||||
callerIds: string,
|
||||
tx: EntityManager,
|
||||
): Promise<void> {
|
||||
const update = tx
|
||||
.createQueryBuilder()
|
||||
.update(WorkflowEntity)
|
||||
.where('id = :workflowId', { workflowId: workflow.id });
|
||||
|
||||
if (this.globalConfig.database.type === 'postgresdb') {
|
||||
// `jsonb_set` rejects characters the `json` column accepts, so rewrite the whole document
|
||||
// read under the row lock.
|
||||
await update.set({ settings: { ...workflow.settings, callerIds } }).execute();
|
||||
return;
|
||||
}
|
||||
|
||||
await update
|
||||
.set({ settings: () => "JSON_SET(COALESCE(settings, '{}'), '$.callerIds', :callerIds)" })
|
||||
.setParameter('callerIds', callerIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findById(workflowId: string) {
|
||||
return await this.findOne({
|
||||
where: { id: workflowId },
|
||||
|
|
@ -849,7 +979,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: WorkflowListQueryOptions = {},
|
||||
callableForParentWorkflowId?: string,
|
||||
) {
|
||||
if (
|
||||
options.filter?.parentFolderId &&
|
||||
|
|
@ -869,18 +998,8 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
}
|
||||
|
||||
const [workflowsAndFolders, count] = await Promise.all([
|
||||
this.getWorkflowsAndFoldersUnionWithSharingSubquery(
|
||||
user,
|
||||
sharingOptions,
|
||||
options,
|
||||
callableForParentWorkflowId,
|
||||
),
|
||||
this.getWorkflowsAndFoldersCountWithSharingSubquery(
|
||||
user,
|
||||
sharingOptions,
|
||||
options,
|
||||
callableForParentWorkflowId,
|
||||
),
|
||||
this.getWorkflowsAndFoldersUnionWithSharingSubquery(user, sharingOptions, options),
|
||||
this.getWorkflowsAndFoldersCountWithSharingSubquery(user, sharingOptions, options),
|
||||
]);
|
||||
|
||||
const isArchived =
|
||||
|
|
@ -907,15 +1026,9 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: WorkflowListQueryOptions = {},
|
||||
callableForParentWorkflowId?: string,
|
||||
) {
|
||||
const { baseQuery, sortByColumn, sortByDirection } =
|
||||
this.buildBaseUnionQueryWithSharingSubquery(
|
||||
user,
|
||||
sharingOptions,
|
||||
options,
|
||||
callableForParentWorkflowId,
|
||||
);
|
||||
this.buildBaseUnionQueryWithSharingSubquery(user, sharingOptions, options);
|
||||
|
||||
const query = this.buildUnionQuery(baseQuery, {
|
||||
sortByColumn,
|
||||
|
|
@ -941,7 +1054,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: WorkflowListQueryOptions = {},
|
||||
callableForParentWorkflowId?: string,
|
||||
) {
|
||||
const { skip, take, ...baseQueryParameters } = options;
|
||||
|
||||
|
|
@ -949,7 +1061,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
user,
|
||||
sharingOptions,
|
||||
baseQueryParameters,
|
||||
callableForParentWorkflowId,
|
||||
);
|
||||
|
||||
const response = await baseQuery
|
||||
|
|
@ -971,7 +1082,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: WorkflowListQueryOptions = {},
|
||||
callableForParentWorkflowId?: string,
|
||||
) {
|
||||
// Common fields for both folders and workflows
|
||||
const commonFields = {
|
||||
|
|
@ -1020,7 +1130,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
user,
|
||||
sharingOptions,
|
||||
workflowQueryParameters,
|
||||
callableForParentWorkflowId,
|
||||
).addSelect("'workflow'", 'resource');
|
||||
|
||||
const qb = this.manager.createQueryBuilder();
|
||||
|
|
@ -1139,14 +1248,8 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: WorkflowListQueryOptions = {},
|
||||
callableForParentWorkflowId?: string,
|
||||
): Promise<WorkflowListResult> {
|
||||
const query = this.getManyQueryWithSharingSubquery(
|
||||
user,
|
||||
sharingOptions,
|
||||
options,
|
||||
callableForParentWorkflowId,
|
||||
);
|
||||
const query = this.getManyQueryWithSharingSubquery(user, sharingOptions, options);
|
||||
|
||||
const [workflows, count] = await query.getManyAndCount();
|
||||
return { workflows, count };
|
||||
|
|
@ -1166,7 +1269,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: WorkflowListQueryOptions = {},
|
||||
callableForParentWorkflowId?: string,
|
||||
): SelectQueryBuilder<WorkflowEntity> {
|
||||
const qb = this.createQueryBuilder('workflow');
|
||||
|
||||
|
|
@ -1184,21 +1286,9 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
sharingOptionsWithProjectId,
|
||||
);
|
||||
|
||||
if (callableForParentWorkflowId) {
|
||||
// Union: workflows readable by the user OR callable by the parent workflow.
|
||||
const callableSubquery = this.buildCallablePolicySubquery(callableForParentWorkflowId);
|
||||
qb.andWhere(
|
||||
`(workflow.id IN (${sharedWorkflowSubquery.getQuery()}) OR workflow.id IN (${callableSubquery.getQuery()}))`,
|
||||
);
|
||||
qb.setParameters({
|
||||
...sharedWorkflowSubquery.getParameters(),
|
||||
...callableSubquery.getParameters(),
|
||||
});
|
||||
} else {
|
||||
// Apply the sharing filter using the subquery
|
||||
qb.andWhere(`workflow.id IN (${sharedWorkflowSubquery.getQuery()})`);
|
||||
qb.setParameters(sharedWorkflowSubquery.getParameters());
|
||||
}
|
||||
// Apply the sharing filter using the subquery
|
||||
qb.andWhere(`workflow.id IN (${sharedWorkflowSubquery.getQuery()})`);
|
||||
qb.setParameters(sharedWorkflowSubquery.getParameters());
|
||||
|
||||
// Apply other filters
|
||||
// For personal project and shared-with-me cases, projectId is already handled in the subquery
|
||||
|
|
@ -1221,70 +1311,98 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build a subquery returning IDs of workflows whose callerPolicy permits the
|
||||
* given `parentWorkflowId` to call them.
|
||||
* List the workflows that `parentWorkflowId` can call as sub-workflows. The
|
||||
* list holds the workflows the user can read, plus the workflows whose
|
||||
* caller policy names the parent explicitly (`any`, or an allowlist that
|
||||
* contains the parent id).
|
||||
*
|
||||
* Only a minimal set of columns is loaded, because the callable rows can
|
||||
* belong to projects that the user has no access to.
|
||||
*/
|
||||
async findCallableSubworkflows(
|
||||
user: User,
|
||||
sharingOptions: { scopes: Scope[]; projectRoles: string[]; workflowRoles: string[] },
|
||||
parentWorkflowId: string,
|
||||
options: { query?: string; triggerNodeTypes: string[]; skip: number; take: number },
|
||||
): Promise<{ workflows: WorkflowEntity[]; count: number }> {
|
||||
const sharedWorkflowSubquery = this.buildSharedWorkflowIdsSubquery(user, sharingOptions);
|
||||
const callableSubquery = this.buildCallablePolicySubquery(parentWorkflowId);
|
||||
|
||||
const qb = this.createQueryBuilder('workflow')
|
||||
.select([
|
||||
'workflow.id',
|
||||
'workflow.name',
|
||||
'workflow.activeVersionId',
|
||||
'workflow.isArchived',
|
||||
'workflow.updatedAt',
|
||||
])
|
||||
.leftJoin('workflow.shared', 'owner', 'owner.role = :callableOwnerRole', {
|
||||
callableOwnerRole: 'workflow:owner',
|
||||
})
|
||||
.leftJoin('owner.project', 'ownerProject')
|
||||
.addSelect([
|
||||
'owner.workflowId',
|
||||
'owner.projectId',
|
||||
'owner.role',
|
||||
'ownerProject.id',
|
||||
'ownerProject.type',
|
||||
'ownerProject.name',
|
||||
])
|
||||
.where(
|
||||
`(workflow.id IN (${sharedWorkflowSubquery.getQuery()}) OR workflow.id IN (${callableSubquery.getQuery()}))`,
|
||||
)
|
||||
.setParameters({
|
||||
...sharedWorkflowSubquery.getParameters(),
|
||||
...callableSubquery.getParameters(),
|
||||
});
|
||||
|
||||
// Match names only: the list can hold workflows the user cannot read, so
|
||||
// their descriptions must not affect the result.
|
||||
const searchWords = this.parseSearchWords(options.query);
|
||||
if (searchWords.length > 0) {
|
||||
const { conditions, parameters } = this.buildSearchConditions(
|
||||
searchWords,
|
||||
'LOWER(workflow.name)',
|
||||
);
|
||||
qb.andWhere(`(${conditions.join(' AND ')})`, parameters);
|
||||
}
|
||||
this.applyTriggerNodeTypesFilter(qb, options.triggerNodeTypes);
|
||||
qb.orderBy('workflow.updatedAt', 'DESC').skip(options.skip).take(options.take);
|
||||
|
||||
const [workflows, count] = await qb.getManyAndCount();
|
||||
return { workflows, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a subquery returning IDs of workflows whose callerPolicy names the
|
||||
* given `parentWorkflowId` explicitly.
|
||||
*
|
||||
* The `workflowsFromSameOwner` policy is left out on purpose: for a user who
|
||||
* is not the owner, it would list workflows that the owner did not share.
|
||||
* The owner can already read those workflows through the sharing subquery.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private buildCallablePolicySubquery(parentWorkflowId: string): SelectQueryBuilder<any> {
|
||||
const subquery = this.manager
|
||||
.createQueryBuilder()
|
||||
.select('cpw.id')
|
||||
.from(WorkflowEntity, 'cpw')
|
||||
.leftJoin(
|
||||
SharedWorkflow,
|
||||
'sw_sub',
|
||||
"sw_sub.workflowId = cpw.id AND sw_sub.role = 'workflow:owner'",
|
||||
)
|
||||
.leftJoin(
|
||||
SharedWorkflow,
|
||||
'sw_par',
|
||||
"sw_par.workflowId = :cpParentWorkflowId AND sw_par.role = 'workflow:owner'",
|
||||
);
|
||||
|
||||
const { conditions, params } = this.buildCallablePolicyConditions(parentWorkflowId);
|
||||
|
||||
subquery.where('(' + conditions.join(' OR ') + ')', params);
|
||||
|
||||
return subquery;
|
||||
}
|
||||
|
||||
private buildCallablePolicyConditions(parentWorkflowId: string): {
|
||||
conditions: string[];
|
||||
params: Record<string, string>;
|
||||
} {
|
||||
const defaultPolicy = this.globalConfig.workflows.callerPolicyDefaultOption;
|
||||
const callerPolicy = this.settingsTextValue('cpw.settings', 'callerPolicy');
|
||||
const callerIds = this.settingsTextValue('cpw.settings', 'callerIds');
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: Record<string, string> = {
|
||||
cpParentWorkflowId: parentWorkflowId,
|
||||
cpCallerIdMembership: `%,${escapeLike(parentWorkflowId)},%`,
|
||||
};
|
||||
|
||||
// Branch 1: callerPolicy = 'any'
|
||||
conditions.push(`${callerPolicy} = 'any'`);
|
||||
|
||||
// Branch 2: callerPolicy = 'workflowsFromAList' and the allowlist contains parentWorkflowId as a whole ID.
|
||||
conditions.push(
|
||||
const conditions = [
|
||||
`${callerPolicy} = 'any'`,
|
||||
// The allowlist must contain parentWorkflowId as a whole ID.
|
||||
`(${callerPolicy} = 'workflowsFromAList' AND (',' || REPLACE(${callerIds}, ' ', '') || ',') LIKE :cpCallerIdMembership ${LIKE_ESCAPE_CLAUSE})`,
|
||||
);
|
||||
|
||||
// Branch 3: callerPolicy = 'workflowsFromSameOwner' (or NULL when default is 'workflowsFromSameOwner').
|
||||
const sameOwnerPolicyClauses = [`${callerPolicy} = 'workflowsFromSameOwner'`];
|
||||
if (defaultPolicy === 'workflowsFromSameOwner') {
|
||||
sameOwnerPolicyClauses.push(`${callerPolicy} IS NULL`);
|
||||
}
|
||||
conditions.push(
|
||||
`((${sameOwnerPolicyClauses.join(' OR ')}) AND sw_sub.projectId = sw_par.projectId AND sw_par.projectId IS NOT NULL)`,
|
||||
);
|
||||
|
||||
// Handle NULL callerPolicy when default is 'any'
|
||||
];
|
||||
if (defaultPolicy === 'any') {
|
||||
conditions.push(`${callerPolicy} IS NULL`);
|
||||
}
|
||||
|
||||
return { conditions, params };
|
||||
return this.manager
|
||||
.createQueryBuilder()
|
||||
.select('cpw.id')
|
||||
.from(WorkflowEntity, 'cpw')
|
||||
.where(`(${conditions.join(' OR ')})`, {
|
||||
cpCallerIdMembership: `%,${escapeLike(parentWorkflowId)},%`,
|
||||
});
|
||||
}
|
||||
|
||||
private settingsTextValue(field: string, key: string): string {
|
||||
|
|
@ -1449,14 +1567,15 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
/**
|
||||
* Builds search conditions and parameters for matching all of the search words
|
||||
*/
|
||||
private buildSearchConditions(searchWords: string[]): {
|
||||
private buildSearchConditions(
|
||||
searchWords: string[],
|
||||
fieldExpression = this.getFieldConcatExpression(),
|
||||
): {
|
||||
conditions: string[];
|
||||
parameters: Record<string, string>;
|
||||
} {
|
||||
const concatExpression = this.getFieldConcatExpression();
|
||||
|
||||
const conditions = searchWords.map((_, index) => {
|
||||
return `${concatExpression} LIKE :searchWord${index}`;
|
||||
return `${fieldExpression} LIKE :searchWord${index}`;
|
||||
});
|
||||
|
||||
const parameters: Record<string, string> = {};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "1.43.0",
|
||||
"version": "1.43.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ export type WorkflowExecuteAfterContext = {
|
|||
retryOf?: string;
|
||||
/** Who initiated the run. Unset means a regular user-initiated run. */
|
||||
source?: WorkflowExecutionSource;
|
||||
/**
|
||||
* False when this process only enqueued the run and waited for its job. In queue mode,
|
||||
* the process that ran the job fires its own `workflowExecuteAfter` for the same run.
|
||||
*/
|
||||
executedInThisProcess: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowExecuteResumeContext = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/engine",
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.3",
|
||||
"description": "n8n workflow execution engine (v2)",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo compiled",
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ register at install time and the failure is opaque to the user.
|
|||
This rule enforces the structural contract:
|
||||
|
||||
- `package.json` must contain an `n8n` object.
|
||||
- `n8n.n8nNodesApiVersion` must be present and a positive integer. It must live
|
||||
inside `n8n`, not at the root.
|
||||
- `n8n.n8nNodesApiVersion` must be present and a positive integer, or a
|
||||
`"<major>"` or `"<major>.<minor>"` string such as `"3"` or `"3.1"`. It must
|
||||
live inside `n8n`, not at the root.
|
||||
- `n8n.nodes` must be a non-empty array of strings, each starting with `dist/`.
|
||||
- `n8n.credentials`, if present, must be an array of strings, each starting
|
||||
with `dist/`.
|
||||
|
|
@ -53,7 +54,7 @@ the templates published by `@n8n/node-cli` and `@n8n/create-node`.
|
|||
{
|
||||
"name": "n8n-nodes-example",
|
||||
"n8n": {
|
||||
"n8nNodesApiVersion": "1",
|
||||
"n8nNodesApiVersion": "v1",
|
||||
"nodes": ["nodes/Foo/Foo.node.js"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/eslint-plugin-community-nodes",
|
||||
"type": "module",
|
||||
"version": "0.35.0",
|
||||
"version": "0.35.1",
|
||||
"main": "./dist/plugin.js",
|
||||
"types": "./dist/plugin.d.ts",
|
||||
"exports": {
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
"watch": "tsc --watch --project tsconfig.build.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@n8n/utils": "workspace:*",
|
||||
"@typescript-eslint/typescript-estree": "catalog:",
|
||||
"@typescript-eslint/utils": "catalog:",
|
||||
"fastest-levenshtein": "catalog:"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,21 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
|
|||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 2, "nodes": ["dist/x.js"] } }',
|
||||
},
|
||||
{
|
||||
name: 'major-only string api version is allowed',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3", "nodes": ["dist/x.js"] } }',
|
||||
},
|
||||
{
|
||||
name: 'major.minor string api version is allowed',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1", "nodes": ["dist/x.js"] } }',
|
||||
},
|
||||
{
|
||||
name: 'two-digit minor string api version is allowed',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.10", "nodes": ["dist/x.js"] } }',
|
||||
},
|
||||
{
|
||||
name: 'non-package.json file is ignored',
|
||||
filename: 'some-config.json',
|
||||
|
|
@ -77,10 +92,22 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
|
|||
errors: [{ messageId: 'missingNodesApiVersion' }],
|
||||
},
|
||||
{
|
||||
name: 'n8nNodesApiVersion is a string',
|
||||
name: 'n8nNodesApiVersion is a non-numeric string',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "1", "nodes": ["dist/x.js"] } }',
|
||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '1' } }],
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "v3", "nodes": ["dist/x.js"] } }',
|
||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: 'v3' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8nNodesApiVersion is a string with a patch part',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1.0", "nodes": ["dist/x.js"] } }',
|
||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '3.1.0' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8nNodesApiVersion is a zero-major string',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "0.1", "nodes": ["dist/x.js"] } }',
|
||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '0.1' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8nNodesApiVersion is zero',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { parseNodesApiLevel } from '@n8n/utils/nodes-api-level';
|
||||
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
|
||||
|
|
@ -34,9 +35,9 @@ export const N8nObjectValidationRule = createRule<[], MessageIds>({
|
|||
wrongLocationApiVersion:
|
||||
'"n8nNodesApiVersion" must be inside the "n8n" section, not at the root level of package.json.',
|
||||
missingNodesApiVersion:
|
||||
'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer).',
|
||||
'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer, or a "<major>" or "<major>.<minor>" string).',
|
||||
invalidNodesApiVersion:
|
||||
'"n8n.n8nNodesApiVersion" must be a positive integer, got {{ value }}.',
|
||||
'"n8n.n8nNodesApiVersion" must be a positive integer, or a "<major>" or "<major>.<minor>" string, got {{ value }}.',
|
||||
missingN8nNodes: 'The "n8n" object must declare "nodes" as an array of "dist/" paths.',
|
||||
n8nNodesNotArray: '"n8n.nodes" must be an array of "dist/" paths.',
|
||||
emptyN8nNodes: '"n8n.nodes" must contain at least one path.',
|
||||
|
|
@ -108,7 +109,7 @@ function validateApiVersion(context: Context, n8nObject: TSESTree.ObjectExpressi
|
|||
}
|
||||
|
||||
const valueNode = apiVersionProp.value;
|
||||
if (valueNode.type !== AST_NODE_TYPES.Literal || !isPositiveInteger(valueNode.value)) {
|
||||
if (valueNode.type !== AST_NODE_TYPES.Literal || parseNodesApiLevel(valueNode.value) === null) {
|
||||
context.report({
|
||||
node: apiVersionProp,
|
||||
messageId: 'invalidNodesApiVersion',
|
||||
|
|
@ -194,7 +195,3 @@ function validatePathArray(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): boolean {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/expression-runtime",
|
||||
"version": "0.34.0",
|
||||
"version": "0.34.2",
|
||||
"description": "Secure, isolated expression evaluation runtime for n8n",
|
||||
"main": "dist/cjs/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ const unique = arrayExtensions.functions.unique as (
|
|||
extraArgs: string[],
|
||||
) => unknown[];
|
||||
|
||||
const chunk = arrayExtensions.functions.chunk as (
|
||||
value: unknown[],
|
||||
extraArgs: unknown[],
|
||||
) => unknown[][];
|
||||
|
||||
const merge = arrayExtensions.functions.merge as (
|
||||
value: unknown[],
|
||||
extraArgs: unknown[][],
|
||||
|
|
@ -132,3 +137,15 @@ describe.each([
|
|||
expect(({} as Record<string, unknown>).marker).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chunk', () => {
|
||||
it('should split by a positive size', () => {
|
||||
expect(chunk([1, 2, 3, 4, 5], [2])).toEqual([[1, 2], [3, 4], [5]]);
|
||||
});
|
||||
|
||||
it('should reject a size that is not a positive number', () => {
|
||||
for (const size of [-1, 0, NaN, '5', undefined]) {
|
||||
expect(() => chunk([1, 2, 3], [size])).toThrow('positive numeric arg');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -194,8 +194,8 @@ function smartJoin(value: unknown[], extraArgs: string[]): object {
|
|||
|
||||
function chunk(value: unknown[], extraArgs: number[]) {
|
||||
const [chunkSize] = extraArgs;
|
||||
if (typeof chunkSize !== 'number' || chunkSize === 0) {
|
||||
throw new ExpressionExtensionError('chunk(): expected non-zero numeric arg, e.g. .chunk(5)');
|
||||
if (typeof chunkSize !== 'number' || Number.isNaN(chunkSize) || chunkSize <= 0) {
|
||||
throw new ExpressionExtensionError('chunk(): expected positive numeric arg, e.g. .chunk(5)');
|
||||
}
|
||||
const chunks: unknown[][] = [];
|
||||
for (let i = 0; i < value.length; i += chunkSize) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/imap",
|
||||
"version": "0.29.0",
|
||||
"version": "0.29.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/inbound-auth",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/instance-ai",
|
||||
"version": "1.28.0",
|
||||
"version": "1.28.6",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
||||
|
|
|
|||
|
|
@ -455,6 +455,63 @@ describe('createInstanceAgent', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('starts preparing runtime skills in the background without waiting for them', async () => {
|
||||
let finishPrepare: () => void = () => {};
|
||||
const prepare = vi.fn(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
finishPrepare = resolve;
|
||||
}),
|
||||
);
|
||||
const runtimeSkills = {
|
||||
registry: {
|
||||
schemaVersion: 1,
|
||||
skillsHash: 'skills-hash',
|
||||
skills: [{ id: 'data-table-manager', name: 'data-table-manager', description: 'x' }],
|
||||
},
|
||||
prepare,
|
||||
loadSkill: vi.fn(),
|
||||
};
|
||||
|
||||
await createInstanceAgent({
|
||||
modelId: 'test-model',
|
||||
context: {},
|
||||
orchestrationContext: { runId: 'skills-test', runtimeSkills },
|
||||
memoryConfig: {},
|
||||
mcpManager: createMcpManagerStub(),
|
||||
} as never);
|
||||
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
finishPrepare();
|
||||
});
|
||||
|
||||
it('logs a background skill preparation failure instead of rejecting', async () => {
|
||||
const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
const runtimeSkills = {
|
||||
registry: {
|
||||
schemaVersion: 1,
|
||||
skillsHash: 'skills-hash',
|
||||
skills: [{ id: 'data-table-manager', name: 'data-table-manager', description: 'x' }],
|
||||
},
|
||||
prepare: vi.fn(async () => await Promise.reject(new Error('sandbox unavailable'))),
|
||||
loadSkill: vi.fn(),
|
||||
};
|
||||
|
||||
await createInstanceAgent({
|
||||
modelId: 'test-model',
|
||||
context: {},
|
||||
orchestrationContext: { runId: 'skills-test', runtimeSkills, logger },
|
||||
memoryConfig: {},
|
||||
mcpManager: createMcpManagerStub(),
|
||||
} as never);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(logger.warn).toHaveBeenCalledWith('Failed to warm runtime skills in the background', {
|
||||
error: 'sandbox unavailable',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the selected catalog to domain tools before workspace materialization', async () => {
|
||||
const runtimeSkillCatalog = {
|
||||
registry: { schemaVersion: 1, skillsHash: 'selected-skills', skills: [] },
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { attachRuntimeWorkspaceCapabilities } from './runtime-workspace';
|
||||
import { listConnectedMcpServices } from '../mcp/connected-mcp-services';
|
||||
import { getVersionedSystemPrompt, resolvePromptProfile } from '../prompts/prompt-profiles';
|
||||
import { hasRuntimeSkills } from '../skills/runtime-skills';
|
||||
import { hasRuntimeSkills, warmRuntimeSkills } from '../skills/runtime-skills';
|
||||
import { createToolRegistry, mergeToolRegistries, toolRegistryValues } from '../tool-registry';
|
||||
import {
|
||||
createOrchestratorDomainTools,
|
||||
|
|
@ -254,6 +254,10 @@ export async function createInstanceAgent(
|
|||
const runtimeSkills = orchestrationContext?.runtimeSkills;
|
||||
if (hasRuntimeSkills(runtimeSkills)) {
|
||||
agent.skills(runtimeSkills);
|
||||
warmRuntimeSkills(runtimeSkills, {
|
||||
logger: orchestrationContext?.logger,
|
||||
tracing: orchestrationContext?.tracing,
|
||||
});
|
||||
}
|
||||
if (telemetry) {
|
||||
agent.telemetry(telemetry);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
INSTANCE_AI_SKILLS_DIR,
|
||||
loadInstanceAiRuntimeSkillSource,
|
||||
loadInstanceAiRuntimeSkillSourceForBuildMode,
|
||||
warmRuntimeSkills,
|
||||
} from '../runtime-skills';
|
||||
import { CONFIG_EVALS_SKILL_ID, disabledInstanceAiSkillIds } from '../skill-gates';
|
||||
|
||||
|
|
@ -614,3 +615,60 @@ async function loadRuntimeSkillSourceWithEnabledModules(enabledModules: string |
|
|||
const { loadInstanceAiRuntimeSkillSource } = await import('../runtime-skills.js');
|
||||
return loadInstanceAiRuntimeSkillSource();
|
||||
}
|
||||
|
||||
describe('warmRuntimeSkills', () => {
|
||||
const registry = {
|
||||
schemaVersion: 1 as const,
|
||||
skillsHash: 'skills-hash',
|
||||
skills: [{ id: 'data-table-manager', name: 'data-table-manager', description: 'x' }],
|
||||
};
|
||||
|
||||
it('runs the background preparation in the actor span of the chat trace', async () => {
|
||||
let insideSpan = false;
|
||||
let preparedInsideSpan = false;
|
||||
const actorRun = { id: 'actor-run' };
|
||||
const withActiveSpan = vi.fn(async (_run: unknown, fn: () => Promise<unknown>) => {
|
||||
insideSpan = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
insideSpan = false;
|
||||
}
|
||||
});
|
||||
const prepare = vi.fn(async () => {
|
||||
preparedInsideSpan = insideSpan;
|
||||
});
|
||||
|
||||
warmRuntimeSkills({ registry, prepare, loadSkill: vi.fn() } as never, {
|
||||
tracing: { actorRun, withActiveSpan } as never,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(prepare).toHaveBeenCalledTimes(1));
|
||||
expect(withActiveSpan).toHaveBeenCalledWith(actorRun, expect.any(Function));
|
||||
expect(preparedInsideSpan).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the chat trace open until the background preparation settles', () => {
|
||||
const preparation = Promise.resolve();
|
||||
const keepOpenUntilSettled = vi.fn();
|
||||
const tracing = {
|
||||
actorRun: { id: 'actor-run' },
|
||||
withActiveSpan: vi.fn(async () => await preparation),
|
||||
keepOpenUntilSettled,
|
||||
};
|
||||
|
||||
warmRuntimeSkills({ registry, prepare: vi.fn(), loadSkill: vi.fn() } as never, {
|
||||
tracing: tracing as never,
|
||||
});
|
||||
|
||||
expect(keepOpenUntilSettled).toHaveBeenCalledWith(tracing.withActiveSpan.mock.results[0].value);
|
||||
});
|
||||
|
||||
it('prepares the source without a span when tracing is off', async () => {
|
||||
const prepare = vi.fn(async () => {});
|
||||
|
||||
warmRuntimeSkills({ registry, prepare, loadSkill: vi.fn() } as never);
|
||||
|
||||
await vi.waitFor(() => expect(prepare).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { loadRuntimeSkillSourceFromDirectory, type RuntimeSkillSource } from '@n8n/agents';
|
||||
import type { InstanceAiBuildMode } from '@n8n/api-types';
|
||||
import { getErrorMessage } from '@n8n/utils/errors/get-error-message';
|
||||
import { GROUPING_GUIDANCE } from '@n8n/workflow-sdk/prompts/sdk-reference';
|
||||
import { TOP_LEVEL_ITEM_CEILING } from 'n8n-workflow';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { isAgentFeatureEnabled } from '@/utils/agent-feature-enabled';
|
||||
|
||||
import type { Logger } from '../logger';
|
||||
import type { InstanceAiTraceContext } from '../types';
|
||||
import {
|
||||
PROMPT_FRAGMENT_SKILLS,
|
||||
resolvePromptProfile,
|
||||
|
|
@ -63,3 +66,27 @@ export function hasRuntimeSkills(
|
|||
): source is RuntimeSkillSource {
|
||||
return (source?.registry.skills.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the skill source in the background so it overlaps with the first model
|
||||
* call. load_skill awaits the same promise and retries if this one fails. It runs
|
||||
* in the actor span, and keeps the trace open until it settles, so the sandbox
|
||||
* spans stay in the chat trace even when the reply finishes first.
|
||||
*/
|
||||
export function warmRuntimeSkills(
|
||||
source: RuntimeSkillSource,
|
||||
options: { logger?: Logger; tracing?: InstanceAiTraceContext } = {},
|
||||
): void {
|
||||
const { prepare } = source;
|
||||
if (!prepare) return;
|
||||
const { logger, tracing } = options;
|
||||
const preparation = tracing
|
||||
? tracing.withActiveSpan(tracing.actorRun, async () => await prepare.call(source))
|
||||
: prepare.call(source);
|
||||
tracing?.keepOpenUntilSettled?.(preparation);
|
||||
preparation.catch((error: unknown) => {
|
||||
logger?.warn('Failed to warm runtime skills in the background', {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1185,6 +1185,66 @@ describe('createInstanceAiTraceContext', () => {
|
|||
expect(provider.shutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the trace open for a background operation that outlives the root run', async () => {
|
||||
const tracing = await createInstanceAiTraceContext({
|
||||
threadId: 'thread-background-op',
|
||||
messageId: 'message-background-op',
|
||||
runId: 'run-background-op',
|
||||
userId: 'user-background-op',
|
||||
input: { message: 'hi' },
|
||||
});
|
||||
expect(tracing).toBeDefined();
|
||||
await startForegroundActor(tracing!);
|
||||
const provider = agentsMock.getProvider();
|
||||
|
||||
const acquired = createDeferredPromise<undefined>();
|
||||
const operation = tracing!.withActiveSpan(tracing!.actorRun, async () => {
|
||||
await withCurrentTraceSpan({ name: 'sandbox: acquire' }, async () => await acquired.promise);
|
||||
await withCurrentTraceSpan({ name: 'sandbox: sync-skills' }, async () => {});
|
||||
});
|
||||
tracing!.keepOpenUntilSettled?.(operation);
|
||||
|
||||
await tracing!.finishRun(tracing!.rootRun, { outputs: { status: 'done' } });
|
||||
expect(provider.shutdown).not.toHaveBeenCalled();
|
||||
expect(tracing?.isLive?.()).toBe(true);
|
||||
|
||||
acquired.resolve(undefined);
|
||||
await operation;
|
||||
await vi.waitFor(() => expect(provider.shutdown).toHaveBeenCalledTimes(1));
|
||||
|
||||
const spans = agentsMock.getSpans();
|
||||
const acquireSpan = spans.find((span) => span.name === 'sandbox: acquire');
|
||||
expect(acquireSpan?.ended).toBe(true);
|
||||
expect(acquireSpan?.status?.message).toBeUndefined();
|
||||
expect(spans.find((span) => span.name === 'sandbox: sync-skills')?.ended).toBe(true);
|
||||
expect(tracing?.isLive?.()).toBe(false);
|
||||
});
|
||||
|
||||
it('releases the trace after a time limit when a background operation does not settle', async () => {
|
||||
const tracing = await createInstanceAiTraceContext({
|
||||
threadId: 'thread-stuck-op',
|
||||
messageId: 'message-stuck-op',
|
||||
runId: 'run-stuck-op',
|
||||
userId: 'user-stuck-op',
|
||||
input: { message: 'hi' },
|
||||
});
|
||||
expect(tracing).toBeDefined();
|
||||
const provider = agentsMock.getProvider();
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
tracing!.keepOpenUntilSettled?.(new Promise<void>(() => {}));
|
||||
await tracing!.finishRun(tracing!.rootRun, { outputs: { status: 'done' } });
|
||||
expect(provider.shutdown).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
|
||||
expect(provider.shutdown).toHaveBeenCalledTimes(1);
|
||||
expect(tracing?.isLive?.()).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the provider alive for a queued memory task until it releases, then exports its span and shuts down', async () => {
|
||||
const tracing = await createInstanceAiTraceContext({
|
||||
threadId: 'thread-late-memory-task',
|
||||
|
|
|
|||
|
|
@ -185,10 +185,16 @@ interface ProductOtelTraceRuntime {
|
|||
spans: Map<string, OtelApiSpan>;
|
||||
contexts: Map<string, OtelContext>;
|
||||
pendingOperations: Map<string, InstanceAiTraceRun>;
|
||||
/** Work started in the trace that can outlive the root run. The release waits for it. */
|
||||
backgroundOperations: Set<Promise<unknown>>;
|
||||
releaseDeferred: boolean;
|
||||
shutdown: boolean;
|
||||
lifetime: ProductTelemetryLifetime;
|
||||
}
|
||||
|
||||
/** Upper limit for how long a background operation can keep a closed trace open. */
|
||||
const BACKGROUND_OPERATION_RELEASE_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
|
||||
interface OTelTracer {
|
||||
startSpan(
|
||||
name: string,
|
||||
|
|
@ -488,7 +494,19 @@ async function releaseProductOtelRuntime(
|
|||
runtime: ProductOtelTraceRuntime,
|
||||
traceId: string,
|
||||
): Promise<void> {
|
||||
if (runtime.shutdown) return;
|
||||
if (runtime.shutdown || runtime.releaseDeferred) return;
|
||||
|
||||
if (runtime.backgroundOperations.size > 0) {
|
||||
// Do not block the caller. Release after the background work settles, so its
|
||||
// spans finish normally instead of being cancelled.
|
||||
runtime.releaseDeferred = true;
|
||||
void settleBackgroundOperations(runtime).then(async () => {
|
||||
runtime.releaseDeferred = false;
|
||||
runtime.backgroundOperations.clear();
|
||||
await releaseProductOtelRuntime(runtime, traceId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.shutdown = true;
|
||||
for (const run of runtime.pendingOperations.values()) {
|
||||
|
|
@ -505,6 +523,27 @@ async function releaseProductOtelRuntime(
|
|||
await runtime.lifetime.release();
|
||||
}
|
||||
|
||||
async function settleBackgroundOperations(runtime: ProductOtelTraceRuntime): Promise<void> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, BACKGROUND_OPERATION_RELEASE_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
});
|
||||
const allSettled = (async () => {
|
||||
// Operations can register while earlier ones are in flight.
|
||||
while (runtime.backgroundOperations.size > 0) {
|
||||
const operations = [...runtime.backgroundOperations];
|
||||
await Promise.allSettled(operations);
|
||||
for (const operation of operations) runtime.backgroundOperations.delete(operation);
|
||||
}
|
||||
})();
|
||||
try {
|
||||
await Promise.race([allSettled, timeout]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function withProxyHeaders<T>(
|
||||
proxyConfig: ServiceProxyConfig | undefined,
|
||||
fn: () => T | Promise<T>,
|
||||
|
|
@ -1318,6 +1357,13 @@ function createTraceContext(
|
|||
finishRun,
|
||||
failRun,
|
||||
onMemoryTaskEvent,
|
||||
keepOpenUntilSettled: (operation) => {
|
||||
if (otelRuntime.shutdown) return;
|
||||
otelRuntime.backgroundOperations.add(operation);
|
||||
void operation
|
||||
.catch(() => undefined)
|
||||
.finally(() => otelRuntime.backgroundOperations.delete(operation));
|
||||
},
|
||||
...(telemetryFactory ? { getTelemetry: telemetryFactory } : {}),
|
||||
wrapTools: (tools, traceOptions) => {
|
||||
if (ctx.replayMode === 'replay' && ctx.traceIndex && ctx.idRemapper) {
|
||||
|
|
@ -1767,6 +1813,8 @@ async function createProductOtelRuntime(
|
|||
spans: new Map(),
|
||||
contexts: new Map(),
|
||||
pendingOperations: new Map(),
|
||||
backgroundOperations: new Set(),
|
||||
releaseDeferred: false,
|
||||
shutdown: false,
|
||||
lifetime: new ProductTelemetryLifetime(telemetry),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2139,6 +2139,11 @@ export interface InstanceAiTraceContext {
|
|||
* for any agent (main or sub-agent) whose spans should join this trace.
|
||||
*/
|
||||
onMemoryTaskEvent?: (event: ScopedMemoryTaskEvent) => void;
|
||||
/**
|
||||
* Keep the trace open until a background operation settles, so its spans
|
||||
* finish normally when it outlives the root run.
|
||||
*/
|
||||
keepOpenUntilSettled?: (operation: Promise<unknown>) => void;
|
||||
/** Trace replay mode: 'record' captures tool I/O, 'replay' remaps IDs, 'off' disables. */
|
||||
replayMode: TraceReplayMode;
|
||||
/** Shared ID remapper instance — available in 'replay' mode. */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/mcp-apps",
|
||||
"version": "0.20.0",
|
||||
"version": "0.20.6",
|
||||
"description": "MCP Apps UI resources and server helpers for n8n",
|
||||
"main": "dist/server/index.js",
|
||||
"types": "dist/server/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/mcp-browser",
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"description": "Browser automation MCP tools built on Playwright, WebDriver BiDi, and safaridriver",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/node-cli",
|
||||
"version": "0.52.0",
|
||||
"version": "0.52.5",
|
||||
"description": "Official CLI for developing community nodes for n8n",
|
||||
"bin": {
|
||||
"n8n-node": "bin/n8n-node.mjs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/node-engine-compatibility",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.5",
|
||||
"description": "Adapts v1 nodes and workflows to the Engine v2 execution model",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import { LOG_LEVELS, CONSOLE_OUTPUT_REDACTED_MESSAGE } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
Code,
|
||||
transformLegacyLangchainImport,
|
||||
createSandboxLogger,
|
||||
createProductionConsoleLog,
|
||||
} from './Code.node';
|
||||
|
||||
describe('Code.node', () => {
|
||||
it('is marked deprecated so the backend refuses new instances', () => {
|
||||
const node = new Code();
|
||||
expect(node.description.deprecated).toBe(true);
|
||||
});
|
||||
|
||||
describe('createSandboxLogger', () => {
|
||||
const logLevelKeys = LOG_LEVELS.filter((level) => level !== 'silent');
|
||||
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@ export class Code implements INodeType {
|
|||
description: INodeTypeDescription = {
|
||||
displayName: 'LangChain Code',
|
||||
name: 'code',
|
||||
deprecated: true,
|
||||
icon: 'fa:code',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ import {
|
|||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
import { shouldIncludeGoogleModel } from '@n8n/ai-utilities/model-discovery';
|
||||
import {
|
||||
getGoogleModelLabel,
|
||||
pinPrioritizedModels,
|
||||
shouldIncludeGoogleModel,
|
||||
} from '@n8n/ai-utilities/model-discovery';
|
||||
|
||||
import { MODEL_SELECTION_HINT } from '@utils/model-builder-hints';
|
||||
|
||||
|
|
@ -37,6 +41,30 @@ async function filterChatModels(
|
|||
);
|
||||
}
|
||||
|
||||
/** Show models under the name the API reports, sorted by it, with prioritized models first. */
|
||||
async function labelModels(
|
||||
this: IExecuteSingleFunctions,
|
||||
items: INodeExecutionData[],
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const labelled = items.map((item) => {
|
||||
// `displayName` and `priority` only carry the API's values to here; options do not keep them.
|
||||
const { displayName, priority, ...option } = item.json;
|
||||
const json =
|
||||
typeof option.value === 'string'
|
||||
? {
|
||||
...option,
|
||||
name: getGoogleModelLabel(
|
||||
option.value,
|
||||
typeof displayName === 'string' ? displayName : undefined,
|
||||
),
|
||||
}
|
||||
: option;
|
||||
return { item: { ...item, json }, priority };
|
||||
});
|
||||
labelled.sort((a, b) => String(a.item.json.name).localeCompare(String(b.item.json.name)));
|
||||
return pinPrioritizedModels(labelled, (entry) => entry.priority).map((entry) => entry.item);
|
||||
}
|
||||
|
||||
function errorDescriptionMapper(error: NodeError) {
|
||||
if (error.description?.includes('properties: should be non-empty for OBJECT type')) {
|
||||
return 'Google Gemini requires at least one <a href="https://docs.n8n.io/advanced-ai/examples/using-the-fromai-function/" target="_blank">dynamic parameter</a> when using tools';
|
||||
|
|
@ -78,14 +106,11 @@ const modelRLC: INodeProperties = {
|
|||
name: '={{$responseItem.name}}',
|
||||
value: '={{$responseItem.name}}',
|
||||
description: '={{$responseItem.description}}',
|
||||
displayName: '={{$responseItem.displayName}}',
|
||||
priority: '={{$responseItem.priority}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
labelModels,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
@ -130,14 +155,11 @@ const modelRLCV2: INodeProperties = {
|
|||
name: '={{$responseItem.name}}',
|
||||
value: '={{$responseItem.name}}',
|
||||
description: '={{$responseItem.description}}',
|
||||
displayName: '={{$responseItem.displayName}}',
|
||||
priority: '={{$responseItem.priority}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
labelModels,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import type { IExecuteSingleFunctions, INodeExecutionData, PostReceiveAction } from 'n8n-workflow';
|
||||
|
||||
import { LmChatGoogleGemini } from '../LmChatGoogleGemini.node';
|
||||
|
||||
describe('LmChatGoogleGemini', () => {
|
||||
it('should label, sort and pin models in every version', async () => {
|
||||
const modelProperties = new LmChatGoogleGemini().description.properties.filter(
|
||||
(p) => p.name === 'modelName',
|
||||
);
|
||||
const options: INodeExecutionData[] = [
|
||||
{
|
||||
json: {
|
||||
name: 'models/gemini-zeta',
|
||||
value: 'models/gemini-zeta',
|
||||
displayName: '',
|
||||
priority: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
name: 'models/gemini-featured',
|
||||
value: 'models/gemini-featured',
|
||||
displayName: 'Gemini Featured',
|
||||
priority: 10,
|
||||
},
|
||||
},
|
||||
{ json: { name: 'models/gemini-2.5-pro', value: 'models/gemini-2.5-pro' } },
|
||||
{
|
||||
json: {
|
||||
name: 'models/gemini-api-named',
|
||||
value: 'models/gemini-api-named',
|
||||
displayName: 'Gemini Api Named',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(modelProperties).toHaveLength(3);
|
||||
for (const property of modelProperties) {
|
||||
const postReceive = property.typeOptions?.loadOptions?.routing?.output
|
||||
?.postReceive as PostReceiveAction[];
|
||||
const labelModels = postReceive.at(-1);
|
||||
if (typeof labelModels !== 'function') throw new Error('Expected a postReceive function');
|
||||
|
||||
const result = await labelModels.call({} as IExecuteSingleFunctions, options, {} as never);
|
||||
|
||||
// The API's displayName labels a model, else its id. A priority pins a model.
|
||||
expect(result.map((option) => option.json)).toEqual([
|
||||
{ name: 'Gemini Featured', value: 'models/gemini-featured' },
|
||||
{ name: 'Gemini Api Named', value: 'models/gemini-api-named' },
|
||||
{ name: 'models/gemini-2.5-pro', value: 'models/gemini-2.5-pro' },
|
||||
{ name: 'models/gemini-zeta', value: 'models/gemini-zeta' },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should leave the label of an option without a string value alone', async () => {
|
||||
const property = new LmChatGoogleGemini().description.properties.find(
|
||||
(p) => p.name === 'modelName',
|
||||
);
|
||||
const postReceive = property?.typeOptions?.loadOptions?.routing?.output
|
||||
?.postReceive as PostReceiveAction[];
|
||||
const labelModels = postReceive.at(-1);
|
||||
if (typeof labelModels !== 'function') throw new Error('Expected a postReceive function');
|
||||
|
||||
const result = await labelModels.call(
|
||||
{} as IExecuteSingleFunctions,
|
||||
[{ json: { name: 'Unknown', value: 42, displayName: 'Ignored' } }],
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result.map((option) => option.json)).toEqual([{ name: 'Unknown', value: 42 }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1098,6 +1098,10 @@ export class ChatTrigger extends Node {
|
|||
// By header as well as by the iframe's attribute, so the document has no
|
||||
// origin even if the attribute is ever stripped.
|
||||
res.setHeader('Content-Security-Policy', `sandbox ${CHAT_FRAME_SANDBOX}`);
|
||||
} else {
|
||||
// The webhook route sandboxes every response. This page keeps its origin,
|
||||
// because the widget keeps the session in `localStorage`.
|
||||
res.removeHeader('Content-Security-Policy');
|
||||
}
|
||||
|
||||
const page = createPage({
|
||||
|
|
|
|||
|
|
@ -984,6 +984,17 @@ describe('ChatTrigger Node', () => {
|
|||
expect(renderedView().sandbox).not.toContain('allow-popups-to-escape-sandbox');
|
||||
});
|
||||
|
||||
it('removes the route sandbox from the public chat page', async () => {
|
||||
await renderSetupPage('none');
|
||||
|
||||
expect(mockResponse.removeHeader).toHaveBeenCalledWith('Content-Security-Policy');
|
||||
expect(mockResponse.setHeader).not.toHaveBeenCalledWith(
|
||||
'Content-Security-Policy',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(renderedPage()).toContain('createChat');
|
||||
});
|
||||
|
||||
it('renders the author chat for the frame own request', async () => {
|
||||
mockRequest.query = { n8nShellInner: '1' };
|
||||
mockRequest.headers = {
|
||||
|
|
@ -998,6 +1009,7 @@ describe('ChatTrigger Node', () => {
|
|||
'Content-Security-Policy',
|
||||
'sandbox allow-scripts allow-forms allow-modals allow-popups',
|
||||
);
|
||||
expect(mockResponse.removeHeader).not.toHaveBeenCalled();
|
||||
expect(renderedPage()).toContain('createChat');
|
||||
// Identity injected server-side, and a header token for the messages that follow.
|
||||
expect(renderedPage()).toContain('"email":"visitor@example.com"');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { MongoClient } from 'mongodb';
|
||||
import type { ILoadOptionsFunctions, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
|
|
@ -144,6 +145,30 @@ describe('VectorStoreMongoDBAtlas', () => {
|
|||
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
||||
expect(client).toBe(mockClient);
|
||||
});
|
||||
it('should not include the connection string in the error when the client cannot be created', async () => {
|
||||
MockMongoClient.mockImplementation(function () {
|
||||
throw new Error(
|
||||
'Protocol and host list are required in "mongodb://testuser:secret-pass@?x:27017"',
|
||||
);
|
||||
});
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
configurationType: 'values',
|
||||
host: '?x',
|
||||
user: 'testuser',
|
||||
password: 'secret-pass',
|
||||
port: 27017,
|
||||
database: 'testdb',
|
||||
});
|
||||
|
||||
const error = await createMongoClient(mockContext, 1.1).catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(NodeOperationError);
|
||||
expect((error as Error).message).toBe(
|
||||
'MongoDB connection error: Protocol and host list are required in "mongodb://[REDACTED]"',
|
||||
);
|
||||
expect(JSON.stringify(error)).not.toContain('secret-pass');
|
||||
expect((error as Error).stack).not.toContain('secret-pass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('.getCollections', () => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import {
|
|||
} from 'n8n-workflow';
|
||||
import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities';
|
||||
|
||||
import { validateAndResolveMongoCredentials } from 'n8n-nodes-base/dist/nodes/MongoDb/GenericFunctions';
|
||||
import {
|
||||
sanitizeMongoUriInMessage,
|
||||
validateAndResolveMongoCredentials,
|
||||
} from 'n8n-nodes-base/dist/nodes/MongoDb/GenericFunctions';
|
||||
|
||||
/**
|
||||
* Constants for the name of the credentials and Node parameters.
|
||||
|
|
@ -169,15 +172,22 @@ export async function createMongoClient(
|
|||
const node = context.getNode();
|
||||
const { connectionString } = validateAndResolveMongoCredentials(node, credentials);
|
||||
|
||||
const client = new MongoClient(connectionString, {
|
||||
appName: 'devrel.integration.n8n_vector_integ',
|
||||
driverInfo: {
|
||||
name: 'n8n_vector',
|
||||
version: version.toString(),
|
||||
},
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
try {
|
||||
const client = new MongoClient(connectionString, {
|
||||
appName: 'devrel.integration.n8n_vector_integ',
|
||||
driverInfo: {
|
||||
name: 'n8n_vector',
|
||||
version: version.toString(),
|
||||
},
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`MongoDB connection error: ${sanitizeMongoUriInMessage(error, connectionString)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Embeddings } from '@langchain/core/embeddings';
|
|||
import type { VectorStore } from '@langchain/core/vectorstores';
|
||||
import type { VectorStoreNodeConstructorArgs } from '@n8n/ai-utilities';
|
||||
import { ConnectionPoolManager } from 'n8n-nodes-base/dist/utils/connection-pool-manager';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import type { Pool } from 'oracledb';
|
||||
import type { Mocked } from 'vitest';
|
||||
|
||||
|
|
@ -323,9 +323,12 @@ vi.mock('@n8n/ai-utilities', () => {
|
|||
import { VectorStoreOracleDB } from './VectorStoreOracleDB.node';
|
||||
|
||||
describe('VectorStoreOracleDB.node', () => {
|
||||
const node = { name: 'Oracle Database Vector Store', type: 'vectorStoreOracleDBVector' } as INode;
|
||||
|
||||
const context = {
|
||||
getNodeParameter: vi.fn(),
|
||||
getCredentials: vi.fn(),
|
||||
getNode: vi.fn(() => node),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
|
|
@ -594,4 +597,104 @@ describe('VectorStoreOracleDB.node', () => {
|
|||
expect(connectionCloseMocks[1]).toHaveBeenCalledTimes(1);
|
||||
expect(poolCloseMocks[0]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('parameter validation', () => {
|
||||
const withTableName = (tableName: string) => (name: string) => {
|
||||
if (name === 'tableName') return tableName;
|
||||
if (name === 'options.distanceStrategy') return DistanceStrategyMock.DOT_PRODUCT;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
it('rejects a metadata filter name that is not a plain identifier', async () => {
|
||||
const node = createNode();
|
||||
|
||||
await expect(
|
||||
node.getVectorStoreClient(context, { "userId'": 'alice' } as OracleFilter, embeddings, 0),
|
||||
).rejects.toThrow('Invalid metadata filter key');
|
||||
expect(initializeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a metadata filter name supplied at search time', async () => {
|
||||
const node = createNode();
|
||||
const vectorStore = await node.getVectorStoreClient(context, undefined, embeddings, 0);
|
||||
|
||||
await expect(
|
||||
vectorStore.similaritySearchVectorWithScore([0.1], 3, {
|
||||
'userId?(@ == "bob")': 'alice',
|
||||
} as OracleFilter),
|
||||
).rejects.toThrow('Invalid metadata filter key');
|
||||
expect(similaritySearchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a metadata filter name nested under $and at search time', async () => {
|
||||
const node = createNode();
|
||||
const vectorStore = await node.getVectorStoreClient(context, undefined, embeddings, 0);
|
||||
|
||||
await expect(
|
||||
vectorStore.similaritySearchVectorWithScore([0.1], 3, {
|
||||
$and: [{ project: 'n8n' }, { 'a.b': 'x' }],
|
||||
} as OracleFilter),
|
||||
).rejects.toThrow('Invalid metadata filter key');
|
||||
expect(similaritySearchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a filter whose names are plain identifiers', async () => {
|
||||
const node = createNode();
|
||||
const filter = { userId: 'alice', user_id: 'alice' } as OracleFilter;
|
||||
|
||||
const vectorStore = await node.getVectorStoreClient(context, filter, embeddings, 0);
|
||||
await vectorStore.similaritySearchVectorWithScore([0.1], 3, undefined);
|
||||
|
||||
expect(similaritySearchSpy).toHaveBeenCalledWith([0.1], 3, filter);
|
||||
});
|
||||
|
||||
it('rejects a table name that is not a plain identifier', async () => {
|
||||
const node = createNode();
|
||||
context.getNodeParameter.mockImplementation(withTableName('n8n_vectors; SELECT 1'));
|
||||
|
||||
await expect(node.getVectorStoreClient(context, undefined, embeddings, 0)).rejects.toThrow(
|
||||
'Invalid table name',
|
||||
);
|
||||
|
||||
context.getNodeParameter.mockImplementation(defaultGetNodeParameter);
|
||||
});
|
||||
|
||||
it('rejects a table name that is not a plain identifier when inserting', async () => {
|
||||
const node = createNode();
|
||||
context.getNodeParameter.mockImplementation(withTableName('n8n_vectors; SELECT 1'));
|
||||
|
||||
await expect(node.populateVectorStore(context, embeddings, documents, 0)).rejects.toThrow(
|
||||
'Invalid table name',
|
||||
);
|
||||
expect(fromDocumentsSpy).not.toHaveBeenCalled();
|
||||
|
||||
context.getNodeParameter.mockImplementation(defaultGetNodeParameter);
|
||||
});
|
||||
|
||||
it('accepts a schema-qualified table name', async () => {
|
||||
const node = createNode();
|
||||
context.getNodeParameter.mockImplementation(withTableName('app.n8n_vectors'));
|
||||
|
||||
await node.getVectorStoreClient(context, undefined, embeddings, 0);
|
||||
|
||||
const lastCall = initializeSpy.mock.calls.at(-1);
|
||||
if (!lastCall) throw new Error('initializeSpy should have been called');
|
||||
expect(lastCall[1]?.tableName).toBe('app.n8n_vectors');
|
||||
|
||||
context.getNodeParameter.mockImplementation(defaultGetNodeParameter);
|
||||
});
|
||||
|
||||
it('falls back to the default table name when the parameter resolves to empty', async () => {
|
||||
const node = createNode();
|
||||
context.getNodeParameter.mockImplementation(withTableName(''));
|
||||
|
||||
await node.getVectorStoreClient(context, undefined, embeddings, 0);
|
||||
|
||||
const lastCall = initializeSpy.mock.calls.at(-1);
|
||||
if (!lastCall) throw new Error('initializeSpy should have been called');
|
||||
expect(lastCall[1]?.tableName).toBe('n8n_vectors');
|
||||
|
||||
context.getNodeParameter.mockImplementation(defaultGetNodeParameter);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,9 +4,15 @@ import { createVectorStoreNode, metadataFilterField } from '@n8n/ai-utilities';
|
|||
import { DistanceStrategy, OracleVS, type OracleDBVSArgs } from '@oracle/langchain-oracledb';
|
||||
import type { OracleDBNodeCredentials } from 'n8n-nodes-base/dist/nodes/Oracle/Sql/helpers/interfaces';
|
||||
import { configureOracleDB } from 'n8n-nodes-base/dist/nodes/Oracle/Sql/transport';
|
||||
import type { IExecuteFunctions, INodeProperties, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import type { INode, IExecuteFunctions, INodeProperties, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type oracledb from 'oracledb';
|
||||
|
||||
import {
|
||||
findUnsupportedJsonPathFilterKey,
|
||||
isSafeQualifiedSqlIdentifier,
|
||||
} from '@utils/sqlIdentifier';
|
||||
|
||||
const sharedFields: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Table Name',
|
||||
|
|
@ -20,6 +26,40 @@ const sharedFields: INodeProperties[] = [
|
|||
|
||||
const DEFAULT_INITIALIZATION_TEXT = 'n8n vector store initialization text';
|
||||
|
||||
const DEFAULT_TABLE_NAME = 'n8n_vectors';
|
||||
|
||||
/** Reads the Table Name, falling back to the default when an expression resolves to empty. */
|
||||
function getTableName(
|
||||
context: IExecuteFunctions | ISupplyDataFunctions,
|
||||
itemIndex: number,
|
||||
): string {
|
||||
const tableName =
|
||||
(context.getNodeParameter('tableName', itemIndex, '', { extractValue: true }) as string) ||
|
||||
DEFAULT_TABLE_NAME;
|
||||
|
||||
if (!isSafeQualifiedSqlIdentifier(tableName)) {
|
||||
throw new NodeOperationError(context.getNode(), `Invalid table name \"${tableName}\"`, {
|
||||
itemIndex,
|
||||
description:
|
||||
'The table name must be a plain name, or a schema and name separated by a dot. Letters, digits and underscores only.',
|
||||
});
|
||||
}
|
||||
|
||||
return tableName;
|
||||
}
|
||||
|
||||
/** Rejects a metadata filter the Oracle store cannot resolve into a SQL/JSON path. */
|
||||
function assertSupportedFilter(node: INode, filter: unknown, itemIndex?: number): void {
|
||||
const unsupported = findUnsupportedJsonPathFilterKey(filter);
|
||||
if (unsupported === undefined) return;
|
||||
|
||||
throw new NodeOperationError(node, `Invalid metadata filter key \"${unsupported}\"`, {
|
||||
itemIndex,
|
||||
description:
|
||||
'A metadata filter name must start with a letter or underscore and contain only letters, digits and underscores.',
|
||||
});
|
||||
}
|
||||
|
||||
const NO_ROWS_FOUND_ERROR_MESSAGE = 'No rows found.';
|
||||
|
||||
const isNoRowsFoundError = (error: unknown): error is Error =>
|
||||
|
|
@ -146,11 +186,15 @@ const createLazyOraclePool = (
|
|||
* with any ad-hoc filter provided at runtime.
|
||||
*/
|
||||
class ExtendedOracleDBVectorStore extends OracleVS {
|
||||
n8nNode!: INode;
|
||||
|
||||
static async initialize(
|
||||
embeddings: EmbeddingsInterface,
|
||||
args: OracleDBVSArgs,
|
||||
args: OracleDBVSArgs & { n8nNode: INode },
|
||||
): Promise<ExtendedOracleDBVectorStore> {
|
||||
const oracleDBVectorStore = new this(embeddings, args);
|
||||
const { n8nNode, ...rest } = args;
|
||||
const oracleDBVectorStore = new this(embeddings, rest);
|
||||
oracleDBVectorStore.n8nNode = n8nNode;
|
||||
|
||||
await oracleDBVectorStore.initialize();
|
||||
return oracleDBVectorStore;
|
||||
|
|
@ -162,6 +206,9 @@ class ExtendedOracleDBVectorStore extends OracleVS {
|
|||
filter?: OracleVS['FilterType'],
|
||||
) {
|
||||
const mergedFilter = mergeFilters(this.filter, filter);
|
||||
// Checked here as well as at construction, because load and both
|
||||
// retrieve-as-tool modes supply the filter at search time.
|
||||
assertSupportedFilter(this.n8nNode, mergedFilter);
|
||||
|
||||
try {
|
||||
return await super.similaritySearchVectorWithScore(query, k, mergedFilter);
|
||||
|
|
@ -199,17 +246,17 @@ export class VectorStoreOracleDB extends createVectorStoreNode<ExtendedOracleDBV
|
|||
embeddings: EmbeddingsInterface,
|
||||
itemIndex: number,
|
||||
): Promise<ExtendedOracleDBVectorStore> {
|
||||
const tableName = context.getNodeParameter('tableName', itemIndex, '', {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const tableName = getTableName(context, itemIndex);
|
||||
assertSupportedFilter(context.getNode(), filter, itemIndex);
|
||||
const credentials = (await context.getCredentials('oracleDBApi')) as OracleDBNodeCredentials;
|
||||
const client = createLazyOraclePool(context, credentials);
|
||||
const query = DEFAULT_INITIALIZATION_TEXT;
|
||||
const config: OracleDBVSArgs = {
|
||||
const config: OracleDBVSArgs & { n8nNode: INode } = {
|
||||
client,
|
||||
tableName,
|
||||
query,
|
||||
filter: filter as ExtendedOracleDBVectorStore['FilterType'] | undefined,
|
||||
n8nNode: context.getNode(),
|
||||
};
|
||||
|
||||
config.distanceStrategy = context.getNodeParameter(
|
||||
|
|
@ -227,9 +274,7 @@ export class VectorStoreOracleDB extends createVectorStoreNode<ExtendedOracleDBV
|
|||
documents: Array<Document<Record<string, unknown>>>,
|
||||
itemIndex: number,
|
||||
): Promise<void> {
|
||||
const tableName = context.getNodeParameter('tableName', itemIndex, '', {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const tableName = getTableName(context, itemIndex);
|
||||
const credentials = (await context.getCredentials('oracleDBApi')) as OracleDBNodeCredentials;
|
||||
const client = createLazyOraclePool(context, credentials);
|
||||
const query = DEFAULT_INITIALIZATION_TEXT;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,12 @@ const mockResponse = {
|
|||
{
|
||||
name: 'models/gemini-3.1-flash-image-preview',
|
||||
},
|
||||
{
|
||||
name: 'models/gemini-nano-banana-2.1',
|
||||
},
|
||||
{
|
||||
name: 'models/gemini-3.1-flash-lite-image',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -95,6 +101,44 @@ describe('GoogleGemini -> listSearch', () => {
|
|||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should label, sort and pin models', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [
|
||||
{ name: 'models/gemini-zeta' },
|
||||
{ name: 'models/gemini-featured', displayName: 'Gemini Featured', priority: 10 },
|
||||
{ name: 'models/gemini-2.5-pro' },
|
||||
{ name: 'models/gemini-api-named', displayName: 'Gemini Api Named' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(mockExecuteFunctions);
|
||||
|
||||
// The API's displayName labels a model, else its id. A priority pins a model.
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{ name: 'Gemini Featured', value: 'models/gemini-featured' },
|
||||
{ name: 'Gemini Api Named', value: 'models/gemini-api-named' },
|
||||
{ name: 'models/gemini-2.5-pro', value: 'models/gemini-2.5-pro' },
|
||||
{ name: 'models/gemini-zeta', value: 'models/gemini-zeta' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should match the filter against the label and the id', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [
|
||||
{ name: 'models/gemini-zeta' },
|
||||
{ name: 'models/gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' },
|
||||
],
|
||||
});
|
||||
|
||||
const byName = await modelSearch.call(mockExecuteFunctions, '2.5 Pro');
|
||||
const byId = await modelSearch.call(mockExecuteFunctions, 'gemini-zeta');
|
||||
|
||||
expect(byName.results).toEqual([{ name: 'Gemini 2.5 Pro', value: 'models/gemini-2.5-pro' }]);
|
||||
expect(byId.results).toEqual([{ name: 'models/gemini-zeta', value: 'models/gemini-zeta' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('audioModelSearch', () => {
|
||||
|
|
@ -109,14 +153,14 @@ describe('GoogleGemini -> listSearch', () => {
|
|||
name: 'models/gemini-2.5-flash',
|
||||
value: 'models/gemini-2.5-flash',
|
||||
},
|
||||
{
|
||||
name: 'models/gemma-3-1b-it',
|
||||
value: 'models/gemma-3-1b-it',
|
||||
},
|
||||
{
|
||||
name: 'models/gemini-2.5-flash-preview-native-audio-dialog',
|
||||
value: 'models/gemini-2.5-flash-preview-native-audio-dialog',
|
||||
},
|
||||
{
|
||||
name: 'models/gemma-3-1b-it',
|
||||
value: 'models/gemma-3-1b-it',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
@ -134,10 +178,6 @@ describe('GoogleGemini -> listSearch', () => {
|
|||
name: 'models/gemini-2.0-flash-exp-image-generation',
|
||||
value: 'models/gemini-2.0-flash-exp-image-generation',
|
||||
},
|
||||
{
|
||||
name: 'models/imagen-3.0-generate-002',
|
||||
value: 'models/imagen-3.0-generate-002',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'models/gemini-2.5-flash-image (Nano Banana)',
|
||||
|
|
@ -153,6 +193,20 @@ describe('GoogleGemini -> listSearch', () => {
|
|||
name: 'models/gemini-3.1-flash-image-preview (Nano Banana 2)',
|
||||
value: 'models/gemini-3.1-flash-image-preview',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'models/gemini-3.1-flash-lite-image (Nano Banana 2 Lite)',
|
||||
value: 'models/gemini-3.1-flash-lite-image',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'models/gemini-nano-banana-2.1 (Nano Banana 2.1)',
|
||||
value: 'models/gemini-nano-banana-2.1',
|
||||
},
|
||||
{
|
||||
name: 'models/imagen-3.0-generate-002',
|
||||
value: 'models/imagen-3.0-generate-002',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
@ -173,6 +227,26 @@ describe('GoogleGemini -> listSearch', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('imageModelSearch with names from the API', () => {
|
||||
it('should match the filter against the id when the API names the model', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [{ name: 'models/gemini-2.5-flash-image', displayName: 'Gemini 2.5 Flash Image' }],
|
||||
});
|
||||
|
||||
const result = await imageGenerationModelSearch.call(
|
||||
mockExecuteFunctions,
|
||||
'gemini-2.5-flash-image',
|
||||
);
|
||||
|
||||
expect(result.results).toEqual([
|
||||
{
|
||||
name: 'Gemini 2.5 Flash Image (Nano Banana)',
|
||||
value: 'models/gemini-2.5-flash-image',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('imageEditModelSearch', () => {
|
||||
it('should return image edit models', async () => {
|
||||
apiRequestMock.mockResolvedValue(mockResponse);
|
||||
|
|
@ -196,6 +270,16 @@ describe('GoogleGemini -> listSearch', () => {
|
|||
name: 'models/gemini-3.1-flash-image-preview (Nano Banana 2)',
|
||||
value: 'models/gemini-3.1-flash-image-preview',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'models/gemini-3.1-flash-lite-image (Nano Banana 2 Lite)',
|
||||
value: 'models/gemini-3.1-flash-lite-image',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'models/gemini-nano-banana-2.1 (Nano Banana 2.1)',
|
||||
value: 'models/gemini-nano-banana-2.1',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,27 +1,45 @@
|
|||
import {
|
||||
getGoogleModelLabel,
|
||||
parseGoogleModels,
|
||||
pinPrioritizedModels,
|
||||
} from '@n8n/ai-utilities/model-discovery';
|
||||
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
/** Nano Banana ids do not contain the word "image". */
|
||||
function isImageModel(model: string): boolean {
|
||||
return model.includes('image') || model.includes('nano-banana');
|
||||
}
|
||||
|
||||
async function baseModelSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
modelFilter: (model: string) => boolean,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const response = (await apiRequest.call(this, 'GET', '/v1beta/models', {
|
||||
const response: unknown = await apiRequest.call(this, 'GET', '/v1beta/models', {
|
||||
qs: {
|
||||
pageSize: 1000,
|
||||
},
|
||||
})) as {
|
||||
models: Array<{ name: string }>;
|
||||
};
|
||||
});
|
||||
|
||||
let models = response.models.filter((model) => modelFilter(model.name));
|
||||
let entries = parseGoogleModels(response)
|
||||
.filter((model) => modelFilter(model.name))
|
||||
.map((model) => ({
|
||||
result: { name: getGoogleModelLabel(model.name, model.displayName), value: model.name },
|
||||
priority: model.priority,
|
||||
}));
|
||||
if (filter) {
|
||||
models = models.filter((model) => model.name.toLowerCase().includes(filter.toLowerCase()));
|
||||
const query = filter.toLowerCase();
|
||||
entries = entries.filter(
|
||||
({ result }) =>
|
||||
result.name.toLowerCase().includes(query) || result.value.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.result.name.localeCompare(b.result.name));
|
||||
return {
|
||||
results: models.map((model) => ({ name: model.name, value: model.name })),
|
||||
results: pinPrioritizedModels(entries, (entry) => entry.priority).map((entry) => entry.result),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -32,9 +50,9 @@ export async function modelSearch(
|
|||
return await baseModelSearch.call(
|
||||
this,
|
||||
(model) =>
|
||||
!isImageModel(model) &&
|
||||
!model.includes('embedding') &&
|
||||
!model.includes('aqa') &&
|
||||
!model.includes('image') &&
|
||||
!model.includes('vision') &&
|
||||
!model.includes('veo') &&
|
||||
!model.includes('audio') &&
|
||||
|
|
@ -50,9 +68,9 @@ export async function audioModelSearch(
|
|||
return await baseModelSearch.call(
|
||||
this,
|
||||
(model) =>
|
||||
!isImageModel(model) &&
|
||||
!model.includes('embedding') &&
|
||||
!model.includes('aqa') &&
|
||||
!model.includes('image') &&
|
||||
!model.includes('vision') &&
|
||||
!model.includes('veo') &&
|
||||
!model.includes('tts'), // we don't have a tts operation
|
||||
|
|
@ -64,17 +82,27 @@ export async function imageGenerationModelSearch(
|
|||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const rawResult = await baseModelSearch.call(this, (model) => model.includes('image'));
|
||||
const rawResult = await baseModelSearch.call(this, isImageModel);
|
||||
let results = rawResult.results.map((r) => {
|
||||
if (r.name.includes('gemini-3.1-flash-image')) {
|
||||
const modelId = String(r.value);
|
||||
if (modelId.includes('gemini-nano-banana-2.1')) {
|
||||
return { name: `${r.name} (Nano Banana 2.1)`, value: r.value };
|
||||
}
|
||||
|
||||
// The Lite id also contains "gemini-3.1-flash-image". Match it first.
|
||||
if (modelId.includes('gemini-3.1-flash-lite-image')) {
|
||||
return { name: `${r.name} (Nano Banana 2 Lite)`, value: r.value };
|
||||
}
|
||||
|
||||
if (modelId.includes('gemini-3.1-flash-image')) {
|
||||
return { name: `${r.name} (Nano Banana 2)`, value: r.value };
|
||||
}
|
||||
|
||||
if (r.name.includes('gemini-2.5-flash-image')) {
|
||||
if (modelId.includes('gemini-2.5-flash-image')) {
|
||||
return { name: `${r.name} (Nano Banana)`, value: r.value };
|
||||
}
|
||||
|
||||
if (r.name.includes('gemini-3-pro-image')) {
|
||||
if (modelId.includes('gemini-3-pro-image')) {
|
||||
return { name: `${r.name} (Nano Banana Pro)`, value: r.value };
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +111,11 @@ export async function imageGenerationModelSearch(
|
|||
|
||||
if (filter) {
|
||||
const filterLowerCase = filter.toLowerCase();
|
||||
results = results.filter((r) => r.name.toLowerCase().includes(filterLowerCase));
|
||||
results = results.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(filterLowerCase) ||
|
||||
String(r.value).toLowerCase().includes(filterLowerCase),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "2.43.0",
|
||||
"version": "2.43.5",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
escapeQualifiedSqlIdentifier,
|
||||
escapeSqlIdentifier,
|
||||
findUnsafeMetadataFilterKey,
|
||||
findUnsupportedJsonPathFilterKey,
|
||||
isSafeQualifiedSqlIdentifier,
|
||||
isSafeSqlIdentifier,
|
||||
} from './sqlIdentifier';
|
||||
|
|
@ -97,4 +98,56 @@ describe('sqlIdentifier', () => {
|
|||
expect(findUnsafeMetadataFilterKey({ ok: '1', 'a\\b': '2' })).toBe('a\\b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUnsupportedJsonPathFilterKey', () => {
|
||||
it.each([
|
||||
['a plain name', { userId: 'alice' }],
|
||||
['an underscored name', { user_id: 'alice' }],
|
||||
['a leading underscore', { _internal: true }],
|
||||
['mixed case', { UserID: 'alice' }],
|
||||
['trailing digits', { field2: 'x' }],
|
||||
['several names at once', { project: 'n8n', category: 'movies', author: 'a' }],
|
||||
['an array value', { author: ['a', 'b'] }],
|
||||
['an operator in value position', { rating: { $gte: 4.5 } }],
|
||||
['an operator the store does not know', { nested: { flag: true } }],
|
||||
['names nested under $and', { $and: [{ project: 'n8n' }, { language: 'en' }] }],
|
||||
['names nested under $or', { $or: [{ project: 'n8n' }] }],
|
||||
['no filter at all', undefined],
|
||||
])('accepts %s', (_name, filter) => {
|
||||
expect(findUnsupportedJsonPathFilterKey(filter)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a quote', "x'"],
|
||||
['a backslash', 'a\\b'],
|
||||
['a double quote', 'a"b'],
|
||||
['a path predicate', 'userId?(@ == "bob")'],
|
||||
['a dot', 'user.id'],
|
||||
['a hyphen', 'file-name'],
|
||||
['a space', 'my key'],
|
||||
['a dollar sign', 'a$b'],
|
||||
['a leading digit', '1field'],
|
||||
['an empty name', ''],
|
||||
])('rejects %s', (_name, key) => {
|
||||
expect(findUnsupportedJsonPathFilterKey({ [key]: 'value' })).toBe(key);
|
||||
});
|
||||
|
||||
it('reports a name nested under $and', () => {
|
||||
const filter = { $and: [{ project: 'n8n' }, { "x'": 'alice' }] };
|
||||
|
||||
expect(findUnsupportedJsonPathFilterKey(filter)).toBe("x'");
|
||||
});
|
||||
|
||||
it('reports a name nested under $or inside $and', () => {
|
||||
const filter = { $and: [{ $or: [{ ok: 1 }, { 'a.b': 2 }] }] };
|
||||
|
||||
expect(findUnsupportedJsonPathFilterKey(filter)).toBe('a.b');
|
||||
});
|
||||
|
||||
it('reports the first unsupported name when several are present', () => {
|
||||
const filter = { good: 1, "bad'one": 2, 'bad.two': 3 };
|
||||
|
||||
expect(findUnsupportedJsonPathFilterKey(filter)).toBe("bad'one");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import pg from 'pg';
|
||||
|
||||
/**
|
||||
|
|
@ -64,3 +65,39 @@ export function findUnsafeMetadataFilterKey(
|
|||
if (!filter) return undefined;
|
||||
return Object.keys(filter).find((key) => key.includes("'") || key.includes('\\'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a field name that Oracle accepts as an unquoted SQL/JSON path step.
|
||||
* Oracle documents the unquoted form as a leading letter followed by letters and
|
||||
* digits; underscore is allowed here because it is inert in both the path
|
||||
* grammar and the surrounding string literal, and is common in existing keys.
|
||||
*/
|
||||
const SAFE_JSON_PATH_FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
/**
|
||||
* Returns the first metadata-filter key that the Oracle store cannot resolve as
|
||||
* an unquoted SQL/JSON path step, or undefined when every key is usable.
|
||||
*
|
||||
* Walks `$and` and `$or` because the store expands them before building the
|
||||
* path, so a nested key reaches the same place a top-level one does.
|
||||
*/
|
||||
export function findUnsupportedJsonPathFilterKey(filter: unknown): string | undefined {
|
||||
if (!isRecord(filter)) return undefined;
|
||||
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (key === '$and' || key === '$or') {
|
||||
if (!Array.isArray(value)) continue;
|
||||
for (const condition of value) {
|
||||
const unsupported = findUnsupportedJsonPathFilterKey(condition);
|
||||
if (unsupported !== undefined) return unsupported;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only the field name reaches the path. Operator names in value position
|
||||
// are matched against a fixed set by the store, and values are bound.
|
||||
if (!SAFE_JSON_PATH_FIELD.test(key)) return key;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/scan-community-package",
|
||||
"version": "0.38.0",
|
||||
"version": "0.38.2",
|
||||
"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.12.0",
|
||||
"version": "1.12.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/task-runner",
|
||||
"version": "2.43.0",
|
||||
"version": "2.43.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"start": "node dist/start.js",
|
||||
|
|
|
|||
|
|
@ -193,6 +193,34 @@ describe('JsTaskRunner', () => {
|
|||
expect(outcome.result).toEqual([wrapIntoJson({ allZeros: true })]);
|
||||
});
|
||||
|
||||
it('should give small buffers their own backing memory', async () => {
|
||||
const outcome = await executeForAllItems({
|
||||
code: `
|
||||
const fromString = Buffer.from('.');
|
||||
const fromArray = Buffer.from([1, 2]);
|
||||
return [{ json: {
|
||||
fromString: fromString.buffer.byteLength,
|
||||
fromArray: fromArray.buffer.byteLength,
|
||||
} }]
|
||||
`,
|
||||
inputItems: [{ a: 1 }],
|
||||
});
|
||||
|
||||
expect(outcome.result).toEqual([wrapIntoJson({ fromString: 1, fromArray: 2 })]);
|
||||
});
|
||||
|
||||
it('should prevent guest code from re-enabling the buffer pool', async () => {
|
||||
const outcome = await executeForAllItems({
|
||||
code: `
|
||||
Buffer.poolSize = 8192;
|
||||
return [{ json: { poolSize: Buffer.poolSize } }]
|
||||
`,
|
||||
inputItems: [{ a: 1 }],
|
||||
});
|
||||
|
||||
expect(outcome.result).toEqual([wrapIntoJson({ poolSize: 0 })]);
|
||||
});
|
||||
|
||||
it('should support Buffer.from and Buffer.concat', async () => {
|
||||
const outcome = await executeForAllItems({
|
||||
code: `
|
||||
|
|
|
|||
|
|
@ -186,6 +186,9 @@ export class JsTaskRunner extends TaskRunner {
|
|||
const safeAlloc = Buffer.alloc.bind(Buffer);
|
||||
Buffer.allocUnsafe = safeAlloc;
|
||||
Buffer.allocUnsafeSlow = safeAlloc;
|
||||
// Disable the shared buffer pool so small buffers get their own backing memory.
|
||||
// Use defineProperty so the value is read-only: sandboxed code must not re-enable the pool.
|
||||
Object.defineProperty(Buffer, 'poolSize', { value: 0, writable: false, configurable: false });
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
freezeGlobals();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/telemetry",
|
||||
"version": "1.11.0",
|
||||
"version": "1.11.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/utils",
|
||||
"type": "module",
|
||||
"version": "1.50.0",
|
||||
"version": "1.50.1",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
@ -41,13 +41,15 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@n8n/constants": "workspace:*",
|
||||
"nanoid": "catalog:"
|
||||
"nanoid": "catalog:",
|
||||
"semver": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/oxlint-config": "workspace:*",
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@testing-library/jest-dom": "catalog:frontend",
|
||||
"@types/semver": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"oxlint-tsgolint": "catalog:",
|
||||
|
|
|
|||
58
packages/@n8n/utils/src/nodes-api-level.test.ts
Normal file
58
packages/@n8n/utils/src/nodes-api-level.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { formatNodesApiLevel, parseNodesApiLevel } from './nodes-api-level';
|
||||
|
||||
describe('parseNodesApiLevel', () => {
|
||||
it.each([
|
||||
['3.1', { major: 3, minor: 1 }],
|
||||
['3.0', { major: 3, minor: 0 }],
|
||||
['3', { major: 3, minor: 0 }],
|
||||
['3.10', { major: 3, minor: 10 }],
|
||||
])('parses the string %p as %p', (value, expected) => {
|
||||
expect(parseNodesApiLevel(value)).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1, { major: 1, minor: 0 }],
|
||||
[3, { major: 3, minor: 0 }],
|
||||
])('reads the integer %p as %p', (value, expected) => {
|
||||
expect(parseNodesApiLevel(value)).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
0,
|
||||
-1,
|
||||
2.5,
|
||||
3.1,
|
||||
'0.0',
|
||||
'0.1',
|
||||
'01.1',
|
||||
'03',
|
||||
'3.01',
|
||||
'3.',
|
||||
'.1',
|
||||
' 3.2 ',
|
||||
'3.1.2',
|
||||
'v3',
|
||||
'',
|
||||
null,
|
||||
NaN,
|
||||
Infinity,
|
||||
true,
|
||||
{},
|
||||
])('rejects %p', (value) => {
|
||||
expect(parseNodesApiLevel(value)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([2 ** 53, '9007199254740992', '1.9007199254740992'])(
|
||||
'rejects the level %p above the safe integer range',
|
||||
(value) => {
|
||||
expect(parseNodesApiLevel(value)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('formatNodesApiLevel', () => {
|
||||
it('writes major and minor', () => {
|
||||
expect(formatNodesApiLevel(parseNodesApiLevel('3.0')!)).toBe('3.0');
|
||||
expect(formatNodesApiLevel(parseNodesApiLevel('3.10')!)).toBe('3.10');
|
||||
});
|
||||
});
|
||||
19
packages/@n8n/utils/src/nodes-api-level.ts
Normal file
19
packages/@n8n/utils/src/nodes-api-level.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { coerce, type SemVer } from 'semver';
|
||||
|
||||
// A float is rejected, since an unquoted `3.10` in package.json is read as
|
||||
// the number `3.1`. The input must print back from its coerced version as
|
||||
// `<major>` or `<major>.<minor>`.
|
||||
export function parseNodesApiLevel(value: unknown): SemVer | null {
|
||||
const text = typeof value === 'number' && Number.isInteger(value) ? String(value) : value;
|
||||
if (typeof text !== 'string') return null;
|
||||
|
||||
const version = coerce(text);
|
||||
if (version === null || version.major < 1) return null;
|
||||
if (text !== `${version.major}` && text !== `${version.major}.${version.minor}`) return null;
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
export function formatNodesApiLevel({ major, minor }: SemVer): string {
|
||||
return `${major}.${minor}`;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/workflow-sdk",
|
||||
"version": "0.36.0",
|
||||
"version": "0.36.5",
|
||||
"description": "TypeScript SDK for programmatically creating n8n workflows",
|
||||
"exports": {
|
||||
".": {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user