diff --git a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts index fd0486e730a..98e974ee049 100644 --- a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts +++ b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts @@ -1,6 +1,6 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; -import type { WorkflowEntity } from '@n8n/db'; +import type { WorkflowEntity, User, Project } from '@n8n/db'; import { ExecutionRepository, WorkflowPublishHistoryRepository, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; @@ -26,11 +26,14 @@ import { SubworkflowPolicyChecker, } from '@/executions/pre-execution-checks'; import { ExternalHooks } from '@/external-hooks'; +import { AgentsService } from '@/modules/agents/agents.service'; import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service'; +import { OwnershipService } from '@/services/ownership.service'; import { UrlService } from '@/services/url.service'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { Telemetry } from '@/telemetry'; import { + executeAgent, executeWorkflow, getBase, getRunData, @@ -759,4 +762,99 @@ describe('WorkflowExecuteAdditionalData', () => { expect(additionalData.workflowSettings).toBe(workflowSettings); }); }); + + describe('executeAgent', () => { + const ownershipService = mockInstance(OwnershipService); + const agentsService = mockInstance(AgentsService); + + const AGENT_ID = 'agent-id'; + const MESSAGE = 'hello'; + const EXEC_ID = 'exec-id'; + const THREAD_ID = 'thread-id'; + + beforeEach(() => { + jest.clearAllMocks(); + agentsService.executeForWorkflow.mockResolvedValue( + mock>>(), + ); + }); + + it('uses userId and projectId from additionalData when both are present', async () => { + const additionalData = mock({ + userId: 'user-1', + projectId: 'project-1', + workflowId: 'workflow-1', + }); + + await executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData); + + expect(ownershipService.getWorkflowProjectCached).not.toHaveBeenCalled(); + expect(ownershipService.getPersonalProjectOwnerCached).not.toHaveBeenCalled(); + expect(agentsService.executeForWorkflow).toHaveBeenCalledWith( + AGENT_ID, + MESSAGE, + EXEC_ID, + THREAD_ID, + 'user-1', + 'project-1', + ); + }); + + it('backfills userId and projectId from the workflow owner when both are missing', async () => { + const additionalData = mock({ + userId: undefined, + projectId: undefined, + workflowId: 'workflow-1', + }); + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce( + mock({ id: 'project-1' }), + ); + ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce( + mock({ id: 'owner-1' }), + ); + + await executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData); + + expect(ownershipService.getWorkflowProjectCached).toHaveBeenCalledWith('workflow-1'); + expect(ownershipService.getPersonalProjectOwnerCached).toHaveBeenCalledWith('project-1'); + expect(agentsService.executeForWorkflow).toHaveBeenCalledWith( + AGENT_ID, + MESSAGE, + EXEC_ID, + THREAD_ID, + 'owner-1', + 'project-1', + ); + }); + + it('throws when userId is missing and the workflow has no personal-project owner', async () => { + const additionalData = mock({ + userId: undefined, + projectId: undefined, + workflowId: 'workflow-1', + }); + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce( + mock({ id: 'project-1' }), + ); + ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(null); + + await expect( + executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData), + ).rejects.toThrow('Cannot execute agent without a userId in additional data'); + expect(agentsService.executeForWorkflow).not.toHaveBeenCalled(); + }); + + it('throws when userId is missing and no workflowId is available to resolve ownership', async () => { + const additionalData = mock({ + userId: undefined, + projectId: undefined, + workflowId: undefined, + }); + + await expect( + executeAgent(AGENT_ID, MESSAGE, EXEC_ID, THREAD_ID, additionalData), + ).rejects.toThrow('Cannot execute agent without a userId in additional data'); + expect(ownershipService.getWorkflowProjectCached).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 1a5992a1316..1da2053a0f6 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -261,22 +261,32 @@ export async function executeAgent( threadId: string, additionalData: IWorkflowExecuteAdditionalData, ): Promise { - if (!additionalData.userId) { - throw new UnexpectedError('Cannot execute agent without a userId in additional data'); + let userId = additionalData.userId; + let projectId = additionalData.projectId; + + // Trigger-fired and webhook executions build `additionalData` without a + // `userId` (see `getBase` callers in `active-workflow-manager`, + // `webhooks/*`, `scaling/job-processor`). Resolve the workflow's owning + // project to derive both `userId` and `projectId` so the agent runs under + // the workflow owner's identity, mirroring the projectId backfill below. + if ((!userId || !projectId) && additionalData.workflowId) { + const { OwnershipService } = await import('@/services/ownership.service'); + const ownershipService = Container.get(OwnershipService); + const project = await ownershipService.getWorkflowProjectCached(additionalData.workflowId); + projectId = projectId ?? project.id; + if (!userId) { + const owner = await ownershipService.getPersonalProjectOwnerCached(project.id); + userId = owner?.id; + } } - let projectId = additionalData.projectId; + if (!userId) { + throw new UnexpectedError('Cannot execute agent without a userId in additional data'); + } if (!projectId) { - if (!additionalData.workflowId) { - throw new UnexpectedError( - 'Cannot execute agent without a projectId or workflowId in additional data', - ); - } - const { OwnershipService } = await import('@/services/ownership.service'); - const project = await Container.get(OwnershipService).getWorkflowProjectCached( - additionalData.workflowId, + throw new UnexpectedError( + 'Cannot execute agent without a projectId or workflowId in additional data', ); - projectId = project.id; } const { AgentsService } = await import('@/modules/agents/agents.service'); @@ -287,7 +297,7 @@ export async function executeAgent( message, executionId, threadId, - additionalData.userId, + userId, projectId, ); }