feat: Add self-hosted AI Assistant onboarding (#35579)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Tuukka Kantola <tuukka@n8n.io>
This commit is contained in:
oleg
2026-08-07 11:02:52 +02:00
committed by GitHub
parent 3adfdf8cca
commit 4ae4cc3602
48 changed files with 5822 additions and 2117 deletions
@@ -345,6 +345,8 @@ export type FrontendModuleSettings = {
browserUseEnabled: boolean;
proxyEnabled: boolean;
cloudManaged: boolean;
/** Whether model, sandbox, and the explicit web-search decision are configured. */
setupCompleted?: boolean;
sandboxEnabled: boolean;
workflowBuilderAvailable: boolean;
sandboxUnavailableReason: string | null;
+6
View File
@@ -366,6 +366,9 @@ export {
instanceAiWorkflowAttachmentSchema,
InstanceAiThreadMessagesQuery,
InstanceAiAdminSettingsUpdateRequest,
InstanceAiVerifyModelRequest,
InstanceAiVerifySandboxRequest,
InstanceAiVerifySearchRequest,
InstanceAiUserPreferencesUpdateRequest,
InstanceAiGatewayCapabilitiesDto,
InstanceAiGatewayCreateCredentialDto,
@@ -447,6 +450,9 @@ export type {
InstanceAiThreadStatusResponse,
InstanceAiConfirmResponse,
InstanceAiAdminSettingsResponse,
InstanceAiEnvManagedFields,
InstanceAiVerificationFailure,
InstanceAiVerificationResponse,
InstanceAiUserPreferencesResponse,
InstanceAiProviderConnection,
InstanceAiSandboxProvider,
@@ -1446,6 +1446,25 @@ export const INSTANCE_AI_MODEL_CREDENTIAL_TYPES = [
export const INSTANCE_AI_SEARCH_CREDENTIAL_TYPES = ['braveSearchApi', 'searXngApi'] as const;
export interface InstanceAiEnvManagedFields {
model: {
provider: boolean;
apiKey: boolean;
baseUrl: boolean;
model: boolean;
};
sandbox: {
provider: boolean;
serviceUrl: boolean;
apiKey: boolean;
};
search: {
provider: boolean;
apiKey: boolean;
url: boolean;
};
}
export interface InstanceAiAdminSettingsResponse {
enabled: boolean;
permissions: InstanceAiPermissions;
@@ -1460,6 +1479,9 @@ export interface InstanceAiAdminSettingsResponse {
modelEnvConfigured: boolean;
sandboxEnvConfigured: boolean;
searchEnvConfigured: boolean;
searchDisabled: boolean;
n8nSandboxServiceUrl: string | null;
envManaged: InstanceAiEnvManagedFields;
localGatewayDisabled: boolean;
browserUseEnabled: boolean;
}
@@ -1491,10 +1513,48 @@ export class InstanceAiAdminSettingsUpdateRequest extends Z.class({
sandboxConnection: instanceAiConnectionSchema.nullable().optional(),
searchConnection: instanceAiConnectionSchema.nullable().optional(),
modelName: z.string().trim().min(1).nullable().optional(),
searchDisabled: z.boolean().optional(),
n8nSandboxServiceUrl: z.string().url().nullable().optional(),
localGatewayDisabled: z.boolean().optional(),
browserUseEnabled: z.boolean().optional(),
}) {}
export const instanceAiVerificationFailureSchema = z.enum([
'unauthorized',
'forbidden',
'timeout',
'rate_limited',
'quota_exceeded',
'unreachable',
'invalid_response',
'provider_error',
]);
export type InstanceAiVerificationFailure = z.infer<typeof instanceAiVerificationFailureSchema>;
export class InstanceAiVerifyModelRequest extends Z.class({
connection: instanceAiConnectionSchema.optional(),
modelName: z.string().trim().min(1).optional(),
}) {}
export class InstanceAiVerifySandboxRequest extends Z.class({
provider: instanceAiSandboxProviderSchema.optional(),
connection: instanceAiConnectionSchema.optional(),
serviceUrl: z.string().url().optional(),
}) {}
export class InstanceAiVerifySearchRequest extends Z.class({
connection: instanceAiConnectionSchema.optional(),
}) {}
export type InstanceAiVerificationResponse =
| {
ok: true;
latencyMs?: number;
startupMs?: number;
resultCount?: number;
}
| { ok: false; failure: InstanceAiVerificationFailure };
// ---------------------------------------------------------------------------
// User preferences — per-user, self-service
// ---------------------------------------------------------------------------
@@ -46,7 +46,7 @@ describe('InstanceAiSettingsService', () => {
sandboxProvider: 'n8n-sandbox',
sandboxImage: '',
sandboxTimeout: 60,
n8nSandboxServiceUrl: 'http://sandbox-api:8080',
n8nSandboxServiceUrl: '',
n8nSandboxServiceApiKey: '',
localGatewayDisabled: false,
} as unknown as InstanceAiConfig,
@@ -66,16 +66,35 @@ describe('InstanceAiSettingsService', () => {
let service: InstanceAiSettingsService;
let persistedSettingsValue: string | undefined;
const createService = () =>
new InstanceAiSettingsService(
globalConfig as never,
dbLockService,
settingsRepository,
userRepository,
userService,
aiService,
credentialsService,
credentialsFinderService,
instanceCredentialBroker,
eventService,
);
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv('N8N_INSTANCE_AI_MODEL', '');
vi.stubEnv('OPENAI_API_KEY', '');
vi.stubEnv('ANTHROPIC_API_KEY', '');
persistedSettingsValue = undefined;
logger.scoped.mockReturnValue(logger);
Container.set(Logger, logger);
Object.assign(globalConfig.instanceAi, {
model: 'openai/gpt-4',
modelApiKey: '',
modelUrl: '',
sandboxEnabled: false,
sandboxProvider: 'n8n-sandbox',
n8nSandboxServiceUrl: 'http://sandbox-api:8080',
n8nSandboxServiceUrl: '',
n8nSandboxServiceApiKey: '',
mcpServers: '',
browserMcp: false,
@@ -116,18 +135,7 @@ describe('InstanceAiSettingsService', () => {
dbLockService.withLockContext.mockImplementation(async (_lockId, fn) => {
return await fn(operationContext);
});
service = new InstanceAiSettingsService(
globalConfig as never,
dbLockService,
settingsRepository,
userRepository,
userService,
aiService,
credentialsService,
credentialsFinderService,
instanceCredentialBroker,
eventService,
);
service = createService();
});
afterEach(() => {
@@ -140,7 +148,7 @@ describe('InstanceAiSettingsService', () => {
await expect(
service.updateAdminSettings({
sandboxEnabled: true,
sandboxImage: 'custom-image',
}),
).rejects.toThrow(UnprocessableRequestError);
});
@@ -150,25 +158,104 @@ describe('InstanceAiSettingsService', () => {
await expect(
service.updateAdminSettings({
sandboxEnabled: true,
sandboxImage: 'custom-image',
mcpServers: '[]',
}),
).rejects.toThrow(/sandboxEnabled.*mcpServers|mcpServers.*sandboxEnabled/);
).rejects.toThrow(/sandboxImage.*mcpServers|mcpServers.*sandboxImage/);
});
it('should reject environment-managed fields on self-hosted', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
await expect(service.updateAdminSettings({ sandboxEnabled: true })).rejects.toThrow(
await expect(service.updateAdminSettings({ sandboxImage: 'custom-image' })).rejects.toThrow(
UnprocessableRequestError,
);
await expect(service.updateAdminSettings({ mcpServers: '[]' })).rejects.toThrow(
UnprocessableRequestError,
);
await service.updateAdminSettings({
n8nSandboxServiceUrl: 'http://sandbox-api:8080',
});
await expect(service.updateAdminSettings({ sandboxEnabled: true })).resolves.toBeDefined();
});
it('rejects model connection fields managed by the environment', async () => {
globalConfig.instanceAi.modelApiKey = 'environment-key';
await expect(
service.updateAdminSettings({
modelConnection: { type: 'openAiApi', data: { apiKey: 'database-key' } },
modelName: 'gpt-5',
}),
).rejects.toThrow('Cannot update environment-managed fields: modelConnection');
await expect(service.updateAdminSettings({ modelName: 'gpt-5' })).resolves.toBeDefined();
vi.stubEnv('N8N_INSTANCE_AI_MODEL', 'openai/gpt-5');
await expect(service.updateAdminSettings({ modelName: 'gpt-4' })).rejects.toThrow(
'Cannot update environment-managed fields: modelName',
);
});
it('rejects sandbox connection fields managed by the environment', async () => {
Object.assign(globalConfig.instanceAi, {
sandboxProvider: 'daytona',
daytonaApiKey: 'environment-key',
});
service = createService();
await expect(
service.updateAdminSettings({
sandboxConnection: {
type: 'daytonaApi',
data: { apiUrl: 'https://daytona.example.com', apiKey: 'database-key' },
},
}),
).rejects.toThrow('Cannot update environment-managed fields: sandboxConnection');
await expect(service.updateAdminSettings({ sandboxEnabled: true })).resolves.toBeDefined();
});
it('rejects search decisions managed by the environment', async () => {
globalConfig.instanceAi.braveSearchApiKey = 'environment-key';
await expect(service.updateAdminSettings({ searchDisabled: true })).rejects.toThrow(
'Cannot update environment-managed fields: searchDisabled',
);
await expect(
service.updateAdminSettings({
searchConnection: { type: 'braveSearchApi', data: { apiKey: 'database-key' } },
}),
).rejects.toThrow('Cannot update environment-managed fields: searchConnection');
});
it('persists an explicit decision to disable web search and clears its assignment', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
const result = await service.updateAdminSettings({ searchDisabled: true });
expect(result.searchDisabled).toBe(true);
expect(instanceCredentialBroker.clearForUse).toHaveBeenCalledWith(
INSTANCE_AI_SEARCH_CREDENTIAL_POLICY,
operationContext,
);
expect(persistedSettingsValue).toContain('"searchDisabled":true');
});
it('rejects disabling web search while configuring a search connection', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
await expect(
service.updateAdminSettings({
searchDisabled: true,
searchConnection: { type: 'braveSearchApi', data: { apiKey: 'key' } },
}),
).rejects.toThrow('Cannot disable web search while configuring a search connection');
});
it('should store service credential selections as broker assignments', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
await service.updateAdminSettings({
n8nSandboxServiceUrl: 'http://sandbox-api:8080',
});
await service.updateAdminSettings({
daytonaCredentialId: 'daytona-cred',
@@ -213,6 +300,9 @@ describe('InstanceAiSettingsService', () => {
it('should reject an n8n sandbox credential whose header name is not x-api-key', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
await service.updateAdminSettings({
n8nSandboxServiceUrl: 'http://sandbox-api:8080',
});
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-cred',
name: 'Sandbox header',
@@ -227,6 +317,9 @@ describe('InstanceAiSettingsService', () => {
it('should accept an n8n sandbox credential with the x-api-key header', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
await service.updateAdminSettings({
n8nSandboxServiceUrl: 'http://sandbox-api:8080',
});
settingsRepository.upsert.mockResolvedValue(undefined as never);
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-cred',
@@ -544,13 +637,17 @@ describe('InstanceAiSettingsService', () => {
expect.objectContaining({ id: 'instance-ai:sandbox:n8n' }),
operationContext,
);
expect(result.sandboxProvider).toBe('daytona');
expect(result).toMatchObject({ sandboxEnabled: true, sandboxProvider: 'daytona' });
expect(persistedSettingsValue).toContain('"sandboxEnabled":true');
});
it('should restore the environment sandbox provider when the connection is cleared', async () => {
credentialsService.createInstanceCredential.mockResolvedValue({
id: 'daytona-cred',
} as never);
await service.updateAdminSettings({
n8nSandboxServiceUrl: 'http://sandbox-api:8080',
});
await service.updateAdminSettings(
{
@@ -732,6 +829,7 @@ describe('InstanceAiSettingsService', () => {
it('should validate sandbox settings before running connection hooks', async () => {
globalConfig.instanceAi.sandboxEnabled = true;
globalConfig.instanceAi.n8nSandboxServiceUrl = '';
service = createService();
await expect(
service.updateAdminSettings(
@@ -815,6 +913,8 @@ describe('InstanceAiSettingsService', () => {
it('should clear the inactive Daytona slot when selecting n8n Sandbox behind the proxy', async () => {
aiService.isProxyEnabled.mockReturnValue(true);
globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080';
service = createService();
await expect(
service.updateAdminSettings({
@@ -840,6 +940,8 @@ describe('InstanceAiSettingsService', () => {
it('should accept n8n Sandbox connections on proxy deployments', async () => {
aiService.isProxyEnabled.mockReturnValue(true);
globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080';
service = createService();
credentialsService.createInstanceCredential.mockResolvedValue({
id: 'sandbox-cred',
} as never);
@@ -902,7 +1004,10 @@ describe('InstanceAiSettingsService', () => {
});
await expect(
service.updateAdminSettings({ daytonaCredentialId: 'daytona-cred' }),
service.updateAdminSettings({
daytonaCredentialId: 'daytona-cred',
sandboxProvider: 'daytona',
}),
).rejects.toThrow(/apiKey/);
});
@@ -923,6 +1028,7 @@ describe('InstanceAiSettingsService', () => {
it('should reject n8n sandbox selection without a service URL', async () => {
globalConfig.instanceAi.sandboxEnabled = true;
globalConfig.instanceAi.n8nSandboxServiceUrl = '';
service = createService();
await expect(
service.updateAdminSettings({
@@ -1298,6 +1404,26 @@ describe('InstanceAiSettingsService', () => {
);
});
it('uses the environment model connection without resolving stored credentials', async () => {
Object.assign(globalConfig.instanceAi, {
model: 'openai/gpt-5',
modelApiKey: 'environment-key',
});
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'stored-model',
name: 'Stored model',
type: 'openAiApi',
data: { apiKey: 'database-key' },
});
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
id: 'openai/gpt-5',
url: '',
apiKey: 'environment-key',
});
expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled();
});
it('reads the admin model name and same-id credential update in one locked snapshot', async () => {
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'credential-a',
@@ -1552,7 +1678,7 @@ describe('InstanceAiSettingsService', () => {
});
describe('search credential', () => {
it('uses the resolved credential data for the search config', async () => {
it('uses environment search settings without resolving the stored credential', async () => {
globalConfig.instanceAi.braveSearchApiKey = 'env-key';
globalConfig.instanceAi.searxngUrl = 'https://search.example.com';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
@@ -1563,12 +1689,14 @@ describe('InstanceAiSettingsService', () => {
});
await expect(service.resolveSearchConfig()).resolves.toEqual({
braveApiKey: 'credential-key',
braveApiKey: 'env-key',
searxngUrl: 'https://search.example.com',
});
expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
it('falls back to environment config when the selected credential cannot be resolved', async () => {
it('does not resolve an unavailable stored credential when search is environment-managed', async () => {
globalConfig.instanceAi.braveSearchApiKey = 'env-key';
globalConfig.instanceAi.searxngUrl = 'https://search.example.com';
instanceCredentialBroker.resolveForUse.mockRejectedValue(new Error('not found'));
@@ -1577,13 +1705,11 @@ describe('InstanceAiSettingsService', () => {
braveApiKey: 'env-key',
searxngUrl: 'https://search.example.com',
});
expect(logger.warn).toHaveBeenCalledWith(
'Could not resolve the configured search credential; using environment fallback',
{ credentialUseId: 'instance-ai:search', error: 'not found' },
);
expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
it('falls back to environment config when resolved credential data is incomplete', async () => {
it('does not inspect incomplete stored credential data when search is environment-managed', async () => {
globalConfig.instanceAi.braveSearchApiKey = 'env-key';
globalConfig.instanceAi.searxngUrl = 'https://search.example.com';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
@@ -1597,13 +1723,8 @@ describe('InstanceAiSettingsService', () => {
braveApiKey: 'env-key',
searxngUrl: 'https://search.example.com',
});
expect(logger.warn).toHaveBeenCalledWith(
'Could not resolve the configured search credential; using environment fallback',
{
credentialUseId: 'instance-ai:search',
error: 'Credential data is incomplete',
},
);
expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
});
@@ -1673,7 +1794,8 @@ describe('InstanceAiSettingsService', () => {
});
describe('n8n sandbox credential', () => {
it('uses the resolved api key instead of the environment api key', async () => {
it('uses the resolved api key for an admin-configured service URL', async () => {
globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080';
globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-credential',
@@ -1688,8 +1810,29 @@ describe('InstanceAiSettingsService', () => {
});
});
it('uses environment settings without resolving the stored credential', async () => {
Object.assign(globalConfig.instanceAi, {
n8nSandboxServiceUrl: 'http://sandbox-api:8080',
n8nSandboxServiceApiKey: 'environment-key',
});
service = createService();
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-credential',
name: 'Sandbox',
type: 'httpHeaderAuth',
data: { name: 'X-Api-Key', value: 'credential-key' },
});
await expect(service.resolveN8nSandboxConfig()).resolves.toEqual({
serviceUrl: 'http://sandbox-api:8080',
apiKey: 'environment-key',
});
expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled();
});
it('uses the resolved api key when the assistant proxy is enabled', async () => {
aiService.isProxyEnabled.mockReturnValue(true);
globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080';
globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-credential',
@@ -1705,6 +1848,7 @@ describe('InstanceAiSettingsService', () => {
});
it('falls back to environment config when the credential header is not x-api-key', async () => {
globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080';
globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-credential',
@@ -1727,6 +1871,7 @@ describe('InstanceAiSettingsService', () => {
});
it('falls back to environment config when the api key is missing', async () => {
globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080';
globalConfig.instanceAi.n8nSandboxServiceApiKey = 'env-key';
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: 'sandbox-credential',
@@ -1757,6 +1902,7 @@ describe('InstanceAiSettingsService', () => {
modelUrl: '',
n8nSandboxServiceUrl: '',
});
service = createService();
});
it('reports the model as env-configured when a custom or provider key is set', async () => {
@@ -1780,6 +1926,7 @@ describe('InstanceAiSettingsService', () => {
it('reports environment configuration for the selected sandbox provider', async () => {
globalConfig.instanceAi.sandboxProvider = 'daytona';
globalConfig.instanceAi.daytonaApiKey = 'dtn-key';
service = createService();
expect((await service.getAdminSettings()).sandboxEnvConfigured).toBe(true);
globalConfig.instanceAi.daytonaApiKey = '';
@@ -1787,9 +1934,11 @@ describe('InstanceAiSettingsService', () => {
globalConfig.instanceAi.sandboxProvider = 'n8n-sandbox';
globalConfig.instanceAi.n8nSandboxServiceUrl = 'http://sandbox-api:8080';
service = createService();
expect((await service.getAdminSettings()).sandboxEnvConfigured).toBe(true);
globalConfig.instanceAi.n8nSandboxServiceUrl = '';
service = createService();
expect((await service.getAdminSettings()).sandboxEnvConfigured).toBe(false);
});
@@ -1803,6 +1952,237 @@ describe('InstanceAiSettingsService', () => {
globalConfig.instanceAi.searxngUrl = 'http://searxng:8080';
expect((await service.getAdminSettings()).searchEnvConfigured).toBe(true);
});
it('only exposes env-management booleans for server-managed values', async () => {
globalConfig.instanceAi.modelApiKey = 'model-secret';
globalConfig.instanceAi.braveSearchApiKey = 'search-secret';
const settings = await service.getAdminSettings();
const serialized = JSON.stringify(settings);
expect(settings.envManaged.model.apiKey).toBe(true);
expect(settings.envManaged.search.apiKey).toBe(true);
expect(serialized).not.toContain('model-secret');
expect(serialized).not.toContain('search-secret');
});
});
describe('verification connection resolution', () => {
beforeEach(() => {
credentialsService.unredact.mockImplementation((_data, currentData) => currentData);
instanceCredentialBroker.resolveForUse.mockImplementation(async (policy) => {
if (policy.id === INSTANCE_AI_MODEL_CREDENTIAL_POLICY.id) {
return {
id: 'model-credential',
name: 'Model',
type: 'openAiApi',
data: { apiKey: 'saved-model-key' },
} as never;
}
if (policy.id === INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY.id) {
return {
id: 'sandbox-credential',
name: 'Sandbox',
type: 'httpHeaderAuth',
data: { name: 'x-api-key', value: 'saved-sandbox-key' },
} as never;
}
return {
id: 'search-credential',
name: 'Search',
type: 'braveSearchApi',
data: { apiKey: 'saved-search-key' },
} as never;
});
});
it('restores redacted model, sandbox, and search fields on the server', async () => {
await expect(
service.resolveModelConnectionForVerification({
type: 'openAiApi',
data: { apiKey: '__redacted__' },
}),
).resolves.toEqual({ type: 'openAiApi', data: { apiKey: 'saved-model-key' } });
await expect(
service.resolveSandboxConnectionForVerification({
type: 'httpHeaderAuth',
data: { name: 'x-api-key', value: '__redacted__' },
}),
).resolves.toEqual({
type: 'httpHeaderAuth',
data: { name: 'x-api-key', value: 'saved-sandbox-key' },
});
await expect(
service.resolveSearchConnectionForVerification({
type: 'braveSearchApi',
data: { apiKey: '__redacted__' },
}),
).resolves.toEqual({ type: 'braveSearchApi', data: { apiKey: 'saved-search-key' } });
});
it('rejects a missing prepared connection', async () => {
await expect(
service.resolveModelConnectionForVerification(undefined as never),
).rejects.toThrow('Prepared provider connection is missing');
});
});
describe('model verification config', () => {
it.each([
['openai/original', 'replacement', 'openai/replacement'],
['original', 'replacement', 'custom/replacement'],
[
{ id: 'anthropic/original', url: '', apiKey: 'key' },
'replacement',
{ id: 'anthropic/replacement', url: '', apiKey: 'key' },
],
[
{ id: 'original', url: '', apiKey: 'key' },
'replacement',
{ id: 'custom/replacement', url: '', apiKey: 'key' },
],
] as const)(
'replaces the selected model while keeping its provider',
async (config, model, expected) => {
vi.spyOn(service, 'resolveModelConfig').mockResolvedValue(config as never);
await expect(
service.resolveModelConfigForVerification(mock<User>(), model),
).resolves.toEqual(expected);
},
);
it('keeps the resolved config when no model is selected or the config is opaque', async () => {
const opaqueConfig = { provider: 'custom' };
const resolveModelConfig = vi
.spyOn(service, 'resolveModelConfig')
.mockResolvedValueOnce('openai/original')
.mockResolvedValueOnce(opaqueConfig as never);
await expect(service.resolveModelConfigForVerification(mock<User>())).resolves.toBe(
'openai/original',
);
await expect(
service.resolveModelConfigForVerification(mock<User>(), 'replacement'),
).resolves.toBe(opaqueConfig);
expect(resolveModelConfig).toHaveBeenCalledTimes(2);
});
it('builds and validates a model config from a draft connection', () => {
expect(
service.buildModelConfigForConnection(
{ type: 'openAiApi', data: { apiKey: 'key' } },
'gpt-5.4',
),
).toEqual({ id: 'openai/gpt-5.4', url: '', apiKey: 'key' });
expect(() =>
service.buildModelConfigForConnection(
{ type: 'braveSearchApi', data: { apiKey: 'key' } },
'gpt-5.4',
),
).toThrow('is not supported for the model');
expect(() =>
service.buildModelConfigForConnection({ type: 'openAiApi', data: {} }, 'gpt-5.4'),
).toThrow('The field "apiKey" or "url" is required');
});
it('builds model configs from environment URL and API key combinations', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
Object.assign(globalConfig.instanceAi, {
model: 'openai/gpt-4',
modelUrl: 'https://model.example.com/v1',
modelApiKey: 'model-key',
});
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
id: 'openai/gpt-4',
url: 'https://model.example.com/v1',
apiKey: 'model-key',
});
globalConfig.instanceAi.modelUrl = '';
await expect(service.resolveModelConfig(mock<User>())).resolves.toEqual({
id: 'openai/gpt-4',
url: '',
apiKey: 'model-key',
});
globalConfig.instanceAi.modelApiKey = '';
await expect(service.resolveModelConfig(mock<User>())).resolves.toBe('openai/gpt-4');
});
});
describe('isSetupCompleted', () => {
it('is complete on managed deployments', async () => {
globalConfig.deployment.type = 'cloud';
await expect(service.isSetupCompleted()).resolves.toBe(true);
globalConfig.deployment.type = 'default';
aiService.isProxyEnabled.mockReturnValue(true);
await expect(service.isSetupCompleted()).resolves.toBe(true);
expect(instanceCredentialBroker.getAssignedCredentialId).not.toHaveBeenCalled();
});
it('accepts a setup fully configured through environment variables', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
Object.assign(globalConfig.instanceAi, {
modelApiKey: 'model-key',
sandboxEnabled: true,
sandboxProvider: 'daytona',
daytonaApiKey: 'sandbox-key',
braveSearchApiKey: 'search-key',
});
service = createService();
await expect(service.isSetupCompleted()).resolves.toBe(true);
});
it('accepts Daytona and search credential assignments', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
persistedSettingsValue = JSON.stringify({
modelName: 'gpt-5.4',
sandboxEnabled: true,
sandboxProvider: 'daytona',
});
instanceCredentialBroker.getAssignedCredentialId.mockImplementation(async (policy) => {
if (policy.id === INSTANCE_AI_MODEL_CREDENTIAL_POLICY.id) return 'model-credential';
if (policy.id === INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY.id) return 'sandbox-credential';
if (policy.id === INSTANCE_AI_SEARCH_CREDENTIAL_POLICY.id) return 'search-credential';
return null;
});
await service.reloadFromDb();
await expect(service.isSetupCompleted()).resolves.toBe(true);
});
it('requires an explicit web-search decision after model and sandbox are configured', async () => {
aiService.isProxyEnabled.mockReturnValue(false);
persistedSettingsValue = JSON.stringify({
modelName: 'gpt-5.4',
sandboxEnabled: true,
sandboxProvider: 'n8n-sandbox',
});
instanceCredentialBroker.getAssignedCredentialId.mockImplementation(async (policy) => {
if (policy.id === INSTANCE_AI_MODEL_CREDENTIAL_POLICY.id) return 'model-credential';
if (policy.id === INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY.id) {
return 'sandbox-credential';
}
return null;
});
await expect(service.isSetupCompleted()).resolves.toBe(false);
persistedSettingsValue = JSON.stringify({
modelName: 'gpt-5.4',
sandboxEnabled: true,
sandboxProvider: 'n8n-sandbox',
searchDisabled: true,
});
await service.reloadFromDb();
await expect(service.isSetupCompleted()).resolves.toBe(true);
});
});
describe('service credential assignments', () => {
@@ -1823,6 +2203,25 @@ describe('InstanceAiSettingsService', () => {
});
});
it('hides stored assignments when environment connections are active', async () => {
Object.assign(globalConfig.instanceAi, {
modelApiKey: 'model-environment-key',
n8nSandboxServiceUrl: 'http://sandbox-api:8080',
braveSearchApiKey: 'search-environment-key',
});
service = createService();
instanceCredentialBroker.getAssignedCredentialId.mockImplementation(async (credentialUse) => {
return `${credentialUse.id}-credential`;
});
await expect(service.getAdminSettings()).resolves.toMatchObject({
modelCredentialId: null,
daytonaCredentialId: null,
n8nSandboxCredentialId: null,
searchCredentialId: null,
});
});
it('exposes n8n Sandbox assignments when the assistant proxy is enabled', async () => {
aiService.isProxyEnabled.mockReturnValue(true);
instanceCredentialBroker.getAssignedCredentialId.mockResolvedValue('sandbox-cred');
@@ -0,0 +1,391 @@
import type { InstanceAiVerificationFailure } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { OutboundHttp } from '@n8n/backend-network';
import type { GlobalConfig, InstanceAiConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
const raceWithAbortMock = vi.hoisted(() => vi.fn());
vi.mock('@n8n/agents', () => ({ createModel: vi.fn(), raceWithAbort: raceWithAbortMock }));
vi.mock('ai', () => ({ generateText: vi.fn() }));
vi.mock('@n8n/instance-ai', () => ({ createSandbox: vi.fn(), createWorkspace: vi.fn() }));
vi.mock('@n8n/ai-utilities', () => ({ braveSearch: vi.fn(), searxngSearch: vi.fn() }));
vi.mock('@/utils/ai-proxy-fetch', () => ({ createAiProxyFetch: vi.fn(() => vi.fn()) }));
import { braveSearch, searxngSearch } from '@n8n/ai-utilities';
import { createModel } from '@n8n/agents';
import { createSandbox, createWorkspace } from '@n8n/instance-ai';
import { generateText } from 'ai';
import type { InstanceAiModelService } from '../instance-ai-model.service';
import type { InstanceAiSettingsService } from '../instance-ai-settings.service';
import { InstanceAiVerificationService } from '../instance-ai-verification.service';
describe('InstanceAiVerificationService', () => {
const globalConfig = mock<GlobalConfig>({
instanceAi: {
sandboxProvider: 'n8n-sandbox',
sandboxImage: 'sandbox-image',
sandboxTimeout: 60,
n8nSandboxServiceUrl: 'https://env.sandbox',
n8nSandboxServiceApiKey: 'env-sandbox-key',
daytonaApiUrl: 'https://env.daytona',
daytonaApiKey: 'env-daytona-key',
} as unknown as InstanceAiConfig,
});
const logger = mock<Logger>();
const settingsService = mock<InstanceAiSettingsService>();
const modelService = mock<InstanceAiModelService>();
const outboundHttp = mock<OutboundHttp>();
const user = mock<User>();
const createModelMock = vi.mocked(createModel);
const generateTextMock = vi.mocked(generateText);
const createSandboxMock = vi.mocked(createSandbox);
const createWorkspaceMock = vi.mocked(createWorkspace);
const braveSearchMock = vi.mocked(braveSearch);
const searxngSearchMock = vi.mocked(searxngSearch);
let service: InstanceAiVerificationService;
beforeEach(() => {
vi.resetAllMocks();
raceWithAbortMock.mockImplementation(
async (work: Promise<unknown> | (() => Promise<unknown>)) =>
await (typeof work === 'function' ? work() : work),
);
Object.assign(globalConfig.instanceAi, {
sandboxProvider: 'n8n-sandbox',
sandboxImage: 'sandbox-image',
sandboxTimeout: 60,
n8nSandboxServiceUrl: 'https://env.sandbox',
n8nSandboxServiceApiKey: 'env-sandbox-key',
daytonaApiUrl: 'https://env.daytona',
daytonaApiKey: 'env-daytona-key',
});
modelService.resolveAgentModelConfig.mockResolvedValue('openai/saved-model');
createModelMock.mockReturnValue({} as never);
generateTextMock.mockResolvedValue({} as never);
service = new InstanceAiVerificationService(
logger,
globalConfig,
settingsService,
modelService,
outboundHttp,
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('verifyModel', () => {
it('verifies a draft connection with restored credential data', async () => {
const connection = { type: 'openAiApi', data: { apiKey: '__redacted__' } };
const restored = { type: 'openAiApi', data: { apiKey: 'saved-key' } };
const modelConfig = { id: 'openai/gpt-5.4', url: '', apiKey: 'saved-key' } as const;
settingsService.resolveModelConnectionForVerification.mockResolvedValue(restored);
settingsService.buildModelConfigForConnection.mockReturnValue(modelConfig);
await expect(
service.verifyModel(user, { connection, modelName: 'gpt-5.4' }),
).resolves.toMatchObject({ ok: true, latencyMs: expect.any(Number) });
expect(settingsService.resolveModelConnectionForVerification).toHaveBeenCalledWith(
connection,
);
expect(settingsService.buildModelConfigForConnection).toHaveBeenCalledWith(
restored,
'gpt-5.4',
);
expect(createModelMock).toHaveBeenCalledWith(modelConfig, expect.any(Function));
expect(generateTextMock).toHaveBeenCalledWith(
expect.objectContaining({ prompt: 'Reply with OK.', maxOutputTokens: 8 }),
);
});
it('uses the saved model configuration when no draft connection is provided', async () => {
settingsService.resolveModelConfigForVerification.mockResolvedValue(
'anthropic/claude-opus-4-6',
);
await expect(
service.verifyModel(user, { modelName: 'claude-opus-4-6' }),
).resolves.toMatchObject({
ok: true,
});
expect(settingsService.resolveModelConfigForVerification).toHaveBeenCalledWith(
user,
'claude-opus-4-6',
);
expect(modelService.resolveAgentModelConfig).not.toHaveBeenCalled();
});
it('uses the active agent model when no model override is provided', async () => {
await expect(service.verifyModel(user, {})).resolves.toMatchObject({ ok: true });
expect(modelService.resolveAgentModelConfig).toHaveBeenCalledWith(user);
expect(createModelMock).toHaveBeenCalledWith('openai/saved-model', expect.any(Function));
});
it.each<{ error: unknown; failure: InstanceAiVerificationFailure }>([
{ error: { status: 401 }, failure: 'unauthorized' },
{ error: { statusCode: '403', message: 'Access forbidden' }, failure: 'forbidden' },
{
error: Object.assign(new Error('Quota limit reached'), { code: 403 }),
failure: 'quota_exceeded',
},
{ error: { status: 429 }, failure: 'rate_limited' },
{ error: Object.assign(new Error('aborted'), { name: 'AbortError' }), failure: 'timeout' },
{
error: Object.assign(new Error('timed out'), { name: 'TimeoutError' }),
failure: 'timeout',
},
{ error: new Error('Provider returned 401'), failure: 'unauthorized' },
{ error: new Error('Provider returned 403: limit exceeded'), failure: 'quota_exceeded' },
{ error: new Error('Provider returned 403'), failure: 'forbidden' },
{ error: new Error('Provider returned 429'), failure: 'rate_limited' },
{ error: new Error('Request timeout'), failure: 'timeout' },
{ error: new Error('fetch failed: ECONNREFUSED'), failure: 'unreachable' },
{ error: new Error('Invalid JSON response'), failure: 'invalid_response' },
{ error: new Error('Unexpected provider failure'), failure: 'provider_error' },
])('classifies model verification failures as $failure', async ({ error, failure }) => {
generateTextMock.mockRejectedValueOnce(error);
await expect(service.verifyModel(user, {})).resolves.toEqual({ ok: false, failure });
expect(logger.warn).toHaveBeenCalledWith(
'Instance AI model verification failed',
expect.objectContaining({ error: expect.any(String), failure }),
);
});
});
describe('verifySandbox', () => {
it('verifies an n8n Sandbox draft and destroys the workspace', async () => {
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout');
const connection = { type: 'httpHeaderAuth', data: { value: '__redacted__' } };
settingsService.resolveSandboxConnectionForVerification.mockResolvedValue({
type: 'httpHeaderAuth',
data: { value: 'saved-key' },
});
const workspace = {
init: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockResolvedValue(undefined),
sandbox: { executeCommand: vi.fn().mockResolvedValue({ exitCode: 0 }) },
};
createSandboxMock.mockResolvedValue({} as never);
createWorkspaceMock.mockReturnValue(workspace as never);
await expect(
service.verifySandbox(user, {
provider: 'n8n-sandbox',
connection,
serviceUrl: 'https://draft.sandbox',
}),
).resolves.toMatchObject({ ok: true, startupMs: expect.any(Number) });
expect(settingsService.resolveSandboxConnectionForVerification).toHaveBeenCalledWith(
connection,
);
expect(createSandboxMock).toHaveBeenCalledWith({
enabled: true,
provider: 'n8n-sandbox',
serviceUrl: 'https://draft.sandbox',
apiKey: 'saved-key',
timeout: 60,
});
expect(timeoutSpy).toHaveBeenCalledWith(60);
expect(workspace.sandbox.executeCommand).toHaveBeenCalledWith('printf', ['ok'], {
abortSignal: expect.any(AbortSignal),
});
expect(workspace.destroy).toHaveBeenCalled();
});
it('returns a timeout and starts cleanup when sandbox verification exceeds the deadline', async () => {
const timeoutError = Object.assign(new Error('Sandbox verification timed out'), {
name: 'TimeoutError',
});
let raceCount = 0;
raceWithAbortMock.mockImplementation(
async (work: Promise<unknown> | (() => Promise<unknown>)) => {
raceCount++;
if (raceCount === 3) throw timeoutError;
return await (typeof work === 'function' ? work() : work);
},
);
const workspace = {
init: vi.fn(),
destroy: vi.fn().mockResolvedValue(undefined),
sandbox: { executeCommand: vi.fn() },
};
createSandboxMock.mockResolvedValue({} as never);
createWorkspaceMock.mockReturnValue(workspace as never);
await expect(service.verifySandbox(user, {})).resolves.toEqual({
ok: false,
failure: 'timeout',
});
expect(workspace.destroy).toHaveBeenCalledOnce();
expect(workspace.init).toHaveBeenCalledOnce();
expect(workspace.sandbox.executeCommand).not.toHaveBeenCalled();
});
it('verifies saved Daytona settings', async () => {
settingsService.resolveDaytonaConfig.mockResolvedValue({
apiUrl: 'https://saved.daytona',
apiKey: 'saved-daytona-key',
});
const workspace = {
init: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockRejectedValue(new Error('cleanup failed')),
sandbox: { executeCommand: vi.fn().mockResolvedValue({ exitCode: 0 }) },
};
createSandboxMock.mockResolvedValue({} as never);
createWorkspaceMock.mockReturnValue(workspace as never);
await expect(service.verifySandbox(user, { provider: 'daytona' })).resolves.toMatchObject({
ok: true,
});
expect(createSandboxMock).toHaveBeenCalledWith({
enabled: true,
provider: 'daytona',
daytonaApiUrl: 'https://saved.daytona',
daytonaApiKey: 'saved-daytona-key',
image: 'sandbox-image',
timeout: 60,
ephemeral: true,
});
expect(logger.warn).toHaveBeenCalledWith(
'Instance AI sandbox verification cleanup failed',
expect.objectContaining({ error: expect.any(String), provider: 'daytona' }),
);
});
it('uses saved n8n Sandbox settings when no draft is provided', async () => {
settingsService.resolveN8nSandboxConfig.mockResolvedValue({
serviceUrl: 'https://saved.sandbox',
apiKey: 'saved-key',
});
const workspace = {
init: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockResolvedValue(undefined),
sandbox: { executeCommand: vi.fn().mockResolvedValue({ exitCode: 0 }) },
};
createSandboxMock.mockResolvedValue({} as never);
createWorkspaceMock.mockReturnValue(workspace as never);
await expect(service.verifySandbox(user, {})).resolves.toMatchObject({ ok: true });
expect(createSandboxMock).toHaveBeenCalledWith({
enabled: true,
provider: 'n8n-sandbox',
serviceUrl: 'https://saved.sandbox',
apiKey: 'saved-key',
timeout: 60,
});
});
it.each([
['missing sandbox', undefined, undefined],
['missing workspace', {}, undefined],
['failed command', {}, { exitCode: 1 }],
] as const)('reports provider errors for a %s', async (_name, sandbox, commandResult) => {
createSandboxMock.mockResolvedValue(sandbox as never);
if (sandbox && commandResult === undefined) {
createWorkspaceMock.mockReturnValue(undefined as never);
} else if (sandbox) {
createWorkspaceMock.mockReturnValue({
init: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockResolvedValue(undefined),
sandbox: { executeCommand: vi.fn().mockResolvedValue(commandResult) },
} as never);
}
await expect(service.verifySandbox(user, {})).resolves.toEqual({
ok: false,
failure: 'provider_error',
});
});
it('maps Daytona forbidden responses to quota errors', async () => {
createSandboxMock.mockRejectedValue({ status: 403, message: 'Forbidden' });
await expect(service.verifySandbox(user, { provider: 'daytona' })).resolves.toEqual({
ok: false,
failure: 'quota_exceeded',
});
expect(logger.warn).toHaveBeenCalledWith(
'Instance AI sandbox verification failed',
expect.objectContaining({
error: expect.any(String),
failure: 'quota_exceeded',
provider: 'daytona',
}),
);
});
});
describe('verifySearch', () => {
it('verifies a Brave draft with restored credential data', async () => {
const connection = { type: 'braveSearchApi', data: { apiKey: '__redacted__' } };
settingsService.resolveSearchConnectionForVerification.mockResolvedValue({
type: 'braveSearchApi',
data: { apiKey: ' saved-key ' },
});
braveSearchMock.mockResolvedValue({ results: [{}, {}] } as never);
await expect(service.verifySearch({ connection })).resolves.toEqual({
ok: true,
resultCount: 2,
});
expect(braveSearchMock).toHaveBeenCalledWith(
'saved-key',
'n8n workflow automation',
expect.objectContaining({ maxResults: 10 }),
);
expect(searxngSearchMock).not.toHaveBeenCalled();
});
it('verifies a saved SearXNG connection', async () => {
settingsService.resolveSearchConfig.mockResolvedValue({
searxngUrl: 'https://saved.searxng',
});
searxngSearchMock.mockResolvedValue({ results: [{}] } as never);
await expect(service.verifySearch({})).resolves.toEqual({ ok: true, resultCount: 1 });
expect(searxngSearchMock).toHaveBeenCalledWith(
'https://saved.searxng',
'n8n workflow automation',
expect.objectContaining({ maxResults: 10 }),
);
});
it('reports an unconfigured provider without exposing connection details', async () => {
settingsService.resolveSearchConfig.mockResolvedValue({});
await expect(service.verifySearch({})).resolves.toEqual({
ok: false,
failure: 'provider_error',
});
});
it('classifies search provider failures', async () => {
settingsService.resolveSearchConfig.mockResolvedValue({ braveApiKey: 'saved-key' });
braveSearchMock.mockRejectedValue(new Error('network request failed'));
await expect(service.verifySearch({})).resolves.toEqual({
ok: false,
failure: 'unreachable',
});
expect(logger.warn).toHaveBeenCalledWith(
'Instance AI search verification failed',
expect.objectContaining({ error: expect.any(String), failure: 'unreachable' }),
);
});
});
});
@@ -121,7 +121,7 @@ function validateModelCredential({
data,
}: {
type: string;
data: ICredentialDataDecryptedObject;
data: Record<string, unknown>;
}): void {
const apiKey = data.apiKey;
if (typeof apiKey === 'string' && apiKey.trim().length > 0) return;
@@ -137,7 +137,7 @@ function validateModelCredential({
function modelCredentialHeaders(
credentialType: string,
data: ICredentialDataDecryptedObject,
data: Record<string, unknown>,
): Record<string, string> | undefined {
const headers: Record<string, string> = {};
if (credentialType === 'openAiApi' && typeof data.organizationId === 'string') {
@@ -250,6 +250,8 @@ interface PersistedAdminSettings {
sandboxImage?: string;
sandboxTimeout?: number;
modelName?: string | null;
searchDisabled?: boolean;
n8nSandboxServiceUrl?: string | null;
localGatewayDisabled?: boolean;
browserUseEnabled?: boolean;
}
@@ -300,6 +302,13 @@ export class InstanceAiSettingsService {
private permissions: InstanceAiPermissions = { ...DEFAULT_INSTANCE_AI_PERMISSIONS };
private adminModelName: string | null = null;
private searchDisabled = false;
private adminN8nSandboxServiceUrl: string | null = null;
private readonly environmentN8nSandboxServiceUrl: string;
constructor(
globalConfig: GlobalConfig,
private readonly dbLockService: DbLockService,
@@ -315,6 +324,7 @@ export class InstanceAiSettingsService {
this.config = globalConfig.instanceAi;
this.deploymentConfig = globalConfig.deployment;
this.environmentSandboxProvider = normalizeSandboxProvider(this.config.sandboxProvider);
this.environmentN8nSandboxServiceUrl = this.config.n8nSandboxServiceUrl;
this.config.sandboxProvider = this.environmentSandboxProvider;
}
@@ -407,29 +417,71 @@ export class InstanceAiSettingsService {
const modelProviderApiKeyEnv = MODEL_PROVIDER_API_KEY_ENV.get(c.model.split('/', 1)[0] ?? '');
const isProxyEnabled = this.aiService.isProxyEnabled();
const isManaged = this.isCloud || isProxyEnabled;
const sandboxProvider = normalizeSandboxProvider(c.sandboxProvider);
const providerModelApiKeyConfigured = Boolean(
modelProviderApiKeyEnv && process.env[modelProviderApiKeyEnv]?.trim(),
);
const modelConnectionEnvConfigured = Boolean(
c.modelApiKey.trim() || c.modelUrl.trim() || providerModelApiKeyConfigured,
);
const sandboxEnvConfigured = this.hasEnvironmentSandboxConnection();
const searchEnvConfigured = this.hasEnvironmentSearchConnection();
const directEnvironmentConfig = !isManaged;
const sandboxProvider = normalizeSandboxProvider(
directEnvironmentConfig && sandboxEnvConfigured
? this.environmentSandboxProvider
: c.sandboxProvider,
);
return {
enabled: this.enabled,
permissions: { ...this.permissions },
mcpAccessEnabled: this.mcpAccessEnabled,
sandboxEnabled: c.sandboxEnabled,
sandboxProvider,
daytonaCredentialId: isManaged ? null : credentialSelection.daytonaCredentialId,
n8nSandboxCredentialId: this.isCloud ? null : credentialSelection.n8nSandboxCredentialId,
searchCredentialId: isManaged ? null : credentialSelection.searchCredentialId,
modelCredentialId: isManaged ? null : credentialSelection.modelCredentialId,
modelName: isManaged ? null : credentialSelection.modelName,
modelEnvConfigured: Boolean(
c.modelApiKey.trim() ||
c.modelUrl.trim() ||
(modelProviderApiKeyEnv && process.env[modelProviderApiKeyEnv]?.trim()),
),
// n8n-sandbox needs only the URL; the service accepts keyless clients.
sandboxEnvConfigured:
sandboxProvider === 'daytona'
? isProxyEnabled || Boolean(c.daytonaApiKey.trim())
: Boolean(c.n8nSandboxServiceUrl.trim()),
searchEnvConfigured: Boolean(c.braveSearchApiKey.trim() || c.searxngUrl.trim()),
daytonaCredentialId:
isManaged || (directEnvironmentConfig && sandboxEnvConfigured)
? null
: credentialSelection.daytonaCredentialId,
n8nSandboxCredentialId:
this.isCloud || (directEnvironmentConfig && sandboxEnvConfigured)
? null
: credentialSelection.n8nSandboxCredentialId,
searchCredentialId:
isManaged || (directEnvironmentConfig && searchEnvConfigured)
? null
: credentialSelection.searchCredentialId,
modelCredentialId:
isManaged || (directEnvironmentConfig && modelConnectionEnvConfigured)
? null
: credentialSelection.modelCredentialId,
modelName: isManaged || this.hasEnvironmentModelName() ? null : credentialSelection.modelName,
modelEnvConfigured: modelConnectionEnvConfigured,
sandboxEnvConfigured,
searchEnvConfigured,
searchDisabled: directEnvironmentConfig && searchEnvConfigured ? false : this.searchDisabled,
n8nSandboxServiceUrl: this.environmentN8nSandboxServiceUrl
? null
: this.adminN8nSandboxServiceUrl,
envManaged: {
model: {
provider: modelConnectionEnvConfigured,
apiKey: Boolean(c.modelApiKey.trim() || providerModelApiKeyConfigured),
baseUrl: Boolean(c.modelUrl.trim()),
model: Boolean(process.env.N8N_INSTANCE_AI_MODEL?.trim()),
},
sandbox: {
provider: Boolean(process.env.N8N_INSTANCE_AI_SANDBOX_PROVIDER?.trim()),
serviceUrl: Boolean(this.environmentN8nSandboxServiceUrl.trim()),
apiKey:
sandboxProvider === 'daytona'
? Boolean(c.daytonaApiKey.trim())
: Boolean(c.n8nSandboxServiceApiKey.trim()),
},
search: {
provider: Boolean(c.braveSearchApiKey.trim() || c.searxngUrl.trim()),
apiKey: Boolean(c.braveSearchApiKey.trim()),
url: Boolean(c.searxngUrl.trim()),
},
},
localGatewayDisabled: this.isLocalGatewayDisabled(),
browserUseEnabled: this.isBrowserUseEnabled(),
};
@@ -439,6 +491,7 @@ export class InstanceAiSettingsService {
update: InstanceAiAdminSettingsUpdateRequest,
user?: User,
): Promise<InstanceAiAdminSettingsResponse> {
this.rejectEnvironmentManagedFields(update);
this.rejectManagedFields(
update,
InstanceAiSettingsService.MANAGED_ADMIN_FIELDS,
@@ -450,14 +503,28 @@ export class InstanceAiSettingsService {
InstanceAiSettingsService.INSTANCE_CREDENTIAL_FIELDS,
this.deploymentLabel(),
);
this.rejectManagedFields(update, ['modelName', 'sandboxProvider'], this.deploymentLabel());
this.rejectManagedFields(
update,
[
'modelName',
'sandboxProvider',
'sandboxEnabled',
'n8nSandboxServiceUrl',
'searchDisabled',
],
this.deploymentLabel(),
);
} else if (this.aiService.isProxyEnabled()) {
this.rejectManagedFields(
update,
['modelCredentialId', 'searchCredentialId', 'modelConnection', 'searchConnection'],
this.deploymentLabel(),
);
this.rejectManagedFields(update, ['modelName'], this.deploymentLabel());
this.rejectManagedFields(
update,
['modelName', 'sandboxEnabled', 'n8nSandboxServiceUrl', 'searchDisabled'],
this.deploymentLabel(),
);
if (update.daytonaCredentialId !== null) {
this.rejectManagedFields(update, ['daytonaCredentialId'], this.deploymentLabel());
}
@@ -479,6 +546,16 @@ export class InstanceAiSettingsService {
let daytonaCredentialId = initialDaytonaCredentialId;
let n8nSandboxCredentialId = initialN8nSandboxCredentialId;
let searchCredentialId = initialSearchCredentialId;
if (settingsUpdate.searchDisabled === true) {
if (searchConnection) {
throw new UnprocessableRequestError(
'Cannot disable web search while configuring a search connection',
);
}
searchCredentialId = null;
} else if (searchConnection || typeof searchCredentialId === 'string') {
settingsUpdate.searchDisabled = false;
}
this.rejectConnectionConflicts(update);
if (
@@ -614,8 +691,14 @@ export class InstanceAiSettingsService {
n8nSandboxCredentialId !== undefined) &&
nextDaytonaCredentialId === null &&
nextN8nCredentialId === null;
const assignsSandboxConnection =
(sandboxConnection !== undefined ||
daytonaCredentialId !== undefined ||
n8nSandboxCredentialId !== undefined) &&
(nextDaytonaCredentialId !== null || nextN8nCredentialId !== null);
if (assignsSandboxConnection) settingsUpdate.sandboxEnabled = true;
this.validateAdminSettingsUpdate(
update,
settingsUpdate,
current,
clearsSandboxConnection
? this.environmentSandboxProvider
@@ -642,7 +725,7 @@ export class InstanceAiSettingsService {
'modelName must be set together with modelCredentialId',
);
}
if (hasModelName && !hasCredential) {
if (hasModelName && !hasCredential && !this.hasEnvironmentModelConnection()) {
throw new UnprocessableRequestError('modelName requires modelCredentialId');
}
}
@@ -987,6 +1070,46 @@ export class InstanceAiSettingsService {
// ── Shared accessors ──────────────────────────────────────────────────
/** Restores redacted fields from the assigned credential without sending them to the client. */
async resolveModelConnectionForVerification(
connection: InstanceAiConnectionUpdate,
): Promise<InstanceAiConnectionUpdate> {
const prepared = await this.prepareConnection(
INSTANCE_AI_MODEL_CREDENTIAL_POLICY,
'AI Assistant model',
connection,
);
return this.connectionForVerification(prepared);
}
async resolveSandboxConnectionForVerification(
connection: InstanceAiConnectionUpdate,
): Promise<InstanceAiConnectionUpdate> {
const prepared = await this.prepareSandboxConnection(connection);
return this.connectionForVerification(prepared);
}
async resolveSearchConnectionForVerification(
connection: InstanceAiConnectionUpdate,
): Promise<InstanceAiConnectionUpdate> {
const prepared = await this.prepareConnection(
INSTANCE_AI_SEARCH_CREDENTIAL_POLICY,
'AI Assistant web search',
connection,
);
return this.connectionForVerification(prepared);
}
private connectionForVerification(
prepared: PreparedConnection | undefined,
): InstanceAiConnectionUpdate {
if (!prepared) throw new UnexpectedError('Prepared provider connection is missing');
return {
type: prepared.credential.type,
data: prepared.credential.data,
};
}
async listInstanceModelCredentials(): Promise<InstanceAiProviderConnection[]> {
if (this.isCloud || this.aiService.isProxyEnabled()) return [];
const instanceCredentials = await this.instanceCredentialBroker.listForUse(
@@ -1024,6 +1147,13 @@ export class InstanceAiSettingsService {
apiUrl: daytonaApiUrl || undefined,
apiKey: daytonaApiKey || undefined,
};
if (
this.isDirectSelfManaged() &&
this.environmentSandboxProvider === 'daytona' &&
this.hasEnvironmentSandboxConnection()
) {
return envConfig;
}
const resolved = await this.resolveServiceCredential(
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY,
'Daytona sandbox',
@@ -1049,6 +1179,13 @@ export class InstanceAiSettingsService {
serviceUrl: n8nSandboxServiceUrl || undefined,
apiKey: n8nSandboxServiceApiKey || undefined,
};
if (
this.isDirectSelfManaged() &&
this.environmentSandboxProvider === 'n8n-sandbox' &&
this.hasEnvironmentSandboxConnection()
) {
return envConfig;
}
const resolved = await this.resolveServiceCredential(
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY,
'n8n Sandbox',
@@ -1083,6 +1220,7 @@ export class InstanceAiSettingsService {
braveApiKey: braveSearchApiKey || undefined,
searxngUrl: searxngUrl || undefined,
};
if (this.isDirectSelfManaged() && this.hasEnvironmentSearchConnection()) return envConfig;
const resolved = await this.resolveServiceCredential(
INSTANCE_AI_SEARCH_CREDENTIAL_POLICY,
'search',
@@ -1195,9 +1333,49 @@ export class InstanceAiSettingsService {
return this.enabled;
}
/** Public, detail-free setup state used to gate member-facing entry points. */
async isSetupCompleted(): Promise<boolean> {
if (this.isCloud || this.aiService.isProxyEnabled()) return true;
const [modelSelection, daytonaCredentialId, n8nSandboxCredentialId, searchCredentialId] =
await Promise.all([
this.readAdminModelSelection(),
this.instanceCredentialBroker.getAssignedCredentialId(
INSTANCE_AI_DAYTONA_CREDENTIAL_POLICY,
),
this.instanceCredentialBroker.getAssignedCredentialId(
INSTANCE_AI_N8N_SANDBOX_CREDENTIAL_POLICY,
),
this.instanceCredentialBroker.getAssignedCredentialId(INSTANCE_AI_SEARCH_CREDENTIAL_POLICY),
]);
const response = this.buildAdminSettingsResponse({
...modelSelection,
daytonaCredentialId,
n8nSandboxCredentialId,
searchCredentialId,
});
const modelConfigured = Boolean(
response.modelEnvConfigured || (response.modelCredentialId && response.modelName),
);
const sandboxCredentialId =
response.sandboxProvider === 'daytona'
? response.daytonaCredentialId
: response.n8nSandboxCredentialId;
const sandboxConfigured = Boolean(
response.sandboxEnabled && (response.sandboxEnvConfigured || sandboxCredentialId),
);
const searchDecided = Boolean(
response.searchEnvConfigured || response.searchCredentialId || response.searchDisabled,
);
return modelConfigured && sandboxConfigured && searchDecided;
}
/** Resolve just the model name (e.g. 'claude-sonnet-4-20250514') for proxy routing. */
resolveModelName(user: User): string {
const prefs = this.readUserPreferences(user);
if (this.isDirectSelfManaged() && this.hasEnvironmentModelName())
return this.extractModelName(this.config.model);
const adminModelName =
this.isCloud || this.aiService.isProxyEnabled() ? null : this.adminModelName;
return adminModelName ?? prefs.modelName ?? this.extractModelName(this.config.model);
@@ -1206,6 +1384,8 @@ export class InstanceAiSettingsService {
async resolveModelConfig(user: User): Promise<ModelConfig> {
const prefs = this.readUserPreferences(user);
const fallbackModelName = prefs.modelName ?? this.extractModelName(this.config.model);
if (this.isDirectSelfManaged() && this.hasEnvironmentModelConnection())
return this.envVarModelConfig();
const adminModelConfig = await this.resolveAdminModelConfig();
if (adminModelConfig) {
@@ -1234,8 +1414,46 @@ export class InstanceAiSettingsService {
);
}
async resolveModelConfigForVerification(user: User, modelName?: string): Promise<ModelConfig> {
const config = await this.resolveModelConfig(user);
if (!modelName) return config;
if (typeof config === 'string') {
const provider = config.includes('/') ? config.slice(0, config.indexOf('/')) : 'custom';
return `${provider}/${modelName}`;
}
if ('id' in config && typeof config.id === 'string') {
const provider = config.id.includes('/')
? config.id.slice(0, config.id.indexOf('/'))
: 'custom';
return { ...config, id: `${provider}/${modelName}` };
}
return config;
}
buildModelConfigForConnection(
connection: InstanceAiConnectionUpdate,
modelName: string,
): ModelConfig {
if (!INSTANCE_AI_MODEL_CREDENTIAL_POLICY.credentialTypes.includes(connection.type)) {
throw new UnprocessableRequestError(
`Connection type "${connection.type}" is not supported for the model`,
);
}
validateModelCredential({ type: connection.type, data: connection.data });
const config = this.buildModelConfig(connection.type, connection.data, modelName);
if (!config) {
throw new UnprocessableRequestError('The model connection is incomplete');
}
return config;
}
private async resolveAdminModelConfig(): Promise<ModelConfig | null> {
if (this.isCloud || this.aiService.isProxyEnabled()) return null;
if (
this.isCloud ||
this.aiService.isProxyEnabled() ||
(this.isDirectSelfManaged() && this.hasEnvironmentModelConnection())
)
return null;
return await this.withPersistedAdminSettings(async (ctx, persisted) => {
const modelName = persisted.modelName ?? null;
@@ -1270,7 +1488,7 @@ export class InstanceAiSettingsService {
private buildModelConfig(
credentialType: string,
data: ICredentialDataDecryptedObject,
data: Record<string, unknown>,
modelName: string,
): ModelConfig | null {
const provider = CREDENTIAL_TO_MODEL_PROVIDER[credentialType];
@@ -1297,7 +1515,6 @@ export class InstanceAiSettingsService {
*/
private static readonly MANAGED_ADMIN_FIELDS: readonly string[] = [
'mcpServers',
'sandboxEnabled',
'sandboxImage',
'sandboxTimeout',
];
@@ -1318,6 +1535,28 @@ export class InstanceAiSettingsService {
'modelName',
];
private rejectEnvironmentManagedFields(update: InstanceAiAdminSettingsUpdateRequest): void {
if (!this.isDirectSelfManaged()) return;
const managedFields: string[] = [];
if (this.hasEnvironmentModelConnection()) {
managedFields.push('modelCredentialId', 'modelConnection');
}
if (this.hasEnvironmentModelName()) managedFields.push('modelName');
if (this.hasEnvironmentSandboxConnection()) {
managedFields.push(
'sandboxProvider',
'daytonaCredentialId',
'n8nSandboxCredentialId',
'sandboxConnection',
'n8nSandboxServiceUrl',
);
}
if (this.hasEnvironmentSearchConnection()) {
managedFields.push('searchCredentialId', 'searchConnection', 'searchDisabled');
}
this.rejectManagedFields(update, managedFields, 'environment');
}
/** Label for the deployment surface that owns the env-managed config, used in error messages. */
private deploymentLabel(): string {
if (this.isCloud) return 'cloud';
@@ -1347,6 +1586,7 @@ export class InstanceAiSettingsService {
const touchesSandboxSettings =
update.sandboxEnabled !== undefined ||
update.sandboxProvider !== undefined ||
update.n8nSandboxServiceUrl !== undefined ||
update.sandboxImage !== undefined ||
update.sandboxTimeout !== undefined ||
update.daytonaCredentialId !== undefined ||
@@ -1361,18 +1601,28 @@ export class InstanceAiSettingsService {
this.environmentSandboxProvider,
);
const sandboxEnabled = update.sandboxEnabled ?? current.sandboxEnabled ?? false;
const unavailableReason = this.getSandboxUnavailableReason(sandboxEnabled, sandboxProvider);
const sandboxServiceUrl =
this.environmentN8nSandboxServiceUrl ||
update.n8nSandboxServiceUrl ||
current.n8nSandboxServiceUrl ||
'';
const unavailableReason = this.getSandboxUnavailableReason(
sandboxEnabled,
sandboxProvider,
sandboxServiceUrl,
);
if (unavailableReason) throw new UnprocessableRequestError(unavailableReason);
}
private getSandboxUnavailableReason(
sandboxEnabled: boolean,
sandboxProvider: InstanceAiSandboxProvider,
sandboxServiceUrl = this.config.n8nSandboxServiceUrl,
): string | null {
if (
sandboxEnabled &&
sandboxProvider === 'n8n-sandbox' &&
this.config.n8nSandboxServiceUrl.trim().length === 0
sandboxServiceUrl.trim().length === 0
) {
return N8N_SANDBOX_SERVICE_URL_REQUIRED_MESSAGE;
}
@@ -1381,7 +1631,42 @@ export class InstanceAiSettingsService {
}
private envVarModelConfig(): ModelConfig {
return this.envVarModelConfigForModel(this.config.model);
const configuredModel = this.config.model;
if (this.hasEnvironmentModelName() || !this.adminModelName)
return this.envVarModelConfigForModel(configuredModel);
const slash = configuredModel.indexOf('/');
const provider = slash >= 0 ? configuredModel.slice(0, slash) : 'custom';
return this.envVarModelConfigForModel(`${provider}/${this.adminModelName}`);
}
private hasEnvironmentModelConnection(): boolean {
const provider = this.config.model.split('/', 1)[0] ?? '';
const providerApiKeyEnv = MODEL_PROVIDER_API_KEY_ENV.get(provider);
return Boolean(
this.config.modelApiKey.trim() ||
this.config.modelUrl.trim() ||
(providerApiKeyEnv && process.env[providerApiKeyEnv]?.trim()),
);
}
private hasEnvironmentModelName(): boolean {
return Boolean(process.env.N8N_INSTANCE_AI_MODEL?.trim());
}
private hasEnvironmentSandboxConnection(): boolean {
if (this.environmentSandboxProvider === 'daytona') {
return this.aiService.isProxyEnabled() || Boolean(this.config.daytonaApiKey.trim());
}
// n8n Sandbox accepts keyless clients, so the service URL completes the connection.
return Boolean(this.environmentN8nSandboxServiceUrl.trim());
}
private hasEnvironmentSearchConnection(): boolean {
return Boolean(this.config.braveSearchApiKey.trim() || this.config.searxngUrl.trim());
}
private isDirectSelfManaged(): boolean {
return !this.isCloud && !this.aiService.isProxyEnabled();
}
private envVarModelConfigForModel(model: string): ModelConfig {
@@ -1420,13 +1705,25 @@ export class InstanceAiSettingsService {
this.mcpAccessEnabled = persisted.mcpAccessEnabled;
if (persisted.sandboxEnabled !== undefined) c.sandboxEnabled = persisted.sandboxEnabled;
this.sandboxProviderOverride =
this.isCloud || !persisted.sandboxProvider
this.isCloud ||
(this.isDirectSelfManaged() && this.hasEnvironmentSandboxConnection()) ||
!persisted.sandboxProvider
? undefined
: normalizeSandboxProvider(persisted.sandboxProvider);
c.sandboxProvider = this.sandboxProviderOverride ?? this.environmentSandboxProvider;
if (persisted.sandboxImage !== undefined) c.sandboxImage = persisted.sandboxImage;
if (persisted.sandboxTimeout !== undefined) c.sandboxTimeout = persisted.sandboxTimeout;
if (persisted.modelName !== undefined) this.adminModelName = persisted.modelName;
if (persisted.searchDisabled !== undefined)
this.searchDisabled =
this.isDirectSelfManaged() && this.hasEnvironmentSearchConnection()
? false
: persisted.searchDisabled;
if (persisted.n8nSandboxServiceUrl !== undefined) {
this.adminN8nSandboxServiceUrl = persisted.n8nSandboxServiceUrl;
this.config.n8nSandboxServiceUrl =
this.environmentN8nSandboxServiceUrl || persisted.n8nSandboxServiceUrl || '';
}
if (persisted.localGatewayDisabled !== undefined)
c.localGatewayDisabled = persisted.localGatewayDisabled;
if (persisted.browserUseEnabled !== undefined)
@@ -1449,6 +1746,8 @@ export class InstanceAiSettingsService {
sandboxImage: c.sandboxImage,
sandboxTimeout: c.sandboxTimeout,
modelName: this.adminModelName,
searchDisabled: this.searchDisabled,
n8nSandboxServiceUrl: this.adminN8nSandboxServiceUrl,
localGatewayDisabled: c.localGatewayDisabled,
browserUseEnabled: c.browserUseEnabled,
};
@@ -0,0 +1,251 @@
import type {
InstanceAiConnectionUpdate,
InstanceAiVerificationFailure,
InstanceAiVerificationResponse,
InstanceAiVerifyModelRequest,
InstanceAiVerifySandboxRequest,
InstanceAiVerifySearchRequest,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { OutboundHttp } from '@n8n/backend-network';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { SandboxConfig } from '@n8n/instance-ai';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import { createAiProxyFetch } from '@/utils/ai-proxy-fetch';
import { InstanceAiModelService } from './instance-ai-model.service';
import { InstanceAiSettingsService } from './instance-ai-settings.service';
const VERIFICATION_TIMEOUT_MS = 30_000;
function numericStatus(error: unknown): number | undefined {
if (typeof error !== 'object' || error === null) return undefined;
for (const key of ['status', 'statusCode', 'code']) {
const value = Reflect.get(error, key);
if (typeof value === 'number') return value;
if (typeof value === 'string' && /^\d{3}$/.test(value)) return Number(value);
}
return undefined;
}
function classifyFailure(error: unknown): InstanceAiVerificationFailure {
const status = numericStatus(error);
if (status === 401) return 'unauthorized';
if (status === 403) {
const message = ensureError(error).message.toLowerCase();
return message.includes('quota') || message.includes('limit') ? 'quota_exceeded' : 'forbidden';
}
if (status === 429) return 'rate_limited';
const errorObject = ensureError(error);
if (errorObject.name === 'AbortError' || errorObject.name === 'TimeoutError') return 'timeout';
const message = errorObject.message.toLowerCase();
if (/\b401\b/.test(message)) return 'unauthorized';
if (/\b403\b/.test(message)) {
return message.includes('quota') || message.includes('limit') ? 'quota_exceeded' : 'forbidden';
}
if (/\b429\b/.test(message)) return 'rate_limited';
if (message.includes('timeout') || message.includes('timed out')) return 'timeout';
if (
message.includes('econnrefused') ||
message.includes('enotfound') ||
message.includes('fetch failed') ||
message.includes('network')
)
return 'unreachable';
if (message.includes('json') || message.includes('response')) return 'invalid_response';
return 'provider_error';
}
function connectionString(
connection: InstanceAiConnectionUpdate | undefined,
field: string,
): string | undefined {
const value = connection?.data[field];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
@Service()
export class InstanceAiVerificationService {
constructor(
private readonly logger: Logger,
private readonly globalConfig: GlobalConfig,
private readonly settingsService: InstanceAiSettingsService,
private readonly modelService: InstanceAiModelService,
private readonly outboundHttp: OutboundHttp,
) {}
async verifyModel(
user: User,
request: InstanceAiVerifyModelRequest,
): Promise<InstanceAiVerificationResponse> {
try {
const connection = request.connection
? await this.settingsService.resolveModelConnectionForVerification(request.connection)
: undefined;
const modelConfig = connection
? this.settingsService.buildModelConfigForConnection(connection, request.modelName ?? '')
: request.modelName
? await this.settingsService.resolveModelConfigForVerification(user, request.modelName)
: await this.modelService.resolveAgentModelConfig(user);
const { createModel } = await import('@n8n/agents');
const { generateText } = await import('ai');
const startedAt = performance.now();
await generateText({
model: createModel(modelConfig, createAiProxyFetch(this.outboundHttp)),
prompt: 'Reply with OK.',
maxOutputTokens: 8,
abortSignal: AbortSignal.timeout(VERIFICATION_TIMEOUT_MS),
});
return { ok: true, latencyMs: Math.round(performance.now() - startedAt) };
} catch (error) {
const failure = classifyFailure(error);
this.logVerificationFailure('model', failure, error);
return { ok: false, failure };
}
}
async verifySandbox(
user: User,
request: InstanceAiVerifySandboxRequest,
): Promise<InstanceAiVerificationResponse> {
const provider = request.provider ?? this.globalConfig.instanceAi.sandboxProvider;
let abortSignal: AbortSignal | undefined;
let raceWithAbort: typeof import('@n8n/agents').raceWithAbort | undefined;
let workspace:
| Awaited<ReturnType<typeof import('@n8n/instance-ai')['createWorkspace']>>
| undefined;
try {
const config = await this.resolveSandboxConfig(user, request);
abortSignal = AbortSignal.timeout(config.timeout ?? VERIFICATION_TIMEOUT_MS);
const [instanceAi, agents] = await Promise.all([
import('@n8n/instance-ai'),
import('@n8n/agents'),
]);
const { createSandbox, createWorkspace } = instanceAi;
raceWithAbort = agents.raceWithAbort;
const startedAt = performance.now();
const sandbox = await raceWithAbort(async () => await createSandbox(config), abortSignal);
if (!sandbox) throw new Error('Sandbox did not start');
const activeWorkspace = createWorkspace(sandbox);
if (!activeWorkspace) throw new Error('Sandbox workspace did not start');
workspace = activeWorkspace;
await raceWithAbort(async () => await activeWorkspace.init(), abortSignal);
const startupMs = Math.round(performance.now() - startedAt);
const result = await raceWithAbort(
async () =>
await activeWorkspace.sandbox?.executeCommand?.('printf', ['ok'], { abortSignal }),
abortSignal,
);
if (!result || result.exitCode !== 0) throw new Error('Sandbox command failed');
return { ok: true, startupMs };
} catch (error) {
const classifiedFailure = classifyFailure(error);
const failure =
provider === 'daytona' && classifiedFailure === 'forbidden'
? 'quota_exceeded'
: classifiedFailure;
this.logVerificationFailure('sandbox', failure, error, { provider });
return {
ok: false,
failure,
};
} finally {
if (workspace) {
const cleanup = workspace.destroy().catch((error: unknown) => {
this.logger.warn('Instance AI sandbox verification cleanup failed', {
error: ensureError(error).message,
provider,
});
});
if (!abortSignal || abortSignal.aborted || !raceWithAbort) {
void cleanup;
} else {
await raceWithAbort(cleanup, abortSignal).catch(() => {});
}
}
}
}
async verifySearch(
request: InstanceAiVerifySearchRequest,
): Promise<InstanceAiVerificationResponse> {
try {
const connection = request.connection
? await this.settingsService.resolveSearchConnectionForVerification(request.connection)
: undefined;
const saved = connection ? undefined : await this.settingsService.resolveSearchConfig();
const braveApiKey = connectionString(connection, 'apiKey') ?? saved?.braveApiKey;
const searxngUrl = connectionString(connection, 'apiUrl') ?? saved?.searxngUrl;
const { braveSearch, searxngSearch } = await import('@n8n/ai-utilities');
const options = {
maxResults: 10,
abortSignal: AbortSignal.timeout(VERIFICATION_TIMEOUT_MS),
};
const result = braveApiKey
? await braveSearch(braveApiKey, 'n8n workflow automation', options)
: searxngUrl
? await searxngSearch(searxngUrl, 'n8n workflow automation', options)
: undefined;
if (!result) throw new Error('Search provider is not configured');
return { ok: true, resultCount: result.results.length };
} catch (error) {
const failure = classifyFailure(error);
this.logVerificationFailure('search', failure, error);
return { ok: false, failure };
}
}
private logVerificationFailure(
kind: 'model' | 'sandbox' | 'search',
failure: InstanceAiVerificationFailure,
error: unknown,
context: Record<string, unknown> = {},
): void {
this.logger.warn(`Instance AI ${kind} verification failed`, {
...context,
error: ensureError(error).message,
failure,
});
}
private async resolveSandboxConfig(
_user: User,
request: InstanceAiVerifySandboxRequest,
): Promise<SandboxConfig> {
const instanceAi = this.globalConfig.instanceAi;
const provider = request.provider ?? instanceAi.sandboxProvider;
const connection = request.connection
? await this.settingsService.resolveSandboxConnectionForVerification(request.connection)
: undefined;
if (provider === 'daytona') {
const saved = connection ? undefined : await this.settingsService.resolveDaytonaConfig();
return {
enabled: true,
provider: 'daytona',
daytonaApiUrl:
connectionString(connection, 'apiUrl') ?? saved?.apiUrl ?? instanceAi.daytonaApiUrl,
daytonaApiKey:
connectionString(connection, 'apiKey') ?? saved?.apiKey ?? instanceAi.daytonaApiKey,
image: instanceAi.sandboxImage,
timeout: instanceAi.sandboxTimeout,
ephemeral: true,
};
}
const saved = connection ? undefined : await this.settingsService.resolveN8nSandboxConfig();
return {
enabled: true,
provider: 'n8n-sandbox',
serviceUrl: request.serviceUrl ?? saved?.serviceUrl ?? instanceAi.n8nSandboxServiceUrl,
apiKey:
connectionString(connection, 'value') ??
saved?.apiKey ??
instanceAi.n8nSandboxServiceApiKey,
timeout: instanceAi.sandboxTimeout,
};
}
}
@@ -12,6 +12,9 @@ import {
InstanceAiEnsureThreadRequest,
InstanceAiThreadMessagesQuery,
InstanceAiAdminSettingsUpdateRequest,
InstanceAiVerifyModelRequest,
InstanceAiVerifySandboxRequest,
InstanceAiVerifySearchRequest,
InstanceAiUserPreferencesUpdateRequest,
InstanceAiEvalExecutionRequest,
InstanceAiEvalAgentExecutionRequest,
@@ -28,6 +31,7 @@ import type {
import { ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import {
RestController,
GlobalScope,
@@ -63,6 +67,7 @@ import { InstanceAiErrorReporterService } from './instance-ai-error-reporter.ser
import { InstanceAiGatewayService } from './instance-ai-gateway.service';
import { InstanceAiMemoryService } from './instance-ai-memory.service';
import { InstanceAiSettingsService } from './instance-ai-settings.service';
import { InstanceAiVerificationService } from './instance-ai-verification.service';
import { InstanceAiService } from './instance-ai.service';
import { CredentialsService } from '@/credentials/credentials.service';
@@ -699,6 +704,36 @@ export class InstanceAiController {
return result;
}
@Post('/settings/verify/model')
@GlobalScope('instanceAi:manage')
async verifyModel(
req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiVerifyModelRequest,
) {
return await Container.get(InstanceAiVerificationService).verifyModel(req.user, payload);
}
@Post('/settings/verify/sandbox')
@GlobalScope('instanceAi:manage')
async verifySandbox(
req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiVerifySandboxRequest,
) {
return await Container.get(InstanceAiVerificationService).verifySandbox(req.user, payload);
}
@Post('/settings/verify/search')
@GlobalScope('instanceAi:manage')
async verifySearch(
_req: AuthenticatedRequest,
_res: Response,
@Body payload: InstanceAiVerifySearchRequest,
) {
return await Container.get(InstanceAiVerificationService).verifySearch(payload);
}
@OnPubSubEvent('reload-instance-ai-settings', { instanceType: 'main' })
async reloadAdminSettings() {
await this.settingsService.reloadFromDb();
@@ -71,12 +71,14 @@ export class InstanceAiModule implements ModuleInterface {
const localGatewayDisabled = settingsService.isLocalGatewayDisabled();
const browserUseEnabled = settingsService.isBrowserUseEnabled();
const sandboxStatus = settingsService.getSandboxStatus();
const setupCompleted = await settingsService.isSetupCompleted();
return {
enabled,
localGatewayDisabled,
browserUseEnabled,
proxyEnabled: service.isProxyEnabled(),
cloudManaged: globalConfig.deployment.type === 'cloud',
setupCompleted,
sandboxEnabled: sandboxStatus.enabled,
workflowBuilderAvailable: enabled && sandboxStatus.workflowBuilderAvailable,
sandboxUnavailableReason: sandboxStatus.unavailableReason,
@@ -5116,6 +5116,92 @@
"settings.n8nConnect.wallet.quota": "budget",
"settings.n8nConnect.wallet.topUp": "Top up balance",
"settings.n8nConnect.usage.refresh.tooltip": "Refresh usage records",
"instanceAi.onboarding.title": "AI Assistant",
"instanceAi.onboarding.benefit.build": "Build and edit workflows through conversation",
"instanceAi.onboarding.benefit.debug": "Debug failed executions and suggest fixes",
"instanceAi.onboarding.benefit.help": "Ask anything about n8n and get contextual help",
"instanceAi.onboarding.incomplete.lede": "Build and edit workflows through conversation, troubleshoot failed executions, and get contextual help.",
"instanceAi.onboarding.setUp": "Set up",
"instanceAi.onboarding.connectModel": "Connect a model",
"instanceAi.onboarding.finishSetup": "Finish setup",
"instanceAi.onboarding.learnMore": "Learn more",
"instanceAi.onboarding.notSet": "Not set",
"instanceAi.onboarding.disabled": "Disabled",
"instanceAi.onboarding.foundOnServer": "Found in server configuration",
"instanceAi.onboarding.recommended": "Recommended",
"instanceAi.onboarding.existingConnection.label": "Connection",
"instanceAi.onboarding.existingConnection.current": "Current connection",
"instanceAi.onboarding.existingConnection.new": "Set up a new connection",
"instanceAi.onboarding.env.title": "These settings are managed by environment variables.",
"instanceAi.onboarding.env.description": "To edit them here, remove the environment variables and restart n8n.",
"instanceAi.onboarding.env.docs": "View configuration docs",
"instanceAi.onboarding.model.label": "Model",
"instanceAi.onboarding.model.description": "The model the Assistant thinks with.",
"instanceAi.onboarding.model.title": "Connect a model",
"instanceAi.onboarding.model.lede": "The Assistant runs on a model you pay for directly. Your prompts, workflows, and the execution data it reads go to this provider.",
"instanceAi.onboarding.model.provider": "Provider",
"instanceAi.onboarding.model.customProvider": "Self-hosted or OpenAI-compatible endpoint",
"instanceAi.onboarding.model.baseUrl": "Base URL",
"instanceAi.onboarding.model.apiKey": "API key",
"instanceAi.onboarding.model.model": "Model",
"instanceAi.onboarding.model.anthropicHint": "Opus gives the best results for complex workflows. Sonnet is faster and costs less.",
"instanceAi.onboarding.model.weakModelWarning": "Local and smaller open models can run the Assistant, but they often produce workflows that don't execute. Expect worse results than with a frontier model.",
"instanceAi.onboarding.model.success": "The credential works. The model responded in {latency} ms.",
"instanceAi.onboarding.sandbox.label": "Code sandbox",
"instanceAi.onboarding.sandbox.description": "Where the Assistant runs the code it writes.",
"instanceAi.onboarding.sandbox.title": "Add a code sandbox",
"instanceAi.onboarding.sandbox.lede": "The Assistant runs code to build and test what you ask for. It never runs on the host running n8n, so choose where the sandbox comes from.",
"instanceAi.onboarding.sandbox.freeRecommended": "Free · Recommended",
"instanceAi.onboarding.sandbox.paid": "Paid",
"instanceAi.onboarding.sandbox.n8nDescription": "Runs on your own Docker host. Nothing leaves your infrastructure.",
"instanceAi.onboarding.sandbox.daytonaDescription": "Hosted and maintained for you, set up with one API key. Your code and workflow data leave your network.",
"instanceAi.onboarding.sandbox.installDescription": "Run the sandbox service on a Docker host your n8n instance can reach, then enter its URL and key.",
"instanceAi.onboarding.sandbox.installLink": "Install instructions",
"instanceAi.onboarding.sandbox.serviceUrl": "Service URL",
"instanceAi.onboarding.sandbox.apiKey": "API key",
"instanceAi.onboarding.sandbox.apiKeyPlaceholder": "Paste the key you set on the service",
"instanceAi.onboarding.sandbox.daytonaKey": "Create a key in the",
"instanceAi.onboarding.sandbox.daytonaDashboard": "Daytona dashboard",
"instanceAi.onboarding.sandbox.success": "The sandbox works. It started in {seconds} s and ran a command successfully.",
"instanceAi.onboarding.search.label": "Web search",
"instanceAi.onboarding.search.description": "Lets the Assistant read docs and API changes.",
"instanceAi.onboarding.search.title": "Add web search",
"instanceAi.onboarding.search.lede": "Web search lets the AI Assistant read external docs and APIs instead of relying on what its model remembers.",
"instanceAi.onboarding.search.free": "Free · Recommended",
"instanceAi.onboarding.search.disable": "Disable web search",
"instanceAi.onboarding.search.searxngDescription": "Free, open-source search you host yourself. No API key required.",
"instanceAi.onboarding.search.braveDescription": "Hosted by Brave, set up with one API key. The free tier covers light use.",
"instanceAi.onboarding.search.disabledDescription": "The AI Assistant cant search external documentation or APIs. You can enable web search later in Settings.",
"instanceAi.onboarding.search.apiKey": "API key",
"instanceAi.onboarding.search.instanceUrl": "Instance URL",
"instanceAi.onboarding.search.installDescription": "Follow the",
"instanceAi.onboarding.search.installLink": "setup instructions",
"instanceAi.onboarding.search.searxngInstallSuffix": "then enter the instance URL below.",
"instanceAi.onboarding.search.braveKeyDescription": "Generate an API key in the",
"instanceAi.onboarding.search.braveKeyLink": "Brave Search API dashboard",
"instanceAi.onboarding.search.braveKeySuffix": "then enter it below.",
"instanceAi.onboarding.search.success": "Search works. The test query came back with {count} results.",
"instanceAi.onboarding.wizard.ariaLabel": "Set up AI Assistant",
"instanceAi.onboarding.wizard.testing": "Testing…",
"instanceAi.onboarding.wizard.continue": "Continue",
"instanceAi.onboarding.wizard.apply": "Apply",
"instanceAi.onboarding.wizard.startUsing": "Start using AI Assistant",
"instanceAi.onboarding.done.title": "AI Assistant is on for everyone on this instance",
"instanceAi.onboarding.done.footnote": "You can update these options later in Settings under AI Assistant.",
"instanceAi.onboarding.verification.unauthorized": "The provider rejected the credential. Check the API key and try again.",
"instanceAi.onboarding.verification.forbidden": "The provider refused the test. Check that the credential has the required permissions and try again.",
"instanceAi.onboarding.verification.timeout": "The service didn't respond in time. Check that it's reachable and try again.",
"instanceAi.onboarding.verification.rate_limited": "The provider's rate limit was reached. Wait a minute and try again.",
"instanceAi.onboarding.verification.quota_exceeded": "The provider quota is exhausted. Add capacity or use another account, then try again.",
"instanceAi.onboarding.verification.unreachable": "The service couldn't be reached. Check the URL and network access, then try again.",
"instanceAi.onboarding.verification.invalid_response": "The service returned an unexpected response. Check that the URL points to the supported API and try again.",
"instanceAi.onboarding.verification.provider_error": "The service couldn't complete the test. Check its status and your settings, then try again.",
"instanceAi.onboarding.turnOff.action": "Turn off for this instance",
"instanceAi.onboarding.turnOff.title": "Turn off AI Assistant",
"instanceAi.onboarding.turnOff.description": "The Assistant disappears from the sidebar for everyone on this instance. You can enable AI Assistant at any time from Settings.",
"instanceAi.onboarding.turnOff.confirm": "Turn off AI Assistant",
"instanceAi.onboarding.turnOff.toastTitle": "AI Assistant turned off",
"instanceAi.onboarding.turnOff.toastDescription": "You can turn it back on at any time in Settings.",
"settings.n8nAgent": "AI Assistant",
"settings.n8nAgent.description": "Control how the AI Assistant runs on this instance and what data it can use.",
"settings.n8nAgent.docsLabel": "AI Assistant docs",
@@ -5172,7 +5258,7 @@
"settings.n8nAgent.mcpAccess.description": "Anyone on this instance can connect servers from the MCP registry to the AI Assistant.",
"settings.n8nAgent.modelCredential.label": "Model",
"settings.n8nAgent.modelCredential.missing.description": "No model connected. The AI Assistant won't respond until you connect one.",
"settings.n8nAgent.modelCredential.env.description": "Configured via environment variables. Connect a provider to manage it here.",
"settings.n8nAgent.modelCredential.env.description": "Managed by environment variables. Remove them and restart n8n to change the model connection here.",
"settings.n8nAgent.modelCredential.env.value": "Environment configuration",
"settings.n8nAgent.modelCredential.add": "Connect",
"settings.n8nAgent.modelCredential.field": "Provider",
@@ -5193,9 +5279,10 @@
"settings.n8nAgent.sandbox.label": "Code sandbox",
"settings.n8nAgent.sandbox.set.description": "Runs and tests generated code, isolated from your instance.",
"settings.n8nAgent.sandbox.missing.description": "No sandbox set. The AI Assistant won't work until you add one.",
"settings.n8nAgent.sandbox.env.description": "Configured via environment variables. Connect a provider to manage it here.",
"settings.n8nAgent.sandbox.env.description": "Managed by environment variables. Remove them and restart n8n to change the sandbox here.",
"settings.n8nAgent.sandbox.env.value": "Environment configuration",
"settings.n8nAgent.sandbox.add": "Add sandbox",
"settings.n8nAgent.sandbox.enable": "Enable sandbox",
"settings.n8nAgent.sandboxDialog.title": "Code sandbox",
"settings.n8nAgent.sandboxDialog.description": "The AI Assistant runs and tests generated code in the sandbox, isolated from your instance.",
"settings.n8nAgent.sandboxCredential.apiKey": "API key",
@@ -5204,7 +5291,7 @@
"settings.n8nAgent.search.label": "Web search",
"settings.n8nAgent.search.recommended": "Recommended",
"settings.n8nAgent.search.description": "Lets the AI Assistant research approved websites.",
"settings.n8nAgent.search.env.description": "Set via environment variables. Remove them to manage web search here.",
"settings.n8nAgent.search.env.description": "Managed by environment variables. Remove them and restart n8n to change web search here.",
"settings.n8nAgent.search.env.value": "Environment configuration",
"settings.n8nAgent.search.setup": "Set up",
"settings.n8nAgent.searchDialog.title": "Set up web search",
@@ -38,11 +38,13 @@ export function useEditorContext() {
case 'instanceAi':
// Mirrors useInstanceAiAvailable() (the feature-layer gate) with
// app-layer primitives so this base composable imports no feature:
// the module is active, an admin hasn't disabled it, and the user
// may message Instance AI.
// the module is active, enabled, ready (or admin-fixable), and the
// user may message Instance AI.
return (
settings.isModuleActive('instance-ai') &&
settings.moduleSettings['instance-ai']?.enabled !== false &&
(settings.moduleSettings['instance-ai']?.setupCompleted === true ||
hasPermission(['rbac'], { rbac: { scope: 'instanceAi:manage' } })) &&
hasPermission(['rbac'], { rbac: { scope: 'instanceAi:message' } })
);
}
@@ -619,6 +619,7 @@ describe('useGlobalEntityCreation', () => {
describe('instance-ai module', () => {
const INSTANCE_AI_SETTINGS = {
enabled: true,
setupCompleted: true,
localGatewayDisabled: false,
browserUseEnabled: true,
proxyEnabled: false,
@@ -3,6 +3,7 @@ import { EnterpriseEditionFeature, VIEWS } from '@/app/constants';
import { AGENTS_MODULE_NAME } from '@/features/agents/constants';
import { instanceAiCreateAgentRoute } from '@/features/ai/instanceAi/createAgentRoute';
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability';
import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { sortByProperty } from '@n8n/utils/sort/sort-by-property';
@@ -18,7 +19,6 @@ import { VARIABLE_MODAL_KEY } from '@/features/settings/environments.ee/environm
import { PROJECT_DATA_TABLES } from '@/features/core/dataTable/constants';
import { getResourcePermissions } from '@n8n/permissions';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { hasPermission } from '@/app/utils/rbac/permissions';
import type { Scope } from '@n8n/permissions';
import type { RouteLocationRaw } from 'vue-router';
import { updatedIconSet, type IconName } from '@n8n/design-system/components/N8nIcon/icons';
@@ -93,12 +93,7 @@ export const useGlobalEntityCreation = () => {
const isAgentsModuleActive = computed(() => settingsStore.isModuleActive(AGENTS_MODULE_NAME));
const isInstanceAiAvailable = computed(
() =>
settingsStore.isModuleActive('instance-ai') &&
settingsStore.moduleSettings['instance-ai']?.enabled !== false &&
hasPermission(['rbac'], { rbac: { scope: 'instanceAi:message' } }),
);
const isInstanceAiAvailable = useInstanceAiAvailable();
const instanceAiThreadItem = computed<Item | null>(() =>
isInstanceAiAvailable.value
@@ -264,6 +264,7 @@ describe('router', () => {
// Drive the `/` route's beforeEnter directly with a captured `next` instead.
const instanceAiModuleSettings = {
enabled: true,
setupCompleted: true,
localGatewayDisabled: false,
browserUseEnabled: true,
proxyEnabled: false,
@@ -26,7 +26,10 @@ import { RESOURCE_CENTER_EXPERIMENT, TEMPLATE_SETUP_EXPERIENCE } from '@/app/con
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
import { canMessageInstanceAi } from '@/features/ai/instanceAi/instanceAiPermissions';
import {
canManageInstanceAi,
canMessageInstanceAi,
} from '@/features/ai/instanceAi/instanceAiPermissions';
const ChangePasswordView = async () =>
await import('@/features/core/auth/views/ChangePasswordView.vue');
@@ -175,9 +178,11 @@ export const routes: RouteRecordRaw[] = [
component: { render: () => null },
beforeEnter: (_to, _from, next) => {
const settingsStore = useSettingsStore();
const instanceAiSettings = settingsStore.moduleSettings['instance-ai'];
if (
settingsStore.isModuleActive('instance-ai') &&
settingsStore.moduleSettings['instance-ai']?.enabled !== false &&
instanceAiSettings?.enabled !== false &&
(instanceAiSettings?.setupCompleted === true || canManageInstanceAi()) &&
canMessageInstanceAi()
) {
return next({ name: INSTANCE_AI_VIEW });
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { onMounted, onUnmounted, provide, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue';
import { onBeforeRouteLeave, RouterView, useRoute, useRouter } from 'vue-router';
import { N8nResizeWrapper } from '@n8n/design-system';
import { useEventListener, useSessionStorage } from '@vueuse/core';
@@ -9,14 +9,17 @@ import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useUIStore } from '@/app/stores/ui.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useInstanceAiStore } from './instanceAi.store';
import { useInstanceAiSettingsStore } from './instanceAiSettings.store';
import InstanceAiThreadList from './components/InstanceAiThreadList.vue';
import { INSTANCE_AI_VIEW, isInstanceAiChatRoute } from './constants';
import { SidebarStateKey } from './instanceAiLayout';
import InstanceAiOnboardingView from './onboarding/InstanceAiOnboardingView.vue';
const store = useInstanceAiStore();
const settingsStore = useInstanceAiSettingsStore();
const appSettingsStore = useSettingsStore();
const i18n = useI18n();
const documentTitle = useDocumentTitle();
const route = useRoute();
@@ -25,6 +28,38 @@ const uiStore = useUIStore();
const rootStore = useRootStore();
const telemetry = useTelemetry();
const { isCtrlKeyPressed } = useDeviceSupport();
const setupCompletionState = computed(
() => appSettingsStore.moduleSettings['instance-ai']?.setupCompleted,
);
const setupWasIncomplete = setupCompletionState.value === false;
let setupWasObservedIncomplete = setupWasIncomplete;
const onboardingCompletionPending = useSessionStorage(
'instanceAi.onboarding.completionPending',
setupWasIncomplete,
);
if (setupCompletionState.value === true) onboardingCompletionPending.value = false;
else if (setupWasIncomplete) onboardingCompletionPending.value = true;
const onboardingActive = ref(
setupCompletionState.value !== true && (setupWasIncomplete || onboardingCompletionPending.value),
);
const showOnboarding = computed(
() =>
settingsStore.canManage &&
!settingsStore.isProxyEnabled &&
!settingsStore.isCloudManaged &&
onboardingActive.value,
);
watch(setupCompletionState, (setupCompleted) => {
if (setupCompleted === true) {
onboardingCompletionPending.value = false;
if (!setupWasObservedIncomplete) onboardingActive.value = false;
} else if (setupCompleted === false) {
setupWasObservedIncomplete = true;
onboardingCompletionPending.value = true;
onboardingActive.value = true;
}
});
documentTitle.set(i18n.baseText('instanceAi.view.title'));
@@ -47,6 +82,11 @@ function handleSidebarResize({ width }: { width: number }) {
sidebarWidth.value = width;
}
function handleOnboardingCompleted() {
onboardingCompletionPending.value = false;
onboardingActive.value = false;
}
provide(SidebarStateKey, {
collapsed: sidebarCollapsed,
width: sidebarWidth,
@@ -79,6 +119,9 @@ useEventListener(document, 'keydown', (event: KeyboardEvent) => {
// These run once when the user enters the InstanceAi feature. Route changes
// (empty ↔ thread) don't remount the layout, so the listeners persist.
onMounted(() => {
if (showOnboarding.value && route.name !== INSTANCE_AI_VIEW) {
void router.replace({ name: INSTANCE_AI_VIEW });
}
// In-app navigations expose the previous route via history state; direct
// visits (bookmark, external link) fall back to the document referrer.
const previousRoute = router.options.history.state.back;
@@ -146,27 +189,30 @@ onUnmounted(() => {
<template>
<div :class="$style.container" data-test-id="instance-ai-container">
<!-- Resizable sidebar -->
<Transition name="sidebar-slide">
<N8nResizeWrapper
v-if="!sidebarCollapsed"
:class="$style.sidebar"
:width="sidebarWidth"
:style="{ width: `${sidebarWidth}px` }"
:supported-directions="['right']"
:is-resizing-enabled="true"
:min-width="200"
:max-width="400"
@resize="handleSidebarResize"
>
<InstanceAiThreadList @collapse="toggleSidebarCollapse" />
</N8nResizeWrapper>
</Transition>
<InstanceAiOnboardingView v-if="showOnboarding" @completed="handleOnboardingCompleted" />
<template v-else>
<!-- Resizable sidebar -->
<Transition name="sidebar-slide">
<N8nResizeWrapper
v-if="!sidebarCollapsed"
:class="$style.sidebar"
:width="sidebarWidth"
:style="{ width: `${sidebarWidth}px` }"
:supported-directions="['right']"
:is-resizing-enabled="true"
:min-width="200"
:max-width="400"
@resize="handleSidebarResize"
>
<InstanceAiThreadList @collapse="toggleSidebarCollapse" />
</N8nResizeWrapper>
</Transition>
<!-- Inner route Empty for `/assistant`, Thread for `/assistant/:threadId` -->
<RouterView v-slot="{ Component }">
<component :is="Component" :key="String(route.params.threadId ?? 'empty')" />
</RouterView>
<!-- Inner route Empty for `/assistant`, Thread for `/assistant/:threadId` -->
<RouterView v-slot="{ Component }">
<component :is="Component" :key="String(route.params.threadId ?? 'empty')" />
</RouterView>
</template>
</div>
</template>
@@ -1,20 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { fireEvent, waitFor } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import InstanceAiView from '../InstanceAiView.vue';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import { INSTANCE_AI_VIEW } from '../constants';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { hasPermission } from '@/app/utils/rbac/permissions';
const TEST_INSTANCE_ID = 'test-instance-id';
const routerPush = vi.hoisted(() => vi.fn());
const routerReplace = vi.hoisted(() => vi.fn());
const routerHistoryState = vi.hoisted(() => ({ back: null as string | null }));
const routeState = vi.hoisted(() => ({ name: '', params: { threadId: 'thread-1' } }));
const telemetryTrack = vi.hoisted(() => vi.fn());
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal()),
useRoute: () => ({ params: { threadId: 'thread-1' } }),
useRouter: () => ({ push: routerPush, options: { history: { state: routerHistoryState } } }),
useRoute: () => routeState,
useRouter: () => ({
push: routerPush,
replace: routerReplace,
options: { history: { state: routerHistoryState } },
}),
onBeforeRouteLeave: vi.fn(),
RouterView: { template: '<div data-test-id="router-view-stub" />' },
}));
@@ -29,6 +38,10 @@ vi.mock('@n8n/composables/useDeviceSupport', () => ({
}),
}));
vi.mock('@/app/utils/rbac/permissions', () => ({
hasPermission: vi.fn().mockReturnValue(false),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ instanceId: TEST_INSTANCE_ID }),
}));
@@ -37,6 +50,10 @@ const renderView = createComponentRenderer(InstanceAiView, {
global: {
stubs: {
InstanceAiThreadList: { template: '<div data-test-id="thread-list-stub" />' },
InstanceAiOnboardingView: {
emits: ['completed'],
template: '<button data-test-id="onboarding-view-stub" @click="$emit(\'completed\')" />',
},
N8nResizeWrapper: { template: '<div><slot /></div>' },
},
},
@@ -50,9 +67,13 @@ describe('InstanceAiView', () => {
const settingsStore = useInstanceAiSettingsStore();
settingsStore.refreshModuleSettings = vi.fn().mockResolvedValue(undefined);
settingsStore.ensurePreferencesLoaded = vi.fn().mockResolvedValue(undefined);
vi.mocked(hasPermission).mockReturnValue(false);
routerPush.mockClear();
routerReplace.mockClear();
telemetryTrack.mockClear();
routerHistoryState.back = null;
routeState.name = INSTANCE_AI_VIEW;
sessionStorage.clear();
});
it('opens a new thread with Ctrl/Cmd+Shift+O', () => {
@@ -92,4 +113,85 @@ describe('InstanceAiView', () => {
source_url: null,
});
});
it('shows onboarding to self-managed admins and returns to chat after completion', async () => {
vi.mocked(hasPermission).mockReturnValue(true);
useSettingsStore().moduleSettings = {
'instance-ai': {
enabled: true,
localGatewayDisabled: false,
browserUseEnabled: true,
proxyEnabled: false,
cloudManaged: false,
setupCompleted: false,
sandboxEnabled: false,
workflowBuilderAvailable: false,
sandboxUnavailableReason: null,
runDebugEnabled: false,
},
};
routeState.name = 'InstanceAiThread';
const { findByTestId, getByTestId, queryByTestId } = renderView({ pinia });
expect(await findByTestId('onboarding-view-stub')).toBeVisible();
expect(routerReplace).toHaveBeenCalledWith({ name: INSTANCE_AI_VIEW });
expect(queryByTestId('router-view-stub')).toBeNull();
await fireEvent.click(getByTestId('onboarding-view-stub'));
expect(getByTestId('router-view-stub')).toBeVisible();
expect(sessionStorage.getItem('instanceAi.onboarding.completionPending')).toBe('false');
});
it('ignores a stale onboarding session after setup completes in Settings', () => {
vi.mocked(hasPermission).mockReturnValue(true);
sessionStorage.setItem('instanceAi.onboarding.completionPending', 'true');
useSettingsStore().moduleSettings = {
'instance-ai': {
enabled: true,
localGatewayDisabled: false,
browserUseEnabled: true,
proxyEnabled: false,
cloudManaged: false,
setupCompleted: true,
sandboxEnabled: true,
workflowBuilderAvailable: true,
sandboxUnavailableReason: null,
runDebugEnabled: false,
},
};
const { getByTestId, queryByTestId } = renderView({ pinia });
expect(queryByTestId('onboarding-view-stub')).toBeNull();
expect(getByTestId('router-view-stub')).toBeVisible();
expect(sessionStorage.getItem('instanceAi.onboarding.completionPending')).toBe('false');
});
it('keeps the active wizard open when its final save completes setup', async () => {
vi.mocked(hasPermission).mockReturnValue(true);
const appSettingsStore = useSettingsStore();
appSettingsStore.moduleSettings = {
'instance-ai': {
enabled: true,
localGatewayDisabled: false,
browserUseEnabled: true,
proxyEnabled: false,
cloudManaged: false,
setupCompleted: false,
sandboxEnabled: true,
workflowBuilderAvailable: true,
sandboxUnavailableReason: null,
runDebugEnabled: false,
},
};
const { getByTestId, queryByTestId } = renderView({ pinia });
appSettingsStore.moduleSettings = {
'instance-ai': { ...appSettingsStore.moduleSettings['instance-ai']!, setupCompleted: true },
};
await waitFor(() => expect(getByTestId('onboarding-view-stub')).toBeVisible());
expect(queryByTestId('router-view-stub')).toBeNull();
});
});
@@ -36,6 +36,9 @@ const mockFetchPreferences = vi.fn();
const mockUpdatePreferences = vi.fn();
const mockFetchServiceCredentials = vi.fn().mockResolvedValue([]);
const mockFetchInstanceModelCredentials = vi.fn().mockResolvedValue([]);
const mockVerifyModel = vi.fn();
const mockVerifySandbox = vi.fn();
const mockVerifySearch = vi.fn();
const mockCreateGatewayLink = vi.fn();
const mockDisconnectGatewaySession = vi.fn();
@@ -46,6 +49,9 @@ vi.mock('../instanceAi.settings.api', () => ({
updatePreferences: (...args: unknown[]) => mockUpdatePreferences(...args),
fetchServiceCredentials: (...args: unknown[]) => mockFetchServiceCredentials(...args),
fetchInstanceModelCredentials: (...args: unknown[]) => mockFetchInstanceModelCredentials(...args),
verifyModel: (...args: unknown[]) => mockVerifyModel(...args),
verifySandbox: (...args: unknown[]) => mockVerifySandbox(...args),
verifySearch: (...args: unknown[]) => mockVerifySearch(...args),
}));
const mockGetGatewayStatus = vi.fn();
@@ -375,6 +381,42 @@ describe('useInstanceAiSettingsStore', () => {
});
});
describe('onboarding verification', () => {
it('delegates model, sandbox, and search checks to the settings API', async () => {
mockVerifyModel.mockResolvedValue({ ok: true, latencyMs: 10 });
mockVerifySandbox.mockResolvedValue({ ok: true, startupMs: 20 });
mockVerifySearch.mockResolvedValue({ ok: true, resultCount: 10 });
const modelPayload = { modelName: 'gpt-5.6-sol' };
const sandboxPayload = { provider: 'n8n-sandbox' as const };
const searchPayload = {
connection: { type: 'braveSearchApi', data: { apiKey: 'key' } },
};
await expect(store.verifyModel(modelPayload)).resolves.toEqual({ ok: true, latencyMs: 10 });
await expect(store.verifySandbox(sandboxPayload)).resolves.toEqual({
ok: true,
startupMs: 20,
});
await expect(store.verifySearch(searchPayload)).resolves.toEqual({
ok: true,
resultCount: 10,
});
expect(mockVerifyModel).toHaveBeenCalledWith(
{ baseUrl: 'http://localhost:5678/rest' },
modelPayload,
);
expect(mockVerifySandbox).toHaveBeenCalledWith(
{ baseUrl: 'http://localhost:5678/rest' },
sandboxPayload,
);
expect(mockVerifySearch).toHaveBeenCalledWith(
{ baseUrl: 'http://localhost:5678/rest' },
searchPayload,
);
});
});
describe('provider credentials', () => {
it('refreshes n8n Sandbox credentials when the assistant proxy is enabled', async () => {
setModuleSettings(settingsStore, { proxyEnabled: true, cloudManaged: false });
@@ -1,7 +1,12 @@
import { ref } from 'vue';
import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router';
import { InstanceAiModule } from '../module.descriptor';
import { INSTANCE_AI_VIEW, INSTANCE_AI_THREAD_VIEW, INSTANCE_AI_SETTINGS_VIEW } from '../constants';
vi.mock('../composables/useInstanceAiAvailability', () => ({
useInstanceAiAvailable: () => ref(true),
}));
const stub = { render: () => null };
// Swap real lazy components for a stub so navigation doesn't pull the view tree.
@@ -1,41 +1,7 @@
<script setup lang="ts">
import { computed, ref, toRaw, watch } from 'vue';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import {
INSTANCE_AI_MODEL_CREDENTIAL_TYPES,
INSTANCE_AI_SEARCH_CREDENTIAL_TYPES,
type InstanceAiProviderConnection,
} from '@n8n/api-types';
import {
N8nButton,
N8nDialog,
N8nDialogDescription,
N8nDialogFooter,
N8nDialogHeader,
N8nDialogTitle,
N8nInput,
N8nInputLabel,
N8nOption,
N8nSelect,
N8nText,
} from '@n8n/design-system';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import type { IUpdateInformation } from '@/Interface';
import Banner from '@/app/components/Banner.vue';
import { useLatestFetch } from '@/app/composables/useLatestFetch';
import { provideWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { SANDBOX_PROVIDER_LABELS, type InstanceAiConnectionKind } from '../../constants';
import { useInstanceAiSetupSteps } from '../../composables/useInstanceAiSetupSteps';
import { useInstanceCredentialTest } from '../../composables/useInstanceCredentialTest';
import { useInstanceAiSettingsStore } from '../../instanceAiSettings.store';
import ConnectionFields from './ConnectionFields.vue';
const DAYTONA_DEFAULT_API_URL = 'https://app.daytona.io/api';
const N8N_SANDBOX_HEADER = 'x-api-key';
const DEFAULT_SEARCH_TYPE = 'searXngApi';
const SANDBOX_CREDENTIAL_TYPES = ['daytonaApi', 'httpHeaderAuth'];
const SETUP_STEP: Record<InstanceAiConnectionKind, number> = { model: 1, sandbox: 2, search: 3 };
import type { InstanceAiConnectionKind } from '../../constants';
import InstanceAiOnboardingWizard from '../../onboarding/InstanceAiOnboardingWizard.vue';
import type { InstanceAiOnboardingStep } from '../../onboarding/useInstanceAiOnboarding';
const open = defineModel<boolean>('open', { required: true });
@@ -43,663 +9,29 @@ const props = withDefaults(defineProps<{ kind: InstanceAiConnectionKind; setup?:
setup: false,
});
const emit = defineEmits<{ saved: []; back: [] }>();
const emit = defineEmits<{ saved: [] }>();
const i18n = useI18n();
const store = useInstanceAiSettingsStore();
const credentialsStore = useCredentialsStore();
const readOnly = computed(() => !store.canManageInstanceCredentials);
const {
credentialTestError,
isTestingCredential,
testCredential,
testSavedCredential,
restoreStoredError,
} = useInstanceCredentialTest();
const { stepLabel, isLastStep } = useInstanceAiSetupSteps(SETUP_STEP[props.kind]);
const setupSequence: InstanceAiOnboardingStep[] = ['model', 'sandbox', 'search', 'done'];
provideWorkflowDocumentStore();
/** For 'sandbox' the selection is a provider ('daytona' | 'n8n-sandbox'); otherwise a credential type. */
const selection = ref('');
const selectedCredentialId = ref('');
const selectingExistingCredential = ref(false);
const fieldsData = ref<ICredentialDataDecryptedObject>({});
/** The one extra input a kind may have: the model name (model) or the API key (n8n sandbox). */
const extraValue = ref('');
const isLoading = ref(false);
const { next: nextHydration } = useLatestFetch();
let hydratedSelection = '';
let hydratedData: ICredentialDataDecryptedObject = {};
let hydratedExtra = '';
let hydratedSnapshot = '';
const usingExisting = computed(() => readOnly.value || selectingExistingCredential.value);
const isProxyDaytonaSelection = computed(
() =>
props.kind === 'sandbox' &&
store.isProxyEnabled &&
selection.value === 'daytona' &&
!usingExisting.value,
);
function credentialTypeLabel(type: string) {
return credentialsStore.getCredentialTypeByName(type)?.displayName ?? type;
}
interface DialogCopy {
idPrefix: string;
titleKey: BaseTextKey;
setupTitleKey: BaseTextKey;
descriptionKey: BaseTextKey;
setupDescriptionKey?: BaseTextKey;
fieldLabelKey: BaseTextKey;
placeholderKey?: BaseTextKey;
providerHintKey?: BaseTextKey;
footnoteKey?: BaseTextKey;
testName: string;
}
const DIALOG_COPY: Record<InstanceAiConnectionKind, DialogCopy> = {
model: {
idPrefix: 'n8n-agent-model',
titleKey: 'settings.n8nAgent.modelDialog.title',
setupTitleKey: 'settings.n8nAgent.modelDialog.setupTitle',
descriptionKey: 'settings.n8nAgent.modelDialog.description',
setupDescriptionKey: 'settings.n8nAgent.modelDialog.setupDescription',
fieldLabelKey: 'settings.n8nAgent.modelCredential.field',
placeholderKey: 'settings.n8nAgent.modelCredential.placeholder',
footnoteKey: 'settings.n8nAgent.modelDialog.footnote',
testName: 'AI Assistant model',
},
sandbox: {
idPrefix: 'n8n-agent-sandbox',
titleKey: 'settings.n8nAgent.sandboxDialog.title',
setupTitleKey: 'settings.n8nAgent.sandboxDialog.setupTitle',
descriptionKey: 'settings.n8nAgent.sandboxDialog.description',
fieldLabelKey: 'settings.n8nAgent.sandboxDialog.provider',
providerHintKey: 'settings.n8nAgent.sandboxDialog.providerHint',
testName: 'AI Assistant sandbox',
},
search: {
idPrefix: 'n8n-agent-search',
titleKey: 'settings.n8nAgent.searchDialog.title',
setupTitleKey: 'settings.n8nAgent.searchDialog.setupTitle',
descriptionKey: 'settings.n8nAgent.searchDialog.description',
fieldLabelKey: 'settings.n8nAgent.searchCredential.label',
placeholderKey: 'settings.n8nAgent.searchCredential.placeholder',
testName: 'AI Assistant web search',
},
};
const copy = DIALOG_COPY[props.kind];
function environmentConfigured(): boolean {
if (props.kind === 'model') return Boolean(store.settings?.modelEnvConfigured);
if (props.kind === 'sandbox') return Boolean(store.settings?.sandboxEnvConfigured);
return Boolean(store.settings?.searchEnvConfigured);
}
function getAssignedId(): string | null {
if (props.kind === 'model') return store.settings?.modelCredentialId ?? null;
if (props.kind === 'sandbox') {
return store.settings?.sandboxProvider === 'daytona'
? (store.settings?.daytonaCredentialId ?? null)
: (store.settings?.n8nSandboxCredentialId ?? null);
}
return store.settings?.searchCredentialId ?? null;
}
function getAssignedSelection(): string {
if (props.kind === 'sandbox') {
return getAssignedId() ? (store.settings?.sandboxProvider ?? '') : '';
}
const credentials =
props.kind === 'model' ? store.instanceModelCredentials : store.serviceCredentials;
return credentials.find(({ id }) => id === getAssignedId())?.type ?? '';
}
function getDefaultSelection(): string {
if (props.kind === 'model') return '';
if (props.kind === 'sandbox') {
return store.settings?.sandboxEnvConfigured ? '' : (store.settings?.sandboxProvider ?? '');
}
return store.settings?.searchEnvConfigured ? '' : DEFAULT_SEARCH_TYPE;
}
function getProviderOptions(): Array<{ value: string; label: string }> {
if (props.kind === 'sandbox') {
return [
{ value: 'daytona', label: SANDBOX_PROVIDER_LABELS.daytona },
{ value: 'n8n-sandbox', label: SANDBOX_PROVIDER_LABELS['n8n-sandbox'] },
];
}
const credentialTypes =
props.kind === 'model'
? INSTANCE_AI_MODEL_CREDENTIAL_TYPES
: INSTANCE_AI_SEARCH_CREDENTIAL_TYPES;
return credentialTypes.map((type) => ({ value: type, label: credentialTypeLabel(type) }));
}
function getExistingCredentials(): InstanceAiProviderConnection[] {
if (props.kind === 'model') return store.instanceModelCredentials;
const allowedTypes =
props.kind === 'sandbox' ? SANDBOX_CREDENTIAL_TYPES : INSTANCE_AI_SEARCH_CREDENTIAL_TYPES;
return store.serviceCredentials.filter(({ type }) =>
allowedTypes.some((allowed) => allowed === type),
);
}
function existingCredentialLabel(credential: InstanceAiProviderConnection): string {
const detail =
props.kind === 'sandbox'
? SANDBOX_PROVIDER_LABELS[credential.type === 'daytonaApi' ? 'daytona' : 'n8n-sandbox']
: credentialTypeLabel(credential.type);
return `${credential.name} · ${detail}`;
}
function selectionForCredential(credential: InstanceAiProviderConnection): string {
if (props.kind !== 'sandbox') return credential.type;
return credential.type === 'daytonaApi' ? 'daytona' : 'n8n-sandbox';
}
function credentialTypeFor(selected: string): string {
if (props.kind !== 'sandbox') return selected;
return selected === 'daytona' ? 'daytonaApi' : 'httpHeaderAuth';
}
function seedData(selected: string): ICredentialDataDecryptedObject {
return props.kind === 'sandbox' && selected === 'daytona'
? { apiUrl: DAYTONA_DEFAULT_API_URL }
: {};
}
function applyLoadedData(data: ICredentialDataDecryptedObject): void {
if (props.kind === 'sandbox' && selection.value === 'n8n-sandbox') {
extraValue.value = typeof data.value === 'string' ? data.value : '';
return;
}
fieldsData.value = data;
}
function newConnectionIsComplete(selected: string): boolean {
if (props.kind === 'model') return true;
if (props.kind === 'sandbox') {
if (selected === 'n8n-sandbox') return extraValue.value.trim().length > 0;
if (isProxyDaytonaSelection.value) return true;
return (
typeof fieldsData.value.apiUrl === 'string' &&
fieldsData.value.apiUrl.trim().length > 0 &&
typeof fieldsData.value.apiKey === 'string' &&
fieldsData.value.apiKey.trim().length > 0
);
}
const field = selected === 'braveSearchApi' ? 'apiKey' : 'apiUrl';
const value = fieldsData.value[field];
return typeof value === 'string' && value.trim().length > 0;
}
function buildConnectionData(): ICredentialDataDecryptedObject {
if (props.kind === 'sandbox' && selection.value === 'n8n-sandbox') {
return { name: N8N_SANDBOX_HEADER, value: extraValue.value.trim() };
}
return { ...toRaw(fieldsData.value) };
}
function stageExisting(): void {
if (props.kind === 'model') {
store.setField('modelCredentialId', selectedCredentialId.value || null);
store.setField('modelName', selectedCredentialId.value ? extraValue.value.trim() : undefined);
return;
}
if (props.kind === 'search') {
store.setField('searchCredentialId', selectedCredentialId.value || null);
return;
}
store.setField(
'daytonaCredentialId',
selection.value === 'daytona' ? selectedCredentialId.value : null,
);
store.setField(
'n8nSandboxCredentialId',
selection.value === 'n8n-sandbox' ? selectedCredentialId.value : null,
);
if (selection.value === 'daytona' || selection.value === 'n8n-sandbox') {
store.setField('sandboxProvider', selection.value);
}
}
function stageNew(connectionData: ICredentialDataDecryptedObject): void {
if (props.kind === 'model') {
store.setField('modelConnection', { type: selection.value, data: connectionData });
store.setField('modelName', extraValue.value.trim());
return;
}
if (props.kind === 'sandbox') {
if (isProxyDaytonaSelection.value) {
store.setField('sandboxProvider', 'daytona');
return;
}
store.setField('sandboxConnection', {
type: credentialTypeFor(selection.value),
data: connectionData,
});
return;
}
store.setField('searchConnection', { type: selection.value, data: connectionData });
}
function stageClear(): void {
if (props.kind === 'model') {
store.setField('modelConnection', null);
store.setField('modelName', undefined);
} else if (props.kind === 'sandbox') {
store.setField('sandboxConnection', null);
} else {
store.setField('searchConnection', null);
}
}
async function refreshCredentials(): Promise<void> {
isLoading.value = true;
try {
if (props.kind === 'model') await store.refreshInstanceModelCredentials();
else await store.refreshCredentials();
} finally {
isLoading.value = false;
}
}
const assignedId = computed(getAssignedId);
const hasSelection = computed(() =>
usingExisting.value ? selectedCredentialId.value : selection.value,
);
const providerOptions = computed(getProviderOptions);
const existingOptions = computed(getExistingCredentials);
const noneLabel = computed(() =>
environmentConfigured()
? i18n.baseText('settings.n8nAgent.connection.none')
: i18n.baseText('settings.n8nAgent.connection.noneNoEnv'),
);
function snapshot() {
return JSON.stringify({
c: usingExisting.value ? selectedCredentialId.value : '',
e: selectingExistingCredential.value,
s: selection.value,
d: fieldsData.value,
x: extraValue.value,
});
}
async function hydrate() {
const isCurrent = nextHydration();
const credentialId = assignedId.value;
extraValue.value = props.kind === 'model' ? (store.settings?.modelName ?? '') : '';
selectedCredentialId.value = credentialId ?? '';
selectingExistingCredential.value = false;
selection.value = readOnly.value
? getAssignedSelection()
: getAssignedSelection() || getDefaultSelection();
fieldsData.value = seedData(selection.value);
isLoading.value = false;
if (credentialId && !readOnly.value) {
isLoading.value = true;
try {
const credential = await credentialsStore.getCredentialData({ id: credentialId });
if (!isCurrent()) return;
const data = (
credential && 'data' in credential ? (credential.data ?? {}) : {}
) as ICredentialDataDecryptedObject;
applyLoadedData(data);
} catch {
if (!isCurrent()) return;
fieldsData.value = seedData(selection.value);
} finally {
if (isCurrent()) isLoading.value = false;
}
}
if (!isCurrent()) return;
hydratedSelection = selection.value;
hydratedData = { ...fieldsData.value };
hydratedExtra = extraValue.value;
hydratedSnapshot = snapshot();
restoreStoredError(credentialId);
}
watch(
open,
async (isOpen) => {
if (isOpen) await hydrate();
},
{ immediate: true },
);
function selectOption(next: string) {
const existingCredential = existingOptions.value.find(({ id }) => id === next);
if (existingCredential) {
credentialTestError.value = '';
selectingExistingCredential.value = true;
selectedCredentialId.value = existingCredential.id;
selection.value = selectionForCredential(existingCredential);
fieldsData.value = {};
extraValue.value = existingCredential.id === assignedId.value ? hydratedExtra : '';
return;
}
const changedMode = selectingExistingCredential.value;
if (next === selection.value && !changedMode) return;
credentialTestError.value = '';
selectingExistingCredential.value = false;
selectedCredentialId.value = '';
selection.value = next;
// Switching starts from a clean slate; only the hydrated selection keeps its values.
fieldsData.value = next === hydratedSelection ? { ...hydratedData } : seedData(next);
extraValue.value = next === hydratedSelection ? hydratedExtra : '';
}
function selectCredential(nextCredentialId: string) {
if (nextCredentialId === selectedCredentialId.value) return;
credentialTestError.value = '';
selectedCredentialId.value = nextCredentialId;
const credential = existingOptions.value.find(({ id }) => id === nextCredentialId);
selection.value = credential ? selectionForCredential(credential) : '';
extraValue.value = nextCredentialId === assignedId.value ? hydratedExtra : '';
}
function setFieldValue(name: string, value: IUpdateInformation['value']) {
fieldsData.value = { ...fieldsData.value, [name]: value } as ICredentialDataDecryptedObject;
}
const isComplete = computed(() => {
if (!hasSelection.value) return true;
if (props.kind === 'model' && extraValue.value.trim().length === 0) return false;
if (usingExisting.value) return true;
return newConnectionIsComplete(selection.value);
});
const isChanged = computed(() => snapshot() !== hydratedSnapshot);
const isBusy = computed(() => store.isSaving || isTestingCredential.value || isLoading.value);
const primaryDisabled = computed(() => {
if (isBusy.value || !isComplete.value) return true;
if (props.setup) return !isChanged.value && !hasSelection.value;
return !isChanged.value && !credentialTestError.value;
});
async function handlePrimary() {
const connectionData = buildConnectionData();
if (
!usingExisting.value &&
selection.value &&
!isProxyDaytonaSelection.value &&
!(await testCredential({
id: selection.value === getAssignedSelection() ? (assignedId.value ?? '') : '',
name: copy.testName,
type: credentialTypeFor(selection.value),
data: connectionData,
}))
)
return;
if (!open.value) return;
// Pre-test a selected existing connection instead of relying on backend validation alone.
// Read-only callers cannot fetch credential data, so the backend stays their safety net.
if (selectingExistingCredential.value && !readOnly.value && selectedCredentialId.value) {
const credential = existingOptions.value.find(({ id }) => id === selectedCredentialId.value);
if (credential && !(await testSavedCredential(credential.id, credential.name, credential.type)))
return;
if (!open.value) return;
}
if (isChanged.value) {
if (usingExisting.value) stageExisting();
else if (!selection.value) stageClear();
else stageNew(connectionData);
if (!(await store.save())) return;
}
await refreshCredentials();
if (!open.value) return;
// Emit before closing so the host can transition to the next dialog without an all-closed gap.
function handleAdvance(): void {
emit('saved');
open.value = false;
if (!props.setup) open.value = false;
}
function handleBack() {
if (isBusy.value) return;
emit('back');
open.value = false;
}
function handleOpenChange(value: boolean) {
if (!value && isBusy.value) return;
open.value = value;
}
function handleClose() {
handleOpenChange(false);
}
const title = computed(() => i18n.baseText(props.setup ? copy.setupTitleKey : copy.titleKey));
const description = computed(() =>
i18n.baseText(
props.setup && copy.setupDescriptionKey ? copy.setupDescriptionKey : copy.descriptionKey,
),
);
const showCancel = computed(() => !props.setup || props.kind === 'model');
const primaryLabel = computed(() => {
if (credentialTestError.value) return i18n.baseText('credentialEdit.credentialConfig.retry');
if (props.setup && (props.kind === 'model' || (props.kind === 'sandbox' && !isLastStep.value)))
return i18n.baseText('settings.n8nAgent.setup.continue');
return i18n.baseText('generic.save');
});
</script>
<template>
<N8nDialog
<InstanceAiOnboardingWizard
:open="open"
size="medium"
:show-close-button="!isBusy"
:data-test-id="`${copy.idPrefix}-dialog`"
@update:open="handleOpenChange"
>
<N8nDialogHeader>
<N8nText
v-if="setup"
:class="$style.step"
size="xsmall"
color="text-light"
bold
tag="p"
:data-test-id="`${copy.idPrefix}-dialog-step`"
>
{{ stepLabel }}
</N8nText>
<N8nDialogTitle>{{ title }}</N8nDialogTitle>
<N8nDialogDescription>{{ description }}</N8nDialogDescription>
</N8nDialogHeader>
<div :class="$style.fields">
<N8nInputLabel :label="i18n.baseText(copy.fieldLabelKey)">
<N8nSelect
v-if="readOnly"
:model-value="selectedCredentialId"
size="medium"
:disabled="isBusy"
:placeholder="copy.placeholderKey ? i18n.baseText(copy.placeholderKey) : undefined"
:data-test-id="`${copy.idPrefix}-provider-select`"
@update:model-value="selectCredential(String($event ?? ''))"
>
<N8nOption v-if="!setup" value="" :label="noneLabel" />
<N8nOption
v-for="credential in existingOptions"
:key="credential.id"
:value="credential.id"
:label="existingCredentialLabel(credential)"
/>
</N8nSelect>
<N8nSelect
v-else
:model-value="selectingExistingCredential ? selectedCredentialId : selection"
size="medium"
:disabled="isBusy"
:placeholder="copy.placeholderKey ? i18n.baseText(copy.placeholderKey) : undefined"
:data-test-id="`${copy.idPrefix}-provider-select`"
@update:model-value="selectOption(String($event ?? ''))"
>
<N8nOption v-if="!setup" value="" :label="noneLabel" />
<N8nOption
v-for="option in providerOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
<N8nOption
v-for="credential in existingOptions"
:key="credential.id"
:value="credential.id"
:label="existingCredentialLabel(credential)"
/>
</N8nSelect>
<N8nText
v-if="copy.providerHintKey"
tag="p"
:class="$style.providerHint"
size="small"
color="text-light"
>
{{ i18n.baseText(copy.providerHintKey) }}
</N8nText>
</N8nInputLabel>
<ConnectionFields
v-if="
!usingExisting &&
selection &&
!isProxyDaytonaSelection &&
(kind !== 'sandbox' || selection === 'daytona') &&
!isLoading
"
:credential-type="credentialTypeFor(selection)"
:data="fieldsData"
:disabled="isBusy"
:data-test-id="`${copy.idPrefix}-connection-fields`"
@update="setFieldValue"
/>
<N8nInputLabel
v-if="kind === 'model' && hasSelection"
:label="i18n.baseText('settings.n8nAgent.modelName.label')"
>
<N8nInput
:model-value="extraValue"
type="text"
size="medium"
:disabled="isBusy"
autocomplete="off"
:spellcheck="false"
:placeholder="i18n.baseText('settings.n8nAgent.modelName.placeholder')"
data-test-id="n8n-agent-model-name-input"
@update:model-value="extraValue = String($event)"
/>
</N8nInputLabel>
<N8nInputLabel
v-else-if="kind === 'sandbox' && !usingExisting && selection === 'n8n-sandbox'"
:label="i18n.baseText('settings.n8nAgent.sandboxCredential.apiKey')"
>
<N8nInput
:model-value="extraValue"
type="password"
size="medium"
:disabled="isBusy"
autocomplete="off"
:spellcheck="false"
data-test-id="n8n-agent-sandbox-api-key-input"
@update:model-value="extraValue = String($event)"
/>
</N8nInputLabel>
</div>
<N8nText
v-if="copy.footnoteKey"
:class="$style.footnote"
size="small"
color="text-light"
tag="p"
>
{{ i18n.baseText(copy.footnoteKey) }}
</N8nText>
<Banner
v-if="credentialTestError"
theme="danger"
:message="i18n.baseText('credentialEdit.credentialConfig.couldntConnectWithTheseSettings')"
:details="credentialTestError"
:data-test-id="`${copy.idPrefix}-credential-test-error`"
/>
<N8nDialogFooter>
<N8nButton
v-if="setup && kind !== 'model'"
variant="outline"
size="medium"
:label="i18n.baseText('generic.back')"
:disabled="isBusy"
:data-test-id="`${copy.idPrefix}-dialog-back`"
@click="handleBack"
/>
<N8nButton
v-if="setup && kind === 'search'"
variant="outline"
size="medium"
:label="i18n.baseText('settings.n8nAgent.setup.skip')"
:disabled="isBusy"
:data-test-id="`${copy.idPrefix}-dialog-skip`"
@click="handleClose"
/>
<N8nButton
v-if="showCancel"
variant="outline"
size="medium"
:label="i18n.baseText('generic.cancel')"
:disabled="isBusy"
:data-test-id="`${copy.idPrefix}-dialog-cancel`"
@click="handleClose"
/>
<N8nButton
variant="solid"
size="medium"
:label="primaryLabel"
:loading="isTestingCredential"
:disabled="primaryDisabled"
:data-test-id="`${copy.idPrefix}-dialog-save`"
@click="handlePrimary"
/>
</N8nDialogFooter>
</N8nDialog>
:step="kind"
:edit-mode="true"
:allow-unchanged="setup"
:sequence="setupSequence"
model-value=""
sandbox-value=""
search-value=""
:compose-fast-path="false"
surface="settings"
@update:open="open = $event"
@advance="handleAdvance"
/>
</template>
<style lang="scss" module>
.fields {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
margin: var(--spacing--sm) 0;
// Long credential forms scroll inside the dialog instead of growing past the viewport.
max-height: calc(100dvh - 20rem);
overflow-y: auto;
}
.providerHint {
margin: var(--spacing--4xs) 0 0;
}
.footnote {
margin: 0 0 var(--spacing--sm);
}
.step {
margin: 0;
text-transform: uppercase;
letter-spacing: var(--letter-spacing--wide);
}
</style>
@@ -1,54 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { DOMAIN_RESTRICTION_FIELDS, type ICredentialDataDecryptedObject } from 'n8n-workflow';
import type { IUpdateInformation } from '@/Interface';
import CredentialInputs from '@/features/credentials/components/CredentialEdit/CredentialInputs.vue';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
const HIDDEN_FIELDS = new Set([
...DOMAIN_RESTRICTION_FIELDS.map((field) => field.name),
'organizationId',
'header',
'headerName',
'headerValue',
]);
const props = defineProps<{
credentialType: string;
data: ICredentialDataDecryptedObject;
disabled?: boolean;
}>();
const emit = defineEmits<{ update: [name: string, value: IUpdateInformation['value']] }>();
const credentialsStore = useCredentialsStore();
const type = computed(() => credentialsStore.getCredentialTypeByName(props.credentialType));
const properties = computed(() =>
(type.value?.properties ?? []).filter((property) => !HIDDEN_FIELDS.has(property.name)),
);
function onUpdate(parameterData: IUpdateInformation) {
emit('update', parameterData.name, parameterData.value);
}
</script>
<template>
<fieldset v-if="properties.length" :disabled="disabled" :class="$style.fieldset">
<CredentialInputs
:credential-properties="properties"
:credential-data="data"
:documentation-url="type?.documentationUrl ?? ''"
@update="onUpdate"
/>
</fieldset>
</template>
<style module>
.fieldset {
margin: 0;
padding: 0;
border: 0;
}
</style>
@@ -1,38 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/vue';
import { createTestingPinia } from '@pinia/testing';
import { fireEvent, waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import type { ICredentialType, INodeCredentialTestResult } from 'n8n-workflow';
import { nextTick } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import ConnectionDialog from '../ConnectionDialog.vue';
import { useInstanceAiSettingsStore } from '../../../instanceAiSettings.store';
// Renders ConnectionFields and CredentialInputs for real (unlike the view suite, which mocks
// ConnectionFields wholesale); only the parameter input leaf is stubbed.
vi.mock('@/features/ndv/parameters/components/ParameterInputExpanded.vue', async () => {
const { defineComponent, h } = await import('vue');
return {
default: defineComponent({
props: { parameter: { type: Object, required: true }, value: { default: '' } },
emits: ['update'],
setup(props, { emit }) {
return () =>
h('input', {
'data-test-id': `param-${(props.parameter as { name: string }).name}`,
value: String(props.value ?? ''),
onInput: (event: Event) =>
emit('update', {
name: (props.parameter as { name: string }).name,
value: (event.target as HTMLInputElement).value,
}),
});
},
}),
};
});
import ConnectionDialog from '../ConnectionDialog.vue';
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
@@ -40,21 +12,7 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
}));
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(),
updatePreferences: vi.fn(),
fetchServiceCredentials: vi.fn().mockResolvedValue([]),
fetchInstanceModelCredentials: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../../instanceAi.api', () => ({
createGatewayLink: vi.fn(),
getGatewayStatus: vi.fn(),
usePushConnectionStore: () => ({ addEventListener: vi.fn() }),
}));
vi.mock('@/app/utils/rbac/permissions', () => ({
@@ -63,67 +21,37 @@ vi.mock('@/app/utils/rbac/permissions', () => ({
const renderDialog = createComponentRenderer(ConnectionDialog);
const DAYTONA_TYPE: ICredentialType = {
name: 'daytonaApi',
displayName: 'Daytona',
properties: [
{ displayName: 'API URL', name: 'apiUrl', type: 'string', required: true, default: '' },
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
typeOptions: { password: true },
required: true,
default: '',
},
],
test: { request: { url: '/test' } },
};
function inputFor(element: HTMLElement): HTMLInputElement {
if (element instanceof HTMLInputElement) return element;
const input = element.querySelector('input');
if (!input) throw new Error('Expected an input');
return input;
}
const OPENAI_TYPE: ICredentialType = {
name: 'openAiApi',
displayName: 'OpenAI',
properties: [
{ displayName: 'API Key', name: 'apiKey', type: 'string', required: true, default: '' },
{ displayName: 'Organization ID', name: 'organizationId', type: 'string', default: '' },
{
displayName: 'Base URL',
name: 'url',
type: 'string',
default: 'https://api.openai.com/v1',
},
{ displayName: 'Add Custom Header', name: 'header', type: 'boolean', default: false },
{
displayName: 'Header Name',
name: 'headerName',
type: 'string',
default: '',
displayOptions: { show: { header: [true] } },
},
{
displayName: 'Header Value',
name: 'headerValue',
type: 'string',
default: '',
displayOptions: { show: { header: [true] } },
},
],
};
describe('ConnectionDialog (real connection fields)', () => {
describe('ConnectionDialog', () => {
let store: ReturnType<typeof useInstanceAiSettingsStore>;
let credentialsStore: ReturnType<typeof useCredentialsStore>;
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createTestingPinia({ stubActions: false }));
store = useInstanceAiSettingsStore();
credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([
{ name: 'anthropicApi', displayName: 'Anthropic', properties: [] },
{ name: 'openAiApi', displayName: 'OpenAI', properties: [] },
{ name: 'openRouterApi', displayName: 'OpenRouter', properties: [] },
{ name: 'daytonaApi', displayName: 'Daytona', properties: [] },
{ name: 'searXngApi', displayName: 'SearXNG', properties: [] },
{ name: 'braveSearchApi', displayName: 'Brave Search', properties: [] },
] as never);
store.$patch({
settings: {
enabled: true,
permissions: {},
mcpAccessEnabled: true,
sandboxEnabled: false,
sandboxProvider: 'daytona',
sandboxProvider: 'n8n-sandbox',
daytonaCredentialId: null,
n8nSandboxCredentialId: null,
searchCredentialId: null,
@@ -132,232 +60,112 @@ describe('ConnectionDialog (real connection fields)', () => {
modelEnvConfigured: false,
sandboxEnvConfigured: false,
searchEnvConfigured: false,
searchDisabled: false,
n8nSandboxServiceUrl: null,
envManaged: {
model: { provider: false, apiKey: false, baseUrl: false, model: false },
sandbox: { provider: false, serviceUrl: false, apiKey: false },
search: { provider: false, apiKey: false, url: false },
},
localGatewayDisabled: false,
},
} as never,
});
vi.spyOn(store, 'save').mockResolvedValue(true);
vi.spyOn(store, 'refreshCredentials').mockResolvedValue(undefined);
vi.spyOn(store, 'refreshInstanceModelCredentials').mockResolvedValue(undefined);
});
it('renders an input per visible property of the selected credential type', async () => {
useCredentialsStore().setCredentialTypes([DAYTONA_TYPE]);
it('uses Cancel and Save without onboarding progress in both direct and setup contexts', async () => {
const direct = renderDialog({ props: { kind: 'search', open: true } });
const { findByTestId, getAllByTestId, getByTestId } = renderDialog({
props: { kind: 'sandbox', open: true },
});
expect(await direct.findByTestId('n8n-agent-search-dialog-cancel')).toBeVisible();
expect(direct.getByTestId('n8n-agent-search-dialog-save')).toBeVisible();
expect(direct.queryByTestId('n8n-agent-search-dialog-step')).toBeNull();
expect(direct.queryByTestId('n8n-agent-search-dialog-back')).toBeNull();
direct.unmount();
await findByTestId('n8n-agent-sandbox-connection-fields');
await waitFor(() => expect(getAllByTestId('credential-connection-parameter')).toHaveLength(2));
expect(getByTestId('param-apiUrl')).toBeVisible();
expect(getByTestId('param-apiKey')).toBeVisible();
expect((getByTestId('param-apiUrl') as HTMLInputElement).value).toBe(
'https://app.daytona.io/api',
);
const setup = renderDialog({ props: { kind: 'search', open: true, setup: true } });
expect(await setup.findByTestId('n8n-agent-search-dialog-cancel')).toBeVisible();
expect(setup.getByTestId('n8n-agent-search-dialog-save')).toBeVisible();
expect(setup.queryByTestId('n8n-agent-search-dialog-step')).toBeNull();
expect(setup.queryByTestId('n8n-agent-search-dialog-back')).toBeNull();
});
it('renders no inputs when credential types are not loaded, so the view must fetch them', async () => {
const { findByTestId, queryAllByTestId, queryByTestId } = renderDialog({
props: { kind: 'sandbox', open: true },
});
await findByTestId('n8n-agent-sandbox-provider-select');
expect(queryByTestId('n8n-agent-sandbox-connection-fields')).toBeNull();
expect(queryAllByTestId('credential-connection-parameter')).toHaveLength(0);
});
it('selects proxy-managed Daytona without requesting direct credentials', async () => {
useSettingsStore().moduleSettings = {
'instance-ai': {
enabled: true,
localGatewayDisabled: false,
browserUseEnabled: true,
proxyEnabled: true,
cloudManaged: false,
sandboxEnabled: true,
workflowBuilderAvailable: true,
sandboxUnavailableReason: null,
runDebugEnabled: false,
},
};
store.$patch({
settings: {
...store.settings!,
sandboxProvider: 'n8n-sandbox',
sandboxEnvConfigured: true,
},
});
vi.mocked(store.save).mockResolvedValue(true);
const credentialsStore = useCredentialsStore();
const { findByText, findByTestId, getByTestId, queryByTestId } = renderDialog({
props: { kind: 'sandbox', open: true },
});
const select = await findByTestId('n8n-agent-sandbox-provider-select');
await fireEvent.click(select.querySelector('input')!);
await fireEvent.click(await findByText('Daytona'));
expect(queryByTestId('n8n-agent-sandbox-connection-fields')).toBeNull();
expect(getByTestId('n8n-agent-sandbox-dialog-save')).not.toBeDisabled();
await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save'));
await waitFor(() => expect(store.save).toHaveBeenCalledOnce());
expect(credentialsStore.testCredential).not.toHaveBeenCalled();
expect(store.draft).toMatchObject({ sandboxProvider: 'daytona' });
expect(store.draft).not.toHaveProperty('sandboxConnection');
});
it('renders only the minimal OpenAI fields while preserving hidden credential data', async () => {
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([OPENAI_TYPE]);
vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({
data: {
apiKey: 'stored-key',
organizationId: 'org-old',
url: 'https://api.openai.com/v1',
header: true,
headerName: 'x-proxy-key',
headerValue: 'old-value',
},
} as never);
store.$patch({
settings: { ...store.settings!, modelCredentialId: 'openai-id', modelName: 'gpt-4o' },
instanceModelCredentials: [
{ id: 'openai-id', name: 'AI Assistant model', type: 'openAiApi' },
],
});
vi.mocked(store.save).mockResolvedValue(true);
const refresh = Promise.withResolvers<void>();
vi.mocked(store.refreshInstanceModelCredentials).mockReturnValue(refresh.promise);
const { emitted, findByTestId, getByTestId, queryByTestId } = renderDialog({
it('uses the same provider and model dropdowns as onboarding', async () => {
const { findByTestId, findByText, getByTestId, queryByTestId } = renderDialog({
props: { kind: 'model', open: true },
});
await findByTestId('param-apiKey');
expect(getByTestId('param-url')).toBeVisible();
expect(queryByTestId('param-organizationId')).toBeNull();
expect(queryByTestId('param-header')).toBeNull();
expect(queryByTestId('param-headerName')).toBeNull();
expect(queryByTestId('param-headerValue')).toBeNull();
await fireEvent.update(getByTestId('param-apiKey'), 'new-key');
await fireEvent.click(getByTestId('n8n-agent-model-dialog-save'));
const provider = await findByTestId('n8n-agent-model-provider-select');
expect(getByTestId('n8n-agent-model-name-input')).toBeVisible();
expect(queryByTestId('assistant-model-base-url')).toBeNull();
await waitFor(() => expect(store.save).toHaveBeenCalledOnce());
expect(emitted().saved).toBeUndefined();
expect(store.draft).toMatchObject({
modelConnection: {
type: 'openAiApi',
data: {
apiKey: 'new-key',
organizationId: 'org-old',
url: 'https://api.openai.com/v1',
header: true,
headerName: 'x-proxy-key',
headerValue: 'old-value',
},
},
});
refresh.resolve();
await waitFor(() => expect(emitted().saved).toEqual([[]]));
await fireEvent.click(inputFor(provider));
await fireEvent.click(await findByText('instanceAi.onboarding.model.customProvider'));
expect(getByTestId('assistant-model-base-url')).toBeVisible();
});
it('hydrates the sandbox credential selected by the configured provider', async () => {
const credentialsStore = useCredentialsStore();
vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({
data: { name: 'x-api-key', value: 'stored-key' },
} as never);
it('does not render an empty existing-credential selector for a fresh connection', async () => {
const { findByTestId, queryByTestId } = renderDialog({
props: { kind: 'sandbox', open: true },
});
expect(await findByTestId('n8n-agent-sandbox-provider-select')).toBeVisible();
expect(queryByTestId('n8n-agent-sandbox-existing-credential-select')).toBeNull();
});
it('assigns a selected compatible credential', async () => {
store.$patch({
instanceModelCredentials: [
{ id: 'anthropic-id', name: 'Existing Anthropic', type: 'anthropicApi' },
{ id: 'openai-id', name: 'Existing OpenAI', type: 'openAiApi' },
],
});
const { emitted, findByTestId, findByText, getByTestId } = renderDialog({
props: { kind: 'model', open: true },
});
const existing = await findByTestId('n8n-agent-model-existing-credential-select');
await fireEvent.click(inputFor(existing));
await fireEvent.click(await findByText('Existing Anthropic · Anthropic'));
await fireEvent.click(getByTestId('n8n-agent-model-dialog-save'));
await waitFor(() => expect(emitted().saved).toEqual([[]]));
expect(store.setField).toHaveBeenCalledWith('modelCredentialId', 'anthropic-id');
expect(store.setField).toHaveBeenCalledWith('modelName', 'claude-opus-5');
expect(emitted()['update:open']).toContainEqual([false]);
});
it('shows environment-managed settings as active and read-only', async () => {
store.$patch({
settings: {
...store.settings!,
sandboxProvider: 'n8n-sandbox',
daytonaCredentialId: 'daytona-id',
n8nSandboxCredentialId: 'n8n-id',
sandboxEnvConfigured: true,
envManaged: {
...store.settings!.envManaged,
sandbox: { provider: true, serviceUrl: true, apiKey: true },
},
},
});
vi.mocked(store.save).mockResolvedValue(true);
const { findByTestId, getByTestId } = renderDialog({
const { findByText, getByTestId, queryByTestId } = renderDialog({
props: { kind: 'sandbox', open: true },
});
const keyField = await findByTestId('n8n-agent-sandbox-api-key-input');
const keyInput =
keyField.tagName === 'INPUT'
? (keyField as HTMLInputElement)
: keyField.querySelector('input')!;
expect(credentialsStore.getCredentialData).toHaveBeenCalledWith({ id: 'n8n-id' });
expect(keyInput.value).toBe('stored-key');
await fireEvent.update(keyInput, 'new-key');
await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save'));
await waitFor(() => expect(store.save).toHaveBeenCalledOnce());
expect(store.draft).toMatchObject({
sandboxConnection: {
type: 'httpHeaderAuth',
data: { name: 'x-api-key', value: 'new-key' },
},
});
expect(await findByText('instanceAi.onboarding.env.title')).toBeVisible();
expect(queryByTestId('n8n-agent-sandbox-provider-select')).toBeNull();
expect(getByTestId('n8n-agent-sandbox-dialog-save')).toBeDisabled();
});
it('ignores a stale hydration after the dialog is reopened', async () => {
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([OPENAI_TYPE]);
store.$patch({
settings: { ...store.settings!, modelCredentialId: 'openai-id', modelName: 'gpt-4o' },
instanceModelCredentials: [
{ id: 'openai-id', name: 'AI Assistant model', type: 'openAiApi' },
],
it('keeps setup open after save so the parent can move to the next connection', async () => {
vi.spyOn(store, 'verifySearch').mockResolvedValue({ ok: true });
const { emitted, findByTestId, getByTestId } = renderDialog({
props: { kind: 'search', open: true, setup: true },
});
const stale =
Promise.withResolvers<Awaited<ReturnType<typeof credentialsStore.getCredentialData>>>();
const fresh =
Promise.withResolvers<Awaited<ReturnType<typeof credentialsStore.getCredentialData>>>();
vi.mocked(credentialsStore.getCredentialData)
.mockReturnValueOnce(stale.promise)
.mockReturnValueOnce(fresh.promise);
const result = renderDialog({ props: { kind: 'model', open: true } });
await waitFor(() => expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(1));
await result.rerender({ kind: 'model', open: false });
await result.rerender({ kind: 'model', open: true });
await waitFor(() => expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(2));
await fireEvent.click(await findByTestId('assistant-search-disabled'));
await fireEvent.click(getByTestId('n8n-agent-search-dialog-save'));
fresh.resolve({ data: { apiKey: 'fresh-key' } } as never);
const apiKeyInput = await result.findByTestId('param-apiKey');
expect((apiKeyInput as HTMLInputElement).value).toBe('fresh-key');
stale.resolve({ data: { apiKey: 'stale-key' } } as never);
await stale.promise;
await nextTick();
expect(result.getByTestId('param-apiKey')).toHaveValue('fresh-key');
});
it('keeps the dialog and fields locked while testing a connection', async () => {
useCredentialsStore().setCredentialTypes([DAYTONA_TYPE]);
const credentialsStore = useCredentialsStore();
let finishTest = (_result: INodeCredentialTestResult) => {};
vi.mocked(credentialsStore.testCredential).mockImplementation(
async () =>
await new Promise<INodeCredentialTestResult>((resolve) => {
finishTest = resolve;
}),
);
vi.mocked(store.save).mockResolvedValue(true);
const { findByTestId, getByTestId } = renderDialog({
props: { kind: 'sandbox', open: true },
});
const apiKeyInput = await findByTestId('param-apiKey');
await fireEvent.update(apiKeyInput, 'secret');
const saveButton = getByTestId('n8n-agent-sandbox-dialog-save');
await waitFor(() => expect(saveButton).not.toBeDisabled());
await fireEvent.click(saveButton);
const cancelButton = getByTestId('n8n-agent-sandbox-dialog-cancel');
await waitFor(() => expect(cancelButton).toBeDisabled());
expect(apiKeyInput).toBeDisabled();
await fireEvent.click(cancelButton);
expect(getByTestId('n8n-agent-sandbox-dialog-cancel')).toBeDisabled();
finishTest({ status: 'OK', message: '' });
await waitFor(() => expect(store.save).toHaveBeenCalled());
await waitFor(() => expect(emitted().saved).toEqual([[]]));
expect(emitted()['update:open']).toBeUndefined();
});
});
@@ -2,13 +2,12 @@ import { computed, type ComputedRef } from 'vue';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { canMessageInstanceAi } from '../instanceAiPermissions';
import { canManageInstanceAi, canMessageInstanceAi } from '../instanceAiPermissions';
/**
* Whether Instance AI can be used right now: the module is active, an admin
* hasn't disabled it, and the current user has permission to message it. This
* is the canonical gate for Instance AI entry points (nav item, command bar,
* editor/credential hand-offs) — use it instead of re-deriving the three checks.
* Whether Instance AI can be used right now: the module is active, enabled,
* ready for members (or the user can finish admin setup), and the user can
* message it. This is the canonical gate for Instance AI entry points.
*/
export function useInstanceAiAvailable(): ComputedRef<boolean> {
const settingsStore = useSettingsStore();
@@ -16,6 +15,8 @@ export function useInstanceAiAvailable(): ComputedRef<boolean> {
() =>
settingsStore.isModuleActive('instance-ai') &&
settingsStore.moduleSettings['instance-ai']?.enabled !== false &&
(settingsStore.moduleSettings['instance-ai']?.setupCompleted === true ||
canManageInstanceAi()) &&
canMessageInstanceAi(),
);
}
@@ -0,0 +1,82 @@
import type { InstanceAiAdminSettingsResponse } from '@n8n/api-types';
import { deriveInstanceAiConfiguration } from './useInstanceAiConfiguration';
function createSettings(
overrides: Partial<InstanceAiAdminSettingsResponse> = {},
): InstanceAiAdminSettingsResponse {
return {
enabled: true,
permissions: {} as InstanceAiAdminSettingsResponse['permissions'],
mcpAccessEnabled: false,
sandboxEnabled: false,
sandboxProvider: 'n8n-sandbox',
daytonaCredentialId: null,
n8nSandboxCredentialId: null,
searchCredentialId: null,
modelCredentialId: null,
modelName: null,
modelEnvConfigured: false,
sandboxEnvConfigured: false,
searchEnvConfigured: false,
searchDisabled: false,
n8nSandboxServiceUrl: null,
envManaged: {
model: { provider: false, apiKey: false, baseUrl: false, model: false },
sandbox: { provider: false, serviceUrl: false, apiKey: false },
search: { provider: false, apiKey: false, url: false },
},
localGatewayDisabled: false,
browserUseEnabled: true,
...overrides,
};
}
describe('deriveInstanceAiConfiguration', () => {
it('keeps setup incomplete until web search is explicitly decided', () => {
const settings = createSettings({
modelCredentialId: 'model-1',
modelName: 'gpt-5.4',
sandboxEnabled: true,
n8nSandboxCredentialId: 'sandbox-1',
});
const undecided = deriveInstanceAiConfiguration(settings, [], []);
expect(undecided.setupCompleted).toBe(false);
expect(undecided.searchState).toBe('notset');
const disabled = deriveInstanceAiConfiguration({ ...settings, searchDisabled: true }, [], []);
expect(disabled.setupCompleted).toBe(true);
expect(disabled.searchState).toBe('disabled');
});
it('accepts env-managed services without exposing configuration details', () => {
const configuration = deriveInstanceAiConfiguration(
createSettings({
modelEnvConfigured: true,
sandboxEnabled: true,
sandboxEnvConfigured: true,
searchEnvConfigured: true,
}),
[],
[],
);
expect(configuration).toMatchObject({
modelConfigured: true,
sandboxConfigured: true,
searchState: 'env',
setupCompleted: true,
});
});
it('does not treat a configured sandbox connection as ready while sandboxing is disabled', () => {
const configuration = deriveInstanceAiConfiguration(
createSettings({ n8nSandboxCredentialId: 'sandbox-1' }),
[],
[],
);
expect(configuration.sandboxConfigured).toBe(false);
});
});
@@ -0,0 +1,29 @@
import { computed } from 'vue';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import { deriveInstanceAiConfiguration } from '../instanceAiConfiguration';
export { deriveInstanceAiConfiguration } from '../instanceAiConfiguration';
export type { InstanceAiSearchState } from '../instanceAiConfiguration';
export function useInstanceAiConfiguration() {
const store = useInstanceAiSettingsStore();
const configuration = computed(() =>
deriveInstanceAiConfiguration(
store.settings,
store.instanceModelCredentials,
store.serviceCredentials,
),
);
return {
modelCredential: computed(() => configuration.value.modelCredential),
modelConfigured: computed(() => configuration.value.modelConfigured),
sandboxCredentialId: computed(() => configuration.value.sandboxCredentialId),
sandboxConfigured: computed(() => configuration.value.sandboxConfigured),
searchCredential: computed(() => configuration.value.searchCredential),
searchState: computed(() => configuration.value.searchState),
setupCompleted: computed(() => configuration.value.setupCompleted),
hasSetupProgress: computed(() => configuration.value.hasSetupProgress),
};
}
@@ -1,21 +0,0 @@
import { computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
/** Step labels for the setup wizard; the optional search step drops off when already configured. */
export function useInstanceAiSetupSteps(step: number) {
const i18n = useI18n();
const store = useInstanceAiSettingsStore();
const totalSteps = computed(() =>
(store.settings?.searchCredentialId ?? store.settings?.searchEnvConfigured) ? 2 : 3,
);
const stepLabel = computed(() =>
i18n.baseText('settings.n8nAgent.setup.step', {
interpolate: { step, total: totalSteps.value },
}),
);
const isLastStep = computed(() => step >= totalSteps.value);
return { stepLabel, isLastStep };
}
@@ -6,6 +6,10 @@ import type {
InstanceAiUserPreferencesResponse,
InstanceAiUserPreferencesUpdateRequest,
InstanceAiProviderConnection,
InstanceAiVerificationResponse,
InstanceAiVerifyModelRequest,
InstanceAiVerifySandboxRequest,
InstanceAiVerifySearchRequest,
} from '@n8n/api-types';
export async function fetchSettings(
@@ -45,3 +49,24 @@ export async function fetchInstanceModelCredentials(
): Promise<InstanceAiProviderConnection[]> {
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/model-credentials');
}
export async function verifyModel(
context: IRestApiContext,
body: InstanceAiVerifyModelRequest,
): Promise<InstanceAiVerificationResponse> {
return await makeRestApiRequest(context, 'POST', '/instance-ai/settings/verify/model', body);
}
export async function verifySandbox(
context: IRestApiContext,
body: InstanceAiVerifySandboxRequest,
): Promise<InstanceAiVerificationResponse> {
return await makeRestApiRequest(context, 'POST', '/instance-ai/settings/verify/sandbox', body);
}
export async function verifySearch(
context: IRestApiContext,
body: InstanceAiVerifySearchRequest,
): Promise<InstanceAiVerificationResponse> {
return await makeRestApiRequest(context, 'POST', '/instance-ai/settings/verify/search', body);
}
@@ -0,0 +1,42 @@
import type { InstanceAiAdminSettingsResponse, InstanceAiProviderConnection } from '@n8n/api-types';
export type InstanceAiSearchState = 'set' | 'env' | 'disabled' | 'notset';
export function deriveInstanceAiConfiguration(
settings: InstanceAiAdminSettingsResponse | null,
modelCredentials: InstanceAiProviderConnection[],
serviceCredentials: InstanceAiProviderConnection[],
) {
const modelCredential = modelCredentials.find(
(credential) => credential.id === settings?.modelCredentialId,
);
const modelConfigured = Boolean(
settings?.modelEnvConfigured || (settings?.modelCredentialId && settings.modelName),
);
const sandboxCredentialId =
settings?.sandboxProvider === 'daytona'
? settings.daytonaCredentialId
: settings?.n8nSandboxCredentialId;
const sandboxConfigured = Boolean(
settings?.sandboxEnabled && (sandboxCredentialId || settings.sandboxEnvConfigured),
);
const searchCredential = serviceCredentials.find(
(credential) => credential.id === settings?.searchCredentialId,
);
let searchState: InstanceAiSearchState = 'notset';
if (settings?.searchCredentialId) searchState = 'set';
else if (settings?.searchEnvConfigured) searchState = 'env';
else if (settings?.searchDisabled) searchState = 'disabled';
const setupCompleted = modelConfigured && sandboxConfigured && searchState !== 'notset';
return {
modelCredential,
modelConfigured,
sandboxCredentialId,
sandboxConfigured,
searchCredential,
searchState,
setupCompleted,
hasSetupProgress: modelConfigured || sandboxConfigured || searchState !== 'notset',
};
}
@@ -0,0 +1,75 @@
import type {
INSTANCE_AI_MODEL_CREDENTIAL_TYPES,
INSTANCE_AI_SEARCH_CREDENTIAL_TYPES,
InstanceAiSandboxProvider,
} from '@n8n/api-types';
import { SANDBOX_PROVIDER_LABELS } from './constants';
export type InstanceAiModelProvider = 'anthropic' | 'openai' | 'openrouter' | 'custom';
export type InstanceAiSearchProvider = 'searxng' | 'brave' | 'disabled';
export const INSTANCE_AI_MODEL_PROVIDERS = [
{
id: 'anthropic',
credentialType: 'anthropicApi',
label: 'Anthropic',
models: ['claude-opus-5', 'claude-sonnet-5'],
placeholder: 'sk-ant-…',
},
{
id: 'openai',
credentialType: 'openAiApi',
label: 'OpenAI',
models: ['gpt-5.6-sol', 'gpt-5.6-terra'],
placeholder: 'sk-…',
},
{
id: 'openrouter',
credentialType: 'openRouterApi',
label: 'OpenRouter',
models: [
'anthropic/claude-opus-5',
'anthropic/claude-sonnet-5',
'openai/gpt-5.6-sol',
'openai/gpt-5.6-terra',
'moonshotai/kimi-k3',
],
placeholder: 'sk-or-…',
},
{
id: 'custom',
credentialType: 'openAiApi',
label: null,
models: [],
placeholder: 'Leave empty for Ollama',
},
] as const satisfies ReadonlyArray<{
id: InstanceAiModelProvider;
credentialType: (typeof INSTANCE_AI_MODEL_CREDENTIAL_TYPES)[number];
label: string | null;
models: readonly string[];
placeholder: string;
}>;
export const INSTANCE_AI_SANDBOX_PROVIDERS = [
{
id: 'n8n-sandbox',
label: SANDBOX_PROVIDER_LABELS['n8n-sandbox'],
onboardingLabel: 'n8n Sandbox',
},
{ id: 'daytona', label: SANDBOX_PROVIDER_LABELS.daytona, onboardingLabel: 'Daytona' },
] as const satisfies ReadonlyArray<{
id: InstanceAiSandboxProvider;
label: string;
onboardingLabel: string;
}>;
export const INSTANCE_AI_SEARCH_PROVIDERS = [
{ id: 'searxng', credentialType: 'searXngApi', label: 'SearXNG' },
{ id: 'brave', credentialType: 'braveSearchApi', label: 'Brave Search' },
] as const satisfies ReadonlyArray<{
id: Exclude<InstanceAiSearchProvider, 'disabled'>;
credentialType: (typeof INSTANCE_AI_SEARCH_CREDENTIAL_TYPES)[number];
label: string;
}>;
@@ -11,6 +11,9 @@ import {
updatePreferences,
fetchServiceCredentials,
fetchInstanceModelCredentials,
verifyModel as verifyModelRequest,
verifySandbox as verifySandboxRequest,
verifySearch as verifySearchRequest,
} from './instanceAi.settings.api';
import { hasPermission } from '@/app/utils/rbac/permissions';
import {
@@ -30,6 +33,10 @@ import type {
InstanceAiPermissions,
InstanceAiPermissionMode,
ToolCategory,
InstanceAiVerifyModelRequest,
InstanceAiVerifySandboxRequest,
InstanceAiVerifySearchRequest,
InstanceAiVerificationResponse,
} from '@n8n/api-types';
import { i18n } from '@n8n/i18n';
import {
@@ -38,6 +45,7 @@ import {
type BrowserUseConnectionType,
type ComputerUseConnectionType,
} from './constants';
import { deriveInstanceAiConfiguration } from './instanceAiConfiguration';
export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () => {
const rootStore = useRootStore();
@@ -126,12 +134,18 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
): void {
const ms = settingsStore.moduleSettings;
const prev = ms['instance-ai'];
const configuration = deriveInstanceAiConfiguration(
adminRes,
instanceModelCredentials.value,
serviceCredentials.value,
);
const merged: NonNullable<FrontendModuleSettings['instance-ai']> = {
enabled: adminRes.enabled,
localGatewayDisabled: adminRes.localGatewayDisabled ?? prev?.localGatewayDisabled ?? false,
browserUseEnabled: adminRes.browserUseEnabled ?? prev?.browserUseEnabled ?? true,
proxyEnabled: prev?.proxyEnabled ?? false,
cloudManaged: prev?.cloudManaged ?? false,
setupCompleted: configuration.setupCompleted,
sandboxEnabled: adminRes.sandboxEnabled,
workflowBuilderAvailable: adminRes.sandboxEnabled
? (prev?.workflowBuilderAvailable ?? true)
@@ -194,7 +208,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
* Persists the staged admin draft. Returns whether the save succeeded; on
* failure the draft is discarded so a later unrelated save can't flush it.
*/
async function save(): Promise<boolean> {
async function save(showToast = true): Promise<boolean> {
if (Object.keys(draft).length === 0) return true;
isSaving.value = true;
try {
@@ -203,10 +217,12 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
} as InstanceAiAdminSettingsUpdateRequest);
settings.value = result;
clearDraft();
toast.showMessage({
title: i18n.baseText('settings.n8nAgent.toast.saved'),
type: 'success',
});
if (showToast) {
toast.showMessage({
title: i18n.baseText('settings.n8nAgent.toast.saved'),
type: 'success',
});
}
syncInstanceAiFlagIntoGlobalModuleSettings(result);
await settingsStore.getModuleSettings().catch(() => {});
return true;
@@ -220,7 +236,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
}
/** Persists only the Instance AI on/off flag (does not send other admin draft fields). */
async function persistEnabled(value: boolean): Promise<boolean> {
async function persistEnabled(value: boolean, showToast = true): Promise<boolean> {
isSaving.value = true;
try {
const result = await updateSettings(rootStore.restApiContext, { enabled: value });
@@ -228,10 +244,12 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
delete draft.enabled;
syncInstanceAiFlagIntoGlobalModuleSettings(result);
await settingsStore.getModuleSettings().catch(() => {});
toast.showMessage({
title: i18n.baseText('settings.n8nAgent.toast.saved'),
type: 'success',
});
if (showToast) {
toast.showMessage({
title: i18n.baseText('settings.n8nAgent.toast.saved'),
type: 'success',
});
}
return true;
} catch {
toast.showError(
@@ -598,6 +616,24 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
await Promise.all(promises);
}
async function verifyModel(
payload: InstanceAiVerifyModelRequest,
): Promise<InstanceAiVerificationResponse> {
return await verifyModelRequest(rootStore.restApiContext, payload);
}
async function verifySandbox(
payload: InstanceAiVerifySandboxRequest,
): Promise<InstanceAiVerificationResponse> {
return await verifySandboxRequest(rootStore.restApiContext, payload);
}
async function verifySearch(
payload: InstanceAiVerifySearchRequest,
): Promise<InstanceAiVerificationResponse> {
return await verifySearchRequest(rootStore.restApiContext, payload);
}
return {
canManage,
canManageAiUsage,
@@ -646,6 +682,9 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
refreshCredentials,
refreshInstanceModelCredentials,
refreshModuleSettings,
verifyModel,
verifySandbox,
verifySearch,
// Browser Use (direct channel)
browserConnected,
browserConnectedAt,
@@ -36,6 +36,7 @@ export const InstanceAiModule: FrontendModuleDescription = {
{
path: '/assistant',
component: InstanceAiView,
beforeEnter: () => (useInstanceAiAvailable().value ? true : { name: VIEWS.HOMEPAGE }),
meta: {
layout: 'instanceAi',
middleware: ['authenticated', 'custom'],
@@ -0,0 +1,76 @@
import { fireEvent } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import InstanceAiOnboardingIntro from './InstanceAiOnboardingIntro.vue';
const DOCS_URL = 'https://docs.n8n.io/build/ways-of-building-workflows/ai-assistant';
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({ baseText: (key: string) => key }),
}));
const renderIntro = createComponentRenderer(InstanceAiOnboardingIntro, {
props: {
incomplete: false,
connectModelOnly: false,
modelValue: 'Not set',
sandboxValue: 'Not set',
searchValue: 'Not set',
},
});
describe('InstanceAiOnboardingIntro', () => {
it('renders the initial benefits and emits its actions', async () => {
const { emitted, getByTestId, getByText } = renderIntro();
expect(getByTestId('assistant-setup-intro')).toBeVisible();
expect(getByText('instanceAi.onboarding.benefit.build')).toBeVisible();
expect(getByText('instanceAi.onboarding.setUp')).toBeVisible();
await fireEvent.click(getByTestId('assistant-setup-cta'));
await fireEvent.click(getByTestId('assistant-turn-off'));
expect(emitted().setup).toEqual([[]]);
expect(emitted().turnOff).toEqual([[]]);
});
it('uses the connect-model CTA for the compose fast path', () => {
const { getByText } = renderIntro({ props: { connectModelOnly: true } });
expect(getByText('instanceAi.onboarding.connectModel')).toBeVisible();
});
it('renders setup progress and opens the selected checklist step', async () => {
const { emitted, getByTestId, getByText } = renderIntro({
props: {
incomplete: true,
modelValue: 'anthropic/claude-opus-5',
sandboxValue: 'n8n Sandbox',
searchValue: 'Disabled',
},
});
expect(getByTestId('assistant-setup-incomplete')).toBeVisible();
expect(getByTestId('settings-row-group')).toBeVisible();
expect(getByText('instanceAi.onboarding.incomplete.lede')).toBeVisible();
expect(getByText('anthropic/claude-opus-5')).toBeVisible();
expect(getByText('n8n Sandbox')).toBeVisible();
expect(getByText('Disabled')).toBeVisible();
await fireEvent.click(getByTestId('assistant-setup-checklist-model'));
await fireEvent.click(getByTestId('assistant-setup-checklist-sandbox'));
await fireEvent.click(getByTestId('assistant-setup-checklist-search'));
const finishSetup = getByTestId('assistant-finish-setup-cta');
const learnMore = getByTestId('assistant-learn-more');
expect(
Boolean(finishSetup.compareDocumentPosition(learnMore) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
expect(learnMore).toHaveAttribute('href', DOCS_URL);
await fireEvent.click(finishSetup);
expect(emitted().openStep).toEqual([['model'], ['sandbox'], ['search']]);
expect(emitted().setup).toEqual([[]]);
});
});
@@ -0,0 +1,229 @@
<script setup lang="ts">
import {
N8nButton,
N8nHeading,
N8nIcon,
N8nPreviewTag,
N8nSettingsRow,
N8nSettingsRowConfigure,
N8nSettingsRowGroup,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
defineProps<{
incomplete: boolean;
connectModelOnly: boolean;
modelValue: string;
sandboxValue: string;
searchValue: string;
}>();
const emit = defineEmits<{
setup: [];
openStep: [step: 'model' | 'sandbox' | 'search'];
turnOff: [];
}>();
const i18n = useI18n();
const DOCS_URL = 'https://docs.n8n.io/build/ways-of-building-workflows/ai-assistant';
</script>
<template>
<div
:class="$style.page"
:data-test-id="incomplete ? 'assistant-setup-incomplete' : 'assistant-setup-intro'"
>
<div :class="[$style.content, incomplete && $style.wide]">
<N8nIcon icon="sparkles" :size="32" :class="$style.heroIcon" />
<N8nHeading tag="h1" size="2xlarge" bold :class="$style.title">
{{ i18n.baseText('instanceAi.onboarding.title') }}
</N8nHeading>
<N8nPreviewTag v-if="!incomplete" :class="$style.preview" size="medium" />
<N8nText v-if="incomplete" tag="p" color="text-base" size="large" :class="$style.lede">
{{ i18n.baseText('instanceAi.onboarding.incomplete.lede') }}
</N8nText>
<div v-if="!incomplete" :class="$style.benefits">
<div :class="$style.benefit">
<N8nIcon icon="workflow" size="small" />
<N8nText size="large">{{ i18n.baseText('instanceAi.onboarding.benefit.build') }}</N8nText>
</div>
<div :class="$style.benefit">
<N8nIcon icon="flask-conical" size="small" />
<N8nText size="large">{{ i18n.baseText('instanceAi.onboarding.benefit.debug') }}</N8nText>
</div>
<div :class="$style.benefit">
<N8nIcon icon="circle-help" size="small" />
<N8nText size="large">{{ i18n.baseText('instanceAi.onboarding.benefit.help') }}</N8nText>
</div>
</div>
<N8nSettingsRowGroup v-else :class="$style.checklist">
<N8nSettingsRow
v-for="item in [
{
id: 'model' as const,
title: i18n.baseText('instanceAi.onboarding.model.label'),
description: i18n.baseText('instanceAi.onboarding.model.description'),
value: modelValue,
},
{
id: 'sandbox' as const,
title: i18n.baseText('instanceAi.onboarding.sandbox.label'),
description: i18n.baseText('instanceAi.onboarding.sandbox.description'),
value: sandboxValue,
},
{
id: 'search' as const,
title: i18n.baseText('instanceAi.onboarding.search.label'),
description: i18n.baseText('instanceAi.onboarding.search.description'),
value: searchValue,
},
]"
:key="item.id"
:title="item.title"
:description="item.description"
clickable
:data-test-id="`assistant-setup-checklist-${item.id}`"
@click="emit('openStep', item.id)"
>
<template #action>
<N8nSettingsRowConfigure :value="item.value" />
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
<div :class="$style.actions">
<N8nButton
variant="solid"
size="medium"
:data-test-id="incomplete ? 'assistant-finish-setup-cta' : 'assistant-setup-cta'"
:label="
incomplete
? i18n.baseText('instanceAi.onboarding.finishSetup')
: connectModelOnly
? i18n.baseText('instanceAi.onboarding.connectModel')
: i18n.baseText('instanceAi.onboarding.setUp')
"
@click="emit('setup')"
/>
<N8nButton
variant="ghost"
size="medium"
:href="DOCS_URL"
target="_blank"
:label="i18n.baseText('instanceAi.onboarding.learnMore')"
data-test-id="assistant-learn-more"
/>
</div>
<div :class="$style.turnOff">
<N8nButton
variant="ghost"
size="small"
:label="i18n.baseText('instanceAi.onboarding.turnOff.action')"
:class="$style.turnOffButton"
data-test-id="assistant-turn-off"
@click="emit('turnOff')"
/>
</div>
</div>
</div>
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion.scss' as motion;
.page {
flex: 1;
min-width: 0;
overflow: auto;
display: flex;
justify-content: center;
padding: var(--spacing--lg) var(--spacing--lg) var(--spacing--3xl);
}
.content {
@include motion.fade-in-up;
width: 100%;
max-width: 27.5rem;
margin: auto 0;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.wide {
max-width: 35rem;
}
.heroIcon {
color: var(--icon-color);
}
.title {
margin: var(--spacing--sm) 0 0;
}
.preview {
margin-top: var(--spacing--xs);
}
.lede {
margin: var(--spacing--sm) 0 0;
}
.benefits {
margin-top: var(--spacing--md);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing--2xs);
text-align: left;
}
.benefit {
display: flex;
align-items: center;
gap: var(--spacing--xs);
}
.benefit svg {
flex-shrink: 0;
color: var(--icon-color);
}
.checklist {
width: 100%;
margin-top: var(--spacing--lg);
text-align: start;
}
.actions {
margin-top: var(--spacing--lg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing--xs);
}
.turnOff {
width: auto;
align-self: stretch;
margin-inline: var(--spacing--lg);
margin-top: var(--spacing--lg);
padding-top: var(--spacing--sm);
border-top: var(--border);
display: flex;
justify-content: center;
}
.turnOffButton {
color: var(--text-color--subtler);
}
</style>
@@ -0,0 +1,296 @@
import { createTestingPinia } from '@pinia/testing';
import { fireEvent, waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import { defineComponent } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { MODAL_CONFIRM, VIEWS } from '@/app/constants';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import InstanceAiOnboardingView from './InstanceAiOnboardingView.vue';
const confirmMock = vi.hoisted(() => vi.fn());
const showMessageMock = vi.hoisted(() => vi.fn());
const routerPushMock = vi.hoisted(() => vi.fn());
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({ baseText: (key: string) => key }),
}));
vi.mock('@/app/composables/useMessage', () => ({
useMessage: () => ({ confirm: confirmMock }),
}));
vi.mock('@n8n/composables/useToast', () => ({
useToast: () => ({ showMessage: showMessageMock }),
}));
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal()),
useRouter: () => ({ push: routerPushMock }),
}));
vi.mock('@/app/stores/pushConnection.store', () => ({
usePushConnectionStore: () => ({ addEventListener: vi.fn() }),
}));
const IntroStub = defineComponent({
name: 'InstanceAiOnboardingIntro',
props: {
incomplete: Boolean,
connectModelOnly: Boolean,
modelValue: String,
sandboxValue: String,
searchValue: String,
},
emits: ['setup', 'openStep', 'turnOff'],
template: `
<div
data-test-id="intro-stub"
:data-incomplete="String(incomplete)"
:data-connect-model-only="String(connectModelOnly)"
:data-model-value="modelValue"
:data-sandbox-value="sandboxValue"
:data-search-value="searchValue"
>
<button data-test-id="intro-setup" @click="$emit('setup')" />
<button data-test-id="intro-open-model" @click="$emit('openStep', 'model')" />
<button data-test-id="intro-open-search" @click="$emit('openStep', 'search')" />
<button data-test-id="intro-turn-off" @click="$emit('turnOff')" />
</div>
`,
});
const WizardStub = defineComponent({
name: 'InstanceAiOnboardingWizard',
props: {
open: Boolean,
step: String,
editMode: Boolean,
composeFastPath: Boolean,
},
emits: ['update:open', 'advance', 'back', 'edit', 'completed'],
template: `
<div
data-test-id="wizard-stub"
:data-open="String(open)"
:data-step="step"
:data-edit-mode="String(editMode)"
:data-compose-fast-path="String(composeFastPath)"
>
<button data-test-id="wizard-open" @click="$emit('update:open', true)" />
<button data-test-id="wizard-close" @click="$emit('update:open', false)" />
<button data-test-id="wizard-advance" @click="$emit('advance')" />
<button data-test-id="wizard-back" @click="$emit('back')" />
<button data-test-id="wizard-edit-sandbox" @click="$emit('edit', 'sandbox')" />
<button data-test-id="wizard-complete" @click="$emit('completed')" />
</div>
`,
});
const renderView = createComponentRenderer(InstanceAiOnboardingView, {
global: {
stubs: {
InstanceAiOnboardingIntro: IntroStub,
InstanceAiOnboardingWizard: WizardStub,
},
},
});
function setupStore(overrides: Record<string, unknown> = {}) {
const pinia = createTestingPinia();
setActivePinia(pinia);
const store = useInstanceAiSettingsStore();
store.$patch({
settings: {
enabled: true,
permissions: {},
mcpAccessEnabled: true,
sandboxEnabled: false,
sandboxProvider: 'n8n-sandbox',
daytonaCredentialId: null,
n8nSandboxCredentialId: null,
searchCredentialId: null,
modelCredentialId: null,
modelName: null,
modelEnvConfigured: false,
sandboxEnvConfigured: false,
searchEnvConfigured: false,
searchDisabled: false,
n8nSandboxServiceUrl: null,
envManaged: {
model: { provider: false, apiKey: false, baseUrl: false, model: false },
sandbox: { provider: false, serviceUrl: false, apiKey: false },
search: { provider: false, apiKey: false, url: false },
},
localGatewayDisabled: false,
...overrides,
} as never,
});
vi.mocked(store.fetch).mockResolvedValue(undefined);
vi.mocked(store.persistEnabled).mockResolvedValue(true);
const credentialsStore = useCredentialsStore();
vi.mocked(credentialsStore.fetchCredentialTypes).mockResolvedValue(undefined as never);
return { pinia, store, credentialsStore };
}
describe('InstanceAiOnboardingView', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('fetches setup data and opens at the first unmet step', async () => {
const { pinia, store, credentialsStore } = setupStore();
const { getByTestId } = renderView({ pinia });
await waitFor(() => expect(store.fetch).toHaveBeenCalled());
expect(credentialsStore.fetchCredentialTypes).toHaveBeenCalledWith(false);
expect(getByTestId('intro-stub')).toHaveAttribute('data-incomplete', 'false');
await fireEvent.click(getByTestId('intro-setup'));
expect(getByTestId('wizard-stub')).toHaveAttribute('data-open', 'true');
expect(getByTestId('wizard-stub')).toHaveAttribute('data-step', 'model');
});
it('shows saved setup progress and opens a selected checklist step', async () => {
const { pinia, store } = setupStore({
modelCredentialId: 'model-credential',
modelName: 'claude-opus-5',
});
store.$patch({
instanceModelCredentials: [
{ id: 'model-credential', name: 'Anthropic', type: 'anthropicApi' },
],
});
const { getByTestId } = renderView({ pinia });
expect(getByTestId('intro-stub')).toHaveAttribute('data-incomplete', 'true');
expect(getByTestId('intro-stub')).toHaveAttribute(
'data-model-value',
'anthropic/claude-opus-5',
);
await fireEvent.click(getByTestId('intro-open-search'));
expect(getByTestId('wizard-stub')).toHaveAttribute('data-step', 'search');
expect(getByTestId('wizard-stub')).toHaveAttribute('data-open', 'true');
expect(getByTestId('wizard-stub')).toHaveAttribute('data-edit-mode', 'true');
});
it('uses the model-only fast path when sandbox and search are server-managed', async () => {
const { pinia } = setupStore({
sandboxEnabled: true,
sandboxEnvConfigured: true,
searchEnvConfigured: true,
});
const { getByTestId } = renderView({ pinia });
expect(getByTestId('intro-stub')).toHaveAttribute('data-connect-model-only', 'true');
expect(getByTestId('intro-stub')).toHaveAttribute(
'data-sandbox-value',
'instanceAi.onboarding.foundOnServer',
);
expect(getByTestId('intro-stub')).toHaveAttribute(
'data-search-value',
'instanceAi.onboarding.foundOnServer',
);
});
it('requires the sandbox step when server configuration is present but disabled', async () => {
const { pinia } = setupStore({
modelEnvConfigured: true,
sandboxEnabled: false,
sandboxEnvConfigured: true,
searchEnvConfigured: true,
});
const { getByTestId } = renderView({ pinia });
expect(getByTestId('intro-stub')).toHaveAttribute('data-connect-model-only', 'false');
await fireEvent.click(getByTestId('intro-setup'));
expect(getByTestId('wizard-stub')).toHaveAttribute('data-step', 'sandbox');
});
it('labels configured Daytona and Brave connections and opens summary edits', async () => {
const { pinia, store } = setupStore({
modelCredentialId: 'model-credential',
modelName: 'custom-model',
sandboxEnabled: true,
sandboxProvider: 'daytona',
daytonaCredentialId: 'sandbox-credential',
searchCredentialId: 'search-credential',
});
store.$patch({
instanceModelCredentials: [
{ id: 'model-credential', name: 'Custom', type: 'customCredential' },
],
serviceCredentials: [{ id: 'search-credential', name: 'Brave', type: 'braveSearchApi' }],
});
const { getByTestId } = renderView({ pinia });
expect(getByTestId('intro-stub')).toHaveAttribute('data-model-value', 'custom-model');
expect(getByTestId('intro-stub')).toHaveAttribute('data-sandbox-value', 'Daytona');
expect(getByTestId('intro-stub')).toHaveAttribute('data-search-value', 'Brave Search');
await fireEvent.click(getByTestId('intro-setup'));
await fireEvent.click(getByTestId('wizard-edit-sandbox'));
expect(getByTestId('wizard-stub')).toHaveAttribute('data-step', 'sandbox');
expect(getByTestId('wizard-stub')).toHaveAttribute('data-edit-mode', 'true');
await fireEvent.click(getByTestId('wizard-open'));
expect(getByTestId('wizard-stub')).toHaveAttribute('data-open', 'true');
});
it('emits completion only when setup is actually complete', async () => {
const incomplete = setupStore({ modelEnvConfigured: true });
const incompleteView = renderView({ pinia: incomplete.pinia });
await fireEvent.click(incompleteView.getByTestId('intro-setup'));
await fireEvent.click(incompleteView.getByTestId('wizard-complete'));
expect(incompleteView.emitted().completed).toBeUndefined();
await fireEvent.click(incompleteView.getByTestId('wizard-close'));
expect(incompleteView.emitted().completed).toBeUndefined();
expect(incompleteView.getByTestId('wizard-stub')).toHaveAttribute('data-open', 'false');
incompleteView.unmount();
const complete = setupStore({
modelEnvConfigured: true,
sandboxEnabled: true,
sandboxEnvConfigured: true,
searchDisabled: true,
});
const completeView = renderView({ pinia: complete.pinia });
await fireEvent.click(completeView.getByTestId('intro-setup'));
await fireEvent.click(completeView.getByTestId('wizard-close'));
expect(completeView.emitted().completed).toEqual([[]]);
expect(completeView.getByTestId('wizard-stub')).toHaveAttribute('data-open', 'false');
});
it('keeps the assistant enabled when turn-off is cancelled', async () => {
const { pinia, store } = setupStore();
confirmMock.mockResolvedValue('cancel');
const { getByTestId } = renderView({ pinia });
await fireEvent.click(getByTestId('intro-turn-off'));
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
expect(store.persistEnabled).not.toHaveBeenCalled();
expect(routerPushMock).not.toHaveBeenCalled();
});
it('turns off the assistant, confirms with a toast, and returns home', async () => {
const { pinia, store } = setupStore();
confirmMock.mockResolvedValue(MODAL_CONFIRM);
vi.mocked(store.persistEnabled).mockResolvedValue(true);
const { getByTestId } = renderView({ pinia });
await fireEvent.click(getByTestId('intro-turn-off'));
await waitFor(() => expect(store.persistEnabled).toHaveBeenCalledWith(false, false));
expect(showMessageMock).toHaveBeenCalledWith({
title: 'instanceAi.onboarding.turnOff.toastTitle',
message: 'instanceAi.onboarding.turnOff.toastDescription',
type: 'success',
});
expect(routerPushMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
});
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { MODAL_CONFIRM, VIEWS } from '@/app/constants';
import { useMessage } from '@/app/composables/useMessage';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useInstanceAiConfiguration } from '../composables/useInstanceAiConfiguration';
import {
INSTANCE_AI_MODEL_PROVIDERS,
INSTANCE_AI_SEARCH_PROVIDERS,
} from '../instanceAiConnection.constants';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import InstanceAiOnboardingIntro from './InstanceAiOnboardingIntro.vue';
import InstanceAiOnboardingWizard from './InstanceAiOnboardingWizard.vue';
import { useInstanceAiOnboarding, type InstanceAiOnboardingStep } from './useInstanceAiOnboarding';
const emit = defineEmits<{ completed: [] }>();
const i18n = useI18n();
const router = useRouter();
const toast = useToast();
const message = useMessage();
const store = useInstanceAiSettingsStore();
const credentialsStore = useCredentialsStore();
const configuration = useInstanceAiConfiguration();
const sandboxEnvConfigured = computed(() => store.settings?.sandboxEnvConfigured === true);
const searchEnvConfigured = computed(() => store.settings?.searchEnvConfigured === true);
const searchDecided = computed(() => configuration.searchState.value !== 'notset');
const onboarding = useInstanceAiOnboarding({
modelConfigured: configuration.modelConfigured,
sandboxConfigured: configuration.sandboxConfigured,
searchDecided,
searchEnvConfigured,
});
const notSet = computed(() => i18n.baseText('instanceAi.onboarding.notSet'));
const modelValue = computed(() => {
if (!configuration.modelConfigured.value) return notSet.value;
if (store.settings?.modelEnvConfigured) {
return i18n.baseText('instanceAi.onboarding.foundOnServer');
}
const provider = INSTANCE_AI_MODEL_PROVIDERS.find(
({ credentialType, id }) =>
id !== 'custom' && credentialType === configuration.modelCredential.value?.type,
)?.id;
return provider && store.settings?.modelName
? `${provider}/${store.settings.modelName}`
: (store.settings?.modelName ?? notSet.value);
});
const sandboxValue = computed(() => {
if (sandboxEnvConfigured.value) {
return i18n.baseText('instanceAi.onboarding.foundOnServer');
}
if (!configuration.sandboxConfigured.value) return notSet.value;
return store.settings?.sandboxProvider === 'daytona' ? 'Daytona' : 'n8n Sandbox';
});
const searchValue = computed(() => {
if (configuration.searchState.value === 'notset') return notSet.value;
if (configuration.searchState.value === 'disabled') {
return i18n.baseText('instanceAi.onboarding.disabled');
}
if (configuration.searchState.value === 'env') {
return i18n.baseText('instanceAi.onboarding.foundOnServer');
}
const credentialType = configuration.searchCredential.value?.type;
return (
INSTANCE_AI_SEARCH_PROVIDERS.find(({ credentialType: type }) => type === credentialType)
?.label ?? i18n.baseText('instanceAi.onboarding.search.label')
);
});
const composeFastPath = computed(
() => configuration.sandboxConfigured.value && searchEnvConfigured.value,
);
const incomplete = computed(() => configuration.hasSetupProgress.value && !composeFastPath.value);
function startAt(step?: Exclude<InstanceAiOnboardingStep, 'done'>): void {
onboarding.start(step ?? onboarding.firstUnmetStep());
}
function editStep(step: Exclude<InstanceAiOnboardingStep, 'done'>): void {
onboarding.start(step, true);
}
function handleWizardOpenChange(open: boolean): void {
if (open) return;
if (configuration.setupCompleted.value) {
finish();
return;
}
onboarding.close();
}
function finish(): void {
if (!configuration.setupCompleted.value) {
onboarding.close();
return;
}
onboarding.close();
emit('completed');
}
async function turnOff(): Promise<void> {
const confirmed = await message.confirm(
i18n.baseText('instanceAi.onboarding.turnOff.description'),
{
title: i18n.baseText('instanceAi.onboarding.turnOff.title'),
confirmButtonText: i18n.baseText('instanceAi.onboarding.turnOff.confirm'),
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
if (confirmed !== MODAL_CONFIRM || !(await store.persistEnabled(false, false))) return;
toast.showMessage({
title: i18n.baseText('instanceAi.onboarding.turnOff.toastTitle'),
message: i18n.baseText('instanceAi.onboarding.turnOff.toastDescription'),
type: 'success',
});
await router.push({ name: VIEWS.HOMEPAGE });
}
onMounted(async () => {
await Promise.all([store.fetch(), credentialsStore.fetchCredentialTypes(false)]);
});
</script>
<template>
<div :class="$style.container">
<InstanceAiOnboardingIntro
v-if="!store.isLoading"
:incomplete="incomplete"
:connect-model-only="composeFastPath"
:model-value="modelValue"
:sandbox-value="sandboxValue"
:search-value="searchValue"
@setup="startAt()"
@open-step="editStep"
@turn-off="turnOff"
/>
<InstanceAiOnboardingWizard
:open="onboarding.open.value"
:step="onboarding.step.value"
:edit-mode="onboarding.editMode.value"
:sequence="onboarding.sequence.value"
:model-value="modelValue"
:sandbox-value="sandboxValue"
:search-value="searchValue"
:compose-fast-path="composeFastPath"
@update:open="handleWizardOpenChange"
@advance="onboarding.advance"
@back="onboarding.back"
@edit="editStep"
@completed="finish"
/>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
}
</style>
@@ -0,0 +1,527 @@
import { createTestingPinia } from '@pinia/testing';
import { fireEvent, waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import InstanceAiOnboardingWizard from './InstanceAiOnboardingWizard.vue';
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({ baseText: (key: string) => key }),
}));
vi.mock('@/app/stores/pushConnection.store', () => ({
usePushConnectionStore: () => ({ addEventListener: vi.fn() }),
}));
vi.mock('@/app/utils/rbac/permissions', () => ({
hasPermission: vi.fn().mockReturnValue(true),
}));
const renderWizard = createComponentRenderer(InstanceAiOnboardingWizard, {
props: {
open: true,
step: 'model',
editMode: false,
sequence: ['model', 'sandbox', 'search', 'done'],
modelValue: 'anthropic/claude-opus-5',
sandboxValue: 'n8n Sandbox',
searchValue: 'Disabled',
composeFastPath: false,
},
});
function inputFor(element: HTMLElement): HTMLInputElement {
if (element instanceof HTMLInputElement) return element;
const input = element.querySelector('input');
if (!input) throw new Error('Expected an input');
return input;
}
function setupStore(overrides: Record<string, unknown> = {}) {
const pinia = createTestingPinia();
setActivePinia(pinia);
const store = useInstanceAiSettingsStore();
store.$patch({
settings: {
enabled: true,
permissions: {},
mcpAccessEnabled: true,
sandboxEnabled: false,
sandboxProvider: 'n8n-sandbox',
daytonaCredentialId: null,
n8nSandboxCredentialId: null,
searchCredentialId: null,
modelCredentialId: null,
modelName: null,
modelEnvConfigured: false,
sandboxEnvConfigured: false,
searchEnvConfigured: false,
searchDisabled: false,
n8nSandboxServiceUrl: null,
envManaged: {
model: { provider: false, apiKey: false, baseUrl: false, model: false },
sandbox: { provider: false, serviceUrl: false, apiKey: false },
search: { provider: false, apiKey: false, url: false },
},
localGatewayDisabled: false,
...overrides,
} as never,
});
vi.mocked(store.save).mockResolvedValue(true);
vi.mocked(store.refreshCredentials).mockResolvedValue(undefined);
vi.mocked(store.refreshInstanceModelCredentials).mockResolvedValue(undefined);
return { pinia, store, credentialsStore: useCredentialsStore() };
}
describe('InstanceAiOnboardingWizard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('verifies and saves a model connection before advancing', async () => {
const { pinia, store } = setupStore();
vi.mocked(store.verifyModel).mockResolvedValue({ ok: true, latencyMs: 125 });
const { emitted, findByTestId, findByText, getByTestId } = renderWizard({ pinia });
const primary = await findByTestId('wizard-primary');
expect(primary).toBeDisabled();
await fireEvent.update(inputFor(getByTestId('assistant-model-api-key')), ' model-key ');
await waitFor(() => expect(primary).not.toBeDisabled());
await fireEvent.click(primary);
await waitFor(() =>
expect(store.verifyModel).toHaveBeenCalledWith({
connection: { type: 'anthropicApi', data: { apiKey: 'model-key' } },
modelName: 'claude-opus-5',
}),
);
expect(store.setField).toHaveBeenCalledWith('modelConnection', {
type: 'anthropicApi',
data: { apiKey: 'model-key' },
});
expect(store.setField).toHaveBeenCalledWith('modelName', 'claude-opus-5');
expect(store.save).toHaveBeenCalledWith(false);
expect(store.refreshInstanceModelCredentials).toHaveBeenCalled();
expect(await findByText('instanceAi.onboarding.model.success')).toBeVisible();
await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 });
});
it('keeps the model step open on verification failure and clears the error after editing', async () => {
const { pinia, store } = setupStore();
vi.mocked(store.verifyModel).mockResolvedValue({ ok: false, failure: 'unauthorized' });
const { emitted, findByTestId, getByTestId, queryByTestId } = renderWizard({ pinia });
const apiKey = inputFor(await findByTestId('assistant-model-api-key'));
await fireEvent.update(apiKey, 'wrong-key');
await fireEvent.click(getByTestId('wizard-primary'));
await waitFor(() => expect(getByTestId('assistant-verification-error')).toBeVisible());
expect(store.save).not.toHaveBeenCalled();
expect(emitted().advance).toBeUndefined();
await fireEvent.update(apiKey, 'new-key');
await waitFor(() => expect(queryByTestId('assistant-verification-error')).toBeNull());
});
it('verifies an environment-managed model without sending a connection', async () => {
const { pinia, store } = setupStore({
modelName: 'claude-opus-5',
modelEnvConfigured: true,
sandboxEnvConfigured: true,
envManaged: {
model: { provider: true, apiKey: true, baseUrl: false, model: false },
sandbox: { provider: true, serviceUrl: true, apiKey: true },
search: { provider: false, apiKey: false, url: false },
},
});
vi.mocked(store.verifyModel).mockResolvedValue({ ok: true, latencyMs: 10 });
const { emitted, findByText, getByTestId } = renderWizard({ pinia });
expect(await findByText('instanceAi.onboarding.env.title')).toBeVisible();
await fireEvent.click(getByTestId('wizard-primary'));
await waitFor(() =>
expect(store.verifyModel).toHaveBeenCalledWith({ modelName: 'claude-opus-5' }),
);
expect(store.setField).toHaveBeenCalledWith('modelName', 'claude-opus-5');
expect(store.setField).toHaveBeenCalledWith('sandboxEnabled', true);
await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 });
});
it('supports a custom OpenAI-compatible model without an API key', async () => {
const { pinia, store } = setupStore();
vi.mocked(store.verifyModel).mockResolvedValue({ ok: true });
const { findByTestId, findByText, getByTestId } = renderWizard({ pinia });
const providerSelect = await findByTestId('assistant-model-provider');
await fireEvent.click(inputFor(providerSelect));
await fireEvent.click(await findByText('instanceAi.onboarding.model.customProvider'));
await fireEvent.update(
inputFor(getByTestId('assistant-model-base-url')),
' http://ollama:11434/v1 ',
);
await fireEvent.update(inputFor(getByTestId('assistant-model-name')), ' qwen3-coder ');
await fireEvent.click(getByTestId('wizard-primary'));
await waitFor(() =>
expect(store.verifyModel).toHaveBeenCalledWith({
connection: { type: 'openAiApi', data: { url: 'http://ollama:11434/v1' } },
modelName: 'qwen3-coder',
}),
);
});
it('verifies and saves an n8n Sandbox connection', async () => {
const { pinia, store } = setupStore();
vi.mocked(store.verifySandbox).mockResolvedValue({ ok: true, startupMs: 1500 });
const { emitted, findByTestId, findByText, getByTestId } = renderWizard({
pinia,
props: { step: 'sandbox' },
});
await fireEvent.click(await findByTestId('assistant-sandbox-n8n-sandbox'));
await fireEvent.update(inputFor(getByTestId('assistant-sandbox-url')), ' http://sandbox:3200 ');
await fireEvent.update(inputFor(getByTestId('assistant-sandbox-api-key')), ' sandbox-key ');
await fireEvent.click(getByTestId('wizard-primary'));
const connection = {
type: 'httpHeaderAuth',
data: { name: 'x-api-key', value: 'sandbox-key' },
};
await waitFor(() =>
expect(store.verifySandbox).toHaveBeenCalledWith({
provider: 'n8n-sandbox',
connection,
serviceUrl: 'http://sandbox:3200',
}),
);
expect(store.setField).toHaveBeenCalledWith('sandboxConnection', connection);
expect(store.setField).toHaveBeenCalledWith('sandboxProvider', 'n8n-sandbox');
expect(store.setField).toHaveBeenCalledWith('sandboxEnabled', true);
expect(store.setField).toHaveBeenCalledWith('n8nSandboxServiceUrl', 'http://sandbox:3200');
expect(store.refreshCredentials).toHaveBeenCalled();
expect(await findByText('instanceAi.onboarding.sandbox.success')).toBeVisible();
await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 });
});
it('hydrates and verifies a saved Daytona connection', async () => {
const { pinia, store, credentialsStore } = setupStore({
sandboxProvider: 'daytona',
daytonaCredentialId: 'daytona-credential',
});
vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({
data: { apiKey: 'saved-daytona-key' },
} as never);
vi.mocked(store.verifySandbox).mockResolvedValue({ ok: true });
const { getByTestId } = renderWizard({ pinia, props: { step: 'sandbox' } });
await waitFor(() =>
expect(inputFor(getByTestId('assistant-daytona-api-key')).value).toBe('saved-daytona-key'),
);
await fireEvent.update(
inputFor(getByTestId('assistant-daytona-api-key')),
'updated-daytona-key',
);
await fireEvent.click(getByTestId('wizard-primary'));
await waitFor(() =>
expect(store.verifySandbox).toHaveBeenCalledWith({
provider: 'daytona',
connection: {
type: 'daytonaApi',
data: {
apiUrl: 'https://app.daytona.io/api',
apiKey: 'updated-daytona-key',
},
},
}),
);
});
it('selects and assigns an existing Daytona credential in settings', async () => {
const { pinia, store } = setupStore();
store.$patch({
serviceCredentials: [
{ id: 'existing-daytona', name: 'Existing Daytona', type: 'daytonaApi' },
{ id: 'existing-sandbox', name: 'Existing Sandbox', type: 'httpHeaderAuth' },
],
});
const { emitted, findByTestId, findByText, getByTestId } = renderWizard({
pinia,
props: { step: 'sandbox', editMode: true, surface: 'settings' },
});
const existingCredential = await findByTestId('n8n-agent-sandbox-existing-credential-select');
await fireEvent.click(inputFor(existingCredential));
await fireEvent.click(await findByText('Existing Daytona · Daytona'));
await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save'));
await waitFor(() =>
expect(store.setField).toHaveBeenCalledWith('daytonaCredentialId', 'existing-daytona'),
);
expect(store.setField).toHaveBeenCalledWith('n8nSandboxCredentialId', null);
expect(store.setField).toHaveBeenCalledWith('sandboxProvider', 'daytona');
expect(store.setField).toHaveBeenCalledWith('sandboxEnabled', true);
expect(store.refreshCredentials).toHaveBeenCalled();
await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 });
});
it('confirms an environment-managed sandbox and enables it', async () => {
const { pinia, store } = setupStore({
sandboxProvider: 'n8n-sandbox',
sandboxEnvConfigured: true,
});
vi.mocked(store.verifySandbox).mockResolvedValue({ ok: true });
const { emitted, findByText, getByTestId } = renderWizard({
pinia,
props: { step: 'sandbox' },
});
expect(await findByText('instanceAi.onboarding.env.title')).toBeVisible();
await fireEvent.click(getByTestId('wizard-primary'));
await waitFor(() =>
expect(store.verifySandbox).toHaveBeenCalledWith({ provider: 'n8n-sandbox' }),
);
expect(store.setField).toHaveBeenCalledWith('sandboxEnabled', true);
expect(store.save).toHaveBeenCalledWith(false);
await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 });
});
it('records disabled web search without calling verification', async () => {
const { pinia, store } = setupStore();
const { emitted, findByTestId, findByText, getByTestId } = renderWizard({
pinia,
props: { step: 'search' },
});
expect(
(await findByText('instanceAi.onboarding.search.free')).closest('.n8n-badge'),
).not.toBeNull();
await fireEvent.click(await findByTestId('assistant-search-disabled'));
await fireEvent.click(getByTestId('wizard-primary'));
await waitFor(() => expect(store.save).toHaveBeenCalledWith(false));
expect(store.verifySearch).not.toHaveBeenCalled();
expect(store.setField).toHaveBeenCalledWith('searchDisabled', true);
expect(store.refreshCredentials).toHaveBeenCalled();
await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 });
});
it('links Brave Search to its API key dashboard', async () => {
const { pinia } = setupStore();
const { findByTestId, findByText } = renderWizard({
pinia,
props: { step: 'search' },
});
await fireEvent.click(await findByTestId('assistant-search-brave'));
const link = (await findByText('instanceAi.onboarding.search.braveKeyLink')).closest('a');
expect(link).toHaveAttribute('href', 'https://api-dashboard.search.brave.com/app/keys');
});
it('accepts environment-managed search without sending or saving credentials', async () => {
const { pinia, store } = setupStore({ searchEnvConfigured: true });
const { emitted, findByText, getByTestId } = renderWizard({
pinia,
props: { step: 'search' },
});
expect(await findByText('instanceAi.onboarding.env.title')).toBeVisible();
await fireEvent.click(getByTestId('wizard-primary'));
await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 });
expect(store.verifySearch).not.toHaveBeenCalled();
expect(store.save).not.toHaveBeenCalled();
});
it.each([
['searxng', 'searXngApi', 'apiUrl', 'http://searxng:8080'],
['brave', 'braveSearchApi', 'apiKey', 'brave-key'],
] as const)('verifies and saves a %s search connection', async (provider, type, field, value) => {
const { pinia, store } = setupStore();
vi.mocked(store.verifySearch).mockResolvedValue({ ok: true, resultCount: 10 });
const { emitted, findByTestId, findByText, getByTestId } = renderWizard({
pinia,
props: { step: 'search' },
});
await fireEvent.click(await findByTestId(`assistant-search-${provider}`));
await fireEvent.update(inputFor(getByTestId('assistant-search-value')), ` ${value} `);
await fireEvent.click(getByTestId('wizard-primary'));
const connection = { type, data: { [field]: value } };
await waitFor(() => expect(store.verifySearch).toHaveBeenCalledWith({ connection }));
expect(store.setField).toHaveBeenCalledWith('searchConnection', connection);
expect(store.setField).toHaveBeenCalledWith('searchDisabled', false);
expect(await findByText('instanceAi.onboarding.search.success')).toBeVisible();
await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 });
});
it('hydrates a saved search credential and handles verification errors', async () => {
const { pinia, store, credentialsStore } = setupStore({
searchCredentialId: 'search-credential',
});
store.$patch({
serviceCredentials: [
{ id: 'search-credential', name: 'Brave Search', type: 'braveSearchApi' },
],
});
vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({
data: { apiKey: 'saved-search-key' },
} as never);
vi.mocked(store.verifySearch).mockRejectedValue(new Error('request failed'));
const { emitted, findByText, getByTestId } = renderWizard({
pinia,
props: { step: 'search' },
});
await waitFor(() =>
expect(inputFor(getByTestId('assistant-search-value')).value).toBe('saved-search-key'),
);
await fireEvent.click(getByTestId('wizard-primary'));
expect(await findByText('instanceAi.onboarding.verification.provider_error')).toBeVisible();
expect(store.save).not.toHaveBeenCalled();
expect(emitted().advance).toBeUndefined();
});
it('selects and assigns an existing Brave Search credential in settings', async () => {
const { pinia, store } = setupStore();
store.$patch({
serviceCredentials: [
{ id: 'existing-brave', name: 'Existing Brave', type: 'braveSearchApi' },
{ id: 'existing-searxng', name: 'Existing SearXNG', type: 'searXngApi' },
],
});
const { emitted, findByTestId, findByText, getByTestId } = renderWizard({
pinia,
props: { step: 'search', editMode: true, surface: 'settings' },
});
const existingCredential = await findByTestId('n8n-agent-search-existing-credential-select');
await fireEvent.click(inputFor(existingCredential));
await fireEvent.click(await findByText('Existing Brave · braveSearchApi'));
await fireEvent.click(getByTestId('n8n-agent-search-dialog-save'));
await waitFor(() =>
expect(store.setField).toHaveBeenCalledWith('searchCredentialId', 'existing-brave'),
);
expect(store.setField).toHaveBeenCalledWith('searchDisabled', false);
expect(store.refreshCredentials).toHaveBeenCalled();
await waitFor(() => expect(emitted().advance).toEqual([[]]));
});
it('does not offer existing credentials during onboarding', async () => {
const { pinia, store } = setupStore();
store.$patch({
instanceModelCredentials: [
{ id: 'first-model', name: 'First model', type: 'openAiApi' },
{ id: 'second-model', name: 'Second model', type: 'anthropicApi' },
],
});
const { findByTestId, queryByTestId } = renderWizard({ pinia });
expect(await findByTestId('assistant-model-provider')).toBeVisible();
expect(queryByTestId('assistant-existing-credential')).toBeNull();
});
it('restores the assigned model connection after switching providers', async () => {
const { pinia, store, credentialsStore } = setupStore({
modelCredentialId: 'assigned-openai',
modelName: 'gpt-5.6-sol',
});
store.$patch({
instanceModelCredentials: [
{ id: 'assigned-openai', name: 'Current OpenAI', type: 'openAiApi' },
],
});
vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({
data: { apiKey: 'saved-openai-key' },
} as never);
const { findAllByText, findByTestId, findByText, getByTestId } = renderWizard({ pinia });
await waitFor(() =>
expect(inputFor(getByTestId('assistant-model-api-key')).value).toBe('saved-openai-key'),
);
const provider = await findByTestId('assistant-model-provider');
await fireEvent.click(inputFor(provider));
await fireEvent.click(await findByText('Anthropic'));
expect(inputFor(getByTestId('assistant-model-api-key')).value).toBe('');
await fireEvent.click(inputFor(provider));
const openAiOptions = await findAllByText('OpenAI');
await fireEvent.click(openAiOptions.at(-1)!);
await waitFor(() =>
expect(inputFor(getByTestId('assistant-model-api-key')).value).toBe('saved-openai-key'),
);
});
it('opens summary rows for editing and completes from the done step', async () => {
const { pinia } = setupStore();
const { emitted, findByText, getByTestId } = renderWizard({
pinia,
props: { step: 'done' },
});
await fireEvent.click(
(await findByText('instanceAi.onboarding.model.label')).closest('button')!,
);
await fireEvent.click(
(await findByText('instanceAi.onboarding.sandbox.label')).closest('button')!,
);
await fireEvent.click(
(await findByText('instanceAi.onboarding.search.label')).closest('button')!,
);
await fireEvent.click(getByTestId('wizard-primary'));
expect(emitted().edit).toEqual([['model'], ['sandbox'], ['search']]);
expect(emitted().completed).toEqual([[]]);
});
it('completes from the compact compose fast path', async () => {
const { pinia } = setupStore();
const { emitted, findByTestId, queryByText } = renderWizard({
pinia,
props: { step: 'done', composeFastPath: true },
});
expect(queryByText('anthropic/claude-opus-5')).toBeNull();
await fireEvent.click(await findByTestId('wizard-primary'));
expect(emitted().completed).toEqual([[]]);
});
it('emits back from later setup steps', async () => {
const { pinia } = setupStore();
const { emitted, findByTestId } = renderWizard({
pinia,
props: { step: 'sandbox' },
});
expect(await findByTestId('wizard-progress')).toBeVisible();
await fireEvent.click(await findByTestId('wizard-back'));
expect(emitted().back).toEqual([[]]);
});
it('uses cancel and save without progress controls in direct edit mode', async () => {
const { pinia } = setupStore();
const { emitted, findByTestId, findByText, queryByTestId } = renderWizard({
pinia,
props: { step: 'search', editMode: true },
});
expect(await findByText('generic.save')).toBeVisible();
expect(queryByTestId('wizard-back')).toBeNull();
expect(queryByTestId('wizard-progress')).toBeNull();
await fireEvent.click(await findByTestId('wizard-cancel'));
expect(emitted()['update:open']).toEqual([[false]]);
});
});
@@ -0,0 +1,135 @@
import { ref } from 'vue';
import { useInstanceAiOnboarding } from './useInstanceAiOnboarding';
function createConfiguration() {
return {
modelConfigured: ref(false),
sandboxConfigured: ref(false),
searchDecided: ref(false),
searchEnvConfigured: ref(false),
};
}
describe('useInstanceAiOnboarding', () => {
it('resumes at the first unmet setup step', () => {
const configuration = createConfiguration();
const onboarding = useInstanceAiOnboarding(configuration);
expect(onboarding.firstUnmetStep()).toBe('model');
configuration.modelConfigured.value = true;
expect(onboarding.firstUnmetStep()).toBe('sandbox');
configuration.sandboxConfigured.value = true;
expect(onboarding.firstUnmetStep()).toBe('search');
configuration.searchDecided.value = true;
expect(onboarding.firstUnmetStep()).toBe('done');
});
it('omits configured services from the setup sequence', () => {
const configuration = createConfiguration();
configuration.sandboxConfigured.value = true;
configuration.searchEnvConfigured.value = true;
configuration.searchDecided.value = true;
const onboarding = useInstanceAiOnboarding(configuration);
expect(onboarding.sequence.value).toEqual(['model', 'done']);
onboarding.start();
configuration.modelConfigured.value = true;
onboarding.advance();
expect(onboarding.step.value).toBe('done');
});
it('keeps a disabled sandbox in the sequence until it is enabled', () => {
const configuration = createConfiguration();
configuration.modelConfigured.value = true;
configuration.searchEnvConfigured.value = true;
configuration.searchDecided.value = true;
const onboarding = useInstanceAiOnboarding(configuration);
expect(onboarding.sequence.value).toEqual(['model', 'sandbox', 'done']);
expect(onboarding.firstUnmetStep()).toBe('sandbox');
configuration.sandboxConfigured.value = true;
expect(onboarding.sequence.value).toEqual(['model', 'done']);
expect(onboarding.firstUnmetStep()).toBe('done');
});
it('returns to an earlier unmet prerequisite after completing a later checklist step', () => {
const configuration = createConfiguration();
configuration.modelConfigured.value = true;
const onboarding = useInstanceAiOnboarding(configuration);
onboarding.start('search');
configuration.searchDecided.value = true;
onboarding.advance();
expect(onboarding.step.value).toBe('sandbox');
});
it('returns to the summary after applying a single-step edit', () => {
const configuration = createConfiguration();
configuration.modelConfigured.value = true;
configuration.sandboxConfigured.value = true;
configuration.searchDecided.value = true;
const onboarding = useInstanceAiOnboarding(configuration);
onboarding.start('sandbox', true);
onboarding.advance();
expect(onboarding.step.value).toBe('done');
expect(onboarding.editMode.value).toBe(false);
expect(onboarding.open.value).toBe(true);
});
it('closes a direct edit when other setup steps are still incomplete', () => {
const configuration = createConfiguration();
configuration.modelConfigured.value = true;
const onboarding = useInstanceAiOnboarding(configuration);
onboarding.start('search', true);
configuration.searchDecided.value = true;
onboarding.advance();
expect(onboarding.open.value).toBe(false);
expect(onboarding.editMode.value).toBe(false);
expect(onboarding.firstUnmetStep()).toBe('sandbox');
});
it('clears edit mode when the wizard closes', () => {
const onboarding = useInstanceAiOnboarding(createConfiguration());
onboarding.start('search', true);
onboarding.close();
expect(onboarding.open.value).toBe(false);
expect(onboarding.editMode.value).toBe(false);
});
it('moves back through the visible setup sequence', () => {
const onboarding = useInstanceAiOnboarding(createConfiguration());
onboarding.start('search');
onboarding.back();
expect(onboarding.step.value).toBe('sandbox');
onboarding.back();
expect(onboarding.step.value).toBe('model');
onboarding.back();
expect(onboarding.step.value).toBe('model');
});
it('returns to the summary when backing out of an edit', () => {
const onboarding = useInstanceAiOnboarding(createConfiguration());
onboarding.start('model', true);
onboarding.back();
expect(onboarding.step.value).toBe('done');
expect(onboarding.editMode.value).toBe(false);
});
});
@@ -0,0 +1,69 @@
import { computed, ref, type Ref } from 'vue';
export type InstanceAiOnboardingStep = 'model' | 'sandbox' | 'search' | 'done';
export interface InstanceAiOnboardingConfiguration {
modelConfigured: Ref<boolean>;
sandboxConfigured: Ref<boolean>;
searchDecided: Ref<boolean>;
searchEnvConfigured: Ref<boolean>;
}
export function useInstanceAiOnboarding(configuration: InstanceAiOnboardingConfiguration) {
const open = ref(false);
const step = ref<InstanceAiOnboardingStep>('model');
const editMode = ref(false);
const sequence = computed<InstanceAiOnboardingStep[]>(() => [
'model',
...(configuration.sandboxConfigured.value ? [] : (['sandbox'] as const)),
...(configuration.searchEnvConfigured.value ? [] : (['search'] as const)),
'done',
]);
function firstUnmetStep(): InstanceAiOnboardingStep {
if (!configuration.modelConfigured.value) return 'model';
if (!configuration.sandboxConfigured.value) return 'sandbox';
if (!configuration.searchDecided.value && !configuration.searchEnvConfigured.value) {
return 'search';
}
return 'done';
}
function start(target = firstUnmetStep(), editing = false): void {
step.value = target;
editMode.value = editing;
open.value = true;
}
function close(): void {
open.value = false;
editMode.value = false;
}
function advance(): void {
if (editMode.value) {
editMode.value = false;
const nextStep = firstUnmetStep();
if (nextStep === 'done') {
step.value = nextStep;
} else {
open.value = false;
}
return;
}
step.value = firstUnmetStep();
}
function back(): void {
if (editMode.value) {
step.value = 'done';
editMode.value = false;
return;
}
const index = sequence.value.indexOf(step.value);
step.value = sequence.value[Math.max(0, index - 1)] ?? 'model';
}
return { open, step, editMode, sequence, firstUnmetStep, start, close, advance, back };
}
@@ -33,6 +33,7 @@ import { useInstanceAiBrowserUseExperiment } from '@/experiments/instanceAiBrows
import { useInstanceAiComputerUseExperiment } from '@/experiments/instanceAiComputerUse';
import { useInstanceAiMcpConnectionsExperiment } from '@/experiments/instanceAiMcpConnections';
import { useInstanceCredentialTest } from '../composables/useInstanceCredentialTest';
import { useInstanceAiConfiguration } from '../composables/useInstanceAiConfiguration';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import { SANDBOX_PROVIDER_LABELS, type InstanceAiConnectionKind } from '../constants';
import ConnectionDialog from '../components/settings/ConnectionDialog.vue';
@@ -45,13 +46,21 @@ const settingsStore = useSettingsStore();
const credentialsStore = useCredentialsStore();
const store = useInstanceAiSettingsStore();
const { isTestingCredential, testSavedCredential } = useInstanceCredentialTest();
const {
modelCredential,
modelConfigured: isModelConfigured,
sandboxCredentialId,
sandboxConfigured: isSandboxConfigured,
searchCredential,
searchState,
} = useInstanceAiConfiguration();
const { isFeatureEnabled: isMcpConnectionsExperimentEnabled } =
useInstanceAiMcpConnectionsExperiment();
const { isFeatureEnabled: isBrowserUseEnabled } = useInstanceAiBrowserUseExperiment();
const { isFeatureEnabled: isComputerUseExperimentEnabled } = useInstanceAiComputerUseExperiment();
const DOCS_URL = 'https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-ai-assistant-preview';
const DOCS_URL = 'https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-ai-assistant';
const isAdmin = computed(() => store.canManage);
const isEnabled = computed(
@@ -62,19 +71,27 @@ const isMcpAccessEnabled = computed(() => store.settings?.mcpAccessEnabled ?? tr
const isSelfManaged = computed(() => !store.isProxyEnabled && !store.isCloudManaged);
const showCredentialsRows = computed(() => isAdmin.value && isSelfManaged.value);
const showSandboxRow = computed(() => isAdmin.value && !store.isCloudManaged);
const isModelConnectionEnvManaged = computed(
() =>
isSelfManaged.value &&
(store.settings?.envManaged?.model.provider ?? store.settings?.modelEnvConfigured ?? false),
);
const isModelNameEnvManaged = computed(
() => isSelfManaged.value && (store.settings?.envManaged?.model.model ?? false),
);
const isModelReadOnly = computed(
() => isModelConnectionEnvManaged.value && isModelNameEnvManaged.value,
);
const isSandboxEnvManaged = computed(
() => isSelfManaged.value && (store.settings?.sandboxEnvConfigured ?? false),
);
const isSearchEnvManaged = computed(
() => isSelfManaged.value && (store.settings?.searchEnvConfigured ?? false),
);
const modelCredential = computed(() =>
store.instanceModelCredentials.find(
(credential) => credential.id === store.settings?.modelCredentialId,
),
);
const isModelConfigured = computed(() =>
Boolean(
store.settings?.modelEnvConfigured ||
(store.settings?.modelCredentialId && store.settings.modelName),
),
);
const modelValue = computed(() => {
if (isModelConnectionEnvManaged.value)
return i18n.baseText('instanceAi.onboarding.foundOnServer');
if (store.settings?.modelCredentialId) {
const typeLabel = modelCredential.value ? credentialTypeLabel(modelCredential.value.type) : '';
const modelName = store.settings.modelName ?? '';
@@ -83,21 +100,14 @@ const modelValue = computed(() => {
return i18n.baseText('settings.n8nAgent.modelCredential.env.value');
});
const modelDescription = computed<{ key: BaseTextKey; warning: boolean } | null>(() => {
if (store.settings?.modelCredentialId && store.settings.modelName) return null;
if (store.settings?.modelEnvConfigured)
if (isModelConnectionEnvManaged.value)
return { key: 'settings.n8nAgent.modelCredential.env.description', warning: false };
if (store.settings?.modelCredentialId && store.settings.modelName) return null;
return { key: 'settings.n8nAgent.modelCredential.missing.description', warning: !isOff.value };
});
const sandboxCredentialId = computed(() =>
store.settings?.sandboxProvider === 'daytona'
? store.settings?.daytonaCredentialId
: store.settings?.n8nSandboxCredentialId,
);
const isSandboxConfigured = computed(() =>
Boolean(sandboxCredentialId.value ?? store.settings?.sandboxEnvConfigured),
);
const sandboxValue = computed(() => {
if (isSandboxEnvManaged.value) return i18n.baseText('instanceAi.onboarding.foundOnServer');
if (sandboxCredentialId.value) {
return store.settings?.sandboxProvider === 'daytona'
? SANDBOX_PROVIDER_LABELS.daytona
@@ -106,25 +116,16 @@ const sandboxValue = computed(() => {
return i18n.baseText('settings.n8nAgent.sandbox.env.value');
});
const sandboxDescription = computed<{ key: BaseTextKey; warning: boolean }>(() => {
if (isSandboxEnvManaged.value)
return { key: 'settings.n8nAgent.sandbox.env.description', warning: false };
if (sandboxCredentialId.value)
return { key: 'settings.n8nAgent.sandbox.set.description', warning: false };
if (store.settings?.sandboxEnvConfigured)
return { key: 'settings.n8nAgent.sandbox.env.description', warning: false };
return { key: 'settings.n8nAgent.sandbox.missing.description', warning: !isOff.value };
});
const searchCredential = computed(() =>
store.serviceCredentials.find(
(credential) => credential.id === store.settings?.searchCredentialId,
),
);
const searchState = computed<'set' | 'env' | 'notset'>(() => {
if (store.settings?.searchCredentialId) return 'set';
if (store.settings?.searchEnvConfigured) return 'env';
return 'notset';
});
const searchValue = computed(() => {
if (searchState.value === 'env') return i18n.baseText('settings.n8nAgent.search.env.value');
if (isSearchEnvManaged.value) return i18n.baseText('instanceAi.onboarding.foundOnServer');
if (searchState.value === 'disabled') return i18n.baseText('instanceAi.onboarding.disabled');
return searchCredential.value ? credentialTypeLabel(searchCredential.value.type) : '';
});
@@ -132,7 +133,8 @@ const isSetupRequired = computed(
() =>
isEnabled.value &&
((showCredentialsRows.value && !isModelConfigured.value) ||
(showSandboxRow.value && !isSandboxConfigured.value)),
(showSandboxRow.value && !isSandboxConfigured.value) ||
(showCredentialsRows.value && searchState.value === 'notset')),
);
const neverConfigured = computed(() => {
if (isEnabled.value) return false;
@@ -271,51 +273,84 @@ function setDialogOpen(kind: InstanceAiConnectionKind, isOpen: boolean) {
}
function openModelDialog() {
if (isModelReadOnly.value) return;
setupChain.value = false;
activeDialog.value = 'model';
}
function openModelSetup() {
setupChain.value = !isSandboxConfigured.value;
setupChain.value =
(!isSandboxConfigured.value && !isSandboxEnvManaged.value) || searchState.value === 'notset';
activeDialog.value = 'model';
}
function openSandboxDialog() {
if (isSandboxEnvManaged.value) return;
setupChain.value = false;
activeDialog.value = 'sandbox';
}
function openSearchDialog() {
if (isSearchEnvManaged.value) return;
setupChain.value = false;
activeDialog.value = 'search';
}
function openSearchSetup(): void {
activeDialog.value = 'search';
}
/** Returns whether the chain may continue (false only when enabling failed). */
async function finishSetup(): Promise<boolean> {
setupChain.value = false;
if (!enableAfterSetup.value) return true;
if (!enableAfterSetup.value) {
activeDialog.value = null;
return true;
}
enableAfterSetup.value = false;
return await store.persistEnabled(true);
const enabled = await store.persistEnabled(true);
if (enabled) activeDialog.value = null;
return enabled;
}
async function handleModelSaved() {
if ((setupChain.value || enableAfterSetup.value) && !(await enableEnvironmentSandboxIfNeeded()))
return;
if (setupChain.value) {
activeDialog.value = 'sandbox';
if (isSandboxConfigured.value || isSandboxEnvManaged.value) {
openSearchSetup();
} else {
activeDialog.value = 'sandbox';
}
return;
}
await finishSetup();
}
async function handleSandboxSaved() {
// The optional search step never gates enablement; enable first, then offer it.
const chainSearch = setupChain.value && searchState.value === 'notset';
if (!(await finishSetup())) return;
if (chainSearch) {
setupChain.value = true;
activeDialog.value = 'search';
openSearchSetup();
return;
}
await finishSetup();
}
async function handleSearchSaved() {
await finishSetup();
}
function credentialTypeLabel(type: string) {
return credentialsStore.getCredentialTypeByName(type)?.displayName ?? type;
}
async function enableEnvironmentSandboxIfNeeded(): Promise<boolean> {
if (!isSandboxEnvManaged.value || isSandboxConfigured.value) return true;
store.setField('sandboxEnabled', true);
return await store.save();
}
onMounted(() => {
documentTitle.set(i18n.baseText('settings.n8nAgent'));
void store.fetch();
@@ -350,7 +385,9 @@ async function handleEnable() {
return;
}
if (showSandboxRow.value && !isSandboxConfigured.value) {
if (!(await enableEnvironmentSandboxIfNeeded())) return;
if (showSandboxRow.value && !isSandboxConfigured.value && !isSandboxEnvManaged.value) {
openSandboxDialog();
return;
}
@@ -369,6 +406,12 @@ async function handleEnable() {
}
}
if (showCredentialsRows.value && searchState.value === 'notset') {
setupChain.value = true;
openSearchSetup();
return;
}
await finishSetup();
}
@@ -509,7 +552,7 @@ function openAiUsageSettings() {
<N8nSettingsRow
v-if="showCredentialsRows"
:class="{ [$style.dim]: isOff }"
:clickable="!isOff && isModelConfigured"
:clickable="!isOff && isModelConfigured && !isModelReadOnly"
data-test-id="n8n-agent-model-row"
@click="openModelDialog"
>
@@ -535,6 +578,14 @@ function openAiUsageSettings() {
data-test-id="n8n-agent-model-add"
@click="openModelSetup"
/>
<N8nText
v-else-if="isModelReadOnly"
size="small"
color="text-light"
data-test-id="n8n-agent-model-env-value"
>
{{ modelValue }}
</N8nText>
<N8nSettingsRowConfigure v-else :value="modelValue" />
</template>
</N8nSettingsRow>
@@ -542,7 +593,7 @@ function openAiUsageSettings() {
<N8nSettingsRow
v-if="showSandboxRow"
:class="{ [$style.dim]: isOff }"
:clickable="!isOff && isSandboxConfigured"
:clickable="!isOff && isSandboxConfigured && !isSandboxEnvManaged"
data-test-id="n8n-agent-sandbox-row"
@click="openSandboxDialog"
>
@@ -556,7 +607,24 @@ function openAiUsageSettings() {
</template>
<template v-if="!isOff" #action>
<N8nButton
v-if="!isSandboxConfigured"
v-if="isSandboxEnvManaged && !isSandboxConfigured"
variant="solid"
size="medium"
:label="i18n.baseText('settings.n8nAgent.sandbox.enable')"
:disabled="store.isSaving"
data-test-id="n8n-agent-sandbox-enable"
@click="enableEnvironmentSandboxIfNeeded"
/>
<N8nText
v-else-if="isSandboxEnvManaged"
size="small"
color="text-light"
data-test-id="n8n-agent-sandbox-env-value"
>
{{ sandboxValue }}
</N8nText>
<N8nButton
v-else-if="!isSandboxConfigured"
variant="solid"
size="medium"
:label="i18n.baseText('settings.n8nAgent.sandbox.add')"
@@ -579,9 +647,9 @@ function openAiUsageSettings() {
<N8nSettingsRow
v-if="showCredentialsRows"
:class="{ [$style.dim]: isOff }"
:clickable="!isOff && searchState !== 'notset'"
:clickable="!isOff && searchState !== 'notset' && !isSearchEnvManaged"
data-test-id="n8n-agent-search-row"
@click="activeDialog = 'search'"
@click="openSearchDialog"
>
<template #info>
<span :class="$style.titleWithTag">
@@ -608,8 +676,16 @@ function openAiUsageSettings() {
:label="i18n.baseText('settings.n8nAgent.search.setup')"
:disabled="store.isSaving"
data-test-id="n8n-agent-search-setup"
@click="activeDialog = 'search'"
@click="openSearchDialog"
/>
<N8nText
v-else-if="isSearchEnvManaged"
size="small"
color="text-light"
data-test-id="n8n-agent-search-env-value"
>
{{ searchValue }}
</N8nText>
<N8nSettingsRowConfigure v-else :value="searchValue" />
</template>
</N8nSettingsRow>
@@ -745,7 +821,7 @@ function openAiUsageSettings() {
</template>
<ConnectionDialog
v-if="showCredentialsRows"
v-if="showCredentialsRows && !isModelReadOnly"
kind="model"
:open="activeDialog === 'model'"
:setup="setupChain"
@@ -753,21 +829,20 @@ function openAiUsageSettings() {
@saved="handleModelSaved"
/>
<ConnectionDialog
v-if="showSandboxRow"
v-if="showSandboxRow && !isSandboxEnvManaged"
kind="sandbox"
:open="activeDialog === 'sandbox'"
:setup="showCredentialsRows && setupChain"
@update:open="setDialogOpen('sandbox', $event)"
@saved="handleSandboxSaved"
@back="activeDialog = 'model'"
/>
<ConnectionDialog
v-if="showCredentialsRows"
v-if="showCredentialsRows && !isSearchEnvManaged"
kind="search"
:open="activeDialog === 'search'"
:setup="setupChain"
@update:open="setDialogOpen('search', $event)"
@back="activeDialog = 'sandbox'"
@saved="handleSearchSaved"
/>
</N8nSettingsLayout>
</template>
@@ -7,6 +7,7 @@ import ProjectsNavigation from './ProjectNavigation.vue';
import { useProjectsStore } from '../projects.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useUsersStore } from '@n8n/stores/users.store';
import { useRBACStore } from '@n8n/stores/rbac.store';
vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router');
@@ -68,6 +69,10 @@ const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team
describe('ProjectsNavigation', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', {
getItem: vi.fn().mockReturnValue(null),
setItem: vi.fn(),
});
createTestingPinia();
projectsStore = mockedStore(useProjectsStore);
@@ -75,6 +80,32 @@ describe('ProjectsNavigation', () => {
usersStore = mockedStore(useUsersStore);
});
function configureInstanceAi(setupCompleted: boolean) {
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
settingsStore.moduleSettings = {
'instance-ai': {
enabled: true,
localGatewayDisabled: false,
browserUseEnabled: true,
proxyEnabled: false,
cloudManaged: false,
setupCompleted,
sandboxEnabled: true,
workflowBuilderAvailable: true,
sandboxUnavailableReason: null,
runDebugEnabled: false,
},
};
}
function configureInstanceAiScopes({ canManage }: { canManage: boolean }) {
vi.mocked(useRBACStore().hasScope).mockImplementation((scope) => {
if (scope === 'instanceAi:manage') return canManage;
if (scope === 'instanceAi:message') return true;
return false;
});
}
it('should not throw an error', () => {
projectsStore.teamProjectsLimit = -1;
expect(() => {
@@ -103,22 +134,10 @@ describe('ProjectsNavigation', () => {
expect(getAllByTestId('project-menu-item')).toHaveLength(teamProjects.length);
});
it('should show Instance AI above Home when enabled', () => {
it('should show Instance AI above Home for a member after setup is complete', () => {
projectsStore.teamProjectsLimit = -1;
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
settingsStore.moduleSettings = {
'instance-ai': {
enabled: true,
localGatewayDisabled: false,
browserUseEnabled: true,
proxyEnabled: false,
cloudManaged: false,
sandboxEnabled: true,
workflowBuilderAvailable: true,
sandboxUnavailableReason: null,
runDebugEnabled: false,
},
};
configureInstanceAiScopes({ canManage: false });
configureInstanceAi(true);
const { getByTestId } = renderComponent({
props: {
@@ -133,6 +152,26 @@ describe('ProjectsNavigation', () => {
).toBeTruthy();
});
it('should hide Instance AI from a member until setup is complete', () => {
projectsStore.teamProjectsLimit = -1;
configureInstanceAiScopes({ canManage: false });
configureInstanceAi(false);
const { queryByTestId } = renderComponent({ props: { collapsed: false } });
expect(queryByTestId('project-instance-ai-menu-item')).toBeNull();
});
it('should show Instance AI to an admin before setup is complete', () => {
projectsStore.teamProjectsLimit = -1;
configureInstanceAiScopes({ canManage: true });
configureInstanceAi(false);
const { getByTestId } = renderComponent({ props: { collapsed: false } });
expect(getByTestId('project-instance-ai-menu-item')).toBeVisible();
});
it('should not show "Projects" title when the menu is collapsed', async () => {
projectsStore.teamProjectsLimit = -1;
@@ -15,6 +15,7 @@ import { CHAT_VIEW } from '@/features/ai/chatHub/constants';
import { useFavoritesStore } from '@/app/stores/favorites.store';
import { useFavoriteNavItems } from '../composables/useFavoriteNavItems';
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability';
import { WORKFLOW_REVIEW_REQUESTS_VIEW } from '@/features/workflow-reviews/constants';
import { useWorkflowReviewsFeature } from '@/features/workflow-reviews/composables/useWorkflowReviewsFeature';
@@ -52,11 +53,7 @@ const isChatLinkAvailable = computed(
settingsStore.isChatFeatureEnabled &&
hasPermission(['rbac'], { rbac: { scope: 'chatHub:message' } }),
);
const isInstanceAiNavVisible = computed(() => {
if (!settingsStore.isModuleActive('instance-ai')) return false;
const ms = settingsStore.moduleSettings['instance-ai'];
return ms?.enabled !== false;
});
const isInstanceAiNavVisible = useInstanceAiAvailable();
const hasMultipleVerifiedUsers = computed(
() => usersStore.allUsers.filter((user) => !user.isPendingUser).length > 1,
);
@@ -3,9 +3,9 @@ import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { N8nIcon } from '@n8n/design-system';
import type { CommandGroup, CommandBarItem } from '../types';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useInstanceAiStore } from '@/features/ai/instanceAi/instanceAi.store';
import { INSTANCE_AI_VIEW, INSTANCE_AI_THREAD_VIEW } from '@/features/ai/instanceAi/constants';
import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability';
const NAME_KEYWORDS = [
'assistant',
@@ -21,14 +21,8 @@ export function useInstanceAiCommands(options: { lastQuery: Ref<string> }): Comm
const i18n = useI18n();
const { lastQuery } = options;
const router = useRouter();
const settingsStore = useSettingsStore();
const instanceAiStore = useInstanceAiStore();
const isInstanceAiCommandsVisible = computed(
() =>
settingsStore.isModuleActive('instance-ai') &&
settingsStore.moduleSettings['instance-ai']?.enabled !== false,
);
const isInstanceAiCommandsVisible = useInstanceAiAvailable();
const filteredThreads = computed(() => {
const trimmed = (lastQuery.value || '').trim().toLowerCase();
@@ -37,6 +37,55 @@ export class InstanceAiPage extends BasePage {
await expect(this.getSendButton()).toBeVisible({ timeout: 30_000 });
}
async gotoOnboarding(): Promise<void> {
await this.page.goto('/assistant');
await expect(
this.container
.getByTestId('assistant-setup-intro')
.or(this.container.getByTestId('assistant-setup-incomplete')),
).toBeVisible({ timeout: 30_000 });
}
getSetupButton(): Locator {
return this.container
.getByTestId('assistant-setup-cta')
.or(this.container.getByTestId('assistant-finish-setup-cta'));
}
getOnboardingWizard(): Locator {
return this.page.getByRole('dialog', { name: 'Set up AI Assistant' });
}
getWizardPrimaryButton(): Locator {
return this.getOnboardingWizard().getByTestId('wizard-primary');
}
getSearchProvider(provider: 'searxng' | 'brave' | 'disabled'): Locator {
return this.getOnboardingWizard().getByTestId(`assistant-search-${provider}`);
}
getSearchValueInput(): Locator {
return this.getOnboardingWizard().getByTestId('assistant-search-value');
}
getVerificationError(): Locator {
return this.getOnboardingWizard().getByTestId('assistant-verification-error');
}
getOnboardingDoneHeading(): Locator {
return this.getOnboardingWizard().getByRole('heading', {
name: 'AI Assistant is on for everyone on this instance',
});
}
async mockSearchVerification(
response: { ok: true; resultCount: number } | { ok: false; failure: string },
): Promise<void> {
await this.page.route('**/rest/instance-ai/settings/verify/search', async (route) => {
await route.fulfill({ json: { data: response } });
});
}
async enableInstanceAiIfPrompted(): Promise<void> {
const dialog = this.page.getByRole('dialog').filter({ hasText: 'Try AI Assistant' });
try {
@@ -3,6 +3,7 @@ import type {
ClusterInfoResponse,
InstanceAiEnsureThreadResponse,
InstanceAiPermissions,
InstanceAiAdminSettingsUpdateRequest,
InstanceAiThreadInfo,
} from '@n8n/api-types';
import { request, type APIRequestContext } from '@playwright/test';
@@ -476,6 +477,15 @@ export class ApiHelpers {
}
}
async updateInstanceAiSettings(settings: InstanceAiAdminSettingsUpdateRequest): Promise<void> {
const response = await this.request.put('/rest/instance-ai/settings', { data: settings });
if (!response.ok()) {
throw new TestError(
`PUT /rest/instance-ai/settings failed (${response.status()}): ${await response.text()}`,
);
}
}
/**
* Check if n8n is healthy
* @returns True if n8n is healthy, false otherwise
@@ -653,7 +653,8 @@ export const test = base.extend<InstanceAiFixtures>({
},
instanceAiProxySetup: [
async ({ n8nContainer, backendUrl }, use, testInfo) => {
async ({ n8nContainer, backendUrl, api }, use, testInfo) => {
await api.updateInstanceAiSettings({ searchDisabled: true });
// Local-build mode (no Docker container) — skip all proxy setup.
// LLM calls go straight to Anthropic, no recording or replay.
if (!n8nContainer) {
@@ -0,0 +1,49 @@
import { test, expect } from '../../../fixtures/base';
test.use({
capability: {
services: ['sandbox'],
env: {
TEST_ISOLATION: 'instance-ai-onboarding',
N8N_ENABLED_MODULES: 'instance-ai',
N8N_INSTANCE_AI_MODEL: 'anthropic/claude-sonnet-4-6',
N8N_INSTANCE_AI_MODEL_API_KEY: 'test-model-key',
N8N_INSTANCE_AI_SANDBOX_ENABLED: 'true',
},
},
});
test.describe(
'AI Assistant self-hosted onboarding @db:reset',
{ annotation: [{ type: 'owner', description: 'instanceAI' }] },
() => {
test('should verify search and complete onboarding', async ({ n8n }) => {
await n8n.instanceAi.gotoOnboarding();
await n8n.instanceAi.mockSearchVerification({ ok: true, resultCount: 10 });
await n8n.instanceAi.getSetupButton().click();
await n8n.instanceAi.getSearchProvider('brave').click();
await n8n.instanceAi.getSearchValueInput().fill('test-search-key');
await n8n.instanceAi.getWizardPrimaryButton().click();
await expect(n8n.instanceAi.getOnboardingDoneHeading()).toBeVisible();
await n8n.instanceAi.getWizardPrimaryButton().click();
await expect(n8n.instanceAi.getChatInput()).toBeVisible();
});
test('should keep the search step open when verification fails', async ({ n8n }) => {
await n8n.instanceAi.gotoOnboarding();
await n8n.instanceAi.mockSearchVerification({ ok: false, failure: 'unauthorized' });
await n8n.instanceAi.getSetupButton().click();
await n8n.instanceAi.getSearchProvider('brave').click();
await n8n.instanceAi.getSearchValueInput().fill('invalid-search-key');
await n8n.instanceAi.getWizardPrimaryButton().click();
await expect(n8n.instanceAi.getVerificationError()).toContainText(
'The provider rejected the credential',
);
await expect(n8n.instanceAi.getSearchProvider('brave')).toBeVisible();
});
},
);