feat(core): Scope agent sessions to projects (#36646)

This commit is contained in:
Michael Drury
2026-08-20 11:23:12 +00:00
committed by GitHub
parent 5e568b2b7c
commit 9d495dbc56
6 changed files with 152 additions and 29 deletions
@@ -1340,6 +1340,7 @@ describe('WorkflowExecuteAdditionalData', () => {
const MESSAGE = 'hello';
const EXEC_ID = 'exec-id';
const THREAD_ID = 'thread-id';
const PROJECT_THREAD_ID = `workflow:project-project-1:${THREAD_ID}`;
const executionSandboxScope = {
principalHash: hashAgentSandboxPrincipal({
type: 'workflow-execution',
@@ -1347,13 +1348,21 @@ describe('WorkflowExecuteAdditionalData', () => {
executionId: EXEC_ID,
}),
};
const sessionSandboxScope = {
const projectSessionSandboxScope = {
principalHash: hashAgentSandboxPrincipal({
type: 'workflow-session',
workflowId: 'workflow-1',
type: 'project-session',
projectId: 'project-1',
sessionId: THREAD_ID,
}),
};
const callerWorkflowContext = (workflowId: string): ExecuteAgentWorkflowContext => ({
workflowId,
workflowName: 'My workflow',
callingNodeName: 'Message an Agent',
hasCallerSessionId: true,
nodes: [{ name: 'Webhook', type: 'n8n-nodes-base.webhook' }],
runExecutionData: { resultData: { runData: {} } } as unknown as IRunExecutionData,
});
beforeEach(() => {
vi.clearAllMocks();
@@ -1382,7 +1391,7 @@ describe('WorkflowExecuteAdditionalData', () => {
inlineAgent,
MESSAGE,
EXEC_ID,
`wf:workflow-1:${THREAD_ID}`,
PROJECT_THREAD_ID,
'project-1',
'user-1',
'production',
@@ -1407,7 +1416,7 @@ describe('WorkflowExecuteAdditionalData', () => {
inlineAgent,
MESSAGE,
EXEC_ID,
`wf:workflow-1:${THREAD_ID}`,
PROJECT_THREAD_ID,
'project-1',
'user-1',
'test',
@@ -1437,7 +1446,7 @@ describe('WorkflowExecuteAdditionalData', () => {
AGENT_ID,
MESSAGE,
EXEC_ID,
`wf:workflow-1:${THREAD_ID}`,
PROJECT_THREAD_ID,
'project-1',
'user-1',
true,
@@ -1473,7 +1482,7 @@ describe('WorkflowExecuteAdditionalData', () => {
AGENT_ID,
MESSAGE,
EXEC_ID,
`wf:workflow-1:${THREAD_ID}`,
PROJECT_THREAD_ID,
'project-1',
'user-1',
true,
@@ -1513,16 +1522,128 @@ describe('WorkflowExecuteAdditionalData', () => {
AGENT_ID,
MESSAGE,
EXEC_ID,
`wf:workflow-1:${THREAD_ID}`,
PROJECT_THREAD_ID,
'project-1',
'user-1',
true,
undefined,
workflowContext,
sessionSandboxScope,
projectSessionSandboxScope,
);
});
it('shares a caller session across workflows in the same project', async () => {
const firstAdditionalData = mock<IWorkflowExecuteAdditionalData>({
userId: 'user-1',
projectId: 'project-1',
workflowId: 'workflow-1',
});
const secondAdditionalData = mock<IWorkflowExecuteAdditionalData>({
userId: 'user-1',
projectId: 'project-1',
workflowId: 'workflow-2',
});
await executeAgent(
{ agentId: AGENT_ID },
MESSAGE,
EXEC_ID,
THREAD_ID,
firstAdditionalData,
'manual',
undefined,
callerWorkflowContext('workflow-1'),
);
await executeAgent(
{ agentId: AGENT_ID },
MESSAGE,
EXEC_ID,
THREAD_ID,
secondAdditionalData,
'manual',
undefined,
callerWorkflowContext('workflow-2'),
);
expect(
agentWorkflowExecutionService.executeForWorkflow.mock.calls.map((call) => call[3]),
).toEqual([PROJECT_THREAD_ID, PROJECT_THREAD_ID]);
});
it('isolates the same caller session ID between projects', async () => {
const firstAdditionalData = mock<IWorkflowExecuteAdditionalData>({
userId: 'user-1',
projectId: 'project-1',
workflowId: 'workflow-1',
});
const secondAdditionalData = mock<IWorkflowExecuteAdditionalData>({
userId: 'user-1',
projectId: 'project-2',
workflowId: 'workflow-2',
});
await executeAgent(
{ agentId: AGENT_ID },
MESSAGE,
EXEC_ID,
THREAD_ID,
firstAdditionalData,
'manual',
undefined,
callerWorkflowContext('workflow-1'),
);
await executeAgent(
{ agentId: AGENT_ID },
MESSAGE,
EXEC_ID,
THREAD_ID,
secondAdditionalData,
'manual',
undefined,
callerWorkflowContext('workflow-2'),
);
expect(
agentWorkflowExecutionService.executeForWorkflow.mock.calls.map((call) => call[3]),
).toEqual([PROJECT_THREAD_ID, `workflow:project-project-2:${THREAD_ID}`]);
});
it('keeps the caller-facing session ID unchanged', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({
userId: 'user-1',
projectId: 'project-1',
workflowId: 'workflow-1',
});
agentWorkflowExecutionService.executeForWorkflow.mockResolvedValueOnce(
mock<Awaited<ReturnType<typeof agentWorkflowExecutionService.executeForWorkflow>>>({
session: {
agentId: AGENT_ID,
projectId: 'project-1',
sessionId: PROJECT_THREAD_ID,
threadId: PROJECT_THREAD_ID,
},
}),
);
const result = await executeAgent(
{ agentId: AGENT_ID },
MESSAGE,
EXEC_ID,
THREAD_ID,
additionalData,
'manual',
undefined,
callerWorkflowContext('workflow-1'),
);
expect(result.session).toEqual({
agentId: AGENT_ID,
projectId: 'project-1',
sessionId: THREAD_ID,
threadId: PROJECT_THREAD_ID,
});
});
it('backfills projectId from the workflow owner when missing', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({
userId: 'user-1',
@@ -1547,7 +1668,7 @@ describe('WorkflowExecuteAdditionalData', () => {
AGENT_ID,
MESSAGE,
EXEC_ID,
`wf:workflow-1:${THREAD_ID}`,
PROJECT_THREAD_ID,
'project-1',
'user-1',
true,
@@ -1610,7 +1731,7 @@ describe('WorkflowExecuteAdditionalData', () => {
AGENT_ID,
MESSAGE,
EXEC_ID,
`wf:workflow-1:${THREAD_ID}`,
PROJECT_THREAD_ID,
'project-1',
undefined,
false,
@@ -1643,7 +1764,7 @@ describe('WorkflowExecuteAdditionalData', () => {
AGENT_ID,
MESSAGE,
EXEC_ID,
`wf:workflow-1:${THREAD_ID}`,
PROJECT_THREAD_ID,
'project-1',
'user-1',
true,
@@ -1678,7 +1799,7 @@ describe('WorkflowExecuteAdditionalData', () => {
AGENT_ID,
MESSAGE,
EXEC_ID,
`wf:workflow-1:${THREAD_ID}`,
PROJECT_THREAD_ID,
'project-1',
'user-1',
false,
@@ -14,8 +14,8 @@ describe('hashAgentSandboxPrincipal', () => {
'7Zqe0BHA0mDnH7Ci9p-Zy7W2uVPQhf_4h01KkKpHnlU',
],
[
{ type: 'workflow-session', workflowId: 'workflow|one', sessionId: 'session|two' },
'U1yUIOzcWHMbn3uOWB_UKJCbW1yZ_aqsOZJl_o3omc0',
{ type: 'project-session', projectId: 'project|one', sessionId: 'session|two' },
'hvv8BfUEg9z51q2osaDXES2zFyThZImKNiT2euVvAUA',
],
[
{
@@ -10,7 +10,7 @@ export type AgentSandboxPrincipal =
platform: string;
platformUserId: string;
}
| { type: 'workflow-session'; workflowId: string; sessionId: string }
| { type: 'project-session'; projectId: string; sessionId: string }
| {
type: 'workflow-execution';
workflowId: string;
@@ -84,8 +84,8 @@ export function hashAgentSandboxPrincipal(
principal.platformUserId,
];
break;
case 'workflow-session':
canonicalPrincipal = [principal.type, principal.workflowId, principal.sessionId];
case 'project-session':
canonicalPrincipal = [principal.type, principal.projectId, principal.sessionId];
break;
case 'workflow-execution':
canonicalPrincipal = [principal.type, principal.workflowId, principal.executionId];
@@ -399,8 +399,7 @@ export async function executeAgent(
throw new UnexpectedError('Cannot execute agent without a workflowId in additional data');
}
// Scope session threads by workflow
const scopedThreadId = `wf:${additionalData.workflowId}:${threadId}`;
const scopedThreadId = `workflow:project-${projectId}:${threadId}`;
if (source.inlineAgent) {
return await agentWorkflowExecutionService.executeInlineForWorkflow(
@@ -422,8 +421,8 @@ export async function executeAgent(
workflowContext?.hasCallerSessionId === true
? {
principalHash: hashAgentSandboxPrincipal({
type: 'workflow-session',
workflowId: additionalData.workflowId,
type: 'project-session',
projectId,
sessionId: threadId,
}),
}
@@ -27,6 +27,8 @@ import { describeCommonTests } from './shared-tests';
import { ExecuteContext } from '../execute-context';
import * as validateUtil from '../utils/validate-value-against-schema';
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
describe('ExecuteContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
@@ -464,7 +466,7 @@ describe('ExecuteContext', () => {
{ agentId: 'agent-1' },
'hello',
'exec-1',
'exec-1-0',
expect.stringMatching(UUID_PATTERN),
agentAdditionalData,
'manual',
undefined,
@@ -502,7 +504,7 @@ describe('ExecuteContext', () => {
{ agentId: 'agent-1' },
'hello',
'exec-1',
'exec-1-0',
expect.stringMatching(UUID_PATTERN),
agentAdditionalData,
'manual',
undefined,
@@ -550,7 +552,7 @@ describe('ExecuteContext', () => {
{ agentId: 'agent-1' },
'hello',
'exec-1',
'exec-1-1',
expect.stringMatching(UUID_PATTERN),
twoItemAdditionalData,
'manual',
undefined,
@@ -594,7 +596,7 @@ describe('ExecuteContext', () => {
{ agentId: 'agent-1' },
'hello',
'exec-1',
'exec-1-0',
expect.stringMatching(UUID_PATTERN),
twoItemAdditionalData,
'manual',
undefined,
@@ -641,7 +643,7 @@ describe('ExecuteContext', () => {
{ agentId: 'agent-1' },
'hello',
'exec-1',
'exec-1-0',
expect.stringMatching(UUID_PATTERN),
multiBranchAdditionalData,
'manual',
undefined,
@@ -688,7 +690,7 @@ describe('ExecuteContext', () => {
{ agentId: 'agent-1' },
'hello',
'exec-1',
'exec-1-5',
expect.stringMatching(UUID_PATTERN),
outOfRangeAdditionalData,
'manual',
undefined,
@@ -40,6 +40,7 @@ import {
shouldRedactConsoleOutput,
CONSOLE_OUTPUT_REDACTED_MESSAGE,
} from 'n8n-workflow';
import { randomUUID } from 'node:crypto';
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/constants';
import { deepMerge } from '@/utils/deep-merge';
@@ -212,7 +213,7 @@ export class BaseExecuteContext extends NodeExecutionContext {
}
const callerSessionId = agentInfo.sessionId?.trim();
const threadId = callerSessionId || `${executionId}-${itemIndex}`;
const threadId = callerSessionId || randomUUID();
const inputDataScope = agentInfo.inputDataScope ?? 'item';
const mainBranches = this.inputData?.main ?? [];