feat(core): Add n8n Sandbox support to Agent Knowledge (#35811)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robin Braumann 2026-08-11 13:18:19 +02:00 committed by GitHub
parent 5cb6d2c565
commit 6a30736841
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1300 additions and 1299 deletions

View File

@ -118,24 +118,65 @@ describe('start()', () => {
await sandbox.start();
expect(mockCreateSandbox).toHaveBeenCalledTimes(1);
expect(mockCreateSandbox).toHaveBeenCalledWith(undefined);
expect(mockCreateSandbox.mock.calls[0]).toEqual([]);
expect(sandbox.id).toBe('sb-123');
});
it('times out stalled creation and removes a sandbox created after the timeout', async () => {
let resolveCreation!: (record: ReturnType<typeof makeSandboxRecord>) => void;
mockCreateSandbox.mockReturnValue(
new Promise((resolve) => {
resolveCreation = resolve;
}),
);
const sandbox = new N8nSandboxServiceSandbox({
...makeDefaultOptions(),
timeout: 10,
});
await expect(sandbox.start()).rejects.toThrow(/abort|timeout/i);
resolveCreation(makeSandboxRecord({ id: 'late-sandbox' }));
await vi.waitFor(() => expect(mockDeleteSandbox).toHaveBeenCalledWith('late-sandbox'));
});
});
describe('create-or-reconnect with a configured id', () => {
it('passes the configured id so the service creates or reconnects', async () => {
mockCreateSandbox.mockResolvedValue(makeSandboxRecord({ id: 'existing-sb' }));
it('creates or reconnects through the idempotent create endpoint', async () => {
const id = '11111111-1111-4111-8111-111111111111';
mockCreateSandbox.mockResolvedValue(makeSandboxRecord({ id }));
const sandbox = new N8nSandboxServiceSandbox({
...makeDefaultOptions(),
id: 'existing-sb',
id,
});
await sandbox.start();
expect(mockCreateSandbox).toHaveBeenCalledTimes(1);
expect(mockCreateSandbox).toHaveBeenCalledWith({ id: 'existing-sb' });
expect(sandbox.id).toBe('existing-sb');
expect(mockCreateSandbox).toHaveBeenCalledWith({ id });
expect(mockGetSandbox).not.toHaveBeenCalled();
expect(sandbox.id).toBe(id);
});
it('does not delete a deterministic sandbox when creation times out', async () => {
let resolveCreation!: (record: ReturnType<typeof makeSandboxRecord>) => void;
mockCreateSandbox.mockReturnValue(
new Promise((resolve) => {
resolveCreation = resolve;
}),
);
const id = '22222222-2222-4222-8222-222222222222';
const sandbox = new N8nSandboxServiceSandbox({
...makeDefaultOptions(),
id,
timeout: 10,
});
await expect(sandbox.start()).rejects.toThrow(/abort|timeout/i);
resolveCreation(makeSandboxRecord({ id }));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockDeleteSandbox).not.toHaveBeenCalled();
});
it('reuses the last known id when restarted after a stop', async () => {

View File

@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { BaseSandbox } from './base-sandbox';
import { raceWithAbort } from '../../sdk/abort';
import type { CommandResult, ExecuteCommandOptions, ProviderStatus, SandboxInfo } from '../types';
export interface N8nSandboxServiceSandboxOptions {
@ -60,13 +61,7 @@ export class N8nSandboxServiceSandbox extends BaseSandbox {
}
override async start(): Promise<void> {
// With an id, the service creates-or-reconnects: an existing live sandbox
// is returned as-is, a stale one is replaced under the same id. This is
// what lets a restarted process (or another main) reattach to a thread's
// sandbox instead of orphaning it.
const sandbox = await this.client.createSandbox(
this.sandboxId ? { id: this.sandboxId } : undefined,
);
const sandbox = await this.createSandbox();
this.sandboxId = sandbox.id;
this.createdAt = new Date(sandbox.createdAt * 1000);
}
@ -74,7 +69,7 @@ export class N8nSandboxServiceSandbox extends BaseSandbox {
override async destroy(): Promise<void> {
if (!this.sandboxId) return;
try {
await this.client.deleteSandbox(this.sandboxId);
await this.withLifecycleTimeout(this.client.deleteSandbox(this.sandboxId));
} catch (error) {
if (error instanceof SandboxServiceError && error.status === 404) return;
throw error;
@ -144,6 +139,25 @@ export class N8nSandboxServiceSandbox extends BaseSandbox {
return this.client;
}
private async createSandbox() {
const existingId = this.sandboxId;
const creation = existingId
? this.client.createSandbox({ id: existingId })
: this.client.createSandbox();
try {
return await this.withLifecycleTimeout(creation);
} catch (error) {
if (!existingId) {
void creation.then(async ({ id }) => await this.client.deleteSandbox(id)).catch(() => {});
}
throw error;
}
}
private async withLifecycleTimeout<T>(operation: Promise<T>): Promise<T> {
return await raceWithAbort(operation, AbortSignal.timeout(this.timeout));
}
/** Merges constructor-level env with per-command env, filtering out undefined values. */
private compactEnv(env: NodeJS.ProcessEnv | undefined): Record<string, string> | undefined {
const merged = {

View File

@ -7,15 +7,19 @@ export const DAYTONA_WORKSPACE_ROOT = `${DAYTONA_HOME}/${WORKSPACE_DIR}`;
export const N8N_SANDBOX_HOME = '/home/user';
export const N8N_SANDBOX_WORKSPACE_ROOT = `${N8N_SANDBOX_HOME}/${WORKSPACE_DIR}`;
export function getPromptWorkspaceRoot(provider: SandboxProvider): string {
export function getSandboxHome(provider: SandboxProvider): string {
switch (provider) {
case 'daytona':
return DAYTONA_WORKSPACE_ROOT;
return DAYTONA_HOME;
case 'n8n-sandbox':
return N8N_SANDBOX_WORKSPACE_ROOT;
return N8N_SANDBOX_HOME;
}
}
export function getPromptWorkspaceRoot(provider: SandboxProvider): string {
return `${getSandboxHome(provider)}/${WORKSPACE_DIR}`;
}
export interface SandboxWorkspace extends SandboxCommandTarget {
filesystem?: {
provider?: string;
@ -45,15 +49,8 @@ async function initializeLazyFilesystem(workspace: SandboxWorkspace): Promise<vo
}
function getFallbackHome(workspace: SandboxWorkspace): string {
switch (workspace.sandbox?.provider) {
case 'n8n-sandbox':
return N8N_SANDBOX_HOME;
case 'daytona':
return DAYTONA_HOME;
case undefined:
default:
return DAYTONA_HOME;
}
const provider = workspace.sandbox?.provider;
return getSandboxHome(provider === 'n8n-sandbox' ? provider : 'daytona');
}
export async function getWorkspaceRoot(workspace: SandboxWorkspace): Promise<string> {

View File

@ -1,4 +1,4 @@
export const MAX_AGENT_FILE_SIZE_MB = 50;
export const MAX_AGENT_FILE_SIZE_MB = 10;
export const MAX_AGENT_FILE_SIZE_BYTES = MAX_AGENT_FILE_SIZE_MB * 1024 * 1024;
export const MAX_AGENT_FILES_PER_UPLOAD = 10;
export const MAX_AGENT_KNOWLEDGE_BASE_SIZE_GB = 1.5;

View File

@ -396,10 +396,8 @@ export type FrontendModuleSettings = {
*/
modules: string[];
/**
* Whether the agent knowledge base is enabled. True when the backend's
* Daytona sandbox env vars (`N8N_AGENTS_AI_SANDBOX_ENABLED=true` +
* `N8N_AGENTS_AI_SANDBOX_PROVIDER=daytona`) are set, OR the AI Assistant
* proxy is available.
* Whether the agent knowledge base is enabled by the backend's
* `N8N_AGENTS_AI_SANDBOX_ENABLED` opt-in.
*/
knowledgeBaseEnabled: boolean;
/** Whether the AI Assistant proxy is available to the agents module. */

View File

@ -71,14 +71,10 @@ export class AgentsConfig {
@Env('N8N_AGENTS_MODULES')
modules: AgentsModuleArray = [];
/** Enable Daytona sandbox for agent knowledge base operations. */
/** Enable sandbox-backed agent knowledge base operations. */
@Env('N8N_AGENTS_AI_SANDBOX_ENABLED')
sandboxEnabled: boolean = false;
/** Sandbox provider for agent knowledge base. Only `daytona` is supported. */
@Env('N8N_AGENTS_AI_SANDBOX_PROVIDER')
sandboxProvider: string = '';
/** Docker image for the Daytona sandbox (default: daytonaio/sandbox:0.5.0). */
@Env('N8N_AGENTS_AI_SANDBOX_IMAGE')
sandboxImage: string = 'daytonaio/sandbox:0.5.0';
@ -97,12 +93,4 @@ export class AgentsConfig {
/** When true, Daytona deletes the knowledge sandbox when it stops. */
@Env('N8N_AGENTS_AI_SANDBOX_EPHEMERAL')
sandboxEphemeral: boolean = false;
/** Daytona API URL (e.g. "https://app.daytona.io/api"). */
@Env('DAYTONA_API_URL')
daytonaApiUrl: string = '';
/** Daytona API key for authentication. */
@Env('DAYTONA_API_KEY')
daytonaApiKey: string = '';
}

View File

@ -676,13 +676,10 @@ describe('GlobalConfig', () => {
tracingRecordOutputs: true,
modules: [],
sandboxEnabled: false,
sandboxProvider: '',
sandboxImage: 'daytonaio/sandbox:0.5.0',
sandboxSnapshot: 'daytonaio/sandbox:0.8.0',
sandboxTimeout: 300000,
sandboxEphemeral: false,
daytonaApiUrl: '',
daytonaApiKey: '',
},
} satisfies GlobalConfigShape;

View File

@ -1,7 +1,6 @@
import {
buildMirrorFinalizeCommand,
buildReadKnowledgeCommand,
buildReadMirrorManifestCommand,
buildSearchKnowledgeCommand,
getSearchContextWindow,
MIRROR_SYNC_TIMEOUT_SECONDS,
@ -9,7 +8,7 @@ import {
parseRipgrepCountOutput,
parseRipgrepOutput,
} from '../agent-knowledge-commands';
import { KNOWLEDGE_MIRROR_FILES_DIR, KNOWLEDGE_MIRROR_MANIFEST } from '../agent-knowledge-storage';
import { getAgentKnowledgePaths } from '../agent-knowledge-storage';
import {
searchKnowledgeInputSchema,
type AgentKnowledgeFileReference,
@ -28,6 +27,7 @@ const mobyDickFile: AgentKnowledgeFileReference = {
const filesByPath = new Map([[mobyDickFile.file, mobyDickFile]]);
const bearerSecret = 'Authorization: Bearer abc.def-ghi_jkl/mno=012345678901234567890123456789';
const knowledgePaths = getAgentKnowledgePaths('daytona');
function rgEvent(
type: 'match' | 'context',
@ -198,42 +198,55 @@ describe('agent knowledge commands', () => {
});
});
describe('buildReadMirrorManifestCommand', () => {
it('reads the manifest and tolerates a missing file', () => {
const command = buildReadMirrorManifestCommand();
expect(command).toBe(`cat ${KNOWLEDGE_MIRROR_MANIFEST} 2>/dev/null || true`);
});
});
describe('buildMirrorFinalizeCommand', () => {
it('moves already-uploaded .tmp- names into place atomically', () => {
const command = buildMirrorFinalizeCommand(['doc1.txt'], [], ['doc1.txt']);
it('moves per-sync staged files and manifest into place atomically', () => {
const command = buildMirrorFinalizeCommand(
['doc1.txt'],
[],
[{ file: 'doc1.txt', fileId: 'file-1' }],
knowledgePaths,
'sync-1',
);
expect(command).toContain(`mkdir -p ${KNOWLEDGE_MIRROR_FILES_DIR}`);
expect(command).toContain(`${KNOWLEDGE_MIRROR_FILES_DIR}/.tmp-doc1.txt`);
expect(command).toContain(`${KNOWLEDGE_MIRROR_FILES_DIR}/doc1.txt`);
expect(command).toMatch(/mv\s+.*\.tmp-doc1\.txt.*doc1\.txt/);
expect(command).toContain(`mkdir -p ${knowledgePaths.filesDir}`);
expect(command).toContain(`${knowledgePaths.stagingDir}/sync-1/doc1.txt`);
expect(command).not.toContain(`${knowledgePaths.filesDir}/.tmp-`);
expect(command).toContain(`${knowledgePaths.filesDir}/doc1.txt`);
expect(command).toMatch(/mv\s+.*\.staging\/sync-1\/doc1\.txt.*doc1\.txt/);
expect(command).toContain(`> ${knowledgePaths.manifest}.sync-1.tmp`);
expect(command).toContain(
`mv ${knowledgePaths.manifest}.sync-1.tmp ${knowledgePaths.manifest}`,
);
expect(command).toContain('file-1\tdoc1.txt');
expect(command).toContain(`timeout ${MIRROR_SYNC_TIMEOUT_SECONDS}`);
});
it('removes deleted names with rm -f', () => {
const command = buildMirrorFinalizeCommand([], ['stale.txt'], []);
const command = buildMirrorFinalizeCommand([], ['stale.txt'], [], knowledgePaths, 'sync-1');
expect(command).toContain('rm -f');
expect(command).toContain(`${KNOWLEDGE_MIRROR_FILES_DIR}/stale.txt`);
});
it('writes the full manifest via a temp file and mv', () => {
const command = buildMirrorFinalizeCommand([], [], ['a.txt', 'b.txt']);
expect(command).toContain(`> ${KNOWLEDGE_MIRROR_MANIFEST}.tmp`);
expect(command).toContain(`mv ${KNOWLEDGE_MIRROR_MANIFEST}.tmp ${KNOWLEDGE_MIRROR_MANIFEST}`);
expect(command).toContain(`${knowledgePaths.filesDir}/stale.txt`);
});
it('rejects names that look like path traversal', () => {
expect(() => buildMirrorFinalizeCommand(['../secret'], [], ['../secret'])).toThrow();
expect(() => buildMirrorFinalizeCommand([], [], ['nested/name.txt'])).toThrow();
expect(() =>
buildMirrorFinalizeCommand(
['../secret'],
[],
[{ file: '../secret', fileId: 'file-1' }],
knowledgePaths,
'sync-1',
),
).toThrow();
expect(() =>
buildMirrorFinalizeCommand(
[],
[],
[{ file: 'nested/name.txt', fileId: 'file-1' }],
knowledgePaths,
'sync-1',
),
).toThrow();
});
});

View File

@ -1,33 +0,0 @@
import { isAgentKnowledgeBaseEnabled } from '../agent-knowledge-gate';
describe('isAgentKnowledgeBaseEnabled', () => {
it('is enabled when direct Daytona sandbox config is complete, even without proxy', () => {
expect(
isAgentKnowledgeBaseEnabled({ sandboxEnabled: true, sandboxProvider: 'daytona' }, false),
).toBe(true);
});
it('is enabled when the AI Assistant proxy is available, even without sandbox env vars', () => {
expect(isAgentKnowledgeBaseEnabled({ sandboxEnabled: false, sandboxProvider: '' }, true)).toBe(
true,
);
});
it('is enabled when the proxy is available even if the sandbox provider is not daytona', () => {
expect(
isAgentKnowledgeBaseEnabled({ sandboxEnabled: true, sandboxProvider: 'n8n-sandbox' }, true),
).toBe(true);
});
it('is disabled when neither the sandbox config nor the proxy is available', () => {
expect(isAgentKnowledgeBaseEnabled({ sandboxEnabled: false, sandboxProvider: '' }, false)).toBe(
false,
);
});
it('is disabled when sandboxEnabled is true but the provider is not daytona and the proxy is unavailable', () => {
expect(
isAgentKnowledgeBaseEnabled({ sandboxEnabled: true, sandboxProvider: 'n8n-sandbox' }, false),
).toBe(false);
});
});

View File

@ -5,7 +5,6 @@ import multer from 'multer';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { AiService } from '@/services/ai.service';
import { AgentKnowledgeController } from '../agent-knowledge.controller';
import type { AgentKnowledgeService } from '../agent-knowledge.service';
@ -19,26 +18,21 @@ function makeController({
agentKnowledgeService = mock<AgentKnowledgeService>(),
agentsConfig = {
sandboxEnabled: true,
sandboxProvider: 'daytona',
} as AgentsConfig,
runtimeCacheService = mock<AgentRuntimeCacheService>(),
aiService = mock<AiService>({ isProxyEnabled: () => false }),
}: {
agentKnowledgeService?: Mocked<AgentKnowledgeService>;
agentsConfig?: AgentsConfig;
runtimeCacheService?: Mocked<AgentRuntimeCacheService>;
aiService?: Mocked<AiService>;
} = {}) {
return {
controller: new AgentKnowledgeController(
agentKnowledgeService,
agentsConfig,
runtimeCacheService,
aiService,
),
agentKnowledgeService,
runtimeCacheService,
aiService,
};
}
@ -137,7 +131,6 @@ describe('AgentKnowledgeController knowledge base gating', () => {
const { controller } = makeController({
agentsConfig: {
sandboxEnabled: false,
sandboxProvider: 'daytona',
} as AgentsConfig,
});

View File

@ -414,7 +414,7 @@ describe('AgentKnowledgeService', () => {
expect(agentKnowledgeSandboxService.warmSandbox).toHaveBeenCalledWith(projectId, agentId);
});
it('invalidates and pre-warms the mirror after a successful upload', async () => {
it('pre-warms the mirror after a successful upload', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue({
id: agentId,
projectId,
@ -433,14 +433,13 @@ describe('AgentKnowledgeService', () => {
}),
]);
expect(agentKnowledgeSandboxService.invalidateMirror).toHaveBeenCalledWith(projectId, agentId);
expect(agentKnowledgeSandboxService.prewarmMirrorInBackground).toHaveBeenCalledWith(
projectId,
agentId,
);
});
it('invalidates and pre-warms the mirror after a file deletion', async () => {
it('pre-warms the mirror after a file deletion', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue({
id: agentId,
projectId,
@ -450,7 +449,6 @@ describe('AgentKnowledgeService', () => {
await service.deleteFile(agentId, projectId, 'file-1');
expect(agentKnowledgeSandboxService.invalidateMirror).toHaveBeenCalledWith(projectId, agentId);
expect(agentKnowledgeSandboxService.prewarmMirrorInBackground).toHaveBeenCalledWith(
projectId,
agentId,

View File

@ -4,23 +4,15 @@ import type { AuthenticatedRequest } from '@n8n/db';
import type { Response } from 'express';
import { mock } from 'vitest-mock-extended';
import type { AiService } from '@/services/ai.service';
import type { AgentKnowledgeService } from '../agent-knowledge.service';
import { AgentSandboxController } from '../agent-sandbox.controller';
describe('AgentSandboxController', () => {
it('accepts knowledge sandbox warmup before files exist', async () => {
const agentKnowledgeService = mock<AgentKnowledgeService>();
const controller = new AgentSandboxController(
agentKnowledgeService,
mock<Logger>(),
{
sandboxEnabled: true,
sandboxProvider: 'daytona',
} as AgentsConfig,
mock<AiService>({ isProxyEnabled: () => false }),
);
const controller = new AgentSandboxController(agentKnowledgeService, mock<Logger>(), {
sandboxEnabled: true,
} as AgentsConfig);
const req = { user: { id: 'user-1' } } as AuthenticatedRequest<{ projectId: string }>;
const res = mock<Response>();
@ -34,4 +26,21 @@ describe('AgentSandboxController', () => {
expect(res.status).toHaveBeenCalledWith(202);
expect(agentKnowledgeService.warmSandbox).toHaveBeenCalledWith('agent-1', 'project-1');
});
it('rejects warmup when the knowledge base is disabled', async () => {
const agentKnowledgeService = mock<AgentKnowledgeService>();
const controller = new AgentSandboxController(agentKnowledgeService, mock<Logger>(), {
sandboxEnabled: false,
} as AgentsConfig);
await expect(
controller.warmKnowledgeSandbox(
{} as AuthenticatedRequest<{ projectId: string }>,
mock<Response>(),
'project-1',
'agent-1',
),
).rejects.toThrow('Agent knowledge base is not enabled');
expect(agentKnowledgeService.warmSandbox).not.toHaveBeenCalled();
});
});

View File

@ -31,7 +31,7 @@ describe('describeMulterError', () => {
it('explains the file size limit', () => {
const error = new multer.MulterError('LIMIT_FILE_SIZE', 'files');
expect(describeMulterError(error)).toBe('Files must be 50 MB or smaller');
expect(describeMulterError(error)).toBe('Files must be 10 MB or smaller');
});
it('falls back to the multer message for other error codes', () => {

View File

@ -15,55 +15,26 @@ describe('AgentsModule', () => {
});
describe('settings()', () => {
it('exposes knowledgeBaseEnabled when the direct Daytona sandbox config is complete', async () => {
Container.set(
AgentsConfig,
mock<AgentsConfig>({
modules: [],
sandboxEnabled: true,
sandboxProvider: 'daytona',
}),
);
Container.set(AiService, mock<AiService>({ isProxyEnabled: () => false }));
it.each([
{ sandboxEnabled: true, proxyEnabled: false },
{ sandboxEnabled: false, proxyEnabled: true },
])(
'keeps knowledge base ($sandboxEnabled) and proxy ($proxyEnabled) availability independent',
async ({ sandboxEnabled, proxyEnabled }) => {
Container.set(
AgentsConfig,
mock<AgentsConfig>({
modules: [],
sandboxEnabled,
}),
);
Container.set(AiService, mock<AiService>({ isProxyEnabled: () => proxyEnabled }));
const settings = await module.settings();
const settings = await module.settings();
expect(settings.knowledgeBaseEnabled).toBe(true);
expect(settings.proxyEnabled).toBe(false);
});
it('exposes knowledgeBaseEnabled when the AI Assistant proxy is available without sandbox env vars', async () => {
Container.set(
AgentsConfig,
mock<AgentsConfig>({
modules: [],
sandboxEnabled: false,
sandboxProvider: '',
}),
);
Container.set(AiService, mock<AiService>({ isProxyEnabled: () => true }));
const settings = await module.settings();
expect(settings.knowledgeBaseEnabled).toBe(true);
expect(settings.proxyEnabled).toBe(true);
});
it('disables knowledgeBaseEnabled when neither the sandbox config nor the proxy is available', async () => {
Container.set(
AgentsConfig,
mock<AgentsConfig>({
modules: [],
sandboxEnabled: false,
sandboxProvider: '',
}),
);
Container.set(AiService, mock<AiService>({ isProxyEnabled: () => false }));
const settings = await module.settings();
expect(settings.knowledgeBaseEnabled).toBe(false);
expect(settings.proxyEnabled).toBe(false);
});
expect(settings.knowledgeBaseEnabled).toBe(sandboxEnabled);
expect(settings.proxyEnabled).toBe(proxyEnabled);
},
);
});
});

View File

@ -16,11 +16,7 @@ import {
type SearchKnowledgeMatch,
type SearchKnowledgeRequest,
} from './agent-knowledge-retrieval';
import {
assertKnowledgePathSegment,
KNOWLEDGE_MIRROR_FILES_DIR,
KNOWLEDGE_MIRROR_MANIFEST,
} from './agent-knowledge-storage';
import { assertKnowledgePathSegment, type AgentKnowledgePaths } from './agent-knowledge-storage';
const COMMAND_TIMEOUT_SECONDS = 20;
export const MIRROR_SYNC_TIMEOUT_SECONDS = 120;
@ -478,13 +474,7 @@ function decodeRipgrepJsonData(value: RipgrepEncodedText): string | undefined {
}
function normalizeRipgrepPath(filePath: string): string {
if (filePath.startsWith(`${KNOWLEDGE_MIRROR_FILES_DIR}/`)) {
return filePath.slice(KNOWLEDGE_MIRROR_FILES_DIR.length + 1);
}
if (filePath.startsWith('./')) {
return filePath.slice(2);
}
return filePath;
return filePath.startsWith('./') ? filePath.slice(2) : filePath;
}
function stripTrailingNewline(text: string): string {
@ -553,51 +543,57 @@ function buildAwkPipeline(command: string, script: string): string {
].join('; ');
}
export function buildScopedKnowledgeShellCommand(command: string): string {
export function buildScopedKnowledgeShellCommand(
command: string,
paths: AgentKnowledgePaths,
): string {
const scopedCommand = [
`[ -d ${quoteShellArg(KNOWLEDGE_MIRROR_FILES_DIR)} ] || exit ${KNOWLEDGE_FILES_DIR_UNAVAILABLE_EXIT_CODE}`,
`cd ${quoteShellArg(KNOWLEDGE_MIRROR_FILES_DIR)} || exit ${KNOWLEDGE_FILES_DIR_UNAVAILABLE_EXIT_CODE}`,
`[ -d ${quoteShellArg(paths.filesDir)} ] || exit ${KNOWLEDGE_FILES_DIR_UNAVAILABLE_EXIT_CODE}`,
`cd ${quoteShellArg(paths.filesDir)} || exit ${KNOWLEDGE_FILES_DIR_UNAVAILABLE_EXIT_CODE}`,
`{ ${command}; }`,
].join('; ');
return `bash -o pipefail -c ${quoteShellArg(scopedCommand)}`;
}
export function buildReadMirrorManifestCommand(): string {
return `cat ${KNOWLEDGE_MIRROR_MANIFEST} 2>/dev/null || true`;
}
/**
* Finalizes a mirror sync: moves `toCopy` names from their already-uploaded
* `.tmp-` staging path into place, removes `toDelete` names, and rewrites the
* manifest to `manifestNames`. The `.tmp-` + `mv` staging means a search
* per-sync staging path into place, removes `toDelete` names, and rewrites the
* manifest to `manifestFiles`. The staging + `mv` sequence means a search
* running concurrently never sees a partially-written file.
*/
export function buildMirrorFinalizeCommand(
toCopy: string[],
toDelete: string[],
manifestNames: string[],
manifestFiles: Array<Pick<AgentKnowledgeFileReference, 'file' | 'fileId'>>,
paths: AgentKnowledgePaths,
stagingId: string,
): string {
for (const name of [...toCopy, ...toDelete, ...manifestNames]) {
for (const name of [...toCopy, ...toDelete, ...manifestFiles.map((file) => file.file)]) {
assertKnowledgePathSegment(name, 'knowledge mirror file name');
}
assertKnowledgePathSegment(stagingId, 'knowledge mirror staging ID');
const commands = [`mkdir -p ${KNOWLEDGE_MIRROR_FILES_DIR}`];
const commands = [`mkdir -p ${paths.filesDir}`];
for (const name of toCopy) {
const tmpPath = quoteShellArg(`${KNOWLEDGE_MIRROR_FILES_DIR}/.tmp-${name}`);
const finalPath = quoteShellArg(`${KNOWLEDGE_MIRROR_FILES_DIR}/${name}`);
const tmpPath = quoteShellArg(`${paths.stagingDir}/${stagingId}/${name}`);
const finalPath = quoteShellArg(`${paths.filesDir}/${name}`);
commands.push(`mv ${tmpPath} ${finalPath}`);
}
if (toDelete.length > 0) {
const targets = toDelete.map((name) => quoteShellArg(`${KNOWLEDGE_MIRROR_FILES_DIR}/${name}`));
const targets = toDelete.map((name) => quoteShellArg(`${paths.filesDir}/${name}`));
commands.push(`rm -f ${targets.join(' ')}`);
}
const manifestBody = manifestNames.length > 0 ? `${manifestNames.join('\n')}\n` : '';
const manifestBody =
manifestFiles.length > 0
? `${manifestFiles.map(({ file, fileId }) => `${fileId}\t${file}`).join('\n')}\n`
: '';
const stagedManifest = `${paths.manifest}.${stagingId}.tmp`;
commands.push(
`printf '%s' ${quoteShellArg(manifestBody)} > ${KNOWLEDGE_MIRROR_MANIFEST}.tmp`,
`mv ${KNOWLEDGE_MIRROR_MANIFEST}.tmp ${KNOWLEDGE_MIRROR_MANIFEST}`,
`printf '%s' ${quoteShellArg(manifestBody)} > ${stagedManifest}`,
`mv ${stagedManifest} ${paths.manifest}`,
);
return `timeout ${MIRROR_SYNC_TIMEOUT_SECONDS} bash -o pipefail -c ${quoteShellArg(commands.join(' && '))}`;

View File

@ -1,8 +0,0 @@
import type { AgentsConfig } from '@n8n/config';
export function isAgentKnowledgeBaseEnabled(
config: Pick<AgentsConfig, 'sandboxEnabled' | 'sandboxProvider'>,
aiAssistantAvailable: boolean,
): boolean {
return aiAssistantAvailable || (config.sandboxEnabled && config.sandboxProvider === 'daytona');
}

View File

@ -1,25 +1,31 @@
import type { Sandbox, SandboxState } from '@daytona/sdk';
import { redactText } from '@n8n/agents';
import { loadDaytona } from '@n8n/agents/sandbox';
import {
createFilesystem,
createSandbox,
type CommandResult,
type DaytonaSandboxConfig,
type N8nSandboxConfig,
type SandboxProvider,
type WorkspaceFilesystem,
type WorkspaceSandbox,
} from '@n8n/agents/sandbox';
import { Logger } from '@n8n/backend-common';
import { AgentsConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import { OperationalError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { createHash } from 'node:crypto';
import { v5 as uuidv5 } from 'uuid';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AiService } from '@/services/ai.service';
import { SandboxSettingsService } from '@/services/sandbox-settings.service';
import { callAiServiceWithRetry } from '@/utils/ai-service-retry';
import { TtlMap } from '@/utils/ttl-map';
import {
buildMirrorFinalizeCommand,
buildReadKnowledgeCommand,
buildReadMirrorManifestCommand,
buildScopedKnowledgeShellCommand,
buildSearchKnowledgeCommand,
getSearchContextWindow,
@ -31,7 +37,6 @@ import {
parseRipgrepOutput,
} from './agent-knowledge-commands';
import { AgentKnowledgeFileStore } from './agent-knowledge-file-store';
import { isAgentKnowledgeBaseEnabled } from './agent-knowledge-gate';
import {
assertValidKnowledgeFilePath,
DEFAULT_GLOB_FILES_LIMIT,
@ -49,51 +54,30 @@ import {
} from './agent-knowledge-retrieval';
import {
assertKnowledgePathSegment,
KNOWLEDGE_MIRROR_FILES_DIR,
getAgentKnowledgePaths,
type AgentKnowledgePaths,
storageFileNameForOriginalFileName,
} from './agent-knowledge-storage';
import { AgentFileRepository } from './repositories/agent-file.repository';
import { AgentRepository } from './repositories/agent.repository';
interface AgentKnowledgeCommandResult {
exitCode: number;
stdout: string;
stderr: string;
}
export const AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX = 'agent-';
const AGENT_KNOWLEDGE_SANDBOX_NAMESPACE = '5b5fd8cd-59c1-5914-aabc-cf7257fb46bc';
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 SANDBOX_STATE_STARTED: SandboxState = 'started';
const DEAD_SANDBOX_STATES = new Set<SandboxState>([
'destroyed',
'destroying',
'error',
'build_failed',
]);
const DEFAULT_SANDBOX_IMAGE = 'daytonaio/sandbox:0.5.0';
const AUTO_STOP_INTERVAL_MINUTES = 5;
const MIRROR_MANIFEST_HASH_TTL_MS = 30 * Time.minutes.toMilliseconds;
// Cap B (1.5 GB knowledge base limit) keeps real copies well under this —
// this only guards against the sandbox disk unexpectedly filling up.
const MIRROR_DISK_FIT_WARNING_BYTES = 2 * 1024 * 1024 * 1024;
// Caps how many file bytes are held in the main process at once while
// staging a mirror sync — the KB itself can be up to 1.5 GB.
const MIRROR_UPLOAD_BATCH_BYTES = 64 * 1024 * 1024;
interface AgentKnowledgeDaytonaConnection {
apiUrl?: string;
apiKey?: string;
image: string;
snapshot?: string;
mode: 'direct' | 'proxy';
interface AgentKnowledgeSandboxRuntime {
sandbox: WorkspaceSandbox;
filesystem: WorkspaceFilesystem;
paths: AgentKnowledgePaths;
cacheKey: string;
}
interface AgentKnowledgeReferenceLookup {
@ -117,10 +101,6 @@ function emptySearchKnowledgeResult(
return { outputMode, matches: [], limit, hasMore: false, truncated: false };
}
function buildSandboxScopeKey(projectId: string, agentId: string): string {
return `${projectId}:${agentId}`;
}
// Proxy sandbox-operation routes only match `[a-z0-9][a-z0-9_-]+` — the name
// must be lowercase. instanceId groups one instance's sandboxes together;
// projectId and agentId are a globally unique nanoid pair beneath it.
@ -132,29 +112,23 @@ function buildSandboxName(scope: {
return `${AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX}${scope.instanceId}-${scope.projectId}-${scope.agentId}`.toLowerCase();
}
function extractCommandOutput(result: {
result?: string;
artifacts?: { stdout?: string };
}): string {
return result.artifacts?.stdout ?? result.result ?? '';
function buildN8nSandboxId(sandboxName: string): string {
return uuidv5(sandboxName, AGENT_KNOWLEDGE_SANDBOX_NAMESPACE);
}
function parseManifestNames(output: string): string[] {
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function hashManifestNames(names: string[]): string {
return createHash('sha256')
.update(JSON.stringify([...names].sort()))
.digest('hex');
}
function isDaytonaNotFoundError(error: unknown): boolean {
const { DaytonaNotFoundError } = loadDaytona();
return error instanceof DaytonaNotFoundError;
function parseMirrorManifest(output: string): Map<string, string> {
const manifest = new Map<string, string>();
for (const entry of output.split(/\r?\n/)) {
const line = entry.trim();
if (!line) continue;
const separator = line.indexOf('\t');
if (separator === -1) {
manifest.set(line, '');
} else {
manifest.set(line.slice(separator + 1), line.slice(0, separator));
}
}
return manifest;
}
function buildScopeLabels(projectId: string, agentId: string): Record<string, string> {
@ -165,12 +139,6 @@ function buildScopeLabels(projectId: string, agentId: string): Record<string, st
};
}
function isUsableSandbox(sandbox: Sandbox): boolean {
const state = sandbox.state;
if (!state) return true;
return !DEAD_SANDBOX_STATES.has(state);
}
function truncateSandboxErrorDetail(value: string): string {
if (value.length <= MAX_SANDBOX_ERROR_DETAIL_CHARS) return value;
return `${value.slice(0, MAX_SANDBOX_ERROR_DETAIL_CHARS)}...[truncated]`;
@ -183,7 +151,7 @@ function sanitizeSandboxErrorDetail(value: string): string {
function formatSandboxCommandFailure(
operation: 'glob' | 'read' | 'search',
result: AgentKnowledgeCommandResult,
result: CommandResult,
): string {
const stderrText = sanitizeSandboxErrorDetail(result.stderr);
const stdoutText = sanitizeSandboxErrorDetail(result.stdout);
@ -195,7 +163,7 @@ function formatSandboxCommandFailure(
function assertKnowledgeFilesDirectoryAvailable(
operation: 'glob' | 'read' | 'search',
result: AgentKnowledgeCommandResult,
result: CommandResult,
): void {
if (result.exitCode !== KNOWLEDGE_FILES_DIR_UNAVAILABLE_EXIT_CODE) return;
@ -206,8 +174,10 @@ function assertKnowledgeFilesDirectoryAvailable(
@Service()
export class AgentKnowledgeSandboxService {
private readonly pendingSandboxAcquisitions = new Map<string, Promise<Sandbox>>();
private readonly mirrorManifestHashes = new TtlMap<string, string>(MIRROR_MANIFEST_HASH_TTL_MS);
private readonly pendingSandboxAcquisitions = new Map<
string,
Promise<AgentKnowledgeSandboxRuntime>
>();
private readonly pendingMirrorSyncs = new Map<string, Promise<void>>();
constructor(
@ -218,6 +188,7 @@ export class AgentKnowledgeSandboxService {
private readonly agentFileRepository: AgentFileRepository,
private readonly agentRepository: AgentRepository,
private readonly agentKnowledgeFileStore: AgentKnowledgeFileStore,
private readonly sandboxSettingsService: SandboxSettingsService,
) {}
async warmSandbox(projectId: string, agentId: string): Promise<void> {
@ -230,28 +201,44 @@ export class AgentKnowledgeSandboxService {
* callers must not have cleanup failures block the parent delete operation.
*/
async destroySandbox(projectId: string, agentId: string): Promise<void> {
if (!this.isKnowledgeBaseEnabled()) return;
const sandboxName = buildSandboxName({
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);
}
}
private async tryDestroySandbox(
projectId: string,
agentId: string,
provider: SandboxProvider,
sandboxId: string,
): Promise<void> {
try {
const { Daytona } = loadDaytona();
const connection = await this.resolveDaytonaConnection(projectId);
const daytona = new Daytona({
apiUrl: connection.apiUrl,
apiKey: connection.apiKey,
});
const name = buildSandboxName({
instanceId: this.instanceSettings.instanceId,
projectId,
agentId,
});
const timeoutSeconds = Math.ceil(this.agentsConfig.sandboxTimeout / 1000);
const sandbox = await daytona.get(name);
await sandbox.delete(timeoutSeconds);
const config =
provider === 'daytona'
? await this.resolveDaytonaSandboxConfig(projectId, agentId, sandboxId)
: await this.resolveN8nSandboxConfig(sandboxId);
const sandbox = await createSandbox(config, { logger: this.logger });
if (!sandbox?.destroy) {
throw new OperationalError('Agent knowledge sandbox does not support provider destroy');
}
await sandbox.destroy();
} catch (error) {
this.logger.warn('Failed to destroy agent knowledge sandbox', {
projectId,
agentId,
error: error instanceof Error ? error.message : String(error),
provider,
sandboxId,
error: sanitizeSandboxErrorDetail(error instanceof Error ? error.message : String(error)),
});
}
}
@ -398,196 +385,156 @@ export class AgentKnowledgeSandboxService {
agentId: string,
command: string,
files: AgentKnowledgeFileReference[],
): Promise<AgentKnowledgeCommandResult> {
const timeoutSeconds = Math.ceil(this.agentsConfig.sandboxTimeout / 1000);
const scopedCommand = buildScopedKnowledgeShellCommand(command);
const sandbox = await this.acquireSandbox(projectId, agentId);
const sandboxName = buildSandboxName({
instanceId: this.instanceSettings.instanceId,
projectId,
agentId,
});
await this.ensureMirrorSynced(sandbox, files, sandboxName);
const result = await sandbox.process.executeCommand(
): Promise<CommandResult> {
const runtime = await this.acquireSandbox(projectId, agentId);
const scopedCommand = buildScopedKnowledgeShellCommand(command, runtime.paths);
await this.ensureMirrorSynced(runtime, files);
return await this.executeSandboxCommand(
runtime.sandbox,
scopedCommand,
undefined,
undefined,
timeoutSeconds,
this.agentsConfig.sandboxTimeout,
);
return {
exitCode: result.exitCode,
stdout: result.artifacts?.stdout ?? result.result ?? '',
stderr:
result.artifacts &&
'stderr' in result.artifacts &&
typeof result.artifacts.stderr === 'string'
? result.artifacts.stderr
: '',
};
}
/**
* Keeps the sandbox-local knowledge mirror in sync with the DB-derived
* file list before a read/search command runs against it. Short-circuits
* via an in-memory manifest hash so repeated operations with an unchanged
* file set issue no sandbox commands at all.
* file list before a read/search command runs against it. The remote
* manifest is read every time because providers can reuse sandbox IDs.
*/
private async ensureMirrorSynced(
sandbox: Sandbox,
runtime: AgentKnowledgeSandboxRuntime,
files: AgentKnowledgeFileReference[],
sandboxName: string,
): Promise<void> {
const expectedHash = hashManifestNames(files.map((file) => file.file));
if (this.mirrorManifestHashes.get(sandboxName) === expectedHash) {
return;
}
const previous = this.pendingMirrorSyncs.get(runtime.cacheKey) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(async () => await this.syncMirror(runtime, files));
this.pendingMirrorSyncs.set(runtime.cacheKey, next);
let pending = this.pendingMirrorSyncs.get(sandboxName);
if (!pending) {
pending = this.syncMirror(sandbox, files, expectedHash, sandboxName).finally(() => {
this.pendingMirrorSyncs.delete(sandboxName);
});
this.pendingMirrorSyncs.set(sandboxName, pending);
try {
await next;
} finally {
if (this.pendingMirrorSyncs.get(runtime.cacheKey) === next) {
this.pendingMirrorSyncs.delete(runtime.cacheKey);
}
}
await pending;
}
private async syncMirror(
sandbox: Sandbox,
runtime: AgentKnowledgeSandboxRuntime,
files: AgentKnowledgeFileReference[],
expectedHash: string,
sandboxName: string,
): Promise<void> {
// Re-check now that we hold the per-sandbox lock — a concurrent call may
// have just finished syncing to the same expected set.
if (this.mirrorManifestHashes.get(sandboxName) === expectedHash) return;
const manifestResult = await sandbox.process.executeCommand(
buildReadMirrorManifestCommand(),
undefined,
undefined,
MIRROR_SYNC_TIMEOUT_SECONDS,
const manifestResult = await this.executeSandboxCommand(
runtime.sandbox,
`cat ${runtime.paths.manifest} 2>/dev/null || true`,
MIRROR_SYNC_TIMEOUT_SECONDS * 1000,
);
const present = parseManifestNames(extractCommandOutput(manifestResult));
const present = parseMirrorManifest(manifestResult.stdout);
const expectedNames = files.map((file) => file.file);
const expectedSet = new Set(expectedNames);
const presentSet = new Set(present);
const toCopy = expectedNames.filter((name) => !presentSet.has(name));
const toDelete = present.filter((name) => !expectedSet.has(name));
const toCopy = files.filter((file) => present.get(file.file) !== file.fileId);
const toDelete = [...present.keys()].filter((name) => !expectedSet.has(name));
if (toCopy.length === 0 && toDelete.length === 0) {
this.mirrorManifestHashes.set(sandboxName, expectedHash);
return;
}
if (toCopy.length === 0 && toDelete.length === 0) return;
for (const name of [...expectedNames, ...toDelete]) {
assertKnowledgePathSegment(name, 'knowledge mirror file name');
}
if (present.length === 0 && toCopy.length > 0) {
const totalBytes = files.reduce((total, file) => total + file.fileSizeBytes, 0);
if (totalBytes > MIRROR_DISK_FIT_WARNING_BYTES) {
this.logger.warn('Agent knowledge mirror copy exceeds the disk-fit guard threshold', {
sandboxName,
totalBytes,
});
const stagingId = nanoid();
const stagingDir = `${runtime.paths.stagingDir}/${stagingId}`;
try {
const copiedNames = await this.uploadMirrorFiles(runtime, toCopy, stagingId);
// Files that failed to load from the knowledge file store are left out of
// the manifest, so the next sync attempt retries them as `toCopy` again.
const finalManifestFiles = files.filter(
(file) => copiedNames.has(file.file) || present.get(file.file) === file.fileId,
);
const staleFiles = toCopy
.filter((file) => present.has(file.file) && !copiedNames.has(file.file))
.map((file) => file.file);
const syncResult = await this.executeSandboxCommand(
runtime.sandbox,
buildMirrorFinalizeCommand(
[...copiedNames],
[...toDelete, ...staleFiles],
finalManifestFiles,
runtime.paths,
stagingId,
),
MIRROR_SYNC_TIMEOUT_SECONDS * 1000,
);
if (syncResult.exitCode !== 0) {
throw new OperationalError(
`Agent knowledge mirror sync failed: exitCode=${syncResult.exitCode}; output=${sanitizeSandboxErrorDetail(syncResult.stdout)}`,
);
}
} finally {
if (toCopy.length > 0) {
try {
await runtime.filesystem.rmdir(stagingDir, { recursive: true, force: true });
} catch (error) {
this.logger.warn('Failed to clean agent knowledge mirror staging directory', {
sandboxName: runtime.cacheKey,
stagingId,
error: sanitizeSandboxErrorDetail(
error instanceof Error ? error.message : String(error),
),
});
}
}
}
const filesByName = new Map(files.map((file) => [file.file, file]));
const copiedNames = await this.uploadMirrorFiles(sandbox, toCopy, filesByName, sandboxName);
// Files that failed to load from the knowledge file store are left out of
// the manifest, so the next sync attempt retries them as `toCopy` again.
const finalManifestNames = expectedNames.filter(
(name) => copiedNames.has(name) || presentSet.has(name),
);
const syncResult = await sandbox.process.executeCommand(
buildMirrorFinalizeCommand([...copiedNames], toDelete, finalManifestNames),
undefined,
undefined,
MIRROR_SYNC_TIMEOUT_SECONDS,
);
if (syncResult.exitCode !== 0) {
throw new OperationalError(
`Agent knowledge mirror sync failed: exitCode=${syncResult.exitCode}; output=${sanitizeSandboxErrorDetail(extractCommandOutput(syncResult))}`,
);
}
// Cache the hash of what's actually on disk, not `expectedHash`: if a
// file failed to load, this mismatches the next `expectedHash` and
// forces a retry instead of silently caching a partial mirror as done.
this.mirrorManifestHashes.set(sandboxName, hashManifestNames(finalManifestNames));
}
/**
* Fetches each `names` entry from the knowledge file store and uploads it to
* the sandbox mirror under a `.tmp-` prefix; `buildMirrorFinalizeCommand`
* Fetches each file from the knowledge file store and uploads it to
* a per-sync staging path; `buildMirrorFinalizeCommand`
* moves it into place so a concurrent search never sees a partially-written
* file. Uploads flush in `MIRROR_UPLOAD_BATCH_BYTES`-sized batches so the
* whole knowledge base (up to 1.5 GB) is never held in memory at once.
* Returns the subset of `names` that were fetched and uploaded successfully.
* file. Files are loaded and written one at a time so the whole knowledge
* base (up to 1.5 GB) is never held in memory at once.
* Returns the names of files that were fetched and uploaded successfully.
*/
private async uploadMirrorFiles(
sandbox: Sandbox,
names: string[],
filesByName: Map<string, AgentKnowledgeFileReference>,
sandboxName: string,
runtime: AgentKnowledgeSandboxRuntime,
files: AgentKnowledgeFileReference[],
stagingId: string,
): Promise<Set<string>> {
const copiedNames = new Set<string>();
if (names.length === 0) return copiedNames;
if (files.length === 0) return copiedNames;
await sandbox.fs.createFolder(KNOWLEDGE_MIRROR_FILES_DIR, '755');
let batch: Array<{ source: Buffer; destination: string }> = [];
let batchNames: string[] = [];
let batchBytes = 0;
const flushBatch = async (): Promise<void> => {
if (batch.length === 0) return;
await sandbox.fs.uploadFiles(batch);
for (const name of batchNames) copiedNames.add(name);
batch = [];
batchNames = [];
batchBytes = 0;
};
for (const name of names) {
const file = filesByName.get(name);
if (!file) continue;
const stagingDir = `${runtime.paths.stagingDir}/${stagingId}`;
await runtime.filesystem.mkdir(stagingDir, { recursive: true });
for (const file of files) {
const name = file.file;
let buffer: Buffer | null;
try {
const buffer = await this.agentKnowledgeFileStore.readAsBuffer({
buffer = await this.agentKnowledgeFileStore.readAsBuffer({
storedAt: file.storedAt,
storageKey: file.storageKey,
});
if (!buffer) {
this.logger.warn('Failed to load agent knowledge file for mirror sync', {
sandboxName,
file: name,
error: 'not found',
});
continue;
}
batch.push({ source: buffer, destination: `${KNOWLEDGE_MIRROR_FILES_DIR}/.tmp-${name}` });
batchNames.push(name);
batchBytes += buffer.length;
} catch (error) {
this.logger.warn('Failed to load agent knowledge file for mirror sync', {
sandboxName,
sandboxName: runtime.cacheKey,
file: name,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
if (batchBytes >= MIRROR_UPLOAD_BATCH_BYTES) {
await flushBatch();
if (!buffer) {
this.logger.warn('Failed to load agent knowledge file for mirror sync', {
sandboxName: runtime.cacheKey,
file: name,
error: 'not found',
});
continue;
}
}
await flushBatch();
await runtime.filesystem.writeFile(`${stagingDir}/${name}`, buffer);
copiedNames.add(name);
}
return copiedNames;
}
@ -600,13 +547,8 @@ export class AgentKnowledgeSandboxService {
prewarmMirrorInBackground(projectId: string, agentId: string): void {
void (async () => {
const references = await this.loadKnowledgeReferenceLookup(projectId, agentId);
const sandbox = await this.acquireSandbox(projectId, agentId);
const sandboxName = buildSandboxName({
instanceId: this.instanceSettings.instanceId,
projectId,
agentId,
});
await this.ensureMirrorSynced(sandbox, references.files, sandboxName);
const runtime = await this.acquireSandbox(projectId, agentId);
await this.ensureMirrorSynced(runtime, references.files);
})().catch((error) => {
this.logger.warn('Agent knowledge mirror pre-warm failed', {
projectId,
@ -616,16 +558,6 @@ export class AgentKnowledgeSandboxService {
});
}
/** Invalidates the cached mirror state so the next operation re-syncs. */
invalidateMirror(projectId: string, agentId: string): void {
const sandboxName = buildSandboxName({
instanceId: this.instanceSettings.instanceId,
projectId,
agentId,
});
this.mirrorManifestHashes.delete(sandboxName);
}
private async loadKnowledgeReferenceLookup(
projectId: string,
agentId: string,
@ -699,12 +631,27 @@ export class AgentKnowledgeSandboxService {
return file;
}
private async acquireSandbox(projectId: string, agentId: string): Promise<Sandbox> {
const cacheKey = buildSandboxScopeKey(projectId, agentId);
private async acquireSandbox(
projectId: string,
agentId: string,
): Promise<AgentKnowledgeSandboxRuntime> {
const provider = this.sandboxSettingsService.getProvider();
const sandboxName = buildSandboxName({
instanceId: this.instanceSettings.instanceId,
projectId,
agentId,
});
const cacheKey = `${provider}:${sandboxName}`;
let pending = this.pendingSandboxAcquisitions.get(cacheKey);
if (!pending) {
pending = this.acquireSandboxFresh(projectId, agentId).finally(() => {
pending = this.acquireSandboxFresh(
projectId,
agentId,
provider,
sandboxName,
cacheKey,
).finally(() => {
this.pendingSandboxAcquisitions.delete(cacheKey);
});
this.pendingSandboxAcquisitions.set(cacheKey, pending);
@ -713,159 +660,126 @@ export class AgentKnowledgeSandboxService {
return await pending;
}
private async acquireSandboxFresh(projectId: string, agentId: string): Promise<Sandbox> {
private async acquireSandboxFresh(
projectId: string,
agentId: string,
provider: SandboxProvider,
sandboxName: string,
cacheKey: string,
): Promise<AgentKnowledgeSandboxRuntime> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
const { Daytona } = loadDaytona();
const connection = await this.resolveDaytonaConnection(projectId);
const daytona = new Daytona({
apiUrl: connection.apiUrl,
apiKey: connection.apiKey,
});
const labels = buildScopeLabels(projectId, agentId);
const timeoutSeconds = Math.ceil(this.agentsConfig.sandboxTimeout / 1000);
const name = buildSandboxName({
instanceId: this.instanceSettings.instanceId,
const config =
provider === 'daytona'
? await this.resolveDaytonaSandboxConfig(projectId, agentId, sandboxName)
: await this.resolveN8nSandboxConfig(buildN8nSandboxId(sandboxName));
return await this.startSandbox(config, projectId, agentId, cacheKey);
}
private async startSandbox(
config: DaytonaSandboxConfig | N8nSandboxConfig,
projectId: string,
agentId: string,
cacheKey: string,
): Promise<AgentKnowledgeSandboxRuntime> {
const { provider } = config;
const sandbox = await createSandbox(config, { logger: this.logger });
if (!sandbox?._start) {
throw new OperationalError('Agent knowledge sandbox does not support lifecycle start');
}
await sandbox._start();
const filesystem = createFilesystem(sandbox);
const paths = getAgentKnowledgePaths(provider);
this.logger.debug('Acquired agent knowledge sandbox', {
projectId,
agentId,
provider,
sandboxId: sandbox.id,
});
return { sandbox, filesystem, paths, cacheKey: `${cacheKey}:${sandbox.id}` };
}
const sandboxByName = await this.resolveSandboxByName(
daytona,
name,
timeoutSeconds,
connection,
);
if (sandboxByName) {
this.logger.debug('Reused agent knowledge sandbox', { projectId, agentId, name });
return sandboxByName;
}
const image = connection.image;
const baseCreateParams = {
name,
labels,
language: 'typescript' as const,
private async resolveDaytonaSandboxConfig(
projectId: string,
agentId: string,
sandboxId: string,
): Promise<DaytonaSandboxConfig> {
const directImage = this.agentsConfig.sandboxImage || DEFAULT_SANDBOX_IMAGE;
const snapshot = this.agentsConfig.sandboxSnapshot.trim() || undefined;
const baseConfig: DaytonaSandboxConfig = {
enabled: true,
provider: 'daytona',
id: sandboxId,
name: sandboxId,
labels: buildScopeLabels(projectId, agentId),
timeout: this.agentsConfig.sandboxTimeout,
createTimeoutSeconds: Math.ceil(this.agentsConfig.sandboxTimeout / 1000),
ephemeral: this.agentsConfig.sandboxEphemeral,
autoStopInterval: AUTO_STOP_INTERVAL_MINUTES,
};
// Only the snapshot path works through the proxy: the Daytona SDK derives `buildInfo` from
// any string image, and the proxy rejects create requests carrying it. Attempting the image
// path there fails with an error about a field we never set, masking the real cause.
let sandbox: Sandbox;
if (connection.snapshot) {
try {
sandbox = await daytona.create(
{ ...baseCreateParams, snapshot: connection.snapshot },
{ timeout: timeoutSeconds },
);
} catch (error) {
if (connection.mode === 'proxy') throw error;
this.logger.warn(
'Agent knowledge sandbox create from snapshot failed; falling back to image',
{
projectId,
agentId,
snapshotName: connection.snapshot,
error: error instanceof Error ? error.message : String(error),
},
);
sandbox = await daytona.create({ ...baseCreateParams, image }, { timeout: timeoutSeconds });
}
} else {
if (connection.mode === 'proxy') {
throw new OperationalError(
'Agent knowledge sandbox requires a snapshot when Daytona is reached through the AI service proxy. Set N8N_AGENTS_AI_SANDBOX_SNAPSHOT to a snapshot available to the instance.',
);
}
sandbox = await daytona.create({ ...baseCreateParams, image }, { timeout: timeoutSeconds });
}
this.logger.debug('Created agent knowledge sandbox', { projectId, agentId, name });
return sandbox;
}
private async resolveDaytonaConnection(
projectId: string,
): Promise<AgentKnowledgeDaytonaConnection> {
const directImage = this.agentsConfig.sandboxImage || DEFAULT_SANDBOX_IMAGE;
const snapshot = this.agentsConfig.sandboxSnapshot.trim() || undefined;
if (!this.aiService.isProxyEnabled()) {
const daytona = await this.sandboxSettingsService.resolveDaytonaConfig();
return {
mode: 'direct',
apiUrl: this.agentsConfig.daytonaApiUrl || undefined,
apiKey: this.agentsConfig.daytonaApiKey || undefined,
...baseConfig,
daytonaApiUrl: daytona.apiUrl,
daytonaApiKey: daytona.apiKey,
image: directImage,
snapshot,
};
}
const client = await this.aiService.getClient();
const proxyConfig = await callAiServiceWithRetry(
'Agent knowledge sandbox proxy config fetch',
async () => await client.getSandboxProxyConfig(),
this.logger,
);
// The proxy does not enforce per-user identity; the project id is the stable scope for agents.
const token = await callAiServiceWithRetry(
'Agent knowledge sandbox proxy token mint',
async () =>
await client.getBuilderApiProxyToken({ id: projectId }, { userMessageId: nanoid() }),
this.logger,
);
if (!snapshot) {
throw new OperationalError(
'Agent knowledge sandbox requires a snapshot when Daytona is reached through the AI service proxy. Set N8N_AGENTS_AI_SANDBOX_SNAPSHOT to a snapshot available to the instance.',
);
}
return {
mode: 'proxy',
apiUrl: client.getSandboxProxyBaseUrl(),
apiKey: token.accessToken,
image: proxyConfig.image || directImage,
...baseConfig,
daytonaApiUrl: client.getSandboxProxyBaseUrl(),
snapshot,
getAuthToken: async () => {
// The proxy does not enforce per-user identity; the project id is the stable scope.
const token = await callAiServiceWithRetry(
'Agent knowledge sandbox proxy token mint',
async () =>
await client.getBuilderApiProxyToken({ id: projectId }, { userMessageId: nanoid() }),
this.logger,
);
return token.accessToken;
},
};
}
private async resolveSandboxByName(
daytona: { get: (name: string) => Promise<Sandbox> },
name: string,
timeoutSeconds: number,
connection: AgentKnowledgeDaytonaConnection,
): Promise<Sandbox | undefined> {
let sandbox: Sandbox;
try {
sandbox = await daytona.get(name);
} catch (error) {
if (isDaytonaNotFoundError(error)) {
return undefined;
}
throw error;
private async resolveN8nSandboxConfig(sandboxId: string): Promise<N8nSandboxConfig> {
const { serviceUrl, apiKey } = await this.sandboxSettingsService.resolveN8nSandboxConfig();
const normalizedServiceUrl = serviceUrl?.trim();
if (!normalizedServiceUrl) {
throw new OperationalError(
'Agent knowledge sandbox requires the n8n sandbox service URL. Set N8N_SANDBOX_SERVICE_URL.',
);
}
if (!isUsableSandbox(sandbox)) {
await sandbox.delete(timeoutSeconds);
return undefined;
}
if (sandbox.state !== SANDBOX_STATE_STARTED) {
await sandbox.start(timeoutSeconds);
}
return await this.resolveReusableSandbox(daytona, sandbox, connection);
return {
enabled: true,
provider: 'n8n-sandbox',
id: sandboxId,
serviceUrl: normalizedServiceUrl,
apiKey,
timeout: this.agentsConfig.sandboxTimeout,
};
}
private async resolveReusableSandbox(
daytona: { get: (name: string) => Promise<Sandbox> },
sandbox: Sandbox,
connection: AgentKnowledgeDaytonaConnection,
): Promise<Sandbox> {
if (connection.mode !== 'proxy') {
return sandbox;
private async executeSandboxCommand(sandbox: WorkspaceSandbox, command: string, timeout: number) {
if (!sandbox.executeCommand) {
throw new OperationalError('Agent knowledge sandbox does not support command execution');
}
return await daytona.get(sandbox.name);
return await sandbox.executeCommand(command, [], { timeout });
}
/**
@ -882,7 +796,9 @@ export class AgentKnowledgeSandboxService {
}
private assertKnowledgeConfiguration(projectId: string, agentId: string): void {
this.assertKnowledgeBaseEnabled();
if (!this.agentsConfig.sandboxEnabled) {
throw new OperationalError('Agent knowledge sandbox is not enabled');
}
this.assertValidPathSegments(projectId, agentId);
}
@ -897,18 +813,6 @@ export class AgentKnowledgeSandboxService {
);
}
}
private isKnowledgeBaseEnabled(): boolean {
return isAgentKnowledgeBaseEnabled(this.agentsConfig, this.aiService.isProxyEnabled());
}
private assertKnowledgeBaseEnabled(): void {
if (this.isKnowledgeBaseEnabled()) {
return;
}
throw new OperationalError('Agent knowledge sandbox is not enabled');
}
}
function matchKnowledgeFilesByGlob(

View File

@ -1,15 +1,26 @@
import type { AgentFileDto } from '@n8n/api-types';
import { getPromptWorkspaceRoot, type SandboxProvider } from '@n8n/agents/sandbox';
import path from 'node:path';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type { AgentFile } from './entities/agent-file.entity';
// Local sandbox disk mirroring the persisted knowledge files, so repeated
// reads/searches avoid re-fetching from the knowledge file store each time.
export const KNOWLEDGE_MIRROR_DIR = '/home/daytona/knowledge-mirror';
export const KNOWLEDGE_MIRROR_FILES_DIR = `${KNOWLEDGE_MIRROR_DIR}/files`;
export const KNOWLEDGE_MIRROR_MANIFEST = `${KNOWLEDGE_MIRROR_DIR}/manifest`;
export interface AgentKnowledgePaths {
filesDir: string;
manifest: string;
stagingDir: string;
}
export function getAgentKnowledgePaths(provider: SandboxProvider): AgentKnowledgePaths {
const home = path.dirname(getPromptWorkspaceRoot(provider));
const mirrorDir = `${home}/knowledge-mirror`;
return {
filesDir: `${mirrorDir}/files`,
manifest: `${mirrorDir}/manifest`,
stagingDir: `${mirrorDir}/.staging`,
};
}
export function hasControlCharacter(value: string): boolean {
for (const character of value) {

View File

@ -7,9 +7,7 @@ import multer from 'multer';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AiService } from '@/services/ai.service';
import { isAgentKnowledgeBaseEnabled } from './agent-knowledge-gate';
import { AgentKnowledgeService } from './agent-knowledge.service';
import { AgentRuntimeCacheService } from './agent-runtime-cache.service';
import {
@ -26,12 +24,11 @@ export class AgentKnowledgeController {
private readonly agentKnowledgeService: AgentKnowledgeService,
private readonly agentsConfig: AgentsConfig,
private readonly runtimeCacheService: AgentRuntimeCacheService,
private readonly aiService: AiService,
) {}
/** Knowledge base endpoints are gated behind Daytona sandbox env vars or AI Assistant proxy availability. */
/** Knowledge base endpoints are gated behind the Agents sandbox opt-in. */
private assertKnowledgeBaseEnabled() {
if (!isAgentKnowledgeBaseEnabled(this.agentsConfig, this.aiService.isProxyEnabled())) {
if (!this.agentsConfig.sandboxEnabled) {
throw new NotFoundError('Agent knowledge base is not enabled');
}
}

View File

@ -73,7 +73,6 @@ export class AgentKnowledgeService {
throw error;
}
this.agentKnowledgeSandboxService.invalidateMirror(projectId, agentId);
this.agentKnowledgeSandboxService.prewarmMirrorInBackground(projectId, agentId);
return uploadedFiles.map((file) => toAgentFileDto(file));
@ -111,11 +110,10 @@ export class AgentKnowledgeService {
error: error instanceof Error ? error.message : error,
});
});
this.agentKnowledgeSandboxService.invalidateMirror(projectId, agentId);
this.agentKnowledgeSandboxService.prewarmMirrorInBackground(projectId, agentId);
}
async deleteAllFilesForAgent(projectId: string, agentId: string): Promise<void> {
async deleteAllFilesForAgent(_projectId: string, agentId: string): Promise<void> {
const files = await this.agentFileRepository.findByAgentId(agentId);
await this.agentFileRepository.delete({ agentId });
if (files.length > 0) {
@ -133,7 +131,6 @@ export class AgentKnowledgeService {
});
});
}
this.agentKnowledgeSandboxService.invalidateMirror(projectId, agentId);
}
/** Best-effort passthrough for agent/project deletion; never throws. */

View File

@ -47,7 +47,6 @@ import { createAiMcpFetch, createAiProxyFetch, createWebSearchFetch } from '@/ut
import { WorkflowRunner } from '@/workflow-runner';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { isAgentKnowledgeBaseEnabled } from './agent-knowledge-gate';
import { AgentChatAttachmentService } from './agent-chat-attachment.service';
import { AgentKnowledgeSandboxService } from './agent-knowledge-sandbox.service';
import type { AgentRuntimeInstrumentation } from './agent-runtime-instrumentation';
@ -596,7 +595,7 @@ export class AgentRuntimeReconstructionService {
if (
runtimeProfile !== 'inline' &&
isAgentKnowledgeBaseEnabled(this.agentsConfig, this.aiService.isProxyEnabled()) &&
this.agentsConfig.sandboxEnabled &&
(await this.agentFileRepository.hasFilesForAgent(agentId))
) {
const { createKnowledgeRetrievalTools } = await import(

View File

@ -5,9 +5,7 @@ import { Param, Post, ProjectScope, RestController } from '@n8n/decorators';
import type { Response } from 'express';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AiService } from '@/services/ai.service';
import { isAgentKnowledgeBaseEnabled } from './agent-knowledge-gate';
import { AgentKnowledgeService } from './agent-knowledge.service';
@RestController('/projects/:projectId/agents/v2')
@ -16,7 +14,6 @@ export class AgentSandboxController {
private readonly agentKnowledgeService: AgentKnowledgeService,
private readonly logger: Logger,
private readonly agentsConfig: AgentsConfig,
private readonly aiService: AiService,
) {}
@Post('/:agentId/sandbox/knowledge/warmup')
@ -36,12 +33,8 @@ export class AgentSandboxController {
return { accepted: true };
}
private isKnowledgeBaseEnabled(): boolean {
return isAgentKnowledgeBaseEnabled(this.agentsConfig, this.aiService.isProxyEnabled());
}
private assertKnowledgeBaseEnabled() {
if (!this.isKnowledgeBaseEnabled()) {
if (!this.agentsConfig.sandboxEnabled) {
throw new NotFoundError('Agent knowledge base is not enabled');
}
}
@ -51,7 +44,6 @@ export class AgentSandboxController {
agentId: string,
): Promise<void> {
try {
if (!this.isKnowledgeBaseEnabled()) return;
await this.agentKnowledgeService.warmSandbox(agentId, projectId);
} catch (error) {
this.logger.warn('Failed to warm agent knowledge sandbox', {

View File

@ -10,6 +10,9 @@ export class AgentsModule implements ModuleInterface {
private interruptedExecutionSweepTimer?: NodeJS.Timeout;
async init() {
const { SandboxSettingsService } = await import('@/services/sandbox-settings.service.js');
Container.get(SandboxSettingsService).registerCredentialUses();
await import('./agents-catalog.controller.js');
await import('./agent-threads.controller.js');
await import('./agents.controller.js');
@ -152,14 +155,13 @@ export class AgentsModule implements ModuleInterface {
async settings() {
const config = Container.get(AgentsConfig);
const { isAgentKnowledgeBaseEnabled } = await import('./agent-knowledge-gate.js');
const { AiService } = await import('@/services/ai.service.js');
const aiService = Container.get(AiService);
const proxyEnabled = aiService.isProxyEnabled();
return {
enabled: true,
modules: [...config.modules],
knowledgeBaseEnabled: isAgentKnowledgeBaseEnabled(config, proxyEnabled),
knowledgeBaseEnabled: config.sandboxEnabled,
proxyEnabled,
};
}

View File

@ -13,15 +13,18 @@ import { mock } from 'vitest-mock-extended';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import type { EventService } from '@/events/event.service';
import type { AiService } from '@/services/ai.service';
import {
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY,
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY,
type SandboxSettingsService,
} from '@/services/sandbox-settings.service';
import type { UserService } from '@/services/user.service';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { InstanceCredentialBroker } from '@/credentials/instance-credential-broker';
import {
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY,
INSTANCE_AI_MODEL_CREDENTIAL_POLICY,
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY,
INSTANCE_AI_SEARCH_CREDENTIAL_POLICY,
InstanceAiSettingsService,
} from '../instance-ai-settings.service';
@ -61,6 +64,7 @@ describe('InstanceAiSettingsService', () => {
const credentialsService = mock<CredentialsService>();
const credentialsFinderService = mock<CredentialsFinderService>();
const instanceCredentialBroker = mock<InstanceCredentialBroker>();
const sandboxSettingsService = mock<SandboxSettingsService>();
const eventService = mock<EventService>();
const logger = mock<Logger>();
@ -77,6 +81,7 @@ describe('InstanceAiSettingsService', () => {
credentialsService,
credentialsFinderService,
instanceCredentialBroker,
sandboxSettingsService,
eventService,
);
@ -106,6 +111,9 @@ describe('InstanceAiSettingsService', () => {
globalConfig.deployment.type = 'default';
instanceCredentialBroker.listForUse.mockResolvedValue([]);
instanceCredentialBroker.getAssignedCredentialId.mockResolvedValue(null);
sandboxSettingsService.getProvider.mockImplementation(() =>
globalConfig.instanceAi.sandboxProvider === 'daytona' ? 'daytona' : 'n8n-sandbox',
);
credentialsService.getCredentialTypeProperties.mockReturnValue([]);
credentialsService.unredact.mockImplementation((data) => data);
credentialsService.runInstanceCredentialHooks.mockImplementation(async (_event, credential) => {
@ -1728,117 +1736,26 @@ describe('InstanceAiSettingsService', () => {
});
});
describe('daytona credential', () => {
it('uses the resolved credential data for the daytona config', async () => {
globalConfig.instanceAi.daytonaApiUrl = 'https://env.daytona.example.com';
globalConfig.instanceAi.daytonaApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'daytona-credential',
name: 'Daytona',
type: 'daytonaApi',
data: { apiUrl: 'https://daytona.example.com', apiKey: 'credential-key' },
});
await expect(service.resolveDaytonaConfig()).resolves.toEqual({
apiUrl: 'https://daytona.example.com',
apiKey: 'credential-key',
});
});
it('falls back to environment config when the selected credential cannot be resolved', async () => {
globalConfig.instanceAi.daytonaApiUrl = 'https://env.daytona.example.com';
globalConfig.instanceAi.daytonaApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockRejectedValue(new Error('not found'));
await expect(service.resolveDaytonaConfig()).resolves.toEqual({
apiUrl: 'https://env.daytona.example.com',
apiKey: 'env-key',
});
expect(logger.warn).toHaveBeenCalledWith(
'Could not resolve the configured Daytona sandbox credential; using environment fallback',
{ credentialUseId: 'instance-ai:sandbox:daytona', error: 'not found' },
);
});
it('falls back to environment config when resolved credential data is incomplete', async () => {
globalConfig.instanceAi.daytonaApiUrl = 'https://env.daytona.example.com';
globalConfig.instanceAi.daytonaApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'daytona-credential',
name: 'Daytona',
type: 'daytonaApi',
data: { apiKey: 'credential-key' },
});
await expect(service.resolveDaytonaConfig()).resolves.toEqual({
apiUrl: 'https://env.daytona.example.com',
apiKey: 'env-key',
});
expect(logger.warn).toHaveBeenCalledWith(
'Could not resolve the configured Daytona sandbox credential; using environment fallback',
{ credentialUseId: 'instance-ai:sandbox:daytona', error: 'Credential data is incomplete' },
);
});
it('ignores stored assignments on cloud deployments', async () => {
globalConfig.deployment.type = 'cloud';
globalConfig.instanceAi.daytonaApiUrl = 'https://env.daytona.example.com';
globalConfig.instanceAi.daytonaApiKey = 'env-key';
await expect(service.resolveDaytonaConfig()).resolves.toEqual({
apiUrl: 'https://env.daytona.example.com',
apiKey: 'env-key',
});
expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled();
});
});
describe('n8n sandbox credential', () => {
it('uses the resolved api key for an admin-configured service URL', async () => {
globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080';
globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-credential',
name: 'Sandbox',
type: 'httpHeaderAuth',
data: { name: 'X-Api-Key', value: 'credential-key' },
});
await expect(service.resolveN8nSandboxConfig()).resolves.toEqual({
serviceUrl: 'http://sandbox-api:8080',
apiKey: 'credential-key',
});
});
it('uses environment settings without resolving the stored credential', async () => {
Object.assign(globalConfig.instanceAi, {
n8nSandboxServiceUrl: 'http://sandbox-api:8080',
n8nSandboxServiceApiKey: 'environment-key',
});
service = createService();
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-credential',
name: 'Sandbox',
type: 'httpHeaderAuth',
data: { name: 'X-Api-Key', value: 'credential-key' },
});
await expect(service.resolveN8nSandboxConfig()).resolves.toEqual({
serviceUrl: 'http://sandbox-api:8080',
apiKey: 'environment-key',
});
expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled();
expect(sandboxSettingsService.resolveN8nSandboxConfig).not.toHaveBeenCalled();
});
it('uses the resolved api key when the assistant proxy is enabled', async () => {
aiService.isProxyEnabled.mockReturnValue(true);
globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080';
globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-credential',
name: 'Sandbox',
type: 'httpHeaderAuth',
data: { name: 'X-Api-Key', value: 'credential-key' },
sandboxSettingsService.resolveN8nSandboxConfig.mockResolvedValue({
serviceUrl: 'http://sandbox-api:8080',
apiKey: 'credential-key',
});
await expect(service.resolveN8nSandboxConfig()).resolves.toEqual({
@ -1846,49 +1763,6 @@ describe('InstanceAiSettingsService', () => {
apiKey: 'credential-key',
});
});
it('falls back to environment config when the credential header is not x-api-key', async () => {
globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080';
globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-credential',
name: 'Sandbox',
type: 'httpHeaderAuth',
data: { name: 'Authorization', value: 'credential-key' },
});
await expect(service.resolveN8nSandboxConfig()).resolves.toEqual({
serviceUrl: 'http://sandbox-api:8080',
apiKey: 'env-key',
});
expect(logger.warn).toHaveBeenCalledWith(
'Could not resolve the configured n8n Sandbox credential; using environment fallback',
{
credentialUseId: 'instance-ai:sandbox:n8n',
error: 'Credential header must be "x-api-key" but is "authorization"',
},
);
});
it('falls back to environment config when the api key is missing', async () => {
globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080';
globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-credential',
name: 'Sandbox',
type: 'httpHeaderAuth',
data: { name: 'x-api-key' },
});
await expect(service.resolveN8nSandboxConfig()).resolves.toEqual({
serviceUrl: 'http://sandbox-api:8080',
apiKey: 'env-key',
});
expect(logger.warn).toHaveBeenCalledWith(
'Could not resolve the configured n8n Sandbox credential; using environment fallback',
{ credentialUseId: 'instance-ai:sandbox:n8n', error: 'Credential data is incomplete' },
);
});
});
describe('getAdminSettings env-configured flags', () => {

View File

@ -40,15 +40,21 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { EventService } from '@/events/event.service';
import { AiService } from '@/services/ai.service';
import {
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY,
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY,
SandboxSettingsService,
} from '@/services/sandbox-settings.service';
import { UserService } from '@/services/user.service';
export { INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY, INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY };
import {
N8N_SANDBOX_SERVICE_URL_REQUIRED_MESSAGE,
normalizeSandboxProvider,
} from './sandbox-provider';
const ADMIN_SETTINGS_KEY = 'instanceAi.settings';
const N8N_SANDBOX_HEADER_NAME = 'x-api-key';
const MODEL_PROVIDER_API_KEY_ENV: ReadonlyMap<string, string> = new Map([
['anthropic', 'ANTHROPIC_API_KEY'],
@ -156,33 +162,6 @@ function modelCredentialHeaders(
return Object.keys(headers).length ? headers : undefined;
}
function validateSandboxServiceCredential({
type,
data,
}: {
type: string;
data: ICredentialDataDecryptedObject;
}): void {
const headerName = requireConnectionValue(type, data, 'name').toLowerCase();
if (headerName !== N8N_SANDBOX_HEADER_NAME) {
throw new UnprocessableRequestError(
`The credential's header name must be "${N8N_SANDBOX_HEADER_NAME}" but is "${headerName}"`,
);
}
requireConnectionValue(type, data, 'value');
}
function validateDaytonaCredential({
type,
data,
}: {
type: string;
data: ICredentialDataDecryptedObject;
}): void {
requireHttpUrl(type, data, 'apiUrl');
requireConnectionValue(type, data, 'apiKey');
}
function validateSearchCredential({
type,
data,
@ -200,18 +179,6 @@ export const INSTANCE_AI_MODEL_CREDENTIAL_POLICY: InstanceCredentialUse = {
validate: validateModelCredential,
};
export const INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY: InstanceCredentialUse = {
id: 'instance-ai:sandbox:daytona',
credentialTypes: ['daytonaApi'],
validate: validateDaytonaCredential,
};
export const INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY: InstanceCredentialUse = {
id: 'instance-ai:sandbox:n8n',
credentialTypes: ['httpHeaderAuth'],
validate: validateSandboxServiceCredential,
};
export const INSTANCE_AI_SEARCH_CREDENTIAL_POLICY: InstanceCredentialUse = {
id: 'instance-ai:search',
credentialTypes: INSTANCE_AI_SEARCH_CREDENTIAL_TYPES,
@ -225,9 +192,9 @@ function validateInstanceAiCredential(
if (policy === INSTANCE_AI_MODEL_CREDENTIAL_POLICY) {
validateModelCredential(credential);
} else if (policy === INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY) {
validateDaytonaCredential(credential);
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY.validate?.(credential);
} else if (policy === INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY) {
validateSandboxServiceCredential(credential);
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY.validate?.(credential);
} else if (policy === INSTANCE_AI_SEARCH_CREDENTIAL_POLICY) {
validateSearchCredential(credential);
} else {
@ -319,13 +286,13 @@ export class InstanceAiSettingsService {
private readonly credentialsService: CredentialsService,
private readonly credentialsFinderService: CredentialsFinderService,
private readonly instanceCredentialBroker: InstanceCredentialBroker,
private readonly sandboxSettingsService: SandboxSettingsService,
private readonly eventService: EventService,
) {
this.config = globalConfig.instanceAi;
this.deploymentConfig = globalConfig.deployment;
this.environmentSandboxProvider = normalizeSandboxProvider(this.config.sandboxProvider);
this.environmentSandboxProvider = this.sandboxSettingsService.getProvider();
this.environmentN8nSandboxServiceUrl = this.config.n8nSandboxServiceUrl;
this.config.sandboxProvider = this.environmentSandboxProvider;
}
/** Whether this instance is running on the cloud platform. */
@ -340,10 +307,9 @@ export class InstanceAiSettingsService {
/** Load persisted settings from DB and apply to the singleton config. Call on module init. */
async loadFromDb(): Promise<void> {
this.config.sandboxProvider = normalizeSandboxProvider(this.config.sandboxProvider);
const envSnapshot = {
sandboxEnabled: this.config.sandboxEnabled,
sandboxProvider: this.config.sandboxProvider,
sandboxProvider: this.environmentSandboxProvider,
};
await this.reloadFromDb();
@ -1148,70 +1114,29 @@ export class InstanceAiSettingsService {
apiKey: daytonaApiKey || undefined,
};
if (
this.isDirectSelfManaged() &&
this.environmentSandboxProvider === 'daytona' &&
this.hasEnvironmentSandboxConnection()
this.aiService.isProxyEnabled() ||
(this.isDirectSelfManaged() &&
this.environmentSandboxProvider === 'daytona' &&
this.hasEnvironmentSandboxConnection())
) {
return envConfig;
}
const resolved = await this.resolveServiceCredential(
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY,
'Daytona sandbox',
);
if (!resolved) return envConfig;
const { data } = resolved;
const apiUrl = typeof data.apiUrl === 'string' ? data.apiUrl : undefined;
const apiKey = typeof data.apiKey === 'string' ? data.apiKey : undefined;
if (!apiUrl || !apiKey) {
this.warnCredentialFallback(
'Daytona sandbox',
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY.id,
'Credential data is incomplete',
);
return envConfig;
}
return { apiUrl, apiKey };
return await this.sandboxSettingsService.resolveDaytonaConfig();
}
async resolveN8nSandboxConfig(): Promise<{ serviceUrl?: string; apiKey?: string }> {
const { n8nSandboxServiceUrl, n8nSandboxServiceApiKey } = this.config;
const envConfig = {
serviceUrl: n8nSandboxServiceUrl || undefined,
apiKey: n8nSandboxServiceApiKey || undefined,
};
if (
this.isDirectSelfManaged() &&
this.environmentSandboxProvider === 'n8n-sandbox' &&
this.hasEnvironmentSandboxConnection()
) {
return envConfig;
const { n8nSandboxServiceUrl, n8nSandboxServiceApiKey } = this.config;
return {
serviceUrl: n8nSandboxServiceUrl || undefined,
apiKey: n8nSandboxServiceApiKey || undefined,
};
}
const resolved = await this.resolveServiceCredential(
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY,
'n8n Sandbox',
);
if (!resolved) return envConfig;
const { data } = resolved;
const headerName = typeof data.name === 'string' ? data.name.trim().toLowerCase() : '';
const apiKey = typeof data.value === 'string' ? data.value : undefined;
if (headerName !== N8N_SANDBOX_HEADER_NAME) {
this.warnCredentialFallback(
'n8n Sandbox',
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY.id,
`Credential header must be "${N8N_SANDBOX_HEADER_NAME}" but is "${headerName || '(empty)'}"`,
);
return envConfig;
}
if (!apiKey) {
this.warnCredentialFallback(
'n8n Sandbox',
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY.id,
'Credential data is incomplete',
);
return envConfig;
}
return { serviceUrl: n8nSandboxServiceUrl || undefined, apiKey };
return await this.sandboxSettingsService.resolveN8nSandboxConfig();
}
async resolveSearchConfig(): Promise<{ braveApiKey?: string; searxngUrl?: string }> {
@ -1255,12 +1180,7 @@ export class InstanceAiSettingsService {
service: string,
ctx?: OperationContext,
): Promise<ResolvedInstanceCredential | null> {
if (
this.isCloud ||
(this.aiService.isProxyEnabled() &&
policy.id !== INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY.id)
)
return null;
if (this.isCloud || this.aiService.isProxyEnabled()) return null;
const resolved = ctx
? this.instanceCredentialBroker.resolveForUse(policy, ctx)
: this.instanceCredentialBroker.resolveForUse(policy);
@ -1314,7 +1234,7 @@ export class InstanceAiSettingsService {
/** Whether workflow building can use the required sandbox workspace. */
getSandboxStatus(): InstanceAiSandboxStatus {
const provider = normalizeSandboxProvider(this.config.sandboxProvider);
const provider = this.sandboxSettingsService.getProvider();
const unavailableReason = this.getSandboxUnavailableReason(
this.config.sandboxEnabled,
provider,

View File

@ -12,15 +12,13 @@ export class InstanceAiModule implements ModuleInterface {
const {
InstanceAiSettingsService,
INSTANCE_AI_MODEL_CREDENTIAL_POLICY,
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY,
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY,
INSTANCE_AI_SEARCH_CREDENTIAL_POLICY,
} = await import('./instance-ai-settings.service.js');
const { SandboxSettingsService } = await import('@/services/sandbox-settings.service.js');
const settingsService = Container.get(InstanceAiSettingsService);
const credentialBroker = Container.get(InstanceCredentialBroker);
credentialBroker.registerUse(INSTANCE_AI_MODEL_CREDENTIAL_POLICY);
credentialBroker.registerUse(INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY);
credentialBroker.registerUse(INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY);
Container.get(SandboxSettingsService).registerCredentialUses();
credentialBroker.registerUse(INSTANCE_AI_SEARCH_CREDENTIAL_POLICY);
await settingsService.loadFromDb();
await import('./instance-ai.controller.js');

View File

@ -0,0 +1,159 @@
import type { Logger } from '@n8n/backend-common';
import type { InstanceAiConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
import type { InstanceCredentialBroker } from '@/credentials/instance-credential-broker';
import { SandboxSettingsService } from '../sandbox-settings.service';
describe('SandboxSettingsService', () => {
const globalConfig = mock<{
instanceAi: InstanceAiConfig;
deployment: { type: string };
}>({
instanceAi: {
sandboxProvider: 'n8n-sandbox',
n8nSandboxServiceUrl: 'http://sandbox-api:8080',
n8nSandboxServiceApiKey: '',
daytonaApiUrl: '',
daytonaApiKey: '',
} as unknown as InstanceAiConfig,
deployment: { type: 'default' },
});
const instanceCredentialBroker = mock<InstanceCredentialBroker>();
const logger = mock<Logger>();
let service: SandboxSettingsService;
beforeEach(() => {
vi.resetAllMocks();
Object.assign(globalConfig.instanceAi, {
sandboxProvider: 'n8n-sandbox',
n8nSandboxServiceUrl: 'http://sandbox-api:8080',
n8nSandboxServiceApiKey: '',
daytonaApiUrl: '',
daytonaApiKey: '',
});
globalConfig.deployment.type = 'default';
logger.scoped.mockReturnValue(logger);
service = new SandboxSettingsService(globalConfig as never, instanceCredentialBroker, logger);
});
it('uses environment config when no credentials are assigned', async () => {
Object.assign(globalConfig.instanceAi, {
daytonaApiUrl: 'https://env.daytona.example.com',
daytonaApiKey: 'daytona-env-key',
n8nSandboxServiceApiKey: 'sandbox-env-key',
});
instanceCredentialBroker.resolveForUse.mockResolvedValue(null);
await expect(service.resolveDaytonaConfig()).resolves.toEqual({
apiUrl: 'https://env.daytona.example.com',
apiKey: 'daytona-env-key',
});
await expect(service.resolveN8nSandboxConfig()).resolves.toEqual({
serviceUrl: 'http://sandbox-api:8080',
apiKey: 'sandbox-env-key',
});
});
it('uses assigned Daytona credentials', async () => {
Object.assign(globalConfig.instanceAi, {
daytonaApiUrl: 'https://env.daytona.example.com',
daytonaApiKey: 'env-key',
});
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'daytona-credential',
name: 'Daytona',
type: 'daytonaApi',
data: { apiUrl: 'https://daytona.example.com', apiKey: 'credential-key' },
});
await expect(service.resolveDaytonaConfig()).resolves.toEqual({
apiUrl: 'https://daytona.example.com',
apiKey: 'credential-key',
});
});
it('falls back to environment config for a malformed Daytona credential', async () => {
Object.assign(globalConfig.instanceAi, {
daytonaApiUrl: 'https://env.daytona.example.com',
daytonaApiKey: 'env-key',
});
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'daytona-credential',
name: 'Daytona',
type: 'daytonaApi',
data: { apiUrl: 'not-a-url', apiKey: 'credential-key' },
});
await expect(service.resolveDaytonaConfig()).resolves.toEqual({
apiUrl: 'https://env.daytona.example.com',
apiKey: 'env-key',
});
expect(logger.warn).toHaveBeenCalledWith(
'Could not resolve the configured Daytona sandbox credential; using environment fallback',
{
credentialUseId: 'instance-ai:sandbox:daytona',
error:
'The field "apiUrl" must be a valid HTTP URL for provider connection type "daytonaApi"',
},
);
});
it('uses the assigned n8n Sandbox API key without replacing the environment URL', async () => {
globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-credential',
name: 'Sandbox',
type: 'httpHeaderAuth',
data: { name: 'X-Api-Key', value: 'credential-key' },
});
await expect(service.resolveN8nSandboxConfig()).resolves.toEqual({
serviceUrl: 'http://sandbox-api:8080',
apiKey: 'credential-key',
});
});
it('falls back to environment config for a malformed n8n Sandbox credential', async () => {
globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-credential',
name: 'Sandbox',
type: 'httpHeaderAuth',
data: { name: 'Authorization', value: 'credential-key' },
});
await expect(service.resolveN8nSandboxConfig()).resolves.toEqual({
serviceUrl: 'http://sandbox-api:8080',
apiKey: 'env-key',
});
expect(logger.warn).toHaveBeenCalledWith(
'Could not resolve the configured n8n Sandbox credential; using environment fallback',
{
credentialUseId: 'instance-ai:sandbox:n8n',
error: 'The credential\'s header name must be "x-api-key" but is "authorization"',
},
);
});
it('ignores assigned sandbox credentials on cloud deployments', async () => {
globalConfig.deployment.type = 'cloud';
Object.assign(globalConfig.instanceAi, {
daytonaApiUrl: 'https://env.daytona.example.com',
daytonaApiKey: 'daytona-env-key',
n8nSandboxServiceApiKey: 'sandbox-env-key',
});
await expect(service.resolveDaytonaConfig()).resolves.toEqual({
apiUrl: 'https://env.daytona.example.com',
apiKey: 'daytona-env-key',
});
await expect(service.resolveN8nSandboxConfig()).resolves.toEqual({
serviceUrl: 'http://sandbox-api:8080',
apiKey: 'sandbox-env-key',
});
expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,185 @@
import type { InstanceAiSandboxProvider } from '@n8n/api-types';
import { normalizeSandboxProvider as normalizeRuntimeSandboxProvider } from '@n8n/agents/sandbox';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { DeploymentConfig, InstanceAiConfig } from '@n8n/config';
import type { OperationContext } from '@n8n/db';
import { Service } from '@n8n/di';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import {
InstanceCredentialBroker,
type InstanceCredentialUse,
type ResolvedInstanceCredential,
} from '@/credentials/instance-credential-broker';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
const N8N_SANDBOX_HEADER_NAME = 'x-api-key';
type SandboxCredential = {
type: string;
data: ICredentialDataDecryptedObject;
};
function requireConnectionValue(
type: string,
data: ICredentialDataDecryptedObject,
field: string,
): string {
const value = data[field];
if (typeof value !== 'string' || value.trim().length === 0) {
throw new UnprocessableRequestError(
`The field "${field}" is required for provider connection type "${type}"`,
);
}
return value.trim();
}
function requireHttpUrl(type: string, data: ICredentialDataDecryptedObject, field: string): string {
const value = requireConnectionValue(type, data, field);
try {
const url = new URL(value);
if (url.protocol === 'http:' || url.protocol === 'https:') return value;
} catch {}
throw new UnprocessableRequestError(
`The field "${field}" must be a valid HTTP URL for provider connection type "${type}"`,
);
}
function parseSandboxServiceCredential({ type, data }: SandboxCredential): string {
const headerName = requireConnectionValue(type, data, 'name').toLowerCase();
if (headerName !== N8N_SANDBOX_HEADER_NAME) {
throw new UnprocessableRequestError(
`The credential's header name must be "${N8N_SANDBOX_HEADER_NAME}" but is "${headerName}"`,
);
}
return requireConnectionValue(type, data, 'value');
}
function parseDaytonaCredential({ type, data }: SandboxCredential): {
apiUrl: string;
apiKey: string;
} {
return {
apiUrl: requireHttpUrl(type, data, 'apiUrl'),
apiKey: requireConnectionValue(type, data, 'apiKey'),
};
}
export const INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY: InstanceCredentialUse = {
id: 'instance-ai:sandbox:daytona',
credentialTypes: ['daytonaApi'],
validate: parseDaytonaCredential,
};
export const INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY: InstanceCredentialUse = {
id: 'instance-ai:sandbox:n8n',
credentialTypes: ['httpHeaderAuth'],
validate: parseSandboxServiceCredential,
};
@Service()
export class SandboxSettingsService {
private readonly config: InstanceAiConfig;
private readonly deploymentConfig: DeploymentConfig;
private credentialUsesRegistered = false;
constructor(
globalConfig: GlobalConfig,
private readonly instanceCredentialBroker: InstanceCredentialBroker,
private readonly logger: Logger,
) {
this.config = globalConfig.instanceAi;
this.deploymentConfig = globalConfig.deployment;
this.config.sandboxProvider = this.getProvider();
}
getProvider(): InstanceAiSandboxProvider {
return normalizeRuntimeSandboxProvider(this.config.sandboxProvider);
}
registerCredentialUses(): void {
if (this.credentialUsesRegistered) return;
this.instanceCredentialBroker.registerUse(INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY);
this.instanceCredentialBroker.registerUse(INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY);
this.credentialUsesRegistered = true;
}
async resolveDaytonaConfig(): Promise<{ apiUrl?: string; apiKey?: string }> {
const { daytonaApiUrl, daytonaApiKey } = this.config;
const envConfig = {
apiUrl: daytonaApiUrl || undefined,
apiKey: daytonaApiKey || undefined,
};
const resolved = await this.resolveServiceCredential(
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY,
'Daytona sandbox',
);
if (!resolved) return envConfig;
try {
return parseDaytonaCredential(resolved);
} catch (error) {
this.warnCredentialFallback(
'Daytona sandbox',
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY.id,
ensureError(error).message,
);
return envConfig;
}
}
async resolveN8nSandboxConfig(
ctx?: OperationContext,
): Promise<{ serviceUrl?: string; apiKey?: string }> {
const { n8nSandboxServiceUrl, n8nSandboxServiceApiKey } = this.config;
const envConfig = {
serviceUrl: n8nSandboxServiceUrl || undefined,
apiKey: n8nSandboxServiceApiKey || undefined,
};
const resolved = await this.resolveServiceCredential(
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY,
'n8n Sandbox',
ctx,
);
if (!resolved) return envConfig;
try {
const apiKey = parseSandboxServiceCredential(resolved);
return { serviceUrl: n8nSandboxServiceUrl || undefined, apiKey };
} catch (error) {
this.warnCredentialFallback(
'n8n Sandbox',
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY.id,
ensureError(error).message,
);
return envConfig;
}
}
private async resolveServiceCredential(
policy: InstanceCredentialUse,
service: string,
ctx?: OperationContext,
): Promise<ResolvedInstanceCredential | null> {
if (this.deploymentConfig.type === 'cloud') return null;
const resolved = ctx
? this.instanceCredentialBroker.resolveForUse(policy, ctx)
: this.instanceCredentialBroker.resolveForUse(policy);
return await resolved.catch((error: unknown) => {
this.warnCredentialFallback(service, policy.id, ensureError(error).message);
return null;
});
}
private warnCredentialFallback(service: string, credentialUseId: string, reason: string): void {
this.logger
.scoped('instance-ai')
.warn(`Could not resolve the configured ${service} credential; using environment fallback`, {
credentialUseId,
error: reason,
});
}
}

View File

@ -208,9 +208,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
return isOtelCustomSpanAttributesLicensed && isOtelModuleActive;
});
// Opt-in flag: enabled when the backend's Daytona sandbox env vars
// (`N8N_AGENTS_AI_SANDBOX_ENABLED=true` + `N8N_AGENTS_AI_SANDBOX_PROVIDER=daytona`)
// are set, OR the AI Assistant proxy is available.
// Opt-in flag controlled by the backend's N8N_AGENTS_AI_SANDBOX_ENABLED setting.
const isAgentsKnowledgeBaseFeatureEnabled = computed(
() => isModuleActive('agents') && moduleSettings.value.agents?.knowledgeBaseEnabled === true,
);