feat(core): Isolate Agent sandboxes by principal (#36203)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robin Braumann
2026-08-17 12:54:24 +00:00
committed by GitHub
parent 6ace2a5b4f
commit ce1b52177e
66 changed files with 2225 additions and 543 deletions
@@ -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<void> {
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);
});
});
@@ -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 = {
+1 -1
View File
@@ -424,7 +424,7 @@ export {
CORE_WORKSPACE_TOOL_NAMES,
createScopedWorkspace,
createWorkspaceTools,
getToolResultThreadDirectory,
reconcileToolResultRuns,
} from './workspace';
export { SandboxProcessManager, ProcessHandle } from './workspace';
@@ -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<void> {
await filesystem.writeFile(`${getToolResultRunDirectory('concurrent-run')}/keep.json`, '{}', {
recursive: true,
});
}
async function expectOnlyCurrentRunRemoved(
filesystem: InMemoryFilesystem,
runId: string,
): Promise<void> {
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 () => {
@@ -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<DelegateSubAgentRunner>()
.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),
@@ -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', () => {
@@ -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<void> {
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`. */
@@ -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<void> {
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<void> {
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(
@@ -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<void> {
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. */
@@ -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
@@ -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 } : {}),
};
}
@@ -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<PropertyKey, unknown> {
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,
+2 -1
View File
@@ -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<Parameters<typeof smoothStream>[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;
/**
@@ -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;
+5 -1
View File
@@ -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';
@@ -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<void> {
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<string>,
ttlMs: number,
): Promise<void> {
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);
}
@@ -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`)
-9
View File
@@ -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',
@@ -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,
);
});
});
@@ -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<User>({ 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<N8NCheckpointStorage>();
const executionService = mock<AgentExecutionService>();
const telemetry = mock<Telemetry>();
@@ -107,6 +120,9 @@ function makeService() {
const integrationMessageContextService = mock<IntegrationMessageContextService>();
const agentRunTracingService = mock<AgentRunTracingService>();
const externalHooks = mock<ExternalHooks>();
const agentSandboxRuntimeService = mock<AgentSandboxRuntimeService>({
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(
@@ -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<ErrorReporter>;
let agentChatAttachmentService: Mocked<AgentChatAttachmentService>;
let executionUpdateBroadcaster: Mocked<AgentExecutionUpdateBroadcaster>;
let agentWorkspaceService: Mocked<AgentWorkspaceService>;
beforeEach(() => {
vi.clearAllMocks();
@@ -85,8 +83,6 @@ describe('AgentExecutionService', () => {
errorReporter = mock<ErrorReporter>();
agentChatAttachmentService = mock<AgentChatAttachmentService>();
executionUpdateBroadcaster = mock<AgentExecutionUpdateBroadcaster>();
agentWorkspaceService = mock<AgentWorkspaceService>();
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' });
});
@@ -93,7 +93,7 @@ function makeRuntimeService(
runtime: AgentSandboxRuntime,
): ReturnType<typeof mock<AgentSandboxRuntimeService>> {
const service = mock<AgentSandboxRuntimeService>();
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>();
agentFileRepository.findByAgentId.mockResolvedValue([]);
const agentRepository = mock<AgentRepository>();
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>();
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<void>((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<WorkspaceFilesystem>();
const replacementFilesystem = mock<WorkspaceFilesystem>();
runtimeService.acquireSandbox
runtimeService.acquireKnowledgeSandbox
.mockResolvedValueOnce(makeRuntime(staleSandbox, staleFilesystem))
.mockResolvedValueOnce(makeRuntime(replacementSandbox, replacementFilesystem));
const agentFileRepository = mock<AgentFileRepository>();
@@ -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();
});
});
@@ -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<AgentRepository>();
const publisher = mock<Publisher>();
const reconstructionService = mock<AgentRuntimeReconstructionService>();
const credentialsService = mock<CredentialsService>();
const sandboxRuntimeService = mock<AgentSandboxRuntimeService>({
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);
@@ -235,6 +235,9 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
const projectRelationRepository = mock<ProjectRelationRepository>();
const agentRuntimeReconstructionService = mock<AgentRuntimeReconstructionService>();
const chatIntegrationRegistry = mock<ChatIntegrationRegistry>();
const agentSandboxRuntimeService = mock<AgentSandboxRuntimeService>({
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<AgentModificationTelemetryService>();
@@ -275,6 +279,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
mock<IntegrationMessageContextService>(),
mock<AgentRunTracingService>(),
mock<ExternalHooks>(),
agentSandboxRuntimeService,
);
agentIntegrationPersistenceService = new AgentIntegrationPersistenceService(
agentRepository,
@@ -310,11 +315,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
mock<AgentSetupCompletionService>(),
mock<AgentModificationTelemetryService>(),
);
agentTestChatService = new AgentTestChatService(
n8nMemory,
mock<AgentChatAttachmentService>(),
mock<AgentWorkspaceService>(),
);
agentTestChatService = new AgentTestChatService(n8nMemory, mock<AgentChatAttachmentService>());
agentsService = new AgentsService(
logger,
agentRepository,
@@ -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');
});
});
@@ -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<Pick<WorkspaceSandbox, '_start' | 'destroy' | 'executeCommand'>>;
function buildExpectedSandboxName(): string {
return `${AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX}${instanceId}-${projectId}-${agentId}`.toLowerCase();
}
function makeAiService(overrides: Partial<AiService> = {}): AiService {
const aiService = mock<AiService>();
aiService.isProxyEnabled.mockReturnValue(false);
@@ -145,7 +152,7 @@ describe('AgentSandboxRuntimeService', () => {
createFilesystemMock.mockReturnValue(mock<WorkspaceFilesystem>());
});
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,
]);
});
});
@@ -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();
});
});
@@ -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<N8nMemory>();
const memory = mock<MemoryImplementation>();
const attachmentService = mock<AgentChatAttachmentService>();
const workspaceService = mock<AgentWorkspaceService>();
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}`);
@@ -105,6 +105,7 @@ describe('AgentTestRunService', () => {
expect.objectContaining({
agentId,
projectId,
user,
source: 'instance-ai',
memory: {
threadId: result.sessionId,
@@ -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(
@@ -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<WorkspaceFilesystem>();
const sandbox = mock<WorkspaceSandbox>({
id: 'sandbox-id',
@@ -17,8 +24,11 @@ function makeService(sandboxEnabled = true) {
status: 'running',
});
const runtimeService = mock<AgentSandboxRuntimeService>();
runtimeService.isEnabled.mockReturnValue(sandboxEnabled);
runtimeService.acquireSandbox.mockResolvedValue({
const checkpointStorage = mock<N8NCheckpointStorage>();
const agentsConfig = mock<AgentsConfig>({ 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<Logger>(), runtimeService),
service: new AgentWorkspaceService(
mock<Logger>(),
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<string>) => 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();
});
});
@@ -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<CredentialProvider>(),
'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<CredentialProvider>(),
'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<CredentialProvider>(),
'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<AgentSandboxRuntimeService>({
isEnabled: () => true,
}),
});
await expect(
service.reconstructFromAgentEntity(
makeAgentEntity(),
mock<CredentialProvider>(),
'production',
),
).resolves.toEqual(expect.objectContaining({ agent: builtAgent }));
expect(builtAgent.workspace).not.toHaveBeenCalled();
).rejects.toThrow('workspace scope is missing');
});
});
@@ -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<AgentsConfig>({
modules: [],
sandboxEnabled: agentsSandboxEnabled,
});
Container.set(AgentsConfig, agentsConfig);
'keeps knowledge base ($sandboxEnabled) and proxy ($proxyEnabled) availability independent',
async ({ sandboxEnabled, proxyEnabled }) => {
Container.set(AgentsConfig, mock<AgentsConfig>({ modules: [] }));
Container.set(
SandboxSettingsService,
new SandboxSettingsService(
{
agents: agentsConfig,
instanceAi: mock<InstanceAiConfig>({
sandboxEnabled: instanceAiSandboxEnabled,
sandboxProvider: 'n8n-sandbox',
}),
deployment: { type: 'default' },
} as never,
mock<InstanceCredentialBroker>(),
mock<Logger>(),
),
mock<SandboxSettingsService>({ isAgentSandboxEnabled: () => sandboxEnabled }),
);
Container.set(AiService, mock<AiService>({ isProxyEnabled: () => proxyEnabled }));
const settings = await module.settings();
expect(settings.knowledgeBaseEnabled).toBe(knowledgeBaseEnabled);
expect(settings.knowledgeBaseEnabled).toBe(sandboxEnabled);
expect(settings.proxyEnabled).toBe(proxyEnabled);
},
);
@@ -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 () => {
@@ -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 };
}
}
@@ -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<StreamChunk> {
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,
@@ -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<string> {
@@ -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 })),
@@ -525,7 +525,10 @@ export class AgentKnowledgeMirrorService {
projectId: string,
agentId: string,
): Promise<AgentKnowledgeMirrorRuntime> {
const runtime = await this.agentSandboxRuntimeService.acquireSandbox(projectId, agentId);
const runtime = await this.agentSandboxRuntimeService.acquireKnowledgeSandbox(
projectId,
agentId,
);
return { ...runtime, paths: getAgentKnowledgePaths(runtime.provider) };
}
}
@@ -89,9 +89,11 @@ export class AgentKnowledgeService {
return files.map((file) => toAgentFileDto(file));
}
async warmSandbox(agentId: string, projectId: string): Promise<void> {
async warmKnowledgeSandbox(agentId: string, projectId: string): Promise<void> {
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<void> {
@@ -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<void> {
@@ -136,8 +142,8 @@ export class AgentKnowledgeService {
}
/** Best-effort passthrough for agent/project deletion; never throws. */
async destroySandbox(projectId: string, agentId: string): Promise<void> {
await this.agentSandboxRuntimeService.destroySandbox(projectId, agentId);
async destroyKnowledgeSandbox(projectId: string, agentId: string): Promise<void> {
await this.agentSandboxRuntimeService.destroyKnowledgeSandbox(projectId, agentId);
}
/** Stores the file's bytes via AgentKnowledgeFileStore, then reserves its DB row. */
@@ -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<string, AgentRuntime>(30 * Time.minutes.toMilliseconds);
private readonly runtimes = new TtlMap<string, AgentRuntime>(
30 * Time.minutes.toMilliseconds,
undefined,
(runtime) => this.closeAgentResources(runtime.agent, runtime.agentId),
);
private readonly runtimeInitializations = new Map<string, RuntimeInitialization>();
private readonly activeRuntimeLeases = new WeakMap<RuntimeAgent, number>();
private readonly runtimesPendingClose = new WeakMap<RuntimeAgent, string>();
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<void> }, 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<AgentRuntime> {
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<AgentRuntime> {
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,
@@ -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<void> {
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,
@@ -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;
}
@@ -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<string, string> {
return {
[LABEL_PROJECT_ID]: projectId,
[LABEL_AGENT_ID]: agentId,
[LABEL_SANDBOX_KIND]: 'workspace',
[LABEL_PRINCIPAL_HASH]: principalHash,
};
}
function buildScopeLabels(projectId: string, agentId: string): Record<string, string> {
function buildKnowledgeLabels(projectId: string, agentId: string): Record<string, string> {
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<void> {
async warmKnowledgeSandbox(projectId: string, agentId: string): Promise<void> {
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<void> {
const sandboxName = buildSandboxName({
async destroyWorkspaceSandbox(
projectId: string,
agentId: string,
principalHash: AgentSandboxPrincipalHash,
): Promise<void> {
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<void> {
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<AgentSandboxRuntime> {
async acquireWorkspaceSandbox(
projectId: string,
agentId: string,
principalHash: AgentSandboxPrincipalHash,
): Promise<AgentSandboxRuntime> {
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<AgentSandboxRuntime> {
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<string, string>,
cacheKey: string,
daytonaLifecycle: DaytonaSandboxLifecycle,
): Promise<AgentSandboxRuntime> {
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<string, string>,
): Promise<void> {
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<string, string>,
): Promise<void> {
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<string, string>,
cacheKey: string,
daytonaLifecycle: DaytonaSandboxLifecycle,
): Promise<AgentSandboxRuntime> {
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<string, string>,
lifecycle: DaytonaSandboxLifecycle = {},
): Promise<DaytonaSandboxConfig> {
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()) {
@@ -44,7 +44,7 @@ export class AgentSandboxController {
agentId: string,
): Promise<void> {
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,
@@ -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. */
@@ -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<WorkflowAgentRunOutcome> {
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<ExecuteAgentData> {
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<ExecuteAgentData> {
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) {
@@ -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<string>();
constructor(
private readonly logger: Logger,
private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService,
private readonly checkpointStorage: N8NCheckpointStorage,
private readonly agentsConfig: AgentsConfig,
) {}
async getAgentWorkspace(projectId: string, agentId: string): Promise<Workspace> {
async getAgentWorkspace(
projectId: string,
agentId: string,
principalHash: AgentSandboxPrincipalHash,
): Promise<Workspace> {
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<void> {
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);
});
}
}
}
@@ -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);
@@ -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<typeof AgentChatBridge>[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',
@@ -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<AgentCheckpointRepository>();
@@ -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<AgentCheckpoint> = {},
) =>
({
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,
);
});
});
@@ -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<StreamChunk>;
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;
@@ -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<Set<string> | 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<string>();
for (const checkpoint of checkpoints) {
if (checkpoint.expired || checkpoint.state === null) continue;
try {
const state = jsonParse<SerializableAgentState>(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');
@@ -24,6 +24,14 @@ export class AgentCheckpointRepository extends Repository<AgentCheckpoint> {
});
}
async findForSandboxReconciliation(agentId: string): Promise<AgentCheckpoint[]> {
return await this.find({
where: { agentId, expired: false },
order: { updatedAt: 'DESC' },
take: 101,
});
}
async claimForResume(
runId: string,
agentId: string,
@@ -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),
);
@@ -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,
@@ -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,
},
{
@@ -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<AgentSandboxPrincipalHash | undefined> {
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;
@@ -33,7 +33,7 @@ function getToolHandler(tool: BuiltTool): NonNullable<BuiltTool['handler']> {
}
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<AgentKnowledgeMirrorService>();
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<AgentKnowledgeMirrorService>();
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<AgentKnowledgeMirrorService>();
knowledgeMirrorService.readKnowledge.mockResolvedValue({
file: 'notes.txt',
@@ -48,7 +48,7 @@ vi.mock('../mcp-mock-fetch', () => ({ createMcpMockFetch: vi.fn(() => vi.fn()) }
vi.mock('../mock-handler', () => ({ createLlmMockHandler: vi.fn() }));
const logger = mock<Logger>();
const user = mock<User>();
const user = mock<User>({ 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 () => {
@@ -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);
@@ -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);
});
@@ -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',
@@ -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);
}
}
+16 -15
View File
@@ -19,16 +19,28 @@ export class TtlMap<K, V> {
* @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<K, V> {
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<K, V> {
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);
}
}
@@ -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