mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Add n8n Sandbox support to Agent Knowledge (#35811)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5cb6d2c565
commit
6a30736841
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(' && '))}`;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
185
packages/cli/src/services/sandbox-settings.service.ts
Normal file
185
packages/cli/src/services/sandbox-settings.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user