From 55df7cbd0619e483e7e02207bc5084c715dcb53a Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Thu, 7 May 2026 11:16:20 +0300 Subject: [PATCH 01/20] fix(Google Chat Node): Clarify message resource name field (#29964) --- .../Chat/descriptions/MessageDescription.ts | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/nodes-base/nodes/Google/Chat/descriptions/MessageDescription.ts b/packages/nodes-base/nodes/Google/Chat/descriptions/MessageDescription.ts index 56dcb0d7c17..1bcc4cbfe61 100644 --- a/packages/nodes-base/nodes/Google/Chat/descriptions/MessageDescription.ts +++ b/packages/nodes-base/nodes/Google/Chat/descriptions/MessageDescription.ts @@ -60,6 +60,19 @@ export const spaceIdProperty: INodeProperties = { 'Space resource name, in the form "spaces/*". Example: spaces/AAAAMpdlehY. Choose from the list, or specify an ID using an expression.', }; +const messageResourceNameDescription = + 'Resource name of the message. Format: spaces/{space}/messages/{message}. For system-assigned IDs, use the full message name, such as spaces/AAAAAAAAAAA/messages/BBBBBBBBBBB.BBBBBBBBBBB. For custom IDs, use spaces/AAAAAAAAAAA/messages/client-custom-name.'; + +const messageResourceNameProperties: INodeProperties = { + displayName: 'Message Resource Name', + name: 'messageId', + type: 'string', + required: true, + default: '', + placeholder: 'e.g. spaces/AAAAAAAAAAA/messages/BBBBBBBBBBB.BBBBBBBBBBB', + description: messageResourceNameDescription, +}; + export const messageFields: INodeProperties[] = [ /* -------------------------------------------------------------------------- */ /* message:create */ @@ -223,54 +236,39 @@ export const messageFields: INodeProperties[] = [ /* messages:delete */ /* -------------------------------------------------------------------------- */ { - displayName: 'Message ID', - name: 'messageId', - type: 'string', - required: true, + ...messageResourceNameProperties, displayOptions: { show: { resource: ['message'], operation: ['delete'], }, }, - default: '', - description: 'Resource name of the message to be deleted, in the form "spaces//messages/"', }, /* -------------------------------------------------------------------------- */ /* message:get */ /* -------------------------------------------------------------------------- */ { - displayName: 'Message ID', - name: 'messageId', - type: 'string', - required: true, + ...messageResourceNameProperties, displayOptions: { show: { resource: ['message'], operation: ['get'], }, }, - default: '', - description: 'Resource name of the message to be retrieved, in the form "spaces//messages/"', }, /* -------------------------------------------------------------------------- */ /* message:update */ /* -------------------------------------------------------------------------- */ { - displayName: 'Message ID', - name: 'messageId', - type: 'string', - required: true, + ...messageResourceNameProperties, displayOptions: { show: { resource: ['message'], operation: ['update'], }, }, - default: '', - description: 'Resource name of the message to be updated, in the form "spaces//messages/"', }, { displayName: 'JSON Parameters', From 5e3aa1a726e903387344d3a4ed51e97811e4ff02 Mon Sep 17 00:00:00 2001 From: Albert Alises Date: Thu, 7 May 2026 10:24:00 +0200 Subject: [PATCH 02/20] fix(ai-builder): Preserve collected planning context (#29916) --- .../instance-ai/src/tools/credentials.tool.ts | 4 +- .../instance-ai/src/tools/data-tables.tool.ts | 6 +- .../__tests__/plan-with-agent.tool.test.ts | 267 ++++++++++- .../tools/orchestration/plan-agent-prompt.ts | 1 + .../orchestration/plan-with-agent.tool.ts | 419 +++++++++++++++++- .../src/tools/shared/ask-user.tool.ts | 4 +- 6 files changed, 693 insertions(+), 8 deletions(-) diff --git a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts index bee126faa5a..38e3eb4e863 100644 --- a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts @@ -11,6 +11,8 @@ import type { InstanceAiContext } from '../types'; // ── Constants ────────────────────────────────────────────────────────────── +export const CREDENTIALS_TOOL_ID = 'credentials'; + const DEFAULT_LIMIT = 50; /** Generic auth types that should be excluded from search results — the AI should prefer dedicated types. */ @@ -340,7 +342,7 @@ async function handleTest(context: InstanceAiContext, input: Extract { @@ -616,7 +618,7 @@ export function createDataTablesTool( const inputSchema = sanitizeInputSchema(z.discriminatedUnion('action', [...allActions])); return createTool({ - id: 'data-tables', + id: DATA_TABLES_TOOL_ID, description: 'Manage data tables — list, query, create, modify columns, and manage rows.', inputSchema, suspendSchema: confirmationSuspendSchema, diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan-with-agent.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan-with-agent.tool.test.ts index cb0dbbe3240..4200bdca7b9 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan-with-agent.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan-with-agent.tool.test.ts @@ -11,7 +11,13 @@ jest.mock('@mastra/core/tools', () => ({ import type { OrchestrationContext, PlannedTaskGraph, PlannedTaskService } from '../../../types'; -const { __testClearPlannedTaskGraph, __testFormatMessagesForBriefing } = +const { + __testBuildPlannerBriefingContext, + __testClearPlannedTaskGraph, + __testFormatMessagesForBriefing, + __testGetRecentMessages, + __testGetPriorToolObservations, +} = // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports require('../plan-with-agent.tool') as typeof import('../plan-with-agent.tool'); @@ -130,4 +136,263 @@ describe('formatMessagesForBriefing', () => { expect(briefing).toMatch(/[^<]+<\/current-datetime>/); expect(briefing).not.toContain(''); }); + + it('renders already-collected answers and discovered resources as dedicated sections', () => { + const briefing = __testFormatMessagesForBriefing( + [{ role: 'user', content: 'Build a Slack to-do agent' }], + undefined, + 'America/New_York', + { + collectedAnswers: [ + 'How often should the agent run?: Every morning', + 'Credential selected for slackApi: Slack account (slackApi)', + ], + discoveredResources: ['Credentials available: Slack account (slackApi)'], + }, + ); + + expect(briefing).toContain('## Already-collected answers'); + expect(briefing).toContain('- How often should the agent run?: Every morning'); + expect(briefing).toContain('- Credential selected for slackApi: Slack account (slackApi)'); + expect(briefing).toContain('## Already-discovered resources'); + expect(briefing).toContain('- Credentials available: Slack account (slackApi)'); + }); +}); + +describe('buildPlannerBriefingContext', () => { + it('extracts ask-user answers and credential selections from prior tool results', () => { + const context = __testBuildPlannerBriefingContext([ + { + toolName: 'credentials', + args: { action: 'list' }, + result: { + credentials: [ + { id: 'cred-slack', name: 'Slack account', type: 'slackApi' }, + { id: 'cred-anthropic', name: 'Anthropic account', type: 'anthropicApi' }, + ], + }, + }, + { + toolName: 'ask-user', + args: { + questions: [ + { + id: 'schedule', + question: 'How often should the agent run?', + type: 'single', + }, + ], + }, + result: { + answered: true, + answers: [ + { + questionId: 'schedule', + selectedOptions: ['Every morning'], + }, + ], + }, + }, + { + toolName: 'credentials', + args: { action: 'setup' }, + result: { + success: true, + credentials: { slackApi: 'cred-slack' }, + }, + }, + ]); + + expect(context.collectedAnswers).toEqual([ + 'How often should the agent run?: Every morning', + 'Credential selected for slackApi: Slack account (slackApi)', + ]); + expect(context.discoveredResources).toEqual([ + 'Credentials available: Slack account (slackApi), Anthropic account (anthropicApi)', + ]); + }); + + it('ignores unanswered and skipped ask-user answers', () => { + const context = __testBuildPlannerBriefingContext([ + { + toolName: 'ask-user', + args: { + questions: [{ id: 'purpose', question: 'What should this do?', type: 'text' }], + }, + result: { + answered: false, + answers: [ + { + questionId: 'purpose', + customText: 'This should not be used', + }, + ], + }, + }, + { + toolName: 'ask-user', + args: { + questions: [ + { id: 'schedule', question: 'How often should it run?', type: 'single' }, + { id: 'model', question: 'Which model should it use?', type: 'single' }, + ], + }, + result: { + answered: true, + answers: [ + { + questionId: 'schedule', + selectedOptions: ['Every morning'], + skipped: true, + }, + { + questionId: 'model', + selectedOptions: ['Anthropic'], + }, + ], + }, + }, + ]); + + expect(context.collectedAnswers).toEqual(['Which model should it use?: Anthropic']); + expect(context.discoveredResources).toEqual([]); + }); +}); + +describe('getPriorToolObservations', () => { + it('reads tool results across the current message group when available', () => { + const askUserCall = { + questions: [{ id: 'purpose', question: 'What should this do?', type: 'text' }], + }; + const askUserResult = { + answered: true, + answers: [ + { questionId: 'purpose', question: 'What should this do?', customText: 'Email me' }, + ], + }; + const getEventsForRun = jest.fn().mockReturnValue([]); + const getEventsForRuns = jest.fn().mockReturnValue([ + { + type: 'tool-call', + runId: 'run-prior', + agentId: 'orchestrator', + payload: { + toolCallId: 'tool-1', + toolName: 'ask-user', + args: askUserCall, + }, + }, + { + type: 'tool-result', + runId: 'run-prior', + agentId: 'orchestrator', + payload: { + toolCallId: 'tool-1', + result: askUserResult, + }, + }, + ]); + const context = { + threadId: 'thread-1', + runId: 'run-current', + messageGroupId: 'message-group-1', + eventBus: { + getEventsAfter: jest.fn().mockReturnValue([ + { + id: 1, + event: { + type: 'run-start', + runId: 'run-prior', + agentId: 'orchestrator', + payload: { messageId: 'message-1', messageGroupId: 'message-group-1' }, + }, + }, + { + id: 2, + event: { + type: 'run-start', + runId: 'run-other', + agentId: 'orchestrator', + payload: { messageId: 'message-2', messageGroupId: 'message-group-2' }, + }, + }, + ]), + getEventsForRuns, + getEventsForRun, + }, + } as unknown as OrchestrationContext; + + const observations = __testGetPriorToolObservations(context); + + expect(getEventsForRuns).toHaveBeenCalledWith('thread-1', ['run-prior', 'run-current']); + expect(getEventsForRun).not.toHaveBeenCalled(); + expect(observations).toEqual([ + { + toolName: 'ask-user', + args: askUserCall, + result: askUserResult, + }, + ]); + }); + + it('pairs out-of-order tool results with their later tool calls', () => { + const args = { action: 'list' }; + const result = { credentials: [{ id: 'cred-1', name: 'Slack', type: 'slackApi' }] }; + const context = { + threadId: 'thread-1', + runId: 'run-current', + eventBus: { + getEventsForRun: jest.fn().mockReturnValue([ + { + type: 'tool-result', + runId: 'run-current', + agentId: 'orchestrator', + payload: { toolCallId: 'tool-1', result }, + }, + { + type: 'tool-call', + runId: 'run-current', + agentId: 'orchestrator', + payload: { toolCallId: 'tool-1', toolName: 'credentials', args }, + }, + ]), + }, + } as unknown as OrchestrationContext; + + expect(__testGetPriorToolObservations(context)).toEqual([ + { toolName: 'credentials', args, result }, + ]); + }); + + it('returns no observations when event lookup fails', () => { + const context = { + threadId: 'thread-1', + runId: 'run-current', + eventBus: { + getEventsForRun: jest.fn(() => { + throw new Error('storage unavailable'); + }), + }, + } as unknown as OrchestrationContext; + + expect(__testGetPriorToolObservations(context)).toEqual([]); + }); +}); + +describe('getRecentMessages', () => { + it('does not append the current user message when memory already returned it', async () => { + const context = { + threadId: 't-1', + currentUserMessage: 'Build a Slack to-do agent', + memory: { + recall: jest.fn().mockResolvedValue({ + messages: [{ role: 'user', content: 'Build a Slack to-do agent' }], + }), + }, + } as unknown as OrchestrationContext; + + const messages = await __testGetRecentMessages(context, 5); + + expect(messages).toEqual([{ role: 'user', content: 'Build a Slack to-do agent' }]); + }); }); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/plan-agent-prompt.ts b/packages/@n8n/instance-ai/src/tools/orchestration/plan-agent-prompt.ts index be0cb5f1626..0bb2f2d9a74 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/plan-agent-prompt.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/plan-agent-prompt.ts @@ -20,6 +20,7 @@ ${SUBAGENT_OUTPUT_CONTRACT} - **Never ask about things you can discover** — call \`credentials(action="list")\`, \`data-tables(action="list")\`, \`templates(action="best-practices")\` instead. - **Never ask about implementation details** — trigger types, node choices, schedule times, column names. Pick sensible defaults. - **Never default resource identifiers** the user didn't mention (Slack channels, calendars, spreadsheets, folders, etc.) — leave them for the builder to resolve at build time. + - **Trust already-collected briefing context** — if the briefing includes an Already-collected answers or Already-discovered resources section, treat those entries as authoritative. Do not ask again for purpose, trigger, integrations, schedule, model, resource, or credential choices already listed there. - **Do ask when the answer would significantly change the plan** — e.g. the user's goal is ambiguous ("build me a CRM" — for sales? support? recruiting?), or a business rule must come from the user ("what should happen when payment fails?"). - **Do ask when a required service has more than one credential of the same type** (e.g. two \`openAiApi\` accounts, three Google Calendar accounts) — which one to use cannot be discovered, only chosen. Record the chosen credential name in \`assumptions\`. - **List your assumptions** on your first \`add-plan-item\` call. The user reviews the plan before execution and can reject/correct. diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/plan-with-agent.tool.ts b/packages/@n8n/instance-ai/src/tools/orchestration/plan-with-agent.tool.ts index 0f22807e57a..00ee06f92a6 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/plan-with-agent.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/plan-with-agent.tool.ts @@ -15,6 +15,7 @@ import { Agent } from '@mastra/core/agent'; import type { ToolsInput } from '@mastra/core/agent'; import { createTool } from '@mastra/core/tools'; +import type { InstanceAiEvent } from '@n8n/api-types'; import { DateTime } from 'luxon'; import { nanoid } from 'nanoid'; import { z } from 'zod'; @@ -37,6 +38,9 @@ import { createLlmStepTraceHooks } from '../../runtime/resumable-stream-executor import { consumeStreamWithHitl } from '../../stream/consume-with-hitl'; import { getTraceParentRun, withTraceParentContext } from '../../tracing/langsmith-tracing'; import type { OrchestrationContext } from '../../types'; +import { CREDENTIALS_TOOL_ID } from '../credentials.tool'; +import { DATA_TABLES_TOOL_ID } from '../data-tables.tool'; +import { ASK_USER_TOOL_ID } from '../shared/ask-user.tool'; import { createTemplatesTool } from '../templates.tool'; /** Number of recent thread messages to include as planner context. */ @@ -48,15 +52,43 @@ const PLANNER_DOMAIN_TOOL_NAMES = ['nodes', 'credentials', 'data-tables', 'workf /** Research tools added when available. */ const PLANNER_RESEARCH_TOOL_NAMES = ['research']; +const RELEVANT_PRIOR_TOOL_NAMES = new Set([ + ASK_USER_TOOL_ID, + CREDENTIALS_TOOL_ID, + DATA_TABLES_TOOL_ID, +]); + // --------------------------------------------------------------------------- // Message history retrieval // --------------------------------------------------------------------------- interface FormattedMessage { - role: string; + role: 'user' | 'assistant'; content: string; } +interface PlannerBriefingContext { + collectedAnswers: string[]; + discoveredResources: string[]; +} + +interface ToolObservation { + toolName: string; + args: Record; + result: unknown; +} + +interface CredentialBrief { + id?: string; + name: string; + type: string; +} + +interface DataTableBrief { + id?: string; + name: string; +} + /** Extract plain text from Mastra memory content (string, array of parts, or {format, parts}). */ function extractTextFromMemoryContent(content: unknown): string { if (typeof content === 'string') return content; @@ -93,6 +125,38 @@ function extractTextParts(parts: unknown[]): string { .join('\n'); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +function readRecord(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + +function readArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function readStringArray(value: unknown): string[] { + return readArray(value).filter((item): item is string => typeof item === 'string'); +} + +function addUnique(target: string[], seen: Set, value: string | undefined): void { + if (!value || seen.has(value)) return; + seen.add(value); + target.push(value); +} + +function summarizeList(values: string[], limit = 10): string { + const visible = values.slice(0, limit).join(', '); + const remaining = values.length - limit; + return remaining > 0 ? `${visible}, and ${remaining} more` : visible; +} + async function getRecentMessages( context: OrchestrationContext, count: number, @@ -120,17 +184,343 @@ async function getRecentMessages( } // Always append the current in-flight user message (not yet saved to memory) - if (context.currentUserMessage) { + if (shouldAppendCurrentUserMessage(messages, context.currentUserMessage)) { messages.push({ role: 'user', content: context.currentUserMessage }); } return messages; } +function shouldAppendCurrentUserMessage( + messages: FormattedMessage[], + currentUserMessage?: string, +): currentUserMessage is string { + const current = currentUserMessage?.trim(); + if (!current) return false; + + const lastUserMessage = [...messages].reverse().find((message) => message.role === 'user'); + return lastUserMessage?.content.trim() !== current; +} + +/** + * Reconstructs prior planner-relevant tool calls from the event stream. + * + * Tool-call and tool-result events are correlated by `toolCallId` so the + * planner can receive structured context that is not preserved in text-only + * memory recall, such as ask-user answers and credential selections. + */ +function getPriorToolObservations(context: OrchestrationContext): ToolObservation[] { + type MutableToolObservation = Omit & { + result: unknown; + hasResult: boolean; + }; + + const toolCalls = new Map(); + const pendingResults = new Map(); + + for (const event of getPriorToolEvents(context)) { + if (event.type === 'tool-call') { + const { toolCallId, toolName, args } = event.payload; + if (!RELEVANT_PRIOR_TOOL_NAMES.has(toolName)) continue; + + const pendingResult = pendingResults.get(toolCallId); + toolCalls.set(toolCallId, { + toolName, + args, + result: pendingResult, + hasResult: pendingResults.has(toolCallId), + }); + continue; + } + + if (event.type === 'tool-result') { + const { toolCallId, result } = event.payload; + const existing = toolCalls.get(toolCallId); + if (existing) { + existing.result = result; + existing.hasResult = true; + } else { + pendingResults.set(toolCallId, result); + } + } + } + + return [...toolCalls.values()] + .filter((observation) => observation.hasResult) + .map(({ toolName, args, result }) => ({ toolName, args, result })); +} + +/** + * Returns the events that may contain prior tool context for this planner run. + * + * When the run belongs to a message group, all runs in that group are searched + * so follow-up runs can see choices collected earlier in the same assistant + * turn. If grouped lookup is unavailable, this falls back to the current run. + */ +function getPriorToolEvents(context: OrchestrationContext): InstanceAiEvent[] { + if (context.messageGroupId) { + const runIds = getMessageGroupRunIds(context); + if (runIds.length > 0) { + try { + return context.eventBus.getEventsForRuns(context.threadId, runIds); + } catch { + // Fall back to the current run below. + } + } + } + + try { + return context.eventBus.getEventsForRun(context.threadId, context.runId); + } catch { + return []; + } +} + +/** + * Finds run IDs that belong to the current message group from run-start events. + * + * The event bus can fetch events for many run IDs, but the orchestration + * context only carries the current run ID and message group ID. This bridges + * those two concepts while keeping the current run as a defensive fallback. + */ +function getMessageGroupRunIds(context: OrchestrationContext): string[] { + const messageGroupId = context.messageGroupId; + if (!messageGroupId) return []; + + const runIds = new Set(); + try { + for (const { event } of context.eventBus.getEventsAfter(context.threadId, 0)) { + if (event.type === 'run-start' && event.payload.messageGroupId === messageGroupId) { + runIds.add(event.runId); + } + } + } catch { + return [context.runId]; + } + runIds.add(context.runId); + + return [...runIds]; +} + +/** + * Converts raw prior tool observations into planner briefing sections. + * + * The resulting strings are intentionally short and human-readable because + * they are embedded directly into the planner prompt under dedicated headings. + */ +function buildPlannerBriefingContext(observations: ToolObservation[]): PlannerBriefingContext { + const collectedAnswers: string[] = []; + const discoveredResources: string[] = []; + const seenAnswers = new Set(); + const seenResources = new Set(); + const credentialsById = buildCredentialLookup(observations); + + for (const observation of observations) { + if (observation.toolName === ASK_USER_TOOL_ID) { + for (const answer of extractAskUserAnswerLines(observation)) { + addUnique(collectedAnswers, seenAnswers, answer); + } + continue; + } + + if (observation.toolName === CREDENTIALS_TOOL_ID) { + const action = readString(observation.args.action); + if (action === 'list') { + addUnique(discoveredResources, seenResources, summarizeCredentials(observation.result)); + } + if (action === 'setup') { + for (const selection of extractCredentialSelectionLines(observation, credentialsById)) { + addUnique(collectedAnswers, seenAnswers, selection); + } + } + continue; + } + + if ( + observation.toolName === DATA_TABLES_TOOL_ID && + readString(observation.args.action) === 'list' + ) { + addUnique(discoveredResources, seenResources, summarizeDataTables(observation.result)); + } + } + + return { collectedAnswers, discoveredResources }; +} + +/** + * Builds an ID lookup from prior credential list results. + * + * Credential setup results contain selected IDs, so this lets the briefing + * render stable user-facing names and credential types when a prior list result + * is available. + */ +function buildCredentialLookup(observations: ToolObservation[]): Map { + const credentialsById = new Map(); + + for (const observation of observations) { + if (observation.toolName !== CREDENTIALS_TOOL_ID) continue; + for (const credential of extractCredentials(observation.result)) { + if (credential.id) credentialsById.set(credential.id, credential); + } + } + + return credentialsById; +} + +/** + * Extracts answered ask-user responses as `question: answer` briefing lines. + * + * Skipped or unanswered prompts are ignored, and question text is recovered + * from tool args when the tool result only includes a question ID. + */ +function extractAskUserAnswerLines(observation: ToolObservation): string[] { + const result = readRecord(observation.result); + if (!result || result.answered === false) return []; + + const questionsById = extractQuestionTextById(observation.args); + const answers = readArray(result.answers); + const lines: string[] = []; + + for (const answerValue of answers) { + const answer = readRecord(answerValue); + if (!answer || answer.skipped === true) continue; + + const questionId = readString(answer.questionId); + const question = + readString(answer.question) ?? (questionId ? questionsById.get(questionId) : undefined); + const selectedOptions = readStringArray(answer.selectedOptions); + const customText = readString(answer.customText); + const values = [...selectedOptions, ...(customText ? [customText] : [])]; + + if (!question || values.length === 0) continue; + lines.push(`${question}: ${values.join(', ')}`); + } + + return lines; +} + +/** + * Maps ask-user question IDs to display text from the original tool args. + */ +function extractQuestionTextById(args: Record): Map { + const questionsById = new Map(); + + for (const questionValue of readArray(args.questions)) { + const question = readRecord(questionValue); + const id = readString(question?.id); + const text = readString(question?.question); + if (id && text) questionsById.set(id, text); + } + + return questionsById; +} + +/** + * Renders credential setup selections as briefing lines. + * + * The setup tool returns a `{ credentialType: credentialId }` map. The optional + * credential lookup turns those IDs back into names so the planner can avoid + * asking the user to choose the same credential again. + */ +function extractCredentialSelectionLines( + observation: ToolObservation, + credentialsById: Map, +): string[] { + const result = readRecord(observation.result); + const credentials = readRecord(result?.credentials); + if (!credentials) return []; + + const lines: string[] = []; + for (const [credentialType, credentialIdValue] of Object.entries(credentials)) { + const credentialId = readString(credentialIdValue); + if (!credentialId) continue; + + const credential = credentialsById.get(credentialId); + const label = credential + ? `${credential.name} (${credential.type})` + : `credential ID ${credentialId}`; + lines.push(`Credential selected for ${credentialType}: ${label}`); + } + + return lines; +} + +/** + * Summarizes a credentials list result for the briefing. + */ +function summarizeCredentials(result: unknown): string | undefined { + const credentials = extractCredentials(result); + if (credentials.length === 0) return undefined; + + return `Credentials available: ${summarizeList( + credentials.map((credential) => `${credential.name} (${credential.type})`), + )}`; +} + +/** + * Reads the minimal credential metadata needed by the planner briefing. + */ +function extractCredentials(result: unknown): CredentialBrief[] { + const record = readRecord(result); + return readArray(record?.credentials) + .map(readCredentialBrief) + .filter((credential): credential is CredentialBrief => credential !== undefined); +} + +function readCredentialBrief(value: unknown): CredentialBrief | undefined { + const record = readRecord(value); + const name = readString(record?.name); + const type = readString(record?.type); + if (!name || !type) return undefined; + const id = readString(record?.id); + + return { + name, + type, + ...(id ? { id } : {}), + }; +} + +/** + * Summarizes a data-tables list result for the briefing. + */ +function summarizeDataTables(result: unknown): string | undefined { + const tables = extractDataTables(result); + if (tables.length === 0) return undefined; + + return `Data tables available: ${summarizeList(tables.map((table) => table.name))}`; +} + +/** + * Reads the minimal data-table metadata needed by the planner briefing. + */ +function extractDataTables(result: unknown): DataTableBrief[] { + const record = readRecord(result); + return readArray(record?.tables) + .map(readDataTableBrief) + .filter((table): table is DataTableBrief => table !== undefined); +} + +function readDataTableBrief(value: unknown): DataTableBrief | undefined { + const record = readRecord(value); + const name = readString(record?.name); + if (!name) return undefined; + const id = readString(record?.id); + + return { + name, + ...(id ? { id } : {}), + }; +} + +/** + * Formats conversation, time, and already-collected context into the planner goal. + */ function formatMessagesForBriefing( messages: FormattedMessage[], guidance?: string, timeZone?: string, + briefingContext?: PlannerBriefingContext, ): string { const parts: string[] = []; @@ -151,6 +541,20 @@ function formatMessagesForBriefing( } } + if (briefingContext?.collectedAnswers.length) { + parts.push('## Already-collected answers'); + for (const answer of briefingContext.collectedAnswers) { + parts.push(`- ${answer}`); + } + } + + if (briefingContext?.discoveredResources.length) { + parts.push('## Already-discovered resources'); + for (const resource of briefingContext.discoveredResources) { + parts.push(`- ${resource}`); + } + } + if (guidance) { parts.push(`\n## Orchestrator guidance\n${guidance}`); } @@ -161,6 +565,9 @@ function formatMessagesForBriefing( } export const __testFormatMessagesForBriefing = formatMessagesForBriefing; +export const __testGetRecentMessages = getRecentMessages; +export const __testGetPriorToolObservations = getPriorToolObservations; +export const __testBuildPlannerBriefingContext = buildPlannerBriefingContext; // --------------------------------------------------------------------------- // Helper: clear draft checklist from taskStorage @@ -268,7 +675,13 @@ export function createPlanWithAgentTool(context: OrchestrationContext) { // ── Retrieve conversation history ───────────────────────────── const messages = await getRecentMessages(context, MESSAGE_HISTORY_COUNT); - const briefing = formatMessagesForBriefing(messages, input.guidance, context.timeZone); + const briefingContext = buildPlannerBriefingContext(getPriorToolObservations(context)); + const briefing = formatMessagesForBriefing( + messages, + input.guidance, + context.timeZone, + briefingContext, + ); // ── IDs & events ────────────────────────────────────────────── const subAgentId = `agent-planner-${nanoid(6)}`; diff --git a/packages/@n8n/instance-ai/src/tools/shared/ask-user.tool.ts b/packages/@n8n/instance-ai/src/tools/shared/ask-user.tool.ts index 0d21ce854a1..8f9553c34e8 100644 --- a/packages/@n8n/instance-ai/src/tools/shared/ask-user.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/shared/ask-user.tool.ts @@ -2,6 +2,8 @@ import { createTool } from '@mastra/core/tools'; import { nanoid } from 'nanoid'; import { z } from 'zod'; +export const ASK_USER_TOOL_ID = 'ask-user'; + const questionSchema = z.object({ id: z.string().describe('Unique question identifier'), question: z.string().describe('The question text to display to the user'), @@ -36,7 +38,7 @@ export const askUserResumeSchema = z.object({ export function createAskUserTool() { return createTool({ - id: 'ask-user', + id: ASK_USER_TOOL_ID, description: 'Ask the user one or more structured questions. Each question can be ' + 'single-select (pick one), multi-select (pick many), or free-text. ' + From be90f9f87331efc3aef123981efbe54ebd22e90a Mon Sep 17 00:00:00 2001 From: Albert Alises Date: Thu, 7 May 2026 10:24:38 +0200 Subject: [PATCH 03/20] fix(ai-builder): Use expiring Computer Use setup tokens (no-changelog) (#29872) --- .../instance-ai/docs/filesystem-access.md | 6 +- .../__tests__/instance-ai.controller.test.ts | 15 +++- .../instance-ai.gateway.service.test.ts | 33 ++++++- .../filesystem/local-gateway-registry.ts | 25 +++++- .../instance-ai/instance-ai.controller.ts | 6 +- .../instance-ai/instance-ai.service.ts | 4 + .../frontend/@n8n/i18n/src/locales/en.json | 3 + .../instanceAiSettings.store.test.ts | 88 ++++++++++++++++++- .../modals/ComputerUseSetupContent.vue | 69 ++++++++++++++- .../features/ai/instanceAi/instanceAi.api.ts | 22 +++-- .../ai/instanceAi/instanceAiSettings.store.ts | 27 ++++++ 11 files changed, 274 insertions(+), 24 deletions(-) diff --git a/packages/@n8n/instance-ai/docs/filesystem-access.md b/packages/@n8n/instance-ai/docs/filesystem-access.md index d6f52510c49..d269b80bf69 100644 --- a/packages/@n8n/instance-ai/docs/filesystem-access.md +++ b/packages/@n8n/instance-ai/docs/filesystem-access.md @@ -270,8 +270,8 @@ Two options: The static key is used for all requests — no pairing/session upgrade. - **Dynamic (pairing → session key)**: 1. `POST /instance-ai/gateway/create-link` (requires session auth) → - returns `{ token, command }`. The token is a **one-time pairing token** - (5-min TTL). + returns `{ token, command, expiresAt, ttlSeconds }`. The token is a + **one-time pairing token** (5-min TTL). 2. Daemon calls `POST /instance-ai/gateway/init` with the pairing token → server consumes the token and returns `{ ok: true, sessionKey }`. 3. All subsequent requests (SSE, response) use the **session key** instead @@ -289,6 +289,8 @@ create-link → pairingToken (5 min TTL, single-use) This prevents token replay: the pairing token is visible in terminal output and `ps aux`, but it becomes useless after the first successful `init` call. +The resulting session key has no time-based expiry and remains valid until +explicit disconnect/revocation. All key comparisons use `timingSafeEqual()` to prevent timing attacks. --- diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts index 7f3c24cdea8..4a2f1b44110 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts @@ -855,8 +855,14 @@ describe('InstanceAiController', () => { }); }); - it('should return token and command', async () => { + it('should return token, command, and token expiry', async () => { + const nowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(new Date('2026-01-01T00:00:00.000Z').getTime()); instanceAiService.generatePairingToken.mockReturnValue('pairing-token'); + instanceAiService.getGatewayApiKeyExpiresAt.mockReturnValue( + new Date('2026-01-01T00:05:00.000Z'), + ); urlService.getInstanceBaseUrl.mockReturnValue('https://myinstance.n8n.cloud'); const result = await controller.createGatewayLink(req); @@ -864,8 +870,15 @@ describe('InstanceAiController', () => { expect(result).toEqual({ token: 'pairing-token', command: 'npx @n8n/computer-use https://myinstance.n8n.cloud pairing-token', + expiresAt: '2026-01-01T00:05:00.000Z', + ttlSeconds: 300, }); expect(instanceAiService.generatePairingToken).toHaveBeenCalledWith(USER_ID); + expect(instanceAiService.getGatewayApiKeyExpiresAt).toHaveBeenCalledWith( + USER_ID, + 'pairing-token', + ); + nowSpy.mockRestore(); }); }); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.gateway.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.gateway.service.test.ts index 303a094dc03..f35761a1d28 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.gateway.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.gateway.service.test.ts @@ -30,11 +30,15 @@ describe('LocalGatewayRegistry — per-user gateway isolation', () => { expect(token1).toBe(token2); }); - it('returns the active session key if one already exists', () => { + it('returns a pairing token instead of exposing an active session key', () => { const pairingToken = registry.generatePairingToken('user-a'); - const sessionKey = registry.consumePairingToken('user-a', pairingToken); + const sessionKey = registry.consumePairingToken('user-a', pairingToken)!; + const nextPairingToken = registry.generatePairingToken('user-a'); - expect(registry.generatePairingToken('user-a')).toBe(sessionKey); + expect(nextPairingToken).toMatch(/^gw_/); + expect(nextPairingToken).not.toBe(sessionKey); + expect(registry.getUserIdForApiKey(sessionKey)).toBe('user-a'); + expect(registry.getUserIdForApiKey(nextPairingToken)).toBe('user-a'); }); it('generates independent tokens for different users', () => { @@ -77,6 +81,16 @@ describe('LocalGatewayRegistry — per-user gateway isolation', () => { }); describe('getPairingToken', () => { + it('returns the expiry time for an active pairing token', () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_000); + const token = registry.generatePairingToken('user-a'); + + expect(registry.getApiKeyExpiresAt('user-a', token)?.toISOString()).toBe( + new Date(301_000).toISOString(), + ); + nowSpy.mockRestore(); + }); + it('returns null and cleans up the reverse lookup for an expired token', () => { const token = registry.generatePairingToken('user-a'); @@ -91,6 +105,19 @@ describe('LocalGatewayRegistry — per-user gateway isolation', () => { expect(registry.getPairingToken('user-a')).toBeNull(); expect(registry.getUserIdForApiKey(token)).toBeUndefined(); }); + + it('rejects an expired pairing token via getUserIdForApiKey without prior cleanup', () => { + const token = registry.generatePairingToken('user-a'); + + const userGateways = ( + registry as unknown as { + userGateways: Map; + } + ).userGateways; + userGateways.get('user-a')!.pairingToken!.createdAt = Date.now() - 10 * 60 * 1000; + + expect(registry.getUserIdForApiKey(token)).toBeUndefined(); + }); }); describe('getGatewayStatus', () => { diff --git a/packages/cli/src/modules/instance-ai/filesystem/local-gateway-registry.ts b/packages/cli/src/modules/instance-ai/filesystem/local-gateway-registry.ts index 1b680decd7b..2fbbb44bd05 100644 --- a/packages/cli/src/modules/instance-ai/filesystem/local-gateway-registry.ts +++ b/packages/cli/src/modules/instance-ai/filesystem/local-gateway-registry.ts @@ -55,15 +55,23 @@ export class LocalGatewayRegistry { /** Resolve an API key (pairing token or session key) back to the owning userId. */ getUserIdForApiKey(key: string): string | undefined { - return this.apiKeyToUserId.get(key); + const userId = this.apiKeyToUserId.get(key); + if (!userId) return undefined; + + const state = this.userGateways.get(userId); + if (state?.pairingToken?.token === key) { + if (Date.now() - state.pairingToken.createdAt > PAIRING_TOKEN_TTL_MS) { + this.apiKeyToUserId.delete(state.pairingToken.token); + state.pairingToken = null; + return undefined; + } + } + return userId; } /** Generate a one-time pairing token for UI-initiated connections. */ generatePairingToken(userId: string): string { const state = this.getOrCreate(userId); - // If there's an active session key, return it so the daemon can reconnect - // without losing its authenticated session (e.g. after a page reload). - if (state.activeSessionKey) return state.activeSessionKey; // Reuse existing valid token to prevent race conditions between concurrent callers. const existing = this.getPairingToken(userId); @@ -87,6 +95,15 @@ export class LocalGatewayRegistry { return state.pairingToken.token; } + /** Get the expiry time for an active pairing token. Session keys do not expire. */ + getApiKeyExpiresAt(userId: string, key: string): Date | null { + const state = this.userGateways.get(userId); + if (!state?.pairingToken || state.pairingToken.token !== key) return null; + const token = this.getPairingToken(userId); + if (!token) return null; + return new Date(state.pairingToken.createdAt + PAIRING_TOKEN_TTL_MS); + } + /** * Consume the pairing token and issue a long-lived session key. * Returns the session key, or null if the token is invalid or expired. diff --git a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts index 76759811730..94e5b5cedad 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts @@ -631,9 +631,13 @@ export class InstanceAiController { async createGatewayLink(req: AuthenticatedRequest) { await this.assertGatewayEnabled(req.user.id); const token = this.instanceAiService.generatePairingToken(req.user.id); + const expiresAt = this.instanceAiService.getGatewayApiKeyExpiresAt(req.user.id, token); + const ttlSeconds = expiresAt + ? Math.max(0, Math.ceil((expiresAt.getTime() - Date.now()) / 1000)) + : null; const baseUrl = this.urlService.getInstanceBaseUrl(); const command = `npx @n8n/computer-use ${baseUrl} ${token}`; - return { token, command }; + return { token, command, expiresAt: expiresAt?.toISOString() ?? null, ttlSeconds }; } @Get('/gateway/events', { usesTemplates: true, skipAuth: true }) diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index a3274315ec9..4394c7961e0 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -1276,6 +1276,10 @@ export class InstanceAiService { return this.gatewayRegistry.generatePairingToken(userId); } + getGatewayApiKeyExpiresAt(userId: string, key: string): Date | null { + return this.gatewayRegistry.getApiKeyExpiresAt(userId, key); + } + getPairingToken(userId: string): string | null { return this.gatewayRegistry.getPairingToken(userId); } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 465b75df63b..1b65c46322e 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5977,5 +5977,8 @@ "instanceAi.welcomeModal.gateway.instructions.mac": "Open Terminal (Cmd + Space, type \"Terminal\") and paste the command below.", "instanceAi.welcomeModal.gateway.instructions.windows": "Open Terminal (Windows key, type \"Terminal\") and paste the command below.", "instanceAi.welcomeModal.gateway.instructions.linux": "Open your terminal and paste the command below.", + "instanceAi.welcomeModal.gateway.tokenExpiresIn": "This token expires in {minutes} min.", + "instanceAi.welcomeModal.gateway.tokenExpired": "This token has expired. Copy the command again.", + "instanceAi.welcomeModal.gateway.leadingSpaceHint": "If your shell supports it, start the command with a space to keep it out of history.", "instanceAi.welcomeModal.gateway.browserAutomationHint": "Want browser automation? Install the n8n Browser Use Chrome extension so the agent can control your browser." } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts index f89795f08d6..c1980274bad 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts @@ -36,6 +36,8 @@ const mockFetchPreferences = vi.fn(); const mockUpdatePreferences = vi.fn(); const mockFetchModelCredentials = vi.fn().mockResolvedValue([]); const mockFetchServiceCredentials = vi.fn().mockResolvedValue([]); +const mockCreateGatewayLink = vi.fn(); +const mockDisconnectGatewaySession = vi.fn(); vi.mock('../instanceAi.settings.api', () => ({ fetchSettings: (...args: unknown[]) => mockFetchSettings(...args), @@ -48,8 +50,8 @@ vi.mock('../instanceAi.settings.api', () => ({ const mockGetGatewayStatus = vi.fn(); vi.mock('../instanceAi.api', () => ({ - createGatewayLink: vi.fn(), - disconnectGatewaySession: vi.fn(), + createGatewayLink: (...args: unknown[]) => mockCreateGatewayLink(...args), + disconnectGatewaySession: (...args: unknown[]) => mockDisconnectGatewaySession(...args), getGatewayStatus: (...args: unknown[]) => mockGetGatewayStatus(...args), })); @@ -396,4 +398,86 @@ describe('useInstanceAiSettingsStore', () => { expect(store.connections[0].type).toBe('computer-use'); }); }); + + describe('setup command', () => { + beforeEach(() => { + setModuleSettings(settingsStore, { + enabled: true, + localGatewayDisabled: false, + proxyEnabled: false, + optinModalDismissed: false, + cloudManaged: false, + }); + setUserPreference(store, { localGatewayDisabled: false }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('clears stale command state while fetching a new setup command', async () => { + let resolveRequest: (value: { + command: string; + expiresAt: string; + ttlSeconds: number; + }) => void = () => {}; + mockCreateGatewayLink.mockReturnValue( + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + store.setupCommand = 'old command'; + store.setupCommandExpiresAt = '2026-01-01T00:00:00.000Z'; + store.setupCommandTtlSeconds = 1; + store.setupCommandFetchedAt = 1; + + const request = store.fetchSetupCommand(); + + expect(store.setupCommand).toBeNull(); + expect(store.setupCommandExpiresAt).toBeNull(); + expect(store.setupCommandTtlSeconds).toBeNull(); + expect(store.setupCommandFetchedAt).toBeNull(); + + resolveRequest({ + command: 'new command', + expiresAt: '2026-01-01T00:05:00.000Z', + ttlSeconds: 300, + }); + await request; + + expect(store.setupCommand).toBe('new command'); + }); + + it('uses the request start time as setup command countdown baseline', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + mockCreateGatewayLink.mockImplementation(async () => { + vi.setSystemTime(new Date('2026-01-01T00:00:10.000Z')); + return { + command: 'command', + expiresAt: '2026-01-01T00:05:00.000Z', + ttlSeconds: 300, + }; + }); + + await store.fetchSetupCommand(); + + expect(store.setupCommandFetchedAt).toBe(new Date('2026-01-01T00:00:00.000Z').getTime()); + }); + + it('clears setup command state on disconnect', async () => { + mockDisconnectGatewaySession.mockResolvedValue(undefined); + store.setupCommand = 'old command'; + store.setupCommandExpiresAt = '2026-01-01T00:00:00.000Z'; + store.setupCommandTtlSeconds = 1; + store.setupCommandFetchedAt = 1; + + await store.disconnectComputerUse(); + + expect(store.setupCommand).toBeNull(); + expect(store.setupCommandExpiresAt).toBeNull(); + expect(store.setupCommandTtlSeconds).toBeNull(); + expect(store.setupCommandFetchedAt).toBeNull(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/ComputerUseSetupContent.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/ComputerUseSetupContent.vue index 63e369602a2..c766e161bdb 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/ComputerUseSetupContent.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/ComputerUseSetupContent.vue @@ -1,5 +1,5 @@ diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts index a5abcbb739d..57c035a9783 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts @@ -6,6 +6,7 @@ import { isSafeObjectKey, type InstanceAiConfirmation, type InstanceAiConfirmRequest, + type InstanceAiResourceDecision, type InstanceAiAttachment, type InstanceAiEvent, type InstanceAiMessage, @@ -794,7 +795,10 @@ export function createThreadRuntime(initialThreadId: string, hooks: ThreadRuntim } } - async function confirmResourceDecision(requestId: string, decision: string): Promise { + async function confirmResourceDecision( + requestId: string, + decision: InstanceAiResourceDecision, + ): Promise { resolveConfirmation(requestId, 'approved'); await confirmAction(requestId, { kind: 'resourceDecision', resourceDecision: decision }); } From 6232de4d477ffa56e0082d87a5b63d1c9ef00d4c Mon Sep 17 00:00:00 2001 From: Arvin A <51036481+DeveloperTheExplorer@users.noreply.github.com> Date: Thu, 7 May 2026 10:31:13 +0200 Subject: [PATCH 06/20] feat(editor): Cap eval concurrency slider at admin-set limit (#29807) Co-authored-by: Claude Opus 4.7 (1M context) --- .../@n8n/api-types/src/frontend-settings.ts | 1 + .../test/OutputParserStructured.node.test.ts | 1 + packages/cli/src/services/frontend.service.ts | 1 + .../editor-ui/src/__tests__/defaults.ts | 1 + .../evaluation.ee/parallelEval.store.test.ts | 75 +++++++++++++++++++ .../ai/evaluation.ee/parallelEval.store.ts | 32 +++++++- .../evaluation.ee/views/EvaluationsView.vue | 2 +- 7 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 82e86dc6710..336b4e56c13 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -101,6 +101,7 @@ export interface FrontendSettings { nodeJsVersion: string; nodeEnv: string | undefined; concurrency: number; + evaluationConcurrencyLimit: number; authCookie: { secure: boolean; }; diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts index 80933034c14..853427fe2ec 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts @@ -828,6 +828,7 @@ describe('OutputParserStructured', () => { await expect(execution).rejects.toThrow( 'Auto-fixing parser prompt has to contain {error} placeholder', ); + await expect(execution).rejects.toThrow(NodeOperationError); }); it('should throw error when prompt template is empty', async () => { diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 5a210b30c3a..3cc97bdef49 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -214,6 +214,7 @@ export class FrontendService { nodeEnv: process.env.NODE_ENV, versionCli: N8N_VERSION, concurrency: this.globalConfig.executions.concurrency.productionLimit, + evaluationConcurrencyLimit: this.globalConfig.executions.concurrency.evaluationLimit, authCookie: { secure: this.globalConfig.auth.cookie.secure, }, diff --git a/packages/frontend/editor-ui/src/__tests__/defaults.ts b/packages/frontend/editor-ui/src/__tests__/defaults.ts index 0469c73cd5c..c7e887531d2 100644 --- a/packages/frontend/editor-ui/src/__tests__/defaults.ts +++ b/packages/frontend/editor-ui/src/__tests__/defaults.ts @@ -118,6 +118,7 @@ export const defaultSettings: FrontendSettings = { nodeJsVersion: '', nodeEnv: '', concurrency: -1, + evaluationConcurrencyLimit: -1, versionNotifications: { enabled: true, endpoint: '', diff --git a/packages/frontend/editor-ui/src/features/ai/evaluation.ee/parallelEval.store.test.ts b/packages/frontend/editor-ui/src/features/ai/evaluation.ee/parallelEval.store.test.ts index f10d04bf6bf..6574315992b 100644 --- a/packages/frontend/editor-ui/src/features/ai/evaluation.ee/parallelEval.store.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/evaluation.ee/parallelEval.store.test.ts @@ -1,4 +1,5 @@ import { createPinia, setActivePinia } from 'pinia'; +import { reactive } from 'vue'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LOCAL_STORAGE_PARALLEL_EVAL_BY_WORKFLOW } from '@/app/constants/localStorage'; @@ -11,10 +12,30 @@ vi.mock('@/app/stores/posthog.store', () => ({ })), })); +// Singleton-shaped mock so the store keeps a stable `settingsStore` reference +// across the test lifetime. Mutating `.settings.evaluationConcurrencyLimit` +// then propagates through the `maxConcurrency` `computed`'s reactive read of +// `settingsStore.settings?.evaluationConcurrencyLimit`, which mirrors how the +// real store updates after `/rest/login` resolves and settings are populated. +const mockSettingsState = reactive({ + settings: { evaluationConcurrencyLimit: -1 }, +}); +vi.mock('@/app/stores/settings.store', () => ({ + useSettingsStore: vi.fn(() => mockSettingsState), +})); + +const mockEvaluationConcurrencyLimit = (limit: number) => { + mockSettingsState.settings.evaluationConcurrencyLimit = limit; +}; + describe('parallelEval.store', () => { beforeEach(() => { setActivePinia(createPinia()); localStorage.removeItem(LOCAL_STORAGE_PARALLEL_EVAL_BY_WORKFLOW); + // Reset settings mock between tests; `clearAllMocks` only resets call + // history, not `mockReturnValue` implementations, so cross-test bleed + // would otherwise cap the slider in unrelated cases. + mockEvaluationConcurrencyLimit(-1); }); afterEach(() => { @@ -132,6 +153,60 @@ describe('parallelEval.store', () => { }); }); + describe('maxConcurrency (admin cap via N8N_CONCURRENCY_EVALUATION_LIMIT)', () => { + it('defaults to 10 when the limit is unset (-1, "unlimited")', () => { + mockEvaluationConcurrencyLimit(-1); + const store = useParallelEvalStore(); + expect(store.maxConcurrency).toBe(10); + }); + + it('lowers the slider ceiling to the configured limit', () => { + mockEvaluationConcurrencyLimit(5); + const store = useParallelEvalStore(); + expect(store.maxConcurrency).toBe(5); + }); + + it('caps at 10 even when the configured limit is higher (BE clamp parity)', () => { + mockEvaluationConcurrencyLimit(50); + const store = useParallelEvalStore(); + expect(store.maxConcurrency).toBe(10); + }); + + it('treats 0 the same as unlimited (BE convention)', () => { + mockEvaluationConcurrencyLimit(0); + const store = useParallelEvalStore(); + expect(store.maxConcurrency).toBe(10); + }); + + it('setConcurrencyValue clamps writes to the configured limit', () => { + mockEvaluationConcurrencyLimit(4); + const store = useParallelEvalStore(); + store.setConcurrencyValue('wf-a', 9); + expect(store.concurrencyValue('wf-a')).toBe(4); + }); + + it('concurrencyValue surfaces the clamped value when admin lowers the cap below a stored preference', () => { + // Pre-existing user preference of 8 (stored when limit was open). + mockEvaluationConcurrencyLimit(-1); + const store = useParallelEvalStore(); + store.setConcurrencyValue('wf-a', 8); + expect(store.concurrencyValue('wf-a')).toBe(8); + + // Admin lowers cap to 3 — UI should reflect 3, not 8. + mockEvaluationConcurrencyLimit(3); + expect(store.concurrencyValue('wf-a')).toBe(3); + }); + + it('effectiveConcurrency reflects the cap so the value sent to BE matches the slider', () => { + mockEvaluationConcurrencyLimit(-1); + const store = useParallelEvalStore(); + store.setConcurrencyValue('wf-a', 8); + + mockEvaluationConcurrencyLimit(2); + expect(store.effectiveConcurrency('wf-a')).toBe(2); + }); + }); + describe('effectiveConcurrency', () => { it('returns the slider value when parallel is enabled', () => { const store = useParallelEvalStore(); diff --git a/packages/frontend/editor-ui/src/features/ai/evaluation.ee/parallelEval.store.ts b/packages/frontend/editor-ui/src/features/ai/evaluation.ee/parallelEval.store.ts index a81869b2440..328139f821d 100644 --- a/packages/frontend/editor-ui/src/features/ai/evaluation.ee/parallelEval.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/evaluation.ee/parallelEval.store.ts @@ -5,6 +5,7 @@ import { computed } from 'vue'; import { LOCAL_STORAGE_PARALLEL_EVAL_BY_WORKFLOW } from '@/app/constants/localStorage'; import { usePostHog } from '@/app/stores/posthog.store'; +import { useSettingsStore } from '@/app/stores/settings.store'; // Sentinel used for workflows that haven't been saved yet (no id assigned). // Mirrors the per-workflow localStorage pattern used elsewhere in the editor. @@ -12,6 +13,11 @@ const NEW_WORKFLOW_SENTINEL = 'new'; export const DEFAULT_PARALLEL_CONCURRENCY = 3; +// Hard upper bound for the slider, mirrored on the BE in +// `test-runner.service.ee.ts`'s `runTest` clamp. Admins can lower this via +// `N8N_CONCURRENCY_EVALUATION_LIMIT`; they cannot raise it. +const SLIDER_HARD_MAX = 10; + interface PerWorkflowState { parallelEnabled: boolean; concurrencyValue: number; @@ -36,6 +42,7 @@ const buildDefaultState = (): PerWorkflowState => ({ */ export const useParallelEvalStore = defineStore('parallelEval', () => { const postHog = usePostHog(); + const settingsStore = useSettingsStore(); const storage = useLocalStorage( LOCAL_STORAGE_PARALLEL_EVAL_BY_WORKFLOW, {}, @@ -48,6 +55,18 @@ export const useParallelEvalStore = defineStore('parallelEval', () => { () => postHog.isFeatureEnabled(EVAL_PARALLEL_EXECUTION_FLAG) === true, ); + // Effective slider ceiling: the BE's `N8N_CONCURRENCY_EVALUATION_LIMIT` + // (`evaluationConcurrencyLimit`) further constrains the 1–10 UX range. + // `<= 0` means unlimited per BE convention. Mirrors the runtime clamp in + // `test-runner.service.ee.ts:runTest` so the slider can only offer values + // the BE will actually accept. + const maxConcurrency = computed(() => { + const limit = settingsStore.settings?.evaluationConcurrencyLimit; + return typeof limit === 'number' && limit > 0 + ? Math.min(SLIDER_HARD_MAX, Math.floor(limit)) + : SLIDER_HARD_MAX; + }); + const resolveKey = (workflowId: string | undefined): string => workflowId && workflowId.length > 0 ? workflowId : NEW_WORKFLOW_SENTINEL; @@ -65,8 +84,12 @@ export const useParallelEvalStore = defineStore('parallelEval', () => { const isParallel = (workflowId: string | undefined): boolean => ensureEntry(resolveKey(workflowId)).parallelEnabled; + // Read-side clamp (no mutation): if the admin lowers + // `N8N_CONCURRENCY_EVALUATION_LIMIT` below a previously-stored value, the + // UI surfaces the capped number while leaving the user's preference intact + // in localStorage so it returns naturally if the cap is later raised. const concurrencyValue = (workflowId: string | undefined): number => - ensureEntry(resolveKey(workflowId)).concurrencyValue; + Math.min(ensureEntry(resolveKey(workflowId)).concurrencyValue, maxConcurrency.value); const setParallel = (workflowId: string | undefined, value: boolean): void => { ensureEntry(resolveKey(workflowId)).parallelEnabled = value; @@ -80,7 +103,7 @@ export const useParallelEvalStore = defineStore('parallelEval', () => { // the checked-but-cleared UX feels natural rather than dropping to // sequential behind the user's back. const safe = Number.isFinite(value) ? value : DEFAULT_PARALLEL_CONCURRENCY; - const clamped = Math.max(1, Math.min(10, Math.floor(safe))); + const clamped = Math.max(1, Math.min(maxConcurrency.value, Math.floor(safe))); ensureEntry(resolveKey(workflowId)).concurrencyValue = clamped; }; @@ -92,11 +115,14 @@ export const useParallelEvalStore = defineStore('parallelEval', () => { */ const effectiveConcurrency = (workflowId: string | undefined): number => { const state = ensureEntry(resolveKey(workflowId)); - return state.parallelEnabled ? state.concurrencyValue : 1; + // Use the read-side clamped value so what we send matches what the + // slider shows when the admin cap is below the stored preference. + return state.parallelEnabled ? Math.min(state.concurrencyValue, maxConcurrency.value) : 1; }; return { isFeatureEnabled, + maxConcurrency, isParallel, concurrencyValue, setParallel, diff --git a/packages/frontend/editor-ui/src/features/ai/evaluation.ee/views/EvaluationsView.vue b/packages/frontend/editor-ui/src/features/ai/evaluation.ee/views/EvaluationsView.vue index cae584c6592..c52f8a1b203 100644 --- a/packages/frontend/editor-ui/src/features/ai/evaluation.ee/views/EvaluationsView.vue +++ b/packages/frontend/editor-ui/src/features/ai/evaluation.ee/views/EvaluationsView.vue @@ -125,7 +125,7 @@ watch(runningTestRun, (run) => { Date: Thu, 7 May 2026 10:31:30 +0200 Subject: [PATCH 07/20] fix(editor): Rename encryption keys "Type" column to "Status" (#29966) Co-authored-by: Claude Opus 4.7 --- packages/frontend/@n8n/i18n/src/locales/en.json | 2 +- .../settings/encryption-keys/views/SettingsEncryptionKeys.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 1b65c46322e..13d36e95482 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -4505,7 +4505,7 @@ "settings.encryptionKeys.description": "Data encryption keys protect credentials, variables, and other sensitive data at rest. Rotating generates a new active key. Past keys are archived and retained for audit.", "settings.encryptionKeys.description.docsLink": "Learn more in documentation", "settings.encryptionKeys.column.key": "Key", - "settings.encryptionKeys.column.type": "Type", + "settings.encryptionKeys.column.status": "Status", "settings.encryptionKeys.column.activated": "Activated", "settings.encryptionKeys.column.archived": "Archived", "settings.encryptionKeys.status.active": "Active", diff --git a/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue b/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue index 39db364df3f..23be1647d71 100644 --- a/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue +++ b/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue @@ -76,7 +76,7 @@ const headers = computed>>(() => [ minWidth: 220, }, { - title: i18n.baseText('settings.encryptionKeys.column.type'), + title: i18n.baseText('settings.encryptionKeys.column.status'), key: 'status', value: (row) => row.status, minWidth: 120, From 29a864ca9bcd88e82cf5f998c9ea36d2f81a5dee Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Thu, 7 May 2026 11:46:16 +0300 Subject: [PATCH 08/20] fix(HTTP Request Node): Validate URL type in older node versions (#29886) --- .../HttpRequest/V1/HttpRequestV1.node.ts | 10 ++- .../HttpRequest/V2/HttpRequestV2.node.ts | 10 ++- .../test/node/HttpRequestV1.test.ts | 70 +++++++++++++++++++ .../test/node/HttpRequestV2.test.ts | 32 +++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV1.test.ts diff --git a/packages/nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts b/packages/nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts index fd91317abb7..c4824dcd058 100644 --- a/packages/nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts @@ -693,7 +693,15 @@ export class HttpRequestV1 implements INodeType { const parametersAreJson = this.getNodeParameter('jsonParameters', itemIndex); const options = this.getNodeParameter('options', itemIndex, {}); - const url = this.getNodeParameter('url', itemIndex) as string; + const url = this.getNodeParameter('url', itemIndex); + + if (typeof url !== 'string') { + const actualType = url === null ? 'null' : typeof url; + throw new NodeOperationError( + this.getNode(), + `URL parameter must be a string, got ${actualType}`, + ); + } if (!url.startsWith('http://') && !url.startsWith('https://')) { throw new NodeOperationError( diff --git a/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts b/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts index 6fb9a2b7a00..2f7f5b75647 100644 --- a/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts @@ -740,7 +740,15 @@ export class HttpRequestV2 implements INodeType { const parametersAreJson = this.getNodeParameter('jsonParameters', itemIndex); const options = this.getNodeParameter('options', itemIndex, {}); - const url = this.getNodeParameter('url', itemIndex) as string; + const url = this.getNodeParameter('url', itemIndex); + + if (typeof url !== 'string') { + const actualType = url === null ? 'null' : typeof url; + throw new NodeOperationError( + this.getNode(), + `URL parameter must be a string, got ${actualType}`, + ); + } if (!url.startsWith('http://') && !url.startsWith('https://')) { throw new NodeOperationError( diff --git a/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV1.test.ts b/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV1.test.ts new file mode 100644 index 00000000000..24163a65a88 --- /dev/null +++ b/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV1.test.ts @@ -0,0 +1,70 @@ +import type { IExecuteFunctions, INodeTypeBaseDescription } from 'n8n-workflow'; + +import { HttpRequestV1 } from '../../V1/HttpRequestV1.node'; + +describe('HttpRequestV1', () => { + let node: HttpRequestV1; + let executeFunctions: IExecuteFunctions; + + beforeEach(() => { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'HTTP Request', + name: 'httpRequest', + description: 'Makes an HTTP request and returns the response data', + group: [], + }; + node = new HttpRequestV1(baseDescription); + executeFunctions = { + getInputData: jest.fn(() => [{ json: {} }]), + getNodeParameter: jest.fn(), + getNode: jest.fn(() => ({ + type: 'n8n-nodes-base.httpRequest', + typeVersion: 1, + })), + getCredentials: jest.fn(), + helpers: { + request: jest.fn(), + requestOAuth1: jest.fn(), + requestOAuth2: jest.fn(), + assertBinaryData: jest.fn(), + getBinaryStream: jest.fn(), + getBinaryMetadata: jest.fn(), + binaryToString: jest.fn(), + prepareBinaryData: jest.fn(), + }, + getContext: jest.fn(), + sendMessageToUI: jest.fn(), + continueOnFail: jest.fn(), + getMode: jest.fn(), + } as unknown as IExecuteFunctions; + }); + + describe('URL Parameter Validation', () => { + it.each([ + { url: undefined, expectedType: 'undefined' }, + { url: null, expectedType: 'null' }, + { url: 42, expectedType: 'number' }, + ])('should throw error when URL is $expectedType', async ({ url, expectedType }) => { + (executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => { + switch (paramName) { + case 'responseFormat': + return 'json'; + case 'requestMethod': + return 'GET'; + case 'jsonParameters': + return false; + case 'options': + return {}; + case 'url': + return url; + default: + return undefined; + } + }); + + await expect(node.execute.call(executeFunctions)).rejects.toThrow( + `URL parameter must be a string, got ${expectedType}`, + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV2.test.ts b/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV2.test.ts index 430ae6d00b3..2dffa30a03f 100644 --- a/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV2.test.ts +++ b/packages/nodes-base/nodes/HttpRequest/test/node/HttpRequestV2.test.ts @@ -160,4 +160,36 @@ describe('HttpRequestV2', () => { }, ); }); + + describe('URL Parameter Validation', () => { + it.each([ + { url: undefined, expectedType: 'undefined' }, + { url: null, expectedType: 'null' }, + { url: 42, expectedType: 'number' }, + ])('should throw error when URL is $expectedType', async ({ url, expectedType }) => { + (executeFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]); + (executeFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => { + switch (paramName) { + case 'responseFormat': + return 'json'; + case 'requestMethod': + return 'GET'; + case 'url': + return url; + case 'authentication': + return 'none'; + case 'jsonParameters': + return false; + case 'options': + return options; + default: + return undefined; + } + }); + + await expect(node.execute.call(executeFunctions)).rejects.toThrow( + `URL parameter must be a string, got ${expectedType}`, + ); + }); + }); }); From acc964381189aaacbeb584a16c0155ba6f96ffa1 Mon Sep 17 00:00:00 2001 From: Dawid Myslak Date: Thu, 7 May 2026 11:00:56 +0200 Subject: [PATCH 09/20] feat(Twilio Trigger Node): Add webhook request verification (#29259) --- .../nodes/Twilio/TwilioTrigger.node.ts | 10 + .../nodes/Twilio/TwilioTriggerHelpers.ts | 79 +++++++ .../Twilio/test/TwilioTrigger.node.test.ts | 83 +++++++ .../Twilio/test/TwilioTriggerHelpers.test.ts | 217 ++++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 packages/nodes-base/nodes/Twilio/TwilioTriggerHelpers.ts create mode 100644 packages/nodes-base/nodes/Twilio/test/TwilioTrigger.node.test.ts create mode 100644 packages/nodes-base/nodes/Twilio/test/TwilioTriggerHelpers.test.ts diff --git a/packages/nodes-base/nodes/Twilio/TwilioTrigger.node.ts b/packages/nodes-base/nodes/Twilio/TwilioTrigger.node.ts index 0a46f5e259f..2b92e3e638e 100644 --- a/packages/nodes-base/nodes/Twilio/TwilioTrigger.node.ts +++ b/packages/nodes-base/nodes/Twilio/TwilioTrigger.node.ts @@ -8,6 +8,7 @@ import { } from 'n8n-workflow'; import { twilioTriggerApiRequest } from './GenericFunctions'; +import { verifySignature } from './TwilioTriggerHelpers'; export class TwilioTrigger implements INodeType { description: INodeTypeDescription = { @@ -188,6 +189,15 @@ export class TwilioTrigger implements INodeType { }; async webhook(this: IWebhookFunctions): Promise { + const isSignatureValid = await verifySignature.call(this); + if (!isSignatureValid) { + const res = this.getResponseObject(); + res.status(401).send('Unauthorized').end(); + return { + noWebhookResponse: true, + }; + } + const bodyData = this.getBodyData(); return { diff --git a/packages/nodes-base/nodes/Twilio/TwilioTriggerHelpers.ts b/packages/nodes-base/nodes/Twilio/TwilioTriggerHelpers.ts new file mode 100644 index 00000000000..06abe60ddbc --- /dev/null +++ b/packages/nodes-base/nodes/Twilio/TwilioTriggerHelpers.ts @@ -0,0 +1,79 @@ +import { createHash, createHmac, timingSafeEqual } from 'crypto'; +import type { IWebhookFunctions } from 'n8n-workflow'; + +import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification'; + +/** + * Verifies a Twilio Event Streams webhook request. + * + * Twilio signs JSON webhooks by: + * 1. Computing SHA-256 of the raw body and appending it as `bodySHA256` query param + * 2. HMAC-SHA1 over the resulting URL using the account auth token + * 3. Sending the base64-encoded result in the `X-Twilio-Signature` header + * + * Falls back to skip verification when no auth token is available + * (e.g. credential uses API Key auth) to preserve existing workflows. + */ +export async function verifySignature(this: IWebhookFunctions): Promise { + try { + const credential = await this.getCredentials<{ authType?: string; authToken?: string }>( + 'twilioApi', + ); + const req = this.getRequestObject(); + const authToken = credential.authToken; + + if (!authToken || typeof authToken !== 'string') { + return true; + } + + const rawBody = req.rawBody; + if (!rawBody) { + return false; + } + + const bodyBuffer = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody); + const computedBodyHash = createHash('sha256').update(bodyBuffer).digest('hex'); + const bodyHashFromQuery = req.query?.bodySHA256; + + if ( + typeof bodyHashFromQuery !== 'string' || + bodyHashFromQuery.length !== computedBodyHash.length || + !timingSafeEqual(Buffer.from(bodyHashFromQuery), Buffer.from(computedBodyHash)) + ) { + return false; + } + + let sinkUrl = this.getNodeWebhookUrl('default'); + if (!sinkUrl) { + return false; + } + + const originalUrl: string = req.originalUrl ?? req.url ?? ''; + + // getNodeWebhookUrl always returns the production path (/webhook/...). + // In test mode the request arrives at /webhook-test/..., so adjust + // the base URL to match what was actually signed against. + const originalPath = originalUrl.split('?')[0]; + if (originalPath.includes('/webhook-test/')) { + sinkUrl = sinkUrl.replace('/webhook/', '/webhook-test/'); + } + + const queryIdx = originalUrl.indexOf('?'); + const queryString = queryIdx === -1 ? '' : originalUrl.substring(queryIdx + 1); + const signedUrl = queryString ? `${sinkUrl}?${queryString}` : sinkUrl; + + return verifySignatureGeneric({ + getExpectedSignature: () => { + const hmac = createHmac('sha1', authToken); + hmac.update(signedUrl); + return hmac.digest('base64'); + }, + getActualSignature: () => { + const sig = req.header('x-twilio-signature'); + return typeof sig === 'string' ? sig : null; + }, + }); + } catch (error) { + return false; + } +} diff --git a/packages/nodes-base/nodes/Twilio/test/TwilioTrigger.node.test.ts b/packages/nodes-base/nodes/Twilio/test/TwilioTrigger.node.test.ts new file mode 100644 index 00000000000..e9429d91e00 --- /dev/null +++ b/packages/nodes-base/nodes/Twilio/test/TwilioTrigger.node.test.ts @@ -0,0 +1,83 @@ +import type { IWebhookFunctions } from 'n8n-workflow'; + +import { TwilioTrigger } from '../TwilioTrigger.node'; +import { verifySignature } from '../TwilioTriggerHelpers'; + +jest.mock('../TwilioTriggerHelpers'); + +describe('TwilioTrigger', () => { + let trigger: TwilioTrigger; + let mockWebhookFunctions: Pick< + jest.Mocked, + 'getBodyData' | 'getResponseObject' | 'helpers' + >; + + beforeEach(() => { + jest.clearAllMocks(); + trigger = new TwilioTrigger(); + + mockWebhookFunctions = { + getBodyData: jest.fn(), + getResponseObject: jest.fn(), + helpers: { + returnJsonArray: jest.fn((data) => data), + } as any, + }; + }); + + describe('webhook', () => { + it('should process the webhook when signature verification passes', async () => { + const bodyData = [ + { specversion: '1.0', type: 'com.twilio.messaging.inbound-message.received' }, + ]; + + (verifySignature as jest.Mock).mockResolvedValue(true); + mockWebhookFunctions.getBodyData.mockReturnValue(bodyData as any); + + const result = await trigger.webhook.call( + mockWebhookFunctions as unknown as IWebhookFunctions, + ); + + expect(verifySignature).toHaveBeenCalled(); + expect(result.workflowData).toBeDefined(); + expect(mockWebhookFunctions.helpers.returnJsonArray).toHaveBeenCalledWith(bodyData); + }); + + it('should return 401 when signature verification fails', async () => { + const mockResponse = { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + end: jest.fn(), + }; + + (verifySignature as jest.Mock).mockResolvedValue(false); + mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse as any); + + const result = await trigger.webhook.call( + mockWebhookFunctions as unknown as IWebhookFunctions, + ); + + expect(verifySignature).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.send).toHaveBeenCalledWith('Unauthorized'); + expect(mockResponse.end).toHaveBeenCalled(); + expect(result).toEqual({ noWebhookResponse: true }); + expect(mockWebhookFunctions.getBodyData).not.toHaveBeenCalled(); + }); + + it('should process the webhook when no auth token is configured (backward compat)', async () => { + const bodyData = [ + { specversion: '1.0', type: 'com.twilio.voice.insights.call-summary.complete' }, + ]; + + (verifySignature as jest.Mock).mockResolvedValue(true); + mockWebhookFunctions.getBodyData.mockReturnValue(bodyData as any); + + const result = await trigger.webhook.call( + mockWebhookFunctions as unknown as IWebhookFunctions, + ); + + expect(result.workflowData).toBeDefined(); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Twilio/test/TwilioTriggerHelpers.test.ts b/packages/nodes-base/nodes/Twilio/test/TwilioTriggerHelpers.test.ts new file mode 100644 index 00000000000..fc0ddefd0c2 --- /dev/null +++ b/packages/nodes-base/nodes/Twilio/test/TwilioTriggerHelpers.test.ts @@ -0,0 +1,217 @@ +import { createHash, createHmac } from 'crypto'; + +import { verifySignature } from '../TwilioTriggerHelpers'; + +describe('TwilioTriggerHelpers', () => { + const testAuthToken = 'test-twilio-auth-token'; + const sinkUrl = 'https://n8n.example.com/webhook/abc/webhook'; + const testBody = '[{"specversion":"1.0","type":"com.twilio.messaging.inbound-message.received"}]'; + const testBodyHash = createHash('sha256').update(testBody).digest('hex'); + const queryString = `bodySHA256=${testBodyHash}`; + const signedUrl = `${sinkUrl}?${queryString}`; + const validSignature = createHmac('sha1', testAuthToken).update(signedUrl).digest('base64'); + + let mockWebhookFunctions: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockWebhookFunctions = { + getCredentials: jest.fn(), + getRequestObject: jest.fn(), + getNodeWebhookUrl: jest.fn().mockReturnValue(sinkUrl), + }; + + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockImplementation((name: string) => { + if (name === 'x-twilio-signature') return validSignature; + return null; + }), + query: { bodySHA256: testBodyHash }, + rawBody: Buffer.from(testBody), + originalUrl: `/webhook/abc/webhook?${queryString}`, + }); + }); + + describe('verifySignature', () => { + it('should return true when no auth token is configured (backward compat)', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + authType: 'apiKey', + apiKeySid: 'SK123', + apiKeySecret: 'secret', + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + expect(mockWebhookFunctions.getCredentials).toHaveBeenCalledWith('twilioApi'); + }); + + it('should return true when auth token is empty string', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + authType: 'authToken', + authToken: '', + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + }); + + it('should return true when signature is valid', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + authType: 'authToken', + authToken: testAuthToken, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + }); + + it('should return false when signature is invalid', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + authType: 'authToken', + authToken: testAuthToken, + }); + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockImplementation((name: string) => { + if (name === 'x-twilio-signature') { + return Buffer.from('invalidsignaturevaluepadded').toString('base64'); + } + return null; + }), + query: { bodySHA256: testBodyHash }, + rawBody: Buffer.from(testBody), + originalUrl: `/webhook/abc/webhook?${queryString}`, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(false); + }); + + it('should return false when X-Twilio-Signature header is missing', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + authType: 'authToken', + authToken: testAuthToken, + }); + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockReturnValue(null), + query: { bodySHA256: testBodyHash }, + rawBody: Buffer.from(testBody), + originalUrl: `/webhook/abc/webhook?${queryString}`, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(false); + }); + + it('should return false when bodySHA256 query param is missing', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + authType: 'authToken', + authToken: testAuthToken, + }); + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockReturnValue(validSignature), + query: {}, + rawBody: Buffer.from(testBody), + originalUrl: '/webhook/abc/webhook', + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(false); + }); + + it('should return false when bodySHA256 does not match raw body hash', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + authType: 'authToken', + authToken: testAuthToken, + }); + const tamperedHash = createHash('sha256').update('tampered').digest('hex'); + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockReturnValue(validSignature), + query: { bodySHA256: tamperedHash }, + rawBody: Buffer.from(testBody), + originalUrl: `/webhook/abc/webhook?bodySHA256=${tamperedHash}`, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(false); + }); + + it('should return false when raw body is missing', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + authType: 'authToken', + authToken: testAuthToken, + }); + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockReturnValue(validSignature), + query: { bodySHA256: testBodyHash }, + rawBody: undefined, + originalUrl: `/webhook/abc/webhook?${queryString}`, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(false); + }); + + it('should return false when getCredentials throws', async () => { + mockWebhookFunctions.getCredentials.mockRejectedValue(new Error('credential lookup failed')); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(false); + }); + + it('should return true in test mode when signature matches test URL', async () => { + const testSinkUrl = 'https://n8n.example.com/webhook-test/abc/webhook'; + const testSignedUrl = `${testSinkUrl}?${queryString}`; + const testModeSignature = createHmac('sha1', testAuthToken) + .update(testSignedUrl) + .digest('base64'); + + mockWebhookFunctions.getCredentials.mockResolvedValue({ + authType: 'authToken', + authToken: testAuthToken, + }); + // getNodeWebhookUrl returns the production URL + mockWebhookFunctions.getNodeWebhookUrl.mockReturnValue(sinkUrl); + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockImplementation((name: string) => { + if (name === 'x-twilio-signature') return testModeSignature; + return null; + }), + query: { bodySHA256: testBodyHash }, + rawBody: Buffer.from(testBody), + // Request arrives at the test URL + originalUrl: `/webhook-test/abc/webhook?${queryString}`, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + }); + + it('should accept rawBody as a string', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + authType: 'authToken', + authToken: testAuthToken, + }); + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockReturnValue(validSignature), + query: { bodySHA256: testBodyHash }, + rawBody: testBody, + originalUrl: `/webhook/abc/webhook?${queryString}`, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + }); + }); +}); From 7fdc7788d563f37f65a2b9b5459bbe5f384d565c Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Thu, 7 May 2026 11:33:55 +0200 Subject: [PATCH 10/20] test(core): Cover JWE decryption on dynamic-credential OAuth2 callback (#29808) Co-authored-by: Claude Opus 4.7 --- .../oauth2-credential.controller.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index 897164cc591..52ea9ae567b 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -830,6 +830,71 @@ describe('OAuth2CredentialController', () => { ['csrfSecret'], ); }); + + it('routes the decrypted response to saveDynamicCredential when origin is dynamic-credential', async () => { + const dynamicState = { + token: 'token', + cid: '1', + userId: '123', + origin: 'dynamic-credential' as const, + credentialResolverId: 'resolver-1', + authorizationHeader: 'Bearer caller-token', + authMetadata: { tenant: 'acme' }, + createdAt: timestamp, + data: 'encrypted-data', + }; + + const mockGetToken = jest + .fn() + .mockResolvedValue({ data: { access_token: 'jwe-blob', refresh_token: 'r' } }); + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + jest + .mocked(ClientOAuth2) + .mockImplementation(() => ({ code: { getToken: mockGetToken } }) as any); + const mockResolvedCredential = mock({ id: '1' }); + oauthService.resolveCredential.mockResolvedValueOnce([ + mockResolvedCredential, + { csrfSecret: 'csrf-secret' }, + { + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://example.domain/oauth2/auth', + accessTokenUrl: 'https://example.domain/oauth2/token', + scope: 'openid', + grantType: 'authorizationCode', + authentication: 'header', + jweEnabled: true, + } as any, + dynamicState, + ]); + oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential'); + externalHooks.run.mockResolvedValue(undefined); + oauthJweServiceProxy.decryptOAuth2TokenData.mockResolvedValue({ + access_token: 'decrypted', + refresh_token: 'r', + }); + + const req = mock({ + query: { code: 'auth_code', state: validState }, + originalUrl: '/oauth2-credential/callback?code=auth_code&state=state', + }); + + await controller.handleCallback(req, res); + + expect(oauthJweServiceProxy.decryptOAuth2TokenData).toHaveBeenCalledWith( + expect.objectContaining({ access_token: 'jwe-blob' }), + ); + expect(oauthService.saveDynamicCredential).toHaveBeenCalledWith( + mockResolvedCredential, + expect.objectContaining({ + oauthTokenData: expect.objectContaining({ access_token: 'decrypted' }), + }), + 'caller-token', + 'resolver-1', + { tenant: 'acme' }, + ); + expect(oauthService.encryptAndSaveData).not.toHaveBeenCalled(); + }); }); it('should handle errors and render error page', async () => { From 2dbf02e63e5ddee8d9e4a94f2ad3cd1f5321f2a7 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Thu, 7 May 2026 11:38:13 +0200 Subject: [PATCH 11/20] fix(core): Harden axios error handling against non-string error stack (#29100) --- package.json | 1 + patches/axios@1.15.0.patch | 13 +++++++ pnpm-lock.yaml | 69 ++++++++++++++++++++------------------ 3 files changed, 50 insertions(+), 33 deletions(-) create mode 100644 patches/axios@1.15.0.patch diff --git a/package.json b/package.json index 07b67f91065..f144a0c7012 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,7 @@ "fast-xml-parser": "5.7.0" }, "patchedDependencies": { + "axios@1.15.0": "patches/axios@1.15.0.patch", "bull@4.16.4": "patches/bull@4.16.4.patch", "pdfjs-dist@5.3.31": "patches/pdfjs-dist@5.3.31.patch", "pkce-challenge@5.0.0": "patches/pkce-challenge@5.0.0.patch", diff --git a/patches/axios@1.15.0.patch b/patches/axios@1.15.0.patch new file mode 100644 index 00000000000..8441cb07c7c --- /dev/null +++ b/patches/axios@1.15.0.patch @@ -0,0 +1,13 @@ +diff --git a/lib/core/Axios.js b/lib/core/Axios.js +index 0000000..0000001 100644 +--- a/lib/core/Axios.js ++++ b/lib/core/Axios.js +@@ -47,7 +47,7 @@ class Axios { + + // slice off the Error: ... line + const stack = (() => { +- if (!dummy.stack) { ++ if (typeof dummy.stack !== 'string') { + return ''; + } + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d86ca8b9dd..db6f03f5ad6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,6 +476,9 @@ patchedDependencies: assert@2.1.0: hash: a73271b303dca8c10a0c9caf77d2083b52032b6a887227563bee3ce3dbf5e9d2 path: patches/assert@2.1.0.patch + axios@1.15.0: + hash: d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7 + path: patches/axios@1.15.0.patch bull@4.16.4: hash: a4b6d56db16fe5870646929938466d6a5c668435fd1551bed6a93fffb597ba42 path: patches/bull@4.16.4.patch @@ -750,7 +753,7 @@ importers: version: 3.0.1 axios: specifier: 1.15.0 - version: 1.15.0 + version: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) jest-mock-extended: specifier: ^3.0.4 version: 3.0.4(jest@29.7.0(@types/node@20.19.21)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@20.19.21)(typescript@6.0.2)))(typescript@6.0.2) @@ -985,7 +988,7 @@ importers: version: 4.0.7 axios: specifier: 1.15.0 - version: 1.15.0 + version: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) dotenv: specifier: 17.2.3 version: 17.2.3 @@ -1039,7 +1042,7 @@ importers: dependencies: axios: specifier: 1.15.0 - version: 1.15.0 + version: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) devDependencies: '@n8n/typescript-config': specifier: workspace:* @@ -2311,7 +2314,7 @@ importers: version: 8.35.0(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.2) axios: specifier: 1.15.0 - version: 1.15.0 + version: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) eslint: specifier: 'catalog:' version: 9.29.0(jiti@2.6.1) @@ -2708,7 +2711,7 @@ importers: version: 1.11.0 axios: specifier: 1.15.0 - version: 1.15.0 + version: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) bcryptjs: specifier: 2.4.3 version: 2.4.3 @@ -3093,7 +3096,7 @@ importers: version: 10.36.0 axios: specifier: 1.15.0 - version: 1.15.0 + version: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) callsites: specifier: 'catalog:' version: 3.1.0 @@ -3566,7 +3569,7 @@ importers: version: link:../../../@n8n/utils axios: specifier: 1.15.0 - version: 1.15.0 + version: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) flatted: specifier: 3.4.2 version: 3.4.2 @@ -3893,7 +3896,7 @@ importers: version: 1.1.4 axios: specifier: 1.15.0 - version: 1.15.0 + version: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) bowser: specifier: 2.11.0 version: 2.11.0 @@ -22614,7 +22617,7 @@ snapshots: '@1password/connect@1.4.2': dependencies: - axios: 1.15.0(debug@4.4.3) + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7)(debug@4.4.3) debug: 4.4.3(supports-color@8.1.1) lodash.clonedeep: 4.5.0 slugify: 1.6.6 @@ -26153,7 +26156,7 @@ snapshots: '@codspeed/core@5.2.0': dependencies: - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) find-up: 6.3.0 form-data: 4.0.4 node-gyp-build: 4.8.4 @@ -26216,8 +26219,8 @@ snapshots: '@commander-js/extra-typings': 12.1.0(commander@12.1.0) '@currents/commit-info': 1.0.1-beta.0 async-retry: 1.3.3 - axios: 1.15.0(debug@4.4.3) - axios-retry: 4.5.0(axios@1.15.0) + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7)(debug@4.4.3) + axios-retry: 4.5.0(axios@1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7)) c12: 1.11.2(magicast@0.3.5) chalk: 4.1.2 commander: 12.1.0 @@ -26267,13 +26270,13 @@ snapshots: '@daytonaio/api-client@0.143.0': dependencies: - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) transitivePeerDependencies: - debug '@daytonaio/api-client@0.149.0': dependencies: - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) transitivePeerDependencies: - debug @@ -26292,7 +26295,7 @@ snapshots: '@opentelemetry/sdk-node': 0.207.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) busboy: 1.6.0 dotenv: 17.2.3 expand-tilde: 2.0.2 @@ -26323,7 +26326,7 @@ snapshots: '@opentelemetry/sdk-node': 0.207.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) busboy: 1.6.0 dotenv: 17.2.3 expand-tilde: 2.0.2 @@ -26341,13 +26344,13 @@ snapshots: '@daytonaio/toolbox-api-client@0.143.0': dependencies: - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) transitivePeerDependencies: - debug '@daytonaio/toolbox-api-client@0.149.0': dependencies: - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) transitivePeerDependencies: - debug @@ -28178,7 +28181,7 @@ snapshots: '@azure/core-auth': 1.10.1 '@azure/msal-node': 3.8.4 '@microsoft/agents-activity': 1.2.3 - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) jsonwebtoken: 9.0.3 jwks-rsa: 3.2.2 object-path: 0.11.8 @@ -30151,8 +30154,8 @@ snapshots: '@rudderstack/rudder-sdk-node@3.0.5': dependencies: - axios: 1.15.0 - axios-retry: 4.5.0(axios@1.15.0) + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) + axios-retry: 4.5.0(axios@1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7)) component-type: 2.0.0 join-component: 1.1.0 lodash.clonedeep: 4.5.0 @@ -30455,7 +30458,7 @@ snapshots: '@slack/types': 2.20.1 '@types/node': 20.19.21 '@types/retry': 0.12.0 - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) eventemitter3: 5.0.1 form-data: 4.0.4 is-electron: 2.2.2 @@ -33563,12 +33566,12 @@ snapshots: axe-core@4.7.2: {} - axios-retry@4.5.0(axios@1.15.0): + axios-retry@4.5.0(axios@1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7)): dependencies: - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) is-retry-allowed: 2.2.0 - axios@1.15.0: + axios@1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7): dependencies: follow-redirects: 1.16.0(debug@4.4.1) form-data: 4.0.4 @@ -33576,7 +33579,7 @@ snapshots: transitivePeerDependencies: - debug - axios@1.15.0(debug@4.4.3): + axios@1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7)(debug@4.4.3): dependencies: follow-redirects: 1.16.0(debug@4.4.3) form-data: 4.0.4 @@ -37539,7 +37542,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 20.19.21 '@types/tough-cookie': 4.0.5 - axios: 1.15.0(debug@4.4.3) + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7)(debug@4.4.3) camelcase: 6.3.0 debug: 4.4.3(supports-color@8.1.1) dotenv: 16.6.1 @@ -37549,7 +37552,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.3 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.15.0) + retry-axios: 2.6.0(axios@1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7)) tough-cookie: 4.1.4 transitivePeerDependencies: - supports-color @@ -37653,7 +37656,7 @@ snapshots: infisical-node@1.3.0: dependencies: - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) dotenv: 16.6.1 tweetnacl: 1.0.3 tweetnacl-util: 0.15.1 @@ -41811,7 +41814,7 @@ snapshots: posthog-node@3.2.1: dependencies: - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) rusha: 0.8.14 transitivePeerDependencies: - debug @@ -42619,9 +42622,9 @@ snapshots: retimer@3.0.0: {} - retry-axios@2.6.0(axios@1.15.0): + retry-axios@2.6.0(axios@1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7)): dependencies: - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) retry-request@7.0.2(encoding@0.1.13): dependencies: @@ -43333,7 +43336,7 @@ snapshots: asn1.js: 5.4.1 asn1.js-rfc2560: 5.0.1(asn1.js@5.4.1) asn1.js-rfc5280: 3.0.0 - axios: 1.15.0 + axios: 1.15.0(patch_hash=d862e214ea9e6eb9abaef9414a626ca7ad9fc23d23e36b950f1eb14b6fdbded7) big-integer: 1.6.52 bignumber.js: 9.1.2 binascii: 0.0.2 From 8f1f42d18056ba51e450ba90ba3be65cbf9745aa Mon Sep 17 00:00:00 2001 From: Dawid Myslak Date: Thu, 7 May 2026 11:42:45 +0200 Subject: [PATCH 12/20] feat(Trello Trigger Node): Add webhook request verification (#29252) --- .../credentials/TrelloApi.credentials.ts | 4 +- .../nodes/Trello/TrelloTrigger.node.ts | 26 ++-- .../nodes/Trello/TrelloTriggerHelpers.ts | 38 ++++++ .../Trello/test/TrelloTrigger.node.test.ts | 76 ++++++++++++ .../Trello/test/TrelloTriggerHelpers.test.ts | 114 ++++++++++++++++++ 5 files changed, 241 insertions(+), 17 deletions(-) create mode 100644 packages/nodes-base/nodes/Trello/TrelloTriggerHelpers.ts create mode 100644 packages/nodes-base/nodes/Trello/test/TrelloTrigger.node.test.ts create mode 100644 packages/nodes-base/nodes/Trello/test/TrelloTriggerHelpers.test.ts diff --git a/packages/nodes-base/credentials/TrelloApi.credentials.ts b/packages/nodes-base/credentials/TrelloApi.credentials.ts index 1623eaa1ac3..d905d573185 100644 --- a/packages/nodes-base/credentials/TrelloApi.credentials.ts +++ b/packages/nodes-base/credentials/TrelloApi.credentials.ts @@ -33,9 +33,11 @@ export class TrelloApi implements ICredentialType { { displayName: 'OAuth Secret', name: 'oauthSecret', - type: 'hidden', + type: 'string', typeOptions: { password: true }, default: '', + description: + 'Used to verify webhook authenticity. Found under the API Key tab at trello.com/power-ups/admin.', }, ]; diff --git a/packages/nodes-base/nodes/Trello/TrelloTrigger.node.ts b/packages/nodes-base/nodes/Trello/TrelloTrigger.node.ts index 26fc9c9c7c8..6f34979afb3 100644 --- a/packages/nodes-base/nodes/Trello/TrelloTrigger.node.ts +++ b/packages/nodes-base/nodes/Trello/TrelloTrigger.node.ts @@ -8,8 +8,7 @@ import { } from 'n8n-workflow'; import { apiRequest } from './GenericFunctions'; - -// import { createHmac } from 'crypto'; +import { verifySignature } from './TrelloTriggerHelpers'; export class TrelloTrigger implements INodeType { description: INodeTypeDescription = { @@ -147,22 +146,17 @@ export class TrelloTrigger implements INodeType { }; } + const isSignatureValid = await verifySignature.call(this); + if (!isSignatureValid) { + const res = this.getResponseObject(); + res.status(401).send('Unauthorized').end(); + return { + noWebhookResponse: true, + }; + } + const bodyData = this.getBodyData(); - // TODO: Check why that does not work as expected even though it gets done as described - // https://developers.trello.com/page/webhooks - - //const credentials = await this.getCredentials('trelloApi'); - // // Check if the request is valid - // const headerData = this.getHeaderData() as IDataObject; - // const webhookUrl = this.getNodeWebhookUrl('default'); - // const checkContent = JSON.stringify(bodyData) + webhookUrl; - // const computedSignature = createHmac('sha1', credentials.oauthSecret as string).update(checkContent).digest('base64'); - // if (headerData['x-trello-webhook'] !== computedSignature) { - // // Signature is not valid so ignore call - // return {}; - // } - return { workflowData: [this.helpers.returnJsonArray(bodyData)], }; diff --git a/packages/nodes-base/nodes/Trello/TrelloTriggerHelpers.ts b/packages/nodes-base/nodes/Trello/TrelloTriggerHelpers.ts new file mode 100644 index 00000000000..b3fccaa37c4 --- /dev/null +++ b/packages/nodes-base/nodes/Trello/TrelloTriggerHelpers.ts @@ -0,0 +1,38 @@ +import { createHmac } from 'crypto'; +import type { IWebhookFunctions } from 'n8n-workflow'; + +import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification'; + +export async function verifySignature(this: IWebhookFunctions): Promise { + const credential = await this.getCredentials('trelloApi'); + const req = this.getRequestObject(); + const secret = credential.oauthSecret; + + return verifySignatureGeneric({ + getExpectedSignature: () => { + if (!secret || typeof secret !== 'string' || !req.rawBody) { + return null; + } + + const callbackURL = this.getNodeWebhookUrl('default'); + const rawBody = Buffer.isBuffer(req.rawBody) + ? req.rawBody.toString('utf-8') + : typeof req.rawBody === 'string' + ? req.rawBody + : null; + + if (!rawBody) { + return null; + } + + return createHmac('sha1', secret) + .update(rawBody + callbackURL) + .digest('base64'); + }, + skipIfNoExpectedSignature: !secret || typeof secret !== 'string', + getActualSignature: () => { + const sig = req.header('x-trello-webhook'); + return typeof sig === 'string' ? sig : null; + }, + }); +} diff --git a/packages/nodes-base/nodes/Trello/test/TrelloTrigger.node.test.ts b/packages/nodes-base/nodes/Trello/test/TrelloTrigger.node.test.ts new file mode 100644 index 00000000000..1aac008c84e --- /dev/null +++ b/packages/nodes-base/nodes/Trello/test/TrelloTrigger.node.test.ts @@ -0,0 +1,76 @@ +import { TrelloTrigger } from '../TrelloTrigger.node'; + +jest.mock('../TrelloTriggerHelpers', () => ({ + verifySignature: jest.fn(), +})); + +import { verifySignature } from '../TrelloTriggerHelpers'; + +const mockedVerifySignature = jest.mocked(verifySignature); + +describe('TrelloTrigger', () => { + let trelloTrigger: TrelloTrigger; + let mockWebhookFunctions: any; + let mockRes: any; + + beforeEach(() => { + jest.clearAllMocks(); + trelloTrigger = new TrelloTrigger(); + + mockRes = { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + }; + + mockWebhookFunctions = { + getWebhookName: jest.fn().mockReturnValue('default'), + getBodyData: jest.fn().mockReturnValue({ action: { type: 'createCard' } }), + getResponseObject: jest.fn().mockReturnValue(mockRes), + helpers: { + returnJsonArray: jest.fn().mockImplementation((data) => [{ json: data }]), + }, + }; + }); + + describe('webhook', () => { + it('should respond 200 to setup HEAD request', async () => { + mockWebhookFunctions.getWebhookName.mockReturnValue('setup'); + + const result = await trelloTrigger.webhook.call(mockWebhookFunctions); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.end).toHaveBeenCalled(); + expect(result).toEqual({ noWebhookResponse: true }); + }); + + it('should return workflow data when signature is valid', async () => { + mockedVerifySignature.mockResolvedValue(true); + + const result = await trelloTrigger.webhook.call(mockWebhookFunctions); + + expect(result).toEqual({ + workflowData: [[{ json: { action: { type: 'createCard' } } }]], + }); + }); + + it('should return 401 when signature is invalid', async () => { + mockedVerifySignature.mockResolvedValue(false); + + const result = await trelloTrigger.webhook.call(mockWebhookFunctions); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.send).toHaveBeenCalledWith('Unauthorized'); + expect(mockRes.end).toHaveBeenCalled(); + expect(result).toEqual({ noWebhookResponse: true }); + }); + + it('should trigger workflow when no secret is configured', async () => { + mockedVerifySignature.mockResolvedValue(true); + + const result = await trelloTrigger.webhook.call(mockWebhookFunctions); + + expect(result).toHaveProperty('workflowData'); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Trello/test/TrelloTriggerHelpers.test.ts b/packages/nodes-base/nodes/Trello/test/TrelloTriggerHelpers.test.ts new file mode 100644 index 00000000000..ec6a814276d --- /dev/null +++ b/packages/nodes-base/nodes/Trello/test/TrelloTriggerHelpers.test.ts @@ -0,0 +1,114 @@ +import { createHmac } from 'crypto'; + +import { verifySignature } from '../TrelloTriggerHelpers'; + +describe('TrelloTriggerHelpers', () => { + let mockWebhookFunctions: any; + const testSecret = 'test-trello-oauth-secret'; + const testBody = '{"action":{"type":"createCard"},"model":{"id":"abc123"}}'; + const testCallbackUrl = 'https://n8n.example.com/webhook/trello'; + const testSignature = createHmac('sha1', testSecret) + .update(testBody + testCallbackUrl) + .digest('base64'); + + beforeEach(() => { + jest.clearAllMocks(); + + mockWebhookFunctions = { + getCredentials: jest.fn().mockResolvedValue({ + oauthSecret: testSecret, + }), + getRequestObject: jest.fn().mockReturnValue({ + header: jest.fn().mockImplementation((header: string) => { + if (header === 'x-trello-webhook') return testSignature; + return null; + }), + rawBody: testBody, + }), + getNodeWebhookUrl: jest.fn().mockReturnValue(testCallbackUrl), + getNode: jest.fn().mockReturnValue({ name: 'Trello Trigger' }), + }; + }); + + describe('verifySignature', () => { + it('should return true when no OAuth secret is configured', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + apiKey: 'test-key', + apiToken: 'test-token', + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + }); + + it('should return true when OAuth secret is empty string', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + oauthSecret: '', + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + }); + + it('should return true when signature is valid', async () => { + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + }); + + it('should return false when signature is invalid', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockImplementation((header: string) => { + if (header === 'x-trello-webhook') return 'invalidsignature'; + return null; + }), + rawBody: testBody, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(false); + }); + + it('should return false when signature header is missing', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockReturnValue(null), + rawBody: testBody, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(false); + }); + + it('should handle rawBody as Buffer', async () => { + const bodyBuffer = Buffer.from(testBody); + const bufferSignature = createHmac('sha1', testSecret) + .update(testBody + testCallbackUrl) + .digest('base64'); + + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockImplementation((header: string) => { + if (header === 'x-trello-webhook') return bufferSignature; + return null; + }), + rawBody: bodyBuffer, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + }); + + it('should include callback URL in signature computation', async () => { + const differentCallbackUrl = 'https://different.example.com/webhook'; + mockWebhookFunctions.getNodeWebhookUrl.mockReturnValue(differentCallbackUrl); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(false); + }); + }); +}); From ebafde7f85f8ccd0dc045e44cea4e74f0292e838 Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Thu, 7 May 2026 10:48:47 +0100 Subject: [PATCH 13/20] feat(core): Show workflow-triggered runs in agent session history (no-changelog) (#29932) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/@n8n/api-types/src/agents.ts | 7 + .../cli/src/modules/agents/agents.service.ts | 170 ++++++++++++------ .../src/workflow-execute-additional-data.ts | 6 +- .../base-execute-context.ts | 2 +- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../useMessageAgentSessionLink.spec.ts | 140 +++++++++++++++ .../composables/useMessageAgentSessionLink.ts | 96 ++++++++++ .../logs/components/LogDetailsPanel.test.ts | 90 +++++++++- .../logs/components/LogDetailsPanel.vue | 18 +- .../MessageAnAgent/MessageAnAgent.node.ts | 34 +++- .../__tests__/MessageAnAgent.node.test.ts | 115 +++++++++--- packages/workflow/src/interfaces.ts | 18 ++ 12 files changed, 609 insertions(+), 88 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/useMessageAgentSessionLink.spec.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useMessageAgentSessionLink.ts diff --git a/packages/@n8n/api-types/src/agents.ts b/packages/@n8n/api-types/src/agents.ts index 48ab91034d5..ea2dfa0b4c9 100644 --- a/packages/@n8n/api-types/src/agents.ts +++ b/packages/@n8n/api-types/src/agents.ts @@ -48,6 +48,13 @@ export const INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES = [ export const AGENT_SCHEDULE_TRIGGER_TYPE = 'schedule'; +/** + * Source string recorded on agent executions invoked from a workflow via the + * MessageAnAgent node. Mirrors the pattern set by chat/slack/schedule sources + * so the session detail view can attribute thread origin uniformly. + */ +export const AGENT_WORKFLOW_TRIGGER_TYPE = 'workflow'; + export const DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT = 'Automated message: you were triggered on schedule.'; diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index d66a58ed65c..0df44908762 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -2,12 +2,12 @@ import type { BuiltAgent, BuiltTool, CredentialProvider, - GenerateResult, StreamChunk, ToolDescriptor, } from '@n8n/agents'; import { AGENT_SCHEDULE_TRIGGER_TYPE, + AGENT_WORKFLOW_TRIGGER_TYPE, isAgentCredentialIntegration, isAgentScheduleIntegration, type AgentSkill, @@ -336,6 +336,28 @@ export class AgentsService { }); } + /** + * Same scoping as {@link findByUser}, but only returns agents that have a + * `publishedVersion`. Used by the MessageAnAgent node's listSearch so the + * dropdown can't surface unpublished agents — `executeForWorkflow` rejects + * those at runtime, and showing them would just lead to a confusing + * "Agent is not published" error after the user picks one. + */ + async findPublishedByUser(userId: string): Promise { + const projectRelations = await this.projectRelationRepository.findAllByUser(userId); + const projectIds = projectRelations.map((pr) => pr.projectId); + + if (projectIds.length === 0) return []; + + const agents = await this.agentRepository.find({ + where: { projectId: In(projectIds) }, + relations: { publishedVersion: true }, + order: { updatedAt: 'DESC' }, + }); + + return agents.filter((agent) => agent.publishedVersion); + } + async publishAgent(agentId: string, projectId: string, userId: string): Promise { const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); if (!agent) { @@ -1039,8 +1061,15 @@ export class AgentsService { /** * Execute an SDK agent within a workflow execution context. - * Compiles a fresh isolated agent per call for credential isolation - * (does not use or affect the shared runtime cache). + * + * Streams the run rather than calling `.generate()` so the same + * `ExecutionRecorder` used by chat/Slack/schedule paths can collect a full + * `MessageRecord` (timeline, tool calls, usage). Without this, sessions + * triggered from a workflow node never appear in the agent's session list + * because nothing creates the agent execution thread row. + * + * Compiles a fresh isolated agent per call for credential isolation (does + * not use or affect the shared runtime cache). */ async executeForWorkflow( agentId: string, @@ -1071,77 +1100,104 @@ export class AgentsService { throw new OperationalError(`Failed to compile agent: ${compiled.error ?? 'unknown error'}`); } - const result = await compiled.agent.generate(message, { - persistence: { - resourceId: executionId, - threadId, - }, + const agentInstance = compiled.agent; + const recorder = new ExecutionRecorder(); + + // `structuredOutput` and `toolCalls` aren't surfaced by the recorder — + // pull them off the `finish` chunk and the discrete `tool-result` chunks + // directly so the workflow node receives the same shape as before. + let structuredOutput: unknown | null = null; + const toolCalls: ExecuteAgentData['toolCalls'] = []; + const toolInputs = new Map(); + + const resultStream = await agentInstance.stream(message, { + persistence: { resourceId: executionId, threadId }, }); - // Check for errors - if (result.error) { - const errorMessage = - result.error instanceof Error - ? result.error.message - : typeof result.error === 'string' - ? result.error - : JSON.stringify(result.error); - throw new OperationalError(`Agent execution failed: ${errorMessage}`); + const reader = resultStream.stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + recorder.record(value); + + if (value.type === 'tool-call') { + toolInputs.set(value.toolCallId, { toolName: value.toolName, input: value.input }); + } else if (value.type === 'tool-result') { + const pending = toolInputs.get(value.toolCallId); + toolCalls.push({ + toolName: value.toolName, + input: pending?.input ?? null, + result: value.output, + }); + toolInputs.delete(value.toolCallId); + } else if (value.type === 'finish' && value.structuredOutput !== undefined) { + structuredOutput = value.structuredOutput; + } + } + } finally { + reader.releaseLock(); } - if (result.finishReason === 'error') { - throw new OperationalError('Agent execution finished with an error.'); - } + const messageRecord = recorder.getMessageRecord(); - if (result.pendingSuspend && result.pendingSuspend.length > 0) { - const toolNames = result.pendingSuspend - .map((s: { toolName: string }) => s.toolName) - .join(', '); + // Persist the thread + execution row + metadata so the session is + // listed under the agent (mirrors chat/slack/schedule recording). + // Fire-and-forget with .catch so a recording failure doesn't fail the + // workflow node — the response is already in hand. + void this.agentExecutionService + .recordMessage({ + threadId, + agentId, + agentName: agentInstance.name, + projectId, + userMessage: message, + record: messageRecord, + source: AGENT_WORKFLOW_TRIGGER_TYPE, + }) + .catch((error) => { + this.logger.warn('Failed to record agent execution from workflow', { + agentId, + threadId, + error: error instanceof Error ? error.message : String(error), + }); + }); + + if (recorder.suspended) { throw new OperationalError( - `Agent execution suspended waiting for tool approval: ${toolNames}. ` + + 'Agent execution suspended waiting for tool approval. ' + 'Suspend/resume is not supported in workflow execution context.', ); } + if (messageRecord.error) { + throw new OperationalError(`Agent execution failed: ${messageRecord.error}`); + } + + if (messageRecord.finishReason === 'error') { + throw new OperationalError('Agent execution finished with an error.'); + } + return { - response: this.extractTextResponse(result), - structuredOutput: result.structuredOutput ?? null, - usage: result.usage + response: messageRecord.assistantResponse, + structuredOutput: structuredOutput ?? null, + usage: messageRecord.usage ? { - promptTokens: result.usage.promptTokens, - completionTokens: result.usage.completionTokens, - totalTokens: result.usage.totalTokens, + promptTokens: messageRecord.usage.promptTokens, + completionTokens: messageRecord.usage.completionTokens, + totalTokens: messageRecord.usage.totalTokens, } : null, - toolCalls: (result.toolCalls ?? []).map( - (tc: { tool: string; input: unknown; output: unknown }) => ({ - toolName: tc.tool, - input: tc.input, - result: tc.output, - }), - ), - finishReason: result.finishReason ?? 'stop', + toolCalls, + finishReason: messageRecord.finishReason, + session: { + agentId, + projectId, + sessionId: threadId, + }, }; } - /** - * Extract the text response from the last assistant message in a GenerateResult. - */ - private extractTextResponse(result: GenerateResult): string { - for (let i = result.messages.length - 1; i >= 0; i--) { - const msg = result.messages[i]; - if (msg.type !== 'custom' && msg.role === 'assistant' && Array.isArray(msg.content)) { - const textParts = (msg.content as Array<{ type: string; text?: string }>) - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text); - if (textParts.length > 0) { - return textParts.join(''); - } - } - } - return ''; - } - /** * Get the JSON config for an agent. */ diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 9cd3bc2a566..1a5992a1316 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -295,7 +295,11 @@ export async function executeAgent( async function listAgents(userId: string): Promise> { const { AgentsService } = await import('@/modules/agents/agents.service'); const agentsService = Container.get(AgentsService); - const agents = await agentsService.findByUser(userId); + // Only published agents are runnable from a workflow — see the publish + // guard in `executeForWorkflow`. Filtering here keeps unpublished agents + // out of the MessageAnAgent dropdown so users don't pick one that would + // fail at execution time. + const agents = await agentsService.findPublishedByUser(userId); return agents.map((agent) => ({ id: agent.id, name: agent.name })); } diff --git a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts index 7030ac3eefd..795f88fe4de 100644 --- a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts @@ -166,7 +166,7 @@ export class BaseExecuteContext extends NodeExecutionContext { throw new OperationalError('Agent execution is not available in this context'); } - const threadId = `${executionId}-${itemIndex}`; + const threadId = agentInfo.sessionId?.trim() || `${executionId}-${itemIndex}`; return await this.additionalData.executeAgent( agentInfo.agentId, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 13d36e95482..6feacf8a891 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1665,6 +1665,7 @@ "logs.overview.body.toggleRow": "Toggle row", "logs.details.header.actions.input": "Input", "logs.details.header.actions.output": "Output", + "logs.details.header.actions.viewAgentSession": "View session", "logs.details.body.itemCount": "{count} item | {count} items", "logs.details.body.multipleInputs": "Multiple inputs. View them by {button}", "logs.details.body.multipleInputs.openingTheNode": "opening the node", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useMessageAgentSessionLink.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useMessageAgentSessionLink.spec.ts new file mode 100644 index 00000000000..0a9a68a0261 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useMessageAgentSessionLink.spec.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { computed, defineComponent, h } from 'vue'; +import { mount } from '@vue/test-utils'; +import { createRouter, createWebHistory } from 'vue-router'; +import type { ITaskData } from 'n8n-workflow'; + +import { MESSAGE_AN_AGENT_NODE_TYPE } from '@/app/constants/nodeTypes'; +import { AGENT_SESSION_DETAIL_VIEW } from '@/features/agents/constants'; +import type { LogEntry } from '@/features/execution/logs/logs.types'; + +import { useMessageAgentSessionLink } from '../composables/useMessageAgentSessionLink'; + +function makeLogEntry(overrides: Partial = {}): LogEntry { + // Only the fields the composable reads matter; the rest is cast through to + // keep this fixture small and avoid pulling in a real Workflow factory. + const base = { + id: 'log-1', + runIndex: 0, + children: [], + consumedTokens: { + completionTokens: 0, + isEstimate: false, + promptTokens: 0, + totalTokens: 0, + }, + executionId: 'exec-1', + execution: { resultData: { runData: {} } }, + isSubExecution: false, + node: { + id: 'node-1', + name: 'Message an Agent', + type: MESSAGE_AN_AGENT_NODE_TYPE, + typeVersion: 1, + parameters: {}, + position: [0, 0], + }, + runData: undefined, + workflow: {}, + }; + return { ...base, ...overrides } as unknown as LogEntry; +} + +function runWithRouter( + logEntry: { value: LogEntry | undefined }, + registerSessionRoute: boolean, +): { link: () => ReturnType['link']['value'] } { + const router = createRouter({ + history: createWebHistory(), + routes: registerSessionRoute + ? [ + { + name: AGENT_SESSION_DETAIL_VIEW, + path: '/projects/:projectId/agents/:agentId/sessions/:threadId', + component: () => h('div'), + }, + ] + : [{ path: '/', component: () => h('div') }], + }); + + let captured: ReturnType['link'] | null = null; + + const Harness = defineComponent({ + setup() { + captured = useMessageAgentSessionLink(computed(() => logEntry.value)).link; + return () => h('div'); + }, + }); + + mount(Harness, { global: { plugins: [router] } }); + return { link: () => captured!.value }; +} + +const sessionRunData = { + executionStatus: 'success', + startTime: 0, + executionTime: 1, + source: [], + data: { + main: [ + [ + { + json: { + response: 'hi', + session: { + agentId: 'agent-1', + projectId: 'project-1', + sessionId: 'thread-1', + }, + }, + }, + ], + ], + }, +} as unknown as ITaskData; + +describe('useMessageAgentSessionLink', () => { + it('returns a resolved href + open() for a messageAnAgent run with a session block', () => { + const logEntry = { value: makeLogEntry({ runData: sessionRunData }) }; + const { link } = runWithRouter(logEntry, true); + + const value = link(); + expect(value).not.toBeNull(); + expect(value!.href).toBe('/projects/project-1/agents/agent-1/sessions/thread-1'); + expect(typeof value!.open).toBe('function'); + }); + + it('returns null when the node-type is not messageAnAgent', () => { + const logEntry = { + value: makeLogEntry({ + node: { + id: 'n', + name: 'Other', + type: 'n8n-nodes-base.set', + typeVersion: 1, + parameters: {}, + position: [0, 0], + } as LogEntry['node'], + runData: sessionRunData, + }), + }; + const { link } = runWithRouter(logEntry, true); + expect(link()).toBeNull(); + }); + + it('returns null when run output has no session block', () => { + const noSession = { + ...sessionRunData, + data: { main: [[{ json: { response: 'hi' } }]] }, + } as unknown as ITaskData; + const logEntry = { value: makeLogEntry({ runData: noSession }) }; + const { link } = runWithRouter(logEntry, true); + expect(link()).toBeNull(); + }); + + it('returns null when the session route is not registered (graceful fallback)', () => { + const logEntry = { value: makeLogEntry({ runData: sessionRunData }) }; + const { link } = runWithRouter(logEntry, false); + expect(link()).toBeNull(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useMessageAgentSessionLink.ts b/packages/frontend/editor-ui/src/features/agents/composables/useMessageAgentSessionLink.ts new file mode 100644 index 00000000000..273433f795f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/composables/useMessageAgentSessionLink.ts @@ -0,0 +1,96 @@ +import { computed, type ComputedRef } from 'vue'; +import { useRouter } from 'vue-router'; + +import { MESSAGE_AN_AGENT_NODE_TYPE } from '@/app/constants/nodeTypes'; +import { AGENT_SESSION_DETAIL_VIEW } from '@/features/agents/constants'; +import type { LogEntry } from '@/features/execution/logs/logs.types'; + +/** + * Session identifiers the MessageAnAgent node emits in its output JSON. Kept + * structural so we don't have to import the runtime type from `n8n-workflow` + * just to read three string fields. + */ +type MessageAgentSession = { + agentId: string; + projectId: string; + sessionId: string; +}; + +function isMessageAgentSession(value: unknown): value is MessageAgentSession { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + typeof v.agentId === 'string' && + typeof v.projectId === 'string' && + typeof v.sessionId === 'string' && + v.agentId !== '' && + v.projectId !== '' && + v.sessionId !== '' + ); +} + +function extractSession(logEntry: LogEntry | undefined): MessageAgentSession | null { + if (!logEntry) return null; + if (logEntry.node.type !== MESSAGE_AN_AGENT_NODE_TYPE) return null; + + const main = logEntry.runData?.data?.main; + if (!Array.isArray(main)) return null; + + for (const branch of main) { + if (!Array.isArray(branch)) continue; + for (const item of branch) { + const session = (item?.json as Record | undefined)?.session; + if (isMessageAgentSession(session)) return session; + } + } + + return null; +} + +/** + * Given a log entry, expose a resolved session URL + opener for MessageAnAgent + * runs that emitted a `session` block in their output JSON. Returns `null` for + * any other node-type or runs missing the expected payload, so the caller can + * `v-if` straight off `link`. + * + * Opens in a new tab (matching n8n's other deep links from execution log) so + * the workflow execution view stays in place — and so the link still works + * when the logs panel is popped out into its own window. + */ +export function useMessageAgentSessionLink(logEntry: ComputedRef): { + link: ComputedRef<{ href: string; open: () => void } | null>; +} { + const router = useRouter(); + + const link = computed(() => { + const session = extractSession(logEntry.value); + if (!session) return null; + + // Guard against the agents module not being mounted (or any router that + // doesn't know the route, e.g. in unit tests). `router.resolve` throws + // for unknown named routes — without this, the button would crash the + // log panel render in environments where agents aren't loaded. + let href: string; + try { + href = router.resolve({ + name: AGENT_SESSION_DETAIL_VIEW, + params: { + projectId: session.projectId, + agentId: session.agentId, + threadId: session.sessionId, + }, + }).href; + } catch { + return null; + } + + return { + href, + open: () => { + window.open(href, '_blank', 'noopener'); + }, + }; + }); + + return { link }; +} diff --git a/packages/frontend/editor-ui/src/features/execution/logs/components/LogDetailsPanel.test.ts b/packages/frontend/editor-ui/src/features/execution/logs/components/LogDetailsPanel.test.ts index 588c1f9efe7..aec308cc531 100644 --- a/packages/frontend/editor-ui/src/features/execution/logs/components/LogDetailsPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/execution/logs/components/LogDetailsPanel.test.ts @@ -18,6 +18,8 @@ import type { LogEntry } from '../logs.types'; import { createTestLogEntry } from '../__test__/mocks'; import { createRunExecutionData, NodeConnectionTypes } from 'n8n-workflow'; import { HTML_NODE_TYPE } from '@/app/constants'; +import { MESSAGE_AN_AGENT_NODE_TYPE } from '@/app/constants/nodeTypes'; +import { AGENT_SESSION_DETAIL_VIEW } from '@/features/agents/constants'; import { WorkflowIdKey } from '@/app/constants/injectionKeys'; describe('LogDetailsPanel', () => { @@ -65,7 +67,14 @@ describe('LogDetailsPanel', () => { plugins: [ createRouter({ history: createWebHistory(), - routes: [{ path: '/', component: () => h('div') }], + routes: [ + { path: '/', component: () => h('div') }, + { + name: AGENT_SESSION_DETAIL_VIEW, + path: '/projects/:projectId/agents/:agentId/sessions/:threadId', + component: () => h('div'), + }, + ], }), pinia, ], @@ -198,6 +207,85 @@ describe('LogDetailsPanel', () => { ).toBeInTheDocument(); }); + describe('messageAnAgent View Session button', () => { + const messageAgentNode = createTestNode({ + name: 'Message an Agent', + type: MESSAGE_AN_AGENT_NODE_TYPE, + }); + const messageAgentRunData = createTestTaskData({ + executionStatus: 'success', + data: { + main: [ + [ + { + json: { + response: 'hi', + session: { + agentId: 'agent-1', + projectId: 'project-1', + sessionId: 'thread-1', + }, + }, + }, + ], + ], + }, + }); + + const baseProps = { + isOpen: true, + panels: LOG_DETAILS_PANEL_STATE.BOTH, + collapsingInputTableColumnName: null, + collapsingOutputTableColumnName: null, + isHeaderClickable: true, + }; + + it('renders a View Session button when run output carries a session block', () => { + const rendered = render({ + ...baseProps, + logEntry: createLogEntry({ + node: messageAgentNode, + runIndex: 0, + runData: messageAgentRunData, + execution: createRunExecutionData({ + resultData: { runData: { 'Message an Agent': [messageAgentRunData] } }, + }), + }), + }); + + expect(rendered.queryByTestId('log-details-view-agent-session')).toBeInTheDocument(); + }); + + it('does not render the button for nodes that are not messageAnAgent', () => { + const rendered = render({ + ...baseProps, + logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }), + }); + + expect(rendered.queryByTestId('log-details-view-agent-session')).not.toBeInTheDocument(); + }); + + it('does not render the button when the session block is missing', () => { + const noSessionRunData = createTestTaskData({ + executionStatus: 'success', + data: { main: [[{ json: { response: 'hi' } }]] }, + }); + const rendered = render({ + ...baseProps, + logEntry: createLogEntry({ + node: messageAgentNode, + runIndex: 0, + runData: noSessionRunData, + execution: createRunExecutionData({ + resultData: { runData: { 'Message an Agent': [noSessionRunData] } }, + }), + }), + }); + + expect(rendered.queryByTestId('log-details-view-agent-session')).not.toBeInTheDocument(); + }); + }); + it('should render output data in HTML mode for HTML node', async () => { const nodeA = createTestNode({ name: 'A' }); const nodeB = createTestNode({ diff --git a/packages/frontend/editor-ui/src/features/execution/logs/components/LogDetailsPanel.vue b/packages/frontend/editor-ui/src/features/execution/logs/components/LogDetailsPanel.vue index ade51b66a39..25762565fa0 100644 --- a/packages/frontend/editor-ui/src/features/execution/logs/components/LogDetailsPanel.vue +++ b/packages/frontend/editor-ui/src/features/execution/logs/components/LogDetailsPanel.vue @@ -26,7 +26,8 @@ import { useExecutionRedaction } from '@/features/execution/executions/composabl import { useUIStore } from '@/app/stores/ui.store'; import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/app/constants/modals'; import RedactedDataState from '@/features/ndv/panel/components/RedactedDataState.vue'; -import { N8nButton, N8nResizeWrapper, N8nText } from '@n8n/design-system'; +import { N8nButton, N8nIcon, N8nResizeWrapper, N8nText } from '@n8n/design-system'; +import { useMessageAgentSessionLink } from '@/features/agents/composables/useMessageAgentSessionLink'; const MIN_IO_PANEL_WIDTH = 200; const { @@ -69,6 +70,7 @@ const { isRedacted, canReveal, isDynamicCredentials, revealData } = useExecution const type = computed(() => nodeTypeStore.getNodeType(logEntry.node.type)); const consumedTokens = computed(() => getSubtreeTotalConsumedTokens(logEntry, false)); const isTriggerNode = computed(() => type.value?.group.includes('trigger')); +const { link: messageAgentSessionLink } = useMessageAgentSessionLink(computed(() => logEntry)); const container = useTemplateRef('container'); const resizer = useResizablePanel('N8N_LOGS_INPUT_PANEL_WIDTH', { container, @@ -127,6 +129,16 @@ function handleResizeEnd() {