feat(core): Make instance AI aware of read-only environments (no-changelog) (#28120)

This commit is contained in:
Jaakko Husso 2026-04-08 18:10:31 +03:00 committed by GitHub
parent a93ae81fa4
commit 7e1bebdae6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 411 additions and 22 deletions

View File

@ -295,6 +295,7 @@ export {
InstanceAiThreadMessagesQuery,
InstanceAiAdminSettingsUpdateRequest,
InstanceAiUserPreferencesUpdateRequest,
applyBranchReadOnlyOverrides,
} from './schemas/instance-ai.schema';
export type {

View File

@ -0,0 +1,55 @@
import {
applyBranchReadOnlyOverrides,
DEFAULT_INSTANCE_AI_PERMISSIONS,
type InstanceAiPermissions,
} from '../instance-ai.schema';
describe('applyBranchReadOnlyOverrides', () => {
it('should block all write permissions while preserving safe ones', () => {
const result = applyBranchReadOnlyOverrides({ ...DEFAULT_INSTANCE_AI_PERMISSIONS });
// These should remain unchanged (safe for read-only instances)
expect(result.readFilesystem).toBe('require_approval');
expect(result.fetchUrl).toBe('require_approval');
expect(result.publishWorkflow).toBe('require_approval');
expect(result.deleteCredential).toBe('require_approval');
expect(result.restoreWorkflowVersion).toBe('require_approval');
// These should all be blocked
expect(result.createWorkflow).toBe('blocked');
expect(result.updateWorkflow).toBe('blocked');
expect(result.runWorkflow).toBe('blocked');
expect(result.deleteWorkflow).toBe('blocked');
expect(result.createFolder).toBe('blocked');
expect(result.deleteFolder).toBe('blocked');
expect(result.moveWorkflowToFolder).toBe('blocked');
expect(result.tagWorkflow).toBe('blocked');
expect(result.createDataTable).toBe('blocked');
expect(result.deleteDataTable).toBe('blocked');
expect(result.mutateDataTableSchema).toBe('blocked');
expect(result.mutateDataTableRows).toBe('blocked');
expect(result.cleanupTestExecutions).toBe('blocked');
});
it('should preserve safe permissions even when set to always_allow', () => {
const permissions: InstanceAiPermissions = {
...DEFAULT_INSTANCE_AI_PERMISSIONS,
publishWorkflow: 'always_allow',
deleteCredential: 'always_allow',
readFilesystem: 'always_allow',
};
const result = applyBranchReadOnlyOverrides(permissions);
expect(result.publishWorkflow).toBe('always_allow');
expect(result.deleteCredential).toBe('always_allow');
expect(result.readFilesystem).toBe('always_allow');
});
it('should not mutate the original permissions object', () => {
const original = { ...DEFAULT_INSTANCE_AI_PERMISSIONS };
applyBranchReadOnlyOverrides(original);
expect(original.createWorkflow).toBe('require_approval');
});
});

View File

@ -864,6 +864,31 @@ export const DEFAULT_INSTANCE_AI_PERMISSIONS: InstanceAiPermissions = {
restoreWorkflowVersion: 'require_approval',
};
/** Permission keys that remain active when branchReadOnly is enabled.
* When changing this set, also update the read-only section in
* `packages/@n8n/instance-ai/src/agent/system-prompt.ts` (`getReadOnlySection`). */
const BRANCH_READ_ONLY_SAFE_PERMISSIONS: ReadonlySet<keyof InstanceAiPermissions> = new Set([
'readFilesystem',
'fetchUrl',
'publishWorkflow',
'deleteCredential',
'restoreWorkflowVersion',
]);
/** Returns a copy of permissions with all write operations set to 'blocked',
* except for the safelisted ones that are allowed on read-only instances. */
export function applyBranchReadOnlyOverrides(
permissions: InstanceAiPermissions,
): InstanceAiPermissions {
const overridden = { ...permissions };
for (const key of Object.keys(overridden) as Array<keyof InstanceAiPermissions>) {
if (!BRANCH_READ_ONLY_SAFE_PERMISSIONS.has(key)) {
overridden[key] = 'blocked';
}
}
return overridden;
}
// ---------------------------------------------------------------------------
// Admin settings — instance-scoped, admin-only
// ---------------------------------------------------------------------------

View File

@ -36,4 +36,27 @@ describe('getSystemPrompt', () => {
expect(prompt).not.toContain('License Limitations');
});
});
describe('read-only instance', () => {
it('includes Read-Only Instance section when branchReadOnly is true', () => {
const prompt = getSystemPrompt({ branchReadOnly: true });
expect(prompt).toContain('## Read-Only Instance');
expect(prompt).toContain('read-only mode');
expect(prompt).toContain('Publishing/unpublishing');
expect(prompt).toContain('credentials');
});
it('omits Read-Only Instance section when branchReadOnly is false', () => {
const prompt = getSystemPrompt({ branchReadOnly: false });
expect(prompt).not.toContain('Read-Only Instance');
});
it('omits Read-Only Instance section when branchReadOnly is not provided', () => {
const prompt = getSystemPrompt({});
expect(prompt).not.toContain('Read-Only Instance');
});
});
});

