From ce1b52177e84b7bd953da8a455ea6538202cd959 Mon Sep 17 00:00:00 2001 From: Robin Braumann <50590409+bjorger@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:54:24 +0000 Subject: [PATCH] feat(core): Isolate Agent sandboxes by principal (#36203) Co-authored-by: Cursor --- .../workspace/tool-result-storage.test.ts | 76 +++++ .../workspace/workspace-tools.test.ts | 2 +- packages/@n8n/agents/src/index.ts | 2 +- .../runtime/__tests__/agent-runtime.test.ts | 271 +++++++++++++--- .../__tests__/delegate-sub-agent-tool.test.ts | 9 +- .../memory-orchestrator-persist-input.test.ts | 66 ++++ .../agents/src/runtime/loop/agent-runtime.ts | 27 +- .../src/runtime/memory/memory-orchestrator.ts | 5 +- .../agents/src/runtime/state/run-state.ts | 6 +- .../runtime/tools/delegate-sub-agent-tool.ts | 7 +- .../src/runtime/tools/tool-call-executor.ts | 1 - .../src/runtime/tools/tool-result-guard.ts | 59 +++- packages/@n8n/agents/src/types/sdk/agent.ts | 3 +- packages/@n8n/agents/src/types/sdk/tool.ts | 1 + packages/@n8n/agents/src/workspace/index.ts | 6 +- .../src/workspace/tool-result-storage.ts | 95 ++++-- .../api-types/src/agents/sub-agent.schema.ts | 2 + packages/@n8n/config/test/config.test.ts | 9 - .../workflow-execute-additional-data.test.ts | 23 ++ ...ent-execution-orchestrator.service.test.ts | 146 ++++++++- .../__tests__/agent-execution.service.test.ts | 15 +- .../agent-knowledge-mirror.service.test.ts | 36 ++- .../__tests__/agent-knowledge.service.test.ts | 29 +- .../agent-runtime-cache.service.test.ts | 89 +++++- ...e-reconstruction-integration-tools.test.ts | 11 +- .../__tests__/agent-sandbox-principal.test.ts | 53 +++ .../agent-sandbox-runtime.service.test.ts | 175 +++++++--- .../agent-sandbox.controller.test.ts | 4 +- .../__tests__/agent-test-chat.service.test.ts | 17 +- .../__tests__/agent-test-run.service.test.ts | 1 + .../agent-workflow-execution.service.test.ts | 58 +++- .../__tests__/agent-workspace.service.test.ts | 74 +++-- .../agents-service-reconstruct-gating.test.ts | 47 ++- .../agents/__tests__/agents.module.test.ts | 53 +-- .../agents/__tests__/agents.service.test.ts | 4 +- .../modules/agents/agent-chat.controller.ts | 2 +- .../agent-execution-orchestrator.service.ts | 301 +++++++++++------- .../modules/agents/agent-execution.service.ts | 3 - .../agents/agent-knowledge-mirror.service.ts | 5 +- .../modules/agents/agent-knowledge.service.ts | 16 +- .../agents/agent-runtime-cache.service.ts | 114 +++++-- .../agent-runtime-reconstruction.service.ts | 22 +- .../modules/agents/agent-sandbox-principal.ts | 101 ++++++ .../agents/agent-sandbox-runtime.service.ts | 205 +++++++++--- .../agents/agent-sandbox.controller.ts | 2 +- .../modules/agents/agent-test-chat.service.ts | 5 +- .../agent-workflow-execution.service.ts | 58 +++- .../modules/agents/agent-workspace.service.ts | 75 +++-- .../cli/src/modules/agents/agents.service.ts | 2 +- .../__tests__/agent-chat-bridge.test.ts | 8 + .../__tests__/n8n-checkpoint-storage.test.ts | 70 +++- .../agents/integrations/agent-chat-bridge.ts | 18 +- .../integrations/n8n-checkpoint-storage.ts | 34 ++ .../agent-checkpoint.repository.ts | 8 + .../__tests__/delegate-sub-agent-tool.test.ts | 14 +- .../sub-agent-foreground-runner.test.ts | 61 ++++ .../sub-agents/delegate-sub-agent-tool.ts | 5 + .../sub-agents/sub-agent-foreground-runner.ts | 46 +++ .../__tests__/search-knowledge.tool.test.ts | 6 +- .../__tests__/agent-execution.service.test.ts | 22 +- .../eval/agent-execution.service.ts | 3 + .../__tests__/project.service.ee.test.ts | 15 +- .../sandbox-settings.service.test.ts | 14 + .../cli/src/services/project.service.ee.ts | 2 +- packages/cli/src/utils/ttl-map.ts | 31 +- .../src/workflow-execute-additional-data.ts | 18 ++ 66 files changed, 2225 insertions(+), 543 deletions(-) create mode 100644 packages/@n8n/agents/src/__tests__/workspace/tool-result-storage.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agent-sandbox-principal.test.ts create mode 100644 packages/cli/src/modules/agents/agent-sandbox-principal.ts diff --git a/packages/@n8n/agents/src/__tests__/workspace/tool-result-storage.test.ts b/packages/@n8n/agents/src/__tests__/workspace/tool-result-storage.test.ts new file mode 100644 index 00000000000..070fa5bf8c6 --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/workspace/tool-result-storage.test.ts @@ -0,0 +1,76 @@ +import { InMemoryFilesystem } from './test-utils'; +import { + getToolResultRunDirectory, + isToolResultPath, + reconcileToolResultRuns, + storeToolResult, +} from '../../workspace/tool-result-storage'; + +const TOOL_RESULT_RUNS_DIRECTORY = 'tool-results/runs'; + +async function writeRunFile(filesystem: InMemoryFilesystem, runId: string): Promise { + await filesystem.writeFile(`${getToolResultRunDirectory(runId)}/payload.json`, '{}', { + recursive: true, + }); +} + +describe('tool result storage', () => { + it('stores results only under the hashed run and rejects thread-scoped paths', async () => { + const filesystem = new InMemoryFilesystem(); + + const path = await storeToolResult( + filesystem, + { runId: 'run-1', toolCallId: 'tool-call-1' }, + 'result', + '{"ok":true}', + ); + + expect(path).toMatch( + /^tool-results\/runs\/[A-Za-z0-9_-]{43}\/[A-Za-z0-9_-]{43}\.result\.json$/, + ); + expect(isToolResultPath(path)).toBe(true); + expect( + isToolResultPath( + `tool-results/threads/${'a'.repeat(43)}/${'b'.repeat(43)}/${'c'.repeat(43)}.result.json`, + ), + ).toBe(false); + await expect(filesystem.readFile(path, { encoding: 'utf8' })).resolves.toBe('{"ok":true}'); + }); + + it('reconciles only old unprotected run directories', async () => { + const filesystem = new InMemoryFilesystem(); + const originalStat = filesystem.stat.bind(filesystem); + for (const runId of ['active', 'suspended', 'recent', 'old']) { + await writeRunFile(filesystem, runId); + } + await filesystem.writeFile(`${TOOL_RESULT_RUNS_DIRECTORY}/ordinary.txt`, 'keep'); + vi.spyOn(filesystem, 'stat').mockImplementation(async (path, options) => { + const stat = await originalStat(path, options); + return path === getToolResultRunDirectory('recent') + ? { ...stat, modifiedAt: new Date() } + : stat; + }); + + await reconcileToolResultRuns(filesystem, ['active', 'suspended'], 60_000); + + await expect(filesystem.exists(getToolResultRunDirectory('active'))).resolves.toBe(true); + await expect(filesystem.exists(getToolResultRunDirectory('suspended'))).resolves.toBe(true); + await expect(filesystem.exists(getToolResultRunDirectory('recent'))).resolves.toBe(true); + await expect(filesystem.exists(getToolResultRunDirectory('old'))).resolves.toBe(false); + await expect(filesystem.exists(`${TOOL_RESULT_RUNS_DIRECTORY}/ordinary.txt`)).resolves.toBe( + true, + ); + }); + + it('examines at most 100 unprotected orphan candidates per pass', async () => { + const filesystem = new InMemoryFilesystem(); + const runIds = Array.from({ length: 101 }, (_, index) => `old-run-${index}`); + await writeRunFile(filesystem, 'protected'); + for (const runId of runIds) await writeRunFile(filesystem, runId); + + await reconcileToolResultRuns(filesystem, ['protected'], 60_000); + + await expect(filesystem.exists(getToolResultRunDirectory(runIds[99]))).resolves.toBe(false); + await expect(filesystem.exists(getToolResultRunDirectory(runIds[100]))).resolves.toBe(true); + }); +}); diff --git a/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts b/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts index 54ca9601e1f..9e05a06d5dc 100644 --- a/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts +++ b/packages/@n8n/agents/src/__tests__/workspace/workspace-tools.test.ts @@ -123,7 +123,7 @@ describe('createWorkspaceTools', () => { it('read_tool_result describes and pages a nested result through escaped pointers', async () => { const hash = 'a'.repeat(43); - const path = `tool-results/threads/${hash}/${hash}/${hash}.result.json`; + const path = `tool-results/runs/${hash}/${hash}.result.json`; const escapedKey = 'folder/name~version'; const largeString = '"\\\n'.repeat(12_000); const storedResult = { diff --git a/packages/@n8n/agents/src/index.ts b/packages/@n8n/agents/src/index.ts index f668ae49030..d7f4cf82f6c 100644 --- a/packages/@n8n/agents/src/index.ts +++ b/packages/@n8n/agents/src/index.ts @@ -424,7 +424,7 @@ export { CORE_WORKSPACE_TOOL_NAMES, createScopedWorkspace, createWorkspaceTools, - getToolResultThreadDirectory, + reconcileToolResultRuns, } from './workspace'; export { SandboxProcessManager, ProcessHandle } from './workspace'; diff --git a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts index 6695f193e91..c9f1c62adfe 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -15,7 +15,7 @@ import type { StreamChunk } from '../../types/sdk/agent'; import type { AgentDbMessage, ContentToolCall, Message } from '../../types/sdk/message'; import type { BuiltTool, InterruptibleToolContext, ToolContext } from '../../types/sdk/tool'; import type { BuiltTelemetry } from '../../types/telemetry'; -import { Workspace, createWorkspaceTools } from '../../workspace'; +import { Workspace, getToolResultRunDirectory } from '../../workspace'; import { AgentRuntime } from '../loop/agent-runtime'; import { InMemoryMemory } from '../memory/memory-store'; import { AgentEventBus } from '../state/event-bus'; @@ -7810,20 +7810,28 @@ describe('AgentRuntime — oversized tool results', () => { } } - function modelToolResults(): unknown[] { - const call = generateText.mock.calls[1][0] as { - messages: Array<{ - role: string; - content: Array<{ type: string; output?: { type: string; value: unknown } }>; - }>; - }; - return call.messages + type ModelMessages = Array<{ + role: string; + content: Array<{ + type: string; + text?: string; + output?: { type: string; value: unknown }; + }>; + }>; + + function toolResultsFromModelMessages(messages: ModelMessages): unknown[] { + return messages .filter((message) => message.role === 'tool') .flatMap((message) => message.content) .filter((part) => part.type === 'tool-result') .map((part) => part.output?.value); } + function modelToolResults(): unknown[] { + const call = generateText.mock.calls[1][0] as { messages: ModelMessages }; + return toolResultsFromModelMessages(call.messages); + } + function createWorkspaceAgent(filesystem: InMemoryFilesystem, tools: BuiltTool[]) { return new Agent('workspace-result-test') .model('openai/gpt-4o-mini') @@ -7833,6 +7841,52 @@ describe('AgentRuntime — oversized tool results', () => { .workspace(new Workspace({ filesystem })); } + function createRunScopedRuntime( + filesystem: InMemoryFilesystem, + tools: BuiltTool[], + runId: string, + eventBus?: AgentEventBus, + checkpointStorage: 'memory' | CheckpointStore = 'memory', + ): AgentRuntime { + return new AgentRuntime({ + name: 'workspace-result-test', + model: 'openai/gpt-4o-mini', + instructions: 'Test', + tools, + runId, + checkpointStorage, + workspaceFilesystem: filesystem, + toolCallConcurrency: Infinity, + ...(eventBus ? { eventBus } : {}), + }); + } + + async function seedUnaffectedWorkspaceFiles(filesystem: InMemoryFilesystem): Promise { + await filesystem.writeFile(`${getToolResultRunDirectory('concurrent-run')}/keep.json`, '{}', { + recursive: true, + }); + } + + async function expectOnlyCurrentRunRemoved( + filesystem: InMemoryFilesystem, + runId: string, + ): Promise { + await expect(filesystem.exists(getToolResultRunDirectory(runId))).resolves.toBe(false); + await expect(filesystem.exists(getToolResultRunDirectory('concurrent-run'))).resolves.toBe( + true, + ); + } + + const largeResultOutput = { + value: 'x '.repeat(MAX_MODEL_TOOL_RESULT_TOKENS + 10_000), + }; + const largeResultTool: BuiltTool = { + name: 'large_result', + description: 'large result', + inputSchema: z.object({}), + handler: async () => await Promise.resolve(largeResultOutput), + }; + it('stores complete concurrent transformed results without changing raw outputs', async () => { const filesystem = new InMemoryFilesystem(); const transformedOutputs = { @@ -7849,6 +7903,7 @@ describe('AgentRuntime — oversized tool results', () => { }), ); const agent = createWorkspaceAgent(filesystem, tools); + const storedResultsSeenByNextModel: string[] = []; generateText .mockResolvedValueOnce( makeGenerateWithToolCalls([ @@ -7856,7 +7911,14 @@ describe('AgentRuntime — oversized tool results', () => { { toolCallId: 'tc/second', toolName: 'second', args: {} }, ]), ) - .mockResolvedValueOnce(makeGenerateSuccess()); + .mockImplementationOnce(async ({ messages }: { messages: ModelMessages }) => { + for (const envelope of toolResultsFromModelMessages(messages) as OffloadedEnvelope[]) { + storedResultsSeenByNextModel.push( + String(await filesystem.readFile(envelope.path, { encoding: 'utf8' })), + ); + } + return await Promise.resolve(makeGenerateSuccess()); + }); const result = await agent.generate('run', { persistence: { threadId: 'slack:C123:123.456', resourceId: 'user-1' }, @@ -7872,29 +7934,143 @@ describe('AgentRuntime — oversized tool results', () => { input: { path, view: 'describe' }, })), ); - await expect(filesystem.readFile(envelopes[0].path, { encoding: 'utf8' })).resolves.toBe( + expect(storedResultsSeenByNextModel).toEqual([ JSON.stringify(transformedOutputs.first), - ); - await expect(filesystem.readFile(envelopes[1].path, { encoding: 'utf8' })).resolves.toBe( JSON.stringify(transformedOutputs.second), - ); + ]); expect(result.toolCalls?.map(({ output }) => output)).toEqual([ { raw: 'first' }, { raw: 'second' }, ]); + await expect(filesystem.exists(getToolResultRunDirectory(result.runId))).resolves.toBe(false); + }); - const reader = createWorkspaceTools({ filesystem }).find( - (tool) => tool.name === 'workspace_read_tool_result', + it('retains current run results when checkpoint deletion fails', async () => { + const filesystem = new InMemoryFilesystem(); + const checkpointStorage: CheckpointStore = { + save: vi.fn(async () => await Promise.resolve()), + load: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockRejectedValue(new Error('checkpoint delete failed')), + }; + const runId = 'checkpoint-delete-failed-run'; + const runtime = createRunScopedRuntime( + filesystem, + [largeResultTool], + runId, + undefined, + checkpointStorage, ); - if (!reader?.handler) throw new Error('Expected workspace_read_tool_result'); - await expect(reader.handler(envelopes[0].requiredAction.input, {} as never)).resolves.toEqual( - { - view: 'describe', - pointer: '', - type: 'object', - childCount: 1, - }, + generateText + .mockResolvedValueOnce( + makeGenerateWithToolCalls([ + { toolCallId: 'tc-large', toolName: largeResultTool.name, args: {} }, + ]), + ) + .mockResolvedValueOnce(makeGenerateSuccess()); + + await runtime.generate('run'); + + expect(checkpointStorage.delete).toHaveBeenCalledWith(runId); + await expect(filesystem.exists(getToolResultRunDirectory(runId))).resolves.toBe(true); + }); + + it('removes only the current run results after a terminal model error', async () => { + const filesystem = new InMemoryFilesystem(); + const runId = 'failed-run'; + await seedUnaffectedWorkspaceFiles(filesystem); + const runtime = createRunScopedRuntime(filesystem, [largeResultTool], runId); + generateText + .mockResolvedValueOnce( + makeGenerateWithToolCalls([ + { toolCallId: 'tc-large', toolName: largeResultTool.name, args: {} }, + ]), + ) + .mockRejectedValueOnce(new Error('model unavailable')); + + await runtime.generate('run'); + + await expectOnlyCurrentRunRemoved(filesystem, runId); + }); + + it('removes only the current run results after cancellation', async () => { + const filesystem = new InMemoryFilesystem(); + const eventBus = new AgentEventBus(); + const runId = 'cancelled-run'; + eventBus.on(AgentEvent.ToolExecutionEnd, () => eventBus.abort()); + const runtime = createRunScopedRuntime(filesystem, [largeResultTool], runId, eventBus); + generateText.mockResolvedValueOnce( + makeGenerateWithToolCalls([ + { toolCallId: 'tc-large', toolName: largeResultTool.name, args: {} }, + ]), ); + + await runtime.generate('run'); + + await expect(filesystem.exists(getToolResultRunDirectory(runId))).resolves.toBe(false); + }); + + it('completes terminal runs when result cleanup hangs', async () => { + const filesystem = new InMemoryFilesystem(); + const runId = 'hung-cleanup-run'; + await filesystem.writeFile(`${getToolResultRunDirectory(runId)}/result.json`, '{}', { + recursive: true, + }); + vi.spyOn(filesystem, 'rmdir').mockReturnValue(new Promise(() => undefined)); + const runtime = createRunScopedRuntime(filesystem, [], runId); + generateText.mockResolvedValueOnce(makeGenerateSuccess()); + + await expect(runtime.generate('run')).resolves.toMatchObject({ finishReason: 'stop' }); + }); + + it('retains offloaded results across suspension and re-suspension, then removes them', async () => { + const filesystem = new InMemoryFilesystem(); + const checkpointStorage = makeClaimingCheckpointStore(); + const approvalTool = makeSuspendingTool('approval', async (_input, ctx) => { + if (ctx.resumeData) return { approved: true }; + return await ctx.suspend({ reason: 'approve' }); + }); + const runId = 'suspended-run'; + const runtime = createRunScopedRuntime( + filesystem, + [largeResultTool, approvalTool], + runId, + undefined, + checkpointStorage, + ); + generateText.mockResolvedValueOnce( + makeGenerateWithToolCalls([ + { toolCallId: 'tc-large', toolName: largeResultTool.name, args: {} }, + { toolCallId: 'tc-approval-1', toolName: approvalTool.name, args: {} }, + { toolCallId: 'tc-approval-2', toolName: approvalTool.name, args: {} }, + ]), + ); + + await runtime.generate('run', { + persistence: { threadId: 'thread-1', resourceId: 'resource-1' }, + }); + + await expect(filesystem.exists(getToolResultRunDirectory(runId))).resolves.toBe(true); + + const second = await runtime.resume( + 'generate', + { approved: true }, + { runId, toolCallId: 'tc-approval-1' }, + ); + expect(second.pendingSuspend?.map(({ toolCallId }) => toolCallId)).toEqual(['tc-approval-2']); + await expect(filesystem.exists(getToolResultRunDirectory(runId))).resolves.toBe(true); + + let resultReadByResumedModel: string | undefined; + generateText.mockImplementationOnce(async ({ messages }: { messages: ModelMessages }) => { + const [resumedEnvelope] = toolResultsFromModelMessages(messages) as OffloadedEnvelope[]; + resultReadByResumedModel = String( + await filesystem.readFile(resumedEnvelope.path, { encoding: 'utf8' }), + ); + return await Promise.resolve(makeGenerateSuccess('done')); + }); + await runtime.resume('generate', { approved: true }, { runId, toolCallId: 'tc-approval-2' }); + + expect(resultReadByResumedModel).toBe(JSON.stringify(largeResultOutput)); + await expect(filesystem.exists(getToolResultRunDirectory(runId))).resolves.toBe(false); }); it('stores oversized errors without changing the rejected tool-call state', async () => { @@ -7908,11 +8084,19 @@ describe('AgentRuntime — oversized tool results', () => { }, }; const agent = createWorkspaceAgent(filesystem, [tool]); + let storedError: string | undefined; generateText .mockResolvedValueOnce( makeGenerateWithToolCalls([{ toolCallId: 'tc-error', toolName: tool.name, args: {} }]), ) - .mockResolvedValueOnce(makeGenerateSuccess()); + .mockImplementationOnce(async ({ messages }: { messages: ModelMessages }) => { + const [errorEnvelope] = toolResultsFromModelMessages(messages); + const envelope = parseOffloadedEnvelope( + typeof errorEnvelope === 'string' ? errorEnvelope : undefined, + ); + storedError = String(await filesystem.readFile(envelope.path, { encoding: 'utf8' })); + return await Promise.resolve(makeGenerateSuccess()); + }); const result = await agent.generate('run', { persistence: { threadId: 'thread-1', resourceId: 'user-1' }, @@ -7923,19 +8107,9 @@ describe('AgentRuntime — oversized tool results', () => { (content): content is ContentToolCall => content.type === 'tool-call' && content.toolCallId === 'tc-error', ); - const envelope = parseOffloadedEnvelope( - toolCall?.state === 'rejected' ? toolCall.error : undefined, - ); expect(toolCall?.state).toBe('rejected'); - expect(envelope._offloaded).toBe(true); - expect(envelope.requiredAction).toEqual({ - toolName: 'workspace_read_tool_result', - input: { path: envelope.path, view: 'describe' }, - }); - await expect(filesystem.readFile(envelope.path, { encoding: 'utf8' })).resolves.toContain( - 'ERROR_HEAD', - ); + expect(storedError).toContain('ERROR_HEAD'); }); it('stores oversized custom-message text while preserving file content', async () => { @@ -7957,11 +8131,20 @@ describe('AgentRuntime — oversized tool results', () => { }), }; const agent = createWorkspaceAgent(filesystem, [tool]); + let storedMessage: string | undefined; generateText .mockResolvedValueOnce( makeGenerateWithToolCalls([{ toolCallId: 'tc-message', toolName: tool.name, args: {} }]), ) - .mockResolvedValueOnce(makeGenerateSuccess()); + .mockImplementationOnce(async ({ messages }: { messages: ModelMessages }) => { + const envelopeText = messages + .filter((message) => message.role === 'assistant') + .flatMap((message) => message.content) + .find((part) => part.type === 'text')?.text; + const envelope = parseOffloadedEnvelope(envelopeText); + storedMessage = String(await filesystem.readFile(envelope.path, { encoding: 'utf8' })); + return await Promise.resolve(makeGenerateSuccess()); + }); const result = await agent.generate('run', { persistence: { threadId: 'thread-1', resourceId: 'user-1' }, @@ -7971,25 +8154,13 @@ describe('AgentRuntime — oversized tool results', () => { 'content' in candidate && candidate.content.some((content) => content.type === 'file' && content.data === fileData), ); - const text = - message && 'content' in message - ? message.content.find((content) => content.type === 'text')?.text - : undefined; - const envelope = parseOffloadedEnvelope(text); - expect(envelope._offloaded).toBe(true); - expect(envelope.requiredAction).toEqual({ - toolName: 'workspace_read_tool_result', - input: { path: envelope.path, view: 'describe' }, - }); expect(message && 'content' in message ? message.content : []).toContainEqual({ type: 'file', mediaType: 'text/plain', data: fileData, }); - await expect(filesystem.readFile(envelope.path, { encoding: 'utf8' })).resolves.toBe( - JSON.stringify([{ type: 'text', text: messageText }]), - ); + expect(storedMessage).toBe(JSON.stringify([{ type: 'text', text: messageText }])); }); it('falls back to bounded truncation when storing the result fails', async () => { diff --git a/packages/@n8n/agents/src/runtime/__tests__/delegate-sub-agent-tool.test.ts b/packages/@n8n/agents/src/runtime/__tests__/delegate-sub-agent-tool.test.ts index 8fbfc8be8d9..e936d273320 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/delegate-sub-agent-tool.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/delegate-sub-agent-tool.test.ts @@ -699,7 +699,7 @@ describe('createDelegateSubAgentTool', () => { expect(executionCounter.incrementTokenCount).toHaveBeenCalledWith(42); }); - it('forwards the parent persistence thread id and resource id', async () => { + it('forwards the parent persistence scope', async () => { const runSubAgent = vi .fn() .mockResolvedValue({ status: 'completed', taskPath: '/root/research_api', answer: 'done' }); @@ -707,13 +707,18 @@ describe('createDelegateSubAgentTool', () => { await tool.handler?.(input, { runId: 'parent-run-1', - persistence: { threadId: 'parent-thread-1', resourceId: 'resource-1' }, + persistence: { + threadId: 'parent-thread-1', + resourceId: 'resource-1', + hostMetadata: { tenant: 'tenant-1', scope: { id: 'scope-1' } }, + }, }); expect(runSubAgent).toHaveBeenCalledWith( expect.objectContaining({ parentThreadId: 'parent-thread-1', parentResourceId: 'resource-1', + parentHostMetadata: { tenant: 'tenant-1', scope: { id: 'scope-1' } }, }), expect.objectContaining({ runInlineSubAgent: expect.any(Function), diff --git a/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-persist-input.test.ts b/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-persist-input.test.ts index 95f219a8abd..8fe28ed1227 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-persist-input.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-persist-input.test.ts @@ -11,6 +11,7 @@ import { AgentMessageList } from '../model/message-list'; import { BackgroundTaskTracker } from '../state/background-task-tracker'; import { AgentEventBus } from '../state/event-bus'; import { RuntimeTelemetry } from '../telemetry/runtime-telemetry'; +import { EXPIRED_OFFLOADED_TOOL_RESULT } from '../tools/tool-result-guard'; const THREAD_ID = 'thread-1'; const RESOURCE_ID = 'user-1'; @@ -247,6 +248,71 @@ describe('MemoryOrchestrator.persistTurnDelta', () => { expect(errors).toHaveLength(1); expect(errors[0]).toMatchObject({ type: AgentEvent.Error, source: 'turn-delta-persistence' }); }); + + it.each(['persistTurnDelta', 'saveToMemory'] as const)( + 'sanitizes offloaded result locators for %s', + async (method) => { + const store = new InMemoryMemory(); + await store.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID }); + const runHash = 'a'.repeat(43); + const envelope = (kind: 'result' | 'error' | 'message') => { + const path = `tool-results/runs/${runHash}/${kind[0].repeat(43)}.${kind}.json`; + return { + _offloaded: true as const, + path, + originalCharCount: 100_000, + estimatedTokenCount: 60_000, + requiredAction: { + toolName: 'workspace_read_tool_result' as const, + input: { path, view: 'describe' as const }, + }, + message: 'Stored in workspace', + }; + }; + const list = new AgentMessageList(); + list.addResponse([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'result-call', + toolName: 'result-tool', + input: {}, + state: 'resolved', + output: envelope('result'), + }, + { + type: 'tool-call', + toolCallId: 'error-call', + toolName: 'error-tool', + input: {}, + state: 'rejected', + error: JSON.stringify(envelope('error')), + }, + { type: 'text', text: JSON.stringify(envelope('message')) }, + ], + }, + ]); + + const orchestrator = buildOrchestrator(store); + await orchestrator[method](list, PERSIST); + + const [persisted] = await store.getMessages(THREAD_ID, { resourceId: RESOURCE_ID }); + if (!persisted || !('content' in persisted)) throw new Error('Expected persisted message'); + expect(persisted.content).toEqual([ + expect.objectContaining({ + state: 'resolved', + output: EXPIRED_OFFLOADED_TOOL_RESULT, + }), + expect.objectContaining({ + state: 'rejected', + error: JSON.stringify(EXPIRED_OFFLOADED_TOOL_RESULT), + }), + { type: 'text', text: JSON.stringify(EXPIRED_OFFLOADED_TOOL_RESULT) }, + ]); + }, + ); }); describe('MemoryOrchestrator save-path telemetry', () => { diff --git a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts index 3f04578ebda..39c6b273047 100644 --- a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts @@ -54,7 +54,8 @@ import type { AgentMessage, ContentToolCall } from '../../types/sdk/message'; import type { JSONValue } from '../../types/utils/json'; import { getModelIdString } from '../../utils/model'; import { parseWithSchema } from '../../utils/parse'; -import type { WorkspaceFilesystem } from '../../workspace'; +import { removeToolResultRun, type WorkspaceFilesystem } from '../../workspace'; +import { createFilteredLogger } from '../logger'; import { MemoryOrchestrator } from '../memory/memory-orchestrator'; import type { ScopedMemoryTaskEvent } from '../memory/scoped-memory-task-runner'; import { generateThreadTitle } from '../memory/title-generation'; @@ -143,6 +144,7 @@ const MAX_LOOP_ITERATIONS = 30; /** Retries for a `stop` turn that produced no output at all (see isEmptyModelTurn). */ const MAX_EMPTY_TURN_RETRIES = 2; +const logger = createFilteredLogger(); const EMPTY_MESSAGE_LIST: SerializedMessageList = { messages: [], @@ -169,7 +171,7 @@ interface LoopContext { * * Memory strategy: * - `filterLlmMessages` strips custom messages before sending to the LLM. - * - Memory stores ALL AgentMessages (including custom) unchanged. + * - Memory stores all messages, but expires run-scoped offload locators. * - New messages for each turn are tracked via AgentMessageList.turnDelta(), * which uses Set-based source tracking to identify turn-only messages. * The list serializes with id-based sets so it can survive process restarts. @@ -292,7 +294,6 @@ export class AgentRuntime { list = builtList; return this.finalizeGenerate(rawResult, list); } catch (error) { - await this.telemetry.flush(options); const isAbort = abortScope.isAborted; this.updateState({ status: isAbort ? 'cancelled' : 'failed' }); if (isAbort) { @@ -302,6 +303,8 @@ export class AgentRuntime { } else { this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error }); } + await this.cleanupRun(); + await this.telemetry.flush(options); return { runId: this.runId, messages: list?.responseDelta() ?? [], @@ -381,6 +384,7 @@ export class AgentRuntime { let resumeData: unknown = data; let abortScope: AgentAbortScope | undefined; + let resumeClaimed = false; const resumeSchema = toolCall.suspended ? toolCall.resumeSchema : tool.resumeSchema; if (!isCancellation(resumeData) && resumeSchema) { @@ -428,6 +432,7 @@ export class AgentRuntime { if (!claimed) { throw new StaleResumeError(`Run ${this.runId} is not suspended. Cannot resume.`); } + resumeClaimed = true; await options.onResumeClaimed?.(); abortScope = this.eventBus.createAbortScope(resumeOptions.abortSignal); @@ -486,6 +491,7 @@ export class AgentRuntime { if (!isAbort) { this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error }); } + if (resumeClaimed) await this.cleanupRun(); if (method === 'generate') { return { runId: this.runId, @@ -1109,7 +1115,20 @@ export class AgentRuntime { /** Clean up stored state for a run when it finishes without re-suspending. */ private async cleanupRun(): Promise { - await this.runState.complete(this.runId); + try { + await this.runState.complete(this.runId); + } catch (error) { + logger.warn('Failed to clean up agent run checkpoint', { runId: this.runId, error }); + return; + } + + if (this.config.workspaceFilesystem) { + try { + await removeToolResultRun(this.config.workspaceFilesystem, this.runId); + } catch (error) { + logger.warn('Failed to clean up agent run tool results', { runId: this.runId, error }); + } + } } /** Emit a TurnEnd event when an assistant message is present in `newMessages`. */ diff --git a/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts b/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts index 03b80a63b65..ea2a4c8314d 100644 --- a/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts +++ b/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts @@ -37,6 +37,7 @@ import { type MemorySpanAttributes, type RuntimeTelemetry, } from '../telemetry/runtime-telemetry'; +import { sanitizeOffloadedToolResultsForMemory } from '../tools/tool-result-guard'; const DEFAULT_MEMORY_TASK_LOCK_TTL_MS = 30_000; const logger = createFilteredLogger(); @@ -270,7 +271,7 @@ export class MemoryOrchestrator { ): Promise { const memory = this.config.memory; if (!memory || !options?.persistence) return; - const delta = list.turnDelta(); + const delta = sanitizeOffloadedToolResultsForMemory(list.turnDelta()); if (delta.length === 0) return; try { const telemetry = this.runtimeTelemetry.resolve(options); @@ -305,7 +306,7 @@ export class MemoryOrchestrator { ): Promise { const memory = this.config.memory; if (!memory || !options?.persistence) return; - const delta = list.turnDelta(); + const delta = sanitizeOffloadedToolResultsForMemory(list.turnDelta()); if (delta.length === 0) return; const telemetry = this.runtimeTelemetry.resolve(options); await this.saveMessagesWithSpan( diff --git a/packages/@n8n/agents/src/runtime/state/run-state.ts b/packages/@n8n/agents/src/runtime/state/run-state.ts index 9ea66309be4..b1ec3c6666e 100644 --- a/packages/@n8n/agents/src/runtime/state/run-state.ts +++ b/packages/@n8n/agents/src/runtime/state/run-state.ts @@ -110,11 +110,7 @@ export class RunStateManager { /** Delete a finished run from storage. Called when a resumed run completes without re-suspending. */ async complete(runId: string): Promise { - try { - await this.store.delete(runId); - } catch (deleteError: unknown) { - console.error(`[RunStateManager] Failed to delete checkpoint ${runId}:`, deleteError); - } + await this.store.delete(runId); } /** Delete a cancelled run and surface failures so its parent can remain retryable. */ diff --git a/packages/@n8n/agents/src/runtime/tools/delegate-sub-agent-tool.ts b/packages/@n8n/agents/src/runtime/tools/delegate-sub-agent-tool.ts index c8f078af951..94f92a91c14 100644 --- a/packages/@n8n/agents/src/runtime/tools/delegate-sub-agent-tool.ts +++ b/packages/@n8n/agents/src/runtime/tools/delegate-sub-agent-tool.ts @@ -32,7 +32,7 @@ import type { ToolContext, } from '../../types/sdk/tool'; import type { BuiltTelemetry } from '../../types/telemetry'; -import type { JSONValue } from '../../types/utils/json'; +import type { JSONObject, JSONValue } from '../../types/utils/json'; import { withoutMessageCount } from '../loop/execution-counter'; export const DELEGATE_SUB_AGENT_TOOL_NAME = 'delegate_subagent'; @@ -169,6 +169,8 @@ export interface DelegateSubAgentRequest extends DelegateSubAgentInput { parentThreadId?: string; /** Parent's episodic-memory resource id (`ctx.persistence.resourceId`). */ parentResourceId?: string; + /** Opaque host metadata from the parent's persistence scope. */ + parentHostMetadata?: JSONObject; /** Parent's tool-call id that triggered this delegation. */ parentToolCallId?: string; /** @@ -718,6 +720,9 @@ function createDelegateSubAgentRequest( ...(ctx.persistence?.resourceId !== undefined ? { parentResourceId: ctx.persistence.resourceId } : {}), + ...(ctx.persistence?.hostMetadata !== undefined + ? { parentHostMetadata: ctx.persistence.hostMetadata } + : {}), ...(ctx.abortSignal !== undefined ? { parentAbortSignal: ctx.abortSignal } : {}), ...(ctx.toolCallId !== undefined ? { parentToolCallId: ctx.toolCallId } : {}), ...(ctx.executionCounter !== undefined diff --git a/packages/@n8n/agents/src/runtime/tools/tool-call-executor.ts b/packages/@n8n/agents/src/runtime/tools/tool-call-executor.ts index 8bbdba9cc79..5bb1c5c7bfe 100644 --- a/packages/@n8n/agents/src/runtime/tools/tool-call-executor.ts +++ b/packages/@n8n/agents/src/runtime/tools/tool-call-executor.ts @@ -1081,7 +1081,6 @@ export class ToolCallExecutor { filesystem, runId: params.runId, toolCallId: params.toolCallId, - ...(params.persistence?.threadId ? { threadId: params.persistence.threadId } : {}), ...(params.abortSignal ? { abortSignal: params.abortSignal } : {}), }; } diff --git a/packages/@n8n/agents/src/runtime/tools/tool-result-guard.ts b/packages/@n8n/agents/src/runtime/tools/tool-result-guard.ts index 07ceba60131..06bd8f7f67c 100644 --- a/packages/@n8n/agents/src/runtime/tools/tool-result-guard.ts +++ b/packages/@n8n/agents/src/runtime/tools/tool-result-guard.ts @@ -1,8 +1,9 @@ import { toJsonValue } from '@n8n/utils/json/to-json-value'; -import type { AgentMessage, MessageContent } from '../../types/sdk/message'; +import type { AgentDbMessage, AgentMessage, MessageContent } from '../../types/sdk/message'; import type { JSONObject, JSONValue } from '../../types/utils/json'; import { + isToolResultPath, storeToolResult, type ToolResultKind, type ToolResultStorageScope, @@ -37,6 +38,14 @@ interface OffloadedToolResult extends JSONObject { message: string; } +export const EXPIRED_OFFLOADED_TOOL_RESULT = { + _offloaded: true, + expired: true, + message: 'The stored tool result expired with its originating run.', +} satisfies JSONObject; + +const EXPIRED_OFFLOADED_TOOL_RESULT_JSON = JSON.stringify(EXPIRED_OFFLOADED_TOOL_RESULT); + export interface ToolResultGuardStorage extends ToolResultStorageScope { filesystem: WorkspaceFilesystem; } @@ -120,6 +129,54 @@ export async function guardToolMessageForModel( return { ...message, content }; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isOffloadedToolResult(value: unknown): boolean { + return ( + isRecord(value) && + value._offloaded === true && + typeof value.path === 'string' && + isToolResultPath(value.path) + ); +} + +function isSerializedOffloadedToolResult(value: string): boolean { + try { + const parsed: unknown = JSON.parse(value); + return isOffloadedToolResult(parsed); + } catch { + return false; + } +} + +export function sanitizeOffloadedToolResultsForMemory( + messages: AgentDbMessage[], +): AgentDbMessage[] { + return messages.map((message) => { + if (!('content' in message)) return { ...message }; + + const content = message.content.map((block): MessageContent => { + if (block.type === 'tool-call') { + if (block.state === 'resolved' && isOffloadedToolResult(block.output)) { + return { ...block, output: { ...EXPIRED_OFFLOADED_TOOL_RESULT } }; + } + if (block.state === 'rejected' && isSerializedOffloadedToolResult(block.error)) { + return { ...block, error: EXPIRED_OFFLOADED_TOOL_RESULT_JSON }; + } + } + + if (block.type === 'text' && isSerializedOffloadedToolResult(block.text)) { + return { ...block, text: EXPIRED_OFFLOADED_TOOL_RESULT_JSON }; + } + + return { ...block }; + }); + return { ...message, content }; + }); +} + async function tryOffloadResult( serialized: string, tokenCount: number, diff --git a/packages/@n8n/agents/src/types/sdk/agent.ts b/packages/@n8n/agents/src/types/sdk/agent.ts index a98db4a7654..ab4bc4e6b02 100644 --- a/packages/@n8n/agents/src/types/sdk/agent.ts +++ b/packages/@n8n/agents/src/types/sdk/agent.ts @@ -18,7 +18,7 @@ import type { } from '../runtime/event'; import type { SerializedMessageList } from '../runtime/message-list'; import type { BuiltTelemetry } from '../telemetry'; -import type { JSONValue } from '../utils/json'; +import type { JSONObject, JSONValue } from '../utils/json'; export type SmoothStreamOptions = NonNullable[0]>; @@ -432,6 +432,7 @@ export interface SerializableAgentState { export type AgentPersistenceOptions = { threadId: string; resourceId: string; + hostMetadata?: JSONObject; /** Internal child runs must only be resumed through their suspended parent. */ delegated?: true; /** diff --git a/packages/@n8n/agents/src/types/sdk/tool.ts b/packages/@n8n/agents/src/types/sdk/tool.ts index 925a03cc837..9d003058185 100644 --- a/packages/@n8n/agents/src/types/sdk/tool.ts +++ b/packages/@n8n/agents/src/types/sdk/tool.ts @@ -26,6 +26,7 @@ export interface ToolExecutionContext { persistence?: { threadId: string; resourceId: string; + hostMetadata?: JSONObject; }; /** Internal runtime event bridge for platform-managed tools. */ emitEvent?: (event: AgentEventData) => void; diff --git a/packages/@n8n/agents/src/workspace/index.ts b/packages/@n8n/agents/src/workspace/index.ts index 4e2d512e2fd..d7300d66d37 100644 --- a/packages/@n8n/agents/src/workspace/index.ts +++ b/packages/@n8n/agents/src/workspace/index.ts @@ -1,6 +1,10 @@ export { Workspace } from './workspace'; export { createScopedWorkspace } from './scoped-workspace'; -export { getToolResultThreadDirectory } from './tool-result-storage'; +export { + getToolResultRunDirectory, + reconcileToolResultRuns, + removeToolResultRun, +} from './tool-result-storage'; export { BaseFilesystem } from './filesystem/base-filesystem'; diff --git a/packages/@n8n/agents/src/workspace/tool-result-storage.ts b/packages/@n8n/agents/src/workspace/tool-result-storage.ts index 48619c455c3..bbcc14d5511 100644 --- a/packages/@n8n/agents/src/workspace/tool-result-storage.ts +++ b/packages/@n8n/agents/src/workspace/tool-result-storage.ts @@ -1,55 +1,45 @@ import { createHash } from 'node:crypto'; -import type { WorkspaceFilesystem } from './types'; +import type { FileEntry, WorkspaceFilesystem } from './types'; +import { raceWithAbort } from '../sdk/abort'; export type ToolResultKind = 'result' | 'error' | 'message'; export interface ToolResultStorageScope { - threadId?: string; runId: string; toolCallId: string; abortSignal?: AbortSignal; } const TOOL_RESULTS_DIRECTORY = 'tool-results'; +const TOOL_RESULT_RUNS_DIRECTORY = `${TOOL_RESULTS_DIRECTORY}/runs`; const HASHED_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9_-]{43}$/; const TOOL_RESULT_FILE_PATTERN = /^([A-Za-z0-9_-]{43})\.(result|error|message)\.json$/; +const MAX_RECONCILIATION_CANDIDATES = 100; +const TOOL_RESULT_CLEANUP_TIMEOUT_MS = 1_000; +const TOOL_RESULT_RECONCILIATION_TIMEOUT_MS = 5_000; function hashPathSegment(value: string): string { return createHash('sha256').update(value).digest('base64url'); } -export function getToolResultThreadDirectory(threadId: string): string { - return `${TOOL_RESULTS_DIRECTORY}/threads/${hashPathSegment(threadId)}`; +export function getToolResultRunDirectory(runId: string): string { + return `${TOOL_RESULT_RUNS_DIRECTORY}/${hashPathSegment(runId)}`; } export function isToolResultPath(path: string): boolean { const segments = path.split('/'); - if (segments[0] !== TOOL_RESULTS_DIRECTORY) return false; - - if (segments[1] === 'threads' && segments.length === 5) { - return ( - HASHED_PATH_SEGMENT_PATTERN.test(segments[2]) && - HASHED_PATH_SEGMENT_PATTERN.test(segments[3]) && - TOOL_RESULT_FILE_PATTERN.test(segments[4]) - ); - } - - if (segments[1] === 'runs' && segments.length === 4) { - return ( - HASHED_PATH_SEGMENT_PATTERN.test(segments[2]) && TOOL_RESULT_FILE_PATTERN.test(segments[3]) - ); - } - - return false; + return ( + segments.length === 4 && + segments[0] === TOOL_RESULTS_DIRECTORY && + segments[1] === 'runs' && + HASHED_PATH_SEGMENT_PATTERN.test(segments[2]) && + TOOL_RESULT_FILE_PATTERN.test(segments[3]) + ); } function getToolResultPath(scope: ToolResultStorageScope, kind: ToolResultKind): string { - const runDirectory = scope.threadId - ? `${getToolResultThreadDirectory(scope.threadId)}/${hashPathSegment(scope.runId)}` - : `${TOOL_RESULTS_DIRECTORY}/runs/${hashPathSegment(scope.runId)}`; - - return `${runDirectory}/${hashPathSegment(scope.toolCallId)}.${kind}.json`; + return `${getToolResultRunDirectory(scope.runId)}/${hashPathSegment(scope.toolCallId)}.${kind}.json`; } export async function storeToolResult( @@ -66,3 +56,56 @@ export async function storeToolResult( }); return path; } + +export async function removeToolResultRun( + filesystem: WorkspaceFilesystem, + runId: string, +): Promise { + const abortSignal = AbortSignal.timeout(TOOL_RESULT_CLEANUP_TIMEOUT_MS); + await raceWithAbort(async () => { + const runDirectory = getToolResultRunDirectory(runId); + if (!(await filesystem.exists(runDirectory, { abortSignal }))) return; + await filesystem.rmdir(runDirectory, { recursive: true, force: true, abortSignal }); + }, abortSignal); +} + +export async function reconcileToolResultRuns( + filesystem: WorkspaceFilesystem, + protectedRunIds: Iterable, + ttlMs: number, +): Promise { + const abortSignal = AbortSignal.timeout(TOOL_RESULT_RECONCILIATION_TIMEOUT_MS); + await raceWithAbort(async () => { + let entries: FileEntry[]; + try { + entries = await filesystem.readdir(TOOL_RESULT_RUNS_DIRECTORY, { abortSignal }); + } catch { + return; + } + + const protectedDirectories = new Set( + Array.from(protectedRunIds, (runId) => hashPathSegment(runId)), + ); + const cutoff = Date.now() - ttlMs; + let examined = 0; + + for (const entry of entries) { + if (examined >= MAX_RECONCILIATION_CANDIDATES) break; + if (entry.type !== 'directory' || !HASHED_PATH_SEGMENT_PATTERN.test(entry.name)) { + continue; + } + if (protectedDirectories.has(entry.name)) continue; + examined++; + + const path = `${TOOL_RESULT_RUNS_DIRECTORY}/${entry.name}`; + try { + const { modifiedAt } = await filesystem.stat(path, { abortSignal }); + const modifiedAtMs = modifiedAt.getTime(); + if (!Number.isFinite(modifiedAtMs) || modifiedAtMs >= cutoff) continue; + await filesystem.rmdir(path, { recursive: true, force: true, abortSignal }); + } catch { + if (abortSignal.aborted) return; + } + } + }, abortSignal); +} diff --git a/packages/@n8n/api-types/src/agents/sub-agent.schema.ts b/packages/@n8n/api-types/src/agents/sub-agent.schema.ts index 1b44c3f9e53..d705a849f94 100644 --- a/packages/@n8n/api-types/src/agents/sub-agent.schema.ts +++ b/packages/@n8n/api-types/src/agents/sub-agent.schema.ts @@ -45,6 +45,8 @@ export interface SubAgentSpawnRequest { parentThreadId?: string; /** Parent's episodic-memory resource id, inherited so the child shares its scope. */ parentResourceId?: string; + /** Parent's workspace principal hash, inherited by configured first-class children. */ + parentSandboxPrincipalHash?: string; /** * This delegation's task path — already assigned and policy-checked by the SDK * delegate tool, then validated by `@n8n/agents` (`assertSubAgentTaskPath`) diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index c32177c6941..05211d61673 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -730,15 +730,6 @@ describe('GlobalConfig', () => { expect(config.agents.tracingRecordOutputs).toBe(false); }); - it('should parse N8N_AGENTS_AI_SANDBOX_EPHEMERAL from env variables', () => { - process.env = { - N8N_AGENTS_AI_SANDBOX_EPHEMERAL: 'true', - }; - const config = Container.get(GlobalConfig); - - expect(config.agents.sandboxEphemeral).toBe(true); - }); - it('should parse N8N_AGENTS_AI_SANDBOX_SNAPSHOT from env variables', () => { process.env = { N8N_AGENTS_AI_SANDBOX_SNAPSHOT: 'n8n/agent-knowledge:1.2.3', 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 ab6b71d13b5..65cfe7b5d42 100644 --- a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts +++ b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts @@ -37,6 +37,7 @@ import { SubworkflowPolicyChecker, } from '@/executions/pre-execution-checks'; import { ExternalHooks } from '@/external-hooks'; +import { hashAgentSandboxPrincipal } from '@/modules/agents/agent-sandbox-principal'; import { AgentWorkflowExecutionService } from '@/modules/agents/agent-workflow-execution.service'; import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service'; import { OwnershipService } from '@/services/ownership.service'; @@ -1339,6 +1340,20 @@ describe('WorkflowExecuteAdditionalData', () => { const MESSAGE = 'hello'; const EXEC_ID = 'exec-id'; const THREAD_ID = 'thread-id'; + const executionSandboxScope = { + principalHash: hashAgentSandboxPrincipal({ + type: 'workflow-execution', + workflowId: 'workflow-1', + executionId: EXEC_ID, + }), + }; + const sessionSandboxScope = { + principalHash: hashAgentSandboxPrincipal({ + type: 'workflow-session', + workflowId: 'workflow-1', + sessionId: THREAD_ID, + }), + }; beforeEach(() => { vi.clearAllMocks(); @@ -1428,6 +1443,7 @@ describe('WorkflowExecuteAdditionalData', () => { true, undefined, undefined, + executionSandboxScope, ); }); @@ -1463,6 +1479,7 @@ describe('WorkflowExecuteAdditionalData', () => { true, outputSchema, undefined, + executionSandboxScope, ); }); @@ -1476,6 +1493,7 @@ describe('WorkflowExecuteAdditionalData', () => { workflowId: 'workflow-1', workflowName: 'My workflow', callingNodeName: 'Message an Agent', + hasCallerSessionId: true, nodes: [{ name: 'Webhook', type: 'n8n-nodes-base.webhook' }], runExecutionData: { resultData: { runData: {} } } as unknown as IRunExecutionData, }; @@ -1501,6 +1519,7 @@ describe('WorkflowExecuteAdditionalData', () => { true, undefined, workflowContext, + sessionSandboxScope, ); }); @@ -1534,6 +1553,7 @@ describe('WorkflowExecuteAdditionalData', () => { true, undefined, undefined, + executionSandboxScope, ); }); @@ -1596,6 +1616,7 @@ describe('WorkflowExecuteAdditionalData', () => { false, undefined, undefined, + executionSandboxScope, ); }); @@ -1628,6 +1649,7 @@ describe('WorkflowExecuteAdditionalData', () => { true, undefined, undefined, + executionSandboxScope, ); }, ); @@ -1662,6 +1684,7 @@ describe('WorkflowExecuteAdditionalData', () => { false, undefined, undefined, + executionSandboxScope, ); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts index ef6a214bec9..7da17352c29 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts @@ -18,6 +18,11 @@ import { AgentExecutionOrchestratorService } from '../agent-execution-orchestrat import type { AgentExecutionService } from '../agent-execution.service'; import type { AgentRunTracingService } from '../agent-run-tracing.service'; import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; +import { + encodeAgentSandboxHostMetadata, + hashAgentSandboxPrincipal, +} from '../agent-sandbox-principal'; +import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service'; import type { IntegrationMessageContextService } from '../integrations/integration-message-context.service'; import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage'; import type { ToolRegistry } from '../tool-registry'; @@ -26,6 +31,14 @@ const agentId = 'agent-1'; const projectId = 'project-1'; const userId = 'user-1'; const user = mock({ id: userId }); +const userPrincipalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId }); +const integrationPrincipalHash = hashAgentSandboxPrincipal({ + type: 'integration-user', + connectionId: 'credential-1', + platform: 'slack', + platformUserId: 'platform-user-1', +}); +const taskPrincipalHash = hashAgentSandboxPrincipal({ type: 'scheduled-task', taskId: 'task-1' }); const schema: AgentJsonConfig = { name: 'Support Agent', @@ -99,7 +112,7 @@ function makeRuntime(chunks: StreamChunk[] = [{ type: 'finish', finishReason: 's }; } -function makeService() { +function makeService(sandboxEnabled = false) { const checkpointStorage = mock(); const executionService = mock(); const telemetry = mock(); @@ -107,6 +120,9 @@ function makeService() { const integrationMessageContextService = mock(); const agentRunTracingService = mock(); const externalHooks = mock(); + const agentSandboxRuntimeService = mock({ + isEnabled: () => sandboxEnabled, + }); executionService.startExecutionRecording.mockResolvedValue('execution-1'); executionService.finalizeExecution.mockResolvedValue('execution-1'); @@ -121,6 +137,7 @@ function makeService() { integrationMessageContextService, agentRunTracingService, externalHooks, + agentSandboxRuntimeService, ); return { @@ -132,6 +149,7 @@ function makeService() { integrationMessageContextService, agentRunTracingService, externalHooks, + agentSandboxRuntimeService, }; } @@ -196,6 +214,7 @@ describe('AgentExecutionOrchestratorService', () => { memory: { threadId: 'thread-1', resourceId: 'resource-1' }, projectId, telemetry: telemetryContext, + sandboxPrincipalHash: userPrincipalHash, }), ); @@ -249,6 +268,7 @@ describe('AgentExecutionOrchestratorService', () => { memory: { threadId: 'thread-1', resourceId: 'resource-1' }, projectId, telemetry: telemetryContext, + sandboxPrincipalHash: userPrincipalHash, abortSignal: abortController.signal, }), ); @@ -257,7 +277,14 @@ describe('AgentExecutionOrchestratorService', () => { expect(runtime.agent.stream).toHaveBeenCalledWith( 'hello', expect.objectContaining({ - persistence: { threadId: 'thread-1', resourceId: 'resource-1' }, + persistence: { + threadId: 'thread-1', + resourceId: 'resource-1', + hostMetadata: encodeAgentSandboxHostMetadata({ + projectId, + principalHash: userPrincipalHash, + }), + }, executionCounter: expect.any(Object), abortSignal: abortController.signal, }), @@ -288,6 +315,7 @@ describe('AgentExecutionOrchestratorService', () => { memory: { threadId: 'thread-1', resourceId: 'resource-1' }, projectId, telemetry: telemetryContext, + sandboxPrincipalHash: userPrincipalHash, onExecutionRecorded, }), ); @@ -310,6 +338,7 @@ describe('AgentExecutionOrchestratorService', () => { memory: { threadId: 'thread-1', resourceId: 'resource-1' }, projectId, telemetry: telemetryContext, + sandboxPrincipalHash: userPrincipalHash, }), ); @@ -344,6 +373,7 @@ describe('AgentExecutionOrchestratorService', () => { projectId, integrationType: N8N_CHAT_INTEGRATION_TYPE, user, + sandboxPrincipalHash: userPrincipalHash, }); expect(integrationMessageContextService.setLatest).toHaveBeenCalledWith( 'thread-1', @@ -359,12 +389,6 @@ describe('AgentExecutionOrchestratorService', () => { expect( integrationMessageContextService.setLatest.mock.invocationCallOrder[0] ?? 0, ).toBeLessThan(runtime.agent.stream.mock.invocationCallOrder[0] ?? 0); - expect(runtime.agent.stream).toHaveBeenCalledWith( - 'hello', - expect.objectContaining({ - persistence: { threadId: 'thread-1', resourceId: 'resource-1' }, - }), - ); expect(externalHooks.run).not.toHaveBeenCalled(); expect(executionService.finalizeExecution).toHaveBeenCalledWith( 'execution-1', @@ -404,6 +428,7 @@ describe('AgentExecutionOrchestratorService', () => { message: 'from slack', memory: { threadId: 'thread-1', resourceId: 'platform-user-1' }, integrationType: 'slack', + sandboxPrincipalHash: integrationPrincipalHash, }), ); @@ -412,6 +437,7 @@ describe('AgentExecutionOrchestratorService', () => { projectId, integrationType: 'slack', usePublishedVersion: true, + sandboxPrincipalHash: integrationPrincipalHash, }); expect(externalHooks.run).toHaveBeenCalledWith('agent.preExecute', [agentId]); expect(externalHooks.run).toHaveBeenCalledTimes(1); @@ -460,6 +486,7 @@ describe('AgentExecutionOrchestratorService', () => { projectId, integrationType: 'task', usePublishedVersion: true, + sandboxPrincipalHash: taskPrincipalHash, }); expect(externalHooks.run).toHaveBeenCalledWith('agent.preExecute', [agentId]); expect(externalHooks.run).toHaveBeenCalledTimes(1); @@ -521,6 +548,11 @@ describe('AgentExecutionOrchestratorService', () => { ); expect(externalHooks.run).not.toHaveBeenCalled(); + expect(runtimeCacheService.getRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + sandboxPrincipalHash: userPrincipalHash, + }), + ); }); it('adds the max-iterations assistant text before the finish chunk and persists it', async () => { @@ -536,6 +568,7 @@ describe('AgentExecutionOrchestratorService', () => { memory: { threadId: 'thread-1', resourceId: 'resource-1' }, projectId, telemetry: telemetryContext, + sandboxPrincipalHash: userPrincipalHash, }), ); @@ -573,6 +606,7 @@ describe('AgentExecutionOrchestratorService', () => { memory: { threadId: 'thread-1', resourceId: 'resource-1' }, projectId, telemetry: telemetryContext, + sandboxPrincipalHash: userPrincipalHash, }), ), ).rejects.toThrow('reader failed while consuming stream'); @@ -609,6 +643,7 @@ describe('AgentExecutionOrchestratorService', () => { memory: { threadId: 'thread-1', resourceId: 'resource-1' }, projectId, telemetry: telemetryContext, + sandboxPrincipalHash: userPrincipalHash, abortSignal: abortController.signal, onExecutionRecorded: vi.fn(), }); @@ -727,6 +762,100 @@ describe('AgentExecutionOrchestratorService', () => { ); }); + it('reconstructs a resumed runtime from the persisted sandbox scope', async () => { + const { service, checkpointStorage, runtimeCacheService } = makeService(true); + const runtime = makeRuntime(); + checkpointStorage.getStatus.mockResolvedValue({ + status: 'active', + checkpoint: { + persistence: { + threadId: 'thread-1', + resourceId: 'platform-user-1', + hostMetadata: encodeAgentSandboxHostMetadata({ + projectId, + principalHash: integrationPrincipalHash, + }), + }, + }, + } as never); + runtimeCacheService.getRuntime.mockResolvedValue(runtime); + + await collect( + service.resumeForChat({ + agentId, + projectId, + runId: 'run-1', + toolCallId: 'tc-1', + resumeData: { value: 'yes' }, + integrationType: 'slack', + }), + ); + + expect(runtimeCacheService.getRuntime).toHaveBeenCalledWith( + expect.objectContaining({ sandboxPrincipalHash: integrationPrincipalHash }), + ); + }); + + it('rejects a draft resume when the checkpoint principal differs from the caller', async () => { + const { service, checkpointStorage, runtimeCacheService } = makeService(true); + checkpointStorage.getStatus.mockResolvedValue({ + status: 'active', + checkpoint: makeCheckpoint( + {}, + { + threadId: 'thread-1', + resourceId: 'draft-chat:user-2', + hostMetadata: encodeAgentSandboxHostMetadata({ + projectId, + principalHash: hashAgentSandboxPrincipal({ + type: 'n8n-user', + userId: 'user-2', + }), + }), + }, + ), + }); + runtimeCacheService.getRuntime.mockResolvedValue(makeRuntime()); + + await expect( + collect( + service.resumeForChat({ + agentId, + projectId, + runId: 'run-1', + toolCallId: 'tc-1', + resumeData: { value: 'yes' }, + user, + usePublishedVersion: false, + integrationType: N8N_CHAT_INTEGRATION_TYPE, + }), + ), + ).rejects.toThrow('unavailable'); + expect(runtimeCacheService.getRuntime).not.toHaveBeenCalled(); + }); + + it('rejects an old checkpoint without sandbox scope when workspaces are enabled', async () => { + const { service, checkpointStorage, runtimeCacheService } = makeService(true); + checkpointStorage.getStatus.mockResolvedValue({ + status: 'active', + checkpoint: { persistence: { threadId: 'thread-1', resourceId: 'platform-user-1' } }, + } as never); + + await expect( + collect( + service.resumeForChat({ + agentId, + projectId, + runId: 'run-1', + toolCallId: 'tc-1', + resumeData: { value: 'yes' }, + integrationType: 'slack', + }), + ), + ).rejects.toThrow('unavailable'); + expect(runtimeCacheService.getRuntime).not.toHaveBeenCalled(); + }); + it('persists an aborted resumed stream as cancelled without discarding partial output', async () => { const { service, checkpointStorage, runtimeCacheService, executionService } = makeService(); const abortController = new AbortController(); @@ -959,6 +1088,7 @@ describe('AgentExecutionOrchestratorService', () => { memory: { threadId: 'thread-1', resourceId: 'resource-1' }, projectId, telemetry: telemetryContext, + sandboxPrincipalHash: userPrincipalHash, }), ); expect(runtime.agent.stream).toHaveBeenCalledWith( diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts index b3bc30fbe82..3a78ffc11ab 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts @@ -8,7 +8,6 @@ import type { Telemetry } from '@/telemetry'; import type { AgentChatAttachmentService } from '../agent-chat-attachment.service'; import { AgentExecutionService, type RecordMessageParams } from '../agent-execution.service'; import type { AgentExecutionUpdateBroadcaster } from '../agent-execution-update-broadcaster'; -import type { AgentWorkspaceService } from '../agent-workspace.service'; import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity'; import type { AgentExecution } from '../entities/agent-execution.entity'; import type { MessageRecord, TimelineEvent } from '../execution-recorder'; @@ -67,7 +66,6 @@ describe('AgentExecutionService', () => { let errorReporter: Mocked; let agentChatAttachmentService: Mocked; let executionUpdateBroadcaster: Mocked; - let agentWorkspaceService: Mocked; beforeEach(() => { vi.clearAllMocks(); @@ -85,8 +83,6 @@ describe('AgentExecutionService', () => { errorReporter = mock(); agentChatAttachmentService = mock(); executionUpdateBroadcaster = mock(); - agentWorkspaceService = mock(); - agentWorkspaceService.cleanupThreadWorkspace.mockResolvedValue(); service = new AgentExecutionService( mockLogger(), @@ -99,7 +95,6 @@ describe('AgentExecutionService', () => { storageConfig, errorReporter, executionUpdateBroadcaster, - agentWorkspaceService, ); }); @@ -286,7 +281,6 @@ describe('AgentExecutionService', () => { storageConfig, errorReporter, executionUpdateBroadcaster, - agentWorkspaceService, ); const record = makeMessageRecord({ @@ -351,7 +345,6 @@ describe('AgentExecutionService', () => { storageConfig, errorReporter, executionUpdateBroadcaster, - agentWorkspaceService, ); const record = makeMessageRecord({ @@ -765,7 +758,6 @@ describe('AgentExecutionService', () => { storageConfig, errorReporter, executionUpdateBroadcaster, - agentWorkspaceService, ); const partial = [{ type: 'text', content: 'Partial', timestamp: 1, endTime: 2 }] as const; agentExecutionRepository.updateIfRunning.mockResolvedValue(true); @@ -926,7 +918,7 @@ describe('AgentExecutionService', () => { }); describe('deleteThread', () => { - it('performs workspace cleanup when deleting an execution thread', async () => { + it('deletes thread memory, attachments, and the execution thread', async () => { agentExecutionThreadRepository.findOneBy.mockResolvedValue({ id: 'thread-1', agentId: 'agent-1', @@ -947,11 +939,6 @@ describe('AgentExecutionService', () => { expect(agentChatAttachmentService.deleteByThread).toHaveBeenCalledWith('thread-1', { projectId: 'project-1', }); - expect(agentWorkspaceService.cleanupThreadWorkspace).toHaveBeenCalledWith( - 'project-1', - 'agent-1', - 'thread-1', - ); expect(agentExecutionThreadRepository.delete).toHaveBeenCalledWith({ id: 'thread-1' }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-knowledge-mirror.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-knowledge-mirror.service.test.ts index 77d4589165b..75431eec909 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-knowledge-mirror.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-knowledge-mirror.service.test.ts @@ -93,7 +93,7 @@ function makeRuntimeService( runtime: AgentSandboxRuntime, ): ReturnType> { const service = mock(); - service.acquireSandbox.mockResolvedValue(runtime); + service.acquireKnowledgeSandbox.mockResolvedValue(runtime); service.executeSandboxCommand.mockImplementation( async (sandbox, command, timeout) => await (sandbox.executeCommand?.(command, [], { timeout }) ?? @@ -206,6 +206,19 @@ describe('AgentKnowledgeMirrorService', () => { }); }); + it('returns an empty search without acquiring a sandbox when no files exist', async () => { + const agentFileRepository = mock(); + agentFileRepository.findByAgentId.mockResolvedValue([]); + const agentRepository = mock(); + agentRepository.existsBy.mockResolvedValue(true); + const service = makeService({ runtimeService, agentFileRepository, agentRepository }); + + const result = await service.searchKnowledge(projectId, agentId, { pattern: 'anything' }); + + expect(result).toEqual(expect.objectContaining({ matches: [], hasMore: false })); + expect(runtimeService.acquireKnowledgeSandbox).not.toHaveBeenCalled(); + }); + describe('mirror sync', () => { function isManifestReadCommand(command: string): boolean { return command.startsWith('cat ') && command.includes('/manifest'); @@ -259,6 +272,8 @@ describe('AgentKnowledgeMirrorService', () => { let commands = sandbox.executeCommand.mock.calls.map(([command]) => command); expect(commands.filter(isManifestReadCommand)).toHaveLength(1); expect(commands.filter(isMirrorSyncCommand)).toHaveLength(1); + expect(runtimeService.acquireKnowledgeSandbox).toHaveBeenCalledWith(projectId, agentId); + expect(runtimeService.acquireWorkspaceSandbox).not.toHaveBeenCalled(); expect(agentKnowledgeFileStore.readAsBuffer).toHaveBeenCalledTimes(2); const stagingDir = filesystem.mkdir.mock.calls[0][0]; expect(stagingDir).toContain(`${knowledgePaths.stagingDir}/`); @@ -299,6 +314,19 @@ describe('AgentKnowledgeMirrorService', () => { expect(syncCommands[0]).toContain('file-1-replacement\tdoc1.txt'); }); + it('reads mirrored knowledge through the knowledge sandbox', async () => { + sandbox.executeCommand.mockImplementation(async (command) => + makeCommandResult(isManifestReadCommand(command) ? 'file-id\tfile.txt\n' : '0\t1\thello\n'), + ); + const agentFileRepository = mock(); + agentFileRepository.findByAgentId.mockResolvedValue([makeAgentFile()]); + const service = makeMirrorService({ fileRepository: agentFileRepository }); + + const result = await service.readKnowledge(projectId, agentId, { file: 'file.txt' }); + + expect(result.ranges[0].text).toBe('1|hello'); + }); + it('runs a queued mirror sync with a newer file snapshot after an in-flight sync', async () => { let releaseFirstManifestRead!: () => void; const firstManifestRead = new Promise((resolve) => { @@ -357,10 +385,10 @@ describe('AgentKnowledgeMirrorService', () => { }); it.each([ - { provider: 'daytona', deterministicId: 'agent-instance-1-project-1-agent-1' }, + { provider: 'daytona', deterministicId: 'agent-kb-a54b9053-9f50-51e5-b971-e02942ff7b6b' }, { provider: 'n8n-sandbox', - deterministicId: 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0', + deterministicId: 'a54b9053-9f50-51e5-b971-e02942ff7b6b', }, ] satisfies Array<{ provider: SandboxProvider; deterministicId: string }>)( 'resyncs the mirror when a $provider sandbox is recreated under the same ID', @@ -369,7 +397,7 @@ describe('AgentKnowledgeMirrorService', () => { const replacementSandbox = makeSandbox(provider, deterministicId); const staleFilesystem = mock(); const replacementFilesystem = mock(); - runtimeService.acquireSandbox + runtimeService.acquireKnowledgeSandbox .mockResolvedValueOnce(makeRuntime(staleSandbox, staleFilesystem)) .mockResolvedValueOnce(makeRuntime(replacementSandbox, replacementFilesystem)); const agentFileRepository = mock(); diff --git a/packages/cli/src/modules/agents/__tests__/agent-knowledge.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-knowledge.service.test.ts index 28af6ddc7f1..6a6e092102e 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-knowledge.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-knowledge.service.test.ts @@ -360,7 +360,7 @@ describe('AgentKnowledgeService', () => { expect(agentKnowledgeFileStore.write).not.toHaveBeenCalled(); }); - it('deletes the DB row and its blob', async () => { + it('deletes the final file, its blob, and the knowledge sandbox', async () => { agentRepository.findByIdAndProjectId.mockResolvedValue({ id: agentId, projectId } as never); await agentFileRepository.save(makeAgentFile({ id: 'file-1', storedAt: 'fs' })); @@ -370,6 +370,11 @@ describe('AgentKnowledgeService', () => { expect(agentKnowledgeFileStore.delete).toHaveBeenCalledWith([ { storedAt: 'fs', storageKey: `agents/${agentId}/knowledge-files/file-1/content` }, ]); + expect(agentSandboxRuntimeService.destroyKnowledgeSandbox).toHaveBeenCalledWith( + projectId, + agentId, + ); + expect(agentKnowledgeMirrorService.prewarmMirrorInBackground).not.toHaveBeenCalled(); }); it('logs blob deletion failures without restoring the DB row', async () => { @@ -407,15 +412,27 @@ describe('AgentKnowledgeService', () => { ]); }); - it('delegates warmup to the sandbox service for an unpublished agent', async () => { + it('warms the knowledge sandbox for an unpublished agent', async () => { agentRepository.findByIdAndProjectId.mockResolvedValue({ id: agentId, projectId, activeVersionId: null, } as never); + await agentFileRepository.save(makeAgentFile()); - await expect(service.warmSandbox(agentId, projectId)).resolves.toBeUndefined(); - expect(agentSandboxRuntimeService.warmSandbox).toHaveBeenCalledWith(projectId, agentId); + await expect(service.warmKnowledgeSandbox(agentId, projectId)).resolves.toBeUndefined(); + expect(agentSandboxRuntimeService.warmKnowledgeSandbox).toHaveBeenCalledWith( + projectId, + agentId, + ); + }); + + it('does not warm the knowledge sandbox without files', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue({ id: agentId, projectId } as never); + + await expect(service.warmKnowledgeSandbox(agentId, projectId)).resolves.toBeUndefined(); + + expect(agentSandboxRuntimeService.warmKnowledgeSandbox).not.toHaveBeenCalled(); }); it('pre-warms the mirror after a successful upload', async () => { @@ -443,13 +460,14 @@ describe('AgentKnowledgeService', () => { ); }); - it('pre-warms the mirror after a file deletion', async () => { + it('pre-warms the mirror when files remain after a deletion', async () => { agentRepository.findByIdAndProjectId.mockResolvedValue({ id: agentId, projectId, activeVersionId: 'version-1', } as never); await agentFileRepository.save(makeAgentFile({ id: 'file-1' })); + await agentFileRepository.save(makeAgentFile({ id: 'file-2', fileName: 'second.txt' })); await service.deleteFile(agentId, projectId, 'file-1'); @@ -457,5 +475,6 @@ describe('AgentKnowledgeService', () => { projectId, agentId, ); + expect(agentSandboxRuntimeService.destroyKnowledgeSandbox).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts index 96b7da92911..371b0a417b2 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts @@ -11,6 +11,8 @@ import type { Publisher } from '@/scaling/pubsub/publisher.service'; import type { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service'; import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; +import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal'; +import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service'; import type { Agent } from '../entities/agent.entity'; import type { AgentRepository } from '../repositories/agent.repository'; import type { ToolRegistry } from '../tool-registry'; @@ -41,11 +43,17 @@ function makeRuntime() { }; } -function makeService({ multiMain = false }: { multiMain?: boolean } = {}) { +function makeService({ + multiMain = false, + sandboxEnabled = false, +}: { multiMain?: boolean; sandboxEnabled?: boolean } = {}) { const agentRepository = mock(); const publisher = mock(); const reconstructionService = mock(); const credentialsService = mock(); + const sandboxRuntimeService = mock({ + isEnabled: () => sandboxEnabled, + }); const globalConfig = { multiMainSetup: { enabled: multiMain } } as GlobalConfig; publisher.publishCommand.mockResolvedValue(); @@ -57,6 +65,7 @@ function makeService({ multiMain = false }: { multiMain?: boolean } = {}) { globalConfig, reconstructionService, credentialsService, + sandboxRuntimeService, ); return { service, agentRepository, publisher, reconstructionService }; @@ -94,9 +103,37 @@ describe('AgentRuntimeCacheService', () => { undefined, undefined, 'manual', + undefined, ); }); + it('defers closing an expired runtime until its active lease is released', async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const { service, agentRepository, reconstructionService } = makeService(); + const expiredRuntime = makeRuntime(); + const freshRuntime = makeRuntime(); + + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + reconstructionService.reconstructFromAgentEntity + .mockResolvedValueOnce(expiredRuntime) + .mockResolvedValueOnce(freshRuntime); + + const leasedRuntime = await service.getRuntime({ agentId, projectId }); + vi.setSystemTime(Date.now() + 30 * 60 * 1000 + 1); + const result = await service.getRuntime({ agentId, projectId }); + + expect(expiredRuntime.agent.close).not.toHaveBeenCalled(); + expect(result.agent).toBe(freshRuntime.agent); + + service.releaseRuntimeLease(leasedRuntime.agent); + expect(expiredRuntime.agent.close).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + it('keeps draft runtimes separate by integration type', async () => { const { service, agentRepository, reconstructionService } = makeService(); const agent = makeAgent(); @@ -127,6 +164,7 @@ describe('AgentRuntimeCacheService', () => { undefined, undefined, 'manual', + undefined, ); }); @@ -160,6 +198,7 @@ describe('AgentRuntimeCacheService', () => { userA, undefined, 'manual', + undefined, ); expect(reconstructionService.reconstructFromAgentEntity).toHaveBeenNthCalledWith( 2, @@ -170,6 +209,52 @@ describe('AgentRuntimeCacheService', () => { userB, undefined, 'manual', + undefined, + ); + }); + + it('reuses an attached runtime for the same principal and isolates different principals', async () => { + const { service, agentRepository, reconstructionService } = makeService({ + sandboxEnabled: true, + }); + const agent = makeAgent(); + const firstRuntime = makeRuntime(); + const secondRuntime = makeRuntime(); + const firstPrincipal = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-a' }); + const secondPrincipal = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-b' }); + + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + reconstructionService.reconstructFromAgentEntity + .mockResolvedValueOnce(firstRuntime) + .mockResolvedValueOnce(secondRuntime); + + const first = await service.getRuntime({ + agentId, + projectId, + sandboxPrincipalHash: firstPrincipal, + }); + const repeated = await service.getRuntime({ + agentId, + projectId, + sandboxPrincipalHash: firstPrincipal, + }); + const second = await service.getRuntime({ + agentId, + projectId, + sandboxPrincipalHash: secondPrincipal, + }); + + expect(repeated).toBe(first); + expect(second).not.toBe(first); + expect( + reconstructionService.reconstructFromAgentEntity.mock.calls.map((call) => call[7]), + ).toEqual([firstPrincipal, secondPrincipal]); + }); + + it('requires a principal when sandbox workspaces are enabled', async () => { + const enabled = makeService({ sandboxEnabled: true }); + await expect(enabled.service.getRuntime({ agentId, projectId })).rejects.toThrow( + 'workspace scope is missing', ); }); @@ -296,6 +381,7 @@ describe('AgentRuntimeCacheService', () => { undefined, undefined, 'integrated', + undefined, ); }); @@ -322,6 +408,7 @@ describe('AgentRuntimeCacheService', () => { agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); reconstructionService.reconstructFromAgentEntity.mockResolvedValue(runtime); await service.getRuntime({ agentId, projectId }); + service.releaseRuntimeLease(runtime.agent); service.clearRuntimes(agentId); diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts index 133748d2e64..48d896cea73 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts @@ -235,6 +235,9 @@ describe('AgentRuntimeReconstructionService integration tools', () => { const projectRelationRepository = mock(); const agentRuntimeReconstructionService = mock(); const chatIntegrationRegistry = mock(); + const agentSandboxRuntimeService = mock({ + isEnabled: () => false, + }); runtimeCacheService = new AgentRuntimeCacheService( logger, @@ -243,6 +246,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => { globalConfig, agentRuntimeReconstructionService, credentialsService, + agentSandboxRuntimeService, ); Container.set(AgentRuntimeCacheService, runtimeCacheService); const modificationTelemetry = mock(); @@ -275,6 +279,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => { mock(), mock(), mock(), + agentSandboxRuntimeService, ); agentIntegrationPersistenceService = new AgentIntegrationPersistenceService( agentRepository, @@ -310,11 +315,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => { mock(), mock(), ); - agentTestChatService = new AgentTestChatService( - n8nMemory, - mock(), - mock(), - ); + agentTestChatService = new AgentTestChatService(n8nMemory, mock()); agentsService = new AgentsService( logger, agentRepository, diff --git a/packages/cli/src/modules/agents/__tests__/agent-sandbox-principal.test.ts b/packages/cli/src/modules/agents/__tests__/agent-sandbox-principal.test.ts new file mode 100644 index 00000000000..5fcdd210110 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-sandbox-principal.test.ts @@ -0,0 +1,53 @@ +import { type AgentSandboxPrincipal, hashAgentSandboxPrincipal } from '../agent-sandbox-principal'; + +describe('hashAgentSandboxPrincipal', () => { + it('produces stable hashes for every principal kind', () => { + const cases: Array<[AgentSandboxPrincipal, string]> = [ + [{ type: 'n8n-user', userId: 'user/123:raw' }, 'Gt4H3q6RzhJe9cTxQm6be0AdIZQlifuy3w9OPSykmYo'], + [ + { + type: 'integration-user', + connectionId: 'connection/raw:id', + platform: 'slack', + platformUserId: 'U/raw:123', + }, + '7Zqe0BHA0mDnH7Ci9p-Zy7W2uVPQhf_4h01KkKpHnlU', + ], + [ + { type: 'workflow-session', workflowId: 'workflow|one', sessionId: 'session|two' }, + 'U1yUIOzcWHMbn3uOWB_UKJCbW1yZ_aqsOZJl_o3omc0', + ], + [ + { + type: 'workflow-execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + '5PiyOW90m1b3G7sUBU89WQEDdW7jT8E2rnaqMtRilc4', + ], + [{ type: 'scheduled-task', taskId: 'task-1' }, 'zNDZ3KUSonoPGuBjyiJ1huc8pr8psw4zGbu7t5vlJFA'], + ]; + + for (const [principal, expectedHash] of cases) { + expect(hashAgentSandboxPrincipal(principal)).toBe(expectedHash); + } + }); + + it('does not collide when delimiter characters move between fields', () => { + const first = hashAgentSandboxPrincipal({ + type: 'integration-user', + connectionId: 'a', + platform: 'b|c', + platformUserId: 'd', + }); + const second = hashAgentSandboxPrincipal({ + type: 'integration-user', + connectionId: 'a|b', + platform: 'c', + platformUserId: 'd', + }); + + expect(first).toBe('f1q_0RPATJAPJMFC_AVuRad38wcVRFv0MfTsU_k1K90'); + expect(second).toBe('U1sl-BmMZxJ_coVScXBuMDpW3mI4oQZOUbi2-xYbn0c'); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-sandbox-runtime.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-sandbox-runtime.service.test.ts index 83e35aa6134..b22c00672ce 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-sandbox-runtime.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-sandbox-runtime.service.test.ts @@ -14,10 +14,8 @@ import type { AiService } from '../../../services/ai.service'; import type { SandboxSettingsService } from '../../../services/sandbox-settings.service'; import type { Agent } from '../entities/agent.entity'; -import { - AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX, - AgentSandboxRuntimeService, -} from '../agent-sandbox-runtime.service'; +import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal'; +import { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service'; import type { AgentRepository } from '../repositories/agent.repository'; const { createSandboxMock, createFilesystemMock } = vi.hoisted(() => ({ @@ -34,14 +32,23 @@ vi.mock('@n8n/agents/sandbox', async (importOriginal) => ({ const instanceId = 'instance-1'; const projectId = 'project-1'; const agentId = 'agent-1'; +const principalHash = hashAgentSandboxPrincipal({ + type: 'integration-user', + connectionId: 'connection/raw:id', + platform: 'slack', + platformUserId: 'U/raw:123', +}); +const otherPrincipalHash = hashAgentSandboxPrincipal({ + type: 'n8n-user', + userId: 'user/123:raw', +}); +const workspaceSandboxId = 'ed4a5e7b-acf0-5f78-b2b3-8fc4182d3c0c'; +const otherWorkspaceSandboxId = '4197eecc-3092-54b8-9196-a4fecccea156'; +const knowledgeSandboxId = 'a54b9053-9f50-51e5-b971-e02942ff7b6b'; type TestWorkspaceSandbox = WorkspaceSandbox & Required>; -function buildExpectedSandboxName(): string { - return `${AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX}${instanceId}-${projectId}-${agentId}`.toLowerCase(); -} - function makeAiService(overrides: Partial = {}): AiService { const aiService = mock(); aiService.isProxyEnabled.mockReturnValue(false); @@ -145,7 +152,7 @@ describe('AgentSandboxRuntimeService', () => { createFilesystemMock.mockReturnValue(mock()); }); - it('creates and starts a deterministic direct-mode Daytona sandbox', async () => { + it('creates and starts the deterministic direct-mode Daytona knowledge sandbox', async () => { const aiService = makeAiService(); const sandboxSettingsService = makeSandboxSettingsService(); const service = makeService({ @@ -156,9 +163,9 @@ describe('AgentSandboxRuntimeService', () => { aiService, sandboxSettingsService, }); - const expectedName = buildExpectedSandboxName(); + const expectedName = `agent-kb-${knowledgeSandboxId}`; - await service.warmSandbox(projectId, agentId); + await service.warmKnowledgeSandbox(projectId, agentId); expect(aiService.getClient).not.toHaveBeenCalled(); expect(sandboxSettingsService.resolveDaytonaConfig).toHaveBeenCalled(); @@ -174,21 +181,26 @@ describe('AgentSandboxRuntimeService', () => { 'n8n-agents-knowledgebase': 'true', 'n8n-project-id': projectId, 'n8n-agent-id': agentId, + 'n8n-agent-sandbox-kind': 'knowledge', }, timeout: 300_000, createTimeoutSeconds: 300, image: 'daytonaio/sandbox:0.5.0', snapshot: 'n8n/agent-knowledge:1.2.3', ephemeral: true, - autoStopInterval: 5, + autoStopInterval: 15, + autoArchiveInterval: 60, }), expect.anything(), ); + expect( + (createSandboxMock.mock.calls[0][0] as DaytonaSandboxConfig).autoDeleteInterval, + ).toBeUndefined(); expect(sandbox._start).toHaveBeenCalled(); expect(createFilesystemMock).toHaveBeenCalledWith(sandbox); }); - it('single-flights concurrent acquisition for the same project and agent', async () => { + it('single-flights concurrent knowledge acquisition for the same project and agent', async () => { let resolveCreation: (value: WorkspaceSandbox) => void; createSandboxMock.mockReturnValue( new Promise((resolve) => { @@ -197,29 +209,91 @@ describe('AgentSandboxRuntimeService', () => { ); const service = makeService(); - const first = service.warmSandbox(projectId, agentId); - const second = service.warmSandbox(projectId, agentId); + const first = service.warmKnowledgeSandbox(projectId, agentId); + const second = service.warmKnowledgeSandbox(projectId, agentId); await vi.waitFor(() => expect(createSandboxMock).toHaveBeenCalledTimes(1)); resolveCreation!(sandbox); await Promise.all([first, second]); }); - it('uses a stable UUID for the n8n sandbox', async () => { - const expectedId = 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0'; + it('uses deterministic, isolated Daytona identities and labels', async () => { + const service = makeService(); + + await service.acquireWorkspaceSandbox(projectId, agentId, principalHash); + await service.acquireWorkspaceSandbox(projectId, agentId, otherPrincipalHash); + await service.acquireKnowledgeSandbox(projectId, agentId); + + const configs = createSandboxMock.mock.calls.map(([config]) => config as DaytonaSandboxConfig); + expect(configs.map(({ id, name }) => ({ id, name }))).toEqual([ + { + id: `agent-ws-${workspaceSandboxId}`, + name: `agent-ws-${workspaceSandboxId}`, + }, + { + id: `agent-ws-${otherWorkspaceSandboxId}`, + name: `agent-ws-${otherWorkspaceSandboxId}`, + }, + { + id: `agent-kb-${knowledgeSandboxId}`, + name: `agent-kb-${knowledgeSandboxId}`, + }, + ]); + expect(configs[0].labels).toEqual({ + 'n8n-project-id': projectId, + 'n8n-agent-id': agentId, + 'n8n-agent-sandbox-kind': 'workspace', + 'n8n-agent-principal-hash': principalHash, + }); + expect( + configs.map(({ ephemeral, autoStopInterval, autoArchiveInterval, autoDeleteInterval }) => [ + ephemeral, + autoStopInterval, + autoArchiveInterval, + autoDeleteInterval, + ]), + ).toEqual([ + [true, 5, undefined, undefined], + [true, 5, undefined, undefined], + [false, 15, 60, 10_080], + ]); + }); + + it('single-flights the same workspace identity without coalescing different principals', async () => { + const resolveCreations: Array<(value: WorkspaceSandbox) => void> = []; + createSandboxMock.mockImplementation( + async () => + await new Promise((resolve) => { + resolveCreations.push(resolve); + }), + ); + const service = makeService(); + + const first = service.acquireWorkspaceSandbox(projectId, agentId, principalHash); + const duplicate = service.acquireWorkspaceSandbox(projectId, agentId, principalHash); + const distinct = service.acquireWorkspaceSandbox(projectId, agentId, otherPrincipalHash); + await vi.waitFor(() => expect(createSandboxMock).toHaveBeenCalledTimes(2)); + for (const resolveCreation of resolveCreations) resolveCreation(sandbox); + await Promise.all([first, duplicate, distinct]); + }); + + it('uses distinct deterministic workspace and knowledge IDs for the n8n sandbox', async () => { const service = makeService({ sandboxSettingsService: makeSandboxSettingsService('n8n-sandbox'), }); - await service.warmSandbox(projectId, agentId); + await service.acquireWorkspaceSandbox(projectId, agentId, principalHash); + await service.acquireKnowledgeSandbox(projectId, agentId); - expect(createSandboxMock).toHaveBeenCalledWith( + expect(createSandboxMock.mock.calls.map(([config]) => config.id)).toEqual([ + workspaceSandboxId, + knowledgeSandboxId, + ]); + expect(createSandboxMock.mock.calls[1][0]).toEqual( expect.objectContaining({ provider: 'n8n-sandbox', - id: expectedId, serviceUrl: 'https://sandbox.example', apiKey: 'sandbox-key', }), - expect.anything(), ); }); @@ -228,7 +302,7 @@ describe('AgentSandboxRuntimeService', () => { settingsService.resolveN8nSandboxConfig.mockResolvedValue({}); const service = makeService({ sandboxSettingsService: settingsService }); - await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow( + await expect(service.warmKnowledgeSandbox(projectId, agentId)).rejects.toThrow( /N8N_SANDBOX_SERVICE_URL/, ); expect(createSandboxMock).not.toHaveBeenCalled(); @@ -249,7 +323,7 @@ describe('AgentSandboxRuntimeService', () => { aiService, }); - await service.warmSandbox(projectId, agentId); + await service.warmKnowledgeSandbox(projectId, agentId); const config = createSandboxMock.mock.calls[0][0] as DaytonaSandboxConfig; expect(config.daytonaApiUrl).toBe('https://sandbox-proxy.example'); @@ -270,7 +344,7 @@ describe('AgentSandboxRuntimeService', () => { aiService: makeProxyAiService(), }); - await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow( + await expect(service.warmKnowledgeSandbox(projectId, agentId)).rejects.toThrow( /requires a snapshot.*N8N_AGENTS_AI_SANDBOX_SNAPSHOT/s, ); expect(createSandboxMock).not.toHaveBeenCalled(); @@ -283,41 +357,36 @@ describe('AgentSandboxRuntimeService', () => { aiService: makeProxyAiService(), }); - await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow('snapshot missing'); + await expect(service.warmKnowledgeSandbox(projectId, agentId)).rejects.toThrow( + 'snapshot missing', + ); const config = createSandboxMock.mock.calls[0][0] as DaytonaSandboxConfig; expect(config.snapshot).toBe('n8n/agent-knowledge:missing'); expect(config.image).toBeUndefined(); }); - it('best-effort destroys both provider sandboxes by their deterministic identities', async () => { - const daytonaSandbox = makeSandbox('daytona', buildExpectedSandboxName()); - daytonaSandbox.destroy.mockRejectedValue(new Error('remote unavailable')); - const n8nSandbox = makeSandbox('n8n-sandbox', 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0'); - createSandboxMock.mockImplementation(async (config) => - config.provider === 'daytona' ? daytonaSandbox : n8nSandbox, - ); - const service = makeService({ configOverrides: { sandboxEnabled: false } }); + it('best-effort destroys workspace and knowledge sandboxes by only their exact identities', async () => { + const destroyedIds: string[] = []; + createSandboxMock.mockImplementation(async (config) => { + const target = makeSandbox(config.provider, config.id); + target.destroy.mockImplementation(async () => { + destroyedIds.push(config.id); + if (config.id === `agent-kb-${knowledgeSandboxId}`) { + throw new Error('remote unavailable'); + } + }); + return target; + }); + const service = makeService(); - await service.destroySandbox(projectId, agentId); + await service.destroyWorkspaceSandbox(projectId, agentId, principalHash); + await service.destroyKnowledgeSandbox(projectId, agentId); - expect(createSandboxMock).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - provider: 'daytona', - id: buildExpectedSandboxName(), - name: buildExpectedSandboxName(), - }), - expect.anything(), - ); - expect(createSandboxMock).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - provider: 'n8n-sandbox', - id: 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0', - }), - expect.anything(), - ); - expect(daytonaSandbox.destroy).toHaveBeenCalled(); - expect(n8nSandbox.destroy).toHaveBeenCalled(); + expect(destroyedIds).toEqual([ + `agent-ws-${workspaceSandboxId}`, + workspaceSandboxId, + `agent-kb-${knowledgeSandboxId}`, + knowledgeSandboxId, + ]); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-sandbox.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-sandbox.controller.test.ts index 28a56db34d9..0ca2e5eb5bf 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-sandbox.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-sandbox.controller.test.ts @@ -26,7 +26,7 @@ describe('AgentSandboxController', () => { }); expect(res.status).toHaveBeenCalledWith(202); - expect(agentKnowledgeService.warmSandbox).toHaveBeenCalledWith('agent-1', 'project-1'); + expect(agentKnowledgeService.warmKnowledgeSandbox).toHaveBeenCalledWith('agent-1', 'project-1'); }); it('rejects warmup when the knowledge base is disabled', async () => { @@ -45,6 +45,6 @@ describe('AgentSandboxController', () => { 'agent-1', ), ).rejects.toThrow('Agent knowledge base is not enabled'); - expect(agentKnowledgeService.warmSandbox).not.toHaveBeenCalled(); + expect(agentKnowledgeService.warmKnowledgeSandbox).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-test-chat.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-test-chat.service.test.ts index 8c7a91c9d2a..20e1257abb6 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-test-chat.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-test-chat.service.test.ts @@ -2,7 +2,6 @@ import { mock } from 'vitest-mock-extended'; import type { AgentChatAttachmentService } from '../agent-chat-attachment.service'; import { AgentTestChatService, chatThreadId } from '../agent-test-chat.service'; -import type { AgentWorkspaceService } from '../agent-workspace.service'; import type { N8nMemory } from '../integrations/n8n-memory'; const agentId = 'agent-1'; @@ -13,15 +12,13 @@ function makeService() { const n8nMemory = mock(); const memory = mock(); const attachmentService = mock(); - const workspaceService = mock(); n8nMemory.getImplementation.mockReturnValue(memory); return { - service: new AgentTestChatService(n8nMemory, attachmentService, workspaceService), + service: new AgentTestChatService(n8nMemory, attachmentService), n8nMemory, memory, attachmentService, - workspaceService, }; } @@ -42,16 +39,14 @@ describe('AgentTestChatService', () => { }); }); - it('performs workspace cleanup for one user thread without changing all-agent cleanup', async () => { - const { service, memory, workspaceService } = makeService(); + it('clears one user thread without changing all-agent cleanup', async () => { + const { service, memory, attachmentService } = makeService(); - await service.clearTestChatMessages('project-1', agentId, userId); + await service.clearTestChatMessages(agentId, userId); expect(memory.deleteThread).toHaveBeenCalledWith(`test-${agentId}:${userId}`); - expect(workspaceService.cleanupThreadWorkspace).toHaveBeenCalledWith( - 'project-1', + expect(attachmentService.deleteByThread).toHaveBeenCalledWith(`test-${agentId}:${userId}`, { agentId, - `test-${agentId}:${userId}`, - ); + }); await service.clearAllTestChatMessages(agentId); expect(memory.deleteThreadsByPrefix).toHaveBeenCalledWith(`test-${agentId}`); diff --git a/packages/cli/src/modules/agents/__tests__/agent-test-run.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-test-run.service.test.ts index e88bae81b2b..a9677e0150a 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-test-run.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-test-run.service.test.ts @@ -105,6 +105,7 @@ describe('AgentTestRunService', () => { expect.objectContaining({ agentId, projectId, + user, source: 'instance-ai', memory: { threadId: result.sessionId, diff --git a/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts index f83174cb15d..f82e47a07a3 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts @@ -14,6 +14,10 @@ import type { Telemetry } from '@/telemetry'; import type { AgentExecutionService } from '../agent-execution.service'; import type { AgentRunTracingService } from '../agent-run-tracing.service'; import type { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service'; +import { + encodeAgentSandboxHostMetadata, + hashAgentSandboxPrincipal, +} from '../agent-sandbox-principal'; import { AgentWorkflowExecutionService } from '../agent-workflow-execution.service'; import type { Agent } from '../entities/agent.entity'; import type { NodeToolAiGatewayService } from '../json-config/node-tool-ai-gateway.service'; @@ -242,16 +246,68 @@ describe('AgentWorkflowExecutionService', () => { ); }); + it('uses the execution-scoped workspace principal', async () => { + const { service, agentRepository, reconstructionService } = makeService(); + const runtime = makeRuntime(); + const principalHash = hashAgentSandboxPrincipal({ + type: 'workflow-execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }); + const sandboxScope = { principalHash }; + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + reconstructionService.reconstructFromAgentEntity.mockResolvedValue(runtime); + + await service.executeForWorkflow( + agentId, + 'hello', + 'execution-1', + 'wf:workflow-1:execution-1-0', + projectId, + undefined, + undefined, + undefined, + undefined, + sandboxScope, + ); + + expect(reconstructionService.reconstructFromAgentEntity.mock.calls[0][7]).toBe(principalHash); + expect(runtime.agent.stream).toHaveBeenCalledWith( + 'hello', + expect.objectContaining({ + persistence: expect.objectContaining({ + hostMetadata: encodeAgentSandboxHostMetadata({ projectId, principalHash }), + }), + }), + ); + }); + it('records workflow stream setup failures', async () => { const { service, agentRepository, reconstructionService, executionService } = makeService(); const runtime = makeRuntime(); + const principalHash = hashAgentSandboxPrincipal({ + type: 'workflow-execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }); runtime.agent.stream.mockRejectedValue(new Error('stream setup failed')); agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); reconstructionService.reconstructFromAgentEntity.mockResolvedValue(runtime); executionService.startExecutionRecording.mockResolvedValue('fallback-execution-1'); await expect( - service.executeForWorkflow(agentId, 'hello', 'execution-1', 'thread-1', projectId), + service.executeForWorkflow( + agentId, + 'hello', + 'execution-1', + 'thread-1', + projectId, + undefined, + undefined, + undefined, + undefined, + { principalHash }, + ), ).rejects.toThrow('stream setup failed'); expect(executionService.finalizeExecution).toHaveBeenCalledWith( diff --git a/packages/cli/src/modules/agents/__tests__/agent-workspace.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-workspace.service.test.ts index cddced17013..0ebc2f157f2 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-workspace.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-workspace.service.test.ts @@ -1,14 +1,21 @@ import type { WorkspaceFilesystem, WorkspaceSandbox } from '@n8n/agents/sandbox'; import type { Logger } from '@n8n/backend-common'; +import type { AgentsConfig } from '@n8n/config'; import { mock } from 'vitest-mock-extended'; +import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal'; import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service'; import { AgentWorkspaceService } from '../agent-workspace.service'; +import { + CHECKPOINT_RECONCILIATION_OVERFLOW, + type N8NCheckpointStorage, +} from '../integrations/n8n-checkpoint-storage'; const projectId = 'project-1'; const agentId = 'agent-1'; +const principalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-1' }); -function makeService(sandboxEnabled = true) { +function makeService() { const filesystem = mock(); const sandbox = mock({ id: 'sandbox-id', @@ -17,8 +24,11 @@ function makeService(sandboxEnabled = true) { status: 'running', }); const runtimeService = mock(); - runtimeService.isEnabled.mockReturnValue(sandboxEnabled); - runtimeService.acquireSandbox.mockResolvedValue({ + const checkpointStorage = mock(); + const agentsConfig = mock({ checkpointTtlSeconds: 60 }); + filesystem.readdir.mockResolvedValue([]); + checkpointStorage.getActiveRunIdsForSandbox.mockResolvedValue(new Set()); + runtimeService.acquireWorkspaceSandbox.mockResolvedValue({ provider: 'daytona', sandbox, filesystem, @@ -27,19 +37,31 @@ function makeService(sandboxEnabled = true) { }); return { - service: new AgentWorkspaceService(mock(), runtimeService), + service: new AgentWorkspaceService( + mock(), + runtimeService, + checkpointStorage, + agentsConfig, + ), filesystem, sandbox, runtimeService, + checkpointStorage, }; } describe('AgentWorkspaceService', () => { it('eagerly creates a scoped workspace with only the core workspace tools', async () => { - const { service, filesystem } = makeService(); + const { service, filesystem, runtimeService } = makeService(); - const workspace = await service.getAgentWorkspace(projectId, agentId); + const workspace = await service.getAgentWorkspace(projectId, agentId, principalHash); + expect(runtimeService.acquireWorkspaceSandbox).toHaveBeenCalledWith( + projectId, + agentId, + principalHash, + ); + expect(runtimeService.acquireKnowledgeSandbox).not.toHaveBeenCalled(); expect(filesystem.mkdir).toHaveBeenCalledWith('/home/daytona/workspace', { recursive: true, }); @@ -53,24 +75,34 @@ describe('AgentWorkspaceService', () => { ]); }); - it('removes only the hashed thread result directory without destroying the sandbox', async () => { - const { service, filesystem, sandbox } = makeService(); - - await service.cleanupThreadWorkspace(projectId, agentId, '../../knowledge-mirror'); - - expect(filesystem.rmdir).toHaveBeenCalledWith( - expect.stringMatching(/^\/home\/daytona\/workspace\/tool-results\/threads\/[A-Za-z0-9_-]+$/), - { recursive: true, force: true }, + it('returns the workspace without waiting for reconciliation and deduplicates concurrent sweeps', async () => { + const { service, checkpointStorage } = makeService(); + let resolveReconciliation!: (activeRunIds: Set) => void; + checkpointStorage.getActiveRunIdsForSandbox.mockReturnValue( + new Promise((resolve) => { + resolveReconciliation = resolve; + }), ); - expect(sandbox.destroy).not.toHaveBeenCalled(); + + const firstWorkspace = await service.getAgentWorkspace(projectId, agentId, principalHash); + const secondWorkspace = await service.getAgentWorkspace(projectId, agentId, principalHash); + + expect(firstWorkspace.filesystem?.basePath).toBe('/home/daytona/workspace'); + expect(secondWorkspace.filesystem?.basePath).toBe('/home/daytona/workspace'); + expect(checkpointStorage.getActiveRunIdsForSandbox).toHaveBeenCalledOnce(); + + resolveReconciliation(new Set()); + await Promise.resolve(); }); - it('does not reject when workspace cleanup fails', async () => { - const { service, runtimeService } = makeService(); - runtimeService.acquireSandbox.mockRejectedValue(new Error('sandbox unavailable')); + it('skips orphan reconciliation when checkpoint protection overflows', async () => { + const { service, filesystem, checkpointStorage } = makeService(); + checkpointStorage.getActiveRunIdsForSandbox.mockResolvedValue( + CHECKPOINT_RECONCILIATION_OVERFLOW, + ); - await expect( - service.cleanupThreadWorkspace(projectId, agentId, 'thread-1'), - ).resolves.toBeUndefined(); + await service.getAgentWorkspace(projectId, agentId, principalHash); + + expect(filesystem.readdir).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts index ef3c9e98b80..d7241070bdc 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts @@ -37,6 +37,7 @@ import type { WorkflowFinderService } from '@/workflows/workflow-finder.service' import type { AgentChatAttachmentService } from '../agent-chat-attachment.service'; import type { AgentKnowledgeMirrorService } from '../agent-knowledge-mirror.service'; import { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service'; +import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal'; import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service'; import type { AgentWorkspaceService } from '../agent-workspace.service'; import type { Agent } from '../entities/agent.entity'; @@ -258,6 +259,19 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — MCP w }); describe('AgentRuntimeReconstructionService — workspace attachment', () => { + const principalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-1' }); + const reconstructWithWorkspace = async (service: AgentRuntimeReconstructionService) => + await service.reconstructFromAgentEntity( + makeAgentEntity(), + mock(), + 'production', + undefined, + undefined, + undefined, + 'manual', + principalHash, + ); + beforeEach(() => { vi.clearAllMocks(); builtAgent.hasCheckpointStorage.mockReturnValue(true); @@ -278,13 +292,14 @@ describe('AgentRuntimeReconstructionService — workspace attachment', () => { agentWorkspaceService, }); - await service.reconstructFromAgentEntity( - makeAgentEntity(), - mock(), - 'production', - ); + await reconstructWithWorkspace(service); expect(builtAgent.workspace).toHaveBeenCalledWith(workspace); + expect(agentWorkspaceService.getAgentWorkspace).toHaveBeenCalledWith( + 'project-1', + 'agent-1', + principalHash, + ); expect(getInjectedToolNames()).not.toContain('find_file'); }); @@ -302,11 +317,7 @@ describe('AgentRuntimeReconstructionService — workspace attachment', () => { agentWorkspaceService, }); - await service.reconstructFromAgentEntity( - makeAgentEntity(), - mock(), - 'production', - ); + await reconstructWithWorkspace(service); expect(getInjectedToolNames()).toEqual( expect.arrayContaining(['find_file', 'search_text', 'read_file']), @@ -355,14 +366,26 @@ describe('AgentRuntimeReconstructionService — workspace attachment', () => { agentWorkspaceService, }); + await expect(reconstructWithWorkspace(service)).resolves.toEqual( + expect.objectContaining({ agent: builtAgent }), + ); + expect(builtAgent.workspace).not.toHaveBeenCalled(); + }); + + it('rejects a first-class runtime without a workspace principal', async () => { + const service = makeReconstructionService({ + agentSandboxRuntimeService: mock({ + isEnabled: () => true, + }), + }); + await expect( service.reconstructFromAgentEntity( makeAgentEntity(), mock(), 'production', ), - ).resolves.toEqual(expect.objectContaining({ agent: builtAgent })); - expect(builtAgent.workspace).not.toHaveBeenCalled(); + ).rejects.toThrow('workspace scope is missing'); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents.module.test.ts b/packages/cli/src/modules/agents/__tests__/agents.module.test.ts index b441340e355..21160debb34 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.module.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.module.test.ts @@ -1,10 +1,7 @@ -import type { Logger } from '@n8n/backend-common'; import { AgentsConfig } from '@n8n/config'; -import type { InstanceAiConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; -import type { InstanceCredentialBroker } from '@/credentials/instance-credential-broker'; import { AiService } from '@/services/ai.service'; import { SandboxSettingsService } from '@/services/sandbox-settings.service'; @@ -20,57 +17,21 @@ describe('AgentsModule', () => { describe('settings()', () => { it.each([ - { - agentsSandboxEnabled: true, - instanceAiSandboxEnabled: false, - proxyEnabled: false, - knowledgeBaseEnabled: true, - }, - { - agentsSandboxEnabled: false, - instanceAiSandboxEnabled: true, - proxyEnabled: false, - knowledgeBaseEnabled: true, - }, - { - agentsSandboxEnabled: false, - instanceAiSandboxEnabled: false, - proxyEnabled: true, - knowledgeBaseEnabled: false, - }, + { sandboxEnabled: true, proxyEnabled: false }, + { sandboxEnabled: false, proxyEnabled: true }, ])( - 'enables knowledge base=$knowledgeBaseEnabled for Agents sandbox=$agentsSandboxEnabled and Instance AI sandbox=$instanceAiSandboxEnabled', - async ({ - agentsSandboxEnabled, - instanceAiSandboxEnabled, - proxyEnabled, - knowledgeBaseEnabled, - }) => { - const agentsConfig = mock({ - modules: [], - sandboxEnabled: agentsSandboxEnabled, - }); - Container.set(AgentsConfig, agentsConfig); + 'keeps knowledge base ($sandboxEnabled) and proxy ($proxyEnabled) availability independent', + async ({ sandboxEnabled, proxyEnabled }) => { + Container.set(AgentsConfig, mock({ modules: [] })); Container.set( SandboxSettingsService, - new SandboxSettingsService( - { - agents: agentsConfig, - instanceAi: mock({ - sandboxEnabled: instanceAiSandboxEnabled, - sandboxProvider: 'n8n-sandbox', - }), - deployment: { type: 'default' }, - } as never, - mock(), - mock(), - ), + mock({ isAgentSandboxEnabled: () => sandboxEnabled }), ); Container.set(AiService, mock({ isProxyEnabled: () => proxyEnabled })); const settings = await module.settings(); - expect(settings.knowledgeBaseEnabled).toBe(knowledgeBaseEnabled); + expect(settings.knowledgeBaseEnabled).toBe(sandboxEnabled); expect(settings.proxyEnabled).toBe(proxyEnabled); }, ); diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index 93abf6aff6e..21ec6c3dc0a 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -341,7 +341,7 @@ describe('AgentsService', () => { ); expect(agentTaskService.requestReconcile).toHaveBeenCalledWith(agentId); expect(testChatService.clearAllTestChatMessages).toHaveBeenCalledWith(agentId); - expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(projectId, agentId); + expect(agentKnowledgeService.destroyKnowledgeSandbox).toHaveBeenCalledWith(projectId, agentId); expect(eventService.emit).toHaveBeenCalledWith('agent-deleted', { agentId, projectId }); }); @@ -375,7 +375,7 @@ describe('AgentsService', () => { await expect(service.delete(agentId, projectId)).resolves.toBe(true); expect(agentRepository.remove).toHaveBeenCalledWith(agent); - expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(projectId, agentId); + expect(agentKnowledgeService.destroyKnowledgeSandbox).toHaveBeenCalledWith(projectId, agentId); }); it('returns false when deleting a missing agent', async () => { diff --git a/packages/cli/src/modules/agents/agent-chat.controller.ts b/packages/cli/src/modules/agents/agent-chat.controller.ts index b7e840eddee..bb943bec993 100644 --- a/packages/cli/src/modules/agents/agent-chat.controller.ts +++ b/packages/cli/src/modules/agents/agent-chat.controller.ts @@ -368,7 +368,7 @@ export class AgentChatController { const { projectId, agentId } = req.params; const agent = await this.agentsService.findById(agentId, projectId); if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); - await this.agentTestChatService.clearTestChatMessages(projectId, agentId, req.user.id); + await this.agentTestChatService.clearTestChatMessages(agentId, req.user.id); return { ok: true }; } } diff --git a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts index a81998924d7..3d53e1c615c 100644 --- a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts +++ b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts @@ -24,6 +24,13 @@ import { } from './agent-execution.service'; import { AgentRunTracingService, modelIdFromSnapshot } from './agent-run-tracing.service'; import { AgentRuntimeCacheService } from './agent-runtime-cache.service'; +import { + decodeAgentSandboxHostMetadata, + encodeAgentSandboxHostMetadata, + hashAgentSandboxPrincipal, + type AgentSandboxPrincipalHash, +} from './agent-sandbox-principal'; +import { AgentSandboxRuntimeService } from './agent-sandbox-runtime.service'; import { ExecutionRecorder, type MessageRecord } from './execution-recorder'; import { IntegrationMessageContextService } from './integrations/integration-message-context.service'; import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage'; @@ -69,11 +76,11 @@ export interface ExecuteForChatPublishedConfig { memory: AgentMemoryScope; attachments?: StoredAttachmentRef[]; integrationType?: string; + sandboxPrincipalHash: AgentSandboxPrincipalHash; // No `user` field here: a published chat integration (Slack, Telegram, …) // run is triggered by an inbound platform event, not an interactive n8n - // session — there is no n8n `User` to attach. This path keeps the - // project-scoped trust boundary that existed before per-user tool - // gating; the admin who published the agent is the one who approved its + // session — there is no n8n `User` to attach. The admin who published the + // agent is the one who approved its // tools, and Layer A's node denylist (`EphemeralNodeExecutor`) still // applies regardless. } @@ -157,6 +164,7 @@ export interface StreamChatResponseConfig { /** Fired after the turn is persisted; used to attach `executionId` to SSE `done`. */ onExecutionRecorded?: (executionId: string) => void; abortSignal?: AbortSignal; + sandboxPrincipalHash: AgentSandboxPrincipalHash; } function getMaxIterationsChunks(): StreamChunk[] { @@ -235,6 +243,7 @@ export class AgentExecutionOrchestratorService { private readonly integrationMessageContextService: IntegrationMessageContextService, private readonly agentRunTracingService: AgentRunTracingService, private readonly externalHooks: ExternalHooks, + private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService, ) {} /** @@ -341,6 +350,20 @@ export class AgentExecutionOrchestratorService { ) { throw new UserError(`Checkpoint ${runId} does not belong to this chat`); } + const sandboxScope = decodeAgentSandboxHostMetadata(memoryScope.hostMetadata); + const sandboxPrincipalHash = sandboxScope?.principalHash; + if ( + this.agentSandboxRuntimeService.isEnabled() && + (!sandboxScope || + sandboxScope.projectId !== projectId || + !sandboxPrincipalHash || + (!usePublishedVersion && + (!user || + sandboxPrincipalHash !== + hashAgentSandboxPrincipal({ type: 'n8n-user', userId: user.id })))) + ) { + throw new UserError(`Checkpoint ${runId} is unavailable and cannot be resumed`); + } const threadId = memoryScope.threadId; @@ -357,18 +380,28 @@ export class AgentExecutionOrchestratorService { // `AgentChatController.chatResume`), and only then does the caller's // `user` actually reach the cache/reconstruction layer. user: usePublishedVersion ? undefined : user, + ...(sandboxPrincipalHash ? { sandboxPrincipalHash } : {}), }); const { agent: agentInstance, toolRegistry } = runtime; let executionId: string | undefined; - const recorder = this.createRecorder(toolRegistry, () => executionId, { - projectId, - agentId, - threadId, - }); - const startedAt = recorder.startedAt; - const runType: AgentRunTelemetryType = usePublishedVersion ? 'production' : 'test'; - let executionSource = source; + let recorder: ExecutionRecorder; + let startedAt: Date; + let runType: AgentRunTelemetryType; + let executionSource: string | undefined; + try { + recorder = this.createRecorder(toolRegistry, () => executionId, { + projectId, + agentId, + threadId, + }); + startedAt = recorder.startedAt; + runType = usePublishedVersion ? 'production' : 'test'; + executionSource = source; + } catch (error) { + this.runtimeCacheService.releaseRuntimeLease(agentInstance); + throw error; + } try { // A resume request carries no `source` of its own — recover it from @@ -427,29 +460,36 @@ export class AgentExecutionOrchestratorService { recorder.record({ type: 'finish', finishReason: 'error' }); throw error; } finally { - // Always record resumed executions — even if they suspend again (chained HITL) - // or fail while streaming. Don't repeat the original user message — the - // pre-suspension execution already has it. - const messageRecord = normalizeAbortedMessageRecord(recorder.getMessageRecord(), abortSignal); - await this.persistRecordedExecution({ - executionId, - onExecutionRecorded, - failureMessage: 'Failed to record resumed agent execution', - params: { - threadId, - agentId, - agentName: agentInstance.name, - projectId, - userMessage: null, - ...(executionSource !== undefined ? { source: executionSource } : {}), - record: messageRecord, - hitlStatus: recorder.suspended ? 'suspended' : 'resumed', - telemetry: { - runType, - configuration: runtime.telemetryConfiguration, + try { + // Always record resumed executions — even if they suspend again (chained HITL) + // or fail while streaming. Don't repeat the original user message — the + // pre-suspension execution already has it. + const messageRecord = normalizeAbortedMessageRecord( + recorder.getMessageRecord(), + abortSignal, + ); + await this.persistRecordedExecution({ + executionId, + onExecutionRecorded, + failureMessage: 'Failed to record resumed agent execution', + params: { + threadId, + agentId, + agentName: agentInstance.name, + projectId, + userMessage: null, + ...(executionSource !== undefined ? { source: executionSource } : {}), + record: messageRecord, + hitlStatus: recorder.suspended ? 'suspended' : 'resumed', + telemetry: { + runType, + configuration: runtime.telemetryConfiguration, + }, }, - }, - }); + }); + } finally { + this.runtimeCacheService.releaseRuntimeLease(agentInstance); + } } } @@ -471,38 +511,48 @@ export class AgentExecutionOrchestratorService { // `user` is always set (see ExecuteForChatConfig) — this builds/reuses a // runtime scoped to this specific user's tool access. + const sandboxPrincipalHash = hashAgentSandboxPrincipal({ + type: 'n8n-user', + userId: user.id, + }); const runtime = await this.runtimeCacheService.getRuntime({ agentId, projectId, integrationType: N8N_CHAT_INTEGRATION_TYPE, user, + sandboxPrincipalHash, }); - await this.integrationMessageContextService.setLatest(memory.threadId, memory.resourceId, { - integrationConnectionId: N8N_CHAT_INTEGRATION_TYPE, - platform: N8N_CHAT_INTEGRATION_TYPE, - target: { type: 'dm', userId: user.id, threadId: memory.threadId }, - interactingUserId: user.id, - updatedAt: new Date().toISOString(), - }); + try { + await this.integrationMessageContextService.setLatest(memory.threadId, memory.resourceId, { + integrationConnectionId: N8N_CHAT_INTEGRATION_TYPE, + platform: N8N_CHAT_INTEGRATION_TYPE, + target: { type: 'dm', userId: user.id, threadId: memory.threadId }, + interactingUserId: user.id, + updatedAt: new Date().toISOString(), + }); - yield* this.streamChatResponse({ - agentInstance: runtime.agent, - toolRegistry: runtime.toolRegistry, - agentId, - userId: user.id, - message, - attachments, - memory, - projectId: runtime.projectId, - source, - telemetry: { - runType: 'test', - configuration: runtime.telemetryConfiguration, - }, - onExecutionRecorded, - abortSignal, - }); + yield* this.streamChatResponse({ + agentInstance: runtime.agent, + toolRegistry: runtime.toolRegistry, + agentId, + userId: user.id, + message, + attachments, + memory, + projectId: runtime.projectId, + source, + telemetry: { + runType: 'test', + configuration: runtime.telemetryConfiguration, + }, + onExecutionRecorded, + abortSignal, + sandboxPrincipalHash, + }); + } finally { + this.runtimeCacheService.releaseRuntimeLease(runtime.agent); + } } /** @@ -513,34 +563,46 @@ export class AgentExecutionOrchestratorService { async *executeForChatPublished( config: ExecuteForChatPublishedConfig, ): AsyncGenerator { - const { agentId, projectId, message, memory, integrationType, attachments } = config; + const { + agentId, + projectId, + message, + memory, + integrationType, + attachments, + sandboxPrincipalHash, + } = config; await this.externalHooks.run('agent.preExecute', [agentId]); - // No `user` (see ExecuteForChatPublishedConfig): this is the shared, - // project-scoped runtime — every caller of this published agent through - // this integration reuses the same cache entry regardless of who - // triggered the platform event. + // Published integration runtimes have no n8n user but are isolated by + // their external caller's hashed workspace principal. const runtime = await this.runtimeCacheService.getRuntime({ agentId, projectId, integrationType, usePublishedVersion: true, + sandboxPrincipalHash, }); - yield* this.streamChatResponse({ - agentInstance: runtime.agent, - toolRegistry: runtime.toolRegistry, - agentId, - message, - attachments, - memory, - projectId: runtime.projectId, - source: integrationType, - telemetry: { - runType: 'production', - configuration: runtime.telemetryConfiguration, - }, - }); + try { + yield* this.streamChatResponse({ + agentInstance: runtime.agent, + toolRegistry: runtime.toolRegistry, + agentId, + message, + attachments, + memory, + projectId: runtime.projectId, + source: integrationType, + telemetry: { + runType: 'production', + configuration: runtime.telemetryConfiguration, + }, + sandboxPrincipalHash, + }); + } finally { + this.runtimeCacheService.releaseRuntimeLease(runtime.agent); + } } /** @@ -553,30 +615,36 @@ export class AgentExecutionOrchestratorService { const { agentId, projectId, message, memory, taskId, taskVersionId } = config; await this.externalHooks.run('agent.preExecute', [agentId]); - // No `user` (see ExecuteForTaskPublishedConfig): cron-fired, no human to - // attach — same shared, project-scoped runtime for every tick. + // Cron-fired runs have no n8n user and reuse the scheduled task's scope. + const sandboxPrincipalHash = hashAgentSandboxPrincipal({ type: 'scheduled-task', taskId }); const runtime = await this.runtimeCacheService.getRuntime({ agentId, projectId, integrationType: 'task', usePublishedVersion: true, + sandboxPrincipalHash, }); - yield* this.streamChatResponse({ - agentInstance: runtime.agent, - toolRegistry: runtime.toolRegistry, - agentId, - message, - memory, - projectId: runtime.projectId, - source: 'task', - taskId, - taskVersionId, - telemetry: { - runType: 'production', - configuration: runtime.telemetryConfiguration, - }, - }); + try { + yield* this.streamChatResponse({ + agentInstance: runtime.agent, + toolRegistry: runtime.toolRegistry, + agentId, + message, + memory, + projectId: runtime.projectId, + source: 'task', + taskId, + taskVersionId, + telemetry: { + runType: 'production', + configuration: runtime.telemetryConfiguration, + }, + sandboxPrincipalHash, + }); + } finally { + this.runtimeCacheService.releaseRuntimeLease(runtime.agent); + } } /** @@ -589,27 +657,37 @@ export class AgentExecutionOrchestratorService { // `user` is always set (see ExecuteForTaskNowConfig) — manual "Run now" // runs get a runtime scoped to the requesting user's tool access, same // as the in-app test chat. + const sandboxPrincipalHash = hashAgentSandboxPrincipal({ + type: 'n8n-user', + userId: user.id, + }); const runtime = await this.runtimeCacheService.getRuntime({ agentId, projectId, user, + sandboxPrincipalHash, }); - yield* this.streamChatResponse({ - agentInstance: runtime.agent, - toolRegistry: runtime.toolRegistry, - agentId, - userId: user.id, - message, - memory, - projectId: runtime.projectId, - source: 'task', - taskId, - telemetry: { - runType: 'test', - configuration: runtime.telemetryConfiguration, - }, - }); + try { + yield* this.streamChatResponse({ + agentInstance: runtime.agent, + toolRegistry: runtime.toolRegistry, + agentId, + userId: user.id, + message, + memory, + projectId: runtime.projectId, + source: 'task', + taskId, + telemetry: { + runType: 'test', + configuration: runtime.telemetryConfiguration, + }, + sandboxPrincipalHash, + }); + } finally { + this.runtimeCacheService.releaseRuntimeLease(runtime.agent); + } } /** @@ -631,6 +709,7 @@ export class AgentExecutionOrchestratorService { telemetry, onExecutionRecorded, abortSignal, + sandboxPrincipalHash, } = config; const { threadId, resourceId } = memory; @@ -653,8 +732,12 @@ export class AgentExecutionOrchestratorService { }); const input = attachments?.length ? buildInboundUserMessage(message, attachments) : message; + const hostMetadata = encodeAgentSandboxHostMetadata({ + projectId, + principalHash: sandboxPrincipalHash, + }); const resultStream = await agentInstance.stream(input, { - persistence: { threadId, resourceId }, + persistence: { threadId, resourceId, hostMetadata }, executionCounter: createAgentExecutionCounter(this.telemetry, { agentId, userId, diff --git a/packages/cli/src/modules/agents/agent-execution.service.ts b/packages/cli/src/modules/agents/agent-execution.service.ts index 497ad9b36da..4fa1c4f2bad 100644 --- a/packages/cli/src/modules/agents/agent-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-execution.service.ts @@ -13,7 +13,6 @@ import { type StoredAttachmentRef, } from './agent-chat-attachment.service'; import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster'; -import { AgentWorkspaceService } from './agent-workspace.service'; import { AgentExecutionThread } from './entities/agent-execution-thread.entity'; import { AgentExecution } from './entities/agent-execution.entity'; import type { MessageRecord, TimelineEvent } from './execution-recorder'; @@ -104,7 +103,6 @@ export class AgentExecutionService { private readonly storageConfig: StorageConfig, private readonly errorReporter: ErrorReporter, private readonly executionUpdateBroadcaster: AgentExecutionUpdateBroadcaster, - private readonly agentWorkspaceService: AgentWorkspaceService, ) {} async startExecutionRecording(params: StartExecutionParams, startedAt: Date): Promise { @@ -445,7 +443,6 @@ export class AgentExecutionService { await this.n8nMemory.getImplementation(agentId).deleteThread(threadId); await this.agentChatAttachmentService.deleteByThread(threadId, { projectId }); await Promise.all([ - this.agentWorkspaceService.cleanupThreadWorkspace(projectId, agentId, threadId), this.agentExecutionThreadRepository.delete({ id: threadId }), this.agentExecutionLogStore.delete( blobRefs.map((r) => ({ agentId, threadId, executionId: r.id, storedAt: r.storedAt })), diff --git a/packages/cli/src/modules/agents/agent-knowledge-mirror.service.ts b/packages/cli/src/modules/agents/agent-knowledge-mirror.service.ts index 6a4b220e6f8..68840e69487 100644 --- a/packages/cli/src/modules/agents/agent-knowledge-mirror.service.ts +++ b/packages/cli/src/modules/agents/agent-knowledge-mirror.service.ts @@ -525,7 +525,10 @@ export class AgentKnowledgeMirrorService { projectId: string, agentId: string, ): Promise { - const runtime = await this.agentSandboxRuntimeService.acquireSandbox(projectId, agentId); + const runtime = await this.agentSandboxRuntimeService.acquireKnowledgeSandbox( + projectId, + agentId, + ); return { ...runtime, paths: getAgentKnowledgePaths(runtime.provider) }; } } diff --git a/packages/cli/src/modules/agents/agent-knowledge.service.ts b/packages/cli/src/modules/agents/agent-knowledge.service.ts index b2da8f748a4..b85a1aeae7a 100644 --- a/packages/cli/src/modules/agents/agent-knowledge.service.ts +++ b/packages/cli/src/modules/agents/agent-knowledge.service.ts @@ -89,9 +89,11 @@ export class AgentKnowledgeService { return files.map((file) => toAgentFileDto(file)); } - async warmSandbox(agentId: string, projectId: string): Promise { + async warmKnowledgeSandbox(agentId: string, projectId: string): Promise { await this.ensureAgentBelongsToProject(agentId, projectId); - await this.agentSandboxRuntimeService.warmSandbox(projectId, agentId); + if (!(await this.agentFileRepository.hasFilesForAgent(agentId))) return; + + await this.agentSandboxRuntimeService.warmKnowledgeSandbox(projectId, agentId); } async deleteFile(agentId: string, projectId: string, fileId: string): Promise { @@ -112,7 +114,11 @@ export class AgentKnowledgeService { error: error instanceof Error ? error.message : error, }); }); - this.agentKnowledgeMirrorService.prewarmMirrorInBackground(projectId, agentId); + if (await this.agentFileRepository.hasFilesForAgent(agentId)) { + this.agentKnowledgeMirrorService.prewarmMirrorInBackground(projectId, agentId); + } else { + await this.agentSandboxRuntimeService.destroyKnowledgeSandbox(projectId, agentId); + } } async deleteAllFilesForAgent(_projectId: string, agentId: string): Promise { @@ -136,8 +142,8 @@ export class AgentKnowledgeService { } /** Best-effort passthrough for agent/project deletion; never throws. */ - async destroySandbox(projectId: string, agentId: string): Promise { - await this.agentSandboxRuntimeService.destroySandbox(projectId, agentId); + async destroyKnowledgeSandbox(projectId: string, agentId: string): Promise { + await this.agentSandboxRuntimeService.destroyKnowledgeSandbox(projectId, agentId); } /** Stores the file's bytes via AgentKnowledgeFileStore, then reserves its DB row. */ diff --git a/packages/cli/src/modules/agents/agent-runtime-cache.service.ts b/packages/cli/src/modules/agents/agent-runtime-cache.service.ts index e4d8752bb66..3bb4f465a88 100644 --- a/packages/cli/src/modules/agents/agent-runtime-cache.service.ts +++ b/packages/cli/src/modules/agents/agent-runtime-cache.service.ts @@ -5,6 +5,7 @@ import { Time } from '@n8n/constants'; import type { User } from '@n8n/db'; import { OnPubSubEvent } from '@n8n/decorators'; import { Service } from '@n8n/di'; +import { UserError } from 'n8n-workflow'; import { CredentialsService } from '@/credentials/credentials.service'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -13,6 +14,11 @@ import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { TtlMap } from '@/utils/ttl-map'; +import { + hashAgentSandboxPrincipal, + type AgentSandboxPrincipalHash, +} from './agent-sandbox-principal'; +import { AgentSandboxRuntimeService } from './agent-sandbox-runtime.service'; import { buildAgentConfigurationTelemetry } from './agent-telemetry'; import { AgentRuntimeReconstructionService } from './agent-runtime-reconstruction.service'; import type { Agent } from './entities/agent.entity'; @@ -30,10 +36,11 @@ export interface GetRuntimeParams { /** * The calling n8n user. When present, the runtime is built with node/workflow * tools filtered down to what this user can access, and the cache key is - * scoped to the user so different users never share a runtime. Absent for + * scoped to the caller so different users never share a runtime. Absent for * published/integration runs, which keep today's project-scoped runtime. */ user?: User; + sandboxPrincipalHash?: AgentSandboxPrincipalHash; } export interface AgentRuntime { @@ -53,8 +60,8 @@ interface RuntimeInitialization { export class AgentRuntimeCacheService { /** * Cached agent runtimes. Keys follow the pattern: - * Draft: `{agentId}:draft[:{integrationType}][:user:{userId}]` - * Published: `{agentId}:published[:{integrationType}]` + * Draft: `{agentId}:draft[:{integrationType}][:{callerScope}]` + * Published: `{agentId}:published[:{integrationType}][:{callerScope}]` * * TTL = 30 minutes — entries are evicted when the agent is idle so that * memory is freed without requiring an explicit shutdown step. @@ -62,18 +69,21 @@ export class AgentRuntimeCacheService { * Separating draft and published with explicit prefixes prevents a draft * runtime from being mistakenly returned to a published-agent execution. * - * The `:user:{userId}` suffix only ever appears on draft keys — published - * runs never carry a `user` (see `GetRuntimeParams.user`), since they have - * no interactive n8n session to gate tools against. A draft runtime's tool - * list is filtered per-user at build time (see - * `AgentRuntimeReconstructionService.reconstructFromAgentEntity`), so two - * different users hitting the same draft agent must never resolve to the - * same cache entry — that would leak one user's tool access to the other. + * With sandbox support enabled, caller scope is the workspace principal. + * Without it, draft runtimes retain equivalent per-user isolation via a hash. */ - private readonly runtimes = new TtlMap(30 * Time.minutes.toMilliseconds); + private readonly runtimes = new TtlMap( + 30 * Time.minutes.toMilliseconds, + undefined, + (runtime) => this.closeAgentResources(runtime.agent, runtime.agentId), + ); private readonly runtimeInitializations = new Map(); + private readonly activeRuntimeLeases = new WeakMap(); + + private readonly runtimesPendingClose = new WeakMap(); + constructor( private readonly logger: Logger, private readonly agentRepository: AgentRepository, @@ -81,20 +91,21 @@ export class AgentRuntimeCacheService { private readonly globalConfig: GlobalConfig, private readonly agentRuntimeReconstructionService: AgentRuntimeReconstructionService, private readonly credentialsService: CredentialsService, + private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService, ) {} private computeRuntimeCacheKey(params: GetRuntimeParams): string { - if (params.usePublishedVersion) { - const parts = [params.agentId, 'published']; - if (params.integrationType) parts.push(params.integrationType); - return parts.join(':'); - } - const parts = [params.agentId, 'draft']; + const sandboxEnabled = this.agentSandboxRuntimeService.isEnabled(); + const parts = [params.agentId, params.usePublishedVersion ? 'published' : 'draft']; if (params.integrationType) parts.push(params.integrationType); // Per-user runtimes have node/workflow tools filtered by that user's // access — keying by user id keeps them from colliding with each other // or with the unscoped (no-user) runtime. - if (params.user) parts.push(`user:${params.user.id}`); + if (sandboxEnabled && params.sandboxPrincipalHash) { + parts.push(`sandbox:${params.sandboxPrincipalHash}`); + } else if (!params.usePublishedVersion && params.user) { + parts.push(`user:${hashAgentSandboxPrincipal({ type: 'n8n-user', userId: params.user.id })}`); + } return parts.join(':'); } @@ -160,7 +171,12 @@ export class AgentRuntimeCacheService { * which disposes the runtime and disconnects any attached MCP clients. * Errors are logged but never thrown. */ - private closeAgentResources(agent: { close(): Promise }, agentId: string): void { + private closeAgentResources(agent: RuntimeAgent, agentId: string): void { + if (this.activeRuntimeLeases.has(agent)) { + this.runtimesPendingClose.set(agent, agentId); + return; + } + agent.close().catch((error) => { this.logger.warn('[AgentRuntimeCacheService] Failed to close agent resources on eviction', { agentId, @@ -169,17 +185,45 @@ export class AgentRuntimeCacheService { }); } + private acquireRuntimeLease(runtime: AgentRuntime): AgentRuntime { + const { agent } = runtime; + this.activeRuntimeLeases.set(agent, (this.activeRuntimeLeases.get(agent) ?? 0) + 1); + return runtime; + } + + releaseRuntimeLease(agent: RuntimeAgent): void { + const activeLeases = this.activeRuntimeLeases.get(agent); + if (activeLeases === undefined) return; + if (activeLeases > 1) { + this.activeRuntimeLeases.set(agent, activeLeases - 1); + return; + } + + this.activeRuntimeLeases.delete(agent); + const agentId = this.runtimesPendingClose.get(agent); + if (agentId !== undefined) { + this.runtimesPendingClose.delete(agent); + this.closeAgentResources(agent, agentId); + } + } + /** - * Return a cached runtime, or reconstruct one from the DB. + * Return a leased cached runtime, or reconstruct one from the DB. + * Callers must release the lease in a `finally` block. */ async getRuntime(params: GetRuntimeParams): Promise { + if (this.agentSandboxRuntimeService.isEnabled() && !params.sandboxPrincipalHash) { + throw new UserError( + 'Agent workspace scope is missing and the runtime cannot be reconstructed', + ); + } const cacheKey = this.computeRuntimeCacheKey(params); const cached = this.runtimes.get(cacheKey); - if (cached) return cached; + if (cached) return this.acquireRuntimeLease(cached); const initialization = this.runtimeInitializations.get(cacheKey); - if (initialization) return await initialization.promise; + if (initialization) return this.acquireRuntimeLease(await initialization.promise); const token = Symbol(cacheKey); const runtimeInitialization: RuntimeInitialization = { @@ -204,11 +248,12 @@ export class AgentRuntimeCacheService { }); this.runtimeInitializations.set(cacheKey, runtimeInitialization); - return await runtimeInitialization.promise; + return this.acquireRuntimeLease(await runtimeInitialization.promise); } private async reconstructRuntime(params: GetRuntimeParams): Promise { - const { agentId, projectId, integrationType, usePublishedVersion, user } = params; + const { agentId, projectId, integrationType, usePublishedVersion, user, sandboxPrincipalHash } = + params; const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); if (!agentEntity) throw new NotFoundError(`Agent ${agentId} not found`); @@ -228,16 +273,17 @@ export class AgentRuntimeCacheService { projectId, user, ); - const { agent: agentInstance, toolRegistry } = - await this.agentRuntimeReconstructionService.reconstructFromAgentEntity( - agentData, - credentialProvider, - usePublishedVersion ? 'production' : 'test', - integrationType, - user, - undefined, - usePublishedVersion ? 'integrated' : 'manual', - ); + const reconstruction = this.agentRuntimeReconstructionService.reconstructFromAgentEntity( + agentData, + credentialProvider, + usePublishedVersion ? 'production' : 'test', + integrationType, + user, + undefined, + usePublishedVersion ? 'integrated' : 'manual', + sandboxPrincipalHash, + ); + const { agent: agentInstance, toolRegistry } = await reconstruction; return { agent: agentInstance, diff --git a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts index d71c8978636..c1049b6ae3b 100644 --- a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts +++ b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts @@ -49,6 +49,7 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { AgentChatAttachmentService } from './agent-chat-attachment.service'; import { AgentKnowledgeMirrorService } from './agent-knowledge-mirror.service'; +import type { AgentSandboxPrincipalHash } from './agent-sandbox-principal'; import { AgentSandboxRuntimeService, sanitizeSandboxErrorDetail, @@ -135,6 +136,7 @@ export interface ReconstructAgentRuntimeParams { user?: User; /** Runtime seams inherited from the delegating parent run (see {@link AgentRuntimeInstrumentation}). */ instrumentation?: AgentRuntimeInstrumentation; + sandboxPrincipalHash?: AgentSandboxPrincipalHash; } async function getChatIntegrationToolServices() { @@ -195,6 +197,7 @@ export class AgentRuntimeReconstructionService { user?: User, instrumentation?: AgentRuntimeInstrumentation, workflowToolExecutionMode: WorkflowToolExecutionMode = 'manual', + sandboxPrincipalHash?: AgentSandboxPrincipalHash, ): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> { let config = agentEntity.schema; if (!config) { @@ -242,6 +245,7 @@ export class AgentRuntimeReconstructionService { subAgentDelegation, user, instrumentation, + sandboxPrincipalHash, }); } @@ -355,6 +359,7 @@ export class AgentRuntimeReconstructionService { subAgentDelegation: SubAgentDelegationConfig; user?: User; instrumentation?: AgentRuntimeInstrumentation; + sandboxPrincipalHash?: AgentSandboxPrincipalHash; }): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> { const { config, @@ -373,6 +378,7 @@ export class AgentRuntimeReconstructionService { subAgentDelegation, user, instrumentation, + sandboxPrincipalHash, } = options; const toolExecutor = this.secureRuntime.createToolExecutor(toolCodeByName); @@ -454,6 +460,7 @@ export class AgentRuntimeReconstructionService { credentialIntegrations, user, instrumentation, + sandboxPrincipalHash, }); return { agent: reconstructed, toolRegistry: buildToolRegistry(resolvedTools) }; @@ -579,6 +586,7 @@ export class AgentRuntimeReconstructionService { credentialIntegrations: AgentIntegrationConfig[]; user?: User; instrumentation?: AgentRuntimeInstrumentation; + sandboxPrincipalHash?: AgentSandboxPrincipalHash; }): Promise { const { agent, @@ -595,13 +603,25 @@ export class AgentRuntimeReconstructionService { credentialIntegrations, user, instrumentation, + sandboxPrincipalHash, } = params; agent.tool(createGetEnvironmentTool()); if (runtimeProfile !== 'inline' && this.agentSandboxRuntimeService.isEnabled()) { + if (!sandboxPrincipalHash) { + throw new UserError( + 'Agent workspace scope is missing and the runtime cannot be reconstructed', + ); + } try { - agent.workspace(await this.agentWorkspaceService.getAgentWorkspace(projectId, agentId)); + agent.workspace( + await this.agentWorkspaceService.getAgentWorkspace( + projectId, + agentId, + sandboxPrincipalHash, + ), + ); } catch (error) { this.logger.warn('Failed to attach agent workspace', { projectId, diff --git a/packages/cli/src/modules/agents/agent-sandbox-principal.ts b/packages/cli/src/modules/agents/agent-sandbox-principal.ts new file mode 100644 index 00000000000..b4f0187b6ba --- /dev/null +++ b/packages/cli/src/modules/agents/agent-sandbox-principal.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto'; + +import type { JSONObject } from '@n8n/agents'; + +export type AgentSandboxPrincipal = + | { type: 'n8n-user'; userId: string } + | { + type: 'integration-user'; + connectionId: string; + platform: string; + platformUserId: string; + } + | { type: 'workflow-session'; workflowId: string; sessionId: string } + | { + type: 'workflow-execution'; + workflowId: string; + executionId: string; + } + | { type: 'scheduled-task'; taskId: string }; + +declare const agentSandboxPrincipalHashBrand: unique symbol; + +export type AgentSandboxPrincipalHash = string & { + readonly [agentSandboxPrincipalHashBrand]: true; +}; + +export interface AgentSandboxPersistenceScope { + projectId: string; + principalHash: AgentSandboxPrincipalHash; +} + +const AGENT_SANDBOX_HOST_METADATA_KEY = 'n8nAgentSandbox'; + +export function isAgentSandboxPrincipalHash(value: string): value is AgentSandboxPrincipalHash { + return /^[A-Za-z0-9_-]{43}$/.test(value); +} + +export function encodeAgentSandboxHostMetadata(scope: AgentSandboxPersistenceScope): JSONObject { + return { + [AGENT_SANDBOX_HOST_METADATA_KEY]: { + projectId: scope.projectId, + principalHash: scope.principalHash, + }, + }; +} + +export function decodeAgentSandboxHostMetadata( + hostMetadata: JSONObject | undefined, +): AgentSandboxPersistenceScope | undefined { + const scope = hostMetadata?.[AGENT_SANDBOX_HOST_METADATA_KEY]; + if ( + typeof scope !== 'object' || + scope === null || + Array.isArray(scope) || + Object.keys(scope).length !== 2 + ) { + return undefined; + } + const { projectId, principalHash } = scope; + if ( + typeof projectId !== 'string' || + typeof principalHash !== 'string' || + !isAgentSandboxPrincipalHash(principalHash) + ) { + return undefined; + } + return { projectId, principalHash }; +} + +export function hashAgentSandboxPrincipal( + principal: AgentSandboxPrincipal, +): AgentSandboxPrincipalHash { + let canonicalPrincipal: string[]; + + switch (principal.type) { + case 'n8n-user': + canonicalPrincipal = [principal.type, principal.userId]; + break; + case 'integration-user': + canonicalPrincipal = [ + principal.type, + principal.connectionId, + principal.platform, + principal.platformUserId, + ]; + break; + case 'workflow-session': + canonicalPrincipal = [principal.type, principal.workflowId, principal.sessionId]; + break; + case 'workflow-execution': + canonicalPrincipal = [principal.type, principal.workflowId, principal.executionId]; + break; + case 'scheduled-task': + canonicalPrincipal = [principal.type, principal.taskId]; + break; + } + + return createHash('sha256') + .update(JSON.stringify(canonicalPrincipal)) + .digest('base64url') as AgentSandboxPrincipalHash; +} diff --git a/packages/cli/src/modules/agents/agent-sandbox-runtime.service.ts b/packages/cli/src/modules/agents/agent-sandbox-runtime.service.ts index 494d8a52c8a..d8d3833b1ee 100644 --- a/packages/cli/src/modules/agents/agent-sandbox-runtime.service.ts +++ b/packages/cli/src/modules/agents/agent-sandbox-runtime.service.ts @@ -24,19 +24,31 @@ import { SandboxSettingsService } from '@/services/sandbox-settings.service'; import { callAiServiceWithRetry } from '@/utils/ai-service-retry'; import { assertKnowledgePathSegment } from './agent-knowledge-storage'; +import type { AgentSandboxPrincipalHash } from './agent-sandbox-principal'; import { AgentRepository } from './repositories/agent.repository'; -export const AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX = 'agent-'; - -const AGENT_KNOWLEDGE_SANDBOX_NAMESPACE = '5b5fd8cd-59c1-5914-aabc-cf7257fb46bc'; +const WORKSPACE_SANDBOX_NAMESPACE = '38348f53-e947-42c7-8c04-83aa154be385'; +const KNOWLEDGE_SANDBOX_NAMESPACE = '51989c13-3ae7-4167-8e64-d874dd068795'; +const WORKSPACE_SANDBOX_NAME_PREFIX = 'agent-ws-'; +const KNOWLEDGE_SANDBOX_NAME_PREFIX = 'agent-kb-'; const MAX_SANDBOX_ERROR_DETAIL_CHARS = 2_000; const LABEL_KNOWLEDGE_BASE = 'n8n-agents-knowledgebase'; const LABEL_PROJECT_ID = 'n8n-project-id'; const LABEL_AGENT_ID = 'n8n-agent-id'; +const LABEL_SANDBOX_KIND = 'n8n-agent-sandbox-kind'; +const LABEL_PRINCIPAL_HASH = 'n8n-agent-principal-hash'; const DEFAULT_SANDBOX_IMAGE = 'daytonaio/sandbox:0.5.0'; -const AUTO_STOP_INTERVAL_MINUTES = 5; +const WORKSPACE_AUTO_STOP_INTERVAL_MINUTES = 5; +const KNOWLEDGE_AUTO_STOP_INTERVAL_MINUTES = 15; +const KNOWLEDGE_AUTO_ARCHIVE_INTERVAL_MINUTES = 60; +const KNOWLEDGE_AUTO_DELETE_INTERVAL_MINUTES = 7 * 24 * 60; + +type DaytonaSandboxLifecycle = Pick< + DaytonaSandboxConfig, + 'ephemeral' | 'autoStopInterval' | 'autoArchiveInterval' | 'autoDeleteInterval' +>; export interface AgentSandboxRuntime { provider: SandboxProvider; @@ -46,23 +58,48 @@ export interface AgentSandboxRuntime { cacheKey: string; } -function buildSandboxName(scope: { +function buildWorkspaceSandboxId(scope: { + instanceId: string; + projectId: string; + agentId: string; + principalHash: AgentSandboxPrincipalHash; +}): string { + return uuidv5( + JSON.stringify([scope.instanceId, scope.projectId, scope.agentId, scope.principalHash]), + WORKSPACE_SANDBOX_NAMESPACE, + ); +} + +function buildKnowledgeSandboxId(scope: { instanceId: string; projectId: string; agentId: string; }): string { - return `${AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX}${scope.instanceId}-${scope.projectId}-${scope.agentId}`.toLowerCase(); + return uuidv5( + JSON.stringify([scope.instanceId, scope.projectId, scope.agentId]), + KNOWLEDGE_SANDBOX_NAMESPACE, + ); } -function buildN8nSandboxId(sandboxName: string): string { - return uuidv5(sandboxName, AGENT_KNOWLEDGE_SANDBOX_NAMESPACE); +function buildWorkspaceLabels( + projectId: string, + agentId: string, + principalHash: AgentSandboxPrincipalHash, +): Record { + return { + [LABEL_PROJECT_ID]: projectId, + [LABEL_AGENT_ID]: agentId, + [LABEL_SANDBOX_KIND]: 'workspace', + [LABEL_PRINCIPAL_HASH]: principalHash, + }; } -function buildScopeLabels(projectId: string, agentId: string): Record { +function buildKnowledgeLabels(projectId: string, agentId: string): Record { return { [LABEL_KNOWLEDGE_BASE]: 'true', [LABEL_PROJECT_ID]: projectId, [LABEL_AGENT_ID]: agentId, + [LABEL_SANDBOX_KIND]: 'knowledge', }; } @@ -89,39 +126,109 @@ export class AgentSandboxRuntimeService { private readonly sandboxSettingsService: SandboxSettingsService, ) {} - async warmSandbox(projectId: string, agentId: string): Promise { + async warmKnowledgeSandbox(projectId: string, agentId: string): Promise { this.assertSandboxConfiguration(projectId, agentId); - await this.acquireSandbox(projectId, agentId); + await this.acquireKnowledgeSandbox(projectId, agentId); } - /** - * Best-effort sandbox teardown for agent/project deletion. Never throws — - * callers must not have cleanup failures block the parent delete operation. - */ - async destroySandbox(projectId: string, agentId: string): Promise { - const sandboxName = buildSandboxName({ + async destroyWorkspaceSandbox( + projectId: string, + agentId: string, + principalHash: AgentSandboxPrincipalHash, + ): Promise { + const sandboxId = buildWorkspaceSandboxId({ + instanceId: this.instanceSettings.instanceId, + projectId, + agentId, + principalHash, + }); + await this.destroySandboxByIdentity( + projectId, + agentId, + `${WORKSPACE_SANDBOX_NAME_PREFIX}${sandboxId}`, + sandboxId, + buildWorkspaceLabels(projectId, agentId, principalHash), + ); + } + + async destroyKnowledgeSandbox(projectId: string, agentId: string): Promise { + const sandboxId = buildKnowledgeSandboxId({ instanceId: this.instanceSettings.instanceId, projectId, agentId, }); - const sandboxes = [ - ['daytona', sandboxName], - ['n8n-sandbox', buildN8nSandboxId(sandboxName)], - ] as const; - - for (const [provider, sandboxId] of sandboxes) { - await this.tryDestroySandbox(projectId, agentId, provider, sandboxId); - } + await this.destroySandboxByIdentity( + projectId, + agentId, + `${KNOWLEDGE_SANDBOX_NAME_PREFIX}${sandboxId}`, + sandboxId, + buildKnowledgeLabels(projectId, agentId), + ); } - async acquireSandbox(projectId: string, agentId: string): Promise { + async acquireWorkspaceSandbox( + projectId: string, + agentId: string, + principalHash: AgentSandboxPrincipalHash, + ): Promise { const provider = this.sandboxSettingsService.getProvider(); - const sandboxName = buildSandboxName({ + const sandboxId = buildWorkspaceSandboxId({ + instanceId: this.instanceSettings.instanceId, + projectId, + agentId, + principalHash, + }); + return await this.acquireSandboxByIdentity( + projectId, + agentId, + provider, + `${WORKSPACE_SANDBOX_NAME_PREFIX}${sandboxId}`, + sandboxId, + buildWorkspaceLabels(projectId, agentId, principalHash), + `${provider}:workspace:${sandboxId}`, + { + ephemeral: true, + autoStopInterval: WORKSPACE_AUTO_STOP_INTERVAL_MINUTES, + }, + ); + } + + async acquireKnowledgeSandbox(projectId: string, agentId: string): Promise { + const provider = this.sandboxSettingsService.getProvider(); + const sandboxId = buildKnowledgeSandboxId({ instanceId: this.instanceSettings.instanceId, projectId, agentId, }); - const cacheKey = `${provider}:${sandboxName}`; + return await this.acquireSandboxByIdentity( + projectId, + agentId, + provider, + `${KNOWLEDGE_SANDBOX_NAME_PREFIX}${sandboxId}`, + sandboxId, + buildKnowledgeLabels(projectId, agentId), + `${provider}:knowledge:${sandboxId}`, + { + ephemeral: this.agentsConfig.sandboxEphemeral, + autoStopInterval: KNOWLEDGE_AUTO_STOP_INTERVAL_MINUTES, + autoArchiveInterval: KNOWLEDGE_AUTO_ARCHIVE_INTERVAL_MINUTES, + ...(this.agentsConfig.sandboxEphemeral + ? {} + : { autoDeleteInterval: KNOWLEDGE_AUTO_DELETE_INTERVAL_MINUTES }), + }, + ); + } + + private async acquireSandboxByIdentity( + projectId: string, + agentId: string, + provider: SandboxProvider, + daytonaName: string, + n8nSandboxId: string, + labels: Record, + cacheKey: string, + daytonaLifecycle: DaytonaSandboxLifecycle, + ): Promise { let pending = this.pendingSandboxAcquisitions.get(cacheKey); if (!pending) { @@ -129,8 +236,11 @@ export class AgentSandboxRuntimeService { projectId, agentId, provider, - sandboxName, + daytonaName, + n8nSandboxId, + labels, cacheKey, + daytonaLifecycle, ).finally(() => { this.pendingSandboxAcquisitions.delete(cacheKey); }); @@ -162,16 +272,34 @@ export class AgentSandboxRuntimeService { this.assertValidPathSegments(projectId, agentId); } + private async destroySandboxByIdentity( + projectId: string, + agentId: string, + daytonaName: string, + n8nSandboxId: string, + labels: Record, + ): Promise { + const sandboxes = [ + ['daytona', daytonaName], + ['n8n-sandbox', n8nSandboxId], + ] as const; + + for (const [provider, sandboxId] of sandboxes) { + await this.tryDestroySandbox(projectId, agentId, provider, sandboxId, labels); + } + } + private async tryDestroySandbox( projectId: string, agentId: string, provider: SandboxProvider, sandboxId: string, + labels: Record, ): Promise { try { const config = provider === 'daytona' - ? await this.resolveDaytonaSandboxConfig(projectId, agentId, sandboxId) + ? await this.resolveDaytonaSandboxConfig(projectId, sandboxId, labels) : await this.resolveN8nSandboxConfig(sandboxId); const sandbox = await createSandbox(config, { logger: this.logger }); if (!sandbox?.destroy) { @@ -193,8 +321,11 @@ export class AgentSandboxRuntimeService { projectId: string, agentId: string, provider: SandboxProvider, - sandboxName: string, + daytonaName: string, + n8nSandboxId: string, + labels: Record, cacheKey: string, + daytonaLifecycle: DaytonaSandboxLifecycle, ): Promise { const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); if (!agent) { @@ -203,8 +334,8 @@ export class AgentSandboxRuntimeService { const config = provider === 'daytona' - ? await this.resolveDaytonaSandboxConfig(projectId, agentId, sandboxName) - : await this.resolveN8nSandboxConfig(buildN8nSandboxId(sandboxName)); + ? await this.resolveDaytonaSandboxConfig(projectId, daytonaName, labels, daytonaLifecycle) + : await this.resolveN8nSandboxConfig(n8nSandboxId); return await this.startSandbox(config, projectId, agentId, cacheKey); } @@ -239,8 +370,9 @@ export class AgentSandboxRuntimeService { private async resolveDaytonaSandboxConfig( projectId: string, - agentId: string, sandboxId: string, + labels: Record, + lifecycle: DaytonaSandboxLifecycle = {}, ): Promise { const directImage = this.agentsConfig.sandboxImage || DEFAULT_SANDBOX_IMAGE; const snapshot = this.agentsConfig.sandboxSnapshot.trim() || undefined; @@ -249,11 +381,10 @@ export class AgentSandboxRuntimeService { provider: 'daytona', id: sandboxId, name: sandboxId, - labels: buildScopeLabels(projectId, agentId), + labels, timeout: this.agentsConfig.sandboxTimeout, createTimeoutSeconds: Math.ceil(this.agentsConfig.sandboxTimeout / 1000), - ephemeral: this.agentsConfig.sandboxEphemeral, - autoStopInterval: AUTO_STOP_INTERVAL_MINUTES, + ...lifecycle, }; if (!this.aiService.isProxyEnabled()) { diff --git a/packages/cli/src/modules/agents/agent-sandbox.controller.ts b/packages/cli/src/modules/agents/agent-sandbox.controller.ts index d7062d7b180..6f78ecef066 100644 --- a/packages/cli/src/modules/agents/agent-sandbox.controller.ts +++ b/packages/cli/src/modules/agents/agent-sandbox.controller.ts @@ -44,7 +44,7 @@ export class AgentSandboxController { agentId: string, ): Promise { try { - await this.agentKnowledgeService.warmSandbox(agentId, projectId); + await this.agentKnowledgeService.warmKnowledgeSandbox(agentId, projectId); } catch (error) { this.logger.warn('Failed to warm agent knowledge sandbox', { projectId, diff --git a/packages/cli/src/modules/agents/agent-test-chat.service.ts b/packages/cli/src/modules/agents/agent-test-chat.service.ts index b4a2f7f4b01..810d5c19efe 100644 --- a/packages/cli/src/modules/agents/agent-test-chat.service.ts +++ b/packages/cli/src/modules/agents/agent-test-chat.service.ts @@ -1,7 +1,6 @@ import { Service } from '@n8n/di'; import { AgentChatAttachmentService } from './agent-chat-attachment.service'; -import { AgentWorkspaceService } from './agent-workspace.service'; import { AGENT_THREAD_PREFIX } from './builder/builder-tool-names'; import { N8nMemory } from './integrations/n8n-memory'; import { draftChatMemoryResourceId } from './utils/agent-memory-scope'; @@ -17,7 +16,6 @@ export class AgentTestChatService { constructor( private readonly n8nMemory: N8nMemory, private readonly agentChatAttachmentService: AgentChatAttachmentService, - private readonly agentWorkspaceService: AgentWorkspaceService, ) {} /** @@ -35,11 +33,10 @@ export class AgentTestChatService { /** * Clear the current user's test-chat messages for an agent. */ - async clearTestChatMessages(projectId: string, agentId: string, userId: string) { + async clearTestChatMessages(agentId: string, userId: string) { const threadId = chatThreadId(agentId, userId); await this.n8nMemory.getImplementation(agentId).deleteThread(threadId); await this.agentChatAttachmentService.deleteByThread(threadId, { agentId }); - await this.agentWorkspaceService.cleanupThreadWorkspace(projectId, agentId, threadId); } /** Delete all test-chat messages + the thread row — used when the agent itself is deleted. */ diff --git a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts index 90b0d4447f3..8da6c187416 100644 --- a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts @@ -27,6 +27,10 @@ import { Telemetry } from '@/telemetry'; import { AgentExecutionService, type StartExecutionParams } from './agent-execution.service'; import { AgentRunTracingService } from './agent-run-tracing.service'; import { AgentRuntimeReconstructionService } from './agent-runtime-reconstruction.service'; +import { + encodeAgentSandboxHostMetadata, + type AgentSandboxPrincipalHash, +} from './agent-sandbox-principal'; import { buildAgentConfigurationTelemetry, buildAgentConfigurationTelemetryFromConfig, @@ -44,6 +48,10 @@ import { streamAgentChunks } from './utils/agent-stream'; import { validateNodeToolConfigs, validateNodeToolExpressions } from './utils/node-tool-validation'; import { describeStructuredOutputError } from './utils/structured-output-error'; +interface WorkflowSandboxScope { + principalHash: AgentSandboxPrincipalHash; +} + function getFinalWorkflowResponse(messageRecord: MessageRecord): string { let lastToolCallIndex = -1; for (let index = messageRecord.timeline.length - 1; index >= 0; index--) { @@ -137,6 +145,7 @@ export class AgentWorkflowExecutionService { runType: AgentRunTelemetryType, outputSchema?: JSONSchema7, extraTools?: BuiltTool[], + sandboxPrincipalHash?: AgentSandboxPrincipalHash, ): Promise<{ ok: boolean; agent?: BuiltAgent; error?: string }> { if (!agentEntity.schema) { return { ok: false, error: 'Agent has no JSON config. Create a config first.' }; @@ -155,6 +164,11 @@ export class AgentWorkflowExecutionService { agentEntity, credentialProvider, runType, + undefined, + undefined, + undefined, + 'manual', + sandboxPrincipalHash, ); return this.applyPerCallAgentExtras(reconstructed, outputSchema, extraTools); } catch (e) { @@ -235,6 +249,7 @@ export class AgentWorkflowExecutionService { nodeName?: string; }; recordingParams?: StartExecutionParams; + sandboxScope?: { projectId: string; principalHash: AgentSandboxPrincipalHash }; }): Promise { const { agentInstance, @@ -246,6 +261,7 @@ export class AgentWorkflowExecutionService { outputSchema, tracing, recordingParams, + sandboxScope, } = params; let agentExecutionId: string | undefined; @@ -301,7 +317,11 @@ export class AgentWorkflowExecutionService { // caller-supplied session id actually continue the conversation. // The previous key — the execution id — changed every run and hid // all prior messages of the thread from the model. - persistence: { resourceId: threadId, threadId }, + persistence: { + resourceId: threadId, + threadId, + ...(sandboxScope ? { hostMetadata: encodeAgentSandboxHostMetadata(sandboxScope) } : {}), + }, executionCounter: createAgentExecutionCounter(this.telemetry, { agentId: telemetryAgentId, userId: telemetryUserId, @@ -454,6 +474,33 @@ export class AgentWorkflowExecutionService { useDraftVersion?: boolean, outputSchema?: JSONSchema7, workflowContext?: ExecuteAgentWorkflowContext, + sandboxScope?: WorkflowSandboxScope, + ): Promise { + return await this.executeForWorkflowInternal( + agentId, + message, + executionId, + threadId, + projectId, + telemetryUserId, + useDraftVersion, + outputSchema, + workflowContext, + sandboxScope, + ); + } + + private async executeForWorkflowInternal( + agentId: string, + message: string, + executionId: string, + threadId: string, + projectId: string, + telemetryUserId?: string, + useDraftVersion?: boolean, + outputSchema?: JSONSchema7, + workflowContext?: ExecuteAgentWorkflowContext, + sandboxScope?: WorkflowSandboxScope, ): Promise { const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); if (!agentEntity) { @@ -477,6 +524,7 @@ export class AgentWorkflowExecutionService { runType, outputSchema, extraTools?.length ? extraTools : undefined, + sandboxScope?.principalHash, ); if (!compiled.ok || !compiled.agent) { throw new OperationalError(`Failed to compile agent: ${compiled.error ?? 'unknown error'}`); @@ -510,6 +558,14 @@ export class AgentWorkflowExecutionService { configuration: telemetryConfiguration, }, }, + ...(sandboxScope + ? { + sandboxScope: { + projectId, + principalHash: sandboxScope.principalHash, + }, + } + : {}), }); if (run.agentExecutionId) { diff --git a/packages/cli/src/modules/agents/agent-workspace.service.ts b/packages/cli/src/modules/agents/agent-workspace.service.ts index 5245564acc3..a5dfa005596 100644 --- a/packages/cli/src/modules/agents/agent-workspace.service.ts +++ b/packages/cli/src/modules/agents/agent-workspace.service.ts @@ -1,57 +1,96 @@ import { CORE_WORKSPACE_TOOL_NAMES, createScopedWorkspace, - getToolResultThreadDirectory, + reconcileToolResultRuns, Workspace, } from '@n8n/agents'; import { Logger } from '@n8n/backend-common'; +import { AgentsConfig } from '@n8n/config'; +import { Time } from '@n8n/constants'; import { Service } from '@n8n/di'; +import type { AgentSandboxPrincipalHash } from './agent-sandbox-principal'; import { AgentSandboxRuntimeService, sanitizeSandboxErrorDetail, } from './agent-sandbox-runtime.service'; +import { + CHECKPOINT_RECONCILIATION_OVERFLOW, + N8NCheckpointStorage, +} from './integrations/n8n-checkpoint-storage'; @Service() export class AgentWorkspaceService { + private readonly pendingReconciliations = new Set(); + constructor( private readonly logger: Logger, private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService, + private readonly checkpointStorage: N8NCheckpointStorage, + private readonly agentsConfig: AgentsConfig, ) {} - async getAgentWorkspace(projectId: string, agentId: string): Promise { + async getAgentWorkspace( + projectId: string, + agentId: string, + principalHash: AgentSandboxPrincipalHash, + ): Promise { this.agentSandboxRuntimeService.assertSandboxConfiguration(projectId, agentId); - const runtime = await this.agentSandboxRuntimeService.acquireSandbox(projectId, agentId); + const runtime = await this.agentSandboxRuntimeService.acquireWorkspaceSandbox( + projectId, + agentId, + principalHash, + ); await runtime.filesystem.mkdir(runtime.workspaceRoot, { recursive: true }); const workspace = createScopedWorkspace( new Workspace({ filesystem: runtime.filesystem, sandbox: runtime.sandbox }), runtime.workspaceRoot, ); + this.reconcileToolResultsInBackground( + runtime.cacheKey, + projectId, + agentId, + principalHash, + workspace, + ); const getTools = workspace.getTools.bind(workspace); workspace.getTools = () => getTools().filter((tool) => CORE_WORKSPACE_TOOL_NAMES.has(tool.name)); return workspace; } - async cleanupThreadWorkspace( + private reconcileToolResultsInBackground( + cacheKey: string, projectId: string, agentId: string, - threadId: string, - ): Promise { - if (!this.agentSandboxRuntimeService.isEnabled()) return; + principalHash: AgentSandboxPrincipalHash, + workspace: Workspace, + ): void { + if (this.pendingReconciliations.has(cacheKey)) return; + this.pendingReconciliations.add(cacheKey); - try { - const runtime = await this.agentSandboxRuntimeService.acquireSandbox(projectId, agentId); - await runtime.filesystem.rmdir( - `${runtime.workspaceRoot}/${getToolResultThreadDirectory(threadId)}`, - { recursive: true, force: true }, - ); - } catch (error) { - this.logger.warn('Failed to clean up agent thread workspace', { - projectId, + void (async () => { + const activeRunIds = await this.checkpointStorage.getActiveRunIdsForSandbox( agentId, - error: sanitizeSandboxErrorDetail(error instanceof Error ? error.message : String(error)), + principalHash, + ); + if (activeRunIds !== CHECKPOINT_RECONCILIATION_OVERFLOW && workspace.filesystem) { + await reconcileToolResultRuns( + workspace.filesystem, + activeRunIds, + this.agentsConfig.checkpointTtlSeconds * Time.seconds.toMilliseconds, + ); + } + })() + .catch((error) => { + this.logger.warn('Failed to reconcile agent workspace tool results', { + projectId, + agentId, + error: sanitizeSandboxErrorDetail(error instanceof Error ? error.message : String(error)), + }); + }) + .finally(() => { + this.pendingReconciliations.delete(cacheKey); }); - } } } diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index a0f260a5395..17986603818 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -308,7 +308,7 @@ export class AgentsService { }); } - await this.agentKnowledgeService.destroySandbox(projectId, agentId); + await this.agentKnowledgeService.destroyKnowledgeSandbox(projectId, agentId); try { await this.agentChatAttachmentService.deleteByAgent(agentId); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts index d1e32c47426..e1beb81c8ec 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts @@ -19,6 +19,8 @@ import { SlackIntegration } from '../platforms/slack/slack-integration'; import type { AgentIntegrationConfig } from '@n8n/api-types'; import type { RichCardComponentType } from '@n8n/api-types'; +import { hashAgentSandboxPrincipal } from '../../agent-sandbox-principal'; + type ChatBotLike = ConstructorParameters[0]; interface FakeThread { @@ -668,6 +670,12 @@ describe('AgentChatBridge — consumeStream', () => { expect(agentExecutor.executeForChatPublished).toHaveBeenNthCalledWith( 1, expect.objectContaining({ + sandboxPrincipalHash: hashAgentSandboxPrincipal({ + type: 'integration-user', + connectionId: 'cred-1', + platform: 'test-streaming', + platformUserId: 'u1', + }), memory: expect.objectContaining({ threadId: expect.objectContaining({ id: 'agent-1:thread-1' }), resourceId: 'integration:test-streaming:u1', diff --git a/packages/cli/src/modules/agents/integrations/__tests__/n8n-checkpoint-storage.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/n8n-checkpoint-storage.test.ts index 40afa7cdc77..26488924e43 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/n8n-checkpoint-storage.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/n8n-checkpoint-storage.test.ts @@ -5,9 +5,16 @@ import type { AgentsConfig } from '@n8n/config'; import type { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; +import { + encodeAgentSandboxHostMetadata, + hashAgentSandboxPrincipal, +} from '../../agent-sandbox-principal'; import type { AgentCheckpoint } from '../../entities/agent-checkpoint.entity'; import type { AgentCheckpointRepository } from '../../repositories/agent-checkpoint.repository'; -import { N8NCheckpointStorage } from '../n8n-checkpoint-storage'; +import { + CHECKPOINT_RECONCILIATION_OVERFLOW, + N8NCheckpointStorage, +} from '../n8n-checkpoint-storage'; const suspendedState: SerializableAgentState = { status: 'suspended', @@ -22,6 +29,7 @@ const suspendedState: SerializableAgentState = { }, }, }; +const principalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-1' }); function makeService() { const repository = mock(); @@ -177,4 +185,64 @@ describe('N8NCheckpointStorage', () => { expect(repository.expireByRunIdAndAgentId).toHaveBeenCalledWith('run-1', 'agent-1'); }); + + it('returns every persisted active run for the principal workspace', async () => { + const { service, repository } = makeService(); + const otherPrincipalHash = hashAgentSandboxPrincipal({ + type: 'n8n-user', + userId: 'user-2', + }); + const stateFor = ( + status: SerializableAgentState['status'], + hash = principalHash, + ): SerializableAgentState => ({ + ...suspendedState, + status, + persistence: { + ...suspendedState.persistence!, + hostMetadata: encodeAgentSandboxHostMetadata({ + projectId: 'project-1', + principalHash: hash, + }), + }, + }); + const checkpoint = ( + runId: string, + state: string | null, + overrides: Partial = {}, + ) => + ({ + runId, + agentId: 'agent-1', + expired: false, + state, + updatedAt: new Date(), + ...overrides, + }) as AgentCheckpoint; + repository.findForSandboxReconciliation.mockResolvedValue([ + checkpoint('run-running', JSON.stringify(stateFor('running'))), + checkpoint('run-suspended', JSON.stringify(stateFor('suspended'))), + checkpoint('run-cancelled', JSON.stringify(stateFor('cancelled'))), + checkpoint('run-other-principal', JSON.stringify(stateFor('running', otherPrincipalHash))), + checkpoint('run-old', JSON.stringify(stateFor('suspended')), { updatedAt: new Date(0) }), + checkpoint('run-expired', JSON.stringify(stateFor('running')), { expired: true }), + checkpoint('run-malformed', '{'), + checkpoint('run-empty', null), + ]); + + await expect(service.getActiveRunIdsForSandbox('agent-1', principalHash)).resolves.toEqual( + new Set(['run-running', 'run-suspended', 'run-old']), + ); + }); + + it('reports overflow instead of returning a partial set of protected runs', async () => { + const { service, repository } = makeService(); + repository.findForSandboxReconciliation.mockResolvedValue( + Array.from({ length: 101 }, (_, index) => ({ runId: `run-${index}` }) as AgentCheckpoint), + ); + + await expect(service.getActiveRunIdsForSandbox('agent-1', principalHash)).resolves.toBe( + CHECKPOINT_RECONCILIATION_OVERFLOW, + ); + }); }); diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts index d8ea2e8f657..dac6ed12d22 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts @@ -4,6 +4,7 @@ import { MAX_AGENT_CHAT_ATTACHMENT_SIZE_BYTES, MAX_AGENT_CHAT_ATTACHMENT_SIZE_MB, MAX_AGENT_CHAT_ATTACHMENTS_PER_MESSAGE, + type AgentIntegrationConfig, } from '@n8n/api-types'; import { LockService } from '@n8n/backend-common'; import { type HttpRequestClient, OutboundHttp } from '@n8n/backend-network'; @@ -11,12 +12,17 @@ import { Container } from '@n8n/di'; import type { Attachment, Author, Chat, Message, Thread } from 'chat'; import type { Logger } from 'n8n-workflow'; +import { CacheService } from '@/services/cache/cache.service'; + import { AgentChatAttachmentService, type StoredAttachmentRef, } from '../agent-chat-attachment.service'; import type { AgentExecutionOrchestratorService } from '../agent-execution-orchestrator.service'; -import { CacheService } from '@/services/cache/cache.service'; +import { + hashAgentSandboxPrincipal, + type AgentSandboxPrincipalHash, +} from '../agent-sandbox-principal'; import { integrationMemoryResourceId } from '../utils/agent-memory-scope'; import { resolveInboundMimeType } from '../utils/inbound-attachments'; import type { @@ -37,7 +43,6 @@ import type { ComponentMapper, ShortenCallback } from './component-mapper'; import { IntegrationMessageContextService } from './integration-message-context.service'; import type { ReplyExpectation } from './integration-tools'; import { downloadDiscordAttachment } from './platforms/discord-operations'; -import type { AgentIntegrationConfig } from '@n8n/api-types'; import { type InternalThread, toInternalThreadId } from './types'; @@ -49,6 +54,7 @@ interface AgentExecutor { attachments?: StoredAttachmentRef[]; memory: { threadId: InternalThread; resourceId: string }; integrationType?: string; + sandboxPrincipalHash: AgentSandboxPrincipalHash; }): AsyncGenerator; resumeForChat(config: { @@ -186,6 +192,7 @@ export class AgentChatBridge { message, attachments, integrationType, + sandboxPrincipalHash, }) { yield* agentService.executeForChatPublished({ agentId: aid, @@ -200,6 +207,7 @@ export class AgentChatBridge { }), }, integrationType, + sandboxPrincipalHash, }); }, async *resumeForChat(config) { @@ -368,6 +376,12 @@ export class AgentChatBridge { resourceId, }, integrationType: this.integration.type, + sandboxPrincipalHash: hashAgentSandboxPrincipal({ + type: 'integration-user', + connectionId: this.integration.credentialId, + platform: this.integration.type, + platformUserId: message.author.userId, + }), }); consumeStarted = true; diff --git a/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts b/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts index 19fa2428bc3..b59535cbd2f 100644 --- a/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts +++ b/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts @@ -12,6 +12,10 @@ import { InstanceSettings } from 'n8n-core'; import { jsonParse, UnexpectedError, UserError } from 'n8n-workflow'; import { strict } from 'node:assert'; +import { + decodeAgentSandboxHostMetadata, + type AgentSandboxPrincipalHash, +} from '../agent-sandbox-principal'; import { AgentCheckpointRepository } from '../repositories/agent-checkpoint.repository'; /** File parts are checkpointed reference-only (a `Uint8Array` would not survive JSON round-tripping). */ @@ -37,6 +41,9 @@ type CheckpointStatus = checkpoint: SerializableAgentState; }; +const MAX_SANDBOX_RECONCILIATION_CHECKPOINTS = 100; +export const CHECKPOINT_RECONCILIATION_OVERFLOW = Symbol('checkpoint-reconciliation-overflow'); + @Service() export class N8NCheckpointStorage { private pruneTimeout: NodeJS.Timeout | undefined; @@ -66,6 +73,33 @@ export class N8NCheckpointStorage { }; } + async getActiveRunIdsForSandbox( + agentId: string, + principalHash: AgentSandboxPrincipalHash, + ): Promise | typeof CHECKPOINT_RECONCILIATION_OVERFLOW> { + const checkpoints = await this.agentCheckpointRepository.findForSandboxReconciliation(agentId); + if (checkpoints.length > MAX_SANDBOX_RECONCILIATION_CHECKPOINTS) { + return CHECKPOINT_RECONCILIATION_OVERFLOW; + } + + const runIds = new Set(); + + for (const checkpoint of checkpoints) { + if (checkpoint.expired || checkpoint.state === null) continue; + + try { + const state = jsonParse(checkpoint.state); + if (state.status !== 'running' && state.status !== 'suspended') continue; + const scope = decodeAgentSandboxHostMetadata(state.persistence?.hostMetadata); + if (scope?.principalHash === principalHash) runIds.add(checkpoint.runId); + } catch { + // A malformed checkpoint must not block workspace acquisition. + } + } + + return runIds; + } + init() { strict(this.instanceSettings.instanceRole !== 'unset', 'Instance role is not set'); diff --git a/packages/cli/src/modules/agents/repositories/agent-checkpoint.repository.ts b/packages/cli/src/modules/agents/repositories/agent-checkpoint.repository.ts index e07ca8ac6a3..8d65799e8b1 100644 --- a/packages/cli/src/modules/agents/repositories/agent-checkpoint.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-checkpoint.repository.ts @@ -24,6 +24,14 @@ export class AgentCheckpointRepository extends Repository { }); } + async findForSandboxReconciliation(agentId: string): Promise { + return await this.find({ + where: { agentId, expired: false }, + order: { updatedAt: 'DESC' }, + take: 101, + }); + } + async claimForResume( runId: string, agentId: string, diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts index 233b747ac72..30549fec85e 100644 --- a/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts +++ b/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts @@ -13,6 +13,10 @@ import { OperationalError, UserError } from 'n8n-workflow'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { + encodeAgentSandboxHostMetadata, + hashAgentSandboxPrincipal, +} from '../../agent-sandbox-principal'; import { createN8nDelegateSubAgentTool, formatSubAgentToolOutput, @@ -168,7 +172,8 @@ describe('createN8nDelegateSubAgentTool', () => { ); }); - it('forwards the parent persistence thread id and resource id to the runner', async () => { + it('forwards the parent persistence scope to the runner', async () => { + const principalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-1' }); const tool = createN8nDelegateSubAgentTool({ runner, sourcesById: { 'agent-2': source }, @@ -181,7 +186,11 @@ describe('createN8nDelegateSubAgentTool', () => { { subAgentId: 'agent-2', taskName: 'Research API', goal: 'Find behavior.' }, { runId: 'parent-run-1', - persistence: { threadId: 'parent-thread-1', resourceId: 'resource-1' }, + persistence: { + threadId: 'parent-thread-1', + resourceId: 'resource-1', + hostMetadata: encodeAgentSandboxHostMetadata({ projectId, principalHash }), + }, }, ); @@ -189,6 +198,7 @@ describe('createN8nDelegateSubAgentTool', () => { expect.objectContaining({ parentThreadId: 'parent-thread-1', parentResourceId: 'resource-1', + parentSandboxPrincipalHash: principalHash, }), expect.any(Object), ); diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-foreground-runner.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-foreground-runner.test.ts index 93f3fa297b1..1b6dbc0ee80 100644 --- a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-foreground-runner.test.ts +++ b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-foreground-runner.test.ts @@ -18,6 +18,10 @@ import { mock } from 'vitest-mock-extended'; import type { AgentExecutionService } from '../../agent-execution.service'; import { AgentRuntimeReconstructionService } from '../../agent-runtime-reconstruction.service'; +import { + encodeAgentSandboxHostMetadata, + hashAgentSandboxPrincipal, +} from '../../agent-sandbox-principal'; import type { N8NCheckpointStorage } from '../../integrations/n8n-checkpoint-storage'; import { SubAgentForegroundRunner } from '../sub-agent-foreground-runner'; import type { @@ -302,6 +306,63 @@ describe('SubAgentForegroundRunner', () => { expect(result.threadId).toEqual(expect.any(String)); }); + it('inherits the parent workspace principal on the initial run and resume', async () => { + const principalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-1' }); + const result = await runner.runForeground( + { ...spawnRequest, parentSandboxPrincipalHash: principalHash }, + { + projectId, + credentialProvider, + runType: 'production', + }, + ); + + expect(reconstructionService.reconstructFromResolvedSource).toHaveBeenLastCalledWith( + expect.objectContaining({ sandboxPrincipalHash: principalHash }), + ); + expect(childAgent.stream).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + persistence: expect.objectContaining({ + hostMetadata: encodeAgentSandboxHostMetadata({ projectId, principalHash }), + }), + }), + ); + + checkpointStorage.load.mockResolvedValue({ + status: 'suspended', + persistence: { + threadId: result.threadId, + resourceId: result.threadId, + delegated: true, + hostMetadata: encodeAgentSandboxHostMetadata({ projectId, principalHash }), + }, + messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] }, + pendingToolCalls: {}, + }); + childAgent.resume.mockResolvedValue(makeStreamResult(defaultStreamChunks)); + await runner.resumeForeground( + { + ...delegatedRequest, + childRunId: 'child-run-1', + childToolCallId: 'tool-call-1', + childThreadId: result.threadId, + resumeData: { approved: true }, + resumeContext: { agentId: 'agent-1', versionId: 'version-7' }, + parentThreadId, + }, + { + projectId, + credentialProvider, + runType: 'production', + }, + ); + + expect(reconstructionService.reconstructFromResolvedSource).toHaveBeenLastCalledWith( + expect.objectContaining({ sandboxPrincipalHash: principalHash }), + ); + }); + it('uses the saved n8n agent id as memory owner and records parent linkage', async () => { sourceResolver.resolveForRuntime.mockResolvedValue({ ...runtimeSource, diff --git a/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts b/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts index 8e722222879..342741b0abd 100644 --- a/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts +++ b/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts @@ -12,6 +12,7 @@ import { OperationalError } from 'n8n-workflow'; import { ResponseError } from '@/errors/response-errors/abstract/response.error'; +import { decodeAgentSandboxHostMetadata } from '../agent-sandbox-principal'; import type { SubAgentForegroundRunContext, SubAgentForegroundResult, @@ -63,6 +64,7 @@ export function createN8nDelegateSubAgentTool(options: CreateN8nDelegateSubAgent error: `No configured subagent matched "${request.subAgentId}". Use "inline" for an inline sub-agent, or pass one of the configured subagent IDs.`, }; } + const parentSandboxScope = decodeAgentSandboxHostMetadata(request.parentHostMetadata); const result = await runner.runForeground( { @@ -80,6 +82,9 @@ export function createN8nDelegateSubAgentTool(options: CreateN8nDelegateSubAgent ...(request.parentResourceId !== undefined ? { parentResourceId: request.parentResourceId } : {}), + ...(parentSandboxScope?.projectId === runContext.projectId + ? { parentSandboxPrincipalHash: parentSandboxScope.principalHash } + : {}), taskPath: request.taskPath, }, { diff --git a/packages/cli/src/modules/agents/sub-agents/sub-agent-foreground-runner.ts b/packages/cli/src/modules/agents/sub-agents/sub-agent-foreground-runner.ts index 71344109f48..1a8d80e2521 100644 --- a/packages/cli/src/modules/agents/sub-agents/sub-agent-foreground-runner.ts +++ b/packages/cli/src/modules/agents/sub-agents/sub-agent-foreground-runner.ts @@ -27,6 +27,12 @@ import type { AgentRunTelemetryType } from '@/interfaces'; import { AgentExecutionService } from '../agent-execution.service'; import type { AgentRuntimeInstrumentation } from '../agent-runtime-instrumentation'; +import { + decodeAgentSandboxHostMetadata, + encodeAgentSandboxHostMetadata, + isAgentSandboxPrincipalHash, + type AgentSandboxPrincipalHash, +} from '../agent-sandbox-principal'; import { buildAgentConfigurationTelemetryFromConfig } from '../agent-telemetry'; import type { MessageRecord } from '../execution-recorder'; import { ExecutionRecorder } from '../execution-recorder'; @@ -167,6 +173,11 @@ export class SubAgentForegroundRunner { const threadId = operation.type === 'run' ? uuid() : operation.threadId; const resourceId = operation.type === 'run' ? (operation.request.parentResourceId ?? threadId) : threadId; + const sandboxPrincipalHash = await this.resolveSandboxPrincipalHash( + operation, + runtimeSource.source.sourceId, + context.projectId, + ); const reconstructionService = await getReconstructionService(); const childConfig = context.instrumentation?.transformDelegatedAgentConfig?.(runtimeSource.source.config, { @@ -186,6 +197,7 @@ export class SubAgentForegroundRunner { parentAgentIdForDelegation: context.parentAgentId, user: context.user, instrumentation: context.instrumentation, + ...(sandboxPrincipalHash !== undefined ? { sandboxPrincipalHash } : {}), }); const telemetry = deriveSubAgentTelemetry(context.telemetry); @@ -219,6 +231,14 @@ export class SubAgentForegroundRunner { resourceId, threadId, delegated: true, + ...(sandboxPrincipalHash !== undefined + ? { + hostMetadata: encodeAgentSandboxHostMetadata({ + projectId: context.projectId, + principalHash: sandboxPrincipalHash, + }), + } + : {}), }, }) : await agent.resume('stream', operation.request.resumeData, { @@ -322,6 +342,32 @@ export class SubAgentForegroundRunner { } } + private async resolveSandboxPrincipalHash( + operation: ForegroundOperation, + childAgentId: string, + projectId: string, + ): Promise { + if (operation.type === 'run') { + const value = operation.request.parentSandboxPrincipalHash; + if (value === undefined) return undefined; + if (!isAgentSandboxPrincipalHash(value)) { + throw new UserError('Configured sub-agent workspace scope is invalid'); + } + return value; + } + + const checkpoint = await this.checkpointStorage.load( + operation.request.childRunId, + childAgentId, + ); + const scope = decodeAgentSandboxHostMetadata(checkpoint?.persistence?.hostMetadata); + if (!scope) return undefined; + if (scope.projectId !== projectId) { + throw new UserError('Configured sub-agent workspace scope is invalid'); + } + return scope.principalHash; + } + private async recordSubAgentExecution(params: { runtimeSource: ResolvedSubAgentSource; projectId: string; diff --git a/packages/cli/src/modules/agents/tools/knowledge/__tests__/search-knowledge.tool.test.ts b/packages/cli/src/modules/agents/tools/knowledge/__tests__/search-knowledge.tool.test.ts index 99ecfb137c3..8f57ee7a3c5 100644 --- a/packages/cli/src/modules/agents/tools/knowledge/__tests__/search-knowledge.tool.test.ts +++ b/packages/cli/src/modules/agents/tools/knowledge/__tests__/search-knowledge.tool.test.ts @@ -33,7 +33,7 @@ function getToolHandler(tool: BuiltTool): NonNullable { } describe('createKnowledgeRetrievalTools', () => { - it('finds files with the project-scoped sandbox using a catch-all pattern', async () => { + it('finds files through the Agent-scoped knowledge mirror', async () => { const knowledgeMirrorService = mock(); knowledgeMirrorService.globKnowledgeFiles.mockResolvedValue({ files: [], @@ -55,7 +55,7 @@ describe('createKnowledgeRetrievalTools', () => { ); }); - it('searches knowledge with the project-scoped sandbox even when memory uses an integration resource', async () => { + it('searches the Agent-scoped knowledge mirror regardless of the memory resource', async () => { const knowledgeMirrorService = mock(); knowledgeMirrorService.searchKnowledge.mockResolvedValue({ outputMode: 'content', @@ -74,7 +74,7 @@ describe('createKnowledgeRetrievalTools', () => { expect(knowledgeMirrorService.searchKnowledge).toHaveBeenCalledWith(projectId, agentId, input); }); - it('reads knowledge with the project-scoped sandbox even when memory uses an integration resource', async () => { + it('reads the Agent-scoped knowledge mirror regardless of the memory resource', async () => { const knowledgeMirrorService = mock(); knowledgeMirrorService.readKnowledge.mockResolvedValue({ file: 'notes.txt', diff --git a/packages/cli/src/modules/instance-ai/eval/__tests__/agent-execution.service.test.ts b/packages/cli/src/modules/instance-ai/eval/__tests__/agent-execution.service.test.ts index 12fdf63542b..29b58f55666 100644 --- a/packages/cli/src/modules/instance-ai/eval/__tests__/agent-execution.service.test.ts +++ b/packages/cli/src/modules/instance-ai/eval/__tests__/agent-execution.service.test.ts @@ -48,7 +48,7 @@ vi.mock('../mcp-mock-fetch', () => ({ createMcpMockFetch: vi.fn(() => vi.fn()) } vi.mock('../mock-handler', () => ({ createLlmMockHandler: vi.fn() })); const logger = mock(); -const user = mock(); +const user = mock({ id: 'user/123:raw' }); const findByIdAndProjectId = vi.fn(); const reconstructFromAgentEntity = vi.fn(); @@ -263,20 +263,22 @@ describe('EvalAgentExecutionService.executeWithLlmMock', () => { expect(close).toHaveBeenCalledTimes(1); // The runtime was built with the eval instrumentation, uncached. - const [entityArg, , runType, integrationType, userArg, instrumentation] = - reconstructFromAgentEntity.mock.calls[0] as [ - AgentEntity, - unknown, - string, - string | undefined, - User, - { modelFetch?: unknown }, - ]; + const call = reconstructFromAgentEntity.mock.calls[0] as [ + AgentEntity, + unknown, + string, + string | undefined, + User, + { modelFetch?: unknown }, + ...unknown[], + ]; + const [entityArg, , runType, integrationType, userArg, instrumentation] = call; expect(entityArg.id).toBe('agent-1'); expect(runType).toBe('test'); expect(integrationType).toBeUndefined(); expect(userArg).toBe(user); expect(instrumentation.modelFetch).toBeDefined(); + expect(call[7]).toBe('Gt4H3q6RzhJe9cTxQm6be0AdIZQlifuy3w9OPSykmYo'); }); it('attributes MCP calls when the server name requires normalization', async () => { diff --git a/packages/cli/src/modules/instance-ai/eval/agent-execution.service.ts b/packages/cli/src/modules/instance-ai/eval/agent-execution.service.ts index 499a39d9a83..b7efaef0f0a 100644 --- a/packages/cli/src/modules/instance-ai/eval/agent-execution.service.ts +++ b/packages/cli/src/modules/instance-ai/eval/agent-execution.service.ts @@ -28,6 +28,7 @@ import { CredentialsService } from '@/credentials/credentials.service'; // Static agents-module imports are safe here: the ModuleRegistry gate decides // availability at runtime. import { AgentRuntimeReconstructionService } from '@/modules/agents/agent-runtime-reconstruction.service'; +import { hashAgentSandboxPrincipal } from '@/modules/agents/agent-sandbox-principal'; import type { Agent as AgentEntity } from '@/modules/agents/entities/agent.entity'; import { sanitizeToolName } from '@/modules/agents/json-config/agent-config-composition'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; @@ -326,6 +327,8 @@ export class EvalAgentExecutionService { ); }, }, + 'manual', + hashAgentSandboxPrincipal({ type: 'n8n-user', userId: user.id }), )); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/cli/src/services/__tests__/project.service.ee.test.ts b/packages/cli/src/services/__tests__/project.service.ee.test.ts index 25a282e006e..a8d803fcbe9 100644 --- a/packages/cli/src/services/__tests__/project.service.ee.test.ts +++ b/packages/cli/src/services/__tests__/project.service.ee.test.ts @@ -649,8 +649,14 @@ describe('ProjectService', () => { expect(agentKnowledgeService.deleteAllFilesForAgent.mock.invocationCallOrder[1]).toBeLessThan( projectRepository.remove.mock.invocationCallOrder[0], ); - expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(project.id, 'agent-1'); - expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(project.id, 'agent-2'); + expect(agentKnowledgeService.destroyKnowledgeSandbox).toHaveBeenCalledWith( + project.id, + 'agent-1', + ); + expect(agentKnowledgeService.destroyKnowledgeSandbox).toHaveBeenCalledWith( + project.id, + 'agent-2', + ); expect(agentExecutionService.deleteExecutionLogsForAgent).toHaveBeenCalledWith('agent-1'); expect(agentExecutionService.deleteExecutionLogsForAgent).toHaveBeenCalledWith('agent-2'); }); @@ -766,7 +772,10 @@ describe('ProjectService', () => { await expect(projectService.deleteProject(user, project.id)).resolves.toBeUndefined(); - expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(project.id, 'agent-1'); + expect(agentKnowledgeService.destroyKnowledgeSandbox).toHaveBeenCalledWith( + project.id, + 'agent-1', + ); expect(agentExecutionService.deleteExecutionLogsForAgent).toHaveBeenCalledWith('agent-1'); expect(projectRepository.remove).toHaveBeenCalledWith(project); }); diff --git a/packages/cli/src/services/__tests__/sandbox-settings.service.test.ts b/packages/cli/src/services/__tests__/sandbox-settings.service.test.ts index 6eece611e0d..36fec3a6086 100644 --- a/packages/cli/src/services/__tests__/sandbox-settings.service.test.ts +++ b/packages/cli/src/services/__tests__/sandbox-settings.service.test.ts @@ -46,6 +46,20 @@ describe('SandboxSettingsService', () => { service = new SandboxSettingsService(globalConfig as never, instanceCredentialBroker, logger); }); + it.each([ + { agentsEnabled: true, instanceAiEnabled: false, expected: true }, + { agentsEnabled: false, instanceAiEnabled: true, expected: true }, + { agentsEnabled: false, instanceAiEnabled: false, expected: false }, + ])( + 'enables Agent sandboxes=$expected for Agents=$agentsEnabled and Instance AI=$instanceAiEnabled', + ({ agentsEnabled, instanceAiEnabled, expected }) => { + globalConfig.agents.sandboxEnabled = agentsEnabled; + globalConfig.instanceAi.sandboxEnabled = instanceAiEnabled; + + expect(service.isAgentSandboxEnabled()).toBe(expected); + }, + ); + it('uses environment config when no credentials are assigned', async () => { Object.assign(globalConfig.instanceAi, { daytonaApiUrl: 'https://env.daytona.example.com', diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 78ea5ef6d66..68b2e6e136e 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -284,7 +284,7 @@ export class ProjectService { }); } - await agentKnowledgeService.destroySandbox(project.id, agent.id); + await agentKnowledgeService.destroyKnowledgeSandbox(project.id, agent.id); await agentExecutionService.deleteExecutionLogsForAgent(agent.id); } } diff --git a/packages/cli/src/utils/ttl-map.ts b/packages/cli/src/utils/ttl-map.ts index 5b68489df89..a31c5784684 100644 --- a/packages/cli/src/utils/ttl-map.ts +++ b/packages/cli/src/utils/ttl-map.ts @@ -19,16 +19,28 @@ export class TtlMap { * @param ttlMs Time-to-live for each entry in milliseconds. * @param sweepIntervalMs How often to run the background sweep (defaults to `ttlMs`). * Set to `0` to disable the background sweep entirely. + * @param onExpire Called after an entry is removed because its TTL elapsed. */ constructor( private readonly ttlMs: number, sweepIntervalMs: number = ttlMs, + private readonly onExpire?: (value: V) => void, ) { if (sweepIntervalMs > 0) { this.sweepTimer = setInterval(() => this.sweep(), sweepIntervalMs).unref(); } } + private deleteIfExpired( + key: K, + entry: { value: V; expiresAt: number }, + now = Date.now(), + ): boolean { + if (now <= entry.expiresAt) return false; + if (this.store.delete(key)) this.onExpire?.(entry.value); + return true; + } + set(key: K, value: V): this { this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs }); return this; @@ -37,22 +49,13 @@ export class TtlMap { get(key: K): V | undefined { const entry = this.store.get(key); if (!entry) return undefined; - if (Date.now() > entry.expiresAt) { - this.store.delete(key); - return undefined; - } + if (this.deleteIfExpired(key, entry)) return undefined; return entry.value; } has(key: K): boolean { - if (!this.store.has(key)) return false; - const expiresAt = this.store.get(key)?.expiresAt; - if (!expiresAt) return false; - if (Date.now() > expiresAt) { - this.store.delete(key); - return false; - } - return true; + const entry = this.store.get(key); + return entry !== undefined && !this.deleteIfExpired(key, entry); } delete(key: K): boolean { @@ -94,9 +97,7 @@ export class TtlMap { sweep(): void { const now = Date.now(); for (const [key, entry] of this.store) { - if (now > entry.expiresAt) { - this.store.delete(key); - } + this.deleteIfExpired(key, entry, now); } } diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index fc08eef1209..a34db6a79cc 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -416,7 +416,24 @@ export async function executeAgent( ); } + const { hashAgentSandboxPrincipal } = await import('@/modules/agents/agent-sandbox-principal.js'); const useDraftVersion = isManualOrChatExecution(executionMode); + const sandboxScope = + workflowContext?.hasCallerSessionId === true + ? { + principalHash: hashAgentSandboxPrincipal({ + type: 'workflow-session', + workflowId: additionalData.workflowId, + sessionId: threadId, + }), + } + : { + principalHash: hashAgentSandboxPrincipal({ + type: 'workflow-execution', + workflowId: additionalData.workflowId, + executionId, + }), + }; const result = await agentWorkflowExecutionService.executeForWorkflow( source.agentId, @@ -428,6 +445,7 @@ export async function executeAgent( useDraftVersion, outputSchema, workflowContext, + sandboxScope, ); // Callers see the session id they supplied (or the derived per-call id), so