fix(core): Rework Instance ai settings (no-changelog) (#28495)

This commit is contained in:
Jaakko Husso 2026-04-17 13:36:49 +02:00 committed by GitHub
parent cff2852332
commit 5c9a732af4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1189 additions and 151 deletions

View file

@ -282,6 +282,7 @@ export type FrontendModuleSettings = {
localGatewayDisabled: boolean;
proxyEnabled: boolean;
optinModalDismissed: boolean;
cloudManaged: boolean;
};
/**

View file

@ -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';

View file

@ -431,13 +431,13 @@ export const toolCategorySchema = z.object({
});
export type ToolCategory = z.infer<typeof toolCategorySchema>;
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<typeof instanceAiGatewayCapabilitiesSchema>;
}) {}
export type InstanceAiGatewayCapabilities = InstanceType<typeof InstanceAiGatewayCapabilitiesDto>;
// ---------------------------------------------------------------------------
// 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<typeof instanceAiFilesystemResponseSchema>;
export type InstanceAiFilesystemResponse = InstanceType<typeof InstanceAiFilesystemResponseDto>;
// ---------------------------------------------------------------------------
// API types

View file

@ -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<SettingsRepository>();
const aiService = mock<AiService>();
@ -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<User>({ 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();
});
});
});
});

View file

@ -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<InProcessEventBus>();
const moduleRegistry = mock<ModuleRegistry>();
const push = mock<Push>();
const urlService = mock<UrlService>();
const globalConfig = mock<GlobalConfig>({
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<typeof controller.gatewayEvents>[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<LocalGateway>({ 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<LocalGateway>({ 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');

View file

@ -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()) {

View file

@ -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<InstanceAiAdminSettingsResponse> {
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<InstanceAiUserPreferencesResponse> {
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<boolean> {
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<string, unknown>,
/** 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<string, unknown>;
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(', ')}`,
);
}
}

View file

@ -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<void> {
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

View file

@ -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',
};
}

View file

@ -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

View file

@ -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();

View file

@ -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<typeof useInstanceAiSettingsStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
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();
});
});

View file

@ -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: `<div data-test-stub="${name}" />` };
}
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<typeof useSettingsStore>,
instanceAi: FrontendModuleSettings['instance-ai'],
) {
settingsStore.moduleSettings = { 'instance-ai': instanceAi };
}
const defaultModuleSettings: NonNullable<FrontendModuleSettings['instance-ai']> = {
enabled: true,
localGatewayDisabled: false,
proxyEnabled: false,
optinModalDismissed: true,
cloudManaged: false,
};
describe('SettingsInstanceAiView', () => {
let store: ReturnType<typeof useInstanceAiSettingsStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
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();
});
});
});

View file

@ -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<typeof useSettingsStore>,
instanceAi: FrontendModuleSettings['instance-ai'],
) {
settingsStore.moduleSettings = { 'instance-ai': instanceAi };
}
describe('useInstanceAiSettingsStore', () => {
let store: ReturnType<typeof useInstanceAiSettingsStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
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);
});
});
});

View file

@ -257,7 +257,7 @@ onUnmounted(() => {
<N8nButton
variant="solid"
size="large"
:disabled="!hasChosen"
:disabled="!hasChosen || isSaving"
:label="
isEnabled
? i18n.baseText('instanceAi.welcomeModal.enable')

View file

@ -1,5 +1,5 @@
<script lang="ts" setup>
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();
});
</script>
@ -102,20 +148,20 @@ onMounted(() => {
</N8nText>
</div>
<ElSwitch
:model-value="!isLocalGatewayDisabled"
:disabled="store.isLocalGatewayDisabled"
@update:model-value="store.setPreferenceField('localGatewayDisabled', !$event)"
:model-value="!store.isLocalGatewayDisabledByAdmin && !isLocalGatewayDisabled"
:disabled="store.isLocalGatewayDisabledByAdmin"
@update:model-value="handleUserToggle"
/>
</div>
<div v-if="store.isLocalGatewayDisabled" :class="$style.warningRow">
<div v-if="store.isLocalGatewayDisabledByAdmin" :class="$style.warningRow">
<N8nIcon icon="triangle-alert" size="small" />
<N8nText size="small" color="text-light">
{{ i18n.baseText('settings.n8nAgent.computerUse.disabled.warning') }}
</N8nText>
</div>
<template v-if="!isLocalGatewayDisabled && !store.isLocalGatewayDisabled">
<template v-if="!isLocalGatewayDisabled && !store.isLocalGatewayDisabledByAdmin">
<!-- Gateway connected -->
<div v-if="store.isGatewayConnected" :class="$style.connectedBlock">
<div :class="$style.statusRow">
@ -197,11 +243,6 @@ onMounted(() => {
padding: var(--spacing--4xs) 0;
}
.switchLabel {
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
}
.warningRow {
display: flex;
align-items: center;

View file

@ -51,12 +51,21 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const gatewayToolCategories = ref<ToolCategory[]>([]);
const isGatewayConnected = computed(() => gatewayConnected.value);
const activeDirectory = computed(() => gatewayDirectory.value);
const isInstanceAiDisabled = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.enabled !== true,
);
const isLocalGatewayDisabledByAdmin = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.localGatewayDisabled !== false,
);
const isLocalGatewayDisabled = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.localGatewayDisabled === true,
() => isLocalGatewayDisabledByAdmin.value || preferences.value?.localGatewayDisabled === true,
);
const isProxyEnabled = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.proxyEnabled === true,
);
const isCloudManaged = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.cloudManaged === true,
);
const isDirty = computed(() => {
if (!settings.value && !preferences.value) return false;
@ -70,9 +79,10 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const prev = ms['instance-ai'];
const merged: NonNullable<FrontendModuleSettings['instance-ai']> = {
enabled: adminRes.enabled,
localGatewayDisabled: prev?.localGatewayDisabled ?? false,
localGatewayDisabled: adminRes.localGatewayDisabled ?? prev?.localGatewayDisabled ?? false,
proxyEnabled: prev?.proxyEnabled ?? false,
optinModalDismissed: adminRes.optinModalDismissed,
cloudManaged: prev?.cloudManaged ?? false,
};
settingsStore.moduleSettings = {
...ms,
@ -354,6 +364,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
}
async function fetchSetupCommand(): Promise<void> {
if (isLocalGatewayDisabled.value) return;
try {
const result = await createGatewayLink(rootStore.restApiContext);
setupCommand.value = result.command;
@ -377,7 +388,15 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
}
async function refreshModuleSettings(): Promise<void> {
await settingsStore.getModuleSettings();
const promises: Array<Promise<unknown>> = [settingsStore.getModuleSettings()];
if (!preferences.value) {
promises.push(
fetchPreferences(rootStore.restApiContext).then((p) => {
preferences.value = p;
}),
);
}
await Promise.all(promises);
}
return {
@ -409,8 +428,11 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
gatewayHostIdentifier,
gatewayToolCategories,
activeDirectory,
isInstanceAiDisabled,
isLocalGatewayDisabled,
isLocalGatewayDisabledByAdmin,
isProxyEnabled,
isCloudManaged,
pollGatewayStatus,
stopGatewayPolling,
startDaemonProbing,

View file

@ -6,6 +6,7 @@ import { useI18n } from '@n8n/i18n';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import type { InstanceAiPermissions, InstanceAiPermissionMode } from '@n8n/api-types';
import type { BaseTextKey } from '@n8n/i18n';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import ModelSection from '../components/settings/ModelSection.vue';
import LocalGatewaySection from '../components/settings/LocalGatewaySection.vue';
@ -16,6 +17,7 @@ import AdvancedSection from '../components/settings/AdvancedSection.vue';
const i18n = useI18n();
const documentTitle = useDocumentTitle();
const settingsStore = useSettingsStore();
const store = useInstanceAiSettingsStore();
const isAdmin = computed(() => store.canManage);
@ -46,7 +48,9 @@ const permissionKeys: Array<{ key: keyof InstanceAiPermissions; labelKey: BaseTe
},
];
const isEnabled = computed(() => store.settings?.enabled ?? false);
const isEnabled = computed(
() => store.settings?.enabled ?? settingsStore.moduleSettings?.['instance-ai']?.enabled ?? false,
);
onMounted(() => {
documentTitle.set(i18n.baseText('settings.n8nAgent'));
@ -87,26 +91,29 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
<template v-else>
<template v-if="isAdmin">
<div :class="$style.card">
<div :class="$style.settingsRow">
<div :class="$style.settingsRowLeft">
<span :class="$style.settingsRowLabel">
<div :class="$style.sectionBlock">
<div :class="$style.enableSection">
<N8nHeading tag="h2" size="small">
{{ i18n.baseText('settings.n8nAgent.enable.label') }}
</span>
<span :class="$style.settingsRowDescription">
{{ i18n.baseText('settings.n8nAgent.enable.description') }}
</span>
</N8nHeading>
<div :class="$style.switchRow">
<span :class="$style.switchDescription">
{{ i18n.baseText('settings.n8nAgent.enable.description') }}
</span>
<ElSwitch
:model-value="isEnabled"
:disabled="store.isSaving"
data-test-id="n8n-agent-enable-toggle"
@update:model-value="handleEnabledToggle"
/>
</div>
</div>
<ElSwitch
:class="$style.toggle"
:model-value="isEnabled"
:disabled="store.isSaving"
data-test-id="n8n-agent-enable-toggle"
@update:model-value="handleEnabledToggle"
/>
</div>
</div>
</template>
<div :class="$style.card">
<template v-if="isEnabled">
<div v-if="isAdmin" :class="$style.card">
<div :class="$style.settingsRow">
<div :class="$style.settingsRowLeft">
<span :class="$style.settingsRowLabel">
@ -125,46 +132,13 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
/>
</div>
</div>
</template>
<div :class="$style.card">
<div :class="$style.sectionBlock">
<ModelSection />
</div>
</div>
<div :class="$style.card">
<div :class="$style.sectionBlock">
<LocalGatewaySection />
</div>
</div>
<template v-if="isAdmin">
<div :class="$style.card">
<div :class="$style.sectionBlock">
<SandboxSection />
<LocalGatewaySection />
</div>
</div>
<div :class="$style.card">
<div :class="$style.sectionBlock">
<MemorySection />
</div>
</div>
<div :class="$style.card">
<div :class="$style.sectionBlock">
<SearchSection />
</div>
</div>
<div :class="$style.card">
<div :class="$style.sectionBlock">
<AdvancedSection />
</div>
</div>
<template v-if="isEnabled">
<template v-if="isAdmin">
<div :class="$style.permissionsHeader">
<N8nHeading :class="$style.sectionTitle" tag="h3" size="medium">
{{ i18n.baseText('settings.n8nAgent.permissions.title') }}
@ -214,6 +188,38 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
</div>
</div>
</template>
<div v-if="!store.isProxyEnabled" :class="$style.card">
<div :class="$style.sectionBlock">
<ModelSection />
</div>
</div>
<template v-if="isAdmin">
<div v-if="!store.isProxyEnabled" :class="$style.card">
<div :class="$style.sectionBlock">
<SandboxSection />
</div>
</div>
<div v-if="!store.isCloudManaged" :class="$style.card">
<div :class="$style.sectionBlock">
<MemorySection />
</div>
</div>
<div v-if="!store.isProxyEnabled" :class="$style.card">
<div :class="$style.sectionBlock">
<SearchSection />
</div>
</div>
<div v-if="!store.isCloudManaged" :class="$style.card">
<div :class="$style.sectionBlock">
<AdvancedSection />
</div>
</div>
</template>
</template>
<div v-if="store.isDirty" :class="$style.footer">
@ -273,10 +279,6 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
background: var(--color--background--light-3);
}
.toggle {
--switch--color--background--active: var(--color--primary);
}
.settingsRow {
display: flex;
align-items: center;
@ -337,6 +339,24 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
flex-shrink: 0;
}
.enableSection {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.switchRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing--4xs) 0;
}
.switchDescription {
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
}
.footer {
display: flex;
justify-content: flex-end;