View File

@ -223,6 +223,7 @@ export async function createInstanceAgent(options: CreateInstanceAgentOptions):
licenseHints: context.licenseHints,
timeZone: options.timeZone,
browserAvailable: browserToolNames.size > 0,
branchReadOnly: context.branchReadOnly,
});
const agent = new Agent({

View File

@ -13,6 +13,8 @@ interface SystemPromptOptions {
/** IANA time zone identifier for the current user (e.g. "Europe/Helsinki"). */
timeZone?: string;
browserAvailable?: boolean;
/** When true, the instance is in read-only mode (source control branchReadOnly). */
branchReadOnly?: boolean;
}
function getDateTimeSection(timeZone?: string): string {
@ -121,6 +123,28 @@ After the user confirms they're done, take a snapshot to verify before continuin
**NEVER include passwords, API keys, tokens, or secrets in your chat messages** even if visible on a page. If the user asks you to retrieve a secret, tell them to read it directly from their browser.`;
}
function getReadOnlySection(branchReadOnly?: boolean): string {
if (!branchReadOnly) return '';
return `
## Read-Only Instance
This n8n instance is in **read-only mode** (protected by source control settings). Write tools for the following operations are blocked and will return errors:
- Creating, modifying, or deleting workflows
- Creating data tables, modifying their schema, or mutating their rows
- Creating or deleting folders, moving or tagging workflows
- Running or stopping workflow executions
The following operations remain available:
- Listing, searching, and reading all resources
- Publishing/unpublishing (activating/deactivating) workflows
- Setting up, editing, and deleting credentials
- Restoring workflow versions
- Browsing the filesystem and fetching URLs
If the user asks for a blocked operation, explain that the instance is in read-only mode. Suggest they make the changes on a development or writable environment, push to version control, and pull the changes to this instance.
`;
}
export function getSystemPrompt(options: SystemPromptOptions = {}): string {
const {
researchMode,
@ -131,6 +155,7 @@ export function getSystemPrompt(options: SystemPromptOptions = {}): string {
licenseHints,
timeZone,
browserAvailable,
branchReadOnly,
} = options;
return `You are the n8n Instance Agent — an AI assistant embedded in an n8n instance. You help users build, run, debug, and manage workflows through natural language.
@ -224,7 +249,7 @@ ${licenseHints.map((h) => `- ${h}`).join('\n')}
`
: ''
}## Conversation Summary
}${getReadOnlySection(branchReadOnly)}## Conversation Summary
When \`<conversation-summary>\` is present in your input, treat it as compressed prior context from earlier turns. Use the recent raw messages for exact wording and details; use the summary for long-range continuity (user goals, past decisions, workflow state). Do not repeat the summary back to the user.

View File

@ -0,0 +1,170 @@
import { DEFAULT_INSTANCE_AI_PERMISSIONS } from '@n8n/api-types';
import type { InstanceAiContext } from '../../../types';
import { createDeleteDataTableTool } from '../delete-data-table.tool';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createMockContext(overrides?: Partial<InstanceAiContext>): InstanceAiContext {
return {
userId: 'test-user',
workflowService: {
list: jest.fn(),
get: jest.fn(),
getAsWorkflowJSON: jest.fn(),
createFromWorkflowJSON: jest.fn(),
updateFromWorkflowJSON: jest.fn(),
archive: jest.fn(),
delete: jest.fn(),
publish: jest.fn(),
unpublish: jest.fn(),
},
executionService: {
list: jest.fn(),
run: jest.fn(),
getStatus: jest.fn(),
getResult: jest.fn(),
stop: jest.fn(),
getDebugInfo: jest.fn(),
getNodeOutput: jest.fn(),
},
credentialService: {
list: jest.fn(),
get: jest.fn(),
delete: jest.fn(),
test: jest.fn(),
},
nodeService: {
listAvailable: jest.fn(),
getDescription: jest.fn(),
listSearchable: jest.fn(),
},
dataTableService: {
list: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
getSchema: jest.fn(),
addColumn: jest.fn(),
deleteColumn: jest.fn(),
renameColumn: jest.fn(),
queryRows: jest.fn(),
insertRows: jest.fn(),
updateRows: jest.fn(),
deleteRows: jest.fn(),
},
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('createDeleteDataTableTool', () => {
let context: InstanceAiContext;
beforeEach(() => {
context = createMockContext();
});
it('has the expected tool id', () => {
const tool = createDeleteDataTableTool(context);
expect(tool.id).toBe('delete-data-table');
});
describe('when permission is blocked', () => {
beforeEach(() => {
context = createMockContext({
permissions: {
...DEFAULT_INSTANCE_AI_PERMISSIONS,
deleteDataTable: 'blocked',
},
});
});
it('returns denied without calling the service', async () => {
const tool = createDeleteDataTableTool(context);
const suspend = jest.fn();
const result = (await tool.execute!({ dataTableId: 'dt-1' }, {
agent: { suspend, resumeData: undefined },
} as never)) as Record<string, unknown>;
expect(suspend).not.toHaveBeenCalled();
expect(context.dataTableService.delete).not.toHaveBeenCalled();
expect(result).toEqual({ success: false, denied: true, reason: 'Action blocked by admin' });
});
});
describe('when permission requires approval (default)', () => {
it('suspends for user confirmation on first call', async () => {
const tool = createDeleteDataTableTool(context);
const suspend = jest.fn();
await tool.execute!({ dataTableId: 'dt-1' }, {
agent: { suspend, resumeData: undefined },
} as never);
expect(suspend).toHaveBeenCalledTimes(1);
const payload = (suspend.mock.calls as unknown[][])[0][0] as Record<string, unknown>;
expect(payload).toEqual(
expect.objectContaining({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
message: expect.stringContaining('dt-1'),
severity: 'destructive',
}),
);
});
it('deletes when resumed with approved: true', async () => {
(context.dataTableService.delete as jest.Mock).mockResolvedValue(undefined);
const tool = createDeleteDataTableTool(context);
const result = (await tool.execute!({ dataTableId: 'dt-1' }, {
agent: { suspend: jest.fn(), resumeData: { approved: true } },
} as never)) as Record<string, unknown>;
expect(context.dataTableService.delete).toHaveBeenCalledWith('dt-1');
expect(result).toEqual({ success: true });
});
it('returns denied when resumed with approved: false', async () => {
const tool = createDeleteDataTableTool(context);
const result = (await tool.execute!({ dataTableId: 'dt-1' }, {
agent: { suspend: jest.fn(), resumeData: { approved: false } },
} as never)) as Record<string, unknown>;
expect(context.dataTableService.delete).not.toHaveBeenCalled();
expect(result).toEqual({ success: false, denied: true, reason: 'User denied the action' });
});
});
describe('when permission is always_allow', () => {
beforeEach(() => {
context = createMockContext({
permissions: {
...DEFAULT_INSTANCE_AI_PERMISSIONS,
deleteDataTable: 'always_allow',
},
});
});
it('skips confirmation and deletes immediately', async () => {
(context.dataTableService.delete as jest.Mock).mockResolvedValue(undefined);
const tool = createDeleteDataTableTool(context);
const suspend = jest.fn();
const result = (await tool.execute!({ dataTableId: 'dt-1' }, {
agent: { suspend, resumeData: undefined },
} as never)) as Record<string, unknown>;
expect(suspend).not.toHaveBeenCalled();
expect(context.dataTableService.delete).toHaveBeenCalledWith('dt-1');
expect(result).toEqual({ success: true });
});
});
});

View File

@ -35,8 +35,14 @@ export function createDeleteDataTableTool(context: InstanceAiContext) {
| undefined;
const suspend = ctx?.agent?.suspend;
// State 1: First call — suspend for confirmation
if (resumeData === undefined || resumeData === null) {
if (context.permissions?.deleteDataTable === 'blocked') {
return { success: false, denied: true, reason: 'Action blocked by admin' };
}
const needsApproval = context.permissions?.deleteDataTable !== 'always_allow';
// State 1: First call — suspend for confirmation (unless always_allow)
if (needsApproval && (resumeData === undefined || resumeData === null)) {
await suspend?.({
requestId: nanoid(),
message: `Delete data table "${input.dataTableId}"? This will permanently remove the table and all its data.`,
@ -46,7 +52,7 @@ export function createDeleteDataTableTool(context: InstanceAiContext) {
}
// State 2: Denied
if (!resumeData.approved) {
if (resumeData !== undefined && resumeData !== null && !resumeData.approved) {
return { success: false, denied: true, reason: 'User denied the action' };
}

View File

@ -17,6 +17,10 @@ export function createStopExecutionTool(context: InstanceAiContext) {
message: z.string(),
}),
execute: async (inputData: z.infer<typeof stopExecutionInputSchema>) => {
if (context.permissions?.runWorkflow === 'blocked') {
return { success: false, message: 'Action blocked by admin' };
}
return await context.executionService.stop(inputData.executionId);
},
});

View File

@ -54,6 +54,11 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
warnings: z.array(z.string()).optional(),
}),
execute: async (input: z.infer<typeof buildWorkflowInputSchema>) => {
const permKey = input.workflowId ? 'updateWorkflow' : 'createWorkflow';
if (context.permissions?.[permKey] === 'blocked') {
return { success: false, errors: ['Action blocked by admin'] };
}
const { code, patches, workflowId, projectId, name } = input;
let finalCode: string;

View File

@ -573,6 +573,8 @@ export interface InstanceAiContext {
localGatewayStatus?: LocalGatewayStatus;
/** Per-action HITL permission overrides. When absent, tools default to requiring approval. */
permissions?: InstanceAiPermissions;
/** When true, the instance is in read-only mode (source control branchReadOnly). */
branchReadOnly?: boolean;
/** Human-readable hints about licensed features that are NOT available on this instance.
* Injected into the system prompt so the agent can explain why certain capabilities are missing. */
licenseHints?: string[];

View File

@ -134,6 +134,9 @@ const user = mock<User>({
beforeEach(() => {
jest.clearAllMocks();
license.isLicensed.mockReturnValue(true);
sourceControlPreferencesService.getPreferences.mockReturnValue({
branchReadOnly: false,
} as never);
});
// ---------------------------------------------------------------------------

View File

@ -885,6 +885,7 @@ describe('createDataTableAdapter', () => {
function createWorkflowAdapterForTests(overrides?: {
namedVersionsLicensed?: boolean;
foldersLicensed?: boolean;
branchReadOnly?: boolean;
}) {
const mockProjectRepository = {
getPersonalProjectForUserOrFail: jest.fn().mockResolvedValue({ id: 'personal-project-id' }),
@ -944,7 +945,9 @@ function createWorkflowAdapterForTests(overrides?: {
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[18],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[19],
{
getPreferences: jest.fn().mockReturnValue({ branchReadOnly: false }),
getPreferences: jest
.fn()
.mockReturnValue({ branchReadOnly: overrides?.branchReadOnly ?? false }),
} as unknown as SourceControlPreferencesService,
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[21],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[22],
@ -1025,6 +1028,32 @@ describe('createWorkflowAdapter', () => {
}),
).rejects.toThrow('User does not have the required permissions in this project');
});
describe('instance read-only mode', () => {
it('blocks createFromWorkflowJSON when branchReadOnly is true', async () => {
const { adapter } = createWorkflowAdapterForTests({ branchReadOnly: true });
await expect(adapter.createFromWorkflowJSON(minimalWorkflowJSON)).rejects.toThrow(
'Cannot modify workflows on a protected instance',
);
});
it('blocks archive when branchReadOnly is true', async () => {
const { adapter } = createWorkflowAdapterForTests({ branchReadOnly: true });
await expect(adapter.archive('wf-1')).rejects.toThrow(
'Cannot modify workflows on a protected instance',
);
});
it('blocks delete when branchReadOnly is true', async () => {
const { adapter } = createWorkflowAdapterForTests({ branchReadOnly: true });
await expect(adapter.delete('wf-1')).rejects.toThrow(
'Cannot modify workflows on a protected instance',
);
});
});
});
// ---------------------------------------------------------------------------

View File

@ -224,6 +224,14 @@ export class InstanceAiAdapterService {
return hints;
}
private assertInstanceNotReadOnly(resourceType: string) {
if (this.sourceControlPreferencesService.getPreferences().branchReadOnly) {
throw new Error(
`Cannot modify ${resourceType} on a protected instance. This instance is in read-only mode.`,
);
}
}
private createProjectScopeHelpers(user: User) {
const { projectRepository } = this;
let personalProjectIdPromise: Promise<string> | null = null;
@ -263,6 +271,7 @@ export class InstanceAiAdapterService {
allowSendingParameterValues,
telemetry,
} = this;
const assertNotReadOnly = () => this.assertInstanceNotReadOnly('workflows');
const { resolveProjectId } = this.createProjectScopeHelpers(user);
const redactParameters = !allowSendingParameterValues;
@ -303,10 +312,12 @@ export class InstanceAiAdapterService {
},
async archive(workflowId: string) {
assertNotReadOnly();
await workflowService.archive(user, workflowId, { skipArchived: true });
},
async delete(workflowId: string) {
assertNotReadOnly();
await workflowService.delete(user, workflowId);
},
@ -346,6 +357,7 @@ export class InstanceAiAdapterService {
},
async createFromWorkflowJSON(json: WorkflowJSON, options?: { projectId?: string }) {
assertNotReadOnly();
const projectId = await resolveProjectId(['workflow:create'], options?.projectId);
// Strip redactionPolicy if the user lacks the required scope —
@ -419,6 +431,7 @@ export class InstanceAiAdapterService {
json: WorkflowJSON,
_options?: { projectId?: string },
) {
assertNotReadOnly();
// Strip redactionPolicy if the user lacks the required scope —
// mirrors the check in createFromWorkflowJSON() and WorkflowService.update().
const settings = (json.settings ?? {}) as IWorkflowSettings;
@ -561,6 +574,7 @@ export class InstanceAiAdapterService {
roleService,
telemetry,
} = this;
const assertNotReadOnly = () => this.assertInstanceNotReadOnly('executions');
const DEFAULT_TIMEOUT_MS = 5 * Time.minutes.toMilliseconds;
const MAX_TIMEOUT_MS = 10 * Time.minutes.toMilliseconds;
@ -645,6 +659,7 @@ export class InstanceAiAdapterService {
},
async run(workflowId: string, inputData, options) {
assertNotReadOnly();
const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:execute',
]);
@ -800,6 +815,7 @@ export class InstanceAiAdapterService {
},
async stop(executionId: string) {
assertNotReadOnly();
await assertExecutionAccess(executionId, ['workflow:execute']);
if (!activeExecutions.has(executionId)) {
return {
@ -1099,15 +1115,8 @@ export class InstanceAiAdapterService {
}
private createDataTableAdapter(user: User): InstanceAiDataTableService {
const { dataTableService, dataTableRepository, sourceControlPreferencesService } = this;
const assertInstanceNotReadOnly = () => {
if (sourceControlPreferencesService.getPreferences().branchReadOnly) {
throw new Error(
'Cannot modify data tables on a protected instance. This instance is in read-only mode.',
);
}
};
const { dataTableService, dataTableRepository } = this;
const assertNotReadOnly = () => this.assertInstanceNotReadOnly('data tables');
const { resolveProjectId } = this.createProjectScopeHelpers(user);
@ -1141,7 +1150,7 @@ export class InstanceAiAdapterService {
},
async create(name, columns, options) {
assertInstanceNotReadOnly();
assertNotReadOnly();
const projectId = await resolveProjectId(['dataTable:create'], options?.projectId);
const result = await dataTableService.createDataTable(projectId, { name, columns });
@ -1156,7 +1165,7 @@ export class InstanceAiAdapterService {
},
async delete(dataTableId) {
assertInstanceNotReadOnly();
assertNotReadOnly();
const projectId = await resolveProjectIdForTable(['dataTable:delete'], dataTableId);
await dataTableService.deleteDataTable(dataTableId, projectId);
},
@ -1175,7 +1184,7 @@ export class InstanceAiAdapterService {
},
async addColumn(dataTableId, column) {
assertInstanceNotReadOnly();
assertNotReadOnly();
const projectId = await resolveProjectIdForTable(['dataTable:update'], dataTableId);
const result = await dataTableService.addColumn(dataTableId, projectId, column);
return {
@ -1187,13 +1196,13 @@ export class InstanceAiAdapterService {
},
async deleteColumn(dataTableId, columnId) {
assertInstanceNotReadOnly();
assertNotReadOnly();
const projectId = await resolveProjectIdForTable(['dataTable:update'], dataTableId);
await dataTableService.deleteColumn(dataTableId, projectId, columnId);
},
async renameColumn(dataTableId, columnId, newName) {
assertInstanceNotReadOnly();
assertNotReadOnly();
const projectId = await resolveProjectIdForTable(['dataTable:update'], dataTableId);
await dataTableService.renameColumn(dataTableId, projectId, columnId, {
name: newName,
@ -1210,7 +1219,7 @@ export class InstanceAiAdapterService {
},
async insertRows(dataTableId, rows) {
assertInstanceNotReadOnly();
assertNotReadOnly();
const projectId = await resolveProjectIdForTable(['dataTable:writeRow'], dataTableId);
const result = await dataTableService.insertRows(
dataTableId,
@ -1222,7 +1231,7 @@ export class InstanceAiAdapterService {
},
async updateRows(dataTableId, filter, data) {
assertInstanceNotReadOnly();
assertNotReadOnly();
const projectId = await resolveProjectIdForTable(['dataTable:writeRow'], dataTableId);
const result = await dataTableService.updateRows(
dataTableId,
@ -1234,7 +1243,7 @@ export class InstanceAiAdapterService {
},
async deleteRows(dataTableId, filter) {
assertInstanceNotReadOnly();
assertNotReadOnly();
const projectId = await resolveProjectIdForTable(['dataTable:writeRow'], dataTableId);
const result = await dataTableService.deleteRows(
dataTableId,
@ -1840,6 +1849,7 @@ export class InstanceAiAdapterService {
executionPersistence,
eventService,
} = this;
const assertNotReadOnly = (resource: string) => this.assertInstanceNotReadOnly(resource);
const { assertProjectScope } = this.createProjectScopeHelpers(user);
const adapter: InstanceAiWorkspaceService = {
@ -1877,6 +1887,7 @@ export class InstanceAiAdapterService {
projectId: string,
parentFolderId?: string,
): Promise<FolderSummary> {
assertNotReadOnly('folders');
await assertProjectScope(['folder:create'], projectId);
const folder = await folderService.createFolder(
{ name, parentFolderId: parentFolderId ?? undefined },
@ -1894,6 +1905,7 @@ export class InstanceAiAdapterService {
projectId: string,
transferToFolderId?: string,
): Promise<void> {
assertNotReadOnly('folders');
await assertProjectScope(['folder:delete'], projectId);
await folderService.deleteFolder(user, folderId, projectId, {
transferToFolderId: transferToFolderId ?? undefined,
@ -1901,6 +1913,7 @@ export class InstanceAiAdapterService {
},
async moveWorkflowToFolder(workflowId: string, folderId: string): Promise<void> {
assertNotReadOnly('workflows');
const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:update',
]);
@ -1915,6 +1928,7 @@ export class InstanceAiAdapterService {
: {}),
async tagWorkflow(workflowId: string, tagNames: string[]): Promise<string[]> {
assertNotReadOnly('workflows');
const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:update',
]);
@ -1969,6 +1983,7 @@ export class InstanceAiAdapterService {
workflowId: string,
options?: { olderThanHours?: number },
): Promise<{ deletedCount: number }> {
assertNotReadOnly('executions');
// Access-check the workflow with execute scope (matches controller behavior)
const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:execute',

View File

@ -1,5 +1,6 @@
import {
UNLIMITED_CREDITS,
applyBranchReadOnlyOverrides,
type InstanceAiAttachment,
type InstanceAiEvent,
type InstanceAiThreadStatusResponse,
@ -62,6 +63,7 @@ import {
import { setSchemaBaseDirs } from '@n8n/workflow-sdk';
import { nanoid } from 'nanoid';
import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee';
import { AiService } from '@/services/ai.service';
import { Push } from '@/push';
import { Telemetry } from '@/telemetry';
@ -169,6 +171,7 @@ export class InstanceAiService {
private readonly urlService: UrlService,
private readonly dbSnapshotStorage: DbSnapshotStorage,
private readonly dbIterationLogStorage: DbIterationLogStorage,
private readonly sourceControlPreferencesService: SourceControlPreferencesService,
private readonly telemetry: Telemetry,
) {
this.logger = logger.scoped('instance-ai');
@ -1105,6 +1108,10 @@ export class InstanceAiService {
context.localMcpServer = userGateway;
}
context.permissions = this.settingsService.getPermissions();
if (this.sourceControlPreferencesService.getPreferences().branchReadOnly) {
context.permissions = applyBranchReadOnlyOverrides(context.permissions);
context.branchReadOnly = true;
}
let domainTracker = this.domainAccessTrackersByThread.get(threadId);
if (!domainTracker) {

View File

@ -1577,6 +1577,7 @@
"readOnlyEnv.cantAdd.project": "You can't add new projects to a protected n8n instance",
"readOnlyEnv.cantAdd.any": "You can't create new workflows or credentials on a protected n8n instance",
"readOnlyEnv.cantEditOrRun": "This workflow can't be edited or run manually because it's on a protected instance",
"readOnlyEnv.instanceAi.notice": "This is a protected instance. Some actions like creating or modifying workflows are restricted.",
"logs.overview.header.title": "Logs",
"logs.overview.header.actions.clearExecution": "Clear execution",
"logs.overview.header.actions.clearExecution.tooltip": "Clear execution data",

View File

@ -18,10 +18,12 @@ import {
N8nText,
} from '@n8n/design-system';
import { useScroll, useWindowSize } from '@vueuse/core';
import { N8nCallout } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { InstanceAiAttachment } from '@n8n/api-types';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useInstanceAiStore } from './instanceAi.store';
import { useInstanceAiSettingsStore } from './instanceAiSettings.store';
@ -47,6 +49,8 @@ import InstanceAiDataTablePreview from './components/InstanceAiDataTablePreview.
const store = useInstanceAiStore();
const settingsStore = useInstanceAiSettingsStore();
const sourceControlStore = useSourceControlStore();
const isReadOnlyEnvironment = computed(() => sourceControlStore.preferences.branchReadOnly);
const pushConnectionStore = usePushConnectionStore();
const rootStore = useRootStore();
const i18n = useI18n();
@ -433,6 +437,15 @@ function handleStop() {
</div>
</div>
<N8nCallout
v-if="isReadOnlyEnvironment"
theme="warning"
icon="lock"
:class="$style.readOnlyBanner"
>
{{ i18n.baseText('readOnlyEnv.instanceAi.notice') }}
</N8nCallout>
<!-- Content area: chat + artifacts side by side below header -->
<div :class="$style.contentArea">
<div :class="$style.chatContent">
@ -592,6 +605,10 @@ function handleStop() {
flex-shrink: 0;
}
.readOnlyBanner {
margin: var(--spacing--xs) var(--spacing--sm) 0;
}
.chatArea {
flex: 1;
display: flex;