mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
Compare commits
20 Commits
master
...
n8n@2.42.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb59153436 | ||
|
|
52eaf7752a | ||
|
|
b8b9712255 | ||
|
|
f6bf84f076 | ||
|
|
50f2e96e5b | ||
|
|
1ea168ee55 | ||
|
|
d09b8a11fb | ||
|
|
eb8d7c295a | ||
|
|
d80d4037d4 | ||
|
|
2a4aa504d3 | ||
|
|
c7841e551f | ||
|
|
e1d17800ae | ||
|
|
92ae66502d | ||
|
|
ed56d85f44 | ||
|
|
10140d19dd | ||
|
|
14b6bfd676 | ||
|
|
8e47a4fa8e | ||
|
|
e710744967 | ||
|
|
9d6c2ad42c | ||
|
|
b5a28aa8c8 |
|
|
@ -43,7 +43,8 @@ frontend, and extensible node-based workflow engine.
|
|||
comments keep the historical `n8nConnect` / `n8n credits` / AI Gateway names
|
||||
- **Shared utilities:** before you hand-roll a utility (`isRecord`, secret or
|
||||
PII redaction, JSON extraction from LLM output, Zod to JSON Schema, model-id
|
||||
parsing, …), you MUST check the shared packages for an existing
|
||||
parsing, AI client HTTP transport and response-size limiting, …), you MUST
|
||||
check the shared packages for an existing
|
||||
implementation and use it: `@n8n/utils` (generic helpers, redaction),
|
||||
`@n8n/ai-utilities` (AI- and LLM-specific helpers) and `n8n-workflow`
|
||||
(workflow graph and traversal). A new shared helper usually belongs in one of
|
||||
|
|
|
|||
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -1,3 +1,43 @@
|
|||
## [2.42.4](https://github.com/n8n-io/n8n/compare/n8n@2.42.3...n8n@2.42.4) (2026-10-07)
|
||||
|
||||
|
||||
## [2.42.3](https://github.com/n8n-io/n8n/compare/n8n@2.42.2...n8n@2.42.3) (2026-10-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **API:** Let global members grant variable scopes to API keys ([#40150](https://github.com/n8n-io/n8n/issues/40150)) ([d09b8a1](https://github.com/n8n-io/n8n/commit/d09b8a11fb097e47b04dbe04ed1d330f736fb507))
|
||||
* **core:** Keep the task runner running on an unhandled promise rejection ([#40091](https://github.com/n8n-io/n8n/issues/40091)) ([50f2e96](https://github.com/n8n-io/n8n/commit/50f2e96e5bb40f23962b372d78ba11af32a3bbe8))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **editor:** Show Gateway credits promotions on community nodes ([#40108](https://github.com/n8n-io/n8n/issues/40108)) ([1ea168e](https://github.com/n8n-io/n8n/commit/1ea168ee557355eefae7a3858a2c435ee83671f1))
|
||||
|
||||
|
||||
## [2.42.2](https://github.com/n8n-io/n8n/compare/n8n@2.42.1...n8n@2.42.2) (2026-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Stop waiting on Bull job.finished() for queued executions ([#39945](https://github.com/n8n-io/n8n/issues/39945)) ([c7841e5](https://github.com/n8n-io/n8n/commit/c7841e551ffb264ef602cbf57e7852b6829ec651))
|
||||
|
||||
|
||||
## [2.42.1](https://github.com/n8n-io/n8n/compare/n8n@2.42.0...n8n@2.42.1) (2026-09-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **API:** Emit valid schemas for untyped values in the Public API spec ([#39917](https://github.com/n8n-io/n8n/issues/39917)) ([ed56d85](https://github.com/n8n-io/n8n/commit/ed56d85f441c0eb287bc7c739f109d5c69a347a6))
|
||||
* **API:** Return an execution when its stored trace context is incomplete ([#39836](https://github.com/n8n-io/n8n/issues/39836)) ([e710744](https://github.com/n8n-io/n8n/commit/e710744967605e2d3ace8a8e1b8f2f85569026e1))
|
||||
* **editor:** Show remaining credits on Assistant banner for Cloud UBB ([#39911](https://github.com/n8n-io/n8n/issues/39911)) ([14b6bfd](https://github.com/n8n-io/n8n/commit/14b6bfd6763f270122e0c75423d05a031d605c69))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **editor:** Show n8n logo on canvas in canvas-only mode ([#39914](https://github.com/n8n-io/n8n/issues/39914)) ([10140d1](https://github.com/n8n-io/n8n/commit/10140d19ddf99995aa4506d82f96dd47a93ed083))
|
||||
|
||||
|
||||
# [2.42.0](https://github.com/n8n-io/n8n/compare/n8n@2.41.0...n8n@2.42.0) (2026-09-29)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.42.0",
|
||||
"version": "2.42.4",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
|
|
|
|||
|
|
@ -85,8 +85,24 @@ carrying a skill never rewrites the cached prefix. The `<active_skills>` block
|
|||
is a recovery path. `instructions()` moves a skill into it only when no
|
||||
visible, successfully resolved tool result can carry it: observational memory
|
||||
masked the result, the activating call failed, or no record of the activation
|
||||
exists. When observational memory masked the result, it already rewrote the
|
||||
prefix, so the move costs no extra cache invalidation.
|
||||
exists.
|
||||
|
||||
The block is not part of the base instructions. `buildSystemMessages` sends it
|
||||
as its own system message, after the base instructions and before the volatile
|
||||
message. The tools and base instructions in front of it stay byte-identical, so
|
||||
they stay cached when a skill moves into the block. The block reuses the
|
||||
instruction cache options, so it gets its own breakpoint and stays cached when
|
||||
memory compacts again later. `buildSkillInstructionCacheOptions` drops that
|
||||
breakpoint when it would leave no slot for the conversation breakpoint next to
|
||||
caller breakpoints on tools, history messages, and their content parts.
|
||||
Providers without split system messages get the block merged after the base
|
||||
instructions, as before.
|
||||
|
||||
Tools that a registered skill lists in `dependencies.tools` are active for the
|
||||
whole run. `AgentRuntime` moves them out of the deferred set at construction.
|
||||
If they loaded only when the skill activated, the tool list would change
|
||||
mid-conversation. Anthropic renders tools first, so that change invalidates the
|
||||
whole cached prefix.
|
||||
|
||||
Other prefix-stability hygiene, already true or verified: tool ordering is
|
||||
append-only (`getCurrentTools()` only ever appends), and none of the current
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/agents",
|
||||
"version": "0.27.0",
|
||||
"version": "0.27.4",
|
||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ProviderOptions } from '@ai-sdk/provider-utils';
|
||||
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
||||
import { sleep } from '@n8n/utils/sleep';
|
||||
import * as aiModule from 'ai';
|
||||
|
|
@ -7407,12 +7408,14 @@ describe('AgentRuntime — mid-run observation', () => {
|
|||
deferredTools?: BuiltTool[];
|
||||
checkpointStorage?: CheckpointStore;
|
||||
model?: ModelConfig;
|
||||
instructionProviderOptions?: ProviderOptions;
|
||||
},
|
||||
): AgentRuntime {
|
||||
return new AgentRuntime({
|
||||
name: 'mid-run-agent',
|
||||
model: extra?.model ?? 'openai/gpt-4o-mini',
|
||||
instructions: 'You are a test assistant.',
|
||||
instructionProviderOptions: extra?.instructionProviderOptions,
|
||||
memory,
|
||||
tools: extra?.tools ?? [makeStepTool()],
|
||||
deferredTools: extra?.deferredTools,
|
||||
|
|
@ -7530,8 +7533,46 @@ describe('AgentRuntime — mid-run observation', () => {
|
|||
expect(JSON.stringify(capturedCall(2).messages)).not.toContain('Wait for a real execution');
|
||||
});
|
||||
|
||||
it('keeps the base instructions unchanged when compaction moves a skill into the system prompt', async () => {
|
||||
const source = createRuntimeSkillSource([
|
||||
{
|
||||
id: 'builder',
|
||||
name: 'builder',
|
||||
description: 'Build workflows.',
|
||||
instructions: 'Wait for a real execution before extending the workflow.',
|
||||
},
|
||||
]);
|
||||
const cacheOptions = { anthropic: { cacheControl: { type: 'ephemeral' } } };
|
||||
const runtime = buildMidRunRuntime(new InMemoryMemory(), {
|
||||
skillSource: source,
|
||||
tools: createRuntimeSkillTools(source),
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
instructionProviderOptions: cacheOptions,
|
||||
});
|
||||
generateText
|
||||
.mockResolvedValueOnce(
|
||||
makeGenerateWithToolCall('load-builder', 'load_skill', { skillId: 'builder' }),
|
||||
)
|
||||
.mockResolvedValueOnce(makeGenerateSuccess('Please test the first workflow.'));
|
||||
|
||||
await runtime.generate('Build it', { persistence: PERSISTENCE });
|
||||
await runtime.dispose();
|
||||
|
||||
const before = capturedCall(0).instructions;
|
||||
const after = capturedCall(1).instructions;
|
||||
if (Array.isArray(before) || !Array.isArray(after)) throw new Error('Unexpected system shape');
|
||||
expect(after[0]).toEqual(before);
|
||||
expect(after[1]).toEqual({
|
||||
role: 'system',
|
||||
content: expect.stringContaining('Wait for a real execution'),
|
||||
providerOptions: cacheOptions,
|
||||
});
|
||||
expect(after[2].content).toContain('Mid-run observation captured.');
|
||||
expect(after[2]).not.toHaveProperty('providerOptions');
|
||||
});
|
||||
|
||||
it.each(['load_skill', 'inspect_node'])(
|
||||
'activates skill tool dependencies after %s and restores them on the next turn',
|
||||
'keeps skill tool dependencies in the tool list before and after %s',
|
||||
async (activationTool) => {
|
||||
const source = createRuntimeSkillSource([
|
||||
{
|
||||
|
|
@ -7569,8 +7610,9 @@ describe('AgentRuntime — mid-run observation', () => {
|
|||
await runtime.generate('Build it', { persistence: PERSISTENCE });
|
||||
await runtime.dispose();
|
||||
|
||||
expect(capturedCall(0).tools).not.toHaveProperty('catalog');
|
||||
expect(capturedCall(1).tools).toHaveProperty('catalog');
|
||||
// The tool list must not change when the skill activates, or the cached prompt is rewritten.
|
||||
expect(Object.keys(capturedCall(1).tools)).toEqual(Object.keys(capturedCall(0).tools));
|
||||
expect(capturedCall(0).tools).toHaveProperty('catalog');
|
||||
expect(capturedCall(1).tools).not.toHaveProperty('optional_tool');
|
||||
expect(flattenInstructions(capturedCall(1).instructions)).toContain(
|
||||
'Choose a model from the catalog.',
|
||||
|
|
|
|||
|
|
@ -360,6 +360,68 @@ describe('buildSystemMessages — volatile tool-instruction fragments', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('buildSystemMessages — recovered skill instructions', () => {
|
||||
const cacheOptions = { anthropic: { cacheControl: { type: 'ephemeral' as const } } };
|
||||
const skills = { content: '<active_skills>\nBuild one workflow.\n</active_skills>' };
|
||||
|
||||
it('places skills in their own cached message between the base and volatile messages', () => {
|
||||
const system = buildSystemMessages(
|
||||
'Base instructions',
|
||||
'<observations>\n* Some memory.\n</observations>',
|
||||
cacheOptions,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
{ ...skills, providerOptions: cacheOptions },
|
||||
);
|
||||
|
||||
expect(system).toEqual([
|
||||
{ role: 'system', content: 'Base instructions', providerOptions: cacheOptions },
|
||||
{ role: 'system', content: `\n\n${skills.content}`, providerOptions: cacheOptions },
|
||||
{ role: 'system', content: '\n\n<observations>\n* Some memory.\n</observations>' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves the skills message uncached when it has no cache options', () => {
|
||||
const system = buildSystemMessages(
|
||||
'Base instructions',
|
||||
undefined,
|
||||
cacheOptions,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
skills,
|
||||
);
|
||||
|
||||
expect(system).toEqual([
|
||||
{ role: 'system', content: 'Base instructions', providerOptions: cacheOptions },
|
||||
{ role: 'system', content: `\n\n${skills.content}` },
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges skills after the base instructions when split messages are unsupported', () => {
|
||||
const system = buildSystemMessages(
|
||||
'Base instructions',
|
||||
'<observations>\n* Some memory.\n</observations>',
|
||||
cacheOptions,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{ ...skills, providerOptions: cacheOptions },
|
||||
);
|
||||
|
||||
expect(system).toEqual({
|
||||
role: 'system',
|
||||
content: [
|
||||
'Base instructions',
|
||||
skills.content,
|
||||
'<observations>\n* Some memory.\n</observations>',
|
||||
].join('\n\n'),
|
||||
providerOptions: cacheOptions,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input / response messages use existing createdAt as a hint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { createRuntimeSkillSource } from '../../skills/registry';
|
||||
import type { AgentRuntimeConfig } from '../../types/runtime/agent-runtime';
|
||||
import type { BuiltTool } from '../../types/sdk/tool';
|
||||
import { activateSkillDependencyTools } from '../skills/skill-dependency-tools';
|
||||
|
||||
function makeTool(name: string): BuiltTool {
|
||||
return {
|
||||
name,
|
||||
description: `Tool ${name}`,
|
||||
inputSchema: z.object({}),
|
||||
handler: async () => await Promise.resolve({}),
|
||||
};
|
||||
}
|
||||
|
||||
const skillSource = createRuntimeSkillSource([
|
||||
{
|
||||
id: 'model-selection',
|
||||
name: 'model-selection',
|
||||
description: 'Choose a model.',
|
||||
instructions: 'Search the catalog.',
|
||||
dependencies: { tools: ['search_models', 'unregistered'] },
|
||||
},
|
||||
]);
|
||||
|
||||
function makeConfig(overrides: Partial<AgentRuntimeConfig>): AgentRuntimeConfig {
|
||||
return { name: 'agent', model: 'anthropic/claude-sonnet-4-5', instructions: '', ...overrides };
|
||||
}
|
||||
|
||||
describe('activateSkillDependencyTools', () => {
|
||||
it('moves deferred skill dependencies after the active tools', () => {
|
||||
const active = makeTool('active');
|
||||
const searchModels = makeTool('search_models');
|
||||
const other = makeTool('other');
|
||||
|
||||
const config = activateSkillDependencyTools(
|
||||
makeConfig({ skillSource, tools: [active], deferredTools: [searchModels, other] }),
|
||||
);
|
||||
|
||||
expect(config.tools).toEqual([active, searchModels]);
|
||||
expect(config.deferredTools).toEqual([other]);
|
||||
});
|
||||
|
||||
it('returns the same config when no deferred tool is a skill dependency', () => {
|
||||
const original = makeConfig({ skillSource, deferredTools: [makeTool('other')] });
|
||||
expect(activateSkillDependencyTools(original)).toBe(original);
|
||||
});
|
||||
|
||||
it('returns the same config without a skill source', () => {
|
||||
const original = makeConfig({ deferredTools: [makeTool('search_models')] });
|
||||
expect(activateSkillDependencyTools(original)).toBe(original);
|
||||
});
|
||||
});
|
||||
|
|
@ -58,6 +58,7 @@ import { AgentMessageList, type SerializedMessageList } from '../model/message-l
|
|||
import { createModelTokenCounter } from '../model/model-token-counter';
|
||||
import { getEffectiveAnthropicCacheTtl } from '../model/prompt-cache';
|
||||
import { ActiveSkills } from '../skills/active-skills';
|
||||
import { activateSkillDependencyTools } from '../skills/skill-dependency-tools';
|
||||
import { BackgroundTaskTracker } from '../state/background-task-tracker';
|
||||
import { AgentEventBus, type AgentAbortScope } from '../state/event-bus';
|
||||
import { generateRunId, RunStateManager, StaleResumeError } from '../state/run-state';
|
||||
|
|
@ -160,7 +161,8 @@ export class AgentRuntime {
|
|||
private toolExecutor: ToolCallExecutor;
|
||||
private activeSkills?: ActiveSkills;
|
||||
|
||||
constructor(config: AgentRuntimeConfig) {
|
||||
constructor(runtimeConfig: AgentRuntimeConfig) {
|
||||
const config = activateSkillDependencyTools(runtimeConfig);
|
||||
this.config = config;
|
||||
// Keep full tool results when the memory backend cannot persist active skill IDs.
|
||||
if (config.skillSource && (!config.memory || config.memory.skillState)) {
|
||||
|
|
@ -880,9 +882,6 @@ export class AgentRuntime {
|
|||
|
||||
private async prepareModelCall(ctx: PreparedLoopContext, iterationCount: number) {
|
||||
const { list, options, abortScope, staticContext } = ctx;
|
||||
for (const toolName of this.activeSkills?.toolDependencies() ?? []) {
|
||||
this.deferredToolManager?.load(toolName);
|
||||
}
|
||||
const tools = this.context.buildToolLoopContext(
|
||||
staticContext.aiProviderTools,
|
||||
options?.persistence,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
applyRuntimeCacheBreakpoints,
|
||||
buildCallPromptCacheOptions,
|
||||
buildInstructionPromptCacheOptions,
|
||||
buildSkillInstructionCacheOptions,
|
||||
mergeProviderOptions,
|
||||
} from '../model/prompt-cache';
|
||||
import {
|
||||
|
|
@ -165,14 +166,23 @@ export class RuntimeContextBuilder {
|
|||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n\n');
|
||||
const skillContent = activeSkills?.instructions();
|
||||
const { system, messages } = list.forLlm(
|
||||
// Skill content changes only on activation. Keep it cached when memory compacts.
|
||||
[tools.effectiveInstructions, activeSkills?.instructions()]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
tools.effectiveInstructions,
|
||||
instructionProviderOptions,
|
||||
combinedVolatileInstructions || undefined,
|
||||
supportsSplitSystemMessages(this.config.model),
|
||||
skillContent
|
||||
? {
|
||||
content: skillContent,
|
||||
cacheOptions: (messages) =>
|
||||
buildSkillInstructionCacheOptions(
|
||||
instructionProviderOptions,
|
||||
tools.aiTools,
|
||||
messages,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
// Cache breakpoints apply to this call only. Do not change stored messages or tools.
|
||||
const cached = applyRuntimeCacheBreakpoints({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
applyRuntimeCacheBreakpoints,
|
||||
buildCallPromptCacheOptions,
|
||||
buildInstructionPromptCacheOptions,
|
||||
buildSkillInstructionCacheOptions,
|
||||
getEffectiveAnthropicCacheTtl,
|
||||
mergeProviderOptions,
|
||||
} from '../prompt-cache';
|
||||
|
|
@ -26,6 +27,65 @@ function makeTool(providerOptions?: Record<string, unknown>): ToolSet[string] {
|
|||
return { inputSchema: {}, providerOptions } as ToolSet[string];
|
||||
}
|
||||
|
||||
describe('buildSkillInstructionCacheOptions', () => {
|
||||
it('reuses the instruction cache options', () => {
|
||||
expect(
|
||||
buildSkillInstructionCacheOptions(ANTHROPIC_CACHE_CONTROL, { a: makeTool() }, [
|
||||
makeUserMessage('hi'),
|
||||
]),
|
||||
).toEqual(ANTHROPIC_CACHE_CONTROL);
|
||||
});
|
||||
|
||||
it('returns undefined when the instructions carry no Anthropic breakpoint', () => {
|
||||
expect(buildSkillInstructionCacheOptions(undefined, {}, [])).toBeUndefined();
|
||||
expect(
|
||||
buildSkillInstructionCacheOptions({ openai: { promptCacheKey: 'key' } }, {}, []),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a slot for the conversation breakpoint next to caller tool breakpoints', () => {
|
||||
expect(
|
||||
buildSkillInstructionCacheOptions(
|
||||
ANTHROPIC_CACHE_CONTROL,
|
||||
{ a: makeTool(ANTHROPIC_CACHE_CONTROL) },
|
||||
[],
|
||||
),
|
||||
).toEqual(ANTHROPIC_CACHE_CONTROL);
|
||||
expect(
|
||||
buildSkillInstructionCacheOptions(
|
||||
ANTHROPIC_CACHE_CONTROL,
|
||||
{ a: makeTool(ANTHROPIC_CACHE_CONTROL), b: makeTool(ANTHROPIC_CACHE_CONTROL) },
|
||||
[],
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('counts caller breakpoints on messages and content parts', () => {
|
||||
const markedPart = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'b', providerOptions: ANTHROPIC_CACHE_CONTROL }],
|
||||
} as ModelMessage;
|
||||
expect(
|
||||
buildSkillInstructionCacheOptions(ANTHROPIC_CACHE_CONTROL, {}, [
|
||||
makeUserMessage('a', ANTHROPIC_CACHE_CONTROL),
|
||||
]),
|
||||
).toEqual(ANTHROPIC_CACHE_CONTROL);
|
||||
expect(
|
||||
buildSkillInstructionCacheOptions(ANTHROPIC_CACHE_CONTROL, {}, [
|
||||
makeUserMessage('a', ANTHROPIC_CACHE_CONTROL),
|
||||
markedPart,
|
||||
]),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
buildSkillInstructionCacheOptions(
|
||||
ANTHROPIC_CACHE_CONTROL,
|
||||
{ a: makeTool(ANTHROPIC_CACHE_CONTROL) },
|
||||
[makeUserMessage('a', ANTHROPIC_CACHE_CONTROL)],
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInstructionPromptCacheOptions — Anthropic', () => {
|
||||
it('defaults to a 1h cache breakpoint when enabled with no ttl override', () => {
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ export type LlmContext = {
|
|||
* cache breakpoint (and OpenAI's automatic prefix cache) on nearly every
|
||||
* call, for no future read. Providers that do not support multiple system
|
||||
* messages receive one merged message instead.
|
||||
*
|
||||
* `skillInstructions` (recovered active skills) sit between the two, in their
|
||||
* own message with their own cache options. They change only when a skill
|
||||
* moves out of the conversation, so the base instructions and the tools in
|
||||
* front of them stay cached when that happens.
|
||||
*/
|
||||
export function buildSystemMessages(
|
||||
baseInstructions: string,
|
||||
|
|
@ -47,35 +52,53 @@ export function buildSystemMessages(
|
|||
volatileInstructions?: string,
|
||||
mcpConnectionNote?: string,
|
||||
splitSystemMessages = true,
|
||||
skillInstructions?: { content: string; providerOptions?: ProviderOptions },
|
||||
): SystemModelMessage | SystemModelMessage[] {
|
||||
const cacheOptions = instructionProviderOptions
|
||||
? { providerOptions: instructionProviderOptions }
|
||||
: {};
|
||||
const skillContent = skillInstructions?.content.trim();
|
||||
const volatileSections = [
|
||||
volatileInstructions?.trim(),
|
||||
mcpConnectionNote?.trim(),
|
||||
observationLogMemory?.trim(),
|
||||
].filter((s): s is string => Boolean(s));
|
||||
|
||||
if (volatileSections.length === 0 || !splitSystemMessages) {
|
||||
if (!splitSystemMessages || (!skillContent && volatileSections.length === 0)) {
|
||||
return {
|
||||
role: 'system',
|
||||
content: [baseInstructions, ...volatileSections].join('\n\n'),
|
||||
content: [
|
||||
baseInstructions,
|
||||
...(skillContent ? [skillContent] : []),
|
||||
...volatileSections,
|
||||
].join('\n\n'),
|
||||
...cacheOptions,
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
const messages: SystemModelMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: baseInstructions,
|
||||
...cacheOptions,
|
||||
},
|
||||
{
|
||||
];
|
||||
if (skillContent) {
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: `\n\n${skillContent}`,
|
||||
...(skillInstructions?.providerOptions
|
||||
? { providerOptions: skillInstructions.providerOptions }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (volatileSections.length > 0) {
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: `\n\n${volatileSections.join('\n\n')}`,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
type MessageSource = 'history' | 'input' | 'response';
|
||||
|
|
@ -347,6 +370,11 @@ export class AgentMessageList {
|
|||
instructionProviderOptions?: ProviderOptions,
|
||||
volatileInstructions?: string,
|
||||
splitSystemMessages = true,
|
||||
skillInstructions?: {
|
||||
content: string;
|
||||
/** Resolved from the conversation messages, which can hold caller breakpoints. */
|
||||
cacheOptions?: (messages: ModelMessage[]) => ProviderOptions | undefined;
|
||||
},
|
||||
): LlmContext {
|
||||
const messages = toAiMessages(
|
||||
filterLlmMessages(stripOrphanedToolMessages(this.llmVisibleMessages())),
|
||||
|
|
@ -364,6 +392,10 @@ export class AgentMessageList {
|
|||
volatileInstructions,
|
||||
this.mcpConnectionNote,
|
||||
splitSystemMessages,
|
||||
skillInstructions && {
|
||||
content: skillInstructions.content,
|
||||
providerOptions: skillInstructions.cacheOptions?.(messages),
|
||||
},
|
||||
),
|
||||
messages,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -153,6 +153,24 @@ export function buildInstructionPromptCacheOptions(
|
|||
return buildAnthropicCacheControl(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache options for the recovered-skills system message. It reuses the
|
||||
* instruction breakpoint, so it is cached only when the instructions are.
|
||||
* Undefined when the extra breakpoint would leave no slot for the
|
||||
* conversation breakpoint next to the caller's tool and message breakpoints.
|
||||
*/
|
||||
export function buildSkillInstructionCacheOptions(
|
||||
instructionProviderOptions: ProviderOptions | undefined,
|
||||
aiTools: ToolSet,
|
||||
messages: ModelMessage[],
|
||||
): ProviderOptions | undefined {
|
||||
if (!hasAnthropicCacheControl(instructionProviderOptions)) return undefined;
|
||||
const callerBreakpoints = countAnthropicBreakpoints([], aiTools, messages);
|
||||
// Instructions + skills + conversation breakpoint.
|
||||
if (callerBreakpoints + 3 > MAX_ANTHROPIC_CACHE_BREAKPOINTS) return undefined;
|
||||
return instructionProviderOptions;
|
||||
}
|
||||
|
||||
/** OpenAI call-level cache options (routing key + retention). Undefined for non-OpenAI models or when disabled. */
|
||||
export function buildCallPromptCacheOptions(
|
||||
config: PromptCachingConfig | undefined,
|
||||
|
|
|
|||
|
|
@ -99,12 +99,6 @@ export class ActiveSkills {
|
|||
return skill;
|
||||
}
|
||||
|
||||
toolDependencies(): string[] {
|
||||
return [
|
||||
...new Set([...this.loaded.values()].flatMap((skill) => skill.dependencies?.tools ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The active skills the anchored path cannot deliver on this call, as one
|
||||
* block for the top-level system prompt. A skill enters the block only when
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import type { AgentRuntimeConfig } from '../../types/runtime/agent-runtime';
|
||||
|
||||
/**
|
||||
* Move the deferred tools that a registered skill depends on into the
|
||||
* always-active toolset. Loading them only when the skill activates would
|
||||
* change the tool list mid-conversation. Anthropic renders tools first, so
|
||||
* that change rewrites the whole cached prompt.
|
||||
*/
|
||||
export function activateSkillDependencyTools(config: AgentRuntimeConfig): AgentRuntimeConfig {
|
||||
const deferredTools = config.deferredTools ?? [];
|
||||
const dependencyNames = new Set(
|
||||
config.skillSource?.registry.skills.flatMap((skill) => skill.dependencies?.tools ?? []),
|
||||
);
|
||||
const dependencyTools = deferredTools.filter((tool) => dependencyNames.has(tool.name));
|
||||
if (dependencyTools.length === 0) return config;
|
||||
|
||||
return {
|
||||
...config,
|
||||
tools: [...(config.tools ?? []), ...dependencyTools],
|
||||
deferredTools: deferredTools.filter((tool) => !dependencyNames.has(tool.name)),
|
||||
};
|
||||
}
|
||||
|
|
@ -62,7 +62,7 @@ export interface RuntimeSkillMcpServerDependency {
|
|||
}
|
||||
|
||||
export interface RuntimeSkillDependenciesContract {
|
||||
/** Activate matching deferred tools while the skill is active. Tools must be registered on the agent. */
|
||||
/** Keep matching deferred tools active for the whole run, so activating the skill never changes the tool list. Tools must be registered on the agent. */
|
||||
tools?: string[];
|
||||
secrets?: string[];
|
||||
mcpServers?: RuntimeSkillMcpServerDependency[];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-node-sdk",
|
||||
"version": "0.32.0",
|
||||
"version": "0.32.3",
|
||||
"description": "SDK for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -23,3 +23,26 @@ model configuration, and model output.
|
|||
- Put generic helpers and generic secret or PII redaction in `@n8n/utils`.
|
||||
- Put workflow graph and traversal utilities in `n8n-workflow`.
|
||||
- Keep domain logic in the package that owns that domain.
|
||||
|
||||
## Bounding AI response sizes
|
||||
|
||||
Every outbound AI client must bound the size of the response it buffers. A
|
||||
hostile or misbehaving endpoint can stream an unbounded or highly compressed
|
||||
body and exhaust the heap. The `http-proxy-agent` subpath applies the cap; it
|
||||
counts decoded bytes, so a compressed body cannot expand past the limit. The
|
||||
default is 100 MB, set through `N8N_AI_MAX_RESPONSE_SIZE` (0 disables it).
|
||||
|
||||
The general rule: never hand the SDK the raw transport. Give it the wrapper
|
||||
that matches how the SDK takes one.
|
||||
|
||||
- The SDK accepts a `fetch`: pass `aiClientFetch`.
|
||||
- The SDK builds requests itself (Ollama, Mistral): call `proxyFetch`.
|
||||
- The SDK returns a Node stream (AWS Bedrock): build the client with
|
||||
`createBedrockRuntimeClient`, which installs a response-limited request
|
||||
handler for the stream.
|
||||
|
||||
Get `egressFilter` for `proxyFetch` from `this.helpers.getSecureEgressFilter()`.
|
||||
|
||||
Some SDK wrappers expose no such seam (Cohere, Google Gemini, Google Vertex,
|
||||
HuggingFace). Do not hand-roll a limiter for these. Flag the gap for a
|
||||
maintainer instead.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-utilities",
|
||||
"version": "0.35.0",
|
||||
"version": "0.35.3",
|
||||
"description": "Utilities for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ vi.mock('@langchain/openai', () => ({
|
|||
|
||||
vi.mock('src/utils/http-proxy-agent', () => ({
|
||||
getProxyAgent: vi.fn().mockReturnValue({ __agent: true }),
|
||||
aiClientFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('src/utils/n8n-llm-tracing', () => ({
|
||||
|
|
@ -41,6 +42,7 @@ vi.mock('src/adapters/langchain-chat-model', () => ({
|
|||
const ChatOpenAI = vi.mocked(openai.ChatOpenAI);
|
||||
const LangchainChatModelAdapter = vi.mocked(langchainChatModelAdapter.LangchainChatModelAdapter);
|
||||
const getProxyAgent = vi.mocked(httpProxyAgent.getProxyAgent);
|
||||
const aiClientFetch = vi.mocked(httpProxyAgent.aiClientFetch);
|
||||
const makeN8nLlmFailedAttemptHandler = vi.mocked(
|
||||
failedAttemptHandler.makeN8nLlmFailedAttemptHandler,
|
||||
);
|
||||
|
|
@ -82,6 +84,7 @@ describe('supplyModel', () => {
|
|||
model: 'gpt-4',
|
||||
apiKey: 'test-key',
|
||||
configuration: expect.objectContaining({
|
||||
fetch: aiClientFetch,
|
||||
baseURL: 'https://api.openai.com',
|
||||
}),
|
||||
onFailedAttempt: expect.any(Function),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,25 @@ import {
|
|||
type EgressFilter,
|
||||
} from 'src/utils/http-proxy-agent';
|
||||
|
||||
// The module reads `N8N_AI_MAX_RESPONSE_SIZE` at import time. Clear it before
|
||||
// the imports evaluate (vi.hoisted runs first) so the cap is the 100 MB
|
||||
// default here, whatever the runner shell has set. Capture the original value
|
||||
// and restore it after the file so the deletion does not leak to other test
|
||||
// files that share the worker.
|
||||
const originalMaxResponseSize = vi.hoisted(() => {
|
||||
const value = process.env.N8N_AI_MAX_RESPONSE_SIZE;
|
||||
delete process.env.N8N_AI_MAX_RESPONSE_SIZE;
|
||||
return value;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalMaxResponseSize === undefined) {
|
||||
delete process.env.N8N_AI_MAX_RESPONSE_SIZE;
|
||||
} else {
|
||||
process.env.N8N_AI_MAX_RESPONSE_SIZE = originalMaxResponseSize;
|
||||
}
|
||||
});
|
||||
|
||||
vi.mock('@n8n/backend-network/transport', () => ({
|
||||
buildDispatcher: vi.fn((proxy: unknown, ssrf: unknown, options: unknown) => ({
|
||||
type: 'Dispatcher',
|
||||
|
|
@ -39,6 +58,15 @@ const DEFAULT_BUILD_OPTIONS = {
|
|||
timeouts: { headersTimeout: 3600000, bodyTimeout: 3600000 },
|
||||
};
|
||||
|
||||
// proxyFetch forwards the AI response-size limit to dispatchedFetch as the 4th
|
||||
// argument. The env var is cleared above, so the cap is the 100 MB default;
|
||||
// asserting the exact value also catches a regression that neutralizes the
|
||||
// limit (0 / negative / Infinity, which the transport treats as disabled).
|
||||
const EXPECTED_RESPONSE_SIZE_LIMIT = expect.objectContaining({
|
||||
maxBytes: 100 * 1024 * 1024,
|
||||
createError: expect.any(Function),
|
||||
});
|
||||
|
||||
describe('getProxyAgent', () => {
|
||||
// Store original environment variables
|
||||
const originalEnv = { ...process.env };
|
||||
|
|
@ -336,6 +364,7 @@ describe('proxyFetch', () => {
|
|||
expect.objectContaining({ type: 'Dispatcher', proxy: false }),
|
||||
url,
|
||||
undefined,
|
||||
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -356,6 +385,7 @@ describe('proxyFetch', () => {
|
|||
expect.objectContaining({ ssrf: egressFilter }),
|
||||
'https://api.openai.com/v1',
|
||||
undefined,
|
||||
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -374,6 +404,7 @@ describe('proxyFetch', () => {
|
|||
expect.objectContaining({ type: 'Dispatcher' }),
|
||||
url,
|
||||
undefined,
|
||||
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -387,14 +418,24 @@ describe('proxyFetch', () => {
|
|||
|
||||
await proxyFetch({ input: url, init, egressFilter: passthroughFilter });
|
||||
|
||||
expect(mockDispatchedFetch).toHaveBeenCalledWith(expect.anything(), url, init);
|
||||
expect(mockDispatchedFetch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
url,
|
||||
init,
|
||||
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle URL objects', async () => {
|
||||
const url = new URL('https://api.openai.com/v1');
|
||||
await proxyFetch({ input: url, egressFilter: passthroughFilter });
|
||||
|
||||
expect(mockDispatchedFetch).toHaveBeenCalledWith(expect.anything(), url, undefined);
|
||||
expect(mockDispatchedFetch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
url,
|
||||
undefined,
|
||||
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Request objects', async () => {
|
||||
|
|
@ -414,6 +455,7 @@ describe('proxyFetch', () => {
|
|||
method: 'POST',
|
||||
headers: [['content-type', 'application/json']],
|
||||
}),
|
||||
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -429,6 +471,7 @@ describe('proxyFetch', () => {
|
|||
expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
|
||||
url,
|
||||
undefined,
|
||||
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -447,6 +490,7 @@ describe('proxyFetch', () => {
|
|||
expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
|
||||
url,
|
||||
init,
|
||||
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -461,6 +505,7 @@ describe('proxyFetch', () => {
|
|||
expect.objectContaining({ type: 'Dispatcher', proxy: false }),
|
||||
url,
|
||||
undefined,
|
||||
EXPECTED_RESPONSE_SIZE_LIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import { Readable } from 'node:stream';
|
||||
// `vi.resetModules` gives the reloaded module its own `OperationalError` class,
|
||||
// so its identity differs from a top-level import. Match on the class name,
|
||||
// which survives the reset.
|
||||
|
||||
// The response-size cap is read from the environment when the module loads, so
|
||||
// each test reloads the module with the value it needs. Global `fetch` is
|
||||
// mocked, so no request leaves the process and the proxy environment is
|
||||
// irrelevant here.
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
async function loadModule(maxResponseSize: string) {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('N8N_AI_MAX_RESPONSE_SIZE', maxResponseSize);
|
||||
return await import('../../utils/http-proxy-agent.js');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('aiClientFetch', () => {
|
||||
it('passes a response within the cap', async () => {
|
||||
global.fetch = vi.fn(async () => new Response(new Uint8Array(5)));
|
||||
const { aiClientFetch } = await loadModule('1000');
|
||||
|
||||
const response = await aiClientFetch('https://provider.test');
|
||||
|
||||
await expect(response.arrayBuffer()).resolves.toHaveProperty('byteLength', 5);
|
||||
});
|
||||
|
||||
it('aborts a response over the cap', async () => {
|
||||
global.fetch = vi.fn(async () => new Response(new Uint8Array(100)));
|
||||
const { aiClientFetch } = await loadModule('10');
|
||||
|
||||
await expect(
|
||||
aiClientFetch('https://provider.test').then(
|
||||
async (response: Response) => await response.text(),
|
||||
),
|
||||
).rejects.toThrow(/exceeded the maximum allowed size/);
|
||||
});
|
||||
|
||||
it('throws an OperationalError when the cap is exceeded', async () => {
|
||||
global.fetch = vi.fn(async () => new Response(new Uint8Array(100)));
|
||||
const { aiClientFetch } = await loadModule('10');
|
||||
|
||||
const error = await aiClientFetch('https://provider.test')
|
||||
.then(async (response: Response) => await response.text())
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).constructor.name).toBe('OperationalError');
|
||||
});
|
||||
|
||||
it('does not cap the response when the value is 0', async () => {
|
||||
global.fetch = vi.fn(async () => new Response(new Uint8Array(100)));
|
||||
const { aiClientFetch } = await loadModule('0');
|
||||
|
||||
const response = await aiClientFetch('https://provider.test');
|
||||
|
||||
await expect(response.arrayBuffer()).resolves.toHaveProperty('byteLength', 100);
|
||||
});
|
||||
|
||||
it('falls back to the default cap when the value is malformed', async () => {
|
||||
global.fetch = vi.fn(async () => new Response(new Uint8Array(100)));
|
||||
const { aiClientFetch } = await loadModule('not-a-number');
|
||||
|
||||
// The default cap is 100 MB, so a 100-byte response passes.
|
||||
const response = await aiClientFetch('https://provider.test');
|
||||
|
||||
await expect(response.arrayBuffer()).resolves.toHaveProperty('byteLength', 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('limitAiResponseStream', () => {
|
||||
async function totalBytes(stream: Readable): Promise<number> {
|
||||
let total = 0;
|
||||
for await (const chunk of stream) total += (chunk as Buffer).length;
|
||||
return total;
|
||||
}
|
||||
|
||||
it('passes a stream within the cap', async () => {
|
||||
const { limitAiResponseStream } = await loadModule('1000');
|
||||
|
||||
const limited = limitAiResponseStream(Readable.from([Buffer.alloc(5)]));
|
||||
|
||||
await expect(totalBytes(limited)).resolves.toBe(5);
|
||||
});
|
||||
|
||||
it('errors a stream over the cap with an OperationalError', async () => {
|
||||
const { limitAiResponseStream } = await loadModule('10');
|
||||
|
||||
// Feed the oversize payload as small chunks so the limiter must stop
|
||||
// mid-stream; assert it never forwards more than the cap before erroring.
|
||||
const chunks = Array.from({ length: 20 }, () => Buffer.alloc(5));
|
||||
const limited = limitAiResponseStream(Readable.from(chunks));
|
||||
let yielded = 0;
|
||||
let error: unknown;
|
||||
try {
|
||||
for await (const chunk of limited) yielded += (chunk as Buffer).length;
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(yielded).toBeLessThanOrEqual(10);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).constructor.name).toBe('OperationalError');
|
||||
});
|
||||
|
||||
it('returns the same stream when the value is 0', async () => {
|
||||
const { limitAiResponseStream } = await loadModule('0');
|
||||
const source = Readable.from([Buffer.alloc(100)]);
|
||||
|
||||
expect(limitAiResponseStream(source)).toBe(source);
|
||||
});
|
||||
});
|
||||
|
|
@ -53,6 +53,7 @@ export { encodingForModel, getEncoding } from './utils/tokenizer/tiktoken';
|
|||
export { makeN8nLlmFailedAttemptHandler } from './utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
|
||||
export {
|
||||
getProxyAgent,
|
||||
aiClientFetch,
|
||||
getNodeProxyAgent,
|
||||
proxyFetch,
|
||||
type AgentTimeoutOptions,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { BaseChatModel } from '../chat-model/base';
|
|||
import type { ChatModel } from '../types/chat-model';
|
||||
import type { OpenAIModelOptions } from '../types/openai';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
|
||||
import { getProxyAgent } from '../utils/http-proxy-agent';
|
||||
import { getProxyAgent, aiClientFetch } from '../utils/http-proxy-agent';
|
||||
import { N8nLlmTracing } from '../utils/n8n-llm-tracing';
|
||||
|
||||
export type OpenAiModel = OpenAIModelOptions & {
|
||||
|
|
@ -21,6 +21,7 @@ function isOpenAiModel(model: SupplyModelOptions): model is OpenAiModel {
|
|||
|
||||
function getOpenAiModel(ctx: ISupplyDataFunctions, model: OpenAiModel) {
|
||||
const clientConfiguration: ClientOptions = {
|
||||
fetch: aiClientFetch,
|
||||
baseURL: model.baseUrl,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,10 +23,14 @@ import {
|
|||
import {
|
||||
buildDispatcher,
|
||||
dispatchedFetch,
|
||||
limitResponseBody,
|
||||
type ResponseSizeLimit,
|
||||
type TransportSsrfPolicy,
|
||||
} from '@n8n/backend-network/transport';
|
||||
import type { AgentOptions } from 'node:https';
|
||||
import { pipeline, type Readable, Transform } from 'node:stream';
|
||||
import type { Dispatcher } from 'undici';
|
||||
import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Options for configuring HTTP agent timeouts.
|
||||
|
|
@ -45,6 +49,40 @@ export type EgressFilter = TransportSsrfPolicy;
|
|||
// Configurable via N8N_AI_TIMEOUT_MAX environment variable to support custom timeout requirements
|
||||
const DEFAULT_TIMEOUT = parseInt(process.env.N8N_AI_TIMEOUT_MAX ?? '3600000', 10);
|
||||
|
||||
// Bound the response body every AI client may buffer, so a hostile or
|
||||
// misbehaving endpoint cannot stream an unbounded body until the heap runs
|
||||
// out. The count is of decoded bytes, so a compressed body cannot expand past
|
||||
// the cap. Real completions are far below this 100 MB default.
|
||||
const DEFAULT_MAX_RESPONSE_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Resolve the cap from `N8N_AI_MAX_RESPONSE_SIZE` (in bytes). Set it to 0 to
|
||||
* disable the cap; a blank, malformed or negative value falls back to the
|
||||
* default.
|
||||
*/
|
||||
function resolveMaxResponseSize(): number {
|
||||
const configured = process.env.N8N_AI_MAX_RESPONSE_SIZE?.trim();
|
||||
if (!configured) return DEFAULT_MAX_RESPONSE_SIZE;
|
||||
|
||||
const parsed = Number(configured);
|
||||
const isValid = Number.isSafeInteger(parsed) && parsed >= 0;
|
||||
return isValid ? parsed : DEFAULT_MAX_RESPONSE_SIZE;
|
||||
}
|
||||
|
||||
const MAX_RESPONSE_SIZE = resolveMaxResponseSize();
|
||||
|
||||
function responseSizeError(maxBytes: number): OperationalError {
|
||||
return new OperationalError(
|
||||
`Response body exceeded the maximum allowed size of ${maxBytes} bytes`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The AI response-size limit, in the shape `@n8n/backend-network` consumes. */
|
||||
const responseSizeLimit: ResponseSizeLimit = {
|
||||
maxBytes: MAX_RESPONSE_SIZE,
|
||||
createError: responseSizeError,
|
||||
};
|
||||
|
||||
/**
|
||||
* Stand-in target used when the real target URL is unknown in advance (e.g. when providing a proxy agent to ChatAwsBedrock).
|
||||
* It only decides whether an env-proxied dispatcher is built at all: that dispatcher re-resolves
|
||||
|
|
@ -151,16 +189,32 @@ export async function proxyFetch({
|
|||
// fetch only recognizes its own Request class and stringifies any other, so
|
||||
// a Request (the Mistral SDK builds one with the global class) is passed
|
||||
// as url + init instead.
|
||||
if (!isRequest) return await dispatchedFetch(dispatcher, input, init);
|
||||
if (!isRequest) return await dispatchedFetch(dispatcher, input, init, responseSizeLimit);
|
||||
|
||||
return await dispatchedFetch(dispatcher, targetUrl, {
|
||||
method: input.method,
|
||||
headers: [...input.headers],
|
||||
body: input.body === null ? undefined : await input.arrayBuffer(),
|
||||
signal: input.signal,
|
||||
redirect: input.redirect,
|
||||
...init,
|
||||
});
|
||||
return await dispatchedFetch(
|
||||
dispatcher,
|
||||
targetUrl,
|
||||
{
|
||||
method: input.method,
|
||||
headers: [...input.headers],
|
||||
body: input.body === null ? undefined : await input.arrayBuffer(),
|
||||
signal: input.signal,
|
||||
redirect: input.redirect,
|
||||
...init,
|
||||
},
|
||||
responseSizeLimit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A drop-in for an SDK's `fetch` option that applies n8n's AI transport
|
||||
* policy. Today that policy bounds the decoded response body; wrap the SDK's
|
||||
* fetch through this instead of using the SDK default. It stays global `fetch`
|
||||
* (with the dispatcher passed through `fetchOptions`), so HTTP mocks that patch
|
||||
* the global undici still see the request.
|
||||
*/
|
||||
export async function aiClientFetch(...args: Parameters<typeof fetch>): Promise<Response> {
|
||||
return limitResponseBody(await fetch(...args), responseSizeLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -180,3 +234,27 @@ export function getNodeProxyAgent(targetUrl?: string, agentOptions?: AgentOption
|
|||
|
||||
return createHttpsProxyAgent(targetUrl ?? PROXY_FALLBACK_TARGET, proxyUrl, agentOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Node `Transform` that errors once the bytes it has passed through exceed
|
||||
* the cap. Its own function so it can be unit-tested in isolation. This is the
|
||||
* Node-stream counterpart of `@n8n/backend-network`'s web-stream limiter, for
|
||||
* SDKs (AWS Bedrock) that hand back a Node `Readable` instead of a fetch body.
|
||||
*/
|
||||
export function createResponseStreamLimit(maxBytes: number): Transform {
|
||||
let received = 0;
|
||||
return new Transform({
|
||||
transform(chunk: Buffer, _encoding, done) {
|
||||
received += chunk.length;
|
||||
done(received > maxBytes ? responseSizeError(maxBytes) : null, chunk);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Bound a response body that arrives as a Node `Readable`. */
|
||||
export function limitAiResponseStream(body: Readable): Readable {
|
||||
if (MAX_RESPONSE_SIZE === 0) return body;
|
||||
return pipeline(body, createResponseStreamLimit(MAX_RESPONSE_SIZE), () => {
|
||||
// The SDK reads errors off the returned stream; pipeline closes the source too.
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-workflow-builder",
|
||||
"version": "1.42.0",
|
||||
"version": "1.42.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'
|
|||
import type { MemorySaver } from '@langchain/langgraph';
|
||||
import { GraphRecursionError } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import type { INodeTypeDescription, IWorkflowBase } from 'n8n-workflow';
|
||||
import { OperationalError, UserError } from 'n8n-workflow';
|
||||
import type { Mock, MockedClass, MockedFunction } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
|
@ -52,6 +52,7 @@ import { MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
|
|||
import { ValidationError } from '@/errors';
|
||||
import type { PlanInterruptValue, PlanOutput } from '@/types/planning';
|
||||
import type { StreamOutput } from '@/types/streaming';
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
import { createStreamProcessor } from '@/utils/stream-processor';
|
||||
import {
|
||||
WorkflowBuilderAgent,
|
||||
|
|
@ -121,6 +122,74 @@ describe('WorkflowBuilderAgent', () => {
|
|||
agent = new WorkflowBuilderAgent(config);
|
||||
});
|
||||
|
||||
describe('workflow entry point', () => {
|
||||
// getDefaultWorkflowJSON is the only producer of the workflowJSON the graph is started
|
||||
// with, so it is the choke point where a stored workflow is made safe to key by node
|
||||
// name. Exercised directly: the call sites below it sit behind the LangGraph stream.
|
||||
function getDefaultWorkflowJSON(payload: ChatPayload): SimpleWorkflow {
|
||||
return (
|
||||
agent as unknown as {
|
||||
getDefaultWorkflowJSON: (p: ChatPayload) => SimpleWorkflow;
|
||||
}
|
||||
).getDefaultWorkflowJSON(payload);
|
||||
}
|
||||
|
||||
function payloadWith(currentWorkflow: unknown): ChatPayload {
|
||||
return {
|
||||
id: '1',
|
||||
message: 'hi',
|
||||
workflowContext: { currentWorkflow: currentWorkflow as IWorkflowBase },
|
||||
};
|
||||
}
|
||||
|
||||
it('should pass an ordinary workflow through unchanged', () => {
|
||||
const currentWorkflow = {
|
||||
name: 'w',
|
||||
nodes: [{ id: '1', name: 'Manual' }],
|
||||
connections: { Manual: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] } },
|
||||
};
|
||||
|
||||
const result = getDefaultWorkflowJSON(payloadWith(currentWorkflow));
|
||||
|
||||
expect(result).toBe(currentWorkflow);
|
||||
});
|
||||
|
||||
it('should return an empty workflow when no workflow is supplied', () => {
|
||||
expect(getDefaultWorkflowJSON({ id: '1', message: 'hi' })).toEqual({
|
||||
nodes: [],
|
||||
connections: {},
|
||||
name: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should drop a node whose name cannot be an object key before the graph starts', () => {
|
||||
const result = getDefaultWorkflowJSON(
|
||||
payloadWith({
|
||||
name: 'w',
|
||||
nodes: [
|
||||
{ id: '1', name: '__proto__' },
|
||||
{ id: '2', name: 'Keep' },
|
||||
],
|
||||
connections: {},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.nodes.map((n) => n.name)).toEqual(['Keep']);
|
||||
});
|
||||
|
||||
it('should drop a connection field that cannot be an object key before the graph starts', () => {
|
||||
const result = getDefaultWorkflowJSON(
|
||||
payloadWith(
|
||||
JSON.parse(
|
||||
'{"name":"w","nodes":[],"connections":{"A":{"main":[[{"node":["__proto__"],"type":"main","index":0}]]}}}',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.connections.A?.main?.[0]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat method', () => {
|
||||
let mockPayload: ChatPayload;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
IConnection,
|
||||
NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
|
||||
import { isUsableObjectKey, NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
ConnectionValidationResult,
|
||||
|
|
@ -137,6 +137,13 @@ export function createConnection(
|
|||
sourceOutputIndex: number = 0,
|
||||
targetInputIndex: number = 0,
|
||||
): IConnections {
|
||||
// Connection maps are keyed by node name and connection type. A key that names an
|
||||
// inherited object property resolves to a slot that is not the map's own, so refuse
|
||||
// it instead of writing through it.
|
||||
if (!isUsableObjectKey(sourceNodeName) || !isUsableObjectKey(connectionType)) {
|
||||
return connections;
|
||||
}
|
||||
|
||||
// Ensure source node exists in connections
|
||||
if (!connections[sourceNodeName]) {
|
||||
connections[sourceNodeName] = {};
|
||||
|
|
@ -194,6 +201,10 @@ export function removeConnection(
|
|||
sourceOutputIndex?: number,
|
||||
targetInputIndex?: number,
|
||||
): IConnections {
|
||||
if (!isUsableObjectKey(sourceNodeName) || !isUsableObjectKey(connectionType)) {
|
||||
return connections;
|
||||
}
|
||||
|
||||
if (!connections[sourceNodeName]?.[connectionType]) {
|
||||
return connections;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1001,4 +1001,74 @@ describe('connection.utils', () => {
|
|||
expect(NodeHelpers.nodeHasOutputType(returnExprNode, 'ai_tool')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserved connection keys', () => {
|
||||
const plantedKeys = ['main', 'ai_tool'];
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of plantedKeys) {
|
||||
delete (Object.prototype as Record<string, unknown>)[key];
|
||||
}
|
||||
});
|
||||
|
||||
it('createConnection should refuse a source name that is not a usable object key', () => {
|
||||
const connections: IConnections = {};
|
||||
|
||||
const result = createConnection(connections, '__proto__', 'Sink', NodeConnectionTypes.Main);
|
||||
|
||||
expect(({} as Record<string, unknown>).main).toBeUndefined();
|
||||
expect(Object.keys(result)).toEqual([]);
|
||||
});
|
||||
|
||||
it('createConnection should refuse a connection type that is not a usable object key', () => {
|
||||
const connections: IConnections = {};
|
||||
|
||||
const result = createConnection(
|
||||
connections,
|
||||
'Source',
|
||||
'Sink',
|
||||
'constructor' as NodeConnectionType,
|
||||
);
|
||||
|
||||
expect(Object.keys(result)).toEqual([]);
|
||||
});
|
||||
|
||||
it('createConnection should still create a connection for usable keys', () => {
|
||||
const connections: IConnections = {};
|
||||
|
||||
const result = createConnection(connections, 'Source', 'Sink', NodeConnectionTypes.Main);
|
||||
|
||||
expect(result.Source?.main?.[0]).toEqual([{ node: 'Sink', type: 'main', index: 0 }]);
|
||||
});
|
||||
|
||||
it('removeConnection should not write through a reserved source name to a shared slot', () => {
|
||||
// Start from a shared slot that already holds a connection-shaped value, so the
|
||||
// unguarded lookup finds something to read, rewrite and store back. One entry is
|
||||
// kept by the filter, which keeps the unguarded path away from its delete branch.
|
||||
const seeded = [[{ node: 'Other', type: 'ai_tool', index: 0 }]];
|
||||
(Object.prototype as Record<string, unknown>).ai_tool = seeded;
|
||||
|
||||
const connections: IConnections = {};
|
||||
|
||||
const result = removeConnection(connections, '__proto__', 'Sink', 'ai_tool');
|
||||
|
||||
// Identity, not deep equality: the unguarded path stores back a filtered copy
|
||||
// whose contents happen to match.
|
||||
expect(({} as Record<string, unknown>).ai_tool).toBe(seeded);
|
||||
expect(seeded).toEqual([[{ node: 'Other', type: 'ai_tool', index: 0 }]]);
|
||||
expect(Object.keys(result)).toEqual([]);
|
||||
});
|
||||
|
||||
it('removeConnection should refuse a connection type that resolves through the prototype', () => {
|
||||
const connections: IConnections = {
|
||||
Source: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] },
|
||||
};
|
||||
|
||||
// 'constructor' reads back a function rather than undefined, so the unguarded
|
||||
// path gets past its early return and then calls an array method on it.
|
||||
expect(() => removeConnection(connections, 'Source', 'Sink', 'constructor')).not.toThrow();
|
||||
|
||||
expect(Object.keys(connections.Source)).toEqual(['main']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { INode, IConnections, INodeTypes } from 'n8n-workflow';
|
||||
import { Workflow } from 'n8n-workflow';
|
||||
import { isUsableObjectKey, Workflow } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow, WorkflowOperation } from '../types/workflow';
|
||||
|
||||
|
|
@ -63,10 +63,14 @@ function applyRemoveNodeOperation(
|
|||
|
||||
// Copy connections, excluding those from/to removed nodes (using node names)
|
||||
for (const [sourceName, nodeConnections] of Object.entries(workflow.connections)) {
|
||||
if (!isUsableObjectKey(sourceName)) continue;
|
||||
|
||||
if (!nodeNamesToRemove.has(sourceName)) {
|
||||
cleanedConnections[sourceName] = {};
|
||||
|
||||
for (const [connectionType, outputs] of Object.entries(nodeConnections)) {
|
||||
if (!isUsableObjectKey(connectionType)) continue;
|
||||
|
||||
if (Array.isArray(outputs)) {
|
||||
cleanedConnections[sourceName][connectionType] = outputs.map((outputConnections) => {
|
||||
if (Array.isArray(outputConnections)) {
|
||||
|
|
@ -132,6 +136,36 @@ function applyUpdateNodeOperation(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the connection types that can be own keys of a plain object.
|
||||
*/
|
||||
function pickSafeConnectionTypes(nodeConnections: IConnections[string]): IConnections[string] {
|
||||
return Object.fromEntries(
|
||||
Object.entries(nodeConnections).filter(([connectionType]) => isUsableObjectKey(connectionType)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the connection entries whose node-name and connection-type keys can be
|
||||
* own keys of a plain object. Used where a whole connection map arrives from outside
|
||||
* and is stored as-is.
|
||||
*/
|
||||
function pickSafeConnections(connections: IConnections): IConnections {
|
||||
const safe: IConnections = {};
|
||||
|
||||
for (const [sourceName, nodeConnections] of Object.entries(connections)) {
|
||||
if (!isUsableObjectKey(sourceName)) continue;
|
||||
|
||||
const safeTypes = pickSafeConnectionTypes(nodeConnections);
|
||||
|
||||
if (Object.keys(safeTypes).length > 0) {
|
||||
safe[sourceName] = safeTypes;
|
||||
}
|
||||
}
|
||||
|
||||
return safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 'setConnections' operation - replace all connections
|
||||
*/
|
||||
|
|
@ -143,7 +177,7 @@ function applySetConnectionsOperation(
|
|||
|
||||
return {
|
||||
...workflow,
|
||||
connections: operation.connections,
|
||||
connections: pickSafeConnections(operation.connections),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -160,11 +194,17 @@ function applyMergeConnectionsOperation(
|
|||
|
||||
// Merge connections additively
|
||||
for (const [sourceId, nodeConnections] of Object.entries(operation.connections)) {
|
||||
// A node name that is not usable as an own key of the map cannot take part in a
|
||||
// connection, so drop the entry rather than resolve it against the prototype chain.
|
||||
if (!isUsableObjectKey(sourceId)) continue;
|
||||
|
||||
if (!connections[sourceId]) {
|
||||
connections[sourceId] = nodeConnections;
|
||||
connections[sourceId] = pickSafeConnectionTypes(nodeConnections);
|
||||
} else {
|
||||
// Merge connections for this source node
|
||||
for (const [connectionType, newOutputs] of Object.entries(nodeConnections)) {
|
||||
if (!isUsableObjectKey(connectionType)) continue;
|
||||
|
||||
if (!connections[sourceId][connectionType]) {
|
||||
connections[sourceId][connectionType] = newOutputs;
|
||||
} else {
|
||||
|
|
@ -221,6 +261,10 @@ function applyRemoveConnectionOperation(
|
|||
|
||||
const { sourceNode, targetNode, connectionType, sourceOutputIndex, targetInputIndex } = operation;
|
||||
|
||||
if (!isUsableObjectKey(sourceNode) || !isUsableObjectKey(connectionType)) {
|
||||
return workflow;
|
||||
}
|
||||
|
||||
const connections = { ...workflow.connections };
|
||||
|
||||
// Check if source node has connections
|
||||
|
|
|
|||
|
|
@ -1128,4 +1128,196 @@ describe('operations-processor', () => {
|
|||
expect(result.workflowOperations).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserved connection keys', () => {
|
||||
const plantedKeys = ['main', 'shell', 'ai_tool'];
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of plantedKeys) {
|
||||
delete (Object.prototype as Record<string, unknown>)[key];
|
||||
}
|
||||
});
|
||||
|
||||
it('should build a fixture whose reserved key is an own property', () => {
|
||||
// Guards the tests below: a plain object literal would set the prototype instead
|
||||
// of adding the key, which would make the cases vacuous.
|
||||
const connections = { ['__proto__']: { main: [[]] } } as unknown as IConnections;
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(connections, '__proto__')).toBe(true);
|
||||
});
|
||||
|
||||
describe('mergeConnections', () => {
|
||||
it('should ignore a source key that is not a usable object key', () => {
|
||||
const workflow = createWorkflow([createNode({ id: 'node1', name: 'Sink' })]);
|
||||
const operations: WorkflowOperation[] = [
|
||||
{
|
||||
type: 'mergeConnections',
|
||||
connections: {
|
||||
['__proto__']: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] },
|
||||
} as unknown as IConnections,
|
||||
},
|
||||
];
|
||||
|
||||
const result = applyOperations(workflow, operations);
|
||||
|
||||
expect(({} as Record<string, unknown>).main).toBeUndefined();
|
||||
expect(Object.keys(result.connections)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore a connection type that is not a usable object key', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [createNode({ id: 'node1', name: 'Source' })],
|
||||
connections: { Source: { main: [[]] } },
|
||||
};
|
||||
const operations: WorkflowOperation[] = [
|
||||
{
|
||||
type: 'mergeConnections',
|
||||
connections: {
|
||||
Source: { ['__proto__']: [[{ node: 'Sink', type: 'main', index: 0 }]] },
|
||||
} as unknown as IConnections,
|
||||
},
|
||||
];
|
||||
|
||||
const result = applyOperations(workflow, operations);
|
||||
|
||||
// Object.keys alone cannot see this: the unguarded write lands on the entry's
|
||||
// prototype, so assert the prototype itself is intact.
|
||||
expect(Object.getPrototypeOf(result.connections.Source!)).toBe(Object.prototype);
|
||||
expect(Object.keys(result.connections.Source!)).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('should still merge connections for usable keys', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [createNode({ id: 'node1', name: 'Source' })],
|
||||
connections: { Source: { main: [[{ node: 'A', type: 'main', index: 0 }]] } },
|
||||
};
|
||||
const operations: WorkflowOperation[] = [
|
||||
{
|
||||
type: 'mergeConnections',
|
||||
connections: {
|
||||
['__proto__']: { main: [[{ node: 'Ignored', type: 'main', index: 0 }]] },
|
||||
Source: { main: [[{ node: 'B', type: 'main', index: 0 }]] },
|
||||
} as unknown as IConnections,
|
||||
},
|
||||
];
|
||||
|
||||
const result = applyOperations(workflow, operations);
|
||||
|
||||
expect(result.connections.Source?.main?.[0]).toEqual([
|
||||
{ node: 'A', type: 'main', index: 0 },
|
||||
{ node: 'B', type: 'main', index: 0 },
|
||||
]);
|
||||
expect(result.connections).not.toHaveProperty('Ignored');
|
||||
});
|
||||
});
|
||||
|
||||
it('mergeConnections should filter types on a first-time source entry', () => {
|
||||
const workflow = createWorkflow([createNode({ id: 'node1', name: 'Source' })]);
|
||||
const operations: WorkflowOperation[] = [
|
||||
{
|
||||
type: 'mergeConnections',
|
||||
connections: JSON.parse(
|
||||
'{"Source":{"__proto__":[[]],"main":[[{"node":"Sink","type":"main","index":0}]]}}',
|
||||
) as IConnections,
|
||||
},
|
||||
];
|
||||
|
||||
const result = applyOperations(workflow, operations);
|
||||
|
||||
expect(Object.keys(result.connections.Source!)).toEqual(['main']);
|
||||
expect(Object.getPrototypeOf(result.connections.Source!)).toBe(Object.prototype);
|
||||
});
|
||||
|
||||
describe('removeNode', () => {
|
||||
it('should not reparent the rebuilt connections map', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [createNode({ id: 'node1', name: 'Keep' }), createNode({ id: 'node2' })],
|
||||
connections: JSON.parse(
|
||||
'{"__proto__":{"shell":[[{"node":"Keep","type":"main","index":0}]]},"Keep":{"main":[[]]}}',
|
||||
) as IConnections,
|
||||
};
|
||||
const operations: WorkflowOperation[] = [{ type: 'removeNode', nodeIds: ['node2'] }];
|
||||
|
||||
const result = applyOperations(workflow, operations);
|
||||
|
||||
expect(Object.getPrototypeOf(result.connections)).toBe(Object.prototype);
|
||||
expect(Object.keys(result.connections)).toEqual(['Keep']);
|
||||
expect(({} as Record<string, unknown>).shell).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeConnection', () => {
|
||||
it('should not write through a reserved source node to a shared slot', () => {
|
||||
// Start from a shared slot that already holds a connection-shaped value, so the
|
||||
// unguarded lookup finds something to rewrite and then delete.
|
||||
(Object.prototype as Record<string, unknown>).main = [
|
||||
[{ node: 'Sink', type: 'main', index: 0 }],
|
||||
];
|
||||
|
||||
const workflow = createWorkflow([createNode({ id: 'node1', name: 'Sink' })]);
|
||||
const operations: WorkflowOperation[] = [
|
||||
{
|
||||
type: 'removeConnection',
|
||||
sourceNode: '__proto__',
|
||||
targetNode: 'Sink',
|
||||
connectionType: 'main',
|
||||
sourceOutputIndex: 0,
|
||||
targetInputIndex: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const result = applyOperations(workflow, operations);
|
||||
|
||||
expect(({} as Record<string, unknown>).main).toEqual([
|
||||
[{ node: 'Sink', type: 'main', index: 0 }],
|
||||
]);
|
||||
expect(result.connections).toEqual({});
|
||||
});
|
||||
|
||||
it('should leave the workflow unchanged for a reserved connection type', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [createNode({ id: 'node1', name: 'Source' })],
|
||||
connections: { Source: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] } },
|
||||
};
|
||||
const operations: WorkflowOperation[] = [
|
||||
{
|
||||
type: 'removeConnection',
|
||||
sourceNode: 'Source',
|
||||
targetNode: 'Sink',
|
||||
connectionType: 'constructor',
|
||||
sourceOutputIndex: 0,
|
||||
targetInputIndex: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const result = applyOperations(workflow, operations);
|
||||
|
||||
expect(result.connections.Source?.main?.[0]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setConnections', () => {
|
||||
it('should keep only entries whose keys are usable object keys', () => {
|
||||
const workflow = createWorkflow([createNode({ id: 'node1', name: 'Source' })]);
|
||||
const operations: WorkflowOperation[] = [
|
||||
{
|
||||
type: 'setConnections',
|
||||
connections: JSON.parse(
|
||||
'{"__proto__":{"ai_tool":[[]]},"Source":{"main":[[{"node":"Sink","type":"main","index":0}]],"constructor":[[]]}}',
|
||||
) as IConnections,
|
||||
},
|
||||
];
|
||||
|
||||
const result = applyOperations(workflow, operations);
|
||||
|
||||
expect(Object.keys(result.connections)).toEqual(['Source']);
|
||||
expect(Object.keys(result.connections.Source!)).toEqual(['main']);
|
||||
expect(({} as Record<string, unknown>).ai_tool).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
import type { IConnection, IConnections, INode, NodeConnectionType } from 'n8n-workflow';
|
||||
import { mapConnectionsByDestination } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
import { sanitizeWorkflowForBuilder } from '../workflow-sanitization';
|
||||
|
||||
/**
|
||||
* A persisted workflow reaches the builder as parsed JSON, so a reserved key arrives as an
|
||||
* own property of the connections map. An object literal cannot express that (`__proto__`
|
||||
* in a literal sets the prototype instead), so build these fixtures with JSON.parse.
|
||||
*/
|
||||
function parseConnections(json: string): IConnections {
|
||||
return JSON.parse(json) as IConnections;
|
||||
}
|
||||
|
||||
function createNode(name: string): INode {
|
||||
return {
|
||||
id: `id-${name}`,
|
||||
name,
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkflow(nodes: INode[], connections: IConnections = {}): SimpleWorkflow {
|
||||
return { name: 'Test Workflow', nodes, connections };
|
||||
}
|
||||
|
||||
describe('sanitizeWorkflowForBuilder', () => {
|
||||
const reservedKeys = ['main', 'ai_languageModel', 'shell'];
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of reservedKeys) {
|
||||
delete (Object.prototype as Record<string, unknown>)[key];
|
||||
}
|
||||
});
|
||||
|
||||
it('should return the same workflow when every name and connection key is usable', () => {
|
||||
const workflow = createWorkflow([createNode('Manual'), createNode('Sink')], {
|
||||
Manual: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] },
|
||||
});
|
||||
|
||||
const result = sanitizeWorkflowForBuilder(workflow);
|
||||
|
||||
// Unchanged input is returned as-is, so the common case allocates nothing.
|
||||
expect(result).toBe(workflow);
|
||||
});
|
||||
|
||||
it('should drop a node whose name is not a usable object key', () => {
|
||||
const workflow = createWorkflow([createNode('__proto__'), createNode('Sink')]);
|
||||
|
||||
const result = sanitizeWorkflowForBuilder(workflow);
|
||||
|
||||
expect(result.nodes.map((n) => n.name)).toEqual(['Sink']);
|
||||
});
|
||||
|
||||
it('should drop connections that start from a node with an unusable name', () => {
|
||||
const workflow = createWorkflow(
|
||||
[createNode('__proto__'), createNode('Sink')],
|
||||
parseConnections('{"__proto__":{"main":[[{"node":"Sink","type":"main","index":0}]]}}'),
|
||||
);
|
||||
|
||||
const result = sanitizeWorkflowForBuilder(workflow);
|
||||
|
||||
expect(Object.keys(result.connections)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should drop connections that point at a node with an unusable name', () => {
|
||||
const workflow = createWorkflow([createNode('Manual'), createNode('__proto__')], {
|
||||
Manual: {
|
||||
main: [
|
||||
[
|
||||
{ node: '__proto__', type: 'main', index: 0 },
|
||||
{ node: 'Sink', type: 'main', index: 0 },
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = sanitizeWorkflowForBuilder(workflow);
|
||||
|
||||
expect(result.connections.Manual?.main?.[0]).toEqual([
|
||||
{ node: 'Sink', type: 'main', index: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should drop a connection type that is not a usable object key', () => {
|
||||
const workflow = createWorkflow(
|
||||
[createNode('Manual'), createNode('Sink')],
|
||||
parseConnections(
|
||||
'{"Manual":{"__proto__":[[{"node":"Sink","type":"main","index":0}]],"main":[[{"node":"Sink","type":"main","index":0}]]}}',
|
||||
),
|
||||
);
|
||||
|
||||
const result = sanitizeWorkflowForBuilder(workflow);
|
||||
|
||||
expect(Object.keys(result.connections.Manual!)).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('should leave the shared object prototype untouched for a reserved source key', () => {
|
||||
const workflow = createWorkflow(
|
||||
[createNode('Sink')],
|
||||
parseConnections('{"__proto__":{"shell":[[{"node":"Sink","type":"main","index":0}]]}}'),
|
||||
);
|
||||
|
||||
sanitizeWorkflowForBuilder(workflow);
|
||||
|
||||
expect(({} as Record<string, unknown>).shell).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should drop a connection whose target name has no node entry', () => {
|
||||
// A stored payload can name a target that is absent from `nodes`, so filtering by the
|
||||
// set of known unsafe node names is not enough on its own.
|
||||
const workflow = createWorkflow([createNode('Manual')], {
|
||||
Manual: {
|
||||
main: [
|
||||
[
|
||||
{ node: '__proto__', type: 'main', index: 0 },
|
||||
{ node: 'Sink', type: 'main', index: 0 },
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = sanitizeWorkflowForBuilder(workflow);
|
||||
|
||||
expect(result.connections.Manual?.main?.[0]).toEqual([
|
||||
{ node: 'Sink', type: 'main', index: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should drop a connection whose type field is not a usable object key', () => {
|
||||
// Traversal helpers key the inverted graph by this field as well as by `node`.
|
||||
const workflow = createWorkflow([createNode('Manual'), createNode('Sink')], {
|
||||
Manual: {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Sink', type: '__proto__' as NodeConnectionType, index: 0 },
|
||||
{ node: 'Sink', type: 'main', index: 0 },
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = sanitizeWorkflowForBuilder(workflow);
|
||||
|
||||
expect(result.connections.Manual?.main?.[0]).toEqual([
|
||||
{ node: 'Sink', type: 'main', index: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
// A request body is JSON, so these fields are only strings by convention. The key check
|
||||
// has to reject a non-string outright: bracket access would coerce it back to the very
|
||||
// name being guarded against ( obj[['__proto__']] writes to obj['__proto__'] ).
|
||||
const coercibleKeys: Array<[string, unknown]> = [
|
||||
['a string', '__proto__'],
|
||||
['an array', ['__proto__']],
|
||||
['a nested array', [['__proto__']]],
|
||||
['an object with toString', { toString: (): string => '__proto__' }],
|
||||
];
|
||||
|
||||
it.each(coercibleKeys)('should drop a connection whose target is %s', (_label, node) => {
|
||||
const workflow = createWorkflow([createNode('Manual')], {
|
||||
Manual: {
|
||||
main: [[{ node, type: 'main', index: 0 } as unknown as IConnection]],
|
||||
},
|
||||
});
|
||||
|
||||
const result = sanitizeWorkflowForBuilder(workflow);
|
||||
|
||||
expect(result.connections.Manual?.main?.[0]).toEqual([]);
|
||||
expect(({} as Record<string, unknown>).main).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(coercibleKeys)('should drop a node whose name is %s', (_label, name) => {
|
||||
const workflow = createWorkflow([
|
||||
{ ...createNode('placeholder'), name } as unknown as INode,
|
||||
createNode('Keep'),
|
||||
]);
|
||||
|
||||
const result = sanitizeWorkflowForBuilder(workflow);
|
||||
|
||||
expect(result.nodes.map((n) => n.name)).toEqual(['Keep']);
|
||||
});
|
||||
|
||||
it('should leave the inverted graph intact for a non-string target', () => {
|
||||
// End to end on the choke-point property: whatever the sanitizer returns must be
|
||||
// safe to invert, which is where a surviving field would be used as a key.
|
||||
const workflow = JSON.parse(
|
||||
'{"name":"w","nodes":[],"connections":{"A":{"main":[[{"node":["__proto__"],"type":"main","index":0}]]}}}',
|
||||
) as SimpleWorkflow;
|
||||
|
||||
const result = sanitizeWorkflowForBuilder(workflow);
|
||||
|
||||
expect(result.connections.A?.main?.[0]).toEqual([]);
|
||||
expect(Object.getPrototypeOf(mapConnectionsByDestination(result.connections))).toBe(
|
||||
Object.prototype,
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep the workflow name and unrelated nodes', () => {
|
||||
const workflow = createWorkflow([createNode('__proto__'), createNode('Keep')], {
|
||||
Keep: { main: [[]] },
|
||||
});
|
||||
|
||||
const result = sanitizeWorkflowForBuilder(workflow);
|
||||
|
||||
expect(result.name).toBe('Test Workflow');
|
||||
expect(result.nodes.map((n) => n.name)).toEqual(['Keep']);
|
||||
expect(result.connections.Keep).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import type { IConnection, IConnections } from 'n8n-workflow';
|
||||
import { isUsableObjectKey } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow } from '../types/workflow';
|
||||
|
||||
/**
|
||||
* A connection can only be kept if both of its node-name and connection-type fields can be
|
||||
* own keys of an object. Traversal helpers invert the graph and key the result by these two
|
||||
* fields, so an unusable value there is as bad as an unusable key in the stored map — and a
|
||||
* stored payload can name a target that has no node entry at all.
|
||||
*/
|
||||
function isUsableConnection(connection: IConnection): boolean {
|
||||
return isUsableObjectKey(connection.node) && isUsableObjectKey(connection.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove nodes whose name cannot be an own key of a plain object, together with every
|
||||
* connection that references them or that is itself keyed by such a name.
|
||||
*
|
||||
* The builder keys its connection maps by node name. A name such as `__proto__` resolves
|
||||
* to an inherited slot instead of an own key, so a node with that name can never take part
|
||||
* in a valid graph. The write paths guard their own keys; this runs once when a stored
|
||||
* workflow enters builder state, so no later step has to reason about the name at all.
|
||||
*/
|
||||
export function sanitizeWorkflowForBuilder(workflow: SimpleWorkflow): SimpleWorkflow {
|
||||
const nodes = workflow.nodes ?? [];
|
||||
|
||||
// Set when anything is dropped, so an untouched workflow is returned as-is.
|
||||
let changed = false;
|
||||
|
||||
const keptNodes = nodes.filter((node) => {
|
||||
if (isUsableObjectKey(node.name)) return true;
|
||||
changed = true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const connections: IConnections = {};
|
||||
|
||||
for (const [sourceName, nodeConnections] of Object.entries(workflow.connections ?? {})) {
|
||||
if (!isUsableObjectKey(sourceName)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const keptTypes: IConnections[string] = {};
|
||||
|
||||
for (const [connectionType, outputs] of Object.entries(nodeConnections)) {
|
||||
if (!isUsableObjectKey(connectionType) || !Array.isArray(outputs)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
keptTypes[connectionType] = outputs.map((outputConnections) => {
|
||||
if (!Array.isArray(outputConnections)) return outputConnections;
|
||||
|
||||
const kept = outputConnections.filter(isUsableConnection);
|
||||
if (kept.length !== outputConnections.length) changed = true;
|
||||
return kept;
|
||||
});
|
||||
}
|
||||
|
||||
connections[sourceName] = keptTypes;
|
||||
}
|
||||
|
||||
if (!changed) return workflow;
|
||||
|
||||
return {
|
||||
...workflow,
|
||||
nodes: keptNodes,
|
||||
connections,
|
||||
};
|
||||
}
|
||||
|
|
@ -602,4 +602,120 @@ describe('autoFixConnections', () => {
|
|||
expect(result.fixed).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserved node names', () => {
|
||||
afterEach(() => {
|
||||
delete (Object.prototype as Record<string, unknown>).ai_languageModel;
|
||||
});
|
||||
|
||||
it('should not connect a sub-node whose name is not a usable object key', () => {
|
||||
// Auto-fix keys the connection map by source node name, so a name that cannot be
|
||||
// an own key of that map must not produce a connection.
|
||||
const workflow = createWorkflow([
|
||||
createNode({ name: 'AI Agent', type: '@n8n/n8n-nodes-langchain.agent' }),
|
||||
createNode({ name: '__proto__', type: '@n8n/n8n-nodes-langchain.lmChatOpenAi' }),
|
||||
]);
|
||||
|
||||
const violations: ProgrammaticViolation[] = [
|
||||
{
|
||||
name: 'node-missing-required-input',
|
||||
type: 'critical',
|
||||
description:
|
||||
'Node AI Agent (@n8n/n8n-nodes-langchain.agent) is missing required input of type ai_languageModel',
|
||||
pointsDeducted: 50,
|
||||
metadata: {
|
||||
nodeName: 'AI Agent',
|
||||
nodeType: '@n8n/n8n-nodes-langchain.agent',
|
||||
missingType: 'ai_languageModel',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sub-node-not-connected',
|
||||
type: 'critical',
|
||||
description:
|
||||
'Sub-node __proto__ (@n8n/n8n-nodes-langchain.lmChatOpenAi) provides ai_languageModel but is not connected to a root node.',
|
||||
pointsDeducted: 50,
|
||||
metadata: {
|
||||
nodeName: '__proto__',
|
||||
nodeType: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
outputType: 'ai_languageModel',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = autoFixConnections(workflow, mockNodeTypes, violations);
|
||||
|
||||
expect(({} as Record<string, unknown>).ai_languageModel).toBeUndefined();
|
||||
expect(Object.keys(result.updatedConnections)).toEqual([]);
|
||||
// createConnection refuses the key and returns the map unchanged, so the result must
|
||||
// not report a fix that was never applied either.
|
||||
expect(result.fixed).toEqual([]);
|
||||
expect(result.unfixable.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The name only has to be a string by convention, and bracket access would coerce a
|
||||
// non-string back to the name being guarded against.
|
||||
it.each([
|
||||
['a string', '__proto__'],
|
||||
['an array', ['__proto__']],
|
||||
['an object with toString', { toString: (): string => '__proto__' }],
|
||||
])('should not connect a sub-node whose name is %s', (_label, subNodeName) => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ name: 'AI Agent', type: '@n8n/n8n-nodes-langchain.agent' }),
|
||||
{
|
||||
...createNode({
|
||||
name: 'placeholder',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
}),
|
||||
name: subNodeName as string,
|
||||
},
|
||||
]);
|
||||
|
||||
const violations: ProgrammaticViolation[] = [
|
||||
{
|
||||
name: 'node-missing-required-input',
|
||||
type: 'critical',
|
||||
description: 'Node AI Agent is missing required input of type ai_languageModel',
|
||||
pointsDeducted: 50,
|
||||
metadata: {
|
||||
nodeName: 'AI Agent',
|
||||
nodeType: '@n8n/n8n-nodes-langchain.agent',
|
||||
missingType: 'ai_languageModel',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = autoFixConnections(workflow, mockNodeTypes, violations);
|
||||
|
||||
expect(({} as Record<string, unknown>).ai_languageModel).toBeUndefined();
|
||||
expect(Object.keys(result.updatedConnections)).toEqual([]);
|
||||
expect(result.fixed).toEqual([]);
|
||||
});
|
||||
|
||||
it('should still connect a sub-node with a usable name', () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ name: 'AI Agent', type: '@n8n/n8n-nodes-langchain.agent' }),
|
||||
createNode({ name: 'OpenAI Model', type: '@n8n/n8n-nodes-langchain.lmChatOpenAi' }),
|
||||
]);
|
||||
|
||||
const violations: ProgrammaticViolation[] = [
|
||||
{
|
||||
name: 'node-missing-required-input',
|
||||
type: 'critical',
|
||||
description:
|
||||
'Node AI Agent (@n8n/n8n-nodes-langchain.agent) is missing required input of type ai_languageModel',
|
||||
pointsDeducted: 50,
|
||||
metadata: {
|
||||
nodeName: 'AI Agent',
|
||||
nodeType: '@n8n/n8n-nodes-langchain.agent',
|
||||
missingType: 'ai_languageModel',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = autoFixConnections(workflow, mockNodeTypes, violations);
|
||||
|
||||
expect(result.updatedConnections['OpenAI Model']?.ai_languageModel).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { INodeTypeDescription, NodeConnectionType, IConnections } from 'n8n-workflow';
|
||||
import { isUsableObjectKey } from 'n8n-workflow';
|
||||
|
||||
import { createConnection } from '@/tools/utils/connection.utils';
|
||||
import type { SimpleWorkflow } from '@/types';
|
||||
|
|
@ -141,6 +142,11 @@ function findCandidateSubNodes(
|
|||
const candidates: string[] = [];
|
||||
|
||||
for (const [subNodeName, outputs] of ctx.subNodeOutputs) {
|
||||
// The source name becomes a key of the connection map, so a name that cannot be an
|
||||
// own key is not a candidate. Dropping it here lets the caller report honestly
|
||||
// instead of counting a connection that the write path would refuse.
|
||||
if (!isUsableObjectKey(subNodeName)) continue;
|
||||
|
||||
if (outputs.has(missingType)) {
|
||||
// Check if this sub-node is already connected to THIS specific target
|
||||
const alreadyConnectedToTarget = connectionExistsBetweenNodes(
|
||||
|
|
@ -174,6 +180,17 @@ function processMissingInputViolation(ctx: AutoFixContext, violation: Programmat
|
|||
// Only auto-fix AI connections (ai_*)
|
||||
if (!missingType.startsWith('ai_')) return;
|
||||
|
||||
if (!isUsableObjectKey(nodeName)) {
|
||||
ctx.result.unfixable.push({
|
||||
nodeName: String(nodeName),
|
||||
missingInputType: missingType as NodeConnectionType,
|
||||
// String(): the check rejects a value that need not be a string at all.
|
||||
reason: `Node name "${String(nodeName)}" cannot be used in a connection`,
|
||||
candidateCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = findCandidateSubNodes(ctx, nodeName, missingType as NodeConnectionType);
|
||||
|
||||
if (candidates.length === 1) {
|
||||
|
|
@ -222,6 +239,9 @@ function findTargetCandidates(
|
|||
|
||||
for (const node of ctx.workflow.nodes) {
|
||||
if (node.disabled || node.name === subNodeName) continue;
|
||||
// The target name is stored as a connection's `node`, which traversal helpers use
|
||||
// as a key when they invert the graph.
|
||||
if (!isUsableObjectKey(node.name)) continue;
|
||||
|
||||
const nodeType = getNodeTypeForNode(node, ctx.nodeTypeMap, ctx.nodeTypesByName);
|
||||
if (!nodeType) continue;
|
||||
|
|
@ -269,6 +289,16 @@ function processDisconnectedSubNodeViolation(
|
|||
|
||||
if (ctx.connectedSubNodes.has(subNodeName)) return;
|
||||
|
||||
if (!isUsableObjectKey(subNodeName)) {
|
||||
ctx.result.unfixable.push({
|
||||
nodeName: String(subNodeName),
|
||||
missingInputType: outputType as NodeConnectionType,
|
||||
reason: `Node name "${String(subNodeName)}" cannot be used in a connection`,
|
||||
candidateCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nowConnected = findExistingOutgoingConnection(
|
||||
ctx.result.updatedConnections,
|
||||
subNodeName,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import type { HITLInterruptValue, PlanOutput } from './types/planning';
|
|||
import type { SimpleWorkflow } from './types/workflow';
|
||||
import { sanitizeLlmErrorMessage } from './utils/error-sanitizer';
|
||||
import { createStreamProcessor, type StreamEvent } from './utils/stream-processor';
|
||||
import { sanitizeWorkflowForBuilder } from './utils/workflow-sanitization';
|
||||
import type { WorkflowState } from './workflow-state';
|
||||
|
||||
const PROMPT_IS_TOO_LARGE_ERROR =
|
||||
|
|
@ -202,12 +203,13 @@ export class WorkflowBuilderAgent {
|
|||
}
|
||||
|
||||
private getDefaultWorkflowJSON(payload: ChatPayload): SimpleWorkflow {
|
||||
return (
|
||||
(payload.workflowContext?.currentWorkflow as SimpleWorkflow) ?? {
|
||||
nodes: [],
|
||||
connections: {},
|
||||
}
|
||||
);
|
||||
const currentWorkflow = payload.workflowContext?.currentWorkflow as SimpleWorkflow | undefined;
|
||||
|
||||
if (!currentWorkflow) {
|
||||
return { nodes: [], connections: {}, name: '' };
|
||||
}
|
||||
|
||||
return sanitizeWorkflowForBuilder(currentWorkflow);
|
||||
}
|
||||
|
||||
async *chat(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.42.0",
|
||||
"version": "1.42.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
/** Temporary Gateway credits offer for a community node. Dates are ISO datetimes. */
|
||||
export type GatewayCreditsPromotion = {
|
||||
text: string;
|
||||
startsAt?: string | null;
|
||||
endsAt?: string | null;
|
||||
};
|
||||
|
||||
export type CommunityNodeType = {
|
||||
id: number;
|
||||
authorGithubUrl: string;
|
||||
|
|
@ -19,4 +26,5 @@ export type CommunityNodeType = {
|
|||
nodeDescription: INodeTypeDescription;
|
||||
isInstalled: boolean;
|
||||
nodeVersions?: Array<{ npmVersion: string; checksum: string }>;
|
||||
gatewayCreditsPromotion?: GatewayCreditsPromotion | null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { CommunityPackageRequestDto } from '../community-package-request.dto';
|
||||
|
||||
describe('CommunityPackageRequestDto', () => {
|
||||
it.each([
|
||||
{ name: 'n8n-nodes-test' },
|
||||
{ name: 'n8n-nodes-test', version: '1.0.0' },
|
||||
{ name: '@scope/n8n-nodes-test@1.0.0' },
|
||||
])('accepts %o', (request) => {
|
||||
expect(CommunityPackageRequestDto.safeParse(request).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'missing name', request: {} },
|
||||
{ name: 'empty name', request: { name: ' ' } },
|
||||
{ name: 'empty version', request: { name: 'n8n-nodes-test', version: '' } },
|
||||
])('rejects $name', ({ request }) => {
|
||||
expect(CommunityPackageRequestDto.safeParse(request).success).toBe(false);
|
||||
});
|
||||
|
||||
it('drops unknown fields', () => {
|
||||
const result = CommunityPackageRequestDto.safeParse({
|
||||
name: 'n8n-nodes-test',
|
||||
checksum: 'sha512-abc',
|
||||
verify: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.success && result.data).toEqual({ name: 'n8n-nodes-test' });
|
||||
});
|
||||
});
|
||||
|
|
@ -47,10 +47,8 @@ export class ListCommunityPackagesQueryDto extends Z.class({}, { strict: true })
|
|||
export class InstallCommunityPackagePublicDto extends Z.class({
|
||||
name: z.string().openapi(installCommunityPackageFieldDocs.name),
|
||||
version: z.string().optional().openapi(installCommunityPackageFieldDocs.version),
|
||||
verify: z.boolean().optional().openapi(installCommunityPackageFieldDocs.verify),
|
||||
}) {}
|
||||
|
||||
export class UpdateCommunityPackagePublicDto extends Z.class({
|
||||
version: z.string().optional().openapi(updateCommunityPackageFieldDocs.version),
|
||||
verify: z.boolean().optional().openapi(updateCommunityPackageFieldDocs.verify),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -6,20 +6,14 @@ export const installCommunityPackageFieldDocs = {
|
|||
'npm package name. Unscoped names start with n8n-nodes-. Scoped names use the @[author]/n8n-nodes- form.',
|
||||
},
|
||||
version: {
|
||||
description: 'Semver version or npm dist-tag, such as latest or beta',
|
||||
},
|
||||
verify: {
|
||||
description:
|
||||
'Whether to verify the package against the n8n-vetted package list. Required when the instance has N8N_UNVERIFIED_PACKAGES_ENABLED=false.',
|
||||
'Semver version or npm dist-tag, such as latest or beta. When unverified packages are disabled, only versions verified by n8n can be installed.',
|
||||
},
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
||||
export const updateCommunityPackageFieldDocs = {
|
||||
version: {
|
||||
description: 'Semver version or npm dist-tag to update to, such as latest or beta',
|
||||
},
|
||||
verify: {
|
||||
description:
|
||||
'Whether to verify the package against the n8n-vetted package list. Setting to false will allow installing or updating to an unverified version. Default is true.',
|
||||
'Semver version or npm dist-tag to update to, such as latest or beta. When unverified packages are disabled, only versions verified by n8n can be installed.',
|
||||
},
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
/** Body of `POST` and `PATCH /community-packages`. */
|
||||
export class CommunityPackageRequestDto extends Z.class({
|
||||
name: z.string().trim().min(1),
|
||||
version: z.string().trim().min(1).optional(),
|
||||
}) {}
|
||||
|
|
@ -460,6 +460,7 @@ export {
|
|||
IMPORT_PACKAGE_SELECTION_REQUEST_FORM_FIELDS,
|
||||
} from './packages/import-package-request.dto';
|
||||
export { ExportPackageRequestDto } from './packages/export-package-request.dto';
|
||||
export { CommunityPackageRequestDto } from './community-packages/community-package-request.dto';
|
||||
|
||||
export * from './evaluations';
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,19 @@ describe('SAML preference DTOs', () => {
|
|||
relayState: 'https://example.com/relay',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'email verification settings',
|
||||
request: {
|
||||
mapping: {
|
||||
email: 'user@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
userPrincipalName: 'johndoe',
|
||||
emailVerified: 'http://schemas.example.com/claims/emailverified',
|
||||
},
|
||||
emailVerifiedRequired: true,
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = SamlPreferences.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
|
|
@ -119,6 +132,13 @@ describe('SAML preference DTOs', () => {
|
|||
},
|
||||
expectedErrorPath: ['mapping', 'email'],
|
||||
},
|
||||
{
|
||||
name: 'invalid emailVerifiedRequired',
|
||||
request: {
|
||||
emailVerifiedRequired: 'yes',
|
||||
},
|
||||
expectedErrorPath: ['emailVerifiedRequired'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = SamlPreferences.safeParse(request);
|
||||
|
||||
|
|
@ -153,6 +173,7 @@ describe('SAML preference DTOs', () => {
|
|||
expect(result.data?.authnRequestsSigned).toBe(false);
|
||||
expect(result.data?.wantAssertionsSigned).toBe(true);
|
||||
expect(result.data?.wantMessageSigned).toBe(true);
|
||||
expect(result.data?.emailVerifiedRequired).toBeUndefined();
|
||||
expect(result.data?.acsBinding).toBe('post');
|
||||
expect(result.data?.signatureConfig).toEqual({
|
||||
prefix: 'ds',
|
||||
|
|
@ -174,6 +195,7 @@ describe('SAML preference DTOs', () => {
|
|||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
userPrincipalName: 'johndoe',
|
||||
emailVerified: '',
|
||||
n8nInstanceRole: '',
|
||||
n8nProjectRoles: [] as string[],
|
||||
},
|
||||
|
|
@ -186,6 +208,7 @@ describe('SAML preference DTOs', () => {
|
|||
authnRequestsSigned: false,
|
||||
wantAssertionsSigned: true,
|
||||
wantMessageSigned: true,
|
||||
emailVerifiedRequired: false,
|
||||
signingPrivateKey: '',
|
||||
signingCertificate: '',
|
||||
acsBinding: 'post' as const,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ export class SamlPreferencesAttributeMapping extends Z.class({
|
|||
lastName: z.string(),
|
||||
/** SAML attribute mapped to the user's principal name. */
|
||||
userPrincipalName: z.string(),
|
||||
/**
|
||||
* SAML attribute that states whether the identity provider verified the user's email.
|
||||
* Accepted values are `true` and `false`, case-insensitive.
|
||||
*/
|
||||
emailVerified: z.string().optional(),
|
||||
/** SAML attribute mapped to the n8n instance role. */
|
||||
n8nInstanceRole: z.string().optional(),
|
||||
/** Each element in the array is formatted like "<projectId>:<role>" */
|
||||
|
|
@ -54,6 +59,12 @@ export class SamlPreferences extends Z.class({
|
|||
authnRequestsSigned: z.boolean().default(false),
|
||||
wantAssertionsSigned: z.boolean().default(true),
|
||||
wantMessageSigned: z.boolean().default(true),
|
||||
/**
|
||||
* Whether the identity provider must assert a verified email before a login is
|
||||
* linked to an existing user by email. When disabled, only an explicit negative
|
||||
* assertion is rejected. Optional so that stored settings and the UI stay valid.
|
||||
*/
|
||||
emailVerifiedRequired: z.boolean().optional(),
|
||||
|
||||
/** PEM-encoded private key for signing SAML AuthnRequests. Stored encrypted at rest. */
|
||||
signingPrivateKey: z.string().optional(),
|
||||
|
|
@ -82,6 +93,7 @@ export class UpdateSamlConfigurationDto extends Z.class({
|
|||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
userPrincipalName: z.string(),
|
||||
emailVerified: z.string(),
|
||||
n8nInstanceRole: z.string(),
|
||||
n8nProjectRoles: z.array(z.string()),
|
||||
}),
|
||||
|
|
@ -94,6 +106,7 @@ export class UpdateSamlConfigurationDto extends Z.class({
|
|||
authnRequestsSigned: z.boolean(),
|
||||
wantAssertionsSigned: z.boolean(),
|
||||
wantMessageSigned: z.boolean(),
|
||||
emailVerifiedRequired: z.boolean(),
|
||||
signingPrivateKey: z.string(),
|
||||
signingCertificate: z.string(),
|
||||
acsBinding: SamlLoginBindingSchema,
|
||||
|
|
|
|||
|
|
@ -708,6 +708,13 @@ export type {
|
|||
InstanceAiEvalSeedFolder,
|
||||
} from './schemas/instance-ai.schema';
|
||||
|
||||
export {
|
||||
instanceAiSetupCredentialSelectionKey,
|
||||
instanceAiSetupCredentialAppliedKey,
|
||||
readPendingInstanceAiSetupCredentialSelections,
|
||||
type InstanceAiSetupCredentialSelection,
|
||||
} from './schemas/instance-ai-setup-credential-selection';
|
||||
|
||||
export type {
|
||||
McpRegistryServerStatus,
|
||||
McpRegistryServerIconResponse,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import {
|
||||
instanceAiSetupCredentialAppliedKey,
|
||||
instanceAiSetupCredentialSelectionKey,
|
||||
readPendingInstanceAiSetupCredentialSelections,
|
||||
type InstanceAiSetupCredentialSelection,
|
||||
} from '../instance-ai-setup-credential-selection';
|
||||
|
||||
const selection: InstanceAiSetupCredentialSelection = {
|
||||
selectionId: 'choice-1',
|
||||
credentialType: 'slackApi',
|
||||
credentialId: 'credential-1',
|
||||
};
|
||||
const itemId = 'workflow-1:credential:slackApi';
|
||||
|
||||
describe('readPendingInstanceAiSetupCredentialSelections', () => {
|
||||
it('reads only valid selections for the requested workflow', () => {
|
||||
expect(
|
||||
readPendingInstanceAiSetupCredentialSelections(
|
||||
{
|
||||
[instanceAiSetupCredentialSelectionKey(itemId)]: selection,
|
||||
[instanceAiSetupCredentialSelectionKey('workflow-2:credential:slackApi')]: selection,
|
||||
[instanceAiSetupCredentialSelectionKey('workflow-1:credential:gmailOAuth2')]: selection,
|
||||
[instanceAiSetupCredentialSelectionKey(`${itemId}:invalid`)]: { credentialId: 'id' },
|
||||
},
|
||||
'workflow-1',
|
||||
),
|
||||
).toEqual([{ itemId, selection }]);
|
||||
});
|
||||
|
||||
it('keeps a newer choice pending when an earlier save finishes', () => {
|
||||
const metadata = {
|
||||
[instanceAiSetupCredentialSelectionKey(itemId)]: selection,
|
||||
[instanceAiSetupCredentialAppliedKey(itemId, 'older-choice')]: true,
|
||||
};
|
||||
expect(readPendingInstanceAiSetupCredentialSelections(metadata, 'workflow-1')).toEqual([
|
||||
{ itemId, selection },
|
||||
]);
|
||||
metadata[instanceAiSetupCredentialAppliedKey(itemId, selection.selectionId)] = true;
|
||||
metadata[instanceAiSetupCredentialAppliedKey(itemId, 'older-choice')] = true;
|
||||
expect(readPendingInstanceAiSetupCredentialSelections(metadata, 'workflow-1')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const instanceAiSetupCredentialSelectionSchema = z.object({
|
||||
selectionId: z.string().min(1),
|
||||
credentialType: z.string().min(1),
|
||||
credentialId: z.string().min(1),
|
||||
nodeNames: z.array(z.string().min(1)).min(1).optional(),
|
||||
});
|
||||
|
||||
export type InstanceAiSetupCredentialSelection = z.infer<
|
||||
typeof instanceAiSetupCredentialSelectionSchema
|
||||
>;
|
||||
|
||||
const SELECTION_KEY_PREFIX = 'instanceAiSetupCredentialSelection:';
|
||||
|
||||
export function instanceAiSetupCredentialSelectionKey(itemId: string): string {
|
||||
return `${SELECTION_KEY_PREFIX}${itemId}`;
|
||||
}
|
||||
|
||||
/** A marker for each choice keeps reordered saves independent. */
|
||||
export function instanceAiSetupCredentialAppliedKey(itemId: string, selectionId: string): string {
|
||||
return `instanceAiSetupCredentialApplied:${itemId}:${selectionId}`;
|
||||
}
|
||||
|
||||
export function readPendingInstanceAiSetupCredentialSelections(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
workflowId: string,
|
||||
): Array<{ itemId: string; selection: InstanceAiSetupCredentialSelection }> {
|
||||
const pending: Array<{ itemId: string; selection: InstanceAiSetupCredentialSelection }> = [];
|
||||
const workflowPrefix = `${workflowId}:credential:`;
|
||||
for (const [key, value] of Object.entries(metadata ?? {})) {
|
||||
if (!key.startsWith(`${SELECTION_KEY_PREFIX}${workflowPrefix}`)) continue;
|
||||
const parsed = instanceAiSetupCredentialSelectionSchema.safeParse(value);
|
||||
if (!parsed.success) continue;
|
||||
const itemId = key.slice(SELECTION_KEY_PREFIX.length);
|
||||
const typePrefix = `${workflowPrefix}${parsed.data.credentialType}`;
|
||||
if (itemId !== typePrefix && !itemId.startsWith(`${typePrefix}:`)) continue;
|
||||
if (metadata?.[instanceAiSetupCredentialAppliedKey(itemId, parsed.data.selectionId)] === true) {
|
||||
continue;
|
||||
}
|
||||
pending.push({ itemId, selection: parsed.data });
|
||||
}
|
||||
// Apply node-specific choices after choices for the whole service.
|
||||
return pending.sort(
|
||||
(a, b) =>
|
||||
Number(a.selection.nodeNames !== undefined) - Number(b.selection.nodeNames !== undefined),
|
||||
);
|
||||
}
|
||||
|
|
@ -1174,6 +1174,7 @@ export const setupItemSchema = z.discriminatedUnion('kind', [
|
|||
kind: z.literal('credential'),
|
||||
credentialType: z.string(),
|
||||
appDisplayName: z.string().optional(),
|
||||
preferNew: z.boolean().optional(),
|
||||
nodeBindings: z.array(z.object({ nodeName: z.string() })).optional(),
|
||||
setupHint: credentialSetupHintSchema.optional(),
|
||||
/** Why the app is needed, e.g. "for the docs search". */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "1.42.0",
|
||||
"version": "1.42.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-network",
|
||||
"version": "1.16.0",
|
||||
"version": "1.16.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
import { startServer, type LocalServer } from '../local-server';
|
||||
import {
|
||||
buildDispatcher,
|
||||
createDispatcherTransport,
|
||||
dispatchedFetch,
|
||||
limitResponseBody,
|
||||
} from '../undici/transport';
|
||||
|
||||
describe('limitResponseBody', () => {
|
||||
it('returns the same response when the limit is disabled', () => {
|
||||
const response = new Response('payload');
|
||||
expect(limitResponseBody(response, { maxBytes: 0 })).toBe(response);
|
||||
});
|
||||
|
||||
it('returns the same response when the body is empty', () => {
|
||||
const response = new Response(null);
|
||||
expect(limitResponseBody(response, { maxBytes: 10 })).toBe(response);
|
||||
});
|
||||
|
||||
it('passes a body that stays within the cap', async () => {
|
||||
const response = limitResponseBody(new Response(new Uint8Array(5)), { maxBytes: 10 });
|
||||
await expect(response.arrayBuffer()).resolves.toHaveProperty('byteLength', 5);
|
||||
});
|
||||
|
||||
it('aborts a body that exceeds the cap', async () => {
|
||||
const response = limitResponseBody(new Response(new Uint8Array(100)), { maxBytes: 10 });
|
||||
await expect(response.text()).rejects.toThrow(/exceeded the maximum allowed size/);
|
||||
});
|
||||
|
||||
it('throws the error the caller supplies', async () => {
|
||||
class CustomLimitError extends Error {}
|
||||
const response = limitResponseBody(new Response(new Uint8Array(100)), {
|
||||
maxBytes: 10,
|
||||
createError: () => new CustomLimitError('too big'),
|
||||
});
|
||||
await expect(response.text()).rejects.toBeInstanceOf(CustomLimitError);
|
||||
});
|
||||
|
||||
it('preserves url, redirected and type', () => {
|
||||
const source = new Response(new Uint8Array(5), { status: 200 });
|
||||
Object.defineProperties(source, {
|
||||
url: { value: 'https://example.test/' },
|
||||
redirected: { value: true },
|
||||
type: { value: 'basic' },
|
||||
});
|
||||
const limited = limitResponseBody(source, { maxBytes: 10 });
|
||||
expect(limited.url).toBe('https://example.test/');
|
||||
expect(limited.redirected).toBe(true);
|
||||
expect(limited.type).toBe('basic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatchedFetch responseSizeLimit', () => {
|
||||
let server: LocalServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startServer((_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end('A'.repeat(1024));
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => await server.close());
|
||||
|
||||
it('streams the response unchanged without a limit', async () => {
|
||||
const dispatcher = buildDispatcher(false, 'disabled', {});
|
||||
const response = await dispatchedFetch(dispatcher, server.url, {});
|
||||
expect((await response.text()).length).toBe(1024);
|
||||
});
|
||||
|
||||
it('aborts a response over the cap', async () => {
|
||||
const dispatcher = buildDispatcher(false, 'disabled', {});
|
||||
await expect(
|
||||
dispatchedFetch(dispatcher, server.url, {}, { maxBytes: 10 }).then(
|
||||
async (response) => await response.text(),
|
||||
),
|
||||
).rejects.toThrow(/exceeded the maximum allowed size/);
|
||||
});
|
||||
|
||||
it('passes a response under the cap', async () => {
|
||||
const dispatcher = buildDispatcher(false, 'disabled', {});
|
||||
const response = await dispatchedFetch(dispatcher, server.url, {}, { maxBytes: 1024 * 1024 });
|
||||
expect((await response.text()).length).toBe(1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDispatcherTransport asCustomFetch responseSizeLimit', () => {
|
||||
let server: LocalServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startServer((_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end('A'.repeat(1024));
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => await server.close());
|
||||
|
||||
it('aborts a response over the cap', async () => {
|
||||
const fetch = createDispatcherTransport({
|
||||
proxy: false,
|
||||
responseSizeLimit: { maxBytes: 10 },
|
||||
}).asCustomFetch();
|
||||
await expect(fetch(server.url).then(async (r) => await r.text())).rejects.toThrow(
|
||||
/exceeded the maximum allowed size/,
|
||||
);
|
||||
});
|
||||
|
||||
it('streams the response unchanged without a limit', async () => {
|
||||
const fetch = createDispatcherTransport({ proxy: false }).asCustomFetch();
|
||||
const response = await fetch(server.url);
|
||||
expect((await response.text()).length).toBe(1024);
|
||||
});
|
||||
});
|
||||
|
|
@ -72,6 +72,8 @@ export interface CreateDispatcherTransportOptions {
|
|||
timeouts?: TransportTimeoutOptions;
|
||||
/** When set, it runs on every dispatched request (including each redirect hop) after the SSRF check */
|
||||
authorize?: RequestAuthorizer;
|
||||
/** When set, `asCustomFetch()` bounds each response body to this decoded-byte limit. */
|
||||
responseSizeLimit?: ResponseSizeLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -216,12 +218,16 @@ export function createDispatcherTransport(
|
|||
const ssrf = options?.ssrf ?? 'disabled';
|
||||
const timeouts = options?.timeouts;
|
||||
const authorize = options?.authorize;
|
||||
const responseSizeLimit = options?.responseSizeLimit;
|
||||
|
||||
const lazyDispatcher = lazyValue(() => buildDispatcher(proxy, ssrf, { timeouts, authorize }));
|
||||
|
||||
return {
|
||||
// `getDispatcher()` returns a bare dispatcher, which sees only wire bytes;
|
||||
// the response-size limit is a decoded-body concern, so it is applied here
|
||||
// on the fetch path where the body is already decompressed.
|
||||
asCustomFetch: () => async (input, init) =>
|
||||
await dispatchedFetch(lazyDispatcher(), input, init),
|
||||
await dispatchedFetch(lazyDispatcher(), input, init, responseSizeLimit),
|
||||
getDispatcher: () => lazyDispatcher(),
|
||||
};
|
||||
}
|
||||
|
|
@ -333,17 +339,77 @@ function failDispatch(handler: FailableDispatchHandler, error: Error): void {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Response-size limit for {@link dispatchedFetch} and {@link limitResponseBody}.
|
||||
* `maxBytes <= 0` disables the limit.
|
||||
*
|
||||
* The count is of *decoded* bytes: `fetch` reverses any `Content-Encoding`
|
||||
* before the body reaches this stream, so a compressed payload cannot expand
|
||||
* past the cap. `createError` lets a caller supply its own error type (n8n's
|
||||
* `OperationalError`, say) without this pure subpath importing `n8n-workflow`.
|
||||
*/
|
||||
export interface ResponseSizeLimit {
|
||||
maxBytes: number;
|
||||
createError?: (maxBytes: number) => Error;
|
||||
}
|
||||
|
||||
function defaultResponseSizeError(maxBytes: number): Error {
|
||||
return new Error(`Response body exceeded the maximum allowed size of ${maxBytes} bytes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `TransformStream` that throws once the decoded bytes pass `maxBytes`. This
|
||||
* rejects the whole response; it does not truncate the body to `maxBytes`. Its
|
||||
* own function so it can be unit-tested without a live response.
|
||||
*/
|
||||
export function createResponseSizeLimit({
|
||||
maxBytes,
|
||||
createError = defaultResponseSizeError,
|
||||
}: ResponseSizeLimit): TransformStream<Uint8Array, Uint8Array> {
|
||||
let received = 0;
|
||||
return new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
received += chunk.byteLength;
|
||||
if (received > maxBytes) throw createError(maxBytes);
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a fetch `Response` so that reading its body throws once the decoded
|
||||
* size passes `limit.maxBytes`. The response is rejected in full, not truncated
|
||||
* to the cap. Returns the response unchanged when the limit is disabled
|
||||
* (`maxBytes <= 0`) or the body is empty.
|
||||
*/
|
||||
export function limitResponseBody(response: Response, limit: ResponseSizeLimit): Response {
|
||||
if (limit.maxBytes <= 0 || !response.body) return response;
|
||||
const body = response.body.pipeThrough(createResponseSizeLimit(limit));
|
||||
// `new Response(body, response)` drops these read-only fields; restore them.
|
||||
return Object.defineProperties(new Response(body, response), {
|
||||
url: { value: response.url },
|
||||
redirected: { value: response.redirected },
|
||||
type: { value: response.type },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a `fetch` bound to the given undici dispatcher (the engine behind `asCustomFetch`).
|
||||
* Without a dispatcher it falls through to this undici's default dispatcher.
|
||||
*
|
||||
* Pass `responseSizeLimit` to reject a response whose decoded body passes the
|
||||
* cap: reading the body then throws, rather than the body being truncated. It
|
||||
* is opt-in; without it the response streams unchanged.
|
||||
*/
|
||||
export async function dispatchedFetch(
|
||||
dispatcher: Dispatcher | undefined,
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
responseSizeLimit?: ResponseSizeLimit,
|
||||
): Promise<Response> {
|
||||
return (await undiciFetch(
|
||||
const response = (await undiciFetch(
|
||||
input as Parameters<typeof undiciFetch>[0],
|
||||
{ ...(init ?? {}), dispatcher } as Parameters<typeof undiciFetch>[1],
|
||||
)) as unknown as Response;
|
||||
return responseSizeLimit ? limitResponseBody(response, responseSizeLimit) : response;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,13 +19,16 @@ export {
|
|||
createSsrfInterceptor,
|
||||
createAuthorizationInterceptor,
|
||||
createDispatcherTransport,
|
||||
createResponseSizeLimit,
|
||||
dispatchedFetch,
|
||||
limitResponseBody,
|
||||
} from './http/undici/transport';
|
||||
export type {
|
||||
CustomFetch,
|
||||
DispatcherTransport,
|
||||
CreateDispatcherTransportOptions,
|
||||
RequestAuthorizer,
|
||||
ResponseSizeLimit,
|
||||
TransportSsrfPolicy,
|
||||
TransportTimeoutOptions,
|
||||
} from './http/undici/transport';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-test-utils",
|
||||
"version": "1.42.0",
|
||||
"version": "1.42.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/blob-storage",
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.3",
|
||||
"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.35.0",
|
||||
"version": "1.35.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "1.24.0",
|
||||
"version": "1.24.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/computer-use",
|
||||
"version": "0.25.0",
|
||||
"version": "0.25.1",
|
||||
"description": "Local AI gateway for n8n Assistant — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
|
||||
"publishConfig": {
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ async function main(
|
|||
|
||||
const settingsStore = await SettingsStore.create();
|
||||
const defaults = settingsStore.getDefaults(parsed.config);
|
||||
const session = new GatewaySession(defaults, settingsStore);
|
||||
const session = new GatewaySession(defaults, settingsStore, origin);
|
||||
|
||||
const confirmConnect = makeConfirmConnect(parsed.nonInteractive, parsed.autoConfirm);
|
||||
const approved = await confirmConnect(url, session);
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ function makeSession(overrides: Partial<GatewaySession> = {}): Mocked<GatewaySes
|
|||
clearSession: vi.fn(),
|
||||
alwaysAllow: vi.fn(),
|
||||
alwaysDeny: vi.fn(),
|
||||
claimUnscopedRules: vi.fn(),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as Mocked<GatewaySession>;
|
||||
|
|
@ -373,12 +374,12 @@ describe('GatewayClient.uploadCapabilities', () => {
|
|||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function makeMinimalClient(): GatewayClient {
|
||||
function makeMinimalClient(session = makeSession()): GatewayClient {
|
||||
const client = new GatewayClient({
|
||||
url: 'http://localhost:5678',
|
||||
apiKey: 'tok',
|
||||
config: makeConfig(),
|
||||
session: makeSession(),
|
||||
session,
|
||||
confirmResourceAccess: vi.fn(),
|
||||
});
|
||||
|
||||
|
|
@ -422,6 +423,28 @@ describe('GatewayClient.uploadCapabilities', () => {
|
|||
await expect(promise).rejects.not.toBeInstanceOf(GatewayAuthError);
|
||||
await expect(promise).rejects.toThrow(/Failed to upload capabilities: 500/);
|
||||
});
|
||||
|
||||
it('start claims unscoped resource rules after the instance accepts the capabilities', async () => {
|
||||
mockFetchResponse(200);
|
||||
const session = makeSession();
|
||||
const client = makeMinimalClient(session);
|
||||
client['connectSSE'] = vi.fn();
|
||||
|
||||
await client.start();
|
||||
|
||||
expect(session.claimUnscopedRules).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('start does not claim unscoped resource rules when the instance rejects the capabilities', async () => {
|
||||
mockFetchResponse(401, 'invalid token');
|
||||
const session = makeSession();
|
||||
const client = makeMinimalClient(session);
|
||||
client['connectSSE'] = vi.fn();
|
||||
|
||||
await expect(client.start()).rejects.toBeInstanceOf(GatewayAuthError);
|
||||
|
||||
expect(session.claimUnscopedRules).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GatewayClient.runTeardowns', () => {
|
||||
|
|
|
|||
|
|
@ -149,6 +149,8 @@ export class GatewayClient {
|
|||
/** Start the client: upload capabilities, connect SSE, handle requests. */
|
||||
async start(): Promise<void> {
|
||||
await this.uploadCapabilities();
|
||||
// Only after the instance accepts the pairing: a declined or failed connect must not claim them.
|
||||
this.options.session.claimUnscopedRules();
|
||||
this.connectSSE();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,18 +14,26 @@ function makeStore(
|
|||
allow: Record<string, string[]>;
|
||||
deny: Record<string, string[]>;
|
||||
}> = {},
|
||||
): Mocked<Pick<SettingsStore, 'getResourcePermissions' | 'alwaysAllow' | 'alwaysDeny' | 'flush'>> {
|
||||
): Mocked<
|
||||
Pick<
|
||||
SettingsStore,
|
||||
'getResourcePermissions' | 'alwaysAllow' | 'alwaysDeny' | 'claimUnscopedRules' | 'flush'
|
||||
>
|
||||
> {
|
||||
return {
|
||||
getResourcePermissions: vi.fn((toolGroup: ToolGroup) => ({
|
||||
getResourcePermissions: vi.fn((_origin: string, toolGroup: ToolGroup) => ({
|
||||
allow: overrides.allow?.[toolGroup] ?? [],
|
||||
deny: overrides.deny?.[toolGroup] ?? [],
|
||||
})),
|
||||
alwaysAllow: vi.fn(),
|
||||
alwaysDeny: vi.fn(),
|
||||
claimUnscopedRules: vi.fn(),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const ORIGIN = 'https://a.app.n8n.cloud';
|
||||
|
||||
const FULL_ALLOW_PERMISSIONS = buildDefaultPermissions({
|
||||
filesystemRead: 'allow',
|
||||
filesystemWrite: 'allow',
|
||||
|
|
@ -78,6 +86,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/home/user' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.dir).toBe('/home/user');
|
||||
expect(session.getAllPermissions()).toEqual(FULL_ALLOW_PERMISSIONS);
|
||||
|
|
@ -89,6 +98,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: defaults, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
defaults.shell = 'deny';
|
||||
expect(session.getAllPermissions().shell).toBe('allow');
|
||||
|
|
@ -101,6 +111,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
session.setPermissions({ ...FULL_ALLOW_PERMISSIONS, shell: 'deny' });
|
||||
expect(session.getAllPermissions().shell).toBe('deny');
|
||||
|
|
@ -111,6 +122,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/old' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
session.setDir('/new');
|
||||
expect(session.dir).toBe('/new');
|
||||
|
|
@ -127,6 +139,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ASK_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.getGroupMode('filesystemRead')).toBe('ask');
|
||||
expect(session.getGroupMode('shell')).toBe('ask');
|
||||
|
|
@ -143,6 +156,7 @@ describe('GatewaySession', () => {
|
|||
dir: '/',
|
||||
},
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.getGroupMode('filesystemWrite')).toBe('deny');
|
||||
});
|
||||
|
|
@ -155,6 +169,7 @@ describe('GatewaySession', () => {
|
|||
dir: '/',
|
||||
},
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.getGroupMode('filesystemWrite')).toBe('allow');
|
||||
});
|
||||
|
|
@ -170,6 +185,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('shell', 'rm -rf /')).toBe('deny');
|
||||
});
|
||||
|
|
@ -182,6 +198,7 @@ describe('GatewaySession', () => {
|
|||
dir: '/',
|
||||
},
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('browser', 'example.com')).toBe('allow');
|
||||
});
|
||||
|
|
@ -194,6 +211,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('shell', 'npm')).toBe('deny');
|
||||
});
|
||||
|
|
@ -203,6 +221,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
session.allowForSession('shell', 'npm');
|
||||
expect(session.check('shell', 'npm')).toBe('allow');
|
||||
|
|
@ -213,6 +232,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('shell', 'npm')).toBe('ask');
|
||||
});
|
||||
|
|
@ -223,6 +243,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('browser', 'credentials', 'credential-write')).toBe('ask');
|
||||
// The group mode still covers ordinary domains.
|
||||
|
|
@ -234,6 +255,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('browser', 'credentials', 'host')).toBe('allow');
|
||||
});
|
||||
|
|
@ -243,6 +265,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('browser', 'credentials')).toBe('allow');
|
||||
});
|
||||
|
|
@ -252,6 +275,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
session.allowForSession('browser', 'credentials');
|
||||
expect(session.check('browser', 'credentials', 'credential-write')).toBe('allow');
|
||||
|
|
@ -262,6 +286,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('browser', 'credentials', 'credential-write')).toBe('allow');
|
||||
});
|
||||
|
|
@ -271,6 +296,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('browser', 'credentials', 'credential-write')).toBe('deny');
|
||||
});
|
||||
|
|
@ -285,6 +311,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('filesystemWrite', settingsFile)).toBe('deny');
|
||||
});
|
||||
|
|
@ -294,6 +321,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('filesystemWrite', settingsDir)).toBe('deny');
|
||||
});
|
||||
|
|
@ -303,6 +331,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('filesystemRead', settingsFile)).toBe('deny');
|
||||
});
|
||||
|
|
@ -312,6 +341,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('filesystemWrite', settingsFile)).toBe('deny');
|
||||
});
|
||||
|
|
@ -321,6 +351,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('filesystemWrite', '/tmp/unrelated.json')).toBe('allow');
|
||||
});
|
||||
|
|
@ -330,6 +361,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
expect(session.check('shell', settingsFile)).toBe('allow');
|
||||
});
|
||||
|
|
@ -346,6 +378,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
session.allowForSession('shell', 'npm');
|
||||
expect(session.check('shell', 'npm')).toBe('allow');
|
||||
|
|
@ -358,6 +391,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: buildDefaultPermissions({ shell: 'ask', browser: 'ask' }), dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
session.allowForSession('shell', 'npm');
|
||||
expect(session.check('browser', 'npm')).toBe('ask');
|
||||
|
|
@ -374,9 +408,10 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
session.alwaysAllow('shell', 'npm');
|
||||
expect(store.alwaysAllow).toHaveBeenCalledWith('shell', 'npm');
|
||||
expect(store.alwaysAllow).toHaveBeenCalledWith(ORIGIN, 'shell', 'npm');
|
||||
});
|
||||
|
||||
it('delegates alwaysDeny to the settings store', () => {
|
||||
|
|
@ -384,9 +419,21 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
session.alwaysDeny('shell', 'rm -rf /');
|
||||
expect(store.alwaysDeny).toHaveBeenCalledWith('shell', 'rm -rf /');
|
||||
expect(store.alwaysDeny).toHaveBeenCalledWith(ORIGIN, 'shell', 'rm -rf /');
|
||||
});
|
||||
|
||||
it('delegates claimUnscopedRules to the settings store with its origin', () => {
|
||||
const store = makeStore();
|
||||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
session.claimUnscopedRules();
|
||||
expect(store.claimUnscopedRules).toHaveBeenCalledWith(ORIGIN);
|
||||
});
|
||||
|
||||
it('delegates flush to the settings store', async () => {
|
||||
|
|
@ -394,6 +441,7 @@ describe('GatewaySession', () => {
|
|||
const session = new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
store as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
await session.flush();
|
||||
expect(store.flush).toHaveBeenCalled();
|
||||
|
|
@ -409,6 +457,7 @@ describe('GatewaySession', () => {
|
|||
return new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
makeStore() as unknown as SettingsStore,
|
||||
ORIGIN,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export class GatewaySession {
|
|||
constructor(
|
||||
defaults: { permissions: Record<ToolGroup, PermissionMode>; dir: string },
|
||||
private readonly settingsStore: SettingsStore,
|
||||
/** Instance origin that persistent resource rules are scoped to. */
|
||||
private readonly origin: string,
|
||||
) {
|
||||
this._permissions = { ...defaults.permissions };
|
||||
this._dir = defaults.dir;
|
||||
|
|
@ -87,7 +89,7 @@ export class GatewaySession {
|
|||
return 'deny';
|
||||
}
|
||||
|
||||
const rp = this.settingsStore.getResourcePermissions(toolGroup);
|
||||
const rp = this.settingsStore.getResourcePermissions(this.origin, toolGroup);
|
||||
if (rp.deny.includes(resource)) return 'deny';
|
||||
if (rp.allow.includes(resource)) return 'allow';
|
||||
if (this.hasSessionAllow(toolGroup, resource)) return 'allow';
|
||||
|
|
@ -141,11 +143,15 @@ export class GatewaySession {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
alwaysAllow(toolGroup: ToolGroup, resource: string): void {
|
||||
this.settingsStore.alwaysAllow(toolGroup, resource);
|
||||
this.settingsStore.alwaysAllow(this.origin, toolGroup, resource);
|
||||
}
|
||||
|
||||
alwaysDeny(toolGroup: ToolGroup, resource: string): void {
|
||||
this.settingsStore.alwaysDeny(toolGroup, resource);
|
||||
this.settingsStore.alwaysDeny(this.origin, toolGroup, resource);
|
||||
}
|
||||
|
||||
claimUnscopedRules(): void {
|
||||
this.settingsStore.claimUnscopedRules(this.origin);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ function parseJson<T>(raw: string): T {
|
|||
}
|
||||
}
|
||||
|
||||
const ORIGIN = 'https://a.app.n8n.cloud';
|
||||
const OTHER_ORIGIN = 'https://b.app.n8n.cloud';
|
||||
|
||||
const BASE_CONFIG: GatewayConfig = {
|
||||
logLevel: 'info',
|
||||
allowedOrigins: [],
|
||||
|
|
@ -69,15 +72,53 @@ describe('SettingsStore.create', () => {
|
|||
it('creates a store when no file exists', async () => {
|
||||
const store = await createStore(tmpDir);
|
||||
// Should not throw; resource permissions are empty
|
||||
expect(store.getResourcePermissions('shell')).toEqual({ allow: [], deny: [] });
|
||||
expect(store.getResourcePermissions(ORIGIN, 'shell')).toEqual({ allow: [], deny: [] });
|
||||
});
|
||||
|
||||
it('loads permissions and resource rules from file', async () => {
|
||||
it('loads resource rules for an origin from file', async () => {
|
||||
const store = await createStore(tmpDir, {
|
||||
permissions: { shell: 'allow' },
|
||||
resourcePermissions: {},
|
||||
resourcePermissionsByOrigin: { [ORIGIN]: { shell: { allow: ['npm'], deny: [] } } },
|
||||
});
|
||||
expect(store.getResourcePermissions(ORIGIN, 'shell')).toEqual({ allow: ['npm'], deny: [] });
|
||||
expect(store.getResourcePermissions(OTHER_ORIGIN, 'shell')).toEqual({ allow: [], deny: [] });
|
||||
});
|
||||
|
||||
it('gives unscoped resource rules to the first origin that claims them', async () => {
|
||||
const store = await createStore(tmpDir, {
|
||||
permissions: {},
|
||||
resourcePermissions: { shell: { allow: ['npm'], deny: ['rm'] } },
|
||||
});
|
||||
|
||||
store.claimUnscopedRules(ORIGIN);
|
||||
store.claimUnscopedRules(OTHER_ORIGIN);
|
||||
|
||||
expect(store.getResourcePermissions(ORIGIN, 'shell')).toEqual({ allow: ['npm'], deny: ['rm'] });
|
||||
expect(store.getResourcePermissions(OTHER_ORIGIN, 'shell')).toEqual({ allow: [], deny: [] });
|
||||
});
|
||||
|
||||
it('merges unscoped resource rules into rules the origin already has', async () => {
|
||||
const store = await createStore(tmpDir, {
|
||||
permissions: {},
|
||||
resourcePermissions: { shell: { allow: ['npm', 'git'], deny: [] } },
|
||||
resourcePermissionsByOrigin: { [ORIGIN]: { shell: { allow: ['git'], deny: [] } } },
|
||||
});
|
||||
|
||||
store.claimUnscopedRules(ORIGIN);
|
||||
|
||||
expect(store.getResourcePermissions(ORIGIN, 'shell').allow).toEqual(['git', 'npm']);
|
||||
});
|
||||
|
||||
it('does not apply unscoped resource rules before they are claimed', async () => {
|
||||
const store = await createStore(tmpDir, {
|
||||
permissions: {},
|
||||
resourcePermissions: { shell: { allow: ['npm'], deny: [] } },
|
||||
});
|
||||
expect(store.getResourcePermissions('shell')).toEqual({ allow: ['npm'], deny: [] });
|
||||
|
||||
store.alwaysAllow(ORIGIN, 'shell', 'git');
|
||||
|
||||
expect(store.getResourcePermissions(ORIGIN, 'shell').allow).toEqual(['git']);
|
||||
});
|
||||
|
||||
it('tolerates a malformed file and starts with empty state', async () => {
|
||||
|
|
@ -86,7 +127,7 @@ describe('SettingsStore.create', () => {
|
|||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, 'not-json', 'utf-8');
|
||||
const store = await SettingsStore.create();
|
||||
expect(store.getResourcePermissions('shell')).toEqual({ allow: [], deny: [] });
|
||||
expect(store.getResourcePermissions(ORIGIN, 'shell')).toEqual({ allow: [], deny: [] });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -194,19 +235,26 @@ describe('getDefaults', () => {
|
|||
describe('getResourcePermissions', () => {
|
||||
it('returns empty lists for unknown groups', async () => {
|
||||
const store = await createStore(tmpDir);
|
||||
expect(store.getResourcePermissions('shell')).toEqual({ allow: [], deny: [] });
|
||||
expect(store.getResourcePermissions(ORIGIN, 'shell')).toEqual({ allow: [], deny: [] });
|
||||
});
|
||||
|
||||
it('reflects alwaysAllow additions', async () => {
|
||||
const store = await createStore(tmpDir);
|
||||
store.alwaysAllow('shell', 'npm');
|
||||
expect(store.getResourcePermissions('shell').allow).toContain('npm');
|
||||
store.alwaysAllow(ORIGIN, 'shell', 'npm');
|
||||
expect(store.getResourcePermissions(ORIGIN, 'shell').allow).toContain('npm');
|
||||
});
|
||||
|
||||
it('keeps rules saved for one origin away from other origins', async () => {
|
||||
const store = await createStore(tmpDir);
|
||||
store.alwaysAllow(ORIGIN, 'shell', 'npm');
|
||||
store.alwaysDeny(ORIGIN, 'shell', 'rm');
|
||||
expect(store.getResourcePermissions(OTHER_ORIGIN, 'shell')).toEqual({ allow: [], deny: [] });
|
||||
});
|
||||
|
||||
it('reflects alwaysDeny additions', async () => {
|
||||
const store = await createStore(tmpDir);
|
||||
store.alwaysDeny('shell', 'rm -rf /');
|
||||
expect(store.getResourcePermissions('shell').deny).toContain('rm -rf /');
|
||||
store.alwaysDeny(ORIGIN, 'shell', 'rm -rf /');
|
||||
expect(store.getResourcePermissions(ORIGIN, 'shell').deny).toContain('rm -rf /');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -217,16 +265,20 @@ describe('getResourcePermissions', () => {
|
|||
describe('alwaysAllow / alwaysDeny deduplication', () => {
|
||||
it('does not add a duplicate allow entry', async () => {
|
||||
const store = await createStore(tmpDir);
|
||||
store.alwaysAllow('shell', 'npm');
|
||||
store.alwaysAllow('shell', 'npm');
|
||||
expect(store.getResourcePermissions('shell').allow.filter((r) => r === 'npm')).toHaveLength(1);
|
||||
store.alwaysAllow(ORIGIN, 'shell', 'npm');
|
||||
store.alwaysAllow(ORIGIN, 'shell', 'npm');
|
||||
expect(
|
||||
store.getResourcePermissions(ORIGIN, 'shell').allow.filter((r) => r === 'npm'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not add a duplicate deny entry', async () => {
|
||||
const store = await createStore(tmpDir);
|
||||
store.alwaysDeny('shell', 'rm');
|
||||
store.alwaysDeny('shell', 'rm');
|
||||
expect(store.getResourcePermissions('shell').deny.filter((r) => r === 'rm')).toHaveLength(1);
|
||||
store.alwaysDeny(ORIGIN, 'shell', 'rm');
|
||||
store.alwaysDeny(ORIGIN, 'shell', 'rm');
|
||||
expect(
|
||||
store.getResourcePermissions(ORIGIN, 'shell').deny.filter((r) => r === 'rm'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -237,11 +289,32 @@ describe('alwaysAllow / alwaysDeny deduplication', () => {
|
|||
describe('flush', () => {
|
||||
it('writes alwaysAllow changes to disk', async () => {
|
||||
const store = await createStore(tmpDir);
|
||||
store.alwaysAllow('shell', 'npm');
|
||||
store.alwaysAllow(ORIGIN, 'shell', 'npm');
|
||||
await store.flush();
|
||||
|
||||
const raw = await fs.readFile(path.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
|
||||
const parsed = parseJson<{ resourcePermissions: { shell: { allow: string[] } } }>(raw);
|
||||
expect(parsed.resourcePermissions.shell.allow).toContain('npm');
|
||||
const parsed = parseJson<{
|
||||
resourcePermissions: object;
|
||||
resourcePermissionsByOrigin: Record<string, { shell: { allow: string[] } }>;
|
||||
}>(raw);
|
||||
expect(parsed.resourcePermissionsByOrigin[ORIGIN].shell.allow).toContain('npm');
|
||||
expect(parsed.resourcePermissions).toEqual({});
|
||||
});
|
||||
|
||||
it('writes moved unscoped rules under the origin', async () => {
|
||||
const store = await createStore(tmpDir, {
|
||||
permissions: {},
|
||||
resourcePermissions: { shell: { allow: ['npm'], deny: [] } },
|
||||
});
|
||||
store.claimUnscopedRules(ORIGIN);
|
||||
await store.flush();
|
||||
|
||||
const raw = await fs.readFile(path.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
|
||||
const parsed = parseJson<{
|
||||
resourcePermissions: object;
|
||||
resourcePermissionsByOrigin: Record<string, { shell: { allow: string[] } }>;
|
||||
}>(raw);
|
||||
expect(parsed.resourcePermissionsByOrigin[ORIGIN].shell.allow).toEqual(['npm']);
|
||||
expect(parsed.resourcePermissions).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,20 @@ interface ResourcePermissions {
|
|||
deny: string[];
|
||||
}
|
||||
|
||||
const resourcePermissionsSchema = z
|
||||
.object(
|
||||
Object.fromEntries(
|
||||
Object.keys(TOOL_GROUP_DEFINITIONS).map((key) => [
|
||||
key,
|
||||
z.object({
|
||||
allow: z.array(z.string()),
|
||||
deny: z.array(z.string()),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.partial(); // Partial<Record<ToolGroup, ResourcePermissions>>
|
||||
|
||||
const persistentSettingsSchema = z.object({
|
||||
logLevel: logLevelSchema.optional(),
|
||||
permissions: z
|
||||
|
|
@ -38,19 +52,12 @@ const persistentSettingsSchema = z.object({
|
|||
)
|
||||
.partial(), //Partial<Record<ToolGroup, PermissionMode>>,
|
||||
filesystemDir: z.string().optional(),
|
||||
resourcePermissions: z
|
||||
.object(
|
||||
Object.fromEntries(
|
||||
Object.keys(TOOL_GROUP_DEFINITIONS).map((key) => [
|
||||
key,
|
||||
z.object({
|
||||
allow: z.array(z.string()),
|
||||
deny: z.array(z.string()),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.partial(), // Partial<Record<ToolGroup, ResourcePermissions>>,
|
||||
/**
|
||||
* Rules saved before rules were scoped to an origin. The first origin that connects takes them
|
||||
* over. Still written as `{}` so that older versions accept the file.
|
||||
*/
|
||||
resourcePermissions: resourcePermissionsSchema,
|
||||
resourcePermissionsByOrigin: z.record(z.string(), resourcePermissionsSchema).optional(),
|
||||
});
|
||||
|
||||
type PersistentSettings = z.infer<typeof persistentSettingsSchema>;
|
||||
|
|
@ -60,7 +67,7 @@ function isValidPersistentSettings(raw: unknown): raw is PersistentSettings {
|
|||
}
|
||||
|
||||
function emptySettings(): PersistentSettings {
|
||||
return { permissions: {}, resourcePermissions: {} };
|
||||
return { permissions: {}, resourcePermissions: {}, resourcePermissionsByOrigin: {} };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -155,8 +162,11 @@ export class SettingsStore {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Read persistent resource rules for a tool group — used by GatewaySession.check(). */
|
||||
getResourcePermissions(toolGroup: ToolGroup): { allow: string[]; deny: string[] } {
|
||||
const rp = this.persistent.resourcePermissions[toolGroup];
|
||||
getResourcePermissions(
|
||||
origin: string,
|
||||
toolGroup: ToolGroup,
|
||||
): { allow: string[]; deny: string[] } {
|
||||
const rp = this.persistent.resourcePermissionsByOrigin?.[origin]?.[toolGroup];
|
||||
return { allow: rp?.allow ?? [], deny: rp?.deny ?? [] };
|
||||
}
|
||||
|
||||
|
|
@ -164,22 +174,37 @@ export class SettingsStore {
|
|||
// Mutation methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
alwaysAllow(toolGroup: ToolGroup, resource: string): void {
|
||||
const rp = this.getOrInitResourcePermissions(toolGroup);
|
||||
alwaysAllow(origin: string, toolGroup: ToolGroup, resource: string): void {
|
||||
const rp = this.getOrInitResourcePermissions(origin, toolGroup);
|
||||
if (!rp.allow.includes(resource)) {
|
||||
rp.allow.push(resource);
|
||||
this.scheduleWrite();
|
||||
}
|
||||
}
|
||||
|
||||
alwaysDeny(toolGroup: ToolGroup, resource: string): void {
|
||||
const rp = this.getOrInitResourcePermissions(toolGroup);
|
||||
alwaysDeny(origin: string, toolGroup: ToolGroup, resource: string): void {
|
||||
const rp = this.getOrInitResourcePermissions(origin, toolGroup);
|
||||
if (!rp.deny.includes(resource)) {
|
||||
rp.deny.push(resource);
|
||||
this.scheduleWrite();
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves rules saved before rules were scoped to an origin to `origin`. */
|
||||
claimUnscopedRules(origin: string): void {
|
||||
const unscoped = Object.entries(this.persistent.resourcePermissions) as Array<
|
||||
[ToolGroup, ResourcePermissions | undefined]
|
||||
>;
|
||||
if (unscoped.length === 0) return;
|
||||
for (const [toolGroup, rules] of unscoped) {
|
||||
const target = this.getOrInitResourcePermissions(origin, toolGroup);
|
||||
target.allow = [...new Set([...target.allow, ...(rules?.allow ?? [])])];
|
||||
target.deny = [...new Set([...target.deny, ...(rules?.deny ?? [])])];
|
||||
}
|
||||
this.persistent.resourcePermissions = {};
|
||||
this.scheduleWrite();
|
||||
}
|
||||
|
||||
/** Force immediate write — must be called on shutdown. */
|
||||
async flush(): Promise<void> {
|
||||
this.cancelDebounce();
|
||||
|
|
@ -191,8 +216,10 @@ export class SettingsStore {
|
|||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private getOrInitResourcePermissions(toolGroup: ToolGroup): ResourcePermissions {
|
||||
const existing = this.persistent.resourcePermissions[toolGroup];
|
||||
private getOrInitResourcePermissions(origin: string, toolGroup: ToolGroup): ResourcePermissions {
|
||||
const byOrigin = (this.persistent.resourcePermissionsByOrigin ??= {});
|
||||
const rules = (byOrigin[origin] ??= {});
|
||||
const existing = rules[toolGroup];
|
||||
if (existing) {
|
||||
// Normalise: zod schema marks allow/deny as optional; ensure they exist.
|
||||
existing.allow ??= [];
|
||||
|
|
@ -200,7 +227,7 @@ export class SettingsStore {
|
|||
return existing;
|
||||
}
|
||||
const fresh: ResourcePermissions = { allow: [], deny: [] };
|
||||
this.persistent.resourcePermissions[toolGroup] = fresh;
|
||||
rules[toolGroup] = fresh;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/create-node",
|
||||
"version": "0.50.0",
|
||||
"version": "0.50.3",
|
||||
"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.42.0",
|
||||
"version": "1.42.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
||||
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
||||
|
|
|
|||
|
|
@ -2,10 +2,26 @@ import { Service } from '@n8n/di';
|
|||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { AuthIdentity } from '../entities';
|
||||
import type { AuthProviderType } from '../entities/types-db';
|
||||
|
||||
@Service()
|
||||
export class AuthIdentityRepository extends Repository<AuthIdentity> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(AuthIdentity, dataSource.manager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the identity of an external provider together with its user. SSO logins
|
||||
* resolve the account by this identity before they fall back to the email.
|
||||
* The user's role and identities are loaded because the entity hooks need them.
|
||||
*/
|
||||
async findByProviderIdWithUser(
|
||||
providerId: string,
|
||||
providerType: AuthProviderType,
|
||||
): Promise<AuthIdentity | null> {
|
||||
return await this.findOne({
|
||||
where: { providerId, providerType },
|
||||
relations: { user: { role: true, authIdentities: true } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "1.42.0",
|
||||
"version": "1.42.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -344,8 +344,26 @@ persisted snapshots at run start and drops a snapshot whose content did not
|
|||
change, so a recomputed, unchanged list publishes nothing. Snapshot reads wait for
|
||||
pending events to drain. The final workflow setup handoff confirms the stored
|
||||
snapshot before it saves the setup routing marker. A failed handoff returns an
|
||||
error instead of `announced: true`. Credential replacement requests and existing
|
||||
setup cards keep their selection and resume flows.
|
||||
error instead of `announced: true`. Requests to replace a bound account and
|
||||
existing setup cards keep their selection and resume flows. A new-account request
|
||||
for an unconnected service stays in the panel. Choices saved during the current
|
||||
build satisfy repeated new-account flags at the final handoff.
|
||||
|
||||
Before generating source, the agent can call `credentials(action="setup")`
|
||||
with `filePath`, `workflowName`, and known service credential types. This creates
|
||||
a temporary workflow in the bound project and persists its source-file binding.
|
||||
The tool confirms the stored checklist and returns `preBuild: true` without
|
||||
suspending. Later calls replace the planned requirements until the first build.
|
||||
New-account preferences keep those rows unselected. A saved user choice satisfies
|
||||
the preference for the rest of that run, including later build repairs.
|
||||
The build saves into the same workflow and replaces the checklist with its
|
||||
actual requirements.
|
||||
|
||||
The panel saves pending credential references through the existing thread
|
||||
metadata endpoint. Each choice has its own completion marker. The builder
|
||||
validates choices against project-scoped credentials before automatic selection.
|
||||
The panel applies choices made later when the build ends. Refresh and artifact
|
||||
switches retain pending choices. Removing a requirement never deletes a credential.
|
||||
|
||||
The agent observes saved workflows at the start of a user turn. This read updates
|
||||
its private open-item memo. It does not publish a snapshot or select a workflow.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/instance-ai",
|
||||
"version": "1.27.0",
|
||||
"version": "1.27.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ dependencies:
|
|||
description: >-
|
||||
ONLY for coordinated multi-artifact work: multiple workflows with dependencies,
|
||||
shared data-table schema/migration across tasks, or the user explicitly asked
|
||||
to review a plan first. Load create-tasks via load_tool before calling it
|
||||
(search "create tasks" if not visible). Do NOT use for new one-off workflows,
|
||||
to review a plan first. If create-tasks is not visible, load it via load_tool
|
||||
(search "create tasks"). Do NOT use for new one-off workflows,
|
||||
single-workflow edits, verification-only requests, or standalone data-table
|
||||
ops — use workflow-builder or data-table-manager instead.
|
||||
recommended_tools:
|
||||
|
|
@ -27,8 +27,8 @@ Use this skill to design a dependency-aware task graph in the orchestrator and
|
|||
submit it with `create-tasks`. Do not spawn another agent and do not use
|
||||
incremental plan item tools.
|
||||
|
||||
Before calling `create-tasks`, load it via `load_tool` (search "create tasks" if
|
||||
it is not visible).
|
||||
If `create-tasks` is not visible, load it via `load_tool` (search "create tasks")
|
||||
before you call it.
|
||||
|
||||
## When NOT to use this skill
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ name: suggest-automations
|
|||
description: >-
|
||||
Offer the three most common automations for a user as a single-choice card,
|
||||
from your own knowledge of their team and their apps, then build the one
|
||||
they choose or offer more. The system preloads it on onboarding threads,
|
||||
where the opening card already asked the user about themselves.
|
||||
they choose once they confirm, or offer more. The system preloads it on
|
||||
onboarding threads, where the opening card already asked the user about
|
||||
themselves.
|
||||
Use it on other threads when the user asks what they could automate or
|
||||
wants ideas for a first workflow.
|
||||
recommended_tools:
|
||||
|
|
@ -25,14 +26,14 @@ block with the user's team and apps, one line per question. Use every line of
|
|||
the block. The answers are final: use them as they come and do not ask these
|
||||
questions again. Two openings:
|
||||
|
||||
- The block is followed by your message "Got it. Finally, tell me a little
|
||||
about how you use <their apps>." and the user's reply. The reply describes how they
|
||||
- The block is followed by your message "Last one: what do you usually do in
|
||||
<their apps>?" and the user's reply. The reply describes how they
|
||||
work, names a task in their own words, or names none. Start at step 1.
|
||||
- The block is the latest message and one answer is free text the user typed
|
||||
into the card. Read that text first. A tool name is one of their apps: ask
|
||||
"Got it. Finally, tell me a little about how you use <their apps>." and end
|
||||
the turn. A task: start at step 1 with it. Anything else, for example a wish to
|
||||
stop, a request about something else, a refusal or an insult: call
|
||||
"Last one: what do you usually do in <their apps>?" and end the turn. A
|
||||
task: start at step 1 with it. Anything else, for example a wish to stop, a
|
||||
request about something else, a refusal or an insult: call
|
||||
`leave-onboarding`, then write a one-sentence reply that invites the user to
|
||||
explore the app and to come back with a task.
|
||||
|
||||
|
|
@ -50,7 +51,7 @@ Slack or Google Sheets. Never run a command or read a file to find them.
|
|||
complete one, suggestion one is that task in their words and the other two
|
||||
are what people on their team set up next to it. Otherwise pick the three
|
||||
that people on the team most often set up with the user's apps. The card
|
||||
always comes before a build: a specific task gets confirmed with one click,
|
||||
always comes before a build: a specific task gets picked and confirmed,
|
||||
never built unasked. Decide in one step: do not run a command, read a file
|
||||
or write the reasoning out.
|
||||
2. Write short text and no list: the card carries the options. First
|
||||
|
|
@ -93,17 +94,30 @@ Slack or Google Sheets. Never run a command or read a file to find them.
|
|||
Example option: "Capture new leads\nMove new enquiries into your sales
|
||||
process so nothing gets missed."
|
||||
3. Read the answer.
|
||||
- A suggestion: its first line, the title, names the automation to build.
|
||||
- A suggestion: its first line, the title, names the automation. Go to
|
||||
step 4.
|
||||
- `Show me other ideas`: repeat step 2 with three automations not shown in
|
||||
this thread yet, after one sentence such as "Here are three more."
|
||||
- Free text that describes a task: that is the user's request; build it.
|
||||
- Free text that describes a task: that is the user's request. Give it a
|
||||
title as in step 2 and go to step 4.
|
||||
- Free text that asks for a change, for example another app or a topic:
|
||||
repeat step 2 with three automations that fit it.
|
||||
- Free text that names no task and no change, for example "skip", "no" or
|
||||
"later", or `answered: false`: do not build. On an onboarding thread call
|
||||
`leave-onboarding`, then write a one-sentence reply, for example "No
|
||||
problem. Explore the app and tell me when you want to automate something."
|
||||
4. Write exactly one line before the first tool call, `Building <title> now.`,
|
||||
4. Ask before you build. Write no text, and make ONE `ask-user` call with
|
||||
`questions` only: a `single` question "Nice! Should I build this now?",
|
||||
`required: true`, with the options `Yes, build it` and `Not now`. Leave
|
||||
`introMessage` and `freeTextLabel` out: the card's own "Something else"
|
||||
row lets the user ask for another automation. Read the answer:
|
||||
- `Yes, build it`: go to step 5.
|
||||
- `Not now`: do not build, and stay in the flow. Repeat step 2 with the
|
||||
same three automations, after one sentence such as "No problem. Pick
|
||||
another one, or ask for other ideas."
|
||||
- `answered: false`: do not build. Reply as for a skip in step 3.
|
||||
- Free text: read it as in step 3.
|
||||
5. Write exactly one line before the first tool call, `Building <title> now.`,
|
||||
and no other text until the `build-workflow` result. Load `workflow-builder`
|
||||
and build the automation with the user's apps the normal way, then follow
|
||||
`postBuildFlow.instructions` from the result. Ask about an app only for a
|
||||
|
|
@ -112,8 +126,9 @@ Slack or Google Sheets. Never run a command or read a file to find them.
|
|||
## Rules
|
||||
|
||||
- One `ask-user` call per turn. Keep every message to four sentences or fewer.
|
||||
- A build needs a picked suggestion or a task in the user's words. Never
|
||||
start one after a skip, a dismissal, or free text that names nothing.
|
||||
- A build needs `Yes, build it` from the user on the step 4 card, for a
|
||||
picked suggestion and for a task in the user's words alike. Never start one
|
||||
after a skip, a dismissal, or free text that names nothing.
|
||||
- No apps given: suggest what people on the team most often automate, and
|
||||
name no app.
|
||||
- Talk about the automation, never about the mechanics. Do not say
|
||||
|
|
|
|||
|
|
@ -59,6 +59,20 @@ first action turn (each extra sequential turn resends the whole context). When
|
|||
unsure which nodes to use, load this skill first and follow its research
|
||||
process below.
|
||||
|
||||
## Early service connections
|
||||
|
||||
When the `credentials(action="setup")` schema offers `filePath`, announce
|
||||
known service credentials immediately after the first node definitions or
|
||||
credential type search. Do this before detailed planning, SDK research, or
|
||||
source generation. Do not wait for every service to be resolved.
|
||||
|
||||
Pick the source `filePath` and call credential setup with the complete list
|
||||
of types known so far. Make it the only tool call in that response. Wait for
|
||||
its successful `preBuild: true` result before continuing. Add later discoveries
|
||||
with another complete list. Do not batch setup with source writing.
|
||||
|
||||
When early setup is unavailable, keep the post-build setup flow below.
|
||||
|
||||
## Repair Strategy
|
||||
|
||||
When the edit is to fix a node the user reports as erroring or showing a red
|
||||
|
|
@ -97,10 +111,12 @@ resources, credentials, channel IDs, or timezone; use placeholders or unresolved
|
|||
for a capability the user did not name,
|
||||
discover coverage first and use a Gateway credits–covered node instead of asking
|
||||
when the user has no credential for a comparable tool (see Gateway credits
|
||||
Preference). Setup details — recipients, accounts,
|
||||
resources, channels, credentials, timezone — belong in placeholders or
|
||||
unresolved `newCredential()` calls until post-build setup. After the first
|
||||
build, use `ask-user` when stuck or genuinely ambiguous; do not retry the same
|
||||
Preference). Setup details such as recipients, accounts, resources,
|
||||
channels, credentials, and timezone belong in placeholders or
|
||||
unresolved `newCredential()` calls. Announce known credentials through early
|
||||
setup when available, then continue building without waiting for the user.
|
||||
After the first build, resolve remaining setup and use `ask-user` when stuck
|
||||
or when choices are ambiguous; do not retry the same
|
||||
failing approach more than twice. Never re-ask an answered, deferred, or skipped
|
||||
question. A skip grants no additional permission. Choose defaults only for
|
||||
unspecified details within the requested task. If a skipped question seeks
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ If the user asks for a blocked operation, explain that the instance is in read-o
|
|||
*/
|
||||
function getCredentialSetupBullet(setupPanelEnabled?: boolean): string {
|
||||
if (setupPanelEnabled) {
|
||||
return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available. Requirements can appear in the setup panel while the workflow is being built. The user can complete them immediately. Do not describe setup as happening only after the build. When the result has `announced: true`, the setup panel lists the remaining credentials and parameters. Summarize that result, report any validation warnings, and end your turn. Other results need their returned guidance: correct validation errors, respect denials and skipped items, and wait for requested destination approvals. Explicit credential replacement and an already-open setup card keep their card flow, including apply and test-trigger results. Do not treat a resumed card as a panel announcement. Each new user turn carries a `<workflow-setup-state>` block with current configuration; trust it over older tool results. Configuration alone does not prove successful testing. Use `credentials(action="setup")` when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor, and never ask the user to paste secrets into chat.';
|
||||
return '**Early credential setup**: announce each explicitly requested service as soon as its exact credential type is known from node definitions or credential type search. Do this before detailed workflow planning, SDK research, or reasoning about implementation. Do not wait to identify every service. Call `credentials(action="setup", filePath, workflowName, credentials)` as the only tool call in that response. Wait for its successful `preBuild: true` result before planning the implementation or generating source. Never batch this call with workspace writes or build-workflow. Pick the source filePath now and reuse it throughout the build. Include workflowId for an existing workflow and folderPath when creating in a known folder. This creates the workflow context and makes setup actionable immediately. Use the announced credential types when configuring matching nodes. Announce only supported service-specific types; leave ambiguous services and generic authentication to build-time discovery. Pass the complete known requirement list again if the plan changes, including an empty list if all requirements are dropped. Do not wait for the user to connect. The early result has `preBuild: true`: continue generating source, omit folderPath from build-workflow, and do not end your turn. This early step overrides runtime skill guidance that defers all setup until after building. After the build, **credential setup** uses `workflows(action="setup")` when a workflowId is available. Requirements can appear in the setup panel while the workflow is being built. The user can complete them immediately. Do not describe setup as happening only after the build. When the result has `announced: true` without `preBuild: true`, the setup panel lists the remaining credentials and parameters. Summarize that result, report any validation warnings, and end your turn. Other results need their returned guidance: correct validation errors, respect denials and skipped items, and wait for requested destination approvals. Explicit credential replacement and an already-open setup card keep their card flow, including apply and test-trigger results. Do not treat a resumed card as a panel announcement. Each new user turn carries a `<workflow-setup-state>` block with current configuration; trust it over older tool results. Configuration alone does not prove successful testing. Use `credentials(action="setup")` without filePath when the user explicitly asks to create a credential outside of any workflow context. Early credential announcements and final workflow setup are separate stages; do not repeat either without changed requirements. Never describe workflow setup as something the user starts from the canvas or editor, and never ask the user to paste secrets into chat.';
|
||||
}
|
||||
return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available — it opens the inline setup card in the n8n Assistant panel and handles credentials, parameters, and triggers in one step. Use `credentials(action="setup")` only when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor. Setup cards are only open while the setup call is pending — once it returns a result, the card is resolved: describe the outcome (e.g. credentials selected and ready), never that a card is open or that the user still needs to authorize. When a node in `nodesStillNeedingSetup` carries `parameterIssues`, the connected credential can\'t reach the value that was configured (e.g. a model outside what the credential allows) — fix the value, then tell the user plainly which value didn\'t work and what you set instead. Never silently swap a model or other parameter without saying so. Nodes listed under `skippedByUser` are different: the user chose to skip them, so never re-open the setup card for those — say what stays unconfigured and offer to set it up later.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -428,7 +428,7 @@ describe('Instance AI runtime skills', () => {
|
|||
'research',
|
||||
'ask-user',
|
||||
]);
|
||||
expect(skill?.description).toContain('Load create-tasks via load_tool before calling it');
|
||||
expect(skill?.description).toContain('If create-tasks is not visible, load it via load_tool');
|
||||
expect(skill?.description).toContain('Do NOT use for new one-off workflows');
|
||||
|
||||
const loaded = await source.loadSkill('planning');
|
||||
|
|
@ -436,7 +436,7 @@ describe('Instance AI runtime skills', () => {
|
|||
expect(loaded?.instructions).toContain('Consult the knowledge base before planning');
|
||||
expect(loaded?.instructions).toContain('never load `templates/index.json` wholesale');
|
||||
expect(loaded?.instructions).toContain(
|
||||
'Before calling `create-tasks`, load it via `load_tool`',
|
||||
'If `create-tasks` is not visible, load it via `load_tool`',
|
||||
);
|
||||
expect(loaded?.instructions).toContain('Do not call `create-tasks` just to get approval');
|
||||
expect(loaded?.instructions).toContain('planningContext.source: "planning-skill"');
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { Mock } from 'vitest';
|
|||
import { executeTool } from '../../__tests__/tool-test-utils';
|
||||
import type { InstanceAiContext, CredentialSummary, CredentialDetail } from '../../types';
|
||||
import { createCredentialsTool, type CredentialAction } from '../credentials.tool';
|
||||
import { prepareWorkflowSetup } from '../workflows/prepare-workflow-setup';
|
||||
import type { SetupRequest } from '../workflows/setup-workflow.schema';
|
||||
import { analyzeWorkflow } from '../workflows/setup-workflow.service';
|
||||
|
||||
|
|
@ -14,6 +15,8 @@ vi.mock('../workflows/setup-workflow.service', async (importOriginal) => ({
|
|||
analyzeWorkflow: vi.fn(async () => await Promise.resolve([])),
|
||||
}));
|
||||
|
||||
vi.mock('../workflows/prepare-workflow-setup', () => ({ prepareWorkflowSetup: vi.fn() }));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function createMockContext(
|
||||
|
|
@ -954,6 +957,7 @@ describe('credentials tool', () => {
|
|||
describe('setup action — setup panel announcement', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(analyzeWorkflow).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(prepareWorkflowSetup).mockReset();
|
||||
});
|
||||
|
||||
function panelContext(overrides: Parameters<typeof createMockContext>[0] = {}) {
|
||||
|
|
@ -983,6 +987,105 @@ describe('credentials tool', () => {
|
|||
).toBe(true);
|
||||
});
|
||||
|
||||
it('exposes early setup fields only when the panel is enabled', () => {
|
||||
const control = inputJsonSchema(createCredentialsTool(createMockContext()));
|
||||
const { context } = panelContext();
|
||||
const panel = inputJsonSchema(createCredentialsTool(context));
|
||||
const withFolders = inputJsonSchema(
|
||||
createCredentialsTool({
|
||||
...context,
|
||||
folderExplorationEnabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(control.properties).not.toHaveProperty('filePath');
|
||||
expect(control.properties).not.toHaveProperty('workflowName');
|
||||
expect(control.properties).not.toHaveProperty('folderPath');
|
||||
expect(panel.properties).toHaveProperty('filePath');
|
||||
expect(panel.properties).toHaveProperty('workflowName');
|
||||
expect(panel.properties).not.toHaveProperty('folderPath');
|
||||
expect(withFolders.properties).toHaveProperty('folderPath');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ credentials: [{ credentialType: 'slackApi', preferNew: true }] },
|
||||
{ credentials: [] },
|
||||
])(
|
||||
'prepares source-bound setup without suspending for requirements %j',
|
||||
async ({ credentials }) => {
|
||||
const { context, emitter } = panelContext({ folderExplorationEnabled: true });
|
||||
const suspend = vi.fn();
|
||||
const prepared = {
|
||||
success: true,
|
||||
announced: true,
|
||||
preBuild: true,
|
||||
workflowId: 'wf-early',
|
||||
filePath: 'src/workflows/main.ts',
|
||||
message: 'Continue the build',
|
||||
};
|
||||
vi.mocked(prepareWorkflowSetup).mockResolvedValue(prepared);
|
||||
const input = {
|
||||
action: 'setup',
|
||||
filePath: prepared.filePath,
|
||||
workflowName: 'Daily update',
|
||||
folderPath: 'Notifications',
|
||||
credentials,
|
||||
};
|
||||
const tool = createCredentialsTool(context);
|
||||
|
||||
expect(getInputSchema(tool).safeParse(input).success).toBe(true);
|
||||
const result = await executeTool(tool, input, suspendCtx(suspend));
|
||||
|
||||
expect(result).toEqual(prepared);
|
||||
expect(prepareWorkflowSetup).toHaveBeenCalledWith(context, input);
|
||||
expect(suspend).not.toHaveBeenCalled();
|
||||
expect(emitter.merge).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not report early setup as announced when persistence fails', async () => {
|
||||
const { context } = panelContext();
|
||||
const suspend = vi.fn();
|
||||
vi.mocked(prepareWorkflowSetup).mockRejectedValue(new Error('Could not persist setup'));
|
||||
|
||||
const result = await executeTool(
|
||||
createCredentialsTool(context),
|
||||
{
|
||||
action: 'setup',
|
||||
filePath: 'src/workflows/main.ts',
|
||||
workflowName: 'Daily update',
|
||||
credentials: [{ credentialType: 'slackApi' }],
|
||||
},
|
||||
suspendCtx(suspend),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
announced: false,
|
||||
message: 'Could not persist setup',
|
||||
});
|
||||
expect(suspend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates registered credential types before creating early workflow context', async () => {
|
||||
const { context } = panelContext();
|
||||
context.credentialService.credentialTypeExists = vi.fn().mockResolvedValue(false);
|
||||
|
||||
const result = await executeTool(
|
||||
createCredentialsTool(context),
|
||||
{
|
||||
action: 'setup',
|
||||
filePath: 'src/workflows/main.ts',
|
||||
workflowName: 'Daily update',
|
||||
credentials: [{ credentialType: 'slackApiTypo' }],
|
||||
},
|
||||
suspendCtx(),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ error: 'unknown_credential_type' });
|
||||
expect(prepareWorkflowSetup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should announce the credentials to the setup panel and return without suspending', async () => {
|
||||
const { context, emitter } = panelContext();
|
||||
(context.credentialService.list as Mock).mockResolvedValue([
|
||||
|
|
@ -1167,8 +1270,28 @@ describe('credentials tool', () => {
|
|||
expect(emitter.merge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep the card when an entry asks for a new credential', async () => {
|
||||
it.each(['bound', 'unreadable'])('keeps the card for %s workflows', async (state) => {
|
||||
const { context, emitter } = panelContext();
|
||||
vi.mocked(analyzeWorkflow).mockResolvedValue([
|
||||
{
|
||||
node: {
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2,
|
||||
id: 'slack-node',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
credentials: { slackApi: { id: 'old-account', name: 'Old account' } },
|
||||
},
|
||||
credentialType: 'slackApi',
|
||||
needsAction: false,
|
||||
credentialNeedsAction: false,
|
||||
isTrigger: false,
|
||||
},
|
||||
]);
|
||||
if (state === 'unreadable') {
|
||||
vi.mocked(analyzeWorkflow).mockRejectedValue(new Error('workflow unavailable'));
|
||||
}
|
||||
const suspendFn = vi.fn();
|
||||
|
||||
await executeTool(
|
||||
|
|
@ -1192,6 +1315,25 @@ describe('credentials tool', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('announces a first account requested through the credential tool without a card', async () => {
|
||||
const { context, emitter } = panelContext();
|
||||
const suspend = vi.fn();
|
||||
const result = await executeTool(
|
||||
createCredentialsTool(context),
|
||||
{
|
||||
action: 'setup',
|
||||
workflowId: 'wf-1',
|
||||
credentials: [{ credentialType: 'slackApi', preferNew: true }],
|
||||
},
|
||||
suspendCtx(suspend),
|
||||
);
|
||||
expect(result).toMatchObject({ success: true, announced: true });
|
||||
expect(suspend).not.toHaveBeenCalled();
|
||||
expect(emitter.merge).toHaveBeenCalledWith('wf-1', [
|
||||
expect.objectContaining({ credentialType: 'slackApi', preferNew: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should still announce when the emitter fails', async () => {
|
||||
const { context, emitter } = panelContext();
|
||||
emitter.merge.mockImplementation(() => {
|
||||
|
|
|
|||
|
|
@ -3904,7 +3904,14 @@ describe('workflows(action="setup") — setup panel', () => {
|
|||
});
|
||||
|
||||
it('keeps explicit credential replacement in the selection card', async () => {
|
||||
const replacement = { ...openSlack, preferNewCredential: true };
|
||||
const replacement = {
|
||||
...openSlack,
|
||||
node: {
|
||||
...openSlack.node,
|
||||
credentials: { slackApi: { id: 'old-account', name: 'Old account' } },
|
||||
},
|
||||
preferNewCredential: true,
|
||||
};
|
||||
(analyzeWorkflow as Mock).mockResolvedValue([replacement, boundGmail]);
|
||||
const { context, emitter, markWorkflowSetupHandled } = panelContext();
|
||||
const suspend = vi.fn();
|
||||
|
|
@ -3920,6 +3927,73 @@ describe('workflows(action="setup") — setup panel', () => {
|
|||
expect(markWorkflowSetupHandled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('announces unbound new-account requirements without opening a setup card', async () => {
|
||||
(analyzeWorkflow as Mock).mockResolvedValue([
|
||||
{ ...openSlack, preferNewCredential: true },
|
||||
boundGmail,
|
||||
sheetParams,
|
||||
]);
|
||||
const { context, emitter, markWorkflowSetupHandled } = panelContext();
|
||||
const suspend = vi.fn();
|
||||
const result = await executeTool(
|
||||
createWorkflowsTool(context, 'full'),
|
||||
{ action: 'setup', workflowId: 'wf1', preferNewCredentials: ['slackApi'] },
|
||||
{ suspend, resumeData: undefined } as never,
|
||||
);
|
||||
expect(result).toMatchObject({ success: true, announced: true });
|
||||
expect(suspend).not.toHaveBeenCalled();
|
||||
expect(emitter.announce).toHaveBeenCalledWith(
|
||||
'wf1',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ credentialType: 'slackApi', preferNew: true }),
|
||||
expect.objectContaining({ credentialType: 'gmailOAuth2' }),
|
||||
expect.objectContaining({ parameterNames: ['documentId'] }),
|
||||
]),
|
||||
);
|
||||
expect(markWorkflowSetupHandled).toHaveBeenCalledWith('wf1');
|
||||
});
|
||||
|
||||
it('does not reopen an account selected during the current build', async () => {
|
||||
(analyzeWorkflow as Mock).mockResolvedValue([boundGmail]);
|
||||
const { context } = panelContext({
|
||||
runId: 'run-early',
|
||||
threadId: 'thread-early',
|
||||
threadMemory: {
|
||||
getThread: vi.fn().mockResolvedValue({
|
||||
id: 'thread-early',
|
||||
resourceId: 'user-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {
|
||||
instanceAiWorkflowSourceFiles: {
|
||||
'src/workflows/main.ts': {
|
||||
filePath: 'src/workflows/main.ts',
|
||||
workflowId: 'wf1',
|
||||
setupPreferences: {
|
||||
runId: 'run-early',
|
||||
satisfiedCredentialTypes: ['gmailOAuth2'],
|
||||
preferNewCredentialTypes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
saveThread: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
const suspend = vi.fn();
|
||||
const result = await executeTool(
|
||||
createWorkflowsTool(context, 'full'),
|
||||
{ action: 'setup', workflowId: 'wf1', preferNewCredentials: ['gmailOAuth2'] },
|
||||
{ suspend, resumeData: undefined } as never,
|
||||
);
|
||||
expect(result).toMatchObject({ success: true, announced: true, open: [] });
|
||||
expect(suspend).not.toHaveBeenCalled();
|
||||
expect(analyzeWorkflow).toHaveBeenCalledWith(context, 'wf1', undefined, {
|
||||
includeSettled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps failed connection checks in the announcement', async () => {
|
||||
(analyzeWorkflow as Mock).mockResolvedValue([
|
||||
{ ...boundGmail, credentialTestResult: { success: false, message: 'Connection failed' } },
|
||||
|
|
|
|||
|
|
@ -24,10 +24,13 @@ import {
|
|||
GENERIC_AUTH_CREDENTIAL_TYPES,
|
||||
N8N_CONNECT_DISPLAY_NAME,
|
||||
} from './workflows/credential-utils';
|
||||
import { prepareWorkflowSetup } from './workflows/prepare-workflow-setup';
|
||||
import { filterSatisfiedSetupCredentialTypes } from './workflows/setup-credential-selections';
|
||||
import {
|
||||
buildSetupItemsFromCredentialRequests,
|
||||
buildSetupItemsFromAnnouncement,
|
||||
isSetupPanelEnabled,
|
||||
requestsCredentialReplacement,
|
||||
} from './workflows/setup-items';
|
||||
import { analyzeWorkflow } from './workflows/setup-workflow.service';
|
||||
|
||||
|
|
@ -435,6 +438,33 @@ const setupAction = z.object({
|
|||
),
|
||||
});
|
||||
|
||||
const earlySetupAction = setupAction.extend({
|
||||
action: z
|
||||
.literal('setup')
|
||||
.describe(
|
||||
'Announce workflow credential requirements without waiting by passing filePath before generating source. Omit filePath for existing workflow or standalone setup. Follow the returned guidance: announced means continue working, while a resumed card reports the completed interaction.',
|
||||
),
|
||||
filePath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Before writing workflow source, announce known service credentials with its planned workspace filePath. This creates or reuses the workflow context and returns without waiting for setup. Pass the complete current requirement list, including an empty list when a plan drops all services. Omit for standalone credential setup.',
|
||||
),
|
||||
workflowName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Name of the new workflow, required on its first early setup call.'),
|
||||
});
|
||||
|
||||
const earlySetupActionWithFolder = earlySetupAction.extend({
|
||||
folderPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Folder for the new workflow. Pass the intended folder here before building; omit it on the later build-workflow call.',
|
||||
),
|
||||
});
|
||||
|
||||
const testAction = z.object({
|
||||
action: z
|
||||
.literal('test')
|
||||
|
|
@ -485,9 +515,16 @@ function getCredentialActions(options: CredentialsToolOptions): CredentialAction
|
|||
return CREDENTIAL_ACTION_ORDER.filter((action) => allowedActions.has(action));
|
||||
}
|
||||
|
||||
function createCredentialInputSchema(actions: readonly CredentialAction[]) {
|
||||
const actionSchemas: CredentialActionSchema[] = actions.map(
|
||||
(action) => CREDENTIAL_ACTION_SCHEMAS[action],
|
||||
function createCredentialInputSchema(
|
||||
actions: readonly CredentialAction[],
|
||||
context: InstanceAiContext,
|
||||
) {
|
||||
const actionSchemas: CredentialActionSchema[] = actions.map((action) =>
|
||||
action === 'setup' && isSetupPanelEnabled(context)
|
||||
? context.folderExplorationEnabled
|
||||
? earlySetupActionWithFolder
|
||||
: earlySetupAction
|
||||
: CREDENTIAL_ACTION_SCHEMAS[action],
|
||||
);
|
||||
|
||||
if (actionSchemas.length === 0) {
|
||||
|
|
@ -515,11 +552,11 @@ type Input =
|
|||
| z.infer<typeof getAction>
|
||||
| z.infer<typeof deleteAction>
|
||||
| z.infer<typeof searchTypesAction>
|
||||
| z.infer<typeof setupAction>
|
||||
| z.infer<typeof earlySetupActionWithFolder>
|
||||
| z.infer<typeof testAction>;
|
||||
|
||||
function buildInputSchema(options: CredentialsToolOptions) {
|
||||
return createCredentialInputSchema(getCredentialActions(options));
|
||||
function buildInputSchema(options: CredentialsToolOptions, context: InstanceAiContext) {
|
||||
return createCredentialInputSchema(getCredentialActions(options), context);
|
||||
}
|
||||
|
||||
function formatActionList(actions: readonly CredentialAction[]): string {
|
||||
|
|
@ -764,6 +801,7 @@ async function announceSetupItems(
|
|||
context: InstanceAiContext & { setupItemsEmitter: SetupItemsEmitter },
|
||||
input: Extract<Input, { action: 'setup' }>,
|
||||
workflowId: string,
|
||||
analyzedRequests?: Awaited<ReturnType<typeof analyzeWorkflow>>,
|
||||
) {
|
||||
const credentials = input.credentials ?? [];
|
||||
// Best-effort, like the build's emission: the panel is a side channel.
|
||||
|
|
@ -771,9 +809,11 @@ async function announceSetupItems(
|
|||
// Announce against the saved workflow's analysis so generic auth types
|
||||
// land on their per-node rows and the snapshot stays whole. A merge, not
|
||||
// a replace: earlier announcements may list types no saved node uses yet.
|
||||
const analyzed = await analyzeWorkflow(context, workflowId, undefined, {
|
||||
includeSettled: true,
|
||||
});
|
||||
const analyzed =
|
||||
analyzedRequests ??
|
||||
(await analyzeWorkflow(context, workflowId, undefined, {
|
||||
includeSettled: true,
|
||||
}));
|
||||
context.setupItemsEmitter.merge(
|
||||
workflowId,
|
||||
buildSetupItemsFromAnnouncement(workflowId, credentials, analyzed),
|
||||
|
|
@ -834,7 +874,7 @@ async function handleSetup(
|
|||
const resumeData = ctx.resumeData;
|
||||
const isFinalize = input.credentialFlow?.stage === 'finalize';
|
||||
|
||||
if (!input.credentials || input.credentials.length === 0) {
|
||||
if (!input.credentials || (input.credentials.length === 0 && !input.filePath)) {
|
||||
return {
|
||||
error: 'missing_credentials',
|
||||
message:
|
||||
|
|
@ -882,21 +922,63 @@ async function handleSetup(
|
|||
problems: hintProblems,
|
||||
};
|
||||
}
|
||||
if (isSetupPanelEnabled(context) && input.filePath) {
|
||||
if (isFinalize || input.requireUserSelection) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'Use early setup for known service requirements. Explicit credential replacement keeps the existing setup flow.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await prepareWorkflowSetup(context, { ...input, filePath: input.filePath });
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
announced: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Setup panel v2: a workflow's credential needs are announced to the
|
||||
// persistent panel and the turn continues — no card, no suspension. The
|
||||
// finalize stage, standalone setup (no workflow), and an explicit user
|
||||
// choice — the picker (`requireUserSelection`) or a fresh credential
|
||||
// (`preferNew`) — keep the card: the panel has no way to express "replace
|
||||
// the bound one", so the next save would silently re-attach it.
|
||||
const keepsCard =
|
||||
isFinalize ||
|
||||
input.requireUserSelection === true ||
|
||||
input.credentials.some((req: { preferNew?: boolean }) => req.preferNew === true);
|
||||
// Finalization and explicit selection keep their card. Workflow requirements
|
||||
// use the panel unless the request replaces an account already bound there.
|
||||
const keepsCard = isFinalize || input.requireUserSelection === true;
|
||||
if (isSetupPanelEnabled(context) && !keepsCard) {
|
||||
const panelWorkflowId = resolveSetupPanelWorkflowId(context, input);
|
||||
if (panelWorkflowId !== undefined) {
|
||||
return await announceSetupItems(context, input, panelWorkflowId);
|
||||
try {
|
||||
const preferNewTypes = await filterSatisfiedSetupCredentialTypes(
|
||||
context,
|
||||
panelWorkflowId,
|
||||
input.credentials
|
||||
.filter((request) => request.preferNew)
|
||||
.map((request) => request.credentialType),
|
||||
);
|
||||
const analyzed = preferNewTypes?.length
|
||||
? await analyzeWorkflow(context, panelWorkflowId, undefined, { includeSettled: true })
|
||||
: undefined;
|
||||
if (!analyzed || !requestsCredentialReplacement(analyzed, preferNewTypes)) {
|
||||
return await announceSetupItems(
|
||||
context,
|
||||
{
|
||||
...input,
|
||||
credentials: input.credentials.map((request) =>
|
||||
request.preferNew
|
||||
? { ...request, preferNew: preferNewTypes?.includes(request.credentialType) }
|
||||
: request,
|
||||
),
|
||||
},
|
||||
panelWorkflowId,
|
||||
analyzed,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
context.logger.warn('Failed to check credential replacement; using setup card', {
|
||||
workflowId: panelWorkflowId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1124,7 +1206,7 @@ export function createCredentialsTool(
|
|||
context: InstanceAiContext,
|
||||
options: CredentialsToolOptions = {},
|
||||
) {
|
||||
const inputSchema = buildInputSchema(options);
|
||||
const inputSchema = buildInputSchema(options, context);
|
||||
|
||||
return new Tool(CREDENTIALS_TOOL_ID)
|
||||
.description(getToolDescription(options, context.credentialDescriptionsEnabled === true))
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ import {
|
|||
describeSavedPublishState,
|
||||
type SavedWorkflowState,
|
||||
} from './workflows/saved-workflow-state';
|
||||
import { isSetupPanelEnabled } from './workflows/setup-items';
|
||||
import { filterSatisfiedSetupCredentialTypes } from './workflows/setup-credential-selections';
|
||||
import { isSetupPanelEnabled, requestsCredentialReplacement } from './workflows/setup-items';
|
||||
import {
|
||||
describeSetupItem,
|
||||
rememberWorkflowSetupState,
|
||||
|
|
@ -1586,8 +1587,13 @@ async function handleSetup(
|
|||
// The setup panel lists bound slots too (rendered as done), so its snapshot
|
||||
// needs the settled requests the card logic below must not see.
|
||||
const setupPanelEnabled = isSetupPanelEnabled(context);
|
||||
const preferNewCredentialTypes = await filterSatisfiedSetupCredentialTypes(
|
||||
context,
|
||||
input.workflowId,
|
||||
input.preferNewCredentials,
|
||||
);
|
||||
const analyzedRequests = await analyzeWorkflow(context, input.workflowId, undefined, {
|
||||
...preferNewCredentialOptions(input),
|
||||
...(preferNewCredentialTypes?.length ? { preferNewCredentialTypes } : {}),
|
||||
...(setupPanelEnabled ? { includeSettled: true } : {}),
|
||||
});
|
||||
const allSetupRequests = setupPanelEnabled
|
||||
|
|
@ -1735,8 +1741,11 @@ async function handleSetup(
|
|||
|
||||
// Setup panel v2: announce the final checklist and return. The user
|
||||
// completes it in the panel; the turn ends with the agent's summary.
|
||||
// Replacement needs an explicit selection. A saved binding already appears done in the panel.
|
||||
if (isSetupPanelEnabled(context) && !input.preferNewCredentials?.length) {
|
||||
// Only a bound account needs the explicit replacement card.
|
||||
if (
|
||||
isSetupPanelEnabled(context) &&
|
||||
!requestsCredentialReplacement(analyzedRequests, preferNewCredentialTypes)
|
||||
) {
|
||||
return await announceWorkflowSetup(context, input.workflowId, analyzedRequests);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { isZodSchema } from '@n8n/agents';
|
||||
import {
|
||||
instanceAiSetupCredentialAppliedKey,
|
||||
instanceAiSetupCredentialSelectionKey,
|
||||
type InstanceAiSetupCredentialSelection,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
import { executeTool } from '../../../__tests__/tool-test-utils';
|
||||
import { FolderResolutionError } from '../../../errors/folder-resolution.error';
|
||||
|
|
@ -8,6 +13,7 @@ import {
|
|||
loadInstanceAiRuntimeSkillSource,
|
||||
loadInstanceAiRuntimeSkillSourceForBuildMode,
|
||||
} from '../../../skills/runtime-skills';
|
||||
import type { ThreadRecord } from '../../../storage/thread-patch';
|
||||
import { emitTraceOnlyChildRun } from '../../../tracing/langsmith-tracing';
|
||||
import type { InstanceAiContext } from '../../../types';
|
||||
import type { WorkflowBuildOutcome } from '../../../workflow-loop/workflow-loop-state';
|
||||
|
|
@ -16,9 +22,10 @@ import {
|
|||
buildWorkflowInputSchema,
|
||||
createBuildWorkflowTool,
|
||||
} from '../build-workflow.tool';
|
||||
import { prepareWorkflowSetup } from '../prepare-workflow-setup';
|
||||
import { buildCredentialMap, resolveCredentials } from '../resolve-credentials';
|
||||
import type { SetupRequest } from '../setup-workflow.schema';
|
||||
import { analyzeWorkflow } from '../setup-workflow.service';
|
||||
import { analyzeWorkflow, getValidCredentialTypes } from '../setup-workflow.service';
|
||||
import { getWorkflowSourceFileBinding, hashWorkflowSource } from '../workflow-file-bindings';
|
||||
import { ensureWebhookIds } from '../workflow-json-utils';
|
||||
import { compileWorkflowSource } from '../workflow-source-compiler';
|
||||
|
|
@ -89,6 +96,7 @@ vi.mock('../resolve-credentials', () => ({
|
|||
|
||||
vi.mock('../setup-workflow.service', () => ({
|
||||
analyzeWorkflow: vi.fn(async () => await Promise.resolve([])),
|
||||
getValidCredentialTypes: vi.fn(async () => new Set<string>()),
|
||||
stripStaleCredentialsFromWorkflow: vi.fn(async () => await Promise.resolve()),
|
||||
}));
|
||||
|
||||
|
|
@ -1502,6 +1510,152 @@ describe('createBuildWorkflowTool', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('continues a stopped early setup draft with the same file and selected account', async () => {
|
||||
const filePath = 'src/workflows/main.workflow.ts';
|
||||
const setupItemId = 'wf-early:credential:slackApi';
|
||||
const selection: InstanceAiSetupCredentialSelection = {
|
||||
selectionId: 'selected-1',
|
||||
credentialType: 'slackApi',
|
||||
credentialId: 'selected-account',
|
||||
};
|
||||
const appliedKey = instanceAiSetupCredentialAppliedKey(setupItemId, selection.selectionId);
|
||||
let thread: ThreadRecord = {
|
||||
id: 'thread-1',
|
||||
resourceId: 'user-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {
|
||||
[instanceAiSetupCredentialSelectionKey(setupItemId)]: selection,
|
||||
},
|
||||
};
|
||||
const { context } = makeContext({
|
||||
filePath,
|
||||
overrides: {
|
||||
threadId: thread.id,
|
||||
runId: 'early-run',
|
||||
threadMemory: {
|
||||
getThread: vi.fn(async () => thread),
|
||||
saveThread: vi.fn(async (updated: ThreadRecord) => (thread = updated)),
|
||||
},
|
||||
setupItemsEmitter: {
|
||||
emit: vi.fn(() => true),
|
||||
announce: vi.fn().mockResolvedValue(undefined),
|
||||
merge: vi.fn(() => true),
|
||||
lastWorkflowId: vi.fn(),
|
||||
workflowIds: vi.fn(() => []),
|
||||
},
|
||||
},
|
||||
});
|
||||
let isTemporary = true;
|
||||
let isArchived = false;
|
||||
vi.mocked(context.workflowService.clearAiTemporary).mockImplementation(async () => {
|
||||
isTemporary = false;
|
||||
});
|
||||
context.workflowService.archiveIfAiTemporary = vi.fn(async () => {
|
||||
isArchived = isTemporary;
|
||||
return isArchived;
|
||||
});
|
||||
vi.mocked(context.workflowService.createFromWorkflowJSON).mockResolvedValue(
|
||||
await context.workflowService.get('wf-early'),
|
||||
);
|
||||
const requirements = [{ credentialType: 'slackApi', preferNew: true }];
|
||||
await prepareWorkflowSetup(context, {
|
||||
filePath,
|
||||
workflowName: 'Slack updates',
|
||||
credentials: requirements,
|
||||
});
|
||||
await context.workflowService.archiveIfAiTemporary('wf-early');
|
||||
const resumedContext = {
|
||||
...context,
|
||||
runId: 'continued-run',
|
||||
aiCreatedWorkflowIds: new Set<string>(),
|
||||
};
|
||||
await prepareWorkflowSetup(resumedContext, { filePath, credentials: requirements });
|
||||
|
||||
vi.mocked(compileWorkflowSource).mockResolvedValueOnce({
|
||||
success: true,
|
||||
compiler: 'sandbox-tsx',
|
||||
warnings: [],
|
||||
workflow: {
|
||||
name: 'Slack updates',
|
||||
connections: {},
|
||||
nodes: [
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
credentials: { slackApi: { id: 'source-account', name: 'Source account' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(buildCredentialMap).mockResolvedValueOnce(
|
||||
new Map([
|
||||
[
|
||||
'slackApi',
|
||||
[
|
||||
{ id: 'source-account', name: 'Source account', type: 'slackApi' },
|
||||
{ id: 'selected-account', name: 'Selected account', type: 'slackApi' },
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
vi.mocked(getValidCredentialTypes).mockResolvedValue(new Set(['slackApi']));
|
||||
const actualResolver =
|
||||
await vi.importActual<typeof import('../resolve-credentials')>('../resolve-credentials');
|
||||
vi.mocked(resolveCredentials).mockImplementationOnce(actualResolver.resolveCredentials);
|
||||
vi.mocked(context.workflowService.updateFromWorkflowJSON).mockImplementationOnce(
|
||||
async (workflowId) => {
|
||||
if (isArchived) throw new Error('Cannot update an archived workflow.');
|
||||
expect(thread.metadata?.[appliedKey]).toBeUndefined();
|
||||
return {
|
||||
id: workflowId,
|
||||
name: 'Slack updates',
|
||||
versionId: 'v-saved',
|
||||
activeVersionId: null,
|
||||
isArchived: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
checksum: 'checksum-saved',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool<BuildToolOutput>(createBuildWorkflowTool(resumedContext), {
|
||||
filePath,
|
||||
preferNewCredentials: ['slackApi'],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ success: true, workflowId: 'wf-early' });
|
||||
expect(context.workflowService.createFromWorkflowJSON).toHaveBeenCalledTimes(1);
|
||||
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledWith(
|
||||
'wf-early',
|
||||
expect.objectContaining({
|
||||
nodes: [
|
||||
expect.objectContaining({
|
||||
credentials: { slackApi: { id: 'selected-account', name: 'Selected account' } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
{ expectedChecksum: 'checksum-current' },
|
||||
);
|
||||
expect(thread.metadata?.[appliedKey]).toBe(true);
|
||||
await expect(getWorkflowSourceFileBinding(resumedContext, filePath)).resolves.toMatchObject({
|
||||
workflowId: 'wf-early',
|
||||
workflowChecksum: 'checksum-saved',
|
||||
setupPending: undefined,
|
||||
setupPreferences: {
|
||||
runId: 'continued-run',
|
||||
satisfiedCredentialTypes: ['slackApi'],
|
||||
preferNewCredentialTypes: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('updates a workflow created earlier in the run without requesting approval', async () => {
|
||||
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const { context, filePath } = makeContext({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
import type { InstanceAiSetupItem } from '@n8n/api-types';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { InstanceAiEventBus } from '../../../event-bus/event-bus.interface';
|
||||
import type { ThreadRecord } from '../../../storage/thread-patch';
|
||||
import type { InstanceAiContext } from '../../../types';
|
||||
import { prepareWorkflowSetup } from '../prepare-workflow-setup';
|
||||
import { createSetupItemsEmitter } from '../setup-items';
|
||||
import { analyzeWorkflow } from '../setup-workflow.service';
|
||||
import { getWorkflowSourceFileBinding } from '../workflow-file-bindings';
|
||||
|
||||
vi.mock('../setup-workflow.service', () => ({ analyzeWorkflow: vi.fn(async () => []) }));
|
||||
|
||||
const filePath = 'src/workflows/slack.workflow.ts';
|
||||
const requirements = [{ credentialType: 'slackApi', reason: 'Send daily updates' }];
|
||||
|
||||
function createContext() {
|
||||
let thread: ThreadRecord = {
|
||||
id: 'thread-1',
|
||||
resourceId: 'user-1',
|
||||
metadata: { unrelated: 'retained' },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const snapshots = new Map<string, InstanceAiSetupItem[]>();
|
||||
const publish = vi.fn<InstanceAiEventBus['publish']>((_threadId, event) => {
|
||||
if (event.type === 'setup-items') snapshots.set(event.payload.workflowId, event.payload.items);
|
||||
});
|
||||
const threadMemory = {
|
||||
patchThread: undefined,
|
||||
getThread: vi.fn(async () => thread),
|
||||
saveThread: vi.fn(async (updated: ThreadRecord) => (thread = updated)),
|
||||
};
|
||||
const workflowService = mock<InstanceAiContext['workflowService']>({
|
||||
createFromWorkflowJSON: vi.fn().mockResolvedValue({
|
||||
id: 'wf-1',
|
||||
versionId: 'version-1',
|
||||
checksum: 'checksum-1',
|
||||
}),
|
||||
get: vi.fn().mockResolvedValue({
|
||||
id: 'wf-1',
|
||||
versionId: 'version-2',
|
||||
checksum: 'checksum-2',
|
||||
}),
|
||||
});
|
||||
const context = mock<InstanceAiContext>({
|
||||
threadId: thread.id,
|
||||
runId: undefined,
|
||||
workspaceRoot: undefined,
|
||||
aiCreatedWorkflowIds: undefined,
|
||||
permissions: mock<InstanceAiContext['permissions']>(),
|
||||
logger: mock<InstanceAiContext['logger']>(),
|
||||
threadMemory,
|
||||
workflowService,
|
||||
setupItemsEmitter: createSetupItemsEmitter({
|
||||
eventBus: { publish },
|
||||
threadId: thread.id,
|
||||
runId: 'run-1',
|
||||
agentId: 'orchestrator',
|
||||
readPersistedSnapshot: async (workflowId) => snapshots.get(workflowId),
|
||||
}),
|
||||
});
|
||||
return { context, threadMemory, workflowService, snapshots, publish };
|
||||
}
|
||||
|
||||
describe('prepareWorkflowSetup', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('persists and promotes the draft before announcing its requirements', async () => {
|
||||
const { context, workflowService, threadMemory, snapshots, publish } = createContext();
|
||||
|
||||
const result = await prepareWorkflowSetup(context, {
|
||||
filePath: `./${filePath}`,
|
||||
workflowName: 'Daily update',
|
||||
folderPath: 'Notifications',
|
||||
credentials: requirements,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ success: true, announced: true, workflowId: 'wf-1', filePath });
|
||||
expect(workflowService.createFromWorkflowJSON).toHaveBeenCalledWith(
|
||||
{ name: 'Daily update', nodes: [], connections: {} },
|
||||
{ markAsAiTemporary: true, folderPath: 'Notifications' },
|
||||
);
|
||||
expect(threadMemory.saveThread.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(workflowService.clearAiTemporary).mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(vi.mocked(workflowService.clearAiTemporary).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
publish.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(await getWorkflowSourceFileBinding({ ...context }, filePath)).toMatchObject({
|
||||
workflowId: 'wf-1',
|
||||
workflowVersionId: 'version-1',
|
||||
setupPending: true,
|
||||
});
|
||||
expect((await threadMemory.getThread()).metadata).toHaveProperty('unrelated', 'retained');
|
||||
expect(context.aiCreatedWorkflowIds).toContain('wf-1');
|
||||
expect(snapshots.get('wf-1')).toEqual([
|
||||
{ id: 'wf-1:credential:slackApi', kind: 'credential', ...requirements[0] },
|
||||
]);
|
||||
expect(analyzeWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses the persisted workflow after refresh and removes dropped requirements', async () => {
|
||||
const { context, workflowService, snapshots } = createContext();
|
||||
await prepareWorkflowSetup(context, {
|
||||
filePath,
|
||||
workflowName: 'Daily update',
|
||||
credentials: requirements,
|
||||
});
|
||||
|
||||
const refreshedContext = { ...context };
|
||||
await prepareWorkflowSetup(refreshedContext, {
|
||||
filePath,
|
||||
credentials: [{ credentialType: 'gmailOAuth2' }],
|
||||
});
|
||||
expect(snapshots.get('wf-1')).toEqual([
|
||||
{ id: 'wf-1:credential:gmailOAuth2', kind: 'credential', credentialType: 'gmailOAuth2' },
|
||||
]);
|
||||
|
||||
await prepareWorkflowSetup(refreshedContext, { filePath, credentials: [] });
|
||||
expect(snapshots.get('wf-1')).toEqual([]);
|
||||
expect(workflowService.createFromWorkflowJSON).toHaveBeenCalledTimes(1);
|
||||
expect(workflowService.get).toHaveBeenCalledWith('wf-1');
|
||||
expect(await getWorkflowSourceFileBinding({ ...context }, filePath)).toMatchObject({
|
||||
workflowId: 'wf-1',
|
||||
workflowVersionId: 'version-2',
|
||||
setupPending: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('announces new-account requirements and retains them for the first build', async () => {
|
||||
const { context, snapshots } = createContext();
|
||||
context.runId = 'run-early';
|
||||
await prepareWorkflowSetup(context, {
|
||||
filePath,
|
||||
workflowName: 'Daily update',
|
||||
credentials: [
|
||||
{ credentialType: 'slackApi', preferNew: true },
|
||||
{ credentialType: 'gmailOAuth2' },
|
||||
],
|
||||
});
|
||||
expect(snapshots.get('wf-1')).toContainEqual({
|
||||
id: 'wf-1:credential:slackApi',
|
||||
kind: 'credential',
|
||||
credentialType: 'slackApi',
|
||||
preferNew: true,
|
||||
});
|
||||
await expect(getWorkflowSourceFileBinding(context, filePath)).resolves.toMatchObject({
|
||||
setupPreferences: {
|
||||
runId: 'run-early',
|
||||
satisfiedCredentialTypes: [],
|
||||
preferNewCredentialTypes: ['slackApi'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses one workflow for concurrent setup calls for the same source', async () => {
|
||||
const { context, workflowService } = createContext();
|
||||
const input = { filePath, workflowName: 'Daily update', credentials: requirements };
|
||||
|
||||
const results = await Promise.all([
|
||||
prepareWorkflowSetup(context, input),
|
||||
prepareWorkflowSetup(context, input),
|
||||
]);
|
||||
|
||||
expect(results.map(({ workflowId }) => workflowId)).toEqual(['wf-1', 'wf-1']);
|
||||
expect(workflowService.createFromWorkflowJSON).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ filePath: '../outside.ts', workflowName: 'Daily update', credentials: requirements },
|
||||
{ filePath, workflowName: 'Daily update', credentials: [{ credentialType: 'httpHeaderAuth' }] },
|
||||
{ filePath, workflowName: ' ', credentials: requirements },
|
||||
{ filePath, workflowName: 'Daily update', credentials: [] },
|
||||
])('rejects invalid early setup before creating a workflow: %j', async (input) => {
|
||||
const { context, workflowService, publish } = createContext();
|
||||
|
||||
await expect(prepareWorkflowSetup(context, input)).rejects.toThrow();
|
||||
expect(workflowService.createFromWorkflowJSON).not.toHaveBeenCalled();
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks existing workflow access before announcing requirements', async () => {
|
||||
const { context, workflowService, publish } = createContext();
|
||||
vi.mocked(workflowService.get).mockRejectedValue(new Error('Workflow is unavailable'));
|
||||
|
||||
await expect(
|
||||
prepareWorkflowSetup(context, {
|
||||
filePath,
|
||||
workflowId: 'wf-foreign',
|
||||
credentials: requirements,
|
||||
}),
|
||||
).rejects.toThrow('Workflow is unavailable');
|
||||
expect(workflowService.get).toHaveBeenCalledWith('wf-foreign');
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('announces a first account when adding a service to an existing workflow', async () => {
|
||||
const { context, snapshots, workflowService } = createContext();
|
||||
await expect(
|
||||
prepareWorkflowSetup(context, {
|
||||
filePath,
|
||||
workflowId: 'wf-1',
|
||||
credentials: [{ credentialType: 'slackApi', preferNew: true }],
|
||||
}),
|
||||
).resolves.toMatchObject({ success: true, announced: true, preBuild: true });
|
||||
expect(workflowService.createFromWorkflowJSON).not.toHaveBeenCalled();
|
||||
expect(snapshots.get('wf-1')).toContainEqual(
|
||||
expect.objectContaining({ credentialType: 'slackApi', preferNew: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the temporary workflow after its first binding write fails', async () => {
|
||||
const { context, threadMemory, workflowService, publish } = createContext();
|
||||
threadMemory.saveThread.mockRejectedValueOnce(new Error('Storage unavailable'));
|
||||
const input = { filePath, workflowName: 'Daily update', credentials: requirements };
|
||||
|
||||
await expect(prepareWorkflowSetup(context, input)).rejects.toThrow('Could not persist');
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
expect(workflowService.clearAiTemporary).not.toHaveBeenCalled();
|
||||
expect(context.aiCreatedWorkflowIds).toContain('wf-1');
|
||||
|
||||
await expect(prepareWorkflowSetup(context, input)).resolves.toMatchObject({
|
||||
success: true,
|
||||
announced: true,
|
||||
workflowId: 'wf-1',
|
||||
});
|
||||
expect(workflowService.createFromWorkflowJSON).toHaveBeenCalledTimes(1);
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
expect(threadMemory.saveThread.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
publish.mock.invocationCallOrder[0],
|
||||
);
|
||||
await expect(getWorkflowSourceFileBinding({ ...context }, filePath)).resolves.toMatchObject({
|
||||
workflowId: 'wf-1',
|
||||
setupPending: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not create a workflow when persisted source bindings cannot be read', async () => {
|
||||
const { context, threadMemory, workflowService, publish } = createContext();
|
||||
threadMemory.getThread.mockRejectedValue(new Error('Storage unavailable'));
|
||||
|
||||
await expect(
|
||||
prepareWorkflowSetup(context, {
|
||||
filePath,
|
||||
workflowName: 'Daily update',
|
||||
credentials: requirements,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(workflowService.createFromWorkflowJSON).not.toHaveBeenCalled();
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['createWorkflow', 'updateWorkflow'] as const)(
|
||||
'respects a blocked %s permission',
|
||||
async (permission) => {
|
||||
const { context, workflowService } = createContext();
|
||||
context.permissions = mock<InstanceAiContext['permissions']>({ [permission]: 'blocked' });
|
||||
|
||||
await expect(
|
||||
prepareWorkflowSetup(context, {
|
||||
filePath,
|
||||
workflowName: 'Daily update',
|
||||
credentials: requirements,
|
||||
}),
|
||||
).rejects.toThrow('blocked by admin policy');
|
||||
expect(workflowService.createFromWorkflowJSON).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
import {
|
||||
AI_GATEWAY_MANAGED_TAG,
|
||||
TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
|
||||
instanceAiSetupCredentialAppliedKey,
|
||||
instanceAiSetupCredentialSelectionKey,
|
||||
readPendingInstanceAiSetupCredentialSelections,
|
||||
type InstanceAiSetupCredentialSelection,
|
||||
} from '@n8n/api-types';
|
||||
import type { NodeJSON, WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { ThreadPatch, ThreadRecord } from '../../../storage/thread-patch';
|
||||
import type { InstanceAiContext, NodeDescription, SetupItemsEmitter } from '../../../types';
|
||||
import { resolveCredentials, type CredentialMap } from '../resolve-credentials';
|
||||
import type { ResolvedCredential } from '../resolved-credential.schema';
|
||||
import {
|
||||
applyPendingSetupCredentialSelections,
|
||||
markSetupCredentialSelectionsApplied,
|
||||
} from '../setup-credential-selections';
|
||||
|
||||
const itemId = 'workflow-1:credential:slackApi';
|
||||
const selection: InstanceAiSetupCredentialSelection = {
|
||||
selectionId: 'choice-1',
|
||||
credentialType: 'slackApi',
|
||||
credentialId: 'selected-credential',
|
||||
};
|
||||
const credentialMap: CredentialMap = new Map([
|
||||
['slackApi', [{ id: 'selected-credential', name: 'Selected account', type: 'slackApi' }]],
|
||||
[
|
||||
'httpBasicAuth',
|
||||
[{ id: 'selected-credential', name: 'Selected account', type: 'httpBasicAuth' }],
|
||||
],
|
||||
]);
|
||||
|
||||
function node(name: string, type = 'slackApi', overrides: Partial<NodeJSON> = {}): NodeJSON {
|
||||
return { id: name, name, type, typeVersion: 1, position: [0, 0], ...overrides };
|
||||
}
|
||||
|
||||
function workflow(nodes: NodeJSON[]): WorkflowJSON {
|
||||
return { name: 'Test', nodes, connections: {} };
|
||||
}
|
||||
|
||||
function createContext(metadata: Record<string, unknown>) {
|
||||
const thread: ThreadRecord = {
|
||||
id: 'thread-1',
|
||||
resourceId: 'user-1',
|
||||
metadata,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const threadMemory = {
|
||||
getThread: vi.fn(async () => thread),
|
||||
patchThread: vi.fn(
|
||||
async ({ update }: { update: (current: ThreadRecord) => ThreadPatch | null | undefined }) => {
|
||||
Object.assign(thread, update(thread));
|
||||
return thread;
|
||||
},
|
||||
),
|
||||
};
|
||||
const context = mock<InstanceAiContext>({
|
||||
threadId: thread.id,
|
||||
threadMemory,
|
||||
setupItemsEmitter: mock<SetupItemsEmitter>(),
|
||||
logger: mock<InstanceAiContext['logger']>(),
|
||||
credentialService: mock<InstanceAiContext['credentialService']>(),
|
||||
workflowService: mock<InstanceAiContext['workflowService']>({
|
||||
getAsWorkflowJSON: vi.fn().mockResolvedValue(workflow([])),
|
||||
}),
|
||||
nodeService: mock<InstanceAiContext['nodeService']>({
|
||||
getNodeCredentialTypes: undefined,
|
||||
getDescription: vi.fn(
|
||||
async (type: string): Promise<NodeDescription> => ({
|
||||
name: type,
|
||||
displayName: type,
|
||||
description: '',
|
||||
version: 1,
|
||||
group: [],
|
||||
properties: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
credentials: [{ name: type, displayOptions: { hide: { authentication: ['none'] } } }],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
return {
|
||||
context,
|
||||
thread,
|
||||
threadMemory,
|
||||
apply: async (json: WorkflowJSON, candidates = credentialMap) =>
|
||||
await applyPendingSetupCredentialSelections(json, 'workflow-1', context, candidates),
|
||||
resolve: async (
|
||||
json: WorkflowJSON,
|
||||
candidates: CredentialMap,
|
||||
preferNewCredentialTypes?: readonly string[],
|
||||
setupSelections?: Record<string, ResolvedCredential[]>,
|
||||
) =>
|
||||
await resolveCredentials(
|
||||
json,
|
||||
'workflow-1',
|
||||
context,
|
||||
candidates,
|
||||
preferNewCredentialTypes,
|
||||
setupSelections,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('setup credential selections', () => {
|
||||
it.each([false, true])(
|
||||
'keeps node-specific choices when inserted first: %s',
|
||||
async (specificFirst) => {
|
||||
const specific = {
|
||||
...selection,
|
||||
selectionId: 'specific',
|
||||
credentialId: 'specific-account',
|
||||
nodeNames: ['First'],
|
||||
};
|
||||
const entries = [
|
||||
[instanceAiSetupCredentialSelectionKey(itemId), selection],
|
||||
[instanceAiSetupCredentialSelectionKey(`${itemId}:First`), specific],
|
||||
];
|
||||
const { apply } = createContext(
|
||||
Object.fromEntries(specificFirst ? entries.reverse() : entries),
|
||||
);
|
||||
const json = workflow([node('First'), node('Second')]);
|
||||
const candidates: CredentialMap = new Map([
|
||||
[
|
||||
'slackApi',
|
||||
[
|
||||
{ id: selection.credentialId, name: 'Default account', type: 'slackApi' },
|
||||
{ id: specific.credentialId, name: 'Specific account', type: 'slackApi' },
|
||||
],
|
||||
],
|
||||
]);
|
||||
const result = await apply(json, candidates);
|
||||
expect(json.nodes.map((current) => current.credentials?.slackApi?.id)).toEqual([
|
||||
'specific-account',
|
||||
'selected-credential',
|
||||
]);
|
||||
expect(result.resolvedCredentialsByNode.First).toEqual([
|
||||
{ type: 'slackApi', id: 'specific-account', name: 'Specific account' },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('applies an early choice to compatible nodes with the stored credential name', async () => {
|
||||
const { apply } = createContext({
|
||||
[instanceAiSetupCredentialSelectionKey(itemId)]: selection,
|
||||
});
|
||||
const json = workflow([
|
||||
node('First', 'slackApi', { credentials: { slackApi: { id: 'old', name: 'Old' } } }),
|
||||
node('Second'),
|
||||
node('Other service', 'gmailOAuth2'),
|
||||
node('Inactive', 'slackApi', { parameters: { authentication: 'none' } }),
|
||||
node('Disabled', 'slackApi', { disabled: true }),
|
||||
]);
|
||||
const result = await apply(json);
|
||||
expect(json.nodes.map(({ credentials }) => credentials)).toEqual([
|
||||
{ slackApi: { id: 'selected-credential', name: 'Selected account' } },
|
||||
{ slackApi: { id: 'selected-credential', name: 'Selected account' } },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
expect(Object.keys(result.resolvedCredentialsByNode)).toEqual(['First', 'Second']);
|
||||
expect(result.consumedSelections).toEqual([{ itemId, selection }]);
|
||||
});
|
||||
|
||||
it('keeps an unavailable choice open and removes its previous binding', async () => {
|
||||
const { context, apply, resolve } = createContext({
|
||||
[instanceAiSetupCredentialSelectionKey(itemId)]: selection,
|
||||
});
|
||||
const json = workflow([
|
||||
node('Slack', 'slackApi', {
|
||||
credentials: { slackApi: { id: 'other-project', name: 'Other' } },
|
||||
}),
|
||||
]);
|
||||
vi.mocked(context.workflowService.getAsWorkflowJSON).mockResolvedValue(structuredClone(json));
|
||||
const result = await apply(json, new Map());
|
||||
expect(json.nodes[0].credentials).toEqual({});
|
||||
expect(result.unavailableCredentialTypes).toEqual(['slackApi']);
|
||||
expect(result.consumedSelections).toEqual([]);
|
||||
await resolve(json, new Map(), result.unavailableCredentialTypes);
|
||||
expect(json.nodes[0].credentials).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps an explicit custom-auth choice through automatic resolution', async () => {
|
||||
const customType = TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE;
|
||||
const customSelection = { ...selection, credentialType: customType, nodeNames: ['API'] };
|
||||
const { apply, resolve } = createContext({
|
||||
[instanceAiSetupCredentialSelectionKey(`workflow-1:credential:${customType}:API`)]:
|
||||
customSelection,
|
||||
});
|
||||
const json = workflow([node('API', customType)]);
|
||||
const customMap: CredentialMap = new Map([
|
||||
[customType, [{ id: selection.credentialId, name: 'Selected account', type: customType }]],
|
||||
]);
|
||||
const selected = await apply(json, customMap);
|
||||
const resolved = await resolve(json, customMap, undefined, selected.resolvedCredentialsByNode);
|
||||
expect(json.nodes[0].credentials?.[customType]).toEqual({
|
||||
id: selection.credentialId,
|
||||
name: 'Selected account',
|
||||
});
|
||||
expect(resolved.mockedNodeNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('applies generic credentials only to the selected nodes', async () => {
|
||||
const genericSelection = {
|
||||
...selection,
|
||||
credentialType: 'httpBasicAuth',
|
||||
nodeNames: ['First'],
|
||||
};
|
||||
const genericId = 'workflow-1:credential:httpBasicAuth:First';
|
||||
const { apply } = createContext({
|
||||
[instanceAiSetupCredentialSelectionKey(genericId)]: genericSelection,
|
||||
});
|
||||
const json = workflow([node('First', 'httpBasicAuth'), node('Second', 'httpBasicAuth')]);
|
||||
await apply(json);
|
||||
expect(json.nodes[0].credentials).toEqual({
|
||||
httpBasicAuth: { id: 'selected-credential', name: 'Selected account' },
|
||||
});
|
||||
expect(json.nodes[1].credentials).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not fan a generic credential out without node names', async () => {
|
||||
const { apply } = createContext({
|
||||
[instanceAiSetupCredentialSelectionKey('workflow-1:credential:httpBasicAuth')]: {
|
||||
...selection,
|
||||
credentialType: 'httpBasicAuth',
|
||||
},
|
||||
});
|
||||
const json = workflow([node('First', 'httpBasicAuth')]);
|
||||
await apply(json);
|
||||
expect(json.nodes[0].credentials).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates Gateway support before applying its marker', async () => {
|
||||
const { context, apply } = createContext({
|
||||
[instanceAiSetupCredentialSelectionKey(itemId)]: {
|
||||
...selection,
|
||||
credentialId: AI_GATEWAY_MANAGED_TAG,
|
||||
},
|
||||
});
|
||||
const json = workflow([node('Slack')]);
|
||||
vi.mocked(context.credentialService.isAiGatewayCredentialType!).mockResolvedValue(false);
|
||||
expect((await apply(json)).unavailableCredentialTypes).toEqual(['slackApi']);
|
||||
vi.mocked(context.credentialService.isAiGatewayCredentialType!).mockResolvedValue(true);
|
||||
await apply(json);
|
||||
expect(json.nodes[0].credentials).toEqual({
|
||||
slackApi: { id: null, name: 'Gateway credits', __aiGatewayManaged: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps selected Gateway credits when a stored alternative exists', async () => {
|
||||
const { context, apply, resolve } = createContext({
|
||||
[instanceAiSetupCredentialSelectionKey(itemId)]: {
|
||||
...selection,
|
||||
credentialId: AI_GATEWAY_MANAGED_TAG,
|
||||
},
|
||||
});
|
||||
vi.mocked(context.credentialService.isAiGatewayCredentialType!).mockResolvedValue(true);
|
||||
const json = workflow([
|
||||
node('Slack', 'slackApi', {
|
||||
credentials: { slackApi: { id: 'selected-credential', name: 'Stored account' } },
|
||||
}),
|
||||
]);
|
||||
const selected = await apply(json);
|
||||
const resolved = await resolve(
|
||||
json,
|
||||
credentialMap,
|
||||
undefined,
|
||||
selected.resolvedCredentialsByNode,
|
||||
);
|
||||
|
||||
expect(json.nodes[0].credentials).toEqual({
|
||||
slackApi: { id: null, name: 'Gateway credits', __aiGatewayManaged: true },
|
||||
});
|
||||
expect(resolved.resolvedCredentialsByNode.Slack).toEqual([
|
||||
{ type: 'slackApi', id: null, name: 'Gateway credits', __aiGatewayManaged: true },
|
||||
]);
|
||||
expect(resolved.mockedCredentialTypes).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a choice until a later build adds the service', async () => {
|
||||
const { context, thread, apply, resolve } = createContext({
|
||||
[instanceAiSetupCredentialSelectionKey(itemId)]: selection,
|
||||
});
|
||||
const first = await apply(workflow([node('Trigger', 'manualTrigger')]));
|
||||
await markSetupCredentialSelectionsApplied(context, first.consumedSelections);
|
||||
expect(readPendingInstanceAiSetupCredentialSelections(thread.metadata, 'workflow-1')).toEqual([
|
||||
{ itemId, selection },
|
||||
]);
|
||||
|
||||
const json = workflow([
|
||||
node('Slack', 'slackApi', {
|
||||
credentials: { slackApi: { id: 'other', name: 'Other account' } },
|
||||
}),
|
||||
]);
|
||||
const candidates: CredentialMap = new Map([
|
||||
[
|
||||
'slackApi',
|
||||
[
|
||||
{ id: selection.credentialId, name: 'Selected account', type: 'slackApi' },
|
||||
{ id: 'other', name: 'Other account', type: 'slackApi' },
|
||||
],
|
||||
],
|
||||
]);
|
||||
const result = await apply(json, candidates);
|
||||
await resolve(json, candidates, undefined, result.resolvedCredentialsByNode);
|
||||
expect(json.nodes[0].credentials?.slackApi?.id).toBe(selection.credentialId);
|
||||
await markSetupCredentialSelectionsApplied(context, result.consumedSelections);
|
||||
expect(readPendingInstanceAiSetupCredentialSelections(thread.metadata, 'workflow-1')).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a newer selection when the earlier workflow save finishes', async () => {
|
||||
const { context, thread, apply } = createContext({
|
||||
[instanceAiSetupCredentialSelectionKey(itemId)]: selection,
|
||||
});
|
||||
const result = await apply(workflow([node('Slack')]));
|
||||
const newerSelection = { ...selection, selectionId: 'choice-2' };
|
||||
thread.metadata = { [instanceAiSetupCredentialSelectionKey(itemId)]: newerSelection };
|
||||
await markSetupCredentialSelectionsApplied(context, result.consumedSelections);
|
||||
expect(
|
||||
thread.metadata[instanceAiSetupCredentialAppliedKey(itemId, newerSelection.selectionId)],
|
||||
).toBeUndefined();
|
||||
expect(readPendingInstanceAiSetupCredentialSelections(thread.metadata, 'workflow-1')).toEqual([
|
||||
{ itemId, selection: newerSelection },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the choice pending if marking a successful save fails', async () => {
|
||||
const { context, thread, threadMemory } = createContext({
|
||||
[instanceAiSetupCredentialSelectionKey(itemId)]: selection,
|
||||
});
|
||||
threadMemory.patchThread.mockRejectedValue(new Error('Storage unavailable'));
|
||||
await expect(
|
||||
markSetupCredentialSelectionsApplied(context, [{ itemId, selection }]),
|
||||
).resolves.toBeUndefined();
|
||||
expect(readPendingInstanceAiSetupCredentialSelections(thread.metadata, 'workflow-1')).toEqual([
|
||||
{ itemId, selection },
|
||||
]);
|
||||
expect(context.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not read selections when the setup panel is disabled', async () => {
|
||||
const { context, threadMemory, apply } = createContext({
|
||||
[instanceAiSetupCredentialSelectionKey(itemId)]: selection,
|
||||
});
|
||||
context.setupItemsEmitter = undefined;
|
||||
await apply(workflow([node('Slack')]));
|
||||
expect(threadMemory.getThread).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
buildSetupItemsFromCredentialRequests,
|
||||
buildSetupItemsFromSetupRequests,
|
||||
createSetupItemsEmitter,
|
||||
requestsCredentialReplacement,
|
||||
} from '../setup-items';
|
||||
import type { SetupRequest } from '../setup-workflow.schema';
|
||||
|
||||
|
|
@ -66,6 +67,23 @@ function setupRequest(overrides: {
|
|||
};
|
||||
}
|
||||
|
||||
describe('requestsCredentialReplacement', () => {
|
||||
it.each([
|
||||
{ boundCredential: undefined, replacement: false },
|
||||
{ boundCredential: { id: '', name: 'Unconnected' }, replacement: false },
|
||||
{ boundCredential: { id: 'existing', name: 'Existing' }, replacement: true },
|
||||
{ boundCredential: { id: null, name: 'Gateway credits' }, replacement: true },
|
||||
])('requires an existing account for replacement: %j', ({ boundCredential, replacement }) => {
|
||||
const request = setupRequest({
|
||||
nodeName: 'Slack',
|
||||
credentialType: 'slackApi',
|
||||
boundCredential,
|
||||
});
|
||||
expect(requestsCredentialReplacement([request], ['slackApi'])).toBe(replacement);
|
||||
expect(requestsCredentialReplacement([request], ['gmailOAuth2'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSetupItemsEmitter', () => {
|
||||
it('waits for persisted state before it completes the final announcement', async () => {
|
||||
const items: InstanceAiSetupItem[] = [
|
||||
|
|
@ -355,6 +373,28 @@ const templatedAuthHint = {
|
|||
};
|
||||
|
||||
describe('buildSetupItemsFromCredentialRequests', () => {
|
||||
it.each([
|
||||
[undefined, true],
|
||||
[true, undefined],
|
||||
])('preserves preferNew across duplicate requests (%s, %s)', (first, second) => {
|
||||
const requests = [first, second].map((preferNew) => ({
|
||||
credentialType: 'slackApi',
|
||||
preferNew,
|
||||
}));
|
||||
const analyzed = [
|
||||
setupRequest({ nodeName: 'Slack', credentialType: 'slackApi', credentialNeedsAction: true }),
|
||||
];
|
||||
|
||||
for (const items of [
|
||||
buildSetupItemsFromCredentialRequests('wf-1', requests),
|
||||
buildSetupItemsFromAnnouncement('wf-1', requests, analyzed),
|
||||
]) {
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({ credentialType: 'slackApi', preferNew: true }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('builds one service-keyed credential item per type, carrying reason and setupHint', () => {
|
||||
const items = buildSetupItemsFromCredentialRequests('wf-1', [
|
||||
{ credentialType: 'slackApi', reason: 'to post alerts' },
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ import {
|
|||
import { resolvedCredentialSchema } from './resolved-credential.schema';
|
||||
import { describeSavedPublishState, savedWorkflowStateSchema } from './saved-workflow-state';
|
||||
import { isSetupPanelEnabled } from './setup-items';
|
||||
import {
|
||||
applyPendingSetupCredentialSelections,
|
||||
markSetupCredentialSelectionsApplied,
|
||||
} from './setup-credential-selections';
|
||||
import { recordWorkflowSetupState } from './setup-panel-state';
|
||||
import { getSkippedSetupSubjects, partitionSkippedSetupRequests } from './setup-skip-state';
|
||||
import { analyzeWorkflow, stripStaleCredentialsFromWorkflow } from './setup-workflow.service';
|
||||
|
|
@ -1174,13 +1178,49 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
|||
}
|
||||
|
||||
const credentialMap = await buildCredentialMap(context.credentialService);
|
||||
const setupSelections = await applyPendingSetupCredentialSelections(
|
||||
json,
|
||||
targetWorkflowId,
|
||||
context,
|
||||
credentialMap,
|
||||
);
|
||||
const setupPreferences =
|
||||
isSetupPanelEnabled(context) && binding.setupPreferences?.runId === context.runId
|
||||
? binding.setupPreferences
|
||||
: undefined;
|
||||
const selectedTypes = new Set(
|
||||
Object.values(setupSelections.resolvedCredentialsByNode)
|
||||
.flat()
|
||||
.map((credential) => credential.type),
|
||||
);
|
||||
// Keep a saved choice when the model repeats setup flags during a repair.
|
||||
const preferNewCredentialTypes = [
|
||||
...new Set([
|
||||
...(setupPreferences?.preferNewCredentialTypes ?? []),
|
||||
...(input.preferNewCredentials ?? []),
|
||||
]),
|
||||
].filter(
|
||||
(type) =>
|
||||
!selectedTypes.has(type) && !setupPreferences?.satisfiedCredentialTypes.includes(type),
|
||||
);
|
||||
const mockResult = await resolveCredentials(
|
||||
json,
|
||||
targetWorkflowId,
|
||||
context,
|
||||
credentialMap,
|
||||
input.preferNewCredentials,
|
||||
[...preferNewCredentialTypes, ...setupSelections.unavailableCredentialTypes],
|
||||
setupSelections.resolvedCredentialsByNode,
|
||||
);
|
||||
for (const [nodeName, selections] of Object.entries(
|
||||
setupSelections.resolvedCredentialsByNode,
|
||||
)) {
|
||||
mockResult.resolvedCredentialsByNode[nodeName] = [
|
||||
...(mockResult.resolvedCredentialsByNode[nodeName] ?? []).filter(
|
||||
(credential) => !selections.some((selection) => selection.type === credential.type),
|
||||
),
|
||||
...selections,
|
||||
];
|
||||
}
|
||||
|
||||
// Deterministic backstop for a builder that never checked credentials:
|
||||
// a chat-model node for a provider the user has no credential for gets
|
||||
|
|
@ -1418,15 +1458,14 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
|||
},
|
||||
operation: 'create' | 'update',
|
||||
) => {
|
||||
await markSetupCredentialSelectionsApplied(context, setupSelections.consumedSelections);
|
||||
// The setup panel lists bound slots too (rendered as done), so its
|
||||
// snapshot needs the settled requests the routing below must not see.
|
||||
const setupItemsEmitter = isSetupPanelEnabled(context)
|
||||
? context.setupItemsEmitter
|
||||
: undefined;
|
||||
const analyzedRequests = await analyzeWorkflow(context, saved.id, undefined, {
|
||||
...(input.preferNewCredentials
|
||||
? { preferNewCredentialTypes: input.preferNewCredentials }
|
||||
: {}),
|
||||
...(preferNewCredentialTypes.length > 0 ? { preferNewCredentialTypes } : {}),
|
||||
...(setupItemsEmitter ? { includeSettled: true } : {}),
|
||||
});
|
||||
const setupRequests = analyzedRequests.filter((request) => !!request.needsAction);
|
||||
|
|
@ -1476,6 +1515,18 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
|||
workflowVersionId: saved.versionId,
|
||||
...(saved.checksum ? { workflowChecksum: saved.checksum } : {}),
|
||||
sourceHash,
|
||||
setupPending: undefined,
|
||||
...(setupPreferences
|
||||
? {
|
||||
setupPreferences: {
|
||||
...setupPreferences,
|
||||
preferNewCredentialTypes,
|
||||
satisfiedCredentialTypes: [
|
||||
...new Set([...setupPreferences.satisfiedCredentialTypes, ...selectedTypes]),
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
// Trace-only compiled-JSON event for eval seed reconstruction — never part
|
||||
// of the tool result, so it never enters the agent's context.
|
||||
|
|
@ -1635,7 +1686,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
|||
json,
|
||||
updateOptions,
|
||||
);
|
||||
return await createSuccessResponse(updated, 'update');
|
||||
return await createSuccessResponse(updated, binding.setupPending ? 'create' : 'update');
|
||||
}
|
||||
|
||||
const created = await context.workflowService.createFromWorkflowJSON(json, {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
import { GENERIC_AUTH_CREDENTIAL_TYPES } from '@n8n/api-types';
|
||||
import { OperationalError, UserError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
buildSetupItemsFromAnnouncement,
|
||||
buildSetupItemsFromCredentialRequests,
|
||||
isSetupPanelEnabled,
|
||||
requestsCredentialReplacement,
|
||||
type AnnouncedCredentialRequest,
|
||||
} from './setup-items';
|
||||
import { analyzeWorkflow } from './setup-workflow.service';
|
||||
import { recordSessionOwnedWorkflow } from './workflow-build-context';
|
||||
import { promoteMainWorkflow } from './workflow-build-reporting';
|
||||
import {
|
||||
getWorkflowSourceFileBinding,
|
||||
normalizeWorkflowSourceFilePath,
|
||||
saveWorkflowSourceFileBinding,
|
||||
type WorkflowSourceFileBinding,
|
||||
} from './workflow-file-bindings';
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
|
||||
const pendingPreparations = new WeakMap<InstanceAiContext, Promise<unknown>>();
|
||||
|
||||
/** Parallel tool calls must not create two workflows for the same source file. */
|
||||
export async function prepareWorkflowSetup(
|
||||
context: InstanceAiContext,
|
||||
input: Parameters<typeof prepare>[1],
|
||||
) {
|
||||
const previous = pendingPreparations.get(context) ?? Promise.resolve();
|
||||
const pending = previous.catch(() => undefined).then(async () => await prepare(context, input));
|
||||
pendingPreparations.set(context, pending);
|
||||
try {
|
||||
return await pending;
|
||||
} finally {
|
||||
if (pendingPreparations.get(context) === pending) pendingPreparations.delete(context);
|
||||
}
|
||||
}
|
||||
|
||||
/** Establish the saved workflow identity before the model writes its source. */
|
||||
async function prepare(
|
||||
context: InstanceAiContext,
|
||||
input: {
|
||||
filePath: string;
|
||||
workflowName?: string;
|
||||
workflowId?: string;
|
||||
folderPath?: string;
|
||||
credentials: readonly AnnouncedCredentialRequest[];
|
||||
},
|
||||
) {
|
||||
if (!isSetupPanelEnabled(context) || !context.threadMemory || !context.threadId) {
|
||||
throw new OperationalError('Early workflow setup is unavailable');
|
||||
}
|
||||
if (
|
||||
input.credentials.some(({ credentialType }) =>
|
||||
GENERIC_AUTH_CREDENTIAL_TYPES.has(credentialType),
|
||||
)
|
||||
) {
|
||||
throw new UserError(
|
||||
'Announce service-specific credential types before building. Leave generic authentication for workflow setup after the build.',
|
||||
);
|
||||
}
|
||||
const filePath = normalizeWorkflowSourceFilePath(input.filePath, {
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
});
|
||||
let binding = await getWorkflowSourceFileBinding(context, filePath, { requirePersistence: true });
|
||||
if (input.workflowId && binding?.workflowId && input.workflowId !== binding.workflowId) {
|
||||
throw new UserError(
|
||||
'The source file belongs to another workflow. Use its bound workflow or a different filePath.',
|
||||
);
|
||||
}
|
||||
const workflowId = binding?.workflowId ?? input.workflowId;
|
||||
const satisfiedCredentialTypes =
|
||||
binding?.setupPreferences?.runId === context.runId
|
||||
? (binding?.setupPreferences?.satisfiedCredentialTypes ?? [])
|
||||
: [];
|
||||
const setupPreferences: NonNullable<WorkflowSourceFileBinding['setupPreferences']> = {
|
||||
runId: context.runId,
|
||||
satisfiedCredentialTypes,
|
||||
preferNewCredentialTypes: input.credentials
|
||||
.filter(
|
||||
(request) =>
|
||||
request.preferNew && !satisfiedCredentialTypes.includes(request.credentialType),
|
||||
)
|
||||
.map((request) => request.credentialType),
|
||||
};
|
||||
let analyzedRequests: Awaited<ReturnType<typeof analyzeWorkflow>> | undefined;
|
||||
if (workflowId) {
|
||||
// Validate access before publishing a workflow-scoped announcement.
|
||||
const saved = await context.workflowService.get(workflowId);
|
||||
if (!binding?.setupPending) {
|
||||
analyzedRequests = await analyzeWorkflow(context, workflowId, undefined, {
|
||||
includeSettled: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
analyzedRequests &&
|
||||
requestsCredentialReplacement(analyzedRequests, setupPreferences.preferNewCredentialTypes)
|
||||
) {
|
||||
throw new UserError(
|
||||
'Use credential setup without filePath to replace an existing workflow account.',
|
||||
);
|
||||
}
|
||||
binding = await saveWorkflowSourceFileBinding(
|
||||
context,
|
||||
{
|
||||
...binding,
|
||||
filePath,
|
||||
workflowId,
|
||||
workflowVersionId: saved.versionId,
|
||||
workflowChecksum: saved.checksum,
|
||||
setupPreferences,
|
||||
},
|
||||
{ requirePersistence: true },
|
||||
);
|
||||
if (context.aiCreatedWorkflowIds?.has(workflowId)) {
|
||||
await recordSessionOwnedWorkflow(context, workflowId);
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
context.permissions?.createWorkflow === 'blocked' ||
|
||||
context.permissions?.updateWorkflow === 'blocked'
|
||||
) {
|
||||
throw new UserError('Early workflow setup is blocked by admin policy');
|
||||
}
|
||||
if (!input.workflowName?.trim() || input.credentials.length === 0) {
|
||||
throw new UserError(
|
||||
'Provide a workflowName and the known credential requirements for a new workflow.',
|
||||
);
|
||||
}
|
||||
const saved = await context.workflowService.createFromWorkflowJSON(
|
||||
{ name: input.workflowName, nodes: [], connections: {} },
|
||||
{ markAsAiTemporary: true, ...(input.folderPath ? { folderPath: input.folderPath } : {}) },
|
||||
);
|
||||
try {
|
||||
binding = await saveWorkflowSourceFileBinding(
|
||||
context,
|
||||
{
|
||||
filePath,
|
||||
workflowId: saved.id,
|
||||
workflowVersionId: saved.versionId,
|
||||
workflowChecksum: saved.checksum,
|
||||
setupPending: true,
|
||||
setupPreferences,
|
||||
},
|
||||
{ requirePersistence: true },
|
||||
);
|
||||
} finally {
|
||||
await recordSessionOwnedWorkflow(context, saved.id);
|
||||
}
|
||||
}
|
||||
if (!binding.workflowId)
|
||||
throw new OperationalError('The workflow setup context has no workflow ID');
|
||||
if (binding.setupPending) await promoteMainWorkflow(context, binding.workflowId);
|
||||
const items = binding.setupPending
|
||||
? buildSetupItemsFromCredentialRequests(binding.workflowId, input.credentials)
|
||||
: buildSetupItemsFromAnnouncement(
|
||||
binding.workflowId,
|
||||
input.credentials,
|
||||
analyzedRequests ??
|
||||
(await analyzeWorkflow(context, binding.workflowId, undefined, { includeSettled: true })),
|
||||
);
|
||||
await context.setupItemsEmitter.announce(binding.workflowId, items);
|
||||
return {
|
||||
success: true,
|
||||
announced: true,
|
||||
preBuild: true,
|
||||
workflowId: binding.workflowId,
|
||||
filePath,
|
||||
message:
|
||||
'The setup panel is ready. Continue writing the workflow source and call build-workflow with this filePath. Do not wait for connections or repeat folderPath on the build. The user can connect while you work. Their selections will be applied to the generated nodes. If the plan changes before building, call setup again with the complete remaining requirements.',
|
||||
};
|
||||
}
|
||||
|
|
@ -183,6 +183,7 @@ export async function resolveCredentials(
|
|||
ctx: InstanceAiContext,
|
||||
availableCredentials?: CredentialMap,
|
||||
preferNewCredentialTypes?: readonly string[],
|
||||
setupSelections?: Record<string, ResolvedCredential[]>,
|
||||
): Promise<CredentialResolutionResult> {
|
||||
const preferNewTypes = new Set(preferNewCredentialTypes ?? []);
|
||||
const heldForNewCredentialTypes = new Set<string>();
|
||||
|
|
@ -435,6 +436,14 @@ export async function resolveCredentials(
|
|||
mockCredential();
|
||||
};
|
||||
|
||||
const setupSelection = node.name
|
||||
? setupSelections?.[node.name]?.find((selection) => selection.type === key)
|
||||
: undefined;
|
||||
if (setupSelection?.id === null && (await isGatewayCredentialType(key))) {
|
||||
await attachGatewayCredential();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
// Templated Custom Auth ids are service-agnostic at the type level, so a
|
||||
// model-attached reference can silently wire another service's credential
|
||||
|
|
@ -444,7 +453,8 @@ export async function resolveCredentials(
|
|||
if (key === TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE) {
|
||||
const suppliedId = getCredentialId(value);
|
||||
const priorId = getCredentialId(existingCreds?.[key]);
|
||||
if (suppliedId !== undefined && suppliedId === priorId) {
|
||||
const selectedByUser = setupSelection?.id === suppliedId;
|
||||
if (suppliedId !== undefined && (suppliedId === priorId || selectedByUser)) {
|
||||
cleanupMockPinData(json, node.name);
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
import {
|
||||
AI_GATEWAY_MANAGED_TAG,
|
||||
GENERIC_AUTH_CREDENTIAL_TYPES,
|
||||
instanceAiSetupCredentialAppliedKey,
|
||||
readPendingInstanceAiSetupCredentialSelections,
|
||||
} from '@n8n/api-types';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import {
|
||||
AI_GATEWAY_CREDENTIAL,
|
||||
N8N_CONNECT_DISPLAY_NAME,
|
||||
assignCredentialToNode,
|
||||
} from './credential-utils';
|
||||
import type { CredentialMap } from './resolve-credentials';
|
||||
import type { ResolvedCredential } from './resolved-credential.schema';
|
||||
import { isSetupPanelEnabled } from './setup-items';
|
||||
import { getValidCredentialTypes } from './setup-workflow.service';
|
||||
import { findWorkflowSourceFileBindingsForWorkflow } from './workflow-file-bindings';
|
||||
import { getThread, patchThread } from '../../storage/thread-patch';
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
|
||||
type PendingSelection = ReturnType<typeof readPendingInstanceAiSetupCredentialSelections>[number];
|
||||
|
||||
/** A repeated setup call must retain choices already saved during this run. */
|
||||
export async function filterSatisfiedSetupCredentialTypes(
|
||||
context: InstanceAiContext,
|
||||
workflowId: string,
|
||||
credentialTypes: readonly string[] | undefined,
|
||||
): Promise<readonly string[] | undefined> {
|
||||
if (!isSetupPanelEnabled(context) || !context.runId || !credentialTypes?.length)
|
||||
return credentialTypes;
|
||||
const bindings = await findWorkflowSourceFileBindingsForWorkflow(context, workflowId);
|
||||
const satisfied = new Set(
|
||||
bindings.flatMap(({ setupPreferences }) =>
|
||||
setupPreferences && setupPreferences.runId === context.runId
|
||||
? setupPreferences.satisfiedCredentialTypes
|
||||
: [],
|
||||
),
|
||||
);
|
||||
return credentialTypes.filter((type) => !satisfied.has(type));
|
||||
}
|
||||
|
||||
export interface SetupCredentialSelectionResult {
|
||||
consumedSelections: PendingSelection[];
|
||||
unavailableCredentialTypes: string[];
|
||||
resolvedCredentialsByNode: Record<string, ResolvedCredential[]>;
|
||||
}
|
||||
|
||||
/** Apply the user's latest choices before automatic credential resolution. */
|
||||
export async function applyPendingSetupCredentialSelections(
|
||||
json: WorkflowJSON,
|
||||
workflowId: string | undefined,
|
||||
context: InstanceAiContext,
|
||||
credentialMap: CredentialMap,
|
||||
): Promise<SetupCredentialSelectionResult> {
|
||||
const result: SetupCredentialSelectionResult = {
|
||||
consumedSelections: [],
|
||||
unavailableCredentialTypes: [],
|
||||
resolvedCredentialsByNode: {},
|
||||
};
|
||||
if (!isSetupPanelEnabled(context) || !workflowId || !context.threadId || !context.threadMemory) {
|
||||
return result;
|
||||
}
|
||||
const thread = await getThread(context.threadMemory, context.threadId);
|
||||
const selections = readPendingInstanceAiSetupCredentialSelections(thread?.metadata, workflowId);
|
||||
if (selections.length === 0) return result;
|
||||
|
||||
const activeNodes = await Promise.all(
|
||||
json.nodes
|
||||
.filter((node) => !node.disabled && node.name)
|
||||
.map(async (node) => ({
|
||||
node,
|
||||
credentialTypes: await getValidCredentialTypes(context, node),
|
||||
})),
|
||||
);
|
||||
|
||||
for (const pending of selections) {
|
||||
const { credentialType, credentialId, nodeNames } = pending.selection;
|
||||
const generic = GENERIC_AUTH_CREDENTIAL_TYPES.has(credentialType);
|
||||
const targets = activeNodes.filter(
|
||||
({ node, credentialTypes }) =>
|
||||
credentialTypes.has(credentialType) &&
|
||||
(nodeNames ? nodeNames.includes(node.name ?? '') : !generic),
|
||||
);
|
||||
if (targets.length === 0) continue;
|
||||
|
||||
const stored = credentialMap.get(credentialType)?.find(({ id }) => id === credentialId);
|
||||
const gateway =
|
||||
credentialId === AI_GATEWAY_MANAGED_TAG &&
|
||||
(await context.credentialService.isAiGatewayCredentialType?.(credentialType));
|
||||
if (!stored && !gateway) {
|
||||
// Leave these slots open instead of substituting another account.
|
||||
for (const { node } of targets) {
|
||||
delete node.credentials?.[credentialType];
|
||||
if (node.name && result.resolvedCredentialsByNode[node.name]) {
|
||||
result.resolvedCredentialsByNode[node.name] = result.resolvedCredentialsByNode[
|
||||
node.name
|
||||
].filter((credential) => credential.type !== credentialType);
|
||||
}
|
||||
}
|
||||
result.unavailableCredentialTypes.push(credentialType);
|
||||
continue;
|
||||
}
|
||||
|
||||
const credential = stored
|
||||
? { id: stored.id, name: stored.name }
|
||||
: { ...AI_GATEWAY_CREDENTIAL, name: N8N_CONNECT_DISPLAY_NAME };
|
||||
for (const { node } of targets) {
|
||||
if (!node.name) continue;
|
||||
assignCredentialToNode(node, credentialType, credential);
|
||||
result.resolvedCredentialsByNode[node.name] = [
|
||||
...(result.resolvedCredentialsByNode[node.name] ?? []).filter(
|
||||
(current) => current.type !== credentialType,
|
||||
),
|
||||
{ type: credentialType, ...credential },
|
||||
];
|
||||
}
|
||||
result.consumedSelections.push(pending);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Call only after the workflow save succeeds. */
|
||||
export async function markSetupCredentialSelectionsApplied(
|
||||
context: InstanceAiContext,
|
||||
selections: readonly PendingSelection[],
|
||||
): Promise<void> {
|
||||
if (!context.threadMemory || !context.threadId || selections.length === 0) return;
|
||||
try {
|
||||
await patchThread(context.threadMemory, {
|
||||
threadId: context.threadId,
|
||||
update: ({ metadata = {} }) => {
|
||||
const updated = { ...metadata };
|
||||
for (const { itemId, selection } of selections) {
|
||||
updated[instanceAiSetupCredentialAppliedKey(itemId, selection.selectionId)] = true;
|
||||
}
|
||||
return { metadata: updated };
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
context.logger?.warn('Failed to mark setup credential selections as applied', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '@n8n/api-types';
|
||||
import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
import { isAiGatewayManagedCredential } from './credential-utils';
|
||||
import type { SetupRequest } from './setup-workflow.schema';
|
||||
import type { InstanceAiEventBus } from '../../event-bus/event-bus.interface';
|
||||
import type { InstanceAiContext, SetupItemsEmitter } from '../../types';
|
||||
|
|
@ -149,9 +150,21 @@ export function parametersSetupItemId(workflowId: string, nodeName: string): str
|
|||
export interface AnnouncedCredentialRequest {
|
||||
credentialType: string;
|
||||
reason?: string;
|
||||
preferNew?: boolean;
|
||||
setupHint?: InstanceAiCredentialSetupHint;
|
||||
}
|
||||
|
||||
function mergeCredentialRequests(requests: readonly AnnouncedCredentialRequest[]) {
|
||||
const byType = new Map<string, AnnouncedCredentialRequest>();
|
||||
for (const request of requests) {
|
||||
const existing = byType.get(request.credentialType);
|
||||
if (!existing || request.preferNew) {
|
||||
byType.set(request.credentialType, existing ? { ...existing, preferNew: true } : request);
|
||||
}
|
||||
}
|
||||
return byType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Items for a `credentials(action="setup")` announcement without node context:
|
||||
* one service-keyed row per type, no bindings; the next build snapshot fans them
|
||||
|
|
@ -162,20 +175,20 @@ export function buildSetupItemsFromCredentialRequests(
|
|||
workflowId: string,
|
||||
requests: readonly AnnouncedCredentialRequest[],
|
||||
): InstanceAiSetupItem[] {
|
||||
const byType = new Map<string, InstanceAiSetupItem>();
|
||||
for (const request of requests) {
|
||||
const items: InstanceAiSetupItem[] = [];
|
||||
for (const request of mergeCredentialRequests(requests).values()) {
|
||||
if (GENERIC_AUTH_CREDENTIAL_TYPES.has(request.credentialType)) continue;
|
||||
const id = credentialSetupItemId(workflowId, request.credentialType);
|
||||
if (byType.has(id)) continue;
|
||||
byType.set(id, {
|
||||
items.push({
|
||||
id,
|
||||
kind: 'credential',
|
||||
credentialType: request.credentialType,
|
||||
...(request.preferNew !== undefined ? { preferNew: request.preferNew } : {}),
|
||||
...(request.reason ? { reason: request.reason } : {}),
|
||||
...(request.setupHint ? { setupHint: request.setupHint } : {}),
|
||||
});
|
||||
}
|
||||
return [...byType.values()];
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -190,12 +203,7 @@ export function buildSetupItemsFromAnnouncement(
|
|||
requests: readonly AnnouncedCredentialRequest[],
|
||||
analyzedRequests: readonly SetupRequest[],
|
||||
): InstanceAiSetupItem[] {
|
||||
const requestByType = new Map<string, AnnouncedCredentialRequest>();
|
||||
for (const request of requests) {
|
||||
if (!requestByType.has(request.credentialType)) {
|
||||
requestByType.set(request.credentialType, request);
|
||||
}
|
||||
}
|
||||
const requestByType = mergeCredentialRequests(requests);
|
||||
const coveredTypes = new Set<string>();
|
||||
const items = buildSetupItemsFromSetupRequests(workflowId, analyzedRequests).map((item) => {
|
||||
if (item.kind !== 'credential') return item;
|
||||
|
|
@ -204,6 +212,7 @@ export function buildSetupItemsFromAnnouncement(
|
|||
coveredTypes.add(item.credentialType);
|
||||
return {
|
||||
...item,
|
||||
...(request.preferNew !== undefined ? { preferNew: request.preferNew } : {}),
|
||||
...(request.reason ? { reason: request.reason } : {}),
|
||||
...(request.setupHint ? { setupHint: request.setupHint } : {}),
|
||||
};
|
||||
|
|
@ -223,6 +232,20 @@ function isBoundToStoredCredential(request: SetupRequest): boolean {
|
|||
return typeof bound?.id === 'string' && bound.id.length > 0;
|
||||
}
|
||||
|
||||
/** A request for a first account does not replace an existing binding. */
|
||||
export function requestsCredentialReplacement(
|
||||
requests: readonly SetupRequest[],
|
||||
credentialTypes: readonly string[] = [],
|
||||
): boolean {
|
||||
return requests.some(
|
||||
(request) =>
|
||||
request.credentialType !== undefined &&
|
||||
credentialTypes.includes(request.credentialType) &&
|
||||
(isBoundToStoredCredential(request) ||
|
||||
isAiGatewayManagedCredential(request.node.credentials?.[request.credentialType])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Items for a saved workflow from its setup analysis (`analyzeWorkflow` with
|
||||
* `includeSettled`): every credential slot that is open or already bound to a
|
||||
|
|
@ -250,11 +273,13 @@ export function buildSetupItemsFromSetupRequests(
|
|||
existing.nodeBindings = [...(existing.nodeBindings ?? []), { nodeName }];
|
||||
}
|
||||
if (!existing.setupHint && request.setupHint) existing.setupHint = request.setupHint;
|
||||
if (request.preferNewCredential) existing.preferNew = true;
|
||||
} else {
|
||||
credentialItems.set(id, {
|
||||
id,
|
||||
kind: 'credential',
|
||||
credentialType,
|
||||
...(request.preferNewCredential ? { preferNew: true } : {}),
|
||||
nodeBindings: [{ nodeName }],
|
||||
...(request.setupHint ? { setupHint: request.setupHint } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
import { getThread, patchThread } from '../../storage/thread-patch';
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
|
|
@ -14,6 +15,14 @@ const workflowSourceFileBindingSchema = z.object({
|
|||
workflowVersionId: z.string().optional(),
|
||||
workflowChecksum: z.string().optional(),
|
||||
sourceHash: z.string().optional(),
|
||||
setupPending: z.boolean().optional(),
|
||||
setupPreferences: z
|
||||
.object({
|
||||
runId: z.string().optional(),
|
||||
satisfiedCredentialTypes: z.array(z.string()),
|
||||
preferNewCredentialTypes: z.array(z.string()),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const workflowSourceFileBindingsSchema = z.record(z.string(), workflowSourceFileBindingSchema);
|
||||
|
|
@ -53,13 +62,22 @@ function getFallbackBindings(context: InstanceAiContext): Map<string, WorkflowSo
|
|||
|
||||
async function readThreadBindings(
|
||||
context: InstanceAiContext,
|
||||
requirePersistence = false,
|
||||
): Promise<Record<string, WorkflowSourceFileBinding> | undefined> {
|
||||
if (!context.threadMemory || !context.threadId) return undefined;
|
||||
if (!context.threadMemory || !context.threadId) {
|
||||
if (requirePersistence) throw new OperationalError('Workflow setup persistence is unavailable');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const thread = await getThread(context.threadMemory, context.threadId);
|
||||
if (requirePersistence) {
|
||||
if (!thread) throw new OperationalError('The workflow setup thread is unavailable');
|
||||
return workflowSourceFileBindingsSchema.parse(thread.metadata?.[METADATA_KEY] ?? {});
|
||||
}
|
||||
return parseBindings(thread?.metadata?.[METADATA_KEY]);
|
||||
} catch (error) {
|
||||
if (requirePersistence) throw error;
|
||||
context.logger?.debug('Failed to read workflow source file bindings from thread metadata', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
|
@ -70,9 +88,10 @@ async function readThreadBindings(
|
|||
export async function getWorkflowSourceFileBinding(
|
||||
context: InstanceAiContext,
|
||||
filePath: string,
|
||||
options: { requirePersistence?: boolean } = {},
|
||||
): Promise<WorkflowSourceFileBinding | undefined> {
|
||||
const normalizedFilePath = normalizeWorkflowSourceFilePath(filePath);
|
||||
const threadBindings = await readThreadBindings(context);
|
||||
const threadBindings = await readThreadBindings(context, options.requirePersistence);
|
||||
if (threadBindings) {
|
||||
return (
|
||||
threadBindings[normalizedFilePath] ?? getFallbackBindings(context).get(normalizedFilePath)
|
||||
|
|
@ -109,14 +128,14 @@ export async function findWorkflowSourceFileBindingsForWorkflow(
|
|||
export async function saveWorkflowSourceFileBinding(
|
||||
context: InstanceAiContext,
|
||||
binding: WorkflowSourceFileBinding,
|
||||
options: { requirePersistence?: boolean } = {},
|
||||
): Promise<WorkflowSourceFileBinding> {
|
||||
const normalizedBinding = {
|
||||
...binding,
|
||||
filePath: normalizeWorkflowSourceFilePath(binding.filePath),
|
||||
};
|
||||
|
||||
// Always keep the run-local copy: a later thread-metadata read can fail, and the
|
||||
// binding must still be found so an existing file is never treated as unbound.
|
||||
// Retain the identity for retries even when its durable write fails.
|
||||
getFallbackBindings(context).set(normalizedBinding.filePath, normalizedBinding);
|
||||
|
||||
if (context.threadMemory && context.threadId) {
|
||||
|
|
@ -137,6 +156,9 @@ export async function saveWorkflowSourceFileBinding(
|
|||
});
|
||||
}
|
||||
}
|
||||
if (options.requirePersistence) {
|
||||
throw new OperationalError('Could not persist the workflow setup context');
|
||||
}
|
||||
|
||||
return normalizedBinding;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
const { mockShowMessageBox } = vi.hoisted(() => ({ mockShowMessageBox: vi.fn() }));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { focus: vi.fn() },
|
||||
dialog: { showMessageBox: mockShowMessageBox },
|
||||
}));
|
||||
|
||||
import { confirmConnect } from './connect-confirmation';
|
||||
|
||||
describe('confirmConnect', () => {
|
||||
beforeEach(() => {
|
||||
mockShowMessageBox.mockReset();
|
||||
});
|
||||
|
||||
it('returns false when the user cancels', async () => {
|
||||
mockShowMessageBox.mockResolvedValue({ response: 1 });
|
||||
|
||||
await expect(confirmConnect('https://a.app.n8n.cloud/', null)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when the user clicks Connect', async () => {
|
||||
mockShowMessageBox.mockResolvedValue({ response: 0 });
|
||||
|
||||
await expect(confirmConnect('https://a.app.n8n.cloud/', null)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('shows the target origin and defaults to Cancel', async () => {
|
||||
mockShowMessageBox.mockResolvedValue({ response: 1 });
|
||||
|
||||
await confirmConnect('https://a.app.n8n.cloud/some/path', null);
|
||||
|
||||
expect(mockShowMessageBox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Connect n8n Gateway to https://a.app.n8n.cloud?',
|
||||
buttons: ['Connect', 'Cancel'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('names the current connection that will end', async () => {
|
||||
mockShowMessageBox.mockResolvedValue({ response: 1 });
|
||||
|
||||
await confirmConnect('https://b.app.n8n.cloud', 'https://a.app.n8n.cloud');
|
||||
|
||||
expect(mockShowMessageBox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: expect.stringContaining('https://a.app.n8n.cloud'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
23
packages/@n8n/local-gateway/src/main/connect-confirmation.ts
Normal file
23
packages/@n8n/local-gateway/src/main/connect-confirmation.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { app, dialog } from 'electron';
|
||||
|
||||
/** Asks the local user to approve the target instance before the gateway connects to it. */
|
||||
export async function confirmConnect(url: string, connectedUrl: string | null): Promise<boolean> {
|
||||
const origin = new URL(url).origin;
|
||||
const replaceNote = connectedUrl
|
||||
? `\n\nThis ends the current connection to ${connectedUrl}.`
|
||||
: '';
|
||||
|
||||
// Tray-only app: without focus the dialog can open behind the browser.
|
||||
app.focus({ steal: true });
|
||||
const { response } = await dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'n8n Gateway',
|
||||
message: `Connect n8n Gateway to ${origin}?`,
|
||||
detail: `This n8n instance will be able to use the tools you enabled on this computer. Only continue if you started this connection from n8n.${replaceNote}`,
|
||||
buttons: ['Connect', 'Cancel'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
});
|
||||
return response === 0;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { assertConnectOriginAllowed } from './connect-origin';
|
||||
import { assertConnectOriginAllowed, isAlreadyConnectedTo } from './connect-origin';
|
||||
|
||||
describe('assertConnectOriginAllowed', () => {
|
||||
it('does not throw when origin matches an allowed pattern', () => {
|
||||
|
|
@ -19,3 +19,26 @@ describe('assertConnectOriginAllowed', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAlreadyConnectedTo', () => {
|
||||
const connected = {
|
||||
status: 'connected' as const,
|
||||
connectedUrl: 'http://localhost:5678',
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
it('returns true for the connected URL, with or without a trailing slash', () => {
|
||||
expect(isAlreadyConnectedTo('http://localhost:5678', connected)).toBe(true);
|
||||
expect(isAlreadyConnectedTo('http://localhost:5678/', connected)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a different URL', () => {
|
||||
expect(isAlreadyConnectedTo('http://127.0.0.1:5678', connected)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the gateway is not connected', () => {
|
||||
expect(isAlreadyConnectedTo('http://localhost:5678', { ...connected, status: 'error' })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { isOriginAllowed } from '@n8n/computer-use/config';
|
||||
|
||||
import type { StatusSnapshot } from '../shared/types';
|
||||
|
||||
/**
|
||||
* Throws if the normalized instance URL's origin is not allowed by the configured patterns.
|
||||
* Call before constructing GatewayClient (deep link / IPC connect).
|
||||
|
|
@ -17,3 +19,8 @@ export function assertConnectOriginAllowed(url: string, allowedOriginPatterns: s
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the gateway already has a live connection to this instance URL. */
|
||||
export function isAlreadyConnectedTo(url: string, snapshot: StatusSnapshot): boolean {
|
||||
return snapshot.status === 'connected' && snapshot.connectedUrl === url.replace(/\/$/, '');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export class DaemonController extends EventEmitter<DaemonControllerEvents> {
|
|||
await this.closeCurrentConnection({ preserveServerSession: true });
|
||||
const store = await this.getSettingsStore();
|
||||
const defaults = store.getDefaults(config);
|
||||
const session = new GatewaySession(defaults, store);
|
||||
const session = new GatewaySession(defaults, store, new URL(normalizedUrl).origin);
|
||||
const client = new GatewayClient({
|
||||
url: normalizedUrl,
|
||||
apiKey,
|
||||
|
|
|
|||
88
packages/@n8n/local-gateway/src/main/index.test.ts
Normal file
88
packages/@n8n/local-gateway/src/main/index.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
const { appHandlers, snapshot, confirmConnect, connect } = vi.hoisted(() => {
|
||||
const snapshot = {
|
||||
status: 'connected' as string,
|
||||
connectedUrl: 'https://a.app.n8n.cloud' as string | null,
|
||||
lastError: null,
|
||||
};
|
||||
return {
|
||||
appHandlers: new Map<string, Handler>(),
|
||||
snapshot,
|
||||
confirmConnect: vi.fn(),
|
||||
connect: vi.fn(async (_config: unknown, url: string) => {
|
||||
snapshot.connectedUrl = url;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
requestSingleInstanceLock: () => true,
|
||||
whenReady: async () => {},
|
||||
on: (event: string, handler: Handler) => appHandlers.set(event, handler),
|
||||
setAsDefaultProtocolClient: vi.fn(),
|
||||
dock: { hide: vi.fn() },
|
||||
quit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('@n8n/computer-use/logger', () => ({
|
||||
configure: vi.fn(),
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
vi.mock('./connect-confirmation', () => ({ confirmConnect }));
|
||||
vi.mock('./daemon-controller', () => ({
|
||||
DaemonController: vi.fn(function () {
|
||||
return { getSnapshot: () => ({ ...snapshot }), connect, on: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
vi.mock('./settings-store', () => ({
|
||||
SettingsStore: vi.fn(function () {
|
||||
return {
|
||||
get: () => ({ allowedOrigins: ['https://*.app.n8n.cloud'], logLevel: 'info' }),
|
||||
toGatewayConfig: () => ({}),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
vi.mock('./ipc-handlers', () => ({ registerIpcHandlers: vi.fn() }));
|
||||
vi.mock('./settings-window', () => ({
|
||||
openSettingsWindow: vi.fn(),
|
||||
notifySettingsWindow: vi.fn(),
|
||||
}));
|
||||
vi.mock('./tray', () => ({ createTray: vi.fn() }));
|
||||
|
||||
import './index';
|
||||
|
||||
function openUrl(host: string): void {
|
||||
appHandlers.get('open-url')?.(
|
||||
{ preventDefault: vi.fn() },
|
||||
`n8n-computer-use://connect?url=https://${host}&token=t`,
|
||||
);
|
||||
}
|
||||
|
||||
describe('deep-link handling', () => {
|
||||
beforeAll(async () => {
|
||||
await vi.waitFor(() => expect(appHandlers.has('open-url')).toBe(true));
|
||||
});
|
||||
|
||||
it('confirms a queued link against the connection made by the link before it', async () => {
|
||||
const answers: Array<(approved: boolean) => void> = [];
|
||||
confirmConnect.mockImplementation(
|
||||
async () => await new Promise<boolean>((resolve) => answers.push(resolve)),
|
||||
);
|
||||
|
||||
openUrl('b.app.n8n.cloud');
|
||||
openUrl('c.app.n8n.cloud');
|
||||
await vi.waitFor(() => expect(answers).toHaveLength(1));
|
||||
answers[0](true);
|
||||
await vi.waitFor(() => expect(answers).toHaveLength(2));
|
||||
answers[1](true);
|
||||
await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(confirmConnect).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://c.app.n8n.cloud',
|
||||
'https://b.app.n8n.cloud',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,8 @@ import { configure, logger } from '@n8n/computer-use/logger';
|
|||
import { app } from 'electron';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { assertConnectOriginAllowed } from './connect-origin';
|
||||
import { confirmConnect } from './connect-confirmation';
|
||||
import { assertConnectOriginAllowed, isAlreadyConnectedTo } from './connect-origin';
|
||||
import {
|
||||
deepLinkProtocolsInArgv,
|
||||
parseConnectPayload,
|
||||
|
|
@ -57,6 +58,16 @@ if (!app.requestSingleInstanceLock()) {
|
|||
'Missing gateway token in deeplink. Connect from n8n using the computer-use link.',
|
||||
);
|
||||
}
|
||||
if (isAlreadyConnectedTo(payload.url, controller.getSnapshot())) {
|
||||
logger.info('Deep-link ignored: already connected to this instance', {
|
||||
url: payload.url,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!(await confirmConnect(payload.url, controller.getSnapshot().connectedUrl))) {
|
||||
logger.info('Deep-link connection declined by user', { url: payload.url });
|
||||
return;
|
||||
}
|
||||
await controller.connect(config, payload.url, token);
|
||||
}
|
||||
|
||||
|
|
@ -64,13 +75,18 @@ if (!app.requestSingleInstanceLock()) {
|
|||
await controller.disconnect();
|
||||
}
|
||||
|
||||
// One link at a time, so each dialog and connect sees the state the previous link left.
|
||||
let connectQueue = Promise.resolve();
|
||||
|
||||
function handleConnectPayload(payload: ConnectPayload): void {
|
||||
logger.info('Handling deep-link connection payload', { url: payload.url });
|
||||
void connect(payload).catch((error: unknown) => {
|
||||
logger.error('Deep-link connection failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
connectQueue = connectQueue.then(async () => {
|
||||
await connect(payload).catch((error: unknown) => {
|
||||
logger.error('Deep-link connection failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
openSettingsWindow(preloadPath, rendererPath);
|
||||
});
|
||||
openSettingsWindow(preloadPath, rendererPath);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/mcp-apps",
|
||||
"version": "0.19.0",
|
||||
"version": "0.19.3",
|
||||
"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/node-cli",
|
||||
"version": "0.51.0",
|
||||
"version": "0.51.3",
|
||||
"description": "Official CLI for developing community nodes for n8n",
|
||||
"bin": {
|
||||
"n8n-node": "bin/n8n-node.mjs"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user