From 5c9a732af47e9682e81aaa4cccba583f2c4910f9 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Fri, 17 Apr 2026 13:36:49 +0200 Subject: [PATCH] fix(core): Rework Instance ai settings (no-changelog) (#28495) --- .../@n8n/api-types/src/frontend-settings.ts | 1 + packages/@n8n/api-types/src/index.ts | 4 +- .../src/schemas/instance-ai.schema.ts | 12 +- .../instance-ai-settings.service.test.ts | 90 +++++- .../__tests__/instance-ai.controller.test.ts | 158 +++++++-- .../filesystem/local-gateway-registry.ts | 7 + .../instance-ai-settings.service.ts | 70 +++- .../instance-ai/instance-ai.controller.ts | 83 +++-- .../modules/instance-ai/instance-ai.module.ts | 3 + .../instance-ai/instance-ai.service.ts | 9 +- .../features/ai/instanceAi/InstanceAiView.vue | 4 +- .../__tests__/LocalGatewaySection.test.ts | 123 +++++++ .../__tests__/SettingsInstanceAiView.test.ts | 242 ++++++++++++++ .../instanceAiSettings.store.test.ts | 303 ++++++++++++++++++ .../components/InstanceAiOptinModal.vue | 2 +- .../settings/LocalGatewaySection.vue | 71 +++- .../ai/instanceAi/instanceAiSettings.store.ts | 28 +- .../views/SettingsInstanceAiView.vue | 130 ++++---- 18 files changed, 1189 insertions(+), 151 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/LocalGatewaySection.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index edc548db650..f20c9b6c42e 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -282,6 +282,7 @@ export type FrontendModuleSettings = { localGatewayDisabled: boolean; proxyEnabled: boolean; optinModalDismissed: boolean; + cloudManaged: boolean; }; /** diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 4a34ad70767..138894b0116 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -278,8 +278,6 @@ export { workflowSetupNodeSchema, errorPayloadSchema, filesystemRequestPayloadSchema, - instanceAiFilesystemResponseSchema, - instanceAiGatewayCapabilitiesSchema, mcpToolSchema, mcpToolCallRequestSchema, mcpToolCallResultSchema, @@ -302,6 +300,8 @@ export { InstanceAiThreadMessagesQuery, InstanceAiAdminSettingsUpdateRequest, InstanceAiUserPreferencesUpdateRequest, + InstanceAiGatewayCapabilitiesDto, + InstanceAiFilesystemResponseDto, applyBranchReadOnlyOverrides, } from './schemas/instance-ai.schema'; diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index 1d3b3ae92a1..5a97a325925 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -431,13 +431,13 @@ export const toolCategorySchema = z.object({ }); export type ToolCategory = z.infer; -export const instanceAiGatewayCapabilitiesSchema = z.object({ +export class InstanceAiGatewayCapabilitiesDto extends Z.class({ rootPath: z.string(), tools: z.array(mcpToolSchema).default([]), hostIdentifier: z.string().optional(), toolCategories: z.array(toolCategorySchema).default([]), -}); -export type InstanceAiGatewayCapabilities = z.infer; +}) {} +export type InstanceAiGatewayCapabilities = InstanceType; // --------------------------------------------------------------------------- // Filesystem bridge payloads (browser ↔ server round-trip) @@ -448,10 +448,10 @@ export const filesystemRequestPayloadSchema = z.object({ toolCall: mcpToolCallRequestSchema, }); -export const instanceAiFilesystemResponseSchema = z.object({ +export class InstanceAiFilesystemResponseDto extends Z.class({ result: mcpToolCallResultSchema.optional(), error: z.string().optional(), -}); +}) {} export const tasksUpdatePayloadSchema = z.object({ tasks: taskListSchema, @@ -544,7 +544,7 @@ export type InstanceAiThreadTitleUpdatedEvent = Extract< { type: 'thread-title-updated' } >; -export type InstanceAiFilesystemResponse = z.infer; +export type InstanceAiFilesystemResponse = InstanceType; // --------------------------------------------------------------------------- // API types 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 daa9e46bbbb..2187dfa4a0c 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 @@ -10,7 +10,10 @@ import type { CredentialsService } from '@/credentials/credentials.service'; import { InstanceAiSettingsService } from '../instance-ai-settings.service'; describe('InstanceAiSettingsService', () => { - const globalConfig = mock<{ instanceAi: InstanceAiConfig }>({ + const globalConfig = mock<{ + instanceAi: InstanceAiConfig; + deployment: { type: string }; + }>({ instanceAi: { lastMessages: 10, model: 'openai/gpt-4', @@ -27,6 +30,7 @@ describe('InstanceAiSettingsService', () => { sandboxTimeout: 60, localGatewayDisabled: false, } as unknown as InstanceAiConfig, + deployment: { type: 'default' }, }); const settingsRepository = mock(); const aiService = mock(); @@ -115,4 +119,88 @@ describe('InstanceAiSettingsService', () => { ).resolves.toBeDefined(); }); }); + + describe('cloud-managed fields', () => { + beforeEach(() => { + globalConfig.deployment.type = 'cloud'; + }); + + afterEach(() => { + globalConfig.deployment.type = 'default'; + }); + + describe('updateAdminSettings', () => { + it('should reject memory fields on cloud', async () => { + await expect(service.updateAdminSettings({ lastMessages: 50 })).rejects.toThrow( + UnprocessableRequestError, + ); + }); + + it('should reject advanced fields on cloud', async () => { + await expect(service.updateAdminSettings({ subAgentMaxSteps: 50 })).rejects.toThrow( + UnprocessableRequestError, + ); + }); + + it('should reject sandbox fields on cloud', async () => { + await expect(service.updateAdminSettings({ sandboxEnabled: true })).rejects.toThrow( + UnprocessableRequestError, + ); + }); + + it('should include cloud-managed label in error message', async () => { + await expect(service.updateAdminSettings({ lastMessages: 50 })).rejects.toThrow( + /cloud-managed/, + ); + }); + + it('should allow enabled toggle on cloud', async () => { + settingsRepository.upsert.mockResolvedValue(undefined as never); + + await expect(service.updateAdminSettings({ enabled: true })).resolves.toBeDefined(); + }); + + it('should allow permissions on cloud', async () => { + settingsRepository.upsert.mockResolvedValue(undefined as never); + + await expect( + service.updateAdminSettings({ + permissions: { createWorkflow: 'always_allow' }, + }), + ).resolves.toBeDefined(); + }); + + it('should allow localGatewayDisabled on cloud', async () => { + settingsRepository.upsert.mockResolvedValue(undefined as never); + + await expect( + service.updateAdminSettings({ localGatewayDisabled: true }), + ).resolves.toBeDefined(); + }); + }); + + describe('updateUserPreferences', () => { + const user = mock({ id: 'user-1' }); + + it('should reject credentialId on cloud', async () => { + await expect( + service.updateUserPreferences(user, { credentialId: 'cred-1' }), + ).rejects.toThrow(UnprocessableRequestError); + }); + + it('should reject modelName on cloud', async () => { + await expect(service.updateUserPreferences(user, { modelName: 'gpt-4' })).rejects.toThrow( + UnprocessableRequestError, + ); + }); + + it('should allow localGatewayDisabled on cloud', async () => { + settingsRepository.upsert.mockResolvedValue(undefined as never); + + await expect( + service.updateUserPreferences(user, { localGatewayDisabled: true }), + ).resolves.toBeDefined(); + }); + }); + }); }); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts index a23408daf97..7b2e0f3408b 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts @@ -22,6 +22,7 @@ jest.mock('../eval/execution.service', () => ({ })); import type { + InstanceAiAdminSettingsUpdateRequest, InstanceAiSendMessageRequest, InstanceAiCorrectTaskRequest, InstanceAiConfirmRequestDto, @@ -44,14 +45,15 @@ import type { Scope } from '@n8n/permissions'; import type { Request, Response } from 'express'; import { mock } from 'jest-mock-extended'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { Push } from '@/push'; +import type { UrlService } from '@/services/url.service'; import type { EvalExecutionService } from '../eval/execution.service'; import type { InProcessEventBus } from '../event-bus/in-process-event-bus'; +import type { LocalGateway } from '../filesystem/local-gateway'; import type { InstanceAiMemoryService } from '../instance-ai-memory.service'; import type { InstanceAiSettingsService } from '../instance-ai-settings.service'; import { InstanceAiController } from '../instance-ai.controller'; @@ -78,6 +80,7 @@ describe('InstanceAiController', () => { const eventBus = mock(); const moduleRegistry = mock(); const push = mock(); + const urlService = mock(); const globalConfig = mock({ instanceAi: { gatewayApiKey: 'static-key' }, editorBaseUrl: 'http://localhost:5678', @@ -92,6 +95,7 @@ describe('InstanceAiController', () => { eventBus, moduleRegistry, push, + urlService, globalConfig, ); @@ -479,6 +483,51 @@ describe('InstanceAiController', () => { globalOnly: true, }); }); + + it('should disconnect all gateways when enabled is set to false', async () => { + settingsService.updateAdminSettings.mockResolvedValue({} as never); + instanceAiService.disconnectAllGateways.mockReturnValue(['user-a', 'user-b']); + const payload = { enabled: false } as InstanceAiAdminSettingsUpdateRequest; + + await controller.updateAdminSettings(req, res, payload); + + expect(instanceAiService.disconnectAllGateways).toHaveBeenCalled(); + expect(push.sendToUsers).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'instanceAiGatewayStateChanged', + data: { connected: false, directory: null, hostIdentifier: null, toolCategories: [] }, + }), + ['user-a', 'user-b'], + ); + }); + + it('should disconnect all gateways when localGatewayDisabled is set to true', async () => { + settingsService.updateAdminSettings.mockResolvedValue({} as never); + instanceAiService.disconnectAllGateways.mockReturnValue(['user-c']); + const payload = { localGatewayDisabled: true } as InstanceAiAdminSettingsUpdateRequest; + + await controller.updateAdminSettings(req, res, payload); + + expect(instanceAiService.disconnectAllGateways).toHaveBeenCalled(); + expect(push.sendToUsers).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'instanceAiGatewayStateChanged', + }), + ['user-c'], + ); + }); + + it('should not disconnect gateways when enabling features', async () => { + settingsService.updateAdminSettings.mockResolvedValue({} as never); + const payload = { + enabled: true, + localGatewayDisabled: false, + } as InstanceAiAdminSettingsUpdateRequest; + + await controller.updateAdminSettings(req, res, payload); + + expect(instanceAiService.disconnectAllGateways).not.toHaveBeenCalled(); + }); }); describe('getUserPreferences', () => { @@ -693,12 +742,13 @@ describe('InstanceAiController', () => { it('should return token and command', async () => { instanceAiService.generatePairingToken.mockReturnValue('pairing-token'); + urlService.getInstanceBaseUrl.mockReturnValue('https://myinstance.n8n.cloud'); const result = await controller.createGatewayLink(req); expect(result).toEqual({ token: 'pairing-token', - command: 'npx @n8n/computer-use http://localhost:5678 pairing-token', + command: 'npx @n8n/computer-use https://myinstance.n8n.cloud pairing-token', }); expect(instanceAiService.generatePairingToken).toHaveBeenCalledWith(USER_ID); }); @@ -712,12 +762,13 @@ describe('InstanceAiController', () => { expect(scopeOf('gatewayInit')).toBeUndefined(); }); - it('should initialize gateway with valid key and body', () => { + it('should initialize gateway with valid key and body', async () => { instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID); instanceAiService.consumePairingToken.mockReturnValue(null); const gatewayReq = makeGatewayReq('session-key', { rootPath: '/home/user' }); + const payload = { rootPath: '/home/user', tools: [], toolCategories: [] }; - const result = controller.gatewayInit(gatewayReq); + const result = await controller.gatewayInit(gatewayReq, res, payload); expect(result).toEqual({ ok: true }); expect(instanceAiService.initGateway).toHaveBeenCalledWith( @@ -738,51 +789,103 @@ describe('InstanceAiController', () => { ); }); - it('should return sessionKey when pairing token is consumed', () => { + it('should return sessionKey when pairing token is consumed', async () => { instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID); instanceAiService.consumePairingToken.mockReturnValue('new-session-key'); const gatewayReq = makeGatewayReq('pairing-token', { rootPath: '/tmp' }); - const result = controller.gatewayInit(gatewayReq); + const result = await controller.gatewayInit(gatewayReq, res, { + rootPath: '/tmp', + tools: [], + toolCategories: [], + }); expect(result).toEqual({ ok: true, sessionKey: 'new-session-key' }); }); - it('should accept static env var key', () => { + it('should accept static env var key', async () => { instanceAiService.consumePairingToken.mockReturnValue(null); const gatewayReq = makeGatewayReq('static-key', { rootPath: '/tmp' }); - const result = controller.gatewayInit(gatewayReq); + const result = await controller.gatewayInit(gatewayReq, res, { + rootPath: '/tmp', + tools: [], + toolCategories: [], + }); expect(result).toEqual({ ok: true }); expect(instanceAiService.initGateway).toHaveBeenCalledWith('env-gateway', expect.anything()); }); - it('should throw ForbiddenError with missing API key', () => { + it('should throw ForbiddenError with missing API key', async () => { const gatewayReq = makeGatewayReq(undefined, { rootPath: '/tmp' }); - expect(() => controller.gatewayInit(gatewayReq)).toThrow(ForbiddenError); + await expect( + controller.gatewayInit(gatewayReq, res, { + rootPath: '/tmp', + tools: [], + toolCategories: [], + }), + ).rejects.toThrow(ForbiddenError); }); - it('should throw ForbiddenError with invalid API key', () => { + it('should throw ForbiddenError with invalid API key', async () => { instanceAiService.getUserIdForApiKey.mockReturnValue(undefined); const gatewayReq = makeGatewayReq('wrong-key', { rootPath: '/tmp' }); - expect(() => controller.gatewayInit(gatewayReq)).toThrow(ForbiddenError); - }); - - it('should throw BadRequestError when body fails schema validation', () => { - instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID); - const gatewayReq = makeGatewayReq('session-key', { unexpected: 123 }); - - expect(() => controller.gatewayInit(gatewayReq)).toThrow(BadRequestError); + await expect( + controller.gatewayInit(gatewayReq, res, { + rootPath: '/tmp', + tools: [], + toolCategories: [], + }), + ).rejects.toThrow(ForbiddenError); }); }); describe('gatewayEvents', () => { + const makeGatewayReq = (key: string) => + ({ + headers: { 'x-gateway-key': key }, + once: jest.fn(), + }) as unknown as Request; + + const makeFlushableRes = () => { + const res = { + setHeader: jest.fn(), + flushHeaders: jest.fn(), + write: jest.fn(), + flush: jest.fn(), + once: jest.fn(), + }; + return res as unknown as Parameters[1]; + }; + it('should have no access scope (skipAuth)', () => { expect(scopeOf('gatewayEvents')).toBeUndefined(); }); + + it('should reject with ForbiddenError when the gateway has not been initialized', async () => { + instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID); + instanceAiService.getLocalGateway.mockReturnValue(mock({ isConnected: false })); + + await expect( + controller.gatewayEvents(makeGatewayReq('session-key'), makeFlushableRes()), + ).rejects.toThrow(ForbiddenError); + + expect(instanceAiService.clearDisconnectTimer).not.toHaveBeenCalled(); + }); + + it('should clear a pending disconnect timer when SSE reconnects while still connected', async () => { + instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID); + const gateway = mock({ isConnected: true }); + gateway.onRequest.mockReturnValue(() => {}); + instanceAiService.getLocalGateway.mockReturnValue(gateway); + + await controller.gatewayEvents(makeGatewayReq('session-key'), makeFlushableRes()); + + expect(instanceAiService.clearDisconnectTimer).toHaveBeenCalledWith(USER_ID); + }); }); describe('gatewayResponse', () => { @@ -798,7 +901,9 @@ describe('InstanceAiController', () => { instanceAiService.resolveGatewayRequest.mockReturnValue(true); const gatewayReq = makeGatewayReq('session-key', { result: { content: [] } }); - const result = controller.gatewayResponse(gatewayReq, res, 'req-1'); + const result = controller.gatewayResponse(gatewayReq, res, 'req-1', { + result: { content: [] }, + }); expect(result).toEqual({ ok: true }); expect(instanceAiService.resolveGatewayRequest).toHaveBeenCalledWith( @@ -814,14 +919,9 @@ describe('InstanceAiController', () => { instanceAiService.resolveGatewayRequest.mockReturnValue(false); const gatewayReq = makeGatewayReq('session-key', { result: { content: [] } }); - expect(() => controller.gatewayResponse(gatewayReq, res, 'req-1')).toThrow(NotFoundError); - }); - - it('should throw BadRequestError when body fails schema validation', () => { - instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID); - const gatewayReq = makeGatewayReq('session-key', { result: 'not-an-object' }); - - expect(() => controller.gatewayResponse(gatewayReq, res, 'req-1')).toThrow(BadRequestError); + expect(() => + controller.gatewayResponse(gatewayReq, res, 'req-1', { result: { content: [] } }), + ).toThrow(NotFoundError); }); }); @@ -870,7 +970,7 @@ describe('InstanceAiController', () => { body: { result: { content: [] } }, } as unknown as Request; - controller.gatewayResponse(gatewayReq, res, 'req-1'); + controller.gatewayResponse(gatewayReq, res, 'req-1', { result: { content: [] } }); // validateGatewayApiKey receives 'key1' (the first element) expect(instanceAiService.getUserIdForApiKey).toHaveBeenCalledWith('key1'); diff --git a/packages/cli/src/modules/instance-ai/filesystem/local-gateway-registry.ts b/packages/cli/src/modules/instance-ai/filesystem/local-gateway-registry.ts index 370899ec133..1b680decd7b 100644 --- a/packages/cli/src/modules/instance-ai/filesystem/local-gateway-registry.ts +++ b/packages/cli/src/modules/instance-ai/filesystem/local-gateway-registry.ts @@ -196,6 +196,13 @@ export class LocalGatewayRegistry { state.disconnectTimer = null; } + /** Return IDs of users with an active gateway connection. */ + getConnectedUserIds(): string[] { + return [...this.userGateways.entries()] + .filter(([, state]) => state.gateway.getStatus().connected) + .map(([userId]) => userId); + } + /** Disconnect all gateways and clear all state (called on service shutdown). */ disconnectAll(): void { for (const state of this.userGateways.values()) { 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 afec0020328..28c5a75b640 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 @@ -1,5 +1,5 @@ import { GlobalConfig } from '@n8n/config'; -import type { InstanceAiConfig } from '@n8n/config'; +import type { InstanceAiConfig, DeploymentConfig } from '@n8n/config'; import { SettingsRepository } from '@n8n/db'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -88,6 +88,8 @@ interface PersistedUserPreferences { export class InstanceAiSettingsService { private readonly config: InstanceAiConfig; + private readonly deploymentConfig: DeploymentConfig; + /** Whether n8n Agent is enabled for this instance. */ private enabled = true; @@ -114,6 +116,12 @@ export class InstanceAiSettingsService { private readonly credentialsFinderService: CredentialsFinderService, ) { this.config = globalConfig.instanceAi; + this.deploymentConfig = globalConfig.deployment; + } + + /** Whether this instance is running on the cloud platform. */ + private get isCloud(): boolean { + return this.deploymentConfig.type === 'cloud'; } /** Whether the AI service proxy is active (model, search, sandbox managed externally). */ @@ -160,8 +168,18 @@ export class InstanceAiSettingsService { async updateAdminSettings( update: InstanceAiAdminSettingsUpdateRequest, ): Promise { - if (this.aiService.isProxyEnabled()) { - this.rejectProxyManagedFields(update, InstanceAiSettingsService.PROXY_MANAGED_ADMIN_FIELDS); + if (this.isCloud) { + this.rejectManagedFields( + update, + InstanceAiSettingsService.CLOUD_MANAGED_ADMIN_FIELDS, + 'cloud', + ); + } else if (this.aiService.isProxyEnabled()) { + this.rejectManagedFields( + update, + InstanceAiSettingsService.PROXY_MANAGED_ADMIN_FIELDS, + 'proxy', + ); } const c = this.config; if (update.enabled !== undefined) this.enabled = update.enabled; @@ -216,8 +234,7 @@ export class InstanceAiSettingsService { credentialType, credentialName, modelName: prefs.modelName || this.extractModelName(this.config.model), - localGatewayDisabled: - this.config.localGatewayDisabled || (prefs.localGatewayDisabled ?? false), + localGatewayDisabled: prefs.localGatewayDisabled ?? false, }; } @@ -225,10 +242,17 @@ export class InstanceAiSettingsService { user: User, update: InstanceAiUserPreferencesUpdateRequest, ): Promise { - if (this.aiService.isProxyEnabled()) { - this.rejectProxyManagedFields( + if (this.isCloud) { + this.rejectManagedFields( + update, + InstanceAiSettingsService.CLOUD_MANAGED_PREFERENCE_FIELDS, + 'cloud', + ); + } else if (this.aiService.isProxyEnabled()) { + this.rejectManagedFields( update, InstanceAiSettingsService.PROXY_MANAGED_PREFERENCE_FIELDS, + 'proxy', ); } const prefs = await this.loadUserPreferences(user.id); @@ -367,9 +391,10 @@ export class InstanceAiSettingsService { } /** Whether the local gateway is disabled for a given user (admin override OR user preference). */ - isLocalGatewayDisabledForUser(userId: string): boolean { + async isLocalGatewayDisabledForUser(userId: string): Promise { + if (!this.enabled) return true; if (this.config.localGatewayDisabled) return true; - const prefs = this.userPreferences.get(userId); + const prefs = await this.loadUserPreferences(userId); return prefs?.localGatewayDisabled ?? false; } @@ -455,14 +480,33 @@ export class InstanceAiSettingsService { 'modelName', ]; - private rejectProxyManagedFields( - update: Record, + /** Admin fields managed by the cloud platform — superset of proxy-managed fields. */ + private static readonly CLOUD_MANAGED_ADMIN_FIELDS: readonly string[] = [ + ...InstanceAiSettingsService.PROXY_MANAGED_ADMIN_FIELDS, + 'n8nSandboxCredentialId', + 'lastMessages', + 'embedderModel', + 'semanticRecallTopK', + 'subAgentMaxSteps', + 'browserMcp', + 'mcpServers', + ]; + + /** User preference fields managed by the cloud platform. */ + private static readonly CLOUD_MANAGED_PREFERENCE_FIELDS: readonly string[] = [ + ...InstanceAiSettingsService.PROXY_MANAGED_PREFERENCE_FIELDS, + ]; + + private rejectManagedFields( + update: object, managedFields: readonly string[], + label: string, ): void { - const present = managedFields.filter((key) => key in update && update[key] !== undefined); + const record = update as Record; + const present = managedFields.filter((key) => key in record && record[key] !== undefined); if (present.length > 0) { throw new UnprocessableRequestError( - `Cannot update proxy-managed fields: ${present.join(', ')}`, + `Cannot update ${label}-managed fields: ${present.join(', ')}`, ); } } diff --git a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts index 073cac5c47f..37d5c153358 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts @@ -1,7 +1,7 @@ import { InstanceAiConfirmRequestDto, - instanceAiGatewayCapabilitiesSchema, - instanceAiFilesystemResponseSchema, + InstanceAiGatewayCapabilitiesDto, + InstanceAiFilesystemResponseDto, InstanceAiRenameThreadRequestDto, InstanceAiSendMessageRequest, InstanceAiEventsQuery, @@ -45,6 +45,7 @@ import { ConflictError } from '@/errors/response-errors/conflict.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { Push } from '@/push'; +import { UrlService } from '@/services/url.service'; type FlushableResponse = Response & { flush?: () => void }; @@ -54,8 +55,6 @@ const KEEP_ALIVE_INTERVAL_MS = 15_000; export class InstanceAiController { private readonly gatewayApiKey: string; - private readonly instanceBaseUrl: string; - private static getTreeRichnessScore(tree: InstanceAiAgentNode): number { let score = 0; const stack = [tree]; @@ -94,10 +93,10 @@ export class InstanceAiController { private readonly eventBus: InProcessEventBus, private readonly moduleRegistry: ModuleRegistry, private readonly push: Push, + private readonly urlService: UrlService, globalConfig: GlobalConfig, ) { this.gatewayApiKey = globalConfig.instanceAi.gatewayApiKey; - this.instanceBaseUrl = globalConfig.editorBaseUrl || `http://localhost:${globalConfig.port}`; } private requireInstanceAiEnabled(): void { @@ -413,6 +412,25 @@ export class InstanceAiController { ) { const result = await this.settingsService.updateAdminSettings(payload); await this.moduleRegistry.refreshModuleSettings('instance-ai'); + + if (payload.enabled === false || payload.localGatewayDisabled === true) { + const disconnectedUserIds = this.instanceAiService.disconnectAllGateways(); + if (disconnectedUserIds.length > 0) { + this.push.sendToUsers( + { + type: 'instanceAiGatewayStateChanged', + data: { + connected: false, + directory: null, + hostIdentifier: null, + toolCategories: [], + }, + }, + disconnectedUserIds, + ); + } + } + return result; } @@ -574,8 +592,9 @@ export class InstanceAiController { @Post('/gateway/create-link') @GlobalScope('instanceAi:gateway') async createGatewayLink(req: AuthenticatedRequest) { + await this.assertGatewayEnabled(req.user.id); const token = this.instanceAiService.generatePairingToken(req.user.id); - const baseUrl = this.instanceBaseUrl.replace(/\/$/, ''); + const baseUrl = this.urlService.getInstanceBaseUrl(); const command = `npx @n8n/computer-use ${baseUrl} ${token}`; return { token, command }; } @@ -583,6 +602,19 @@ export class InstanceAiController { @Get('/gateway/events', { usesTemplates: true, skipAuth: true }) async gatewayEvents(req: Request, res: FlushableResponse) { const userId = this.validateGatewayApiKey(this.getGatewayKeyHeader(req)); + await this.assertGatewayEnabled(userId); + + const gateway = this.instanceAiService.getLocalGateway(userId); + + // If the grace-period timer already fired (e.g. after a long reconnect gap), + // the gateway state is torn down. Reject so the daemon falls into its auth-error + // reconnect branch, which re-uploads capabilities and re-establishes state. + if (!gateway.isConnected) { + throw new ForbiddenError('Local gateway not initialized'); + } + + // Daemon reconnected within the grace window — cancel the pending disconnect. + this.instanceAiService.clearDisconnectTimer(userId); (res as unknown as { compress: boolean }).compress = false; res.setHeader('Content-Type', 'text/event-stream; charset=UTF-8'); @@ -591,7 +623,6 @@ export class InstanceAiController { res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders(); - const gateway = this.instanceAiService.getLocalGateway(userId); const unsubscribe = gateway.onRequest((event) => { res.write(`data: ${JSON.stringify(event)}\n\n`); res.flush?.(); @@ -625,24 +656,21 @@ export class InstanceAiController { } @Post('/gateway/init', { skipAuth: true }) - gatewayInit(req: Request) { + async gatewayInit(req: Request, _res: Response, @Body payload: InstanceAiGatewayCapabilitiesDto) { const key = this.getGatewayKeyHeader(req); const userId = this.validateGatewayApiKey(key); + await this.assertGatewayEnabled(userId); - const parsed = instanceAiGatewayCapabilitiesSchema.safeParse(req.body); - if (!parsed.success) { - throw new BadRequestError(parsed.error.message); - } - this.instanceAiService.initGateway(userId, parsed.data); + this.instanceAiService.initGateway(userId, payload); this.push.sendToUsers( { type: 'instanceAiGatewayStateChanged', data: { connected: true, - directory: parsed.data.rootPath, - hostIdentifier: parsed.data.hostIdentifier ?? null, - toolCategories: parsed.data.toolCategories ?? [], + directory: payload.rootPath, + hostIdentifier: payload.hostIdentifier ?? null, + toolCategories: payload.toolCategories ?? [], }, }, [userId], @@ -674,18 +702,19 @@ export class InstanceAiController { } @Post('/gateway/response/:requestId', { skipAuth: true }) - gatewayResponse(req: Request, _res: Response, @Param('requestId') requestId: string) { + gatewayResponse( + req: Request, + _res: Response, + @Param('requestId') requestId: string, + @Body payload: InstanceAiFilesystemResponseDto, + ) { const userId = this.validateGatewayApiKey(this.getGatewayKeyHeader(req)); - const parsed = instanceAiFilesystemResponseSchema.safeParse(req.body); - if (!parsed.success) { - throw new BadRequestError(parsed.error.message); - } const resolved = this.instanceAiService.resolveGatewayRequest( userId, requestId, - parsed.data.result, - parsed.data.error, + payload.result, + payload.error, ); if (!resolved) { throw new NotFoundError('Gateway request not found or already resolved'); @@ -696,6 +725,7 @@ export class InstanceAiController { @Get('/gateway/status') @GlobalScope('instanceAi:gateway') async gatewayStatus(req: AuthenticatedRequest) { + await this.assertGatewayEnabled(req.user.id); return this.instanceAiService.getGatewayStatus(req.user.id); } @@ -719,6 +749,13 @@ export class InstanceAiController { } } + /** Throw if the local gateway is disabled globally or for this user. */ + private async assertGatewayEnabled(userId: string): Promise { + if (await this.settingsService.isLocalGatewayDisabledForUser(userId)) { + throw new ForbiddenError('Local gateway is disabled'); + } + } + /** * Safely extract and validate the x-gateway-key header value. * Headers can be string | string[] | undefined — take only the first value 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 3163d3bb409..52c28b0218a 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.module.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.module.ts @@ -38,8 +38,10 @@ export class InstanceAiModule implements ModuleInterface { } async settings() { + const { GlobalConfig } = await import('@n8n/config'); const { InstanceAiService } = await import('./instance-ai.service'); const { InstanceAiSettingsService } = await import('./instance-ai-settings.service'); + const globalConfig = Container.get(GlobalConfig); const service = Container.get(InstanceAiService); const settingsService = Container.get(InstanceAiSettingsService); const enabled = service.isEnabled(); @@ -50,6 +52,7 @@ export class InstanceAiModule implements ModuleInterface { localGatewayDisabled, proxyEnabled: service.isProxyEnabled(), optinModalDismissed, + cloudManaged: globalConfig.deployment.type === 'cloud', }; } diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index 28f3a92116f..7714a639509 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -958,6 +958,13 @@ export class InstanceAiService { this.gatewayRegistry.disconnectGateway(userId); } + /** Disconnect all connected gateways and return the user IDs that were connected. */ + disconnectAllGateways(): string[] { + const connectedUserIds = this.gatewayRegistry.getConnectedUserIds(); + this.gatewayRegistry.disconnectAll(); + return connectedUserIds; + } + isLocalGatewayDisabled(): boolean { return this.settingsService.isLocalGatewayDisabled(); } @@ -1180,7 +1187,7 @@ export class InstanceAiService { messageGroupId?: string, pushRef?: string, ) { - const localGatewayDisabled = this.settingsService.isLocalGatewayDisabled(); + const localGatewayDisabled = await this.settingsService.isLocalGatewayDisabledForUser(user.id); const userGateway = this.gatewayRegistry.findGateway(user.id); // When the proxy is enabled, create a single ProxyTokenManager and diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue index c5fed228e18..722275670db 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiView.vue @@ -140,7 +140,7 @@ onMounted(() => { .refreshModuleSettings() .catch(() => {}) .then(() => { - if (!settingsStore.isLocalGatewayDisabled) { + if (!settingsStore.isLocalGatewayDisabled && !settingsStore.isInstanceAiDisabled) { settingsStore.startDaemonProbing(); settingsStore.startGatewayPushListener(); settingsStore.pollGatewayStatus(); @@ -150,7 +150,7 @@ onMounted(() => { // React to local gateway being toggled in settings without requiring a page reload watch( - () => settingsStore.isLocalGatewayDisabled, + () => settingsStore.isLocalGatewayDisabled || settingsStore.isInstanceAiDisabled, (disabled) => { if (disabled) { settingsStore.stopDaemonProbing(); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/LocalGatewaySection.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/LocalGatewaySection.test.ts new file mode 100644 index 00000000000..cc767d58edc --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/LocalGatewaySection.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { within } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { createComponentRenderer } from '@/__tests__/render'; +import LocalGatewaySection from '../components/settings/LocalGatewaySection.vue'; +import { useInstanceAiSettingsStore } from '../instanceAiSettings.store'; +import { useSettingsStore } from '@/app/stores/settings.store'; + +vi.mock('@n8n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ + baseText: (key: string) => key, + }), +})); + +vi.mock('../instanceAi.api', () => ({ + createGatewayLink: vi.fn(), + getGatewayStatus: vi.fn().mockResolvedValue({ + connected: false, + directory: null, + hostIdentifier: null, + toolCategories: [], + }), +})); + +const renderComponent = createComponentRenderer(LocalGatewaySection); + +describe('LocalGatewaySection', () => { + let store: ReturnType; + let settingsStore: ReturnType; + + beforeEach(() => { + const pinia = createTestingPinia({ stubActions: false }); + setActivePinia(pinia); + store = useInstanceAiSettingsStore(); + settingsStore = useSettingsStore(); + settingsStore.moduleSettings = { + 'instance-ai': { + enabled: true, + localGatewayDisabled: false, + proxyEnabled: false, + optinModalDismissed: true, + cloudManaged: false, + }, + }; + store.$patch({ preferences: { localGatewayDisabled: false } }); + }); + + it('shows heading and description', () => { + const { getByText } = renderComponent(); + expect(getByText('instanceAi.filesystem.label')).toBeVisible(); + expect(getByText('instanceAi.filesystem.description')).toBeVisible(); + }); + + it('shows user toggle switch', () => { + const { container } = renderComponent(); + const switchEl = container.querySelector('.el-switch'); + expect(switchEl).toBeTruthy(); + }); + + it('disables user toggle when admin has disabled gateway', () => { + settingsStore.moduleSettings = { + 'instance-ai': { + enabled: true, + localGatewayDisabled: true, + proxyEnabled: false, + optinModalDismissed: true, + cloudManaged: false, + }, + }; + const { container } = renderComponent(); + const switchEl = container.querySelector('.el-switch'); + expect(switchEl?.classList.contains('is-disabled')).toBe(true); + }); + + it('shows warning when admin has disabled gateway', () => { + settingsStore.moduleSettings = { + 'instance-ai': { + enabled: true, + localGatewayDisabled: true, + proxyEnabled: false, + optinModalDismissed: true, + cloudManaged: false, + }, + }; + const { getByText } = renderComponent(); + expect(getByText('settings.n8nAgent.computerUse.disabled.warning')).toBeVisible(); + }); + + it('hides warning when admin has not disabled gateway', () => { + const { queryByText } = renderComponent(); + expect(queryByText('settings.n8nAgent.computerUse.disabled.warning')).toBeNull(); + }); + + it('calls save immediately when user toggle is clicked', async () => { + const saveSpy = vi.spyOn(store, 'save').mockResolvedValue(); + const { container } = renderComponent(); + + const switchRow = container.querySelector('[class*="switchRow"]')!; + const switchEl = within(switchRow as HTMLElement).getByRole('switch'); + await userEvent.click(switchEl); + + expect(store.preferencesDraft.localGatewayDisabled).toBe(true); + expect(saveSpy).toHaveBeenCalled(); + }); + + it('shows setup command when gateway is not connected and user toggle is on', () => { + store.$patch({ preferences: { localGatewayDisabled: false } }); + const { getByText } = renderComponent(); + expect(getByText('instanceAi.filesystem.setupCommand')).toBeVisible(); + }); + + it('hides setup content when user toggle is off', () => { + store.$patch({ + preferences: { localGatewayDisabled: true }, + preferencesDraft: { localGatewayDisabled: true }, + }); + const { queryByText } = renderComponent(); + expect(queryByText('instanceAi.filesystem.setupCommand')).toBeNull(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts new file mode 100644 index 00000000000..e0e4bab1f12 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { createComponentRenderer } from '@/__tests__/render'; +import SettingsInstanceAiView from '../views/SettingsInstanceAiView.vue'; +import { useInstanceAiSettingsStore } from '../instanceAiSettings.store'; +import { useSettingsStore } from '@/app/stores/settings.store'; +import type { FrontendModuleSettings } from '@n8n/api-types'; + +vi.mock('@n8n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ + baseText: (key: string) => key, + }), +})); + +vi.mock('@/app/composables/useDocumentTitle', () => ({ + useDocumentTitle: () => ({ set: vi.fn() }), +})); + +vi.mock('@/app/stores/pushConnection.store', () => ({ + usePushConnectionStore: vi.fn().mockReturnValue({ + addEventListener: vi.fn(), + }), +})); + +vi.mock('../instanceAi.settings.api', () => ({ + fetchSettings: vi.fn().mockResolvedValue(null), + updateSettings: vi.fn(), + fetchPreferences: vi.fn().mockResolvedValue({ + credentialId: null, + credentialType: null, + credentialName: null, + modelName: 'gpt-4', + localGatewayDisabled: false, + }), + updatePreferences: vi.fn(), + fetchModelCredentials: vi.fn().mockResolvedValue([]), + fetchServiceCredentials: vi.fn().mockResolvedValue([]), +})); + +vi.mock('../instanceAi.api', () => ({ + createGatewayLink: vi.fn(), + getGatewayStatus: vi.fn(), +})); + +vi.mock('@/app/utils/rbac/permissions', () => ({ + hasPermission: vi.fn().mockReturnValue(true), +})); + +function makeStub(name: string) { + return { template: `
` }; +} + +const renderComponent = createComponentRenderer(SettingsInstanceAiView, { + global: { + stubs: { + ModelSection: makeStub('ModelSection'), + LocalGatewaySection: makeStub('LocalGatewaySection'), + SandboxSection: makeStub('SandboxSection'), + MemorySection: makeStub('MemorySection'), + SearchSection: makeStub('SearchSection'), + AdvancedSection: makeStub('AdvancedSection'), + }, + }, +}); + +function setModuleSettings( + settingsStore: ReturnType, + instanceAi: FrontendModuleSettings['instance-ai'], +) { + settingsStore.moduleSettings = { 'instance-ai': instanceAi }; +} + +const defaultModuleSettings: NonNullable = { + enabled: true, + localGatewayDisabled: false, + proxyEnabled: false, + optinModalDismissed: true, + cloudManaged: false, +}; + +describe('SettingsInstanceAiView', () => { + let store: ReturnType; + let settingsStore: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + const pinia = createTestingPinia({ stubActions: false }); + setActivePinia(pinia); + store = useInstanceAiSettingsStore(); + settingsStore = useSettingsStore(); + setModuleSettings(settingsStore, { ...defaultModuleSettings }); + store.$patch({ + settings: { + enabled: true, + lastMessages: 20, + embedderModel: '', + semanticRecallTopK: 5, + subAgentMaxSteps: 10, + browserMcp: false, + permissions: {}, + mcpServers: '', + sandboxEnabled: false, + sandboxProvider: '', + sandboxImage: '', + sandboxTimeout: 60, + daytonaCredentialId: null, + n8nSandboxCredentialId: null, + searchCredentialId: null, + localGatewayDisabled: false, + optinModalDismissed: true, + }, + }); + }); + + function queryStub(container: Element, name: string) { + return container.querySelector(`[data-test-stub="${name}"]`); + } + + describe('section visibility — self-hosted (no proxy, no cloud)', () => { + it('shows Model section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'ModelSection')).not.toBeNull(); + }); + + it('shows Sandbox section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'SandboxSection')).not.toBeNull(); + }); + + it('shows Memory section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'MemorySection')).not.toBeNull(); + }); + + it('shows Search section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'SearchSection')).not.toBeNull(); + }); + + it('shows Advanced section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'AdvancedSection')).not.toBeNull(); + }); + }); + + describe('section visibility — proxy enabled (non-cloud)', () => { + beforeEach(() => { + setModuleSettings(settingsStore, { ...defaultModuleSettings, proxyEnabled: true }); + }); + + it('hides Model section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'ModelSection')).toBeNull(); + }); + + it('hides Sandbox section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'SandboxSection')).toBeNull(); + }); + + it('shows Memory section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'MemorySection')).not.toBeNull(); + }); + + it('hides Search section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'SearchSection')).toBeNull(); + }); + + it('shows Advanced section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'AdvancedSection')).not.toBeNull(); + }); + }); + + describe('section visibility — cloud managed', () => { + beforeEach(() => { + setModuleSettings(settingsStore, { + ...defaultModuleSettings, + proxyEnabled: true, + cloudManaged: true, + }); + }); + + it('hides Model section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'ModelSection')).toBeNull(); + }); + + it('hides Sandbox section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'SandboxSection')).toBeNull(); + }); + + it('hides Memory section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'MemorySection')).toBeNull(); + }); + + it('hides Search section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'SearchSection')).toBeNull(); + }); + + it('hides Advanced section', () => { + const { container } = renderComponent(); + expect(queryStub(container, 'AdvancedSection')).toBeNull(); + }); + + it('shows Permissions section', () => { + const { getByText } = renderComponent(); + expect(getByText('settings.n8nAgent.permissions.title')).toBeVisible(); + }); + + it('shows Enable toggle', () => { + const { getByTestId } = renderComponent(); + expect(getByTestId('n8n-agent-enable-toggle')).toBeVisible(); + }); + }); + + describe('isEnabled fallback for non-admin users', () => { + it('falls back to moduleSettings when store.settings is null', () => { + store.$patch({ settings: null }); + setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: true }); + + const { getByText } = renderComponent(); + // When enabled, the local gateway section should render + expect(getByText('settings.n8nAgent.permissions.title')).toBeVisible(); + }); + + it('hides content when disabled via moduleSettings fallback', () => { + store.$patch({ settings: null }); + setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: false }); + + const { queryByText } = renderComponent(); + expect(queryByText('settings.n8nAgent.permissions.title')).toBeNull(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts new file mode 100644 index 00000000000..e91a63c1e4e --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts @@ -0,0 +1,303 @@ +import { setActivePinia, createPinia } from 'pinia'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useInstanceAiSettingsStore } from '../instanceAiSettings.store'; +import { useSettingsStore } from '@/app/stores/settings.store'; +import type { FrontendModuleSettings } from '@n8n/api-types'; + +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: vi.fn().mockReturnValue({ + restApiContext: { baseUrl: 'http://localhost:5678/rest' }, + }), +})); + +vi.mock('@/app/composables/useToast', () => ({ + useToast: vi.fn().mockReturnValue({ + showMessage: vi.fn(), + showError: vi.fn(), + }), +})); + +vi.mock('@/app/stores/pushConnection.store', () => ({ + usePushConnectionStore: vi.fn().mockReturnValue({ + addEventListener: vi.fn(), + }), +})); + +const mockFetchSettings = vi.fn(); +const mockUpdateSettings = vi.fn(); +const mockFetchPreferences = vi.fn(); +const mockUpdatePreferences = vi.fn(); +const mockFetchModelCredentials = vi.fn().mockResolvedValue([]); +const mockFetchServiceCredentials = vi.fn().mockResolvedValue([]); + +vi.mock('../instanceAi.settings.api', () => ({ + fetchSettings: (...args: unknown[]) => mockFetchSettings(...args), + updateSettings: (...args: unknown[]) => mockUpdateSettings(...args), + fetchPreferences: (...args: unknown[]) => mockFetchPreferences(...args), + updatePreferences: (...args: unknown[]) => mockUpdatePreferences(...args), + fetchModelCredentials: (...args: unknown[]) => mockFetchModelCredentials(...args), + fetchServiceCredentials: (...args: unknown[]) => mockFetchServiceCredentials(...args), +})); + +vi.mock('../instanceAi.api', () => ({ + createGatewayLink: vi.fn(), + getGatewayStatus: vi.fn(), +})); + +vi.mock('@/app/utils/rbac/permissions', () => ({ + hasPermission: vi.fn().mockReturnValue(false), +})); + +function setModuleSettings( + settingsStore: ReturnType, + instanceAi: FrontendModuleSettings['instance-ai'], +) { + settingsStore.moduleSettings = { 'instance-ai': instanceAi }; +} + +describe('useInstanceAiSettingsStore', () => { + let store: ReturnType; + let settingsStore: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + setActivePinia(createPinia()); + store = useInstanceAiSettingsStore(); + settingsStore = useSettingsStore(); + }); + + describe('isInstanceAiDisabled', () => { + it('returns true when module settings has enabled=false', () => { + setModuleSettings(settingsStore, { + enabled: false, + localGatewayDisabled: false, + proxyEnabled: false, + optinModalDismissed: false, + cloudManaged: false, + }); + expect(store.isInstanceAiDisabled).toBe(true); + }); + + it('returns false when module settings has enabled=true', () => { + setModuleSettings(settingsStore, { + enabled: true, + localGatewayDisabled: false, + proxyEnabled: false, + optinModalDismissed: false, + cloudManaged: false, + }); + expect(store.isInstanceAiDisabled).toBe(false); + }); + + it('returns true when module settings is undefined', () => { + settingsStore.moduleSettings = {}; + expect(store.isInstanceAiDisabled).toBe(true); + }); + }); + + describe('isLocalGatewayDisabledByAdmin', () => { + it('defaults to true when module settings have not loaded yet', () => { + settingsStore.moduleSettings = {}; + expect(store.isLocalGatewayDisabledByAdmin).toBe(true); + }); + + it('returns true when admin flag is set', () => { + setModuleSettings(settingsStore, { + enabled: true, + localGatewayDisabled: true, + proxyEnabled: false, + optinModalDismissed: false, + cloudManaged: false, + }); + expect(store.isLocalGatewayDisabledByAdmin).toBe(true); + }); + + it('returns false when admin flag is not set even if user preference is', () => { + setModuleSettings(settingsStore, { + enabled: true, + localGatewayDisabled: false, + proxyEnabled: false, + optinModalDismissed: false, + cloudManaged: false, + }); + store.$patch({ preferences: { localGatewayDisabled: true } }); + expect(store.isLocalGatewayDisabledByAdmin).toBe(false); + }); + }); + + describe('isLocalGatewayDisabled', () => { + it('returns true when admin flag is set', () => { + setModuleSettings(settingsStore, { + enabled: true, + localGatewayDisabled: true, + proxyEnabled: false, + optinModalDismissed: false, + cloudManaged: false, + }); + expect(store.isLocalGatewayDisabled).toBe(true); + }); + + it('returns true when user preference is set', () => { + setModuleSettings(settingsStore, { + enabled: true, + localGatewayDisabled: false, + proxyEnabled: false, + optinModalDismissed: false, + cloudManaged: false, + }); + store.$patch({ preferences: { localGatewayDisabled: true } }); + expect(store.isLocalGatewayDisabled).toBe(true); + }); + + it('returns true when both admin flag and user preference are set', () => { + setModuleSettings(settingsStore, { + enabled: true, + localGatewayDisabled: true, + proxyEnabled: false, + optinModalDismissed: false, + cloudManaged: false, + }); + store.$patch({ preferences: { localGatewayDisabled: true } }); + expect(store.isLocalGatewayDisabled).toBe(true); + }); + + it('returns false when neither admin flag nor user preference is set', () => { + setModuleSettings(settingsStore, { + enabled: true, + localGatewayDisabled: false, + proxyEnabled: false, + optinModalDismissed: false, + cloudManaged: false, + }); + store.$patch({ preferences: { localGatewayDisabled: false } }); + expect(store.isLocalGatewayDisabled).toBe(false); + }); + }); + + describe('isProxyEnabled', () => { + it('returns true when proxyEnabled is true in module settings', () => { + setModuleSettings(settingsStore, { + enabled: true, + localGatewayDisabled: false, + proxyEnabled: true, + optinModalDismissed: false, + cloudManaged: false, + }); + expect(store.isProxyEnabled).toBe(true); + }); + + it('returns false when proxyEnabled is false', () => { + setModuleSettings(settingsStore, { + enabled: true, + localGatewayDisabled: false, + proxyEnabled: false, + optinModalDismissed: false, + cloudManaged: false, + }); + expect(store.isProxyEnabled).toBe(false); + }); + }); + + describe('isCloudManaged', () => { + it('returns true when cloudManaged is true in module settings', () => { + setModuleSettings(settingsStore, { + enabled: true, + localGatewayDisabled: false, + proxyEnabled: false, + optinModalDismissed: false, + cloudManaged: true, + }); + expect(store.isCloudManaged).toBe(true); + }); + + it('returns false when cloudManaged is false', () => { + setModuleSettings(settingsStore, { + enabled: true, + localGatewayDisabled: false, + proxyEnabled: false, + optinModalDismissed: false, + cloudManaged: false, + }); + expect(store.isCloudManaged).toBe(false); + }); + }); + + describe('refreshModuleSettings', () => { + it('fetches preferences when they are not loaded yet', async () => { + const prefsResponse = { + credentialId: null, + credentialType: null, + credentialName: null, + modelName: 'gpt-4', + localGatewayDisabled: false, + }; + mockFetchPreferences.mockResolvedValue(prefsResponse); + settingsStore.getModuleSettings = vi.fn().mockResolvedValue(undefined); + + await store.refreshModuleSettings(); + + expect(mockFetchPreferences).toHaveBeenCalled(); + expect(store.preferences).toEqual(prefsResponse); + }); + + it('does not fetch preferences when they are already loaded', async () => { + store.$patch({ + preferences: { + credentialId: null, + credentialType: null, + credentialName: null, + modelName: 'gpt-4', + localGatewayDisabled: false, + }, + }); + settingsStore.getModuleSettings = vi.fn().mockResolvedValue(undefined); + + await store.refreshModuleSettings(); + + expect(mockFetchPreferences).not.toHaveBeenCalled(); + }); + }); + + describe('syncInstanceAiFlagIntoGlobalModuleSettings', () => { + it('preserves cloudManaged when syncing admin settings', async () => { + setModuleSettings(settingsStore, { + enabled: false, + localGatewayDisabled: false, + proxyEnabled: true, + optinModalDismissed: false, + cloudManaged: true, + }); + + const adminResponse = { + enabled: true, + lastMessages: 20, + embedderModel: '', + semanticRecallTopK: 5, + subAgentMaxSteps: 10, + browserMcp: false, + permissions: {}, + mcpServers: '', + sandboxEnabled: false, + sandboxProvider: '', + sandboxImage: '', + sandboxTimeout: 60, + daytonaCredentialId: null, + n8nSandboxCredentialId: null, + searchCredentialId: null, + localGatewayDisabled: false, + optinModalDismissed: true, + }; + + mockUpdateSettings.mockResolvedValue(adminResponse); + settingsStore.getModuleSettings = vi.fn().mockResolvedValue(undefined); + + // persistEnabled triggers syncInstanceAiFlagIntoGlobalModuleSettings + await store.persistEnabled(true); + + const ms = settingsStore.moduleSettings['instance-ai']; + expect(ms?.cloudManaged).toBe(true); + expect(ms?.proxyEnabled).toBe(true); + expect(ms?.enabled).toBe(true); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiOptinModal.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiOptinModal.vue index fc9c1b74eef..7348b10dd07 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiOptinModal.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiOptinModal.vue @@ -257,7 +257,7 @@ onUnmounted(() => { -import { computed, ref, onMounted } from 'vue'; +import { computed, onBeforeUnmount, ref, watch } from 'vue'; import { N8nHeading, N8nIcon, N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system'; import type { IconName } from '@n8n/design-system'; import { ElSwitch } from 'element-plus'; @@ -16,6 +16,15 @@ const isLocalGatewayDisabled = computed(() => { return store.preferences?.localGatewayDisabled ?? false; }); +async function handleUserToggle(value: boolean | string | number) { + const enabling = Boolean(value); + store.setPreferenceField('localGatewayDisabled', !enabling); + await store.save(); + if (enabling && !store.setupCommand) { + void store.fetchSetupCommand(); + } +} + const copied = ref(false); const displayCommand = computed(() => store.setupCommand ?? 'npx @n8n/computer-use'); @@ -83,10 +92,47 @@ const displayCategories = computed(() => { return result.sort((a, b) => Number(b.enabled) - Number(a.enabled)); }); -onMounted(() => { - if (!store.isGatewayConnected) { - void store.fetchSetupCommand(); - } +const shouldShowSetup = computed( + () => + !store.isGatewayConnected && + !store.isLocalGatewayDisabledByAdmin && + !isLocalGatewayDisabled.value, +); + +watch( + shouldShowSetup, + (show) => { + if (show && !store.setupCommand) { + void store.fetchSetupCommand(); + } + }, + { immediate: true }, +); + +// Keep the connection status live on the settings page. Without these, landing +// directly on /settings/n8n-agent leaves `isGatewayConnected` at its default +// `false` because neither the poll nor the push listener is ever started here. +const shouldMonitorConnection = computed( + () => !store.isLocalGatewayDisabledByAdmin && !isLocalGatewayDisabled.value, +); + +watch( + shouldMonitorConnection, + (monitor) => { + if (monitor) { + store.startGatewayPushListener(); + store.pollGatewayStatus(); + } else { + store.stopGatewayPushListener(); + store.stopGatewayPolling(); + } + }, + { immediate: true }, +); + +onBeforeUnmount(() => { + store.stopGatewayPushListener(); + store.stopGatewayPolling(); }); @@ -102,20 +148,20 @@ onMounted(() => {
-
+
{{ i18n.baseText('settings.n8nAgent.computerUse.disabled.warning') }}
-