diff --git a/packages/@n8n/agents/src/__tests__/workspace/sandbox/n8n-sandbox-sandbox.test.ts b/packages/@n8n/agents/src/__tests__/workspace/sandbox/n8n-sandbox-sandbox.test.ts index 9fade2cd8e9..269149999d0 100644 --- a/packages/@n8n/agents/src/__tests__/workspace/sandbox/n8n-sandbox-sandbox.test.ts +++ b/packages/@n8n/agents/src/__tests__/workspace/sandbox/n8n-sandbox-sandbox.test.ts @@ -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) => 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) => 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 () => { diff --git a/packages/@n8n/agents/src/workspace/sandbox/n8n-sandbox-sandbox.ts b/packages/@n8n/agents/src/workspace/sandbox/n8n-sandbox-sandbox.ts index c94c6d55847..adfe4f24f78 100644 --- a/packages/@n8n/agents/src/workspace/sandbox/n8n-sandbox-sandbox.ts +++ b/packages/@n8n/agents/src/workspace/sandbox/n8n-sandbox-sandbox.ts @@ -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 { - // 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 { 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(operation: Promise): Promise { + 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 | undefined { const merged = { diff --git a/packages/@n8n/agents/src/workspace/sandbox/workspace-root.ts b/packages/@n8n/agents/src/workspace/sandbox/workspace-root.ts index 1fefdccdc97..178c51cc65c 100644 --- a/packages/@n8n/agents/src/workspace/sandbox/workspace-root.ts +++ b/packages/@n8n/agents/src/workspace/sandbox/workspace-root.ts @@ -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 { diff --git a/packages/@n8n/api-types/src/agents/agent-files.constants.ts b/packages/@n8n/api-types/src/agents/agent-files.constants.ts index dc712ac0152..bf8a1a39e0a 100644 --- a/packages/@n8n/api-types/src/agents/agent-files.constants.ts +++ b/packages/@n8n/api-types/src/agents/agent-files.constants.ts @@ -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; diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 8b68669f6bc..73e5da42ad6 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -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. */ diff --git a/packages/@n8n/config/src/configs/agents.config.ts b/packages/@n8n/config/src/configs/agents.config.ts index f68999bffe7..9af1330ed28 100644 --- a/packages/@n8n/config/src/configs/agents.config.ts +++ b/packages/@n8n/config/src/configs/agents.config.ts @@ -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 = ''; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 14337b2b95b..da7580e3435 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -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; diff --git a/packages/cli/src/modules/agents/__tests__/agent-knowledge-commands.test.ts b/packages/cli/src/modules/agents/__tests__/agent-knowledge-commands.test.ts index f8b6c895e6b..91efc13026d 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-knowledge-commands.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-knowledge-commands.test.ts @@ -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(); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-knowledge-gate.test.ts b/packages/cli/src/modules/agents/__tests__/agent-knowledge-gate.test.ts deleted file mode 100644 index b8aae463079..00000000000 --- a/packages/cli/src/modules/agents/__tests__/agent-knowledge-gate.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-knowledge-sandbox.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-knowledge-sandbox.service.test.ts index 1087878b91d..c19f055d4b6 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-knowledge-sandbox.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-knowledge-sandbox.service.test.ts @@ -1,4 +1,11 @@ -import type { Mock, Mocked } from 'vitest'; +import type { Mocked } from 'vitest'; +import type { + CommandResult, + DaytonaSandboxConfig, + SandboxProvider, + WorkspaceFilesystem, + WorkspaceSandbox, +} from '@n8n/agents/sandbox'; import type { Logger } from '@n8n/backend-common'; import type { AgentsConfig } from '@n8n/config'; import type { AiAssistantClient } from '@n8n_io/ai-assistant-sdk'; @@ -6,11 +13,12 @@ import { mock } from 'vitest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; import type { AiService } from '../../../services/ai.service'; +import type { SandboxSettingsService } from '../../../services/sandbox-settings.service'; import type { AgentKnowledgeFileStore } from '../agent-knowledge-file-store'; import type { Agent } from '../entities/agent.entity'; import type { AgentFile } from '../entities/agent-file.entity'; -import { KNOWLEDGE_MIRROR_FILES_DIR } from '../agent-knowledge-storage'; +import { getAgentKnowledgePaths } from '../agent-knowledge-storage'; import { AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX, AgentKnowledgeSandboxService, @@ -18,70 +26,25 @@ import { import type { AgentFileRepository } from '../repositories/agent-file.repository'; import type { AgentRepository } from '../repositories/agent.repository'; -interface MockFilesystem { - uploadFiles: Mock; - createFolder: Mock; - deleteFile: Mock; -} +const { createSandboxMock, createFilesystemMock } = vi.hoisted(() => ({ + createSandboxMock: vi.fn(), + createFilesystemMock: vi.fn(), +})); -interface MockProcess { - executeCommand: Mock< - (...args: [string, string | undefined, Record | undefined, number]) => Promise<{ - exitCode: number; - result?: string; - artifacts?: { stdout?: string; stderr?: string }; - }> - >; -} - -interface MockSandbox { - id: string; - name: string; - state?: string; - start: Mock<(...args: [number]) => Promise>; - delete: Mock<(...args: [number]) => Promise>; - fs: MockFilesystem; - process: MockProcess; -} - -class DaytonaNotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = 'DaytonaNotFoundError'; - } -} - -const listMock = - vi.fn< - ( - ...args: [{ labels?: Record; limit?: number }?] - ) => AsyncIterableIterator - >(); -const createMock = - vi.fn<(...args: [Record, { timeout?: number }?]) => Promise>(); -const getMock = vi.fn<(...args: [string]) => Promise>(); -const daytonaInstances: MockDaytona[] = []; - -class MockDaytona { - constructor(readonly config: { apiUrl?: string; apiKey?: string }) { - daytonaInstances.push(this); - } - - list = listMock; - create = createMock; - get = getMock; -} - -vi.mock('@n8n/agents/sandbox', () => ({ - loadDaytona: () => ({ - Daytona: MockDaytona, - DaytonaNotFoundError, - }), +vi.mock('@n8n/agents/sandbox', async (importOriginal) => ({ + ...(await importOriginal()), + createSandbox: createSandboxMock, + createFilesystem: createFilesystemMock, })); const instanceId = 'instance-1'; const projectId = 'project-1'; const agentId = 'agent-1'; +const knowledgePaths = getAgentKnowledgePaths('daytona'); +const n8nKnowledgePaths = getAgentKnowledgePaths('n8n-sandbox'); + +type TestWorkspaceSandbox = WorkspaceSandbox & + Required>; function buildExpectedSandboxName(): string { return `${AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX}${instanceId}-${projectId}-${agentId}`.toLowerCase(); @@ -95,7 +58,6 @@ function makeAiService(overrides: Partial = {}): AiService { function makeProxyAiService(): AiService { const client = mock(); - client.getSandboxProxyConfig.mockResolvedValue({ image: 'proxy-image' }); client.getBuilderApiProxyToken.mockResolvedValue({ accessToken: 'proxy-token', tokenType: 'Bearer', @@ -124,25 +86,48 @@ function makeKnowledgeFileStore(): Mocked { return store; } -function makeService( - configOverrides: Partial = {}, - logger: Logger = mock(), - aiService: AiService = makeAiService(), - instanceSettings: InstanceSettings = mock({ instanceId }), - agentFileRepository: AgentFileRepository = mock(), - agentRepository: AgentRepository = makeAgentRepository(), - agentKnowledgeFileStore: AgentKnowledgeFileStore = makeKnowledgeFileStore(), -): AgentKnowledgeSandboxService { +function makeSandboxSettingsService( + provider: SandboxProvider = 'daytona', +): Mocked { + const service = mock(); + service.getProvider.mockReturnValue(provider); + service.resolveDaytonaConfig.mockResolvedValue({ + apiUrl: 'https://daytona.example', + apiKey: 'test-key', + }); + service.resolveN8nSandboxConfig.mockResolvedValue({ + serviceUrl: 'https://sandbox.example', + apiKey: 'sandbox-key', + }); + return service; +} + +function makeService({ + configOverrides = {}, + logger = mock(), + aiService = makeAiService(), + instanceSettings = mock({ instanceId }), + agentFileRepository = mock(), + agentRepository = makeAgentRepository(), + agentKnowledgeFileStore = makeKnowledgeFileStore(), + sandboxSettingsService = makeSandboxSettingsService(), +}: { + configOverrides?: Partial; + logger?: Logger; + aiService?: AiService; + instanceSettings?: InstanceSettings; + agentFileRepository?: AgentFileRepository; + agentRepository?: AgentRepository; + agentKnowledgeFileStore?: AgentKnowledgeFileStore; + sandboxSettingsService?: SandboxSettingsService; +} = {}): AgentKnowledgeSandboxService { return new AgentKnowledgeSandboxService( { sandboxEnabled: true, - sandboxProvider: 'daytona', sandboxImage: 'daytonaio/sandbox:0.5.0', sandboxSnapshot: '', sandboxTimeout: 300_000, sandboxEphemeral: false, - daytonaApiUrl: 'https://daytona.example', - daytonaApiKey: 'test-key', ...configOverrides, } as AgentsConfig, logger, @@ -151,6 +136,7 @@ function makeService( agentFileRepository, agentRepository, agentKnowledgeFileStore, + sandboxSettingsService, ); } @@ -171,256 +157,196 @@ function makeAgentFile(overrides: Partial = {}): AgentFile { } as AgentFile; } -function makeFilesystem(): MockFilesystem { - return { - uploadFiles: vi.fn< - (...args: [Array<{ source: Buffer | string; destination: string }>]) => Promise - >(async () => {}), - createFolder: vi.fn<(...args: [string, string]) => Promise>(async () => {}), - deleteFile: vi.fn<(...args: [string, boolean?]) => Promise>(async () => {}), - }; +function makeFilesystem(): Mocked { + return mock(); } function makeSandbox( - state = 'started', - overrides: Partial> = {}, -): MockSandbox { + provider: SandboxProvider = 'daytona', + id = 'sandbox-id', +): Mocked { + const sandbox = mock({ + id, + name: provider === 'daytona' ? 'DaytonaSandbox' : 'N8nSandboxServiceSandbox', + provider, + status: 'pending', + }); + sandbox._start.mockResolvedValue(); + sandbox.destroy.mockResolvedValue(); + sandbox.executeCommand.mockResolvedValue({ + success: true, + exitCode: 0, + stdout: '', + stderr: '', + executionTimeMs: 1, + }); + return sandbox; +} + +function makeCommandResult(stdout = '', stderr = '', exitCode = 0): CommandResult { return { - id: overrides.id ?? 'sandbox-id', - name: overrides.name ?? buildExpectedSandboxName(), - state, - start: vi.fn<(...args: [number]) => Promise>(async () => {}), - delete: vi.fn<(...args: [number]) => Promise>(async () => {}), - fs: makeFilesystem(), - process: { - executeCommand: vi.fn< - ( - ...args: [string, string | undefined, Record | undefined, number] - ) => Promise<{ - exitCode: number; - result?: string; - artifacts?: { stdout?: string; stderr?: string }; - }> - >(async () => ({ - exitCode: 0, - artifacts: { stdout: '', stderr: '' }, - })), - }, + success: exitCode === 0, + exitCode, + stdout, + stderr, + executionTimeMs: 1, }; } describe('AgentKnowledgeSandboxService', () => { + let sandbox: Mocked; + let filesystem: Mocked; + beforeEach(() => { vi.clearAllMocks(); - daytonaInstances.length = 0; - createMock.mockResolvedValue(makeSandbox('started')); - getMock.mockRejectedValue(new DaytonaNotFoundError('not found')); + sandbox = makeSandbox(); + filesystem = makeFilesystem(); + createSandboxMock.mockResolvedValue(sandbox); + createFilesystemMock.mockReturnValue(filesystem); }); - afterEach(() => { - vi.useRealTimers(); - }); - - it('creates a scoped sandbox', async () => { + it('creates and starts a deterministic direct-mode Daytona sandbox', async () => { const aiService = makeAiService(); - const service = makeService({}, mock(), aiService); + const sandboxSettingsService = makeSandboxSettingsService(); + const service = makeService({ + configOverrides: { + sandboxSnapshot: 'n8n/agent-knowledge:1.2.3', + sandboxEphemeral: true, + }, + aiService, + sandboxSettingsService, + }); const expectedName = buildExpectedSandboxName(); await service.warmSandbox(projectId, agentId); expect(aiService.getClient).not.toHaveBeenCalled(); - expect(daytonaInstances).toHaveLength(1); - expect(daytonaInstances[0].config).toEqual({ - apiUrl: 'https://daytona.example', - apiKey: 'test-key', - }); - expect(getMock).toHaveBeenCalledWith(expectedName); - expect(listMock).not.toHaveBeenCalled(); - expect(createMock).toHaveBeenCalledTimes(1); - const [params, options] = createMock.mock.calls[0]; - expect(params.name).toBe(expectedName); - expect(params.name).toMatch(/^agent-[a-z0-9-]+$/); - expect(params.labels).toEqual({ - 'n8n-agents-knowledgebase': 'true', - 'n8n-project-id': projectId, - 'n8n-agent-id': agentId, - }); - expect(params.ephemeral).toBe(false); - expect(params.image).toBe('daytonaio/sandbox:0.5.0'); - expect(params.snapshot).toBeUndefined(); - expect(options).toEqual({ timeout: 300 }); - }); - - it('forwards sandboxEphemeral config to Daytona create params', async () => { - const service = makeService({ sandboxEphemeral: true }); - - await service.warmSandbox(projectId, agentId); - - expect(getMock).toHaveBeenCalledWith(buildExpectedSandboxName()); - const [params] = createMock.mock.calls[0]; - expect(params.ephemeral).toBe(true); - }); - - it('reuses deterministic sandbox by name without listing', async () => { - const sandbox = makeSandbox('started'); - getMock.mockResolvedValue(sandbox); - const service = makeService(); - - await service.warmSandbox(projectId, agentId); - - expect(getMock).toHaveBeenCalledWith(buildExpectedSandboxName()); - expect(listMock).not.toHaveBeenCalled(); - expect(createMock).not.toHaveBeenCalled(); - }); - - it('starts a stopped deterministic sandbox before reuse', async () => { - const sandbox = makeSandbox('stopped'); - getMock.mockResolvedValue(sandbox); - const service = makeService(); - - await service.warmSandbox(projectId, agentId); - - expect(sandbox.start).toHaveBeenCalledWith(300); - expect(createMock).not.toHaveBeenCalled(); - }); - - it('deletes dead deterministic sandbox and recreates it', async () => { - const sandbox = makeSandbox('error'); - getMock.mockResolvedValueOnce(sandbox); - const service = makeService(); - const expectedName = buildExpectedSandboxName(); - - await service.warmSandbox(projectId, agentId); - - expect(sandbox.delete).toHaveBeenCalledWith(300); - expect(createMock).toHaveBeenCalledTimes(1); - expect(createMock.mock.calls[0][0].name).toBe(expectedName); - }); - - it('creates a sandbox from configured snapshot', async () => { - const service = makeService({ sandboxSnapshot: 'n8n/agent-knowledge:1.2.3' }); - const expectedName = buildExpectedSandboxName(); - - await service.warmSandbox(projectId, agentId); - - expect(createMock).toHaveBeenCalledTimes(1); - const [params] = createMock.mock.calls[0]; - expect(params.snapshot).toBe('n8n/agent-knowledge:1.2.3'); - expect(params.image).toBeUndefined(); - expect(params.name).toBe(expectedName); - expect(params.ephemeral).toBe(false); - expect(params.autoStopInterval).toBe(5); - }); - - it('falls back to image when configured snapshot create fails', async () => { - const logger = mock(); - createMock - .mockRejectedValueOnce(new Error('snapshot missing')) - .mockResolvedValueOnce(makeSandbox('started')); - const service = makeService({ sandboxSnapshot: 'n8n/agent-knowledge:missing' }, logger); - - await service.warmSandbox(projectId, agentId); - - expect(createMock).toHaveBeenCalledTimes(2); - const [snapshotParams] = createMock.mock.calls[0]; - const [imageParams] = createMock.mock.calls[1]; - expect(snapshotParams.snapshot).toBe('n8n/agent-knowledge:missing'); - expect(snapshotParams.image).toBeUndefined(); - expect(imageParams.image).toBe('daytonaio/sandbox:0.5.0'); - expect(imageParams.snapshot).toBeUndefined(); - expect(logger.warn).toHaveBeenCalledWith( - 'Agent knowledge sandbox create from snapshot failed; falling back to image', + expect(sandboxSettingsService.resolveDaytonaConfig).toHaveBeenCalled(); + expect(createSandboxMock).toHaveBeenCalledWith( expect.objectContaining({ - projectId, - agentId, - snapshotName: 'n8n/agent-knowledge:missing', + enabled: true, + provider: 'daytona', + id: expectedName, + name: expectedName, + daytonaApiUrl: 'https://daytona.example', + daytonaApiKey: 'test-key', + labels: { + 'n8n-agents-knowledgebase': 'true', + 'n8n-project-id': projectId, + 'n8n-agent-id': agentId, + }, + timeout: 300_000, + createTimeoutSeconds: 300, + image: 'daytonaio/sandbox:0.5.0', + snapshot: 'n8n/agent-knowledge:1.2.3', + ephemeral: true, + autoStopInterval: 5, + }), + expect.anything(), + ); + expect(sandbox._start).toHaveBeenCalled(); + expect(createFilesystemMock).toHaveBeenCalledWith(sandbox); + }); + + it('single-flights concurrent acquisition for the same project and agent', async () => { + let resolveCreation: (value: WorkspaceSandbox) => void; + createSandboxMock.mockReturnValue( + new Promise((resolve) => { + resolveCreation = resolve; }), ); + const service = makeService(); + + const first = service.warmSandbox(projectId, agentId); + const second = service.warmSandbox(projectId, agentId); + await vi.waitFor(() => expect(createSandboxMock).toHaveBeenCalledTimes(1)); + resolveCreation!(sandbox); + await Promise.all([first, second]); }); - it('ignores whitespace-only sandboxSnapshot', async () => { - const service = makeService({ sandboxSnapshot: ' ' }); + it('uses a stable UUID for the n8n sandbox', async () => { + const expectedId = 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0'; + const service = makeService({ + sandboxSettingsService: makeSandboxSettingsService('n8n-sandbox'), + }); await service.warmSandbox(projectId, agentId); - const [params] = createMock.mock.calls[0]; - expect(params.image).toBe('daytonaio/sandbox:0.5.0'); - expect(params.snapshot).toBeUndefined(); - }); - - it('requests a proxy token scoped to the project id when the proxy is enabled, even without sandbox enable/provider env vars', async () => { - const client = mock(); - client.getSandboxProxyConfig.mockResolvedValue({ image: 'proxy-image' }); - client.getBuilderApiProxyToken.mockResolvedValue({ - accessToken: 'proxy-token', - tokenType: 'Bearer', - }); - client.getSandboxProxyBaseUrl.mockReturnValue('https://sandbox-proxy.example'); - const aiService = makeAiService({ - isProxyEnabled: vi.fn().mockReturnValue(true), - getClient: vi.fn().mockResolvedValue(client), - }); - const service = makeService( - { sandboxEnabled: false, sandboxProvider: '', sandboxSnapshot: 'n8n/agent-knowledge:1.2.3' }, - mock(), - aiService, - ); - - await service.warmSandbox(projectId, agentId); - - expect(client.getBuilderApiProxyToken).toHaveBeenCalledWith( - { id: projectId }, + expect(createSandboxMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'n8n-sandbox', + id: expectedId, + serviceUrl: 'https://sandbox.example', + apiKey: 'sandbox-key', + }), expect.anything(), ); }); - it('fails with an actionable error instead of creating from an image when the proxy is enabled', async () => { - const service = makeService({ sandboxSnapshot: '' }, mock(), makeProxyAiService()); + it('reports how to configure a missing n8n sandbox service URL', async () => { + const settingsService = makeSandboxSettingsService('n8n-sandbox'); + settingsService.resolveN8nSandboxConfig.mockResolvedValue({}); + const service = makeService({ sandboxSettingsService: settingsService }); await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow( - /requires a snapshot.*N8N_AGENTS_AI_SANDBOX_SNAPSHOT/s, + /N8N_SANDBOX_SERVICE_URL/, ); - expect(createMock).not.toHaveBeenCalled(); + expect(createSandboxMock).not.toHaveBeenCalled(); }); - it('surfaces the snapshot failure instead of falling back to an image when the proxy is enabled', async () => { - createMock.mockRejectedValueOnce(new Error('snapshot missing')); - const service = makeService( - { sandboxSnapshot: 'n8n/agent-knowledge:missing' }, - mock(), - makeProxyAiService(), - ); - - await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow('snapshot missing'); - expect(createMock).toHaveBeenCalledTimes(1); - }); - - it('retries transient proxy setup failures', async () => { - vi.useFakeTimers(); + it('mints refreshable proxy tokens on demand with project scope', async () => { const client = mock(); - client.getSandboxProxyConfig - .mockRejectedValueOnce(Object.assign(new Error('Bad Gateway'), { statusCode: 502 })) - .mockResolvedValue({ image: 'proxy-image' }); - client.getBuilderApiProxyToken.mockResolvedValue({ - accessToken: 'proxy-token', - tokenType: 'Bearer', - }); + client.getBuilderApiProxyToken + .mockResolvedValueOnce({ accessToken: 'proxy-token-1', tokenType: 'Bearer' }) + .mockResolvedValueOnce({ accessToken: 'proxy-token-2', tokenType: 'Bearer' }); client.getSandboxProxyBaseUrl.mockReturnValue('https://sandbox-proxy.example'); const aiService = makeAiService({ isProxyEnabled: vi.fn().mockReturnValue(true), getClient: vi.fn().mockResolvedValue(client), }); - const service = makeService( - { sandboxSnapshot: 'n8n/agent-knowledge:1.2.3' }, - mock(), + const service = makeService({ + configOverrides: { sandboxSnapshot: 'n8n/agent-knowledge:1.2.3' }, aiService, + }); + + await service.warmSandbox(projectId, agentId); + + const config = createSandboxMock.mock.calls[0][0] as DaytonaSandboxConfig; + expect(config.daytonaApiUrl).toBe('https://sandbox-proxy.example'); + expect(config.snapshot).toBe('n8n/agent-knowledge:1.2.3'); + expect(config.image).toBeUndefined(); + expect(client.getBuilderApiProxyToken).not.toHaveBeenCalled(); + + await expect(config.getAuthToken?.()).resolves.toBe('proxy-token-1'); + await expect(config.getAuthToken?.()).resolves.toBe('proxy-token-2'); + expect(client.getBuilderApiProxyToken).toHaveBeenCalledTimes(2); + expect(client.getBuilderApiProxyToken.mock.calls[0][0]).toEqual({ id: projectId }); + expect(client.getBuilderApiProxyToken.mock.calls[1][0]).toEqual({ id: projectId }); + }); + + it('fails with an actionable error instead of creating from an image when the proxy is enabled', async () => { + const service = makeService({ + configOverrides: { sandboxSnapshot: '' }, + aiService: makeProxyAiService(), + }); + + await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow( + /requires a snapshot.*N8N_AGENTS_AI_SANDBOX_SNAPSHOT/s, ); + expect(createSandboxMock).not.toHaveBeenCalled(); + }); - const promise = service.warmSandbox(projectId, agentId); - await vi.advanceTimersByTimeAsync(1000); - await promise; + it('surfaces the snapshot failure instead of falling back to an image when the proxy is enabled', async () => { + createSandboxMock.mockRejectedValueOnce(new Error('snapshot missing')); + const service = makeService({ + configOverrides: { sandboxSnapshot: 'n8n/agent-knowledge:missing' }, + aiService: makeProxyAiService(), + }); - expect(client.getSandboxProxyConfig).toHaveBeenCalledTimes(2); + await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow('snapshot missing'); + const config = createSandboxMock.mock.calls[0][0] as DaytonaSandboxConfig; + expect(config.snapshot).toBe('n8n/agent-knowledge:missing'); + expect(config.image).toBeUndefined(); }); describe('globKnowledgeFiles', () => { @@ -436,14 +362,10 @@ describe('AgentKnowledgeSandboxService', () => { agentFileRepository.findByAgentId.mockResolvedValue(fixtureFiles); const agentRepository = mock(); agentRepository.existsBy.mockResolvedValue(true); - return makeService( - {}, - mock(), - makeAiService(), - mock({ instanceId }), + return makeService({ agentFileRepository, agentRepository, - ); + }); } it('lists all files with a catch-all pattern, sorted by display name', async () => { @@ -500,42 +422,36 @@ describe('AgentKnowledgeSandboxService', () => { }); describe('destroySandbox', () => { - it('deletes the sandbox by name', async () => { - const sandbox = makeSandbox('started'); - getMock.mockResolvedValue(sandbox); - const service = makeService(); - - await service.destroySandbox(projectId, agentId); - - expect(getMock).toHaveBeenCalledWith(buildExpectedSandboxName()); - expect(sandbox.delete).toHaveBeenCalledWith(300); - }); - - it('swallows a NotFound error without throwing', async () => { - getMock.mockRejectedValue(new DaytonaNotFoundError('not found')); - const service = makeService(); - - await expect(service.destroySandbox(projectId, agentId)).resolves.toBeUndefined(); - }); - - it('swallows any other error and logs a warning without throwing', async () => { - getMock.mockRejectedValue(new Error('boom')); - const logger = mock(); - const service = makeService({}, logger); - - await expect(service.destroySandbox(projectId, agentId)).resolves.toBeUndefined(); - expect(logger.warn).toHaveBeenCalledWith( - 'Failed to destroy agent knowledge sandbox', - expect.objectContaining({ projectId, agentId }), + it('best-effort destroys both provider sandboxes by their deterministic identities', async () => { + const daytonaSandbox = makeSandbox('daytona', buildExpectedSandboxName()); + daytonaSandbox.destroy.mockRejectedValue(new Error('remote unavailable')); + const n8nSandbox = makeSandbox('n8n-sandbox', 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0'); + createSandboxMock.mockImplementation(async (config) => + config.provider === 'daytona' ? daytonaSandbox : n8nSandbox, ); - }); - - it('is a no-op when the knowledge base is disabled', async () => { - const service = makeService({ sandboxEnabled: false }); + const service = makeService({ configOverrides: { sandboxEnabled: false } }); await service.destroySandbox(projectId, agentId); - expect(getMock).not.toHaveBeenCalled(); + expect(createSandboxMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + provider: 'daytona', + id: buildExpectedSandboxName(), + name: buildExpectedSandboxName(), + }), + expect.anything(), + ); + expect(createSandboxMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + provider: 'n8n-sandbox', + id: 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0', + }), + expect.anything(), + ); + expect(daytonaSandbox.destroy).toHaveBeenCalled(); + expect(n8nSandbox.destroy).toHaveBeenCalled(); }); }); @@ -545,21 +461,18 @@ describe('AgentKnowledgeSandboxService', () => { } function isMirrorSyncCommand(command: string): boolean { - return command.includes(`mkdir -p ${KNOWLEDGE_MIRROR_FILES_DIR}`); + return command.includes(`mkdir -p ${knowledgePaths.filesDir}`); } function makeMirrorFile(id: string, fileName: string): AgentFile { return makeAgentFile({ id, fileName }); } - it('syncs on the first operation, skips an unchanged repeat, and diffs on a changed file list', async () => { - const sandbox = makeSandbox('started'); + it('syncs once, checks an unchanged repeat, and recopies a replaced file', async () => { let manifestState = ''; - sandbox.process.executeCommand.mockImplementation(async (command) => ({ - exitCode: 0, - artifacts: { stdout: isManifestReadCommand(command) ? manifestState : '', stderr: '' }, - })); - getMock.mockResolvedValue(sandbox); + sandbox.executeCommand.mockImplementation(async (command) => + makeCommandResult(isManifestReadCommand(command) ? manifestState : ''), + ); const agentFileRepository = mock(); agentFileRepository.findByAgentId.mockResolvedValue([ makeMirrorFile('file-1', 'doc1.txt'), @@ -568,145 +481,232 @@ describe('AgentKnowledgeSandboxService', () => { const agentRepository = makeAgentRepository(); agentRepository.existsBy.mockResolvedValue(true); const agentKnowledgeFileStore = makeKnowledgeFileStore(); - const service = makeService( - {}, - mock(), - makeAiService(), - mock({ instanceId }), + const service = makeService({ agentFileRepository, agentRepository, agentKnowledgeFileStore, - ); + }); await service.searchKnowledge(projectId, agentId, { pattern: 'foo' }); - let commands = sandbox.process.executeCommand.mock.calls.map(([command]) => command); + let commands = sandbox.executeCommand.mock.calls.map(([command]) => command); expect(commands.filter(isManifestReadCommand)).toHaveLength(1); expect(commands.filter(isMirrorSyncCommand)).toHaveLength(1); expect(agentKnowledgeFileStore.readAsBuffer).toHaveBeenCalledTimes(2); - expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1); - manifestState = 'doc1.txt\ndoc2.txt\n'; + const stagingDir = filesystem.mkdir.mock.calls[0][0]; + expect(stagingDir).toContain(`${knowledgePaths.stagingDir}/`); + expect(filesystem.mkdir).toHaveBeenCalledWith(stagingDir, { + recursive: true, + }); + expect(filesystem.writeFile).toHaveBeenCalledTimes(2); + expect(filesystem.writeFile.mock.calls[0][0]).toBe(`${stagingDir}/doc1.txt`); + expect(filesystem.writeFile.mock.calls[1][0]).toBe(`${stagingDir}/doc2.txt`); + expect(filesystem.writeFile.mock.invocationCallOrder[1]).toBeLessThan( + sandbox.executeCommand.mock.invocationCallOrder[1], + ); + expect(filesystem.rmdir).toHaveBeenCalledWith(stagingDir, { + recursive: true, + force: true, + }); + manifestState = 'file-1\tdoc1.txt\nfile-2\tdoc2.txt\n'; - sandbox.process.executeCommand.mockClear(); + sandbox.executeCommand.mockClear(); agentKnowledgeFileStore.readAsBuffer.mockClear(); - sandbox.fs.uploadFiles.mockClear(); + filesystem.writeFile.mockClear(); await service.searchKnowledge(projectId, agentId, { pattern: 'bar' }); - commands = sandbox.process.executeCommand.mock.calls.map(([command]) => command); - expect(commands.filter(isManifestReadCommand)).toHaveLength(0); + commands = sandbox.executeCommand.mock.calls.map(([command]) => command); + expect(commands.filter(isManifestReadCommand)).toHaveLength(1); expect(commands.filter(isMirrorSyncCommand)).toHaveLength(0); - expect(commands).toHaveLength(1); expect(agentKnowledgeFileStore.readAsBuffer).not.toHaveBeenCalled(); - expect(sandbox.fs.uploadFiles).not.toHaveBeenCalled(); + expect(filesystem.writeFile).not.toHaveBeenCalled(); - sandbox.process.executeCommand.mockClear(); + sandbox.executeCommand.mockClear(); agentKnowledgeFileStore.readAsBuffer.mockClear(); - sandbox.fs.uploadFiles.mockClear(); + filesystem.writeFile.mockClear(); agentFileRepository.findByAgentId.mockResolvedValue([ - makeMirrorFile('file-1', 'doc1.txt'), + makeMirrorFile('file-1-replacement', 'doc1.txt'), makeMirrorFile('file-2', 'doc2.txt'), - makeMirrorFile('file-3', 'doc3.txt'), ]); await service.searchKnowledge(projectId, agentId, { pattern: 'baz' }); - commands = sandbox.process.executeCommand.mock.calls.map(([command]) => command); + commands = sandbox.executeCommand.mock.calls.map(([command]) => command); expect(commands.filter(isManifestReadCommand)).toHaveLength(1); const syncCommands = commands.filter(isMirrorSyncCommand); expect(syncCommands).toHaveLength(1); - // Only the newly-added name should be fetched and staged for move — - // the manifest rewrite (which always lists every expected name) is - // a separate, later part of the finalize command. expect(agentKnowledgeFileStore.readAsBuffer).toHaveBeenCalledTimes(1); - expect(syncCommands[0]).toContain('.tmp-doc3.txt'); - expect(syncCommands[0]).not.toContain('.tmp-doc1.txt'); - expect(syncCommands[0]).not.toContain('.tmp-doc2.txt'); + expect(syncCommands[0]).toContain(`${knowledgePaths.stagingDir}/`); + expect(syncCommands[0]).toContain('/doc1.txt'); + expect(syncCommands[0]).toContain('file-1-replacement\tdoc1.txt'); }); - it('flushes mirror uploads in size-bounded batches', async () => { - const MIRROR_UPLOAD_BATCH_BYTES = 64 * 1024 * 1024; - const sandbox = makeSandbox('started'); - sandbox.process.executeCommand.mockResolvedValue({ - exitCode: 0, - artifacts: { stdout: '', stderr: '' }, + it('runs a queued mirror sync with a newer file snapshot after an in-flight sync', async () => { + let releaseFirstManifestRead!: () => void; + const firstManifestRead = new Promise((resolve) => { + releaseFirstManifestRead = resolve; + }); + let blockManifestRead = true; + sandbox.executeCommand.mockImplementation(async (command) => { + if (isManifestReadCommand(command) && blockManifestRead) { + blockManifestRead = false; + await firstManifestRead; + } + return makeCommandResult(); }); - getMock.mockResolvedValue(sandbox); const agentFileRepository = mock(); - agentFileRepository.findByAgentId.mockResolvedValue([ - makeMirrorFile('file-1', 'doc1.txt'), - makeMirrorFile('file-2', 'doc2.txt'), - ]); + agentFileRepository.findByAgentId + .mockResolvedValueOnce([makeMirrorFile('file-1', 'doc1.txt')]) + .mockResolvedValueOnce([ + makeMirrorFile('file-1', 'doc1.txt'), + makeMirrorFile('file-2', 'doc2.txt'), + ]); const agentRepository = makeAgentRepository(); agentRepository.existsBy.mockResolvedValue(true); - const agentKnowledgeFileStore = makeKnowledgeFileStore(); - agentKnowledgeFileStore.readAsBuffer.mockResolvedValue( - Buffer.alloc(MIRROR_UPLOAD_BATCH_BYTES), - ); - const service = makeService( - {}, - mock(), - makeAiService(), - mock({ instanceId }), + const service = makeService({ agentFileRepository, agentRepository, - agentKnowledgeFileStore, + }); + + const firstSearch = service.searchKnowledge(projectId, agentId, { pattern: 'first' }); + await vi.waitFor(() => + expect( + sandbox.executeCommand.mock.calls.filter(([command]) => isManifestReadCommand(command)), + ).toHaveLength(1), ); - await service.searchKnowledge(projectId, agentId, { pattern: 'foo' }); + const secondSearch = service.searchKnowledge(projectId, agentId, { pattern: 'second' }); + await vi.waitFor(() => expect(createFilesystemMock).toHaveBeenCalledTimes(2)); + await new Promise((resolve) => setTimeout(resolve, 0)); + releaseFirstManifestRead(); - expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(2); - const uploadCalls = sandbox.fs.uploadFiles.mock.calls as Array< - [Array<{ destination: string }>] - >; - expect(uploadCalls[0][0]).toHaveLength(1); - expect(uploadCalls[1][0]).toHaveLength(1); + await Promise.all([firstSearch, secondSearch]); + + expect(filesystem.writeFile.mock.calls.map(([filePath]) => filePath)).toContainEqual( + expect.stringMatching(/\/doc2\.txt$/), + ); }); - it('cds into the sandbox-local mirror directory for search commands', async () => { - const sandbox = makeSandbox('started'); - sandbox.process.executeCommand.mockResolvedValue({ - exitCode: 0, - artifacts: { stdout: '', stderr: '' }, - }); - getMock.mockResolvedValue(sandbox); + it('uses the n8n sandbox home for the knowledge mirror', async () => { + sandbox = makeSandbox('n8n-sandbox', 'n8n-sandbox-id'); + createSandboxMock.mockResolvedValue(sandbox); const agentFileRepository = mock(); agentFileRepository.findByAgentId.mockResolvedValue([makeMirrorFile('file-1', 'doc1.txt')]); const agentRepository = makeAgentRepository(); agentRepository.existsBy.mockResolvedValue(true); - const service = makeService( - {}, - mock(), - makeAiService(), - mock({ instanceId }), + const service = makeService({ + aiService: makeProxyAiService(), agentFileRepository, agentRepository, - ); + sandboxSettingsService: makeSandboxSettingsService('n8n-sandbox'), + }); await service.searchKnowledge(projectId, agentId, { pattern: 'foo' }); - const searchCommand = sandbox.process.executeCommand.mock.calls - .map(([command]) => command) - .find((command) => command.includes(' rg ')); - expect(searchCommand).toBeDefined(); - expect(searchCommand).toContain(`cd '\\''${KNOWLEDGE_MIRROR_FILES_DIR}'\\''`); + expect(filesystem.mkdir.mock.calls[0][0]).toContain(`${n8nKnowledgePaths.stagingDir}/`); + expect(filesystem.mkdir).toHaveBeenCalledWith(filesystem.mkdir.mock.calls[0][0], { + recursive: true, + }); + const searchCall = sandbox.executeCommand.mock.calls.find(([command]) => + command.includes(' rg '), + ); + expect(searchCall?.[0]).toContain(`cd '\\''${n8nKnowledgePaths.filesDir}'\\''`); + }); + + it.each([ + { provider: 'daytona', deterministicId: buildExpectedSandboxName() }, + { + provider: 'n8n-sandbox', + deterministicId: 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0', + }, + ] satisfies Array<{ provider: SandboxProvider; deterministicId: string }>)( + 'resyncs the mirror when a $provider sandbox is recreated under the same ID', + async ({ provider, deterministicId }) => { + const staleSandbox = makeSandbox(provider, deterministicId); + const replacementSandbox = makeSandbox(provider, deterministicId); + const staleFilesystem = makeFilesystem(); + const replacementFilesystem = makeFilesystem(); + createSandboxMock + .mockResolvedValueOnce(staleSandbox) + .mockResolvedValueOnce(replacementSandbox); + createFilesystemMock + .mockReturnValueOnce(staleFilesystem) + .mockReturnValueOnce(replacementFilesystem); + const agentFileRepository = mock(); + agentFileRepository.findByAgentId.mockResolvedValue([makeMirrorFile('file-1', 'doc1.txt')]); + const agentRepository = makeAgentRepository(); + agentRepository.existsBy.mockResolvedValue(true); + const service = makeService({ + agentFileRepository, + agentRepository, + sandboxSettingsService: makeSandboxSettingsService(provider), + }); + + await service.searchKnowledge(projectId, agentId, { pattern: 'first' }); + await service.searchKnowledge(projectId, agentId, { pattern: 'second' }); + + expect(staleFilesystem.writeFile).toHaveBeenCalledOnce(); + expect(replacementFilesystem.writeFile).toHaveBeenCalledOnce(); + }, + ); + + it('redacts command stderr before reporting a failed knowledge operation', async () => { + const secret = 'Authorization: Bearer abc.def-ghi_jkl/mno=012345678901234567890123456789'; + sandbox.executeCommand.mockImplementation(async (command) => + command.includes(' rg ') + ? makeCommandResult('', `failed with ${secret}`, 2) + : makeCommandResult(), + ); + const agentFileRepository = mock(); + agentFileRepository.findByAgentId.mockResolvedValue([makeMirrorFile('file-1', 'doc1.txt')]); + const agentRepository = makeAgentRepository(); + agentRepository.existsBy.mockResolvedValue(true); + const service = makeService({ + agentFileRepository, + agentRepository, + }); + + const error = await service + .searchKnowledge(projectId, agentId, { pattern: 'foo' }) + .then(() => undefined) + .catch((cause: Error) => cause); + + expect(error?.message).toContain('[REDACTED]'); + expect(error?.message).not.toContain('abc.def'); + }); + + it('propagates filesystem write failures instead of finalizing a partial mirror', async () => { + const providerError = new Error('provider write failed'); + filesystem.writeFile.mockRejectedValue(providerError); + const agentFileRepository = mock(); + agentFileRepository.findByAgentId.mockResolvedValue([makeMirrorFile('file-1', 'doc1.txt')]); + const agentRepository = makeAgentRepository(); + agentRepository.existsBy.mockResolvedValue(true); + const service = makeService({ + agentFileRepository, + agentRepository, + }); + + await expect(service.searchKnowledge(projectId, agentId, { pattern: 'foo' })).rejects.toBe( + providerError, + ); + expect( + sandbox.executeCommand.mock.calls.some(([command]) => isMirrorSyncCommand(command)), + ).toBe(false); + expect(filesystem.rmdir).toHaveBeenCalledWith(filesystem.mkdir.mock.calls[0][0], { + recursive: true, + force: true, + }); }); it('fails the operation instead of returning stale results when the sync command errors', async () => { - const sandbox = makeSandbox('started'); - sandbox.process.executeCommand.mockImplementation(async (command) => - isMirrorSyncCommand(command) - ? { exitCode: 1, artifacts: { stdout: '', stderr: 'disk full' } } - : { exitCode: 0, artifacts: { stdout: '', stderr: '' } }, + sandbox.executeCommand.mockImplementation(async (command) => + isMirrorSyncCommand(command) ? makeCommandResult('', 'disk full', 1) : makeCommandResult(), ); - getMock.mockResolvedValue(sandbox); const agentFileRepository = mock(); agentFileRepository.findByAgentId.mockResolvedValue([makeMirrorFile('file-1', 'doc1.txt')]); const agentRepository = makeAgentRepository(); agentRepository.existsBy.mockResolvedValue(true); - const service = makeService( - {}, - mock(), - makeAiService(), - mock({ instanceId }), + const service = makeService({ agentFileRepository, agentRepository, - ); + }); await expect(service.searchKnowledge(projectId, agentId, { pattern: 'foo' })).rejects.toThrow( /Agent knowledge mirror sync failed/, @@ -714,12 +714,7 @@ describe('AgentKnowledgeSandboxService', () => { }); it('skips a file that fails to load from the knowledge file store and retries it next sync', async () => { - const sandbox = makeSandbox('started'); - sandbox.process.executeCommand.mockResolvedValue({ - exitCode: 0, - artifacts: { stdout: '', stderr: '' }, - }); - getMock.mockResolvedValue(sandbox); + sandbox.executeCommand.mockResolvedValue(makeCommandResult()); const agentFileRepository = mock(); agentFileRepository.findByAgentId.mockResolvedValue([makeMirrorFile('file-1', 'doc1.txt')]); const agentRepository = makeAgentRepository(); @@ -727,35 +722,31 @@ describe('AgentKnowledgeSandboxService', () => { const agentKnowledgeFileStore = makeKnowledgeFileStore(); agentKnowledgeFileStore.readAsBuffer.mockRejectedValueOnce(new Error('missing on disk')); const logger = mock(); - const service = makeService( - {}, + const service = makeService({ logger, - makeAiService(), - mock({ instanceId }), agentFileRepository, agentRepository, agentKnowledgeFileStore, - ); + }); await expect( service.searchKnowledge(projectId, agentId, { pattern: 'foo' }), ).resolves.toBeDefined(); - expect(sandbox.fs.uploadFiles).not.toHaveBeenCalled(); + expect(filesystem.writeFile).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( 'Failed to load agent knowledge file for mirror sync', expect.objectContaining({ file: 'doc1.txt' }), ); - // The failed file was left out of the cached manifest, so the next - // sync's expected hash mismatches and it retries — this time - // `readAsBuffer` succeeds (the mock's default resolves). + // The failed file remains absent from the remote manifest, so the + // next sync retries it and `readAsBuffer` succeeds. await expect( service.searchKnowledge(projectId, agentId, { pattern: 'bar' }), ).resolves.toBeDefined(); expect(agentKnowledgeFileStore.readAsBuffer).toHaveBeenCalledTimes(2); - expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1); + expect(filesystem.writeFile).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-knowledge.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-knowledge.controller.test.ts index 9f8f09abe1e..f35ab11fed4 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-knowledge.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-knowledge.controller.test.ts @@ -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(), agentsConfig = { sandboxEnabled: true, - sandboxProvider: 'daytona', } as AgentsConfig, runtimeCacheService = mock(), - aiService = mock({ isProxyEnabled: () => false }), }: { agentKnowledgeService?: Mocked; agentsConfig?: AgentsConfig; runtimeCacheService?: Mocked; - aiService?: Mocked; } = {}) { 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, }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-knowledge.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-knowledge.service.test.ts index c97895e9de2..99d82d14083 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-knowledge.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-knowledge.service.test.ts @@ -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, diff --git a/packages/cli/src/modules/agents/__tests__/agent-sandbox.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-sandbox.controller.test.ts index 96a605e907e..4182f80e12e 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-sandbox.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-sandbox.controller.test.ts @@ -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(); - const controller = new AgentSandboxController( - agentKnowledgeService, - mock(), - { - sandboxEnabled: true, - sandboxProvider: 'daytona', - } as AgentsConfig, - mock({ isProxyEnabled: () => false }), - ); + const controller = new AgentSandboxController(agentKnowledgeService, mock(), { + sandboxEnabled: true, + } as AgentsConfig); const req = { user: { id: 'user-1' } } as AuthenticatedRequest<{ projectId: string }>; const res = mock(); @@ -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(); + const controller = new AgentSandboxController(agentKnowledgeService, mock(), { + sandboxEnabled: false, + } as AgentsConfig); + + await expect( + controller.warmKnowledgeSandbox( + {} as AuthenticatedRequest<{ projectId: string }>, + mock(), + 'project-1', + 'agent-1', + ), + ).rejects.toThrow('Agent knowledge base is not enabled'); + expect(agentKnowledgeService.warmSandbox).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-upload.middleware.test.ts b/packages/cli/src/modules/agents/__tests__/agent-upload.middleware.test.ts index 3d7b8fc4ec3..b368442d6c7 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-upload.middleware.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-upload.middleware.test.ts @@ -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', () => { diff --git a/packages/cli/src/modules/agents/__tests__/agents.module.test.ts b/packages/cli/src/modules/agents/__tests__/agents.module.test.ts index 954d603df3e..f2a377d3167 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.module.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.module.test.ts @@ -15,55 +15,26 @@ describe('AgentsModule', () => { }); describe('settings()', () => { - it('exposes knowledgeBaseEnabled when the direct Daytona sandbox config is complete', async () => { - Container.set( - AgentsConfig, - mock({ - modules: [], - sandboxEnabled: true, - sandboxProvider: 'daytona', - }), - ); - Container.set(AiService, mock({ 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({ + modules: [], + sandboxEnabled, + }), + ); + Container.set(AiService, mock({ 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({ - modules: [], - sandboxEnabled: false, - sandboxProvider: '', - }), - ); - Container.set(AiService, mock({ 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({ - modules: [], - sandboxEnabled: false, - sandboxProvider: '', - }), - ); - Container.set(AiService, mock({ 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); + }, + ); }); }); diff --git a/packages/cli/src/modules/agents/agent-knowledge-commands.ts b/packages/cli/src/modules/agents/agent-knowledge-commands.ts index 957ca406821..7e88af2c634 100644 --- a/packages/cli/src/modules/agents/agent-knowledge-commands.ts +++ b/packages/cli/src/modules/agents/agent-knowledge-commands.ts @@ -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>, + 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(' && '))}`; diff --git a/packages/cli/src/modules/agents/agent-knowledge-gate.ts b/packages/cli/src/modules/agents/agent-knowledge-gate.ts deleted file mode 100644 index 2db905aa6d0..00000000000 --- a/packages/cli/src/modules/agents/agent-knowledge-gate.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { AgentsConfig } from '@n8n/config'; - -export function isAgentKnowledgeBaseEnabled( - config: Pick, - aiAssistantAvailable: boolean, -): boolean { - return aiAssistantAvailable || (config.sandboxEnabled && config.sandboxProvider === 'daytona'); -} diff --git a/packages/cli/src/modules/agents/agent-knowledge-sandbox.service.ts b/packages/cli/src/modules/agents/agent-knowledge-sandbox.service.ts index 81dcc4aab85..605c60e7886 100644 --- a/packages/cli/src/modules/agents/agent-knowledge-sandbox.service.ts +++ b/packages/cli/src/modules/agents/agent-knowledge-sandbox.service.ts @@ -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([ - '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 { + const manifest = new Map(); + 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 { @@ -165,12 +139,6 @@ function buildScopeLabels(projectId: string, agentId: string): Record>(); - private readonly mirrorManifestHashes = new TtlMap(MIRROR_MANIFEST_HASH_TTL_MS); + private readonly pendingSandboxAcquisitions = new Map< + string, + Promise + >(); private readonly pendingMirrorSyncs = new Map>(); 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 { @@ -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 { - 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 { 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 { - 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 { + 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 { - 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 { - // 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, - sandboxName: string, + runtime: AgentKnowledgeSandboxRuntime, + files: AgentKnowledgeFileReference[], + stagingId: string, ): Promise> { const copiedNames = new Set(); - 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 => { - 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 { - const cacheKey = buildSandboxScopeKey(projectId, agentId); + private async acquireSandbox( + projectId: string, + agentId: string, + ): Promise { + 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 { + private async acquireSandboxFresh( + projectId: string, + agentId: string, + provider: SandboxProvider, + sandboxName: string, + cacheKey: string, + ): Promise { 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 { + 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 { + 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 { - 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 }, - name: string, - timeoutSeconds: number, - connection: AgentKnowledgeDaytonaConnection, - ): Promise { - let sandbox: Sandbox; - try { - sandbox = await daytona.get(name); - } catch (error) { - if (isDaytonaNotFoundError(error)) { - return undefined; - } - throw error; + private async resolveN8nSandboxConfig(sandboxId: string): Promise { + 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, - connection: AgentKnowledgeDaytonaConnection, - ): Promise { - 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( diff --git a/packages/cli/src/modules/agents/agent-knowledge-storage.ts b/packages/cli/src/modules/agents/agent-knowledge-storage.ts index 2b9a0be306b..a24b85fe731 100644 --- a/packages/cli/src/modules/agents/agent-knowledge-storage.ts +++ b/packages/cli/src/modules/agents/agent-knowledge-storage.ts @@ -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) { diff --git a/packages/cli/src/modules/agents/agent-knowledge.controller.ts b/packages/cli/src/modules/agents/agent-knowledge.controller.ts index 900de9aa2f7..937d0742855 100644 --- a/packages/cli/src/modules/agents/agent-knowledge.controller.ts +++ b/packages/cli/src/modules/agents/agent-knowledge.controller.ts @@ -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'); } } diff --git a/packages/cli/src/modules/agents/agent-knowledge.service.ts b/packages/cli/src/modules/agents/agent-knowledge.service.ts index a9694e519f7..ee3f0b94ef7 100644 --- a/packages/cli/src/modules/agents/agent-knowledge.service.ts +++ b/packages/cli/src/modules/agents/agent-knowledge.service.ts @@ -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 { + async deleteAllFilesForAgent(_projectId: string, agentId: string): Promise { 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. */ diff --git a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts index 2b13a166b99..901ebbcc02a 100644 --- a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts +++ b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts @@ -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( diff --git a/packages/cli/src/modules/agents/agent-sandbox.controller.ts b/packages/cli/src/modules/agents/agent-sandbox.controller.ts index 4893a3f5766..76464491d5d 100644 --- a/packages/cli/src/modules/agents/agent-sandbox.controller.ts +++ b/packages/cli/src/modules/agents/agent-sandbox.controller.ts @@ -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 { try { - if (!this.isKnowledgeBaseEnabled()) return; await this.agentKnowledgeService.warmSandbox(agentId, projectId); } catch (error) { this.logger.warn('Failed to warm agent knowledge sandbox', { diff --git a/packages/cli/src/modules/agents/agents.module.ts b/packages/cli/src/modules/agents/agents.module.ts index ab9157cf6d4..750a837f2dd 100644 --- a/packages/cli/src/modules/agents/agents.module.ts +++ b/packages/cli/src/modules/agents/agents.module.ts @@ -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, }; } diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts index 64be087472d..dd5d59739b1 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts @@ -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(); const credentialsFinderService = mock(); const instanceCredentialBroker = mock(); + const sandboxSettingsService = mock(); const eventService = mock(); const logger = mock(); @@ -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', () => { diff --git a/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts index cd8be17bea0..6ad2bea24c2 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts @@ -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 = 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 { - 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 { - 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, diff --git a/packages/cli/src/modules/instance-ai/instance-ai.module.ts b/packages/cli/src/modules/instance-ai/instance-ai.module.ts index a35e56f29aa..7d2c37ba275 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.module.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.module.ts @@ -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'); diff --git a/packages/cli/src/services/__tests__/sandbox-settings.service.test.ts b/packages/cli/src/services/__tests__/sandbox-settings.service.test.ts new file mode 100644 index 00000000000..598d66a491f --- /dev/null +++ b/packages/cli/src/services/__tests__/sandbox-settings.service.test.ts @@ -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(); + const logger = mock(); + + 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(); + }); +}); diff --git a/packages/cli/src/services/sandbox-settings.service.ts b/packages/cli/src/services/sandbox-settings.service.ts new file mode 100644 index 00000000000..3232b8513de --- /dev/null +++ b/packages/cli/src/services/sandbox-settings.service.ts @@ -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 { + 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, + }); + } +} diff --git a/packages/frontend/@n8n/stores/src/settings.store.ts b/packages/frontend/@n8n/stores/src/settings.store.ts index d29c39195b2..1b60f972b40 100644 --- a/packages/frontend/@n8n/stores/src/settings.store.ts +++ b/packages/frontend/@n8n/stores/src/settings.store.ts @@ -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, );