mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(core): Make instance AI aware of read-only environments (no-changelog) (#28120)
This commit is contained in:
parent
a93ae81fa4
commit
7e1bebdae6
|
|
@ -295,6 +295,7 @@ export {
|
|||
InstanceAiThreadMessagesQuery,
|
||||
InstanceAiAdminSettingsUpdateRequest,
|
||||
InstanceAiUserPreferencesUpdateRequest,
|
||||
applyBranchReadOnlyOverrides,
|
||||
} from './schemas/instance-ai.schema';
|
||||
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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' };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ const user = mock<User>({
|
|||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
license.isLicensed.mockReturnValue(true);
|
||||
sourceControlPreferencesService.getPreferences.mockReturnValue({
|
||||
branchReadOnly: false,
|
||||
} as never);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user