chore: Bundle/2.x (#33793)

Co-authored-by: Matsu <matias.huhta@n8n.io>
Co-authored-by: Stephen Wright <sjw948@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Bernhard Wittmann <bernhard.wittmann@n8n.io>
Co-authored-by: Nikhil Kuriakose <nikhil.kuriakose@n8n.io>
Co-authored-by: Albert Alises <albert.alises@gmail.com>
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
Co-authored-by: Ricardo Espinoza <ricardo@n8n.io>
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Elias Meire <elias@meire.dev>
Co-authored-by: bjorger <50590409+bjorger@users.noreply.github.com>
This commit is contained in:
n8n-assistant[bot] 2026-07-08 08:48:55 +03:00 committed by GitHub
parent 5c204364eb
commit 46c1a0e8f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
132 changed files with 5190 additions and 711 deletions

View File

@ -156,6 +156,244 @@ describe('N8nLlmTracing', () => {
expect(estimateTokensFromStringList).toHaveBeenCalledWith(prompts, 'gpt-4o');
});
it('should mask declared header values in persisted input data', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions, {
redactedHeaders: ['x-secret-header'],
});
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'chat_models', 'openai'],
kwargs: {
model: 'gpt-4',
configuration: {
baseURL: 'https://api.openai.com/v1',
defaultHeaders: {
'User-Agent': 'n8n',
'x-secret-header': 'My_secret_API_key123456789',
},
},
},
};
await tracer.handleLLMStart(llm, ['hello'], 'run-123');
const inputArg = mockExecutionFunctions.addInputData.mock.calls[0][1] as Array<
Array<{ json: { options: { configuration: { defaultHeaders: Record<string, string> } } } }>
>;
const persistedHeaders = inputArg[0][0].json.options.configuration.defaultHeaders;
// declared header name stays, value is masked
expect(persistedHeaders['x-secret-header']).toBe('**********');
expect(persistedHeaders['x-secret-header']).not.toBe('My_secret_API_key123456789');
// non-declared header is untouched
expect(persistedHeaders['User-Agent']).toBe('n8n');
// stored run details are masked the same way
const storedOptions = tracer.runsMap['run-123'].options as {
configuration: { defaultHeaders: Record<string, string> };
};
expect(storedOptions.configuration.defaultHeaders['x-secret-header']).toBe('**********');
// The original serialized object is not mutated
expect(
(llm.kwargs.configuration as { defaultHeaders: Record<string, string> }).defaultHeaders[
'x-secret-header'
],
).toBe('My_secret_API_key123456789');
});
it('should mask declared header values inside clientOptions', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions, {
redactedHeaders: ['x-secret-header'],
});
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'chat_models', 'anthropic'],
kwargs: {
model: 'claude',
clientOptions: {
defaultHeaders: {
'User-Agent': 'n8n',
'x-secret-header': 'My_secret_API_key123456789',
},
},
},
};
await tracer.handleLLMStart(llm, ['hello'], 'run-123');
const inputArg = mockExecutionFunctions.addInputData.mock.calls[0][1] as Array<
Array<{ json: { options: { clientOptions: { defaultHeaders: Record<string, string> } } } }>
>;
const persistedHeaders = inputArg[0][0].json.options.clientOptions.defaultHeaders;
expect(persistedHeaders['x-secret-header']).toBe('**********');
expect(persistedHeaders['User-Agent']).toBe('n8n');
});
it('should always mask the Authorization header value', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'chat_models', 'openai'],
kwargs: {
model: 'llama3',
// top-level header map and nested wrapper
headers: { authorization: 'Bearer top-secret', Cookie: 'session=abc' },
configuration: {
defaultHeaders: { Authorization: 'Bearer nested-secret', 'x-api-key': 'sk-123' },
},
},
};
await tracer.handleLLMStart(llm, ['hello'], 'run-123');
const inputArg = mockExecutionFunctions.addInputData.mock.calls[0][1] as Array<
Array<{
json: {
options: {
headers: Record<string, string>;
configuration: { defaultHeaders: Record<string, string> };
};
};
}>
>;
const persisted = inputArg[0][0].json.options;
// Matched case-insensitively, in both container shapes
expect(persisted.headers.authorization).toBe('**********');
expect(persisted.headers.Cookie).toBe('**********');
expect(persisted.configuration.defaultHeaders.Authorization).toBe('**********');
expect(persisted.configuration.defaultHeaders['x-api-key']).toBe('**********');
});
it('should mask the Authorization header value at any nesting depth', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'chat_models', 'openai'],
kwargs: {
configuration: { httpAgent: { headers: { Authorization: 'Bearer deep-secret' } } },
},
};
await tracer.handleLLMStart(llm, ['hello'], 'run-123');
const inputArg = mockExecutionFunctions.addInputData.mock.calls[0][1] as Array<
Array<{
json: { options: { configuration: { httpAgent: { headers: Record<string, string> } } } };
}>
>;
expect(inputArg[0][0].json.options.configuration.httpAgent.headers.Authorization).toBe(
'**********',
);
});
it('should redact header values inside an array element', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'chat_models', 'openai'],
kwargs: {
transports: [{ headers: { Authorization: 'Bearer array-secret' } }],
},
};
await tracer.handleLLMStart(llm, ['hello'], 'run-123');
const inputArg = mockExecutionFunctions.addInputData.mock.calls[0][1] as Array<
Array<{ json: { options: { transports: Array<{ headers: Record<string, string> }> } } }>
>;
expect(inputArg[0][0].json.options.transports[0].headers.Authorization).toBe('**********');
});
it('should match a declared header name case-insensitively', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions, {
redactedHeaders: ['X-Secret-Header'],
});
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'chat_models', 'openai'],
kwargs: {
configuration: { defaultHeaders: { 'x-secret-header': 'My_secret_API_key123456789' } },
},
};
await tracer.handleLLMStart(llm, ['hello'], 'run-123');
const inputArg = mockExecutionFunctions.addInputData.mock.calls[0][1] as Array<
Array<{ json: { options: { configuration: { defaultHeaders: Record<string, string> } } } }>
>;
expect(inputArg[0][0].json.options.configuration.defaultHeaders['x-secret-header']).toBe(
'**********',
);
});
it('should not mask a non-header field that shares a declared name', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions, {
redactedHeaders: ['model'],
});
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'chat_models', 'openai'],
kwargs: {
model: 'gpt-4',
configuration: { defaultHeaders: { model: 'header-value' } },
},
};
await tracer.handleLLMStart(llm, ['hello'], 'run-123');
const inputArg = mockExecutionFunctions.addInputData.mock.calls[0][1] as Array<
Array<{
json: {
options: { model: string; configuration: { defaultHeaders: Record<string, string> } };
};
}>
>;
const persisted = inputArg[0][0].json.options;
// Top-level model field stays; only the header-container occurrence is masked
expect(persisted.model).toBe('gpt-4');
expect(persisted.configuration.defaultHeaders.model).toBe('**********');
});
it('should leave headers unchanged when nothing is declared', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'chat_models', 'openai'],
kwargs: {
configuration: {
defaultHeaders: { 'x-secret-header': 'My_secret_API_key123456789' },
},
},
};
await tracer.handleLLMStart(llm, ['hello'], 'run-123');
const inputArg = mockExecutionFunctions.addInputData.mock.calls[0][1] as Array<
Array<{ json: { options: { configuration: { defaultHeaders: Record<string, string> } } } }>
>;
expect(inputArg[0][0].json.options.configuration.defaultHeaders['x-secret-header']).toBe(
'My_secret_API_key123456789',
);
});
it('should use parent run index when set', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
tracer.setParentRunIndex(5);

View File

@ -13,6 +13,7 @@ import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow
import { NodeConnectionTypes, NodeError, NodeOperationError } from 'n8n-workflow';
import { logAiEvent } from './log-ai-event';
import { redactHeaderValues } from './redact-headers';
import { estimateTokensFromStringList } from './tokenizer/token-estimator';
/** Normalized token usage returned by TokensUsageParser. */
@ -87,6 +88,7 @@ export class N8nLlmTracing extends BaseCallbackHandler {
options: {
tokensUsageParser: TokensUsageParser;
errorDescriptionMapper: (error: NodeError) => string | null | undefined;
redactedHeaders?: string[];
} = {
// Default(OpenAI format) parser
tokensUsageParser: (result: LLMResult) => {
@ -112,6 +114,7 @@ export class N8nLlmTracing extends BaseCallbackHandler {
options?: {
tokensUsageParser?: TokensUsageParser;
errorDescriptionMapper?: (error: NodeError) => string;
redactedHeaders?: string[];
},
) {
super();
@ -216,7 +219,10 @@ export class N8nLlmTracing extends BaseCallbackHandler {
? this.#parentRunIndex + this.executionFunctions.getNextRunIndex()
: undefined;
const options = llm.type === 'constructor' ? llm.kwargs : llm;
const options = redactHeaderValues(
llm.type === 'constructor' ? llm.kwargs : llm,
this.options.redactedHeaders ?? [],
);
const { index } = this.executionFunctions.addInputData(
this.connectionType,
[

View File

@ -0,0 +1,67 @@
import type { SerializedFields } from '@langchain/core/dist/load/map_keys';
import type { SerializedNotImplemented, SerializedSecret } from '@langchain/core/load/serializable';
import { isRecord } from '@n8n/utils/is-record';
type SerializedOptions = SerializedSecret | SerializedNotImplemented | SerializedFields;
const REDACTED_HEADER_VALUE = '**********';
const HEADER_CONTAINER_KEYS = ['defaultheaders', 'headers'];
const ALWAYS_REDACTED_HEADERS = ['authorization', 'x-api-key', 'api-key', 'cookie'];
function maskHeaderContainer(
headers: Record<string, unknown>,
lowerHeaderNames: string[],
): Record<string, unknown> {
const masked = { ...headers };
let changed = false;
for (const key of Object.keys(headers)) {
const lowerKey = key.toLowerCase();
if (lowerHeaderNames.includes(lowerKey) || ALWAYS_REDACTED_HEADERS.includes(lowerKey)) {
masked[key] = REDACTED_HEADER_VALUE;
changed = true;
}
}
return changed ? masked : headers;
}
function redactValue(value: unknown, lowerHeaderNames: string[]): unknown {
if (Array.isArray(value)) {
let result: unknown[] | undefined;
value.forEach((item, index) => {
const masked = redactValue(item, lowerHeaderNames);
if (masked !== item) {
result = result ?? [...value];
result[index] = masked;
}
});
return result ?? value;
}
if (!isRecord(value)) return value;
let result: Record<string, unknown> | undefined;
for (const [key, child] of Object.entries(value)) {
const masked =
HEADER_CONTAINER_KEYS.includes(key.toLowerCase()) && isRecord(child)
? maskHeaderContainer(child, lowerHeaderNames)
: redactValue(child, lowerHeaderNames);
if (masked !== child) {
result = result ?? { ...value };
result[key] = masked;
}
}
return result ?? value;
}
export function redactHeaderValues(
options: SerializedOptions,
headerNames: string[],
): SerializedOptions {
const redacted = redactValue(
options,
headerNames.map((name) => name.toLowerCase()),
);
return isRecord(redacted) ? redacted : options;
}

View File

@ -7,7 +7,13 @@ export { Logger } from './logging/logger';
export { ModuleRegistry } from './modules/module-registry';
export type { ModuleName } from './modules/modules.config';
export { ModulesConfig } from './modules/modules.config';
export { isContainedWithin, safeJoinPath } from './utils/path-util';
export {
isContainedWithin,
safeJoinPath,
pathComponents,
pathSegmentsBetween,
containsSymlinkedComponent,
} from './utils/path-util';
export { assertDir, exists } from './utils/fs';
export { parseFlatted } from './utils/parse-flatted';
export { CliParser } from './cli-parser';

View File

@ -1,4 +1,14 @@
import { isContainedWithin, safeJoinPath } from '../path-util';
import { mkdtemp, mkdir, symlink, rm, realpath, chmod } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
isContainedWithin,
safeJoinPath,
pathComponents,
pathSegmentsBetween,
containsSymlinkedComponent,
} from '../path-util';
describe('isContainedWithin', () => {
it('should return true when parent and child paths are the same', () => {
@ -51,3 +61,84 @@ describe('safeJoinPath', () => {
expect(() => safeJoinPath('/foo/bar', '..')).toThrow('Path traversal detected');
});
});
describe('pathComponents', () => {
it('returns each component of an absolute path from the root down', () => {
expect(pathComponents('/a/b/c')).toEqual(['/a', '/a/b', '/a/b/c']);
});
it('ignores empty segments and trailing slashes', () => {
expect(pathComponents('/a//b/')).toEqual(['/a', '/a/b']);
});
it('returns an empty list for the root path', () => {
expect(pathComponents('/')).toEqual([]);
});
it('builds each component of a relative path', () => {
expect(pathComponents('a/b')).toEqual(['a', 'a/b']);
});
});
describe('pathSegmentsBetween', () => {
it('returns the segments of a descendant below an ancestor', () => {
expect(pathSegmentsBetween('/a', '/a/b/c')).toEqual(['b', 'c']);
});
it('returns an empty list when the paths are equal', () => {
expect(pathSegmentsBetween('/a/b', '/a/b')).toEqual([]);
});
it('returns null when the descendant is not contained within the ancestor', () => {
expect(pathSegmentsBetween('/a/b', '/a/c')).toBeNull();
expect(pathSegmentsBetween('/a/b', '/a/b_but_not_really')).toBeNull();
});
it('normalises traversal and trailing separators', () => {
expect(pathSegmentsBetween('/a', '/a/b/../b/c/')).toEqual(['b', 'c']);
});
});
describe('containsSymlinkedComponent', () => {
let base: string;
beforeEach(async () => {
// realpath so the temp dir is canonical (e.g. macOS /var -> /private/var is a symlink).
base = await realpath(await mkdtemp(join(tmpdir(), 'pathutil-')));
});
afterEach(async () => {
await rm(base, { recursive: true, force: true });
});
it('returns false when no component is a symlink', async () => {
const dir = join(base, 'a', 'b');
await mkdir(dir, { recursive: true });
expect(await containsSymlinkedComponent(dir)).toBe(false);
});
it('returns true when an ancestor component is a symlink', async () => {
const real = join(base, 'real');
await mkdir(real, { recursive: true });
const link = join(base, 'link');
await symlink(real, link);
expect(await containsSymlinkedComponent(join(link, 'child'))).toBe(true);
});
it('ignores missing components', async () => {
expect(await containsSymlinkedComponent(join(base, 'does', 'not', 'exist'))).toBe(false);
});
it('propagates errors other than a missing component', async () => {
// Running as root bypasses permission checks, so the error path can't be exercised there.
if (process.getuid?.() === 0) return;
const unreadable = join(base, 'unreadable');
await mkdir(unreadable);
await chmod(unreadable, 0o000);
try {
await expect(containsSymlinkedComponent(join(unreadable, 'child'))).rejects.toThrow();
} finally {
await chmod(unreadable, 0o755);
}
});
});

View File

@ -1,4 +1,5 @@
import { UnexpectedError } from 'n8n-workflow';
import { lstat } from 'node:fs/promises';
import * as path from 'node:path';
/**
@ -34,3 +35,49 @@ export function safeJoinPath(parentPath: string, ...paths: string[]): string {
return candidate;
}
/** Components of an absolute path, root-first: `/a/b/c` -> [`/a`, `/a/b`, `/a/b/c`]. */
export function pathComponents(targetPath: string): string[] {
// Start from the path root (`/` on POSIX, `C:\` on Windows) so drive-qualified
// paths build correctly instead of being re-rooted at the bare separator.
const root = path.parse(targetPath).root;
const segments = targetPath.slice(root.length).split(path.sep).filter(Boolean);
return segments.reduce<string[]>(
(components, segment) => [...components, path.join(components.at(-1) ?? root, segment)],
[],
);
}
/**
* Path segments of `descendant` below `ancestor`, or `null` if `descendant` is
* not contained within `ancestor`. `/a/b` below `/a` -> [`b`]; equal paths -> [].
*/
export function pathSegmentsBetween(ancestor: string, descendant: string): string[] | null {
if (!isContainedWithin(ancestor, descendant)) {
return null;
}
const relative = path.relative(ancestor, descendant);
return relative.split(path.sep).filter(Boolean);
}
export async function containsSymlinkedComponent(resolvedPath: string): Promise<boolean> {
for (const component of pathComponents(resolvedPath)) {
let stats;
try {
stats = await lstat(component);
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
) {
continue;
}
throw error;
}
if (stats.isSymbolicLink()) {
return true;
}
}
return false;
}

View File

@ -18,13 +18,15 @@ remotely over a secure SSE connection.
|------------|-------|----------|--------------------|
| **Filesystem (read)** | `read_file`, `list_files`, `get_file_tree`, `search_files` | All | `allow` |
| **Filesystem (write)** | `write_file`, `edit_file`, `create_directory`, `delete`, `move`, `copy_file` | All | `ask` |
| **Shell** | `shell_execute` | All | `deny` |
| **Shell** | `shell_execute` | macOS · Linux/WSL2 | `deny` |
| **Computer** | `screen_screenshot`, `screen_screenshot_region`, `mouse_move`, `mouse_click`, `mouse_double_click`, `mouse_drag`, `mouse_scroll`, `keyboard_type`, `keyboard_key_tap`, `keyboard_shortcut` | macOS, Linux (X11), Windows | `deny` |
| **Browser** | 32 browser automation tools | All | `ask` |
Modules that require native dependencies (screenshot, mouse/keyboard) are
automatically disabled when their platform requirements aren't met.
Shell execution runs inside an OS-level sandbox, see [Shell sandboxing](#shell-sandboxing).
## Quick start
### Daemon mode (recommended)
@ -96,6 +98,7 @@ Each capability has an independent permission mode (`deny` \| `ask` \| `allow`):
| Flag | Default | Description |
|------|---------|-------------|
| `--computer-shell-timeout <ms>` | `30000` | Shell command timeout |
| `--dangerously-disable-shell-sandbox` | | Run `shell_execute` without the OS sandbox (**insecure** — see [Shell sandboxing](#shell-sandboxing)) |
#### Browser
@ -113,6 +116,7 @@ take precedence.
| `N8N_GATEWAY_LOG_LEVEL` | `--log-level` |
| `N8N_GATEWAY_FILESYSTEM_DIR` | `--dir` |
| `N8N_GATEWAY_COMPUTER_SHELL_TIMEOUT` | `--computer-shell-timeout` |
| `N8N_GATEWAY_DANGEROUSLY_DISABLE_SHELL_SANDBOX` | `--dangerously-disable-shell-sandbox` (set to `true`) |
| `N8N_GATEWAY_BROWSER_DEFAULT` | `--browser-default` |
| `N8N_GATEWAY_AUTO_CONFIRM` | `--auto-confirm` (set to `true`) |
| `N8N_GATEWAY_NON_INTERACTIVE` | `--non-interactive` (set to `true`) |
@ -160,7 +164,8 @@ operations — paths outside the configured root are rejected.
### Shell
Execute shell commands with configurable timeout.
Execute shell commands with configurable timeout. Commands run inside an
OS-level sandbox — see [Shell sandboxing](#shell-sandboxing).
| Tool | Description |
|------|-------------|
@ -236,10 +241,15 @@ resource is rejected even when the capability is set to `allow`.
## Prerequisites
### Filesystem & shell
### Filesystem
No extra dependencies — works on all platforms.
### Shell
macOS works out of the box. Linux and Windows (WSL2) need `bubblewrap` and
`socat`; native Windows is not supported. See [Shell sandboxing](#shell-sandboxing).
### Screenshot
Requires a display server. Automatically disabled when no monitors are
@ -266,6 +276,87 @@ npx playwright install chromium firefox
For local browser modes, see the
[@n8n/mcp-browser prerequisites](../mcp-browser/README.md#prerequisites).
## Shell sandboxing
`shell_execute` runs every command inside an OS-level sandbox provided by
[`@anthropic-ai/sandbox-runtime`](https://github.com/anthropic-experimental/sandbox-runtime),
so a command, and every child process it spawns can only touch the files and
network the sandbox permits.
### What it restricts
- **Writes** are confined to the working directory. The gateway settings directory is denied for both read and write.
- **Reads** are permitted except for some sensitive directories.
- **Outbound network** is blocked, no domains are allowed.
These boundaries are enforced by the OS, so they apply to the shell command and all of its subprocesses.
### OS-level enforcement
| Platform | Backend | Setup |
|----------|---------|-------|
| macOS | Seatbelt (built in) | None |
| Linux | bubblewrap + socat | Install packages (below) |
| WSL2 | bubblewrap + socat | Install packages (below) |
| WSL1 | — | Not supported — upgrade to WSL2 |
| Native Windows | — | Not supported — run inside WSL2 |
The native Windows backend is not a boundary against a deliberately malicious
process, so it is treated as unavailable. On Windows, run the gateway inside
WSL2.
### Linux / WSL2 setup
The sandbox needs two packages:
- [`bubblewrap`](https://github.com/containers/bubblewrap) — unprivileged
sandbox that enforces filesystem isolation (`bwrap`)
- [`socat`](http://www.dest-unreach.org/socat/) — relays network traffic
through the sandbox proxy
```bash
# Ubuntu/Debian
sudo apt-get install bubblewrap socat
# Fedora
sudo dnf install bubblewrap socat
```
> **Ubuntu 24.04+ (including WSL2):** the default AppArmor policy prevents
> bubblewrap from creating the user namespaces it needs. Check with
> `sysctl kernel.apparmor_restrict_unprivileged_userns`; if it returns `1`,
> grant `bwrap` the capability and reload AppArmor:
>
> ```bash
> sudo tee /etc/apparmor.d/bwrap > /dev/null <<'EOF'
> abi <abi/4.0>,
> include <tunables/global>
>
> profile bwrap /usr/bin/bwrap flags=(unconfined) {
> userns,
> include if exists <local/bwrap>
> }
> EOF
> sudo systemctl reload apparmor
> ```
### Disabling the sandbox
`--dangerously-disable-shell-sandbox` (or
`N8N_GATEWAY_DANGEROUSLY_DISABLE_SHELL_SANDBOX=true`) runs shell commands
**without** any OS isolation.
> **Warning:** this removes the filesystem and network boundaries entirely: a
> command can read your SSH keys, write anywhere your user can, and reach any
> host. Only use it inside an already-isolated, trusted environment such as a
> disposable container or VM.
### Limitations
The sandbox is an OS-level guardrail, not a full VM. Treat it as protection against accidental
or opportunistic access rather than containment for fully untrusted code; for
stronger isolation, run the gateway in a container or VM.
## Development
```bash

View File

@ -38,7 +38,7 @@
"dist/**/*"
],
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.42",
"@anthropic-ai/sandbox-runtime": "^0.0.61",
"@inquirer/prompts": "^8.3.2",
"@jitsi/robotjs": "^0.6.21",
"@modelcontextprotocol/sdk": "catalog:",

View File

@ -13,6 +13,7 @@ import {
printBanner,
printConnected,
printInvalidToken,
printModuleDiagnostics,
printModuleStatus,
printToolList,
} from './logger';
@ -135,6 +136,11 @@ Permissions (deny | ask | allow):
Computer use:
--computer-shell-timeout <ms> Shell command timeout (default: 30000)
--dangerously-disable-shell-sandbox
Run shell commands WITHOUT the OS sandbox.
Insecure — only use in a trusted, isolated
environment. Without a sandbox the shell
tool is disabled by default.
Browser:
--no-browser Disable browser tools
@ -212,13 +218,6 @@ async function main(
process.exit(1);
}
// printModuleStatus expects a GatewayConfig shape — derive one from the session.
printModuleStatus({
...parsed.config,
permissions: session.getAllPermissions(),
filesystem: { dir: session.dir },
});
const client = new GatewayClient({
url,
apiKey,
@ -250,6 +249,15 @@ async function main(
}
printConnected(url);
printModuleStatus(
{
...parsed.config,
permissions: session.getAllPermissions(),
filesystem: { dir: session.dir },
},
client.toolCategories,
);
printModuleDiagnostics(client.disabledModules);
printToolList(client.tools);
}

View File

@ -58,7 +58,7 @@ export interface GatewayConfig {
logLevel: 'silent' | 'error' | 'warn' | 'info' | 'debug';
allowedOrigins: string[];
filesystem: { dir: string };
computer: { shell: { timeout: number } };
computer: { shell: { timeout: number; dangerouslyDisableSandbox: boolean } };
browser: {
defaultBrowser: string;
};
@ -104,7 +104,12 @@ const structuralConfigSchema = z.object({
filesystem: z.object({ dir: z.string().default('.') }).default({}),
computer: z
.object({
shell: z.object({ timeout: z.number().int().positive().default(30_000) }).default({}),
shell: z
.object({
timeout: z.number().int().positive().default(30_000),
dangerouslyDisableSandbox: z.boolean().default(false),
})
.default({}),
})
.default({}),
browser: z
@ -165,8 +170,12 @@ function buildEnvConfig(): PartialStructural {
const fsDir = envString('FILESYSTEM_DIR');
if (fsDir) config.filesystem = { dir: fsDir };
const shell: Record<string, unknown> = {};
const shellTimeout = envNumber('COMPUTER_SHELL_TIMEOUT');
if (shellTimeout !== undefined) config.computer = { shell: { timeout: shellTimeout } };
if (shellTimeout !== undefined) shell.timeout = shellTimeout;
const disableSandbox = envBoolean('DANGEROUSLY_DISABLE_SHELL_SANDBOX');
if (disableSandbox !== undefined) shell.dangerouslyDisableSandbox = disableSandbox;
if (Object.keys(shell).length > 0) config.computer = { shell };
const defaultBrowser = envString('BROWSER_DEFAULT');
if (defaultBrowser) config.browser = { defaultBrowser };
@ -195,8 +204,11 @@ function buildCliConfig(args: yargsParser.Arguments): PartialStructural {
const dir = args.dir as string;
if (dir) config.filesystem = { dir };
const timeout = args['computer-shell-timeout'] as number;
if (timeout !== undefined) config.computer = { shell: { timeout } };
const shell: Record<string, unknown> = {};
const timeout = args['computer-shell-timeout'] as number | undefined;
if (timeout !== undefined) shell.timeout = timeout;
if (args['dangerously-disable-shell-sandbox'] === true) shell.dangerouslyDisableSandbox = true;
if (Object.keys(shell).length > 0) config.computer = { shell };
if (args['browser-default'])
config.browser = { defaultBrowser: args['browser-default'] as string };
@ -303,7 +315,7 @@ export function parseConfig(argv = process.argv.slice(2)): ParsedArgs {
'permission-confirmation',
...permissionFlags,
],
boolean: ['auto-confirm', 'non-interactive', 'help'],
boolean: ['auto-confirm', 'non-interactive', 'help', 'dangerously-disable-shell-sandbox'],
number: ['computer-shell-timeout'],
alias: { h: 'help', d: 'dir' },
});

View File

@ -24,21 +24,42 @@ vi.mock('./logger', () => ({
}));
// Mock tool modules that pull in native/ESM-only dependencies
// Mock modules as unavailable so the client under test doesn't load native deps.
vi.mock('./tools/shell', () => ({
['ShellModule']: { isSupported: vi.fn().mockResolvedValue(false), definitions: [] },
['ShellModule']: {
name: 'Shell',
category: 'shell',
permissionGroup: 'shell',
activate: vi.fn().mockResolvedValue({ supported: false, reason: 'mocked' }),
},
}));
vi.mock('./tools/filesystem', () => ({
filesystemReadTools: [],
filesystemWriteTools: [],
}));
vi.mock('./tools/screenshot', () => ({
['ScreenshotModule']: { isSupported: vi.fn().mockResolvedValue(false), definitions: [] },
['ScreenshotModule']: {
name: 'Screenshot',
category: 'screenshot',
permissionGroup: 'computer',
activate: vi.fn().mockResolvedValue({ supported: false, reason: 'mocked' }),
},
}));
vi.mock('./tools/mouse-keyboard', () => ({
['MouseKeyboardModule']: { isSupported: vi.fn().mockResolvedValue(false), definitions: [] },
['MouseKeyboardModule']: {
name: 'MouseKeyboard',
category: 'mouse-keyboard',
permissionGroup: 'computer',
activate: vi.fn().mockResolvedValue({ supported: false, reason: 'mocked' }),
},
}));
vi.mock('./tools/browser', () => ({
['BrowserModule']: { create: vi.fn().mockResolvedValue(null) },
['BrowserModule']: {
name: 'Browser',
category: 'browser',
permissionGroup: 'browser',
activate: vi.fn().mockResolvedValue({ supported: false, reason: 'mocked' }),
},
}));
vi.mock('@vscode/ripgrep', () => ({ rgPath: '/usr/bin/rg' }));
vi.mock('@anthropic-ai/sandbox-runtime', () => ({
@ -51,7 +72,8 @@ vi.mock('@anthropic-ai/sandbox-runtime', () => ({
import type { GatewayConfig } from './config';
import { GatewayAuthError, GatewayClient } from './gateway-client';
import type { GatewaySession } from './gateway-session';
import { shellExecuteTool } from './tools/shell/shell-execute';
import { logger } from './logger';
import { createShellExecuteTool } from './tools/shell/shell-execute';
import type { AffectedResource, ConfirmResourceAccess, ToolDefinition } from './tools/types';
import { INSTANCE_RESOURCE_DECISION_KEYS } from './tools/types';
@ -64,7 +86,7 @@ function makeConfig(permissionConfirmation: 'client' | 'instance' = 'client'): G
logLevel: 'silent',
allowedOrigins: [],
filesystem: { dir: '/' },
computer: { shell: { timeout: 30_000 } },
computer: { shell: { timeout: 30_000, dangerouslyDisableSandbox: false } },
browser: { defaultBrowser: 'chrome' },
permissions: {},
permissionConfirmation,
@ -238,7 +260,7 @@ describe('GatewayClient.checkPermissions', () => {
it('does not reuse a bare shell command session allow for shell_execute with explicit cwd', async () => {
const execute = vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] });
const shellTool = { ...shellExecuteTool, execute } as ToolDefinition;
const shellTool = { ...createShellExecuteTool('unsandboxed'), execute } as ToolDefinition;
const session = makeSession({
check: vi.fn((_toolGroup, resource) => (resource === 'ls' ? 'allow' : 'ask')),
});
@ -400,3 +422,45 @@ describe('GatewayClient.uploadCapabilities', () => {
await expect(promise).rejects.toThrow(/Failed to upload capabilities: 500/);
});
});
describe('GatewayClient.runTeardowns', () => {
function makeMinimalClient(): GatewayClient {
return new GatewayClient({
url: 'http://localhost:5678',
apiKey: 'tok',
config: makeConfig(),
session: makeSession(),
confirmResourceAccess: vi.fn(),
});
}
beforeEach(() => {
vi.mocked(logger.error).mockClear();
});
it('runs every teardown even when an earlier one rejects', async () => {
const first = vi.fn().mockRejectedValue(new Error('boom'));
const second = vi.fn().mockResolvedValue(undefined);
const third = vi.fn().mockRejectedValue('bad');
const client = makeMinimalClient();
client['teardowns'] = [first, second, third];
await client['runTeardowns']();
expect(first).toHaveBeenCalledOnce();
expect(second).toHaveBeenCalledOnce();
expect(third).toHaveBeenCalledOnce();
expect(logger.error).toHaveBeenCalledTimes(2);
});
it('clears teardowns so a second run is a no-op', async () => {
const teardown = vi.fn().mockResolvedValue(undefined);
const client = makeMinimalClient();
client['teardowns'] = [teardown];
await client['runTeardowns']();
await client['runTeardowns']();
expect(teardown).toHaveBeenCalledOnce();
});
});

View File

@ -14,8 +14,10 @@ import {
printToolCall,
printToolResult,
} from './logger';
import type { BrowserModule } from './tools/browser';
import { BrowserModule } from './tools/browser';
import { filesystemReadTools, filesystemWriteTools } from './tools/filesystem';
import { MouseKeyboardModule } from './tools/mouse-keyboard';
import { ScreenshotModule } from './tools/screenshot';
import { ShellModule } from './tools/shell';
import {
type AffectedResource,
@ -25,6 +27,7 @@ import {
type McpTool,
type ResourceDecision,
type ToolDefinition,
type ToolModule,
GATEWAY_CONFIRMATION_REQUIRED_PREFIX,
INSTANCE_RESOURCE_DECISION_KEYS,
} from './tools/types';
@ -104,13 +107,34 @@ export class GatewayClient {
private definitionMap: Map<string, ToolDefinition> = new Map();
private browserModule: BrowserModule | null = null;
private readonly modules: ToolModule[] = [
ShellModule,
ScreenshotModule,
MouseKeyboardModule,
BrowserModule,
];
/** Teardowns for modules that hold resources; collected as modules activate. */
private teardowns: Array<() => Promise<void>> = [];
/** Diagnostics for permitted-but-unavailable modules; surfaced after connect. */
private disabledModuleReports: Array<{ name: string; reason: string; hint?: string }> = [];
/** Get all registered tool definitions (populated after start). */
get tools(): ToolDefinition[] {
return this.allDefinitions ?? [];
}
/** Resolved per-category enabled state (populated after start). */
get toolCategories(): Array<{ name: string; enabled: boolean; writeAccess?: boolean }> {
return this.activeToolCategories;
}
/** Permitted modules that couldn't activate, with why + how to fix (after start). */
get disabledModules(): Array<{ name: string; reason: string; hint?: string }> {
return this.disabledModuleReports;
}
constructor(private readonly options: GatewayClientOptions) {}
/** Session key when the server has upgraded the pairing token; otherwise the original token. */
@ -135,7 +159,7 @@ export class GatewayClient {
this.eventSource.close();
this.eventSource = null;
}
if (this.browserModule) await this.browserModule.shutdown();
await this.runTeardowns();
}
/**
@ -185,11 +209,25 @@ export class GatewayClient {
this.eventSource.close();
this.eventSource = null;
}
if (this.browserModule) await this.browserModule.shutdown();
await this.runTeardowns();
this.options.onDisconnected?.();
}
/** Run and clear pending module teardowns. Idempotent. */
private async runTeardowns(): Promise<void> {
const teardowns = this.teardowns;
this.teardowns = [];
const results = await Promise.allSettled(teardowns.map(async (teardown) => await teardown()));
for (const result of results) {
if (result.status === 'rejected') {
logger.error('Module teardown failed', {
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
});
}
}
}
private async getAllDefinitions(): Promise<ToolDefinition[]> {
if (this.allDefinitions) return this.allDefinitions;
@ -212,71 +250,32 @@ export class GatewayClient {
writeAccess: fsWriteEnabled,
});
// Computer use modules — check permission mode and platform support
// Lazy-load Screenshot and MouseKeyboard to avoid eager native module imports
const { ScreenshotModule } = await import('./tools/screenshot');
const { MouseKeyboardModule } = await import('./tools/mouse-keyboard');
const computerModules: Array<{
name: string;
category: string;
enabled: boolean;
module: { isSupported(): boolean | Promise<boolean>; definitions: ToolDefinition[] };
}> = [
{
name: 'Shell',
category: 'shell',
enabled: session.getGroupMode('shell') !== 'deny',
module: ShellModule,
},
{
name: 'Screenshot',
category: 'screenshot',
enabled: session.getGroupMode('computer') !== 'deny',
module: ScreenshotModule,
},
{
name: 'MouseKeyboard',
category: 'mouse-keyboard',
enabled: session.getGroupMode('computer') !== 'deny',
module: MouseKeyboardModule,
},
];
for (const { name, category, enabled, module } of computerModules) {
if (!enabled) {
logger.debug('Module denied by permission, skipping', { module: name });
categories.push({ name: category, enabled: false });
for (const module of this.modules) {
if (session.getGroupMode(module.permissionGroup) === 'deny') {
logger.debug('Module denied by permission, skipping', { module: module.name });
categories.push({ name: module.category, enabled: false });
continue;
}
if (await module.isSupported()) {
defs.push(...tagCategory(module.definitions, category));
categories.push({ name: category, enabled: true });
} else {
logger.debug('Module not supported on this platform, skipping', { module: name });
categories.push({ name: category, enabled: false });
}
}
// Browser
if (session.getGroupMode('browser') !== 'deny') {
const { BrowserModule: BrowserModuleClass } = await import('./tools/browser');
this.browserModule = await BrowserModuleClass.create({
...config.browser,
logLevel: config.logLevel,
});
if (this.browserModule) {
defs.push(...tagCategory(this.browserModule.definitions, 'browser'));
categories.push({ name: 'browser', enabled: true });
} else {
logger.debug('Module not supported on this platform, skipping', {
module: 'Browser',
const activation = await module.activate({ config, dir: session.dir });
if (!activation.supported) {
logger.debug('Module disabled', {
module: module.name,
reason: activation.reason,
...(activation.hint ? { hint: activation.hint } : {}),
});
categories.push({ name: 'browser', enabled: false });
this.disabledModuleReports.push({
name: module.name,
reason: activation.reason,
...(activation.hint ? { hint: activation.hint } : {}),
});
categories.push({ name: module.category, enabled: false });
continue;
}
} else {
logger.debug('Module denied by permission, skipping', { module: 'Browser' });
categories.push({ name: 'browser', enabled: false });
defs.push(...tagCategory(activation.tools, module.category));
if (activation.shutdown) this.teardowns.push(activation.shutdown);
categories.push({ name: module.category, enabled: true });
}
for (const def of defs) {

View File

@ -7,7 +7,7 @@ const BASE_CONFIG: GatewayConfig = {
logLevel: 'info',
allowedOrigins: [],
filesystem: { dir: '/test' },
computer: { shell: { timeout: 30_000 } },
computer: { shell: { timeout: 30_000, dangerouslyDisableSandbox: false } },
browser: {
defaultBrowser: 'chrome',
},

View File

@ -165,54 +165,95 @@ function permissionIcon(mode: PermissionMode): string {
return pc.dim('✗');
}
export function printModuleStatus(config: GatewayConfig): void {
/**
* Print the module overview. Pass resolved `categories` (from the gateway) so
* runtime-gated modules (shell sandbox, native computer modules) reflect their
* actual state; without them it falls back to the permission modes alone.
*/
export function printModuleStatus(
config: GatewayConfig,
categories?: Array<{ name: string; enabled: boolean; writeAccess?: boolean }>,
): void {
const { permissions } = config;
const isOn = (category: string, fallback: boolean) =>
categories ? (categories.find((c) => c.name === category)?.enabled ?? false) : fallback;
const icon = (mode: PermissionMode, enabled: boolean) =>
enabled ? permissionIcon(mode) : pc.dim('✗');
const row = (iconStr: string, label: string, value: string) =>
` ${iconStr} ${label.padEnd(16)} ${value}`;
const disabled = pc.dim('(disabled)');
const dir = pc.dim(formatPath(config.filesystem.dir));
// Filesystem — read and write are separate permission groups
// Filesystem — permission-only, no runtime gating
const fsRead = permissions.filesystemRead ?? 'deny';
const fsWrite: PermissionMode =
fsRead === 'deny' ? 'deny' : (permissions.filesystemWrite ?? 'deny');
const dir = pc.dim(formatPath(config.filesystem.dir));
logger.info(row(permissionIcon(fsRead), 'Filesystem read', fsRead !== 'deny' ? dir : disabled), {
module: 'FilesystemRead',
});
logger.info(
` ${permissionIcon(fsRead)} Filesystem read ${fsRead !== 'deny' ? dir : pc.dim('(disabled)')}`,
{ module: 'FilesystemRead' },
);
logger.info(
` ${permissionIcon(fsWrite)} Filesystem write ${fsWrite !== 'deny' ? dir : pc.dim('(disabled)')}`,
row(permissionIcon(fsWrite), 'Filesystem write', fsWrite !== 'deny' ? dir : disabled),
{ module: 'FilesystemWrite' },
);
// Shell
// Shell — gated by the OS sandbox at runtime
const shellMode = permissions.shell ?? 'deny';
const shellDetail =
shellMode === 'deny'
? pc.dim('(disabled)')
: pc.dim(`timeout: ${config.computer.shell.timeout / 1000}s`);
logger.info(` ${permissionIcon(shellMode)} Shell ${shellDetail}`, { module: 'Shell' });
// Computer — Screenshot + Mouse/keyboard share the same group
const computerMode = permissions.computer ?? 'deny';
const computerDisabled = pc.dim('(disabled)');
const shellOn = isOn('shell', shellMode !== 'deny');
logger.info(
` ${permissionIcon(computerMode)} Screenshot ${computerMode === 'deny' ? computerDisabled : ''}`,
{ module: 'Screenshot' },
row(
icon(shellMode, shellOn),
'Shell',
shellOn ? pc.dim(`timeout: ${config.computer.shell.timeout / 1000}s`) : disabled,
),
{ module: 'Shell' },
);
// Computer — Screenshot + Mouse/keyboard, gated by native modules at runtime
const computerMode = permissions.computer ?? 'deny';
const screenshotOn = isOn('screenshot', computerMode !== 'deny');
const mouseKeyboardOn = isOn('mouse-keyboard', computerMode !== 'deny');
logger.info(row(icon(computerMode, screenshotOn), 'Screenshot', screenshotOn ? '' : disabled), {
module: 'Screenshot',
});
logger.info(
` ${permissionIcon(computerMode)} Mouse/keyboard ${computerMode === 'deny' ? computerDisabled : ''}`,
row(icon(computerMode, mouseKeyboardOn), 'Mouse/keyboard', mouseKeyboardOn ? '' : disabled),
{ module: 'MouseKeyboard' },
);
// Browser
const browserMode = permissions.browser ?? 'deny';
const browserDetail =
browserMode === 'deny' ? pc.dim('(disabled)') : pc.dim(config.browser.defaultBrowser);
logger.info(` ${permissionIcon(browserMode)} Browser ${browserDetail}`, {
module: 'Browser',
});
const browserOn = isOn('browser', browserMode !== 'deny');
logger.info(
row(
icon(browserMode, browserOn),
'Browser',
browserOn ? pc.dim(config.browser.defaultBrowser) : disabled,
),
{ module: 'Browser' },
);
logger.info('');
}
/**
* Report permitted modules that couldn't activate, with why + how to fix.
* Printed as visible lines (the logger only surfaces metadata at debug level);
* hints may be multi-line and each line is shown indented.
*/
export function printModuleDiagnostics(
modules: Array<{ name: string; reason: string; hint?: string }>,
): void {
if (modules.length === 0) return;
for (const { name, reason, hint } of modules) {
logger.warn(` ${pc.yellow('⚠')} ${name}`, { module: name, reason, ...(hint ? { hint } : {}) });
logger.warn(` ${pc.dim(reason)}`);
for (const line of hint?.split('\n') ?? []) {
logger.warn(` ${pc.dim(line)}`);
}
}
logger.warn('');
}
export function printToolList(tools: ToolDefinition[]): void {
if (tools.length === 0) return;
@ -243,7 +284,7 @@ export function printWaiting(): void {
}
export function printConnected(url: string): void {
logger.info(` ${pc.green('●')} Connected to ${pc.bold(url)}`, { url });
logger.info(`\n ${pc.green('●')} Connected to ${pc.bold(url)}\n`, { url });
}
export function printDisconnected(): void {
@ -260,9 +301,9 @@ export function printAuthFailure(): void {
}
export function printInvalidToken(url: string): void {
logger.error(` ${pc.red('✗')} Connection token invalid`);
logger.error(`\n ${pc.red('✗')} Connection token invalid`);
logger.error(
` ${pc.dim(`Go to ${url} and reconnect n8n Computer Use using a new connection token`)}`,
` ${pc.dim(`Go to ${url} and reconnect n8n Computer Use using a new connection token`)}\n`,
);
}

View File

@ -27,7 +27,7 @@ const BASE_CONFIG: GatewayConfig = {
logLevel: 'info',
allowedOrigins: [],
filesystem: { dir: process.cwd() },
computer: { shell: { timeout: 30_000 } },
computer: { shell: { timeout: 30_000, dangerouslyDisableSandbox: false } },
browser: { defaultBrowser: 'chrome' },
permissions: {},
permissionConfirmation: 'instance',

View File

@ -28,7 +28,7 @@ const BASE_CONFIG: GatewayConfig = {
logLevel: 'info',
allowedOrigins: [],
filesystem: { dir: '/tmp' },
computer: { shell: { timeout: 30_000 } },
computer: { shell: { timeout: 30_000, dangerouslyDisableSandbox: false } },
browser: {
defaultBrowser: 'chrome',
},

View File

@ -1,64 +1,38 @@
import type { Config as BrowserConfig } from '@n8n/mcp-browser';
import { logger, type LogLevel } from '../../logger';
import type { ToolDefinition, ToolModule } from '../types';
import { ModuleActivation, type ToolModule } from '../types';
export interface BrowserModuleConfig {
defaultBrowser?: string;
logLevel?: LogLevel;
adapter?: 'playwright' | 'agent-browser';
}
function toBrowserConfig(config: BrowserModuleConfig): Partial<BrowserConfig> {
const browserConfig: Partial<BrowserConfig> = {
adapter: config.adapter ?? 'agent-browser',
};
if (config.defaultBrowser) {
browserConfig.defaultBrowser = config.defaultBrowser as BrowserConfig['defaultBrowser'];
function toBrowserConfig(defaultBrowser: string): Partial<BrowserConfig> {
const browserConfig: Partial<BrowserConfig> = { adapter: 'agent-browser' };
if (defaultBrowser) {
browserConfig.defaultBrowser = defaultBrowser as BrowserConfig['defaultBrowser'];
}
return browserConfig;
}
/**
* ToolModule that exposes @n8n/mcp-browser tools through the gateway.
*
* Use `BrowserModule.create()` to construct — it dynamically imports
* `@n8n/mcp-browser` and initialises the BrowserConnection and tools.
*/
export class BrowserModule implements ToolModule {
private connection: { shutdown(): Promise<void> };
definitions: ToolDefinition[];
private constructor(definitions: ToolDefinition[], connection: { shutdown(): Promise<void> }) {
this.definitions = definitions;
this.connection = connection;
}
/**
* Create a BrowserModule if `@n8n/mcp-browser` is available.
* Returns `null` when the package cannot be imported.
*/
static async create(config: BrowserModuleConfig = {}): Promise<BrowserModule | null> {
try {
const { createBrowserTools, configureLogger } = await import('@n8n/mcp-browser');
if (config.logLevel) {
configureLogger({ level: config.logLevel });
}
const { tools, connection } = createBrowserTools(toBrowserConfig(config));
return new BrowserModule(tools, connection);
} catch {
logger.info('Browser module not supported', { reason: '@n8n/mcp-browser not available' });
return null;
}
}
isSupported() {
return true;
}
/** Shut down the BrowserConnection and close the browser. */
async shutdown(): Promise<void> {
await this.connection.shutdown();
async function loadMcpBrowser() {
try {
return await import('@n8n/mcp-browser');
} catch {
return null;
}
}
export const BrowserModule: ToolModule = {
name: 'Browser',
category: 'browser',
permissionGroup: 'browser',
async activate({ config }) {
const mcpBrowser = await loadMcpBrowser();
if (!mcpBrowser) {
return ModuleActivation.unsupported('@n8n/mcp-browser is not available');
}
if (config.logLevel) {
mcpBrowser.configureLogger({ level: config.logLevel });
}
const { tools, connection } = mcpBrowser.createBrowserTools(
toBrowserConfig(config.browser.defaultBrowser),
);
return ModuleActivation.supported(tools, async () => await connection.shutdown());
},
};

View File

@ -1,5 +1,4 @@
import { logger } from '../../logger';
import type { ToolModule } from '../types';
import { ModuleActivation, type ToolModule } from '../types';
import {
mouseMoveTool,
mouseClickTool,
@ -12,33 +11,28 @@ import {
} from './mouse-keyboard';
export const MouseKeyboardModule: ToolModule = {
async isSupported() {
// Linux Wayland: no X display available for robotjs
name: 'MouseKeyboard',
category: 'mouse-keyboard',
permissionGroup: 'computer',
async activate() {
if (process.env.WAYLAND_DISPLAY && !process.env.DISPLAY) {
logger.info('Mouse/keyboard module not supported', {
reason: 'Wayland without X11 compatibility layer',
});
return false;
return ModuleActivation.unsupported('Wayland without X11 compatibility layer');
}
try {
const robot = await import('@jitsi/robotjs');
robot.default.getMousePos();
return true;
return ModuleActivation.supported([
mouseMoveTool,
mouseClickTool,
mouseDoubleClickTool,
mouseDragTool,
mouseScrollTool,
keyboardTypeTool,
keyboardKeyTapTool,
keyboardShortcutTool,
]);
} catch (error) {
logger.info('Mouse/keyboard module not supported', {
error: error instanceof Error ? error.message : String(error),
});
return false;
return ModuleActivation.unsupported(error instanceof Error ? error.message : String(error));
}
},
definitions: [
mouseMoveTool,
mouseClickTool,
mouseDoubleClickTool,
mouseDragTool,
mouseScrollTool,
keyboardTypeTool,
keyboardKeyTapTool,
keyboardShortcutTool,
],
};

View File

@ -1,6 +1,7 @@
import robot from '@jitsi/robotjs';
import type { Mock, Mocked } from 'vitest';
import type { ModuleContext } from '../types';
import { MouseKeyboardModule } from './index';
import {
mouseMoveTool,
@ -253,7 +254,8 @@ describe('keyboard_shortcut', () => {
);
});
describe('MouseKeyboardModule.isSupported', () => {
describe('MouseKeyboardModule.activate', () => {
const MODULE_CTX = {} as ModuleContext;
const originalWaylandDisplay = process.env.WAYLAND_DISPLAY;
const originalDisplay = process.env.DISPLAY;
@ -276,19 +278,19 @@ describe('MouseKeyboardModule.isSupported', () => {
process.env.WAYLAND_DISPLAY = 'wayland-0';
delete process.env.DISPLAY;
const result = await MouseKeyboardModule.isSupported();
const result = await MouseKeyboardModule.activate(MODULE_CTX);
expect(result).toBe(false);
expect(result.supported).toBe(false);
});
it('returns true when robot loads successfully', async () => {
it('is supported when robot loads successfully', async () => {
delete process.env.WAYLAND_DISPLAY;
process.env.DISPLAY = ':0';
// The mock is already set up with getMousePos returning undefined (no throw)
const result = await MouseKeyboardModule.isSupported();
const result = await MouseKeyboardModule.activate(MODULE_CTX);
expect(result).toBe(true);
expect(result.supported).toBe(true);
});
it('returns false when robot native bindings fail to load', async () => {
@ -306,10 +308,10 @@ describe('MouseKeyboardModule.isSupported', () => {
}));
const { MouseKeyboardModule: IsolatedModule } = await import('./index');
const result = await IsolatedModule.isSupported();
const result = await IsolatedModule.activate(MODULE_CTX);
vi.doUnmock('@jitsi/robotjs');
expect(result).toBe(false);
expect(result.supported).toBe(false);
});
});

View File

@ -1,23 +1,19 @@
import { logger } from '../../logger';
import type { ToolModule } from '../types';
import { ModuleActivation, type ToolModule } from '../types';
import { screenshotRegionTool, screenshotTool } from './screenshot';
export const ScreenshotModule: ToolModule = {
async isSupported() {
name: 'Screenshot',
category: 'screenshot',
permissionGroup: 'computer',
async activate() {
try {
const { Monitor } = await import('node-screenshots');
const monitors = Monitor.all();
if (monitors.length === 0) {
logger.info('Screenshot module not supported', { reason: 'no monitors detected' });
return false;
if (Monitor.all().length === 0) {
return ModuleActivation.unsupported('No monitors detected');
}
return true;
return ModuleActivation.supported([screenshotTool, screenshotRegionTool]);
} catch (error) {
logger.info('Screenshot module not supported', {
error: error instanceof Error ? error.message : String(error),
});
return false;
return ModuleActivation.unsupported(error instanceof Error ? error.message : String(error));
}
},
definitions: [screenshotTool, screenshotRegionTool],
};

View File

@ -1,6 +1,7 @@
import { Monitor } from 'node-screenshots';
import type { Mock, MockedClass } from 'vitest';
import type { ModuleContext } from '../types';
import { ScreenshotModule } from './index';
import { screenshotTool, screenshotRegionTool } from './screenshot';
@ -20,6 +21,7 @@ vi.mock('@napi-rs/image', () => ({
const MockMonitor = Monitor as MockedClass<typeof Monitor>;
const DUMMY_CONTEXT = { dir: '/test/base' };
const MODULE_CTX = {} as ModuleContext;
interface MockImage {
width: number;
@ -260,7 +262,7 @@ describe('screen_screenshot_region tool', () => {
});
});
describe('ScreenshotModule.isSupported', () => {
describe('ScreenshotModule.activate', () => {
afterEach(() => {
vi.clearAllMocks();
});
@ -268,15 +270,17 @@ describe('ScreenshotModule.isSupported', () => {
it.each([
['has monitors', [{}], true],
['returns empty array', [], false],
])('returns %s -> %s', async (_label, monitorList, expected) => {
])('%s -> supported %s', async (_label, monitorList, expected) => {
(MockMonitor.all as Mock).mockReturnValue(monitorList);
await expect(ScreenshotModule.isSupported()).resolves.toBe(expected);
const result = await ScreenshotModule.activate(MODULE_CTX);
expect(result.supported).toBe(expected);
});
it('returns false when Monitor.all() throws', async () => {
it('is unsupported when Monitor.all() throws', async () => {
(MockMonitor.all as Mock).mockImplementation(() => {
throw new Error('Display server unavailable');
});
await expect(ScreenshotModule.isSupported()).resolves.toBe(false);
const result = await ScreenshotModule.activate(MODULE_CTX);
expect(result.supported).toBe(false);
});
});

View File

@ -1,9 +1,16 @@
import type { ToolModule } from '../types';
import { shellExecuteTool } from './shell-execute';
import { ModuleActivation, type ToolModule } from '../types';
import { resetShellSandbox, resolveShellSandbox } from './sandbox';
import { createShellExecuteTool } from './shell-execute';
export const ShellModule: ToolModule = {
isSupported() {
return true;
name: 'Shell',
category: 'shell',
permissionGroup: 'shell',
async activate({ config, dir }) {
const status = await resolveShellSandbox({ config, dir });
if (!status.enabled) return ModuleActivation.unsupported(status.reason, status.hint);
// Only the sandboxed mode initializes the runtime, so only it needs teardown.
const shutdown = status.mode === 'sandboxed' ? resetShellSandbox : undefined;
return ModuleActivation.supported([createShellExecuteTool(status.mode)], shutdown);
},
definitions: [shellExecuteTool],
};

View File

@ -0,0 +1,203 @@
import { SandboxManager } from '@anthropic-ai/sandbox-runtime';
import { spawn } from 'child_process';
import type { Mocked, MockedFunction } from 'vitest';
import { resetShellSandbox, resolveShellSandbox, spawnShell } from './sandbox';
import type { GatewayConfig } from '../../config';
import { getSettingsDir } from '../../config';
import { logger } from '../../logger';
vi.mock('@vscode/ripgrep', () => ({ rgPath: '/usr/bin/rg' }));
vi.mock('child_process');
vi.mock('@anthropic-ai/sandbox-runtime', () => ({
['SandboxManager']: {
isSupportedPlatform: vi.fn().mockReturnValue(true),
checkDependencies: vi.fn().mockReturnValue({ errors: [], warnings: [] }),
initialize: vi.fn().mockResolvedValue(undefined),
reset: vi.fn().mockResolvedValue(undefined),
wrapWithSandbox: vi
.fn()
.mockImplementation(async (cmd: string) => await Promise.resolve(`sbx(${cmd})`)),
},
}));
vi.mock('../../logger', () => ({
logger: { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() },
}));
const mockSandbox = SandboxManager as Mocked<typeof SandboxManager>;
const mockSpawn = spawn as MockedFunction<typeof spawn>;
function makeConfig(dangerouslyDisableSandbox = false): GatewayConfig {
return {
logLevel: 'silent',
allowedOrigins: [],
filesystem: { dir: '/work' },
computer: { shell: { timeout: 30_000, dangerouslyDisableSandbox } },
browser: { defaultBrowser: 'chrome' },
permissions: {},
permissionConfirmation: 'client',
};
}
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!;
function setPlatform(value: NodeJS.Platform) {
Object.defineProperty(process, 'platform', { value, configurable: true });
}
afterEach(() => {
vi.clearAllMocks();
Object.defineProperty(process, 'platform', originalPlatform);
mockSandbox.isSupportedPlatform.mockReturnValue(true);
mockSandbox.checkDependencies.mockReturnValue({ errors: [], warnings: [] });
mockSandbox.initialize.mockResolvedValue(undefined);
});
describe('resolveShellSandbox', () => {
it('enables the sandbox on macOS without extra dependencies', async () => {
setPlatform('darwin');
const status = await resolveShellSandbox({ config: makeConfig(), dir: '/work' });
expect(status).toEqual({ enabled: true, mode: 'sandboxed' });
expect(mockSandbox.initialize).toHaveBeenCalledTimes(1);
});
it('enables the sandbox on Linux when dependencies are present', async () => {
setPlatform('linux');
const status = await resolveShellSandbox({ config: makeConfig(), dir: '/work' });
expect(status).toEqual({ enabled: true, mode: 'sandboxed' });
expect(mockSandbox.checkDependencies).toHaveBeenCalled();
expect(mockSandbox.initialize).toHaveBeenCalledTimes(1);
});
it('initializes with ~/.ssh and the settings dir denied, and the working dir writable', async () => {
setPlatform('linux');
await resolveShellSandbox({ config: makeConfig(), dir: '/work' });
const config = mockSandbox.initialize.mock.calls[0][0];
expect(config.filesystem?.denyRead).toEqual(
expect.arrayContaining(['~/.ssh', getSettingsDir()]),
);
expect(config.filesystem?.denyWrite).toContain(getSettingsDir());
expect(config.filesystem?.allowWrite).toEqual(['/work']);
expect(config.ripgrep?.command).toBe('/usr/bin/rg');
});
it('disables the sandbox (fail closed) when Linux dependencies are missing', async () => {
setPlatform('linux');
mockSandbox.checkDependencies.mockReturnValue({
errors: ['bubblewrap (bwrap) not installed'],
warnings: [],
});
const status = await resolveShellSandbox({ config: makeConfig(), dir: '/work' });
expect(status.enabled).toBe(false);
if (!status.enabled) expect(status.reason).toContain('bubblewrap');
expect(mockSandbox.initialize).not.toHaveBeenCalled();
});
it('disables the sandbox on native Windows', async () => {
setPlatform('win32');
const status = await resolveShellSandbox({ config: makeConfig(), dir: '/work' });
expect(status.enabled).toBe(false);
if (!status.enabled) expect(status.reason).toContain('Windows');
expect(mockSandbox.initialize).not.toHaveBeenCalled();
expect(mockSandbox.checkDependencies).not.toHaveBeenCalled();
});
it('disables the sandbox on unsupported platforms (e.g. WSL1)', async () => {
setPlatform('linux');
mockSandbox.isSupportedPlatform.mockReturnValue(false);
const status = await resolveShellSandbox({ config: makeConfig(), dir: '/work' });
expect(status.enabled).toBe(false);
expect(mockSandbox.initialize).not.toHaveBeenCalled();
});
it('disables the sandbox (fail closed) when initialization throws', async () => {
setPlatform('linux');
mockSandbox.initialize.mockRejectedValue(new Error('bwrap: user namespaces disabled'));
const status = await resolveShellSandbox({ config: makeConfig(), dir: '/work' });
expect(status.enabled).toBe(false);
if (!status.enabled) expect(status.reason).toContain('initialize');
});
describe('explicit opt-out (--dangerously-disable-shell-sandbox)', () => {
it('runs unsandboxed and warns, without checking deps or initializing', async () => {
setPlatform('linux');
const status = await resolveShellSandbox({ config: makeConfig(true), dir: '/work' });
expect(status).toEqual({ enabled: true, mode: 'unsandboxed' });
expect(mockSandbox.checkDependencies).not.toHaveBeenCalled();
expect(mockSandbox.initialize).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('--dangerously-disable-shell-sandbox'),
);
});
it('opts out even on native Windows', async () => {
setPlatform('win32');
const status = await resolveShellSandbox({ config: makeConfig(true), dir: '/work' });
expect(status).toEqual({ enabled: true, mode: 'unsandboxed' });
});
});
});
describe('spawnShell', () => {
it('wraps the command with the sandbox when sandboxed', async () => {
await spawnShell('cat /etc/passwd', { cwd: '/work', mode: 'sandboxed' });
expect(mockSandbox.wrapWithSandbox).toHaveBeenCalledWith('cat /etc/passwd');
const [executable, options] = mockSpawn.mock.calls[0];
expect(executable).toBe('sbx(cat /etc/passwd)');
expect(options).toMatchObject({ shell: true, cwd: '/work' });
});
it('fails closed (never spawns) when the sandbox wrap throws', async () => {
mockSandbox.wrapWithSandbox.mockRejectedValue(new Error('bwrap: user namespaces disabled'));
await expect(spawnShell('cat /etc/passwd', { mode: 'sandboxed' })).rejects.toThrow('bwrap');
expect(mockSpawn).not.toHaveBeenCalled();
});
it('runs bare sh on non-Windows when unsandboxed', async () => {
setPlatform('linux');
await spawnShell('id', { cwd: '/work', mode: 'unsandboxed' });
expect(mockSandbox.wrapWithSandbox).not.toHaveBeenCalled();
const [executable, args] = mockSpawn.mock.calls[0];
expect(executable).toBe('sh');
expect(args).toEqual(['-c', 'id']);
});
it('runs cmd.exe on Windows when unsandboxed', async () => {
setPlatform('win32');
await spawnShell('dir', { mode: 'unsandboxed' });
const [executable, args] = mockSpawn.mock.calls[0];
expect(executable).toBe('cmd.exe');
expect(args).toEqual(['/C', 'dir']);
});
});
describe('resetShellSandbox', () => {
it('tears down the sandbox', async () => {
await resetShellSandbox();
expect(mockSandbox.reset).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,128 @@
import type { SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime';
import { spawn, type ChildProcess } from 'node:child_process';
import { getSettingsDir, type GatewayConfig } from '../../config';
import { logger } from '../../logger';
export type ShellSandboxMode = 'sandboxed' | 'unsandboxed';
export type ShellSandboxStatus =
| { enabled: true; mode: ShellSandboxMode }
| { enabled: false; reason: string; hint?: string };
const SANDBOX_DOCS = 'https://www.npmjs.com/package/@n8n/computer-use#shell-sandboxing';
function buildSandboxConfig({
dir,
rgPath,
}: { dir: string; rgPath: string }): SandboxRuntimeConfig {
return {
// Use the bundled ripgrep so a system rg isn't required.
ripgrep: { command: rgPath },
network: {
allowedDomains: [],
deniedDomains: [],
},
filesystem: {
denyRead: ['~/.ssh', getSettingsDir()],
allowRead: [],
allowWrite: [dir],
denyWrite: [getSettingsDir()],
},
};
}
/**
* Resolve whether shell execution can run inside the OS sandbox. Fail-closed:
* when the sandbox is unavailable the shell tool is not registered, unless
* `--dangerously-disable-shell-sandbox` opts out.
*/
export async function resolveShellSandbox({
config,
dir,
}: {
config: GatewayConfig;
dir: string;
}): Promise<ShellSandboxStatus> {
if (config.computer.shell.dangerouslyDisableSandbox) {
logger.warn(
'Shell sandbox disabled via --dangerously-disable-shell-sandbox: commands run without OS-level isolation. Only use this in a trusted, isolated environment.',
);
return { enabled: true, mode: 'unsandboxed' };
}
// The native Windows backend isn't a real security boundary; require WSL2.
if (process.platform === 'win32') {
return {
enabled: false,
reason: 'Shell sandbox is not available on native Windows',
hint: `Run inside WSL2 (needs bubblewrap + socat), or pass --dangerously-disable-shell-sandbox to run without isolation.\nDocs: ${SANDBOX_DOCS}`,
};
}
const { SandboxManager } = await import('@anthropic-ai/sandbox-runtime');
const { rgPath } = await import('@vscode/ripgrep');
if (!SandboxManager.isSupportedPlatform()) {
return {
enabled: false,
reason: 'Shell sandbox is not supported on this platform',
hint: `Use macOS, Linux, or WSL2 (WSL1 is not supported), or pass --dangerously-disable-shell-sandbox to run without isolation.\nDocs: ${SANDBOX_DOCS}`,
};
}
// Checks for required binaries (bwrap/socat on Linux); can't verify the kernel
// allows user namespaces, so initialize() below may still fail closed.
const deps = SandboxManager.checkDependencies({ command: rgPath });
if (deps.errors.length > 0) {
return {
enabled: false,
reason: `Shell sandbox dependencies missing: ${deps.errors.join(', ')}`,
hint:
process.platform === 'linux'
? `Debian/Ubuntu: sudo apt-get install bubblewrap socat\nFedora: sudo dnf install bubblewrap socat\nUbuntu 24.04+: also allow bwrap user namespaces\nDocs: ${SANDBOX_DOCS}`
: undefined,
};
}
for (const warning of deps.warnings) {
logger.warn('Shell sandbox degraded', { warning });
}
try {
await SandboxManager.initialize(buildSandboxConfig({ dir, rgPath }));
} catch (error) {
return {
enabled: false,
reason: `Shell sandbox failed to initialize: ${error instanceof Error ? error.message : String(error)}`,
hint: `Sandbox tools are installed but couldn't start, usually blocked user namespaces.\nUbuntu 24.04+: allow bwrap user namespaces. Docker: run with --privileged.\nDocs: ${SANDBOX_DOCS}`,
};
}
return { enabled: true, mode: 'sandboxed' };
}
/** Tear down the sandbox and its proxy processes. */
export async function resetShellSandbox(): Promise<void> {
const { SandboxManager } = await import('@anthropic-ai/sandbox-runtime');
await SandboxManager.reset();
}
/**
* Launch a shell command. 'sandboxed' wraps it with the OS sandbox; a wrap
* failure throws so the caller fails closed rather than running unsandboxed.
*/
export async function spawnShell(
command: string,
{ cwd, mode }: { cwd?: string; mode: ShellSandboxMode },
): Promise<ChildProcess> {
if (mode === 'sandboxed') {
const { SandboxManager } = await import('@anthropic-ai/sandbox-runtime');
const sandboxedCommand = await SandboxManager.wrapWithSandbox(command);
return spawn(sandboxedCommand, { shell: true, cwd });
}
if (process.platform === 'win32') {
return spawn('cmd.exe', ['/C', command], { cwd });
}
return spawn('sh', ['-c', command], { cwd });
}

View File

@ -3,12 +3,10 @@ import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import type { Mock, Mocked, MockedFunction } from 'vitest';
import { getSettingsDir } from '../../config';
import { textOf } from '../test-utils';
import type { AffectedResource } from '../types';
import { buildShellResource } from './build-shell-resource';
import { ShellModule } from './index';
import { shellExecuteTool } from './shell-execute';
import { createShellExecuteTool } from './shell-execute';
vi.mock('child_process');
vi.mock('@vscode/ripgrep', () => ({ rgPath: '/usr/bin/rg' }));
@ -24,6 +22,10 @@ const mockSandboxManager = SandboxManager as Mocked<typeof SandboxManager>;
const mockSpawn = spawn as MockedFunction<typeof spawn>;
// Plumbing tests use the unsandboxed tool (sh -c) so spawn is called with the
// classic (executable, args, options) shape. Sandbox-mode behavior is covered in
// the cross-platform block below and in sandbox.test.ts.
const shellExecuteTool = createShellExecuteTool('unsandboxed');
const DUMMY_CONTEXT = { dir: '/test/base' };
function makeMockChild(
@ -59,6 +61,9 @@ function getErrorHandler(on: Mock): ((error: Error) => void) | undefined {
async function flushMicrotasks(ticks = 1) {
for (let i = 0; i < ticks; i++) await Promise.resolve();
}
async function flushUntil(predicate: () => boolean, maxTicks = 50) {
for (let i = 0; i < maxTicks && !predicate(); i++) await Promise.resolve();
}
describe('shell_execute tool', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
@ -337,56 +342,28 @@ describe('shell_execute tool', () => {
expect(args).toEqual(['-c', 'ls']);
});
it('wraps command with SandboxManager on darwin', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
it('wraps the command with the sandbox when sandboxed, regardless of platform', async () => {
mockSandboxManager.wrapWithSandbox.mockResolvedValue('sandboxed-ls');
const child = makeMockChild();
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>);
const resultPromise = shellExecuteTool.execute(
{ command: 'ls', timeout: 5000 },
DUMMY_CONTEXT,
);
const sandboxedTool = createShellExecuteTool('sandboxed');
const resultPromise = sandboxedTool.execute({ command: 'ls', timeout: 5000 }, DUMMY_CONTEXT);
// darwin path has extra async depth: initializeSandbox (×2 awaits) + wrapWithSandbox + return + .then()
await flushMicrotasks(5);
// The sandboxed path lazy-imports the runtime and awaits the wrap, so wait
// for the spawn rather than counting microtasks.
await flushUntil(() => mockSpawn.mock.calls.length > 0);
const closeHandler = getCloseHandler(child.on);
closeHandler?.(0);
await resultPromise;
expect(mockSandboxManager.initialize).toHaveBeenCalled();
expect(mockSandboxManager.wrapWithSandbox).toHaveBeenCalledWith('ls');
const [executable, spawnOptions] = mockSpawn.mock.calls[0];
expect(executable).toBe('sandboxed-ls');
expect(spawnOptions).toMatchObject({ shell: true });
});
it('includes settings dir in sandbox denyWrite and denyRead on darwin', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
mockSandboxManager.wrapWithSandbox.mockResolvedValue('sandboxed-ls');
const child = makeMockChild();
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>);
const resultPromise = shellExecuteTool.execute(
{ command: 'ls', timeout: 5000 },
DUMMY_CONTEXT,
);
await flushMicrotasks(5);
const closeHandler = getCloseHandler(child.on);
closeHandler?.(0);
await resultPromise;
const initCall = mockSandboxManager.initialize.mock.calls[0][0] as {
filesystem: { denyWrite: string[]; denyRead: string[] };
};
expect(initCall.filesystem.denyWrite).toContain(getSettingsDir());
expect(initCall.filesystem.denyRead).toContain(getSettingsDir());
});
});
describe('result JSON structure', () => {
@ -446,12 +423,6 @@ describe('shell_execute tool', () => {
});
});
describe('ShellModule', () => {
it('isSupported returns true', () => {
expect(ShellModule.isSupported()).toBe(true);
});
});
describe('getAffectedResources', () => {
it('uses buildShellResource for the resource and includes the full command in description', () => {
const resources = shellExecuteTool.getAffectedResources(

View File

@ -1,32 +1,10 @@
import { SandboxManager, type SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime';
import { rgPath } from '@vscode/ripgrep';
import { spawn } from 'child_process';
import path from 'node:path';
import { z } from 'zod';
import { getSettingsDir } from '../../config';
import type { CallToolResult, ToolDefinition } from '../types';
import { formatCallToolResult, formatErrorResult } from '../utils';
import { buildShellResource } from './build-shell-resource';
async function initializeSandbox({ dir }: { dir: string }) {
const config: SandboxRuntimeConfig = {
ripgrep: {
command: rgPath,
},
network: {
allowedDomains: [],
deniedDomains: [],
},
filesystem: {
denyRead: ['~/.ssh', getSettingsDir()],
allowRead: [],
allowWrite: [dir],
denyWrite: [getSettingsDir()],
},
};
await SandboxManager.initialize(config);
}
import { spawnShell, type ShellSandboxMode } from './sandbox';
const inputSchema = z.object({
command: z.string().describe('Shell command to execute'),
@ -38,81 +16,73 @@ function resolveCommandPath(dir: string, cwd: string | undefined) {
return path.resolve(dir, cwd ?? '.');
}
export const shellExecuteTool: ToolDefinition<typeof inputSchema> = {
name: 'shell_execute',
description: 'Execute a shell command and return stdout, stderr, and exit code',
inputSchema,
annotations: { destructiveHint: true },
getAffectedResources({ command, cwd }, { dir }) {
const resolvedPath = resolveCommandPath(dir, cwd);
const resource = buildShellResource(command, resolvedPath);
const description = `Execute shell command: ${command} in ${resolvedPath}`;
return [
{
toolGroup: 'shell' as const,
resource,
description,
},
];
},
async execute({ command, timeout = 30_000, cwd }, { dir }) {
return await runCommand(command, { timeout, dir, cwd: resolveCommandPath(dir, cwd) });
},
};
async function spawnCommand(command: string, { dir, cwd }: { dir: string; cwd?: string }) {
const isWindows = process.platform === 'win32';
const isMac = process.platform === 'darwin';
if (isWindows) {
return spawn('cmd.exe', ['/C', command], { cwd });
}
if (isMac) {
await initializeSandbox({ dir });
const sandboxedCommand = await SandboxManager.wrapWithSandbox(command);
return spawn(sandboxedCommand, { shell: true, cwd });
}
return spawn('sh', ['-c', command], { cwd });
/** Mode is baked in at creation, so an unsandboxed shell is only reachable when explicitly built as such. */
export function createShellExecuteTool(mode: ShellSandboxMode): ToolDefinition<typeof inputSchema> {
return {
name: 'shell_execute',
description: 'Execute a shell command and return stdout, stderr, and exit code',
inputSchema,
annotations: { destructiveHint: true },
getAffectedResources({ command, cwd }, { dir }) {
const resolvedPath = resolveCommandPath(dir, cwd);
const resource = buildShellResource(command, resolvedPath);
const description = `Execute shell command: ${command} in ${resolvedPath}`;
return [
{
toolGroup: 'shell' as const,
resource,
description,
},
];
},
async execute({ command, timeout = 30_000, cwd }, { dir }) {
return await runCommand(command, { timeout, cwd: resolveCommandPath(dir, cwd), mode });
},
};
}
async function runCommand(
command: string,
{ timeout, cwd, dir }: { timeout: number; dir: string; cwd?: string },
{ timeout, cwd, mode }: { timeout: number; cwd?: string; mode: ShellSandboxMode },
): Promise<CallToolResult> {
return await new Promise<CallToolResult>((resolve, reject) => {
spawnCommand(command, { dir, cwd })
.then((child) => {
let stdout = '';
let stderr = '';
let child;
try {
child = await spawnShell(command, { cwd, mode });
} catch (error) {
// Fail closed: never run unsandboxed.
return formatErrorResult(
`Failed to start sandboxed process: ${error instanceof Error ? error.message : String(error)}`,
);
}
child.stdout?.on('data', (chunk: Buffer) => {
stdout += String(chunk);
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += String(chunk);
});
return await new Promise<CallToolResult>((resolve) => {
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill();
resolve(formatCallToolResult({ stdout, stderr, exitCode: null, timedOut: true }));
}, timeout);
child.stdout?.on('data', (chunk: Buffer) => {
stdout += String(chunk);
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += String(chunk);
});
child.on('close', (code) => {
clearTimeout(timer);
const result = formatCallToolResult({ stdout, stderr, exitCode: code });
if (code !== 0) {
result.isError = true;
}
resolve(result);
});
const timer = setTimeout(() => {
child.kill();
resolve(formatCallToolResult({ stdout, stderr, exitCode: null, timedOut: true }));
}, timeout);
child.on('error', (error) => {
clearTimeout(timer);
resolve(formatErrorResult(`Failed to start process: ${error.message}`));
});
})
.catch(reject);
child.on('close', (code) => {
clearTimeout(timer);
const result = formatCallToolResult({ stdout, stderr, exitCode: code });
if (code !== 0) {
result.isError = true;
}
resolve(result);
});
child.on('error', (error) => {
clearTimeout(timer);
resolve(formatErrorResult(`Failed to start process: ${error.message}`));
});
});
}

View File

@ -2,7 +2,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import type { CreateCredentialPayload, SecretsBuffer } from '@n8n/mcp-browser';
import type { z } from 'zod';
import type { ToolGroup } from '../config';
import type { GatewayConfig, ToolGroup } from '../config';
export type { CallToolResult, CreateCredentialPayload, SecretsBuffer };
@ -85,9 +85,31 @@ export interface ToolDefinition<TSchema extends z.ZodType = z.ZodType> {
): AffectedResource[] | Promise<AffectedResource[]>;
}
export interface ToolModule {
/** Return false if this module cannot run on the current platform or lacks required permissions */
isSupported(): boolean | Promise<boolean>;
/** Tool definitions provided by this module */
definitions: ToolDefinition[];
/** Inputs a module needs to resolve availability and build its tools. */
export interface ModuleContext {
config: GatewayConfig;
dir: string;
}
/** Result of activating a module: available with tools (+ optional teardown), or unavailable with a reason. */
export type ModuleActivation =
| { supported: true; tools: ToolDefinition[]; shutdown?: () => Promise<void> }
| { supported: false; reason: string; hint?: string };
export const ModuleActivation = {
supported(tools: ToolDefinition[], shutdown?: () => Promise<void>): ModuleActivation {
return { supported: true, tools, ...(shutdown ? { shutdown } : {}) };
},
unsupported(reason: string, hint?: string): ModuleActivation {
return { supported: false, reason, ...(hint ? { hint } : {}) };
},
} as const;
export interface ToolModule {
readonly name: string;
/** Annotation used to route tools to the correct sub-agent (e.g. 'browser'). */
readonly category: string;
readonly permissionGroup: ToolGroup;
/** Resolve availability once at startup; may do one-time init (native imports, sandbox setup). */
activate(context: ModuleContext): ModuleActivation | Promise<ModuleActivation>;
}

View File

@ -0,0 +1,68 @@
import {
assertOoxmlWithinBounds,
MAX_OOXML_ENTRIES,
MAX_OOXML_UNCOMPRESSED_BYTES,
} from '../ooxml-guard';
// ── Minimal ZIP builders ─────────────────────────────────────────────────────
// `assertOoxmlWithinBounds` only reads the central directory + EOCD, so the tests
// build just those records (no local file data) and point the EOCD at offset 0.
function centralHeader(name: string, uncompressedSize: number): Buffer {
const nameBuf = Buffer.from(name, 'utf-8');
const header = Buffer.alloc(46 + nameBuf.length);
header.writeUInt32LE(0x02014b50, 0); // central file header signature
header.writeUInt32LE(uncompressedSize >>> 0, 24); // uncompressed size
header.writeUInt16LE(nameBuf.length, 28); // file name length
nameBuf.copy(header, 46);
return header;
}
function makeZip(entries: Array<{ name: string; uncompressedSize: number }>): Buffer {
const cd = Buffer.concat(entries.map((e) => centralHeader(e.name, e.uncompressedSize)));
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0); // EOCD signature
eocd.writeUInt16LE(entries.length, 8); // entries on this disk
eocd.writeUInt16LE(entries.length, 10); // total entries
eocd.writeUInt32LE(cd.length, 12); // central directory size
eocd.writeUInt32LE(0, 16); // central directory offset
return Buffer.concat([cd, eocd]);
}
describe('assertOoxmlWithinBounds', () => {
it('accepts a normal archive within bounds', () => {
const zip = makeZip([
{ name: 'word/document.xml', uncompressedSize: 50_000 },
{ name: '[Content_Types].xml', uncompressedSize: 1_000 },
]);
expect(() => assertOoxmlWithinBounds(zip, 'doc.docx')).not.toThrow();
});
it('rejects when total declared uncompressed size exceeds the cap', () => {
const zip = makeZip([
{ name: 'sheet1.xml', uncompressedSize: MAX_OOXML_UNCOMPRESSED_BYTES },
{ name: 'sheet2.xml', uncompressedSize: 1 },
]);
expect(() => assertOoxmlWithinBounds(zip, 'bomb.xlsx')).toThrow(/decompresses to more than/);
});
it('rejects when entry count exceeds the cap', () => {
const entries = Array.from({ length: MAX_OOXML_ENTRIES + 1 }, (_, i) => ({
name: `part${i}.xml`,
uncompressedSize: 1,
}));
expect(() => assertOoxmlWithinBounds(makeZip(entries), 'bomb.xlsx')).toThrow(
/too many archive entries/,
);
});
it('rejects a ZIP64-sized entry without decoding the extra field', () => {
const zip = makeZip([{ name: 'huge.bin', uncompressedSize: 0xffffffff }]);
expect(() => assertOoxmlWithinBounds(zip, 'bomb.xlsx')).toThrow(/ZIP64/);
});
it('passes through a buffer with no central directory (downstream parser will reject)', () => {
const notAZip = Buffer.from('this is plain text, not an archive', 'utf-8');
expect(() => assertOoxmlWithinBounds(notAZip, 'plain.txt')).not.toThrow();
});
});

View File

@ -1,3 +1,4 @@
import { assertOoxmlWithinBounds } from './ooxml-guard';
import {
MAX_DECODED_SIZE_BYTES,
MAX_RESULT_CHARS,
@ -18,6 +19,7 @@ export async function extractDocxText(attachment: AttachmentInfo): Promise<DocxE
if (decoded.length > MAX_DECODED_SIZE_BYTES) {
throw new Error(formatSizeLimitMessage(decoded.length));
}
assertOoxmlWithinBounds(decoded, attachment.fileName);
const mammoth = await import('mammoth');
const extractRawText = mammoth.extractRawText ?? mammoth.default?.extractRawText;

View File

@ -0,0 +1,83 @@
/**
* Decompression-bomb guard for OOXML attachments (`.docx`, `.xlsx`, …), which are
* ZIP containers. The compressed payload is already capped (MAX_DECODED_SIZE_BYTES),
* but a small archive can still inflate to many gigabytes and OOM the instance.
*
* `mammoth` / SheetJS parse synchronously, so a wall-clock timeout can't interrupt
* them on the main thread. Instead we read the ZIP central directory (cheap — no
* inflation) and reject up front when the declared uncompressed size or entry count
* exceeds a sane bound.
*/
const EOCD_SIGNATURE = 0x06054b50; // "PK\x05\x06"
const CENTRAL_FILE_HEADER_SIGNATURE = 0x02014b50; // "PK\x01\x02"
const EOCD_MIN_SIZE = 22;
const CENTRAL_FILE_HEADER_MIN_SIZE = 46;
const MAX_EOCD_COMMENT = 0xffff;
const ZIP64_SIZE_MARKER = 0xffffffff;
/** Total declared uncompressed size across all entries (200 MB). */
export const MAX_OOXML_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
/** Max number of entries in the archive. */
export const MAX_OOXML_ENTRIES = 1024;
function formatMB(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Reads the ZIP central directory of `buffer` and throws when the archive declares
* more than `MAX_OOXML_UNCOMPRESSED_BYTES` of uncompressed data or more than
* `MAX_OOXML_ENTRIES` entries. A buffer without a readable central directory is
* left untouched — it isn't a valid OOXML archive and the downstream parser will
* reject it (the compressed size is already bounded).
*/
export function assertOoxmlWithinBounds(buffer: Buffer, fileName: string): void {
const eocd = findEndOfCentralDirectory(buffer);
if (eocd === undefined) return;
const entryCount = buffer.readUInt16LE(eocd + 10);
if (entryCount > MAX_OOXML_ENTRIES) {
throw new Error(
`"${fileName}" has too many archive entries (${entryCount} > ${MAX_OOXML_ENTRIES}); refusing to parse a potential decompression bomb.`,
);
}
let offset = buffer.readUInt32LE(eocd + 16);
let totalUncompressed = 0;
for (let i = 0; i < entryCount; i++) {
if (offset + CENTRAL_FILE_HEADER_MIN_SIZE > buffer.length) break;
if (buffer.readUInt32LE(offset) !== CENTRAL_FILE_HEADER_SIGNATURE) break;
const uncompressed = buffer.readUInt32LE(offset + 24);
// A ZIP64 marker means the real size doesn't fit in 32 bits (>= 4 GB) — well
// past any sane bound, so reject without decoding the ZIP64 extra field.
if (uncompressed === ZIP64_SIZE_MARKER) {
throw new Error(
`"${fileName}" declares a ZIP64-sized entry; refusing to parse a potential decompression bomb.`,
);
}
totalUncompressed += uncompressed;
if (totalUncompressed > MAX_OOXML_UNCOMPRESSED_BYTES) {
throw new Error(
`"${fileName}" decompresses to more than ${formatMB(MAX_OOXML_UNCOMPRESSED_BYTES)}; refusing to parse a potential decompression bomb.`,
);
}
const nameLen = buffer.readUInt16LE(offset + 28);
const extraLen = buffer.readUInt16LE(offset + 30);
const commentLen = buffer.readUInt16LE(offset + 32);
offset += CENTRAL_FILE_HEADER_MIN_SIZE + nameLen + extraLen + commentLen;
}
}
/** Scans backwards for the End Of Central Directory record, tolerating a trailing comment. */
function findEndOfCentralDirectory(buffer: Buffer): number | undefined {
if (buffer.length < EOCD_MIN_SIZE) return undefined;
const scanLimit = Math.max(0, buffer.length - EOCD_MIN_SIZE - MAX_EOCD_COMMENT);
for (let i = buffer.length - EOCD_MIN_SIZE; i >= scanLimit; i--) {
if (buffer.readUInt32LE(i) === EOCD_SIGNATURE) return i;
}
return undefined;
}

View File

@ -1,3 +1,4 @@
import { assertOoxmlWithinBounds } from './ooxml-guard';
import {
MAX_DECODED_SIZE_BYTES,
formatSizeLimitMessage,
@ -23,6 +24,7 @@ export async function extractXlsxAsRows(
if (decoded.length > MAX_DECODED_SIZE_BYTES) {
throw new Error(formatSizeLimitMessage(decoded.length));
}
assertOoxmlWithinBounds(decoded, attachment.fileName);
const XLSX = await import('xlsx');

View File

@ -245,13 +245,14 @@ describe('executions tool', () => {
expect(result).toEqual(executionResult);
});
it('should execute immediately when permission is always_allow', async () => {
it('should execute immediately when always_allow + workflow was created by the agent', async () => {
const executionResult: ExecutionResult = {
executionId: 'exec-456',
status: 'success',
};
const context = createMockContext({
permissions: { runWorkflow: 'always_allow' },
aiCreatedWorkflowIds: new Set(['wf-1']),
});
(context.executionService.run as Mock).mockResolvedValue(executionResult);
@ -273,6 +274,7 @@ describe('executions tool', () => {
it('should pass undefined inputData when not provided', async () => {
const context = createMockContext({
permissions: { runWorkflow: 'always_allow' },
aiCreatedWorkflowIds: new Set(['wf-1']),
});
(context.executionService.run as Mock).mockResolvedValue({
executionId: 'exec-1',
@ -606,6 +608,70 @@ describe('executions tool', () => {
expect(result).toBeUndefined();
});
});
describe('aiCreatedWorkflowIds scope (no explicit allow-list)', () => {
it('runs without HITL when always_allow + workflow was created by the agent', async () => {
const context = createMockContext({
permissions: { runWorkflow: 'always_allow' },
aiCreatedWorkflowIds: new Set(['wf-built']),
});
(context.executionService.run as Mock).mockResolvedValue({
executionId: 'exec-1',
status: 'success',
});
const suspendFn = vi.fn();
const tool = createExecutionsTool(context);
await executeTool(
tool,
{ action: 'run' as const, workflowId: 'wf-built' },
createAgentCtx({ suspend: suspendFn }) as never,
);
expect(suspendFn).not.toHaveBeenCalled();
expect(context.executionService.run).toHaveBeenCalledWith('wf-built', undefined, {
timeout: undefined,
});
});
it('still requires HITL for a pre-existing workflow the agent did not create', async () => {
const context = createMockContext({
permissions: { runWorkflow: 'always_allow' },
aiCreatedWorkflowIds: new Set(['wf-built']),
});
(context.workflowService.get as Mock).mockResolvedValue({ name: 'Pre-existing WF' });
const suspendFn = vi.fn();
const tool = createExecutionsTool(context);
const result = await executeTool(
tool,
{ action: 'run' as const, workflowId: 'wf-preexisting' },
createAgentCtx({ suspend: suspendFn }) as never,
);
expect(suspendFn).toHaveBeenCalled();
expect(context.executionService.run).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('requires HITL when always_allow is set but the agent created nothing', async () => {
const context = createMockContext({
permissions: { runWorkflow: 'always_allow' },
});
(context.workflowService.get as Mock).mockResolvedValue({ name: 'Some WF' });
const suspendFn = vi.fn();
const tool = createExecutionsTool(context);
await executeTool(
tool,
{ action: 'run' as const, workflowId: 'wf-1' },
createAgentCtx({ suspend: suspendFn }) as never,
);
expect(suspendFn).toHaveBeenCalled();
expect(context.executionService.run).not.toHaveBeenCalled();
});
});
});
// ── debug ───────────────────────────────────────────────────────────────

View File

@ -212,10 +212,13 @@ async function handleRun(
};
}
// `always_allow` is only honored for the workflow IDs the caller pre-authorized
// (e.g. checkpoint follow-ups scope the override to the workflows the checkpoint
// is verifying). When the allow-list is unset, `always_allow` applies broadly,
// matching the legacy behavior.
// `always_allow` is only honored for the workflow IDs the caller pre-authorized.
// Checkpoint follow-ups pass an explicit allow-list (the workflows the checkpoint is
// verifying). When the allow-list is unset (e.g. planned-build follow-ups, which grant
// `runWorkflow: 'always_allow'` without one), the bypass is scoped to the workflows the
// agent created during the active plan cycle. Running any other pre-existing workflow
// still requires HITL approval, so a prompt injection can't silently run arbitrary
// workflows under the user's authority.
const allowList = context.allowedRunWorkflowIds;
const workflowNameAllowList = context.allowedRunWorkflowNames;
let workflowName: string | undefined;
@ -244,10 +247,14 @@ async function handleRun(
allowedByName = true;
}
}
const allowedByList =
allowList !== undefined
? allowList.has(workflowId)
: (context.aiCreatedWorkflowIds?.has(workflowId) ?? false);
const allowedByScope =
context.requireRunWorkflowApproval !== true &&
context.permissions?.runWorkflow === 'always_allow' &&
(allowList === undefined || allowList.has(workflowId) || allowedByName);
(allowedByList || allowedByName);
// A per-workflow "always allow" grant skips HITL for the rest of the session, but an
// admin's `requireRunWorkflowApproval` always wins - same gate as `allowedByScope`.

View File

@ -66,7 +66,7 @@ const BASE_CONFIG: GatewayConfig = {
logLevel: 'info',
allowedOrigins: ['https://*.app.n8n.cloud'],
filesystem: { dir: '/' },
computer: { shell: { timeout: 30_000 } },
computer: { shell: { timeout: 30_000, dangerouslyDisableSandbox: false } },
browser: { defaultBrowser: 'chrome' },
permissions: {},
permissionConfirmation: 'instance',

View File

@ -64,7 +64,7 @@ export class SettingsStore {
logLevel: s.logLevel,
allowedOrigins: origins,
filesystem: { dir: s.filesystemDir },
computer: { shell: { timeout: 30_000 } },
computer: { shell: { timeout: 30_000, dangerouslyDisableSandbox: false } },
browser: {
defaultBrowser: 'chrome',
},

View File

@ -17,6 +17,8 @@ import {
type SupplyData,
} from 'n8n-workflow';
import { getCustomCredentialHeader } from '@utils/helpers';
import { searchModels } from './methods/searchModels';
const ANTHROPIC_MODEL_BUILDER_HINT = {
@ -583,14 +585,10 @@ export class LmChatAnthropic implements INodeType {
},
};
if (
credentials.header &&
typeof credentials.headerName === 'string' &&
credentials.headerName &&
typeof credentials.headerValue === 'string'
) {
const customHeader = getCustomCredentialHeader(credentials);
if (customHeader) {
clientOptions.defaultHeaders = {
[credentials.headerName]: credentials.headerValue,
[customHeader.name]: customHeader.value,
};
}
@ -637,7 +635,12 @@ export class LmChatAnthropic implements INodeType {
model: modelName,
anthropicApiUrl: baseURL,
maxTokens: options.maxTokensToSample,
callbacks: [new N8nLlmTracing(this, { tokensUsageParser })],
callbacks: [
new N8nLlmTracing(this, {
tokensUsageParser,
redactedHeaders: customHeader ? [customHeader.name] : [],
}),
],
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, failedAttemptHandler),
invocationKwargs,
clientOptions,

View File

@ -466,6 +466,31 @@ describe('LmChatAnthropic', () => {
);
});
it('should pass the declared header name to N8nLlmTracing', async () => {
const mockContext = setupMockContext();
mockContext.getCredentials.mockResolvedValue({
apiKey: 'test-api-key',
header: true,
headerName: 'x-custom-header',
headerValue: 'secret-value',
});
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'model.value') return 'claude-sonnet-4-20250514';
if (paramName === 'options') return {};
return undefined;
});
await lmChatAnthropic.supplyData.call(mockContext, 0);
expect(MockedN8nLlmTracing).toHaveBeenCalledWith(
mockContext,
expect.objectContaining({
redactedHeaders: ['x-custom-header'],
}),
);
});
it('should create failed attempt handler for direct API', async () => {
const mockContext = setupMockContext();

View File

@ -11,7 +11,7 @@ import {
type SupplyData,
} from 'n8n-workflow';
import { mergeCustomHeaders } from '@utils/helpers';
import { getCustomCredentialHeader, mergeCustomHeaders } from '@utils/helpers';
import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling';
import {
@ -778,6 +778,7 @@ export class LmChatOpenAi implements INodeType {
bodyTimeout: timeout,
}),
};
const customHeader = getCustomCredentialHeader(credentials);
configuration.defaultHeaders = mergeCustomHeaders(
credentials,
(configuration.defaultHeaders ?? {}) as Record<string, string>,
@ -811,7 +812,9 @@ export class LmChatOpenAi implements INodeType {
timeout,
maxRetries: options.maxRetries ?? 2,
configuration,
callbacks: [new N8nLlmTracing(this)],
callbacks: [
new N8nLlmTracing(this, { redactedHeaders: customHeader ? [customHeader.name] : [] }),
],
modelKwargs,
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler),
// Set to false to ensure compatibility with OpenAI-compatible backends (LM Studio, vLLM, etc.)

View File

@ -11,7 +11,7 @@ import type {
import { Container } from '@n8n/di';
import { AiConfig } from '@n8n/config';
import { mergeCustomHeaders } from '@utils/helpers';
import { getCustomCredentialHeader, mergeCustomHeaders } from '@utils/helpers';
type LmOpenAiOptions = {
baseURL?: string;
@ -250,6 +250,7 @@ export class LmOpenAi implements INodeType {
};
const { openAiDefaultHeaders } = Container.get(AiConfig);
const customHeader = getCustomCredentialHeader(credentials);
const defaultHeaders = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {});
const timeout = options.timeout;
const configuration: ClientOptions = {
@ -273,7 +274,9 @@ export class LmOpenAi implements INodeType {
configuration,
timeout,
maxRetries: options.maxRetries ?? 2,
callbacks: [new N8nLlmTracing(this)],
callbacks: [
new N8nLlmTracing(this, { redactedHeaders: customHeader ? [customHeader.name] : [] }),
],
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
});

View File

@ -7,12 +7,11 @@ import type {
} from '@langchain/core/load/serializable';
import type { BaseMessage } from '@langchain/core/messages';
import type { LLMResult } from '@langchain/core/outputs';
import { logAiEvent } from '@n8n/ai-utilities';
import pick from 'lodash/pick';
import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow';
import { NodeConnectionTypes, NodeError, NodeOperationError } from 'n8n-workflow';
import { logAiEvent } from '@n8n/ai-utilities';
type RunDetail = {
index: number;
messages: BaseMessage[] | string[] | string;

View File

@ -319,6 +319,7 @@ describe('LmChatAnthropic', () => {
expect(MockedN8nLlmTracing).toHaveBeenCalledWith(mockContext, {
tokensUsageParser: expect.any(Function),
redactedHeaders: [],
});
});

View File

@ -356,7 +356,29 @@ describe('LmChatOpenAi', () => {
await lmChatOpenAi.supplyData.call(mockContext, 0);
expect(MockedN8nLlmTracing).toHaveBeenCalledWith(mockContext);
expect(MockedN8nLlmTracing).toHaveBeenCalledWith(mockContext, { redactedHeaders: [] });
});
it('should pass the declared header name to N8nLlmTracing', async () => {
const mockContext = setupMockContext();
mockContext.getCredentials = vi.fn().mockResolvedValue({
apiKey: 'test-api-key',
header: true,
headerName: 'x-custom-header',
headerValue: 'secret-value',
});
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'model.value') return 'gpt-4o-mini';
if (paramName === 'options') return {};
return undefined;
});
await lmChatOpenAi.supplyData.call(mockContext, 0);
expect(MockedN8nLlmTracing).toHaveBeenCalledWith(mockContext, {
redactedHeaders: ['x-custom-header'],
});
});
it('should create failed attempt handler', async () => {

View File

@ -0,0 +1,95 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { OpenAI } from '@langchain/openai';
import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
import type { Mocked } from 'vitest';
import { LmOpenAi } from '../LMOpenAi/LmOpenAi.node';
vi.mock('@langchain/openai');
vi.mock('@n8n/ai-utilities');
const MockedN8nLlmTracing = vi.mocked(N8nLlmTracing);
const mockedMakeN8nLlmFailedAttemptHandler = vi.mocked(makeN8nLlmFailedAttemptHandler);
const mockedGetProxyAgent = vi.mocked(getProxyAgent);
describe('LmOpenAi', () => {
let lmOpenAi: LmOpenAi;
let mockContext: Mocked<ISupplyDataFunctions>;
const mockNode: INode = {
id: '1',
name: 'OpenAI Model',
typeVersion: 1,
type: 'n8n-nodes-langchain.lmOpenAi',
position: [0, 0],
parameters: {},
};
const setupMockContext = (credentials: Record<string, unknown> = { apiKey: 'test-api-key' }) => {
mockContext = createMockExecuteFunction<ISupplyDataFunctions>(
{},
mockNode,
) as Mocked<ISupplyDataFunctions>;
mockContext.getCredentials = vi.fn().mockResolvedValue(credentials);
mockContext.getNode = vi.fn().mockReturnValue(mockNode);
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'model') return 'gpt-3.5-turbo-instruct';
if (paramName === 'options') return {};
return undefined;
});
mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(vi.fn());
mockedGetProxyAgent.mockReturnValue({} as never);
return mockContext;
};
beforeEach(() => {
lmOpenAi = new LmOpenAi();
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('supplyData', () => {
it('should construct the model with an N8nLlmTracing callback', async () => {
const mockContext = setupMockContext();
await lmOpenAi.supplyData.call(mockContext, 0);
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gpt-3.5-turbo-instruct',
callbacks: expect.arrayContaining([expect.any(Object)]),
}),
);
});
it('should pass empty redactedHeaders to N8nLlmTracing when no custom header is configured', async () => {
const mockContext = setupMockContext();
await lmOpenAi.supplyData.call(mockContext, 0);
expect(MockedN8nLlmTracing).toHaveBeenCalledWith(mockContext, { redactedHeaders: [] });
});
it('should pass the declared header name to N8nLlmTracing', async () => {
const mockContext = setupMockContext({
apiKey: 'test-api-key',
header: true,
headerName: 'x-custom-header',
headerValue: 'secret-value',
});
await lmOpenAi.supplyData.call(mockContext, 0);
expect(MockedN8nLlmTracing).toHaveBeenCalledWith(mockContext, {
redactedHeaders: ['x-custom-header'],
});
});
});
});

View File

@ -113,6 +113,15 @@ export class McpTrigger extends Node {
"Default to 'none'. n8n exposes inbound trigger URLs publicly by design. Only select an authentication method when the user explicitly asks to authenticate inbound traffic.",
},
},
{
displayName: 'Require Workflow Execute Permission',
name: 'requireExecuteAccess',
type: 'boolean',
default: true,
displayOptions: { show: { authentication: ['n8nOAuth2'] } }, // n8nOAuth2 is v2+ only
description:
'Whether the triggering user must also have permission to execute the workflow in the project it belongs to',
},
{
displayName: 'Path',
name: 'path',

View File

@ -0,0 +1,36 @@
import type { IWebhookFunctions, N8nOAuth2ValidationResult } from 'n8n-workflow';
import { createMockRequest, createMockResponse } from './helpers';
import { n8nOAuth2Auth } from '../n8n-oauth2-auth';
const buildContext = (validation: N8nOAuth2ValidationResult) => {
const response = createMockResponse();
const request = createMockRequest({ headers: { authorization: 'Bearer test-token' } });
const context = {
getNodeWebhookUrl: () => 'https://n8n.example.com/mcp/protected-path',
getResponseObject: () => response,
getRequestObject: () => request,
validateN8nOAuth2Token: vi.fn().mockResolvedValue(validation),
} as unknown as IWebhookFunctions;
return { context, response };
};
describe('n8nOAuth2Auth', () => {
// An authenticated user lacking execute access (`insufficient_scope`) is rejected with 403;
// an unverifiable token (`invalid_token`) with 401. Both carry the reason in WWW-Authenticate.
test.each([
['insufficient_scope', 403],
['invalid_token', 401],
] as const)('maps a %s validation failure to a %i response', async (reason, code) => {
const { context, response } = buildContext({ valid: false, reason });
const result = await n8nOAuth2Auth(context);
expect(result).toBe('handled');
expect(response.writeHead).toHaveBeenCalledWith(code, {
'WWW-Authenticate': expect.stringContaining(`error="${reason}"`),
});
});
});

View File

@ -15,13 +15,14 @@ function getBearerToken(headerValue: string | undefined): string | null {
function sendUnauthorizedResponse(
resp: ReturnType<IWebhookFunctions['getResponseObject']>,
code: number,
prmUrl: string,
error?: string,
) {
const authenticateHeader =
`Bearer realm="n8n MCP Server", resource_metadata="${prmUrl}"` +
(error ? `, error="${error}"` : '');
resp.writeHead(401, {
resp.writeHead(code, {
'WWW-Authenticate': authenticateHeader,
});
resp.end();
@ -54,14 +55,16 @@ export const n8nOAuth2Auth = async (
const authHeader = req.headers['authorization'];
const token = getBearerToken(authHeader);
if (!token) {
sendUnauthorizedResponse(resp, prmUrl);
sendUnauthorizedResponse(resp, 401, prmUrl);
return 'handled';
}
const validationResult = await context.validateN8nOAuth2Token(token, resourceUrl);
if (!validationResult.valid) {
if (validationResult.reason === 'invalid_token') {
sendUnauthorizedResponse(resp, prmUrl, 'invalid_token');
sendUnauthorizedResponse(resp, 401, prmUrl, 'invalid_token');
} else if (validationResult.reason === 'insufficient_scope') {
sendUnauthorizedResponse(resp, 403, prmUrl, 'insufficient_scope');
} else {
resp.status(503).send('OAuth token validation is not available');
}

View File

@ -267,6 +267,24 @@ export const getConnectedTools = async (
return finalTools;
};
/**
* Reads the custom header configured on a credential, if present and valid.
* Shared by header merging and tracing redaction so the guard lives in one place.
*/
export function getCustomCredentialHeader(
credentials: ICredentialDataDecryptedObject,
): { name: string; value: string } | undefined {
if (
credentials.header &&
typeof credentials.headerName === 'string' &&
credentials.headerName &&
typeof credentials.headerValue === 'string'
) {
return { name: credentials.headerName, value: credentials.headerValue };
}
return undefined;
}
/**
* Merges custom credential headers into an existing defaultHeaders object.
* Used by OpenAI and other LangChain nodes that pass `configuration.defaultHeaders`.
@ -275,15 +293,11 @@ export function mergeCustomHeaders(
credentials: ICredentialDataDecryptedObject,
defaultHeaders: Record<string, string>,
): Record<string, string> {
if (
credentials.header &&
typeof credentials.headerName === 'string' &&
credentials.headerName &&
typeof credentials.headerValue === 'string'
) {
const customHeader = getCustomCredentialHeader(credentials);
if (customHeader) {
return {
...defaultHeaders,
[credentials.headerName]: credentials.headerValue,
[customHeader.name]: customHeader.value,
};
}
return defaultHeaders;

View File

@ -123,6 +123,20 @@ describe('scrubSecretsInText', () => {
expect(scrubSecretsInText(ownOutput)).toBe(ownOutput);
});
it('matches a quoted value with many escapes in linear time', () => {
// A quoted secret value made of many backslashes with no closing quote.
// The value body must have a single, unambiguous parse so matching stays
// fast regardless of the input shape.
const input = '{"password": "' + '\\'.repeat(200);
const start = performance.now();
const out = scrubSecretsInText(input);
const elapsedMs = performance.now() - start;
expect(elapsedMs).toBeLessThan(50);
// No closing quote, so the JSON-shaped pattern can't match; the value is
// left as-is and matching returns promptly.
expect(out).toContain('password');
});
it('leaves typed redaction markers untouched instead of nesting them', () => {
expect(scrubSecretsInText('[REDACTED:secret:1]')).toBe('[REDACTED:secret:1]');
expect(scrubSecretsInText('[REDACTED:password:2]')).toBe('[REDACTED:password:2]');

View File

@ -45,20 +45,24 @@ export const SECRET_VALUE_PATTERNS: readonly RegExp[] = [
// Run before the loose pattern so nested objects like
// `{"credentials": {"apiKey": "..."}}` don't have the outer key consume
// the inner key on its way to a non-quoted (object) value. The value
// body uses the standard JSON-string idiom `(?:\\.|[^"\r\n])*` so an
// escaped quote inside the value (`"abc\"def"`) doesn't end the match
// early and leak the rest of the secret. The negative lookahead skips
// body uses the unrolled JSON-string idiom `(?:[^"\\\r\n]|\\.)*`: the
// negated class excludes the backslash so a backslash can only be consumed
// by the `\\.` escape branch. Keep the two alternatives disjoint (don't
// fold `\\` back into the negated class) — that keeps every run of
// backslashes to a single, unambiguous parse, so matching stays fast on any
// input. An escaped quote inside the value (`"abc\"def"`) still doesn't end
// the match early, via the escape branch. The negative lookahead skips
// values that are already a `[redacted]` / `[REDACTED]` / typed
// `[REDACTED:<type>:<index>]` placeholder so this stays idempotent when
// chained behind upstream object-walking redaction (langsmith trace
// payloads, mcp-browser markers).
new RegExp(
`"(?:${SECRET_KEYS})"\\s*:\\s*"(?!\\[(?:redacted|REDACTED)(?::[^"\\]]*)?\\]")(?:\\\\.|[^"\\r\\n])*"`,
`"(?:${SECRET_KEYS})"\\s*:\\s*"(?!\\[(?:redacted|REDACTED)(?::[^"\\]]*)?\\]")(?:[^"\\\\\\r\\n]|\\\\.)*"`,
'gi',
),
// JS-object-shaped `'key': 'value'`
new RegExp(
`'(?:${SECRET_KEYS})'\\s*:\\s*'(?!\\[(?:redacted|REDACTED)(?::[^'\\]]*)?\\]')(?:\\\\.|[^'\\r\\n])*'`,
`'(?:${SECRET_KEYS})'\\s*:\\s*'(?!\\[(?:redacted|REDACTED)(?::[^'\\]]*)?\\]')(?:[^'\\\\\\r\\n]|\\\\.)*'`,
'gi',
),
// Generic `password=...` / `api_key=...` / `secret=...` style assignments.

View File

@ -64,7 +64,7 @@ describe('DynamicNodeParametersController', () => {
expect(result).toEqual(expectedResult);
});
it('should call getOptionsViaLoadOptions when loadOptions is provided', async () => {
it('should call getOptionsViaLoadOptionsByPath when loadOptions is provided', async () => {
const loadOptions: ILoadOptions = {
routing: {
operations: {},
@ -77,12 +77,12 @@ describe('DynamicNodeParametersController', () => {
const req = { user: mockUser } as AuthenticatedRequest;
const expectedResult: INodePropertyOptions[] = [{ name: 'test', value: 'value' }];
service.getOptionsViaLoadOptions.mockResolvedValue(expectedResult);
service.getOptionsViaLoadOptionsByPath.mockResolvedValue(expectedResult);
const result = await controller.getOptions(req, mock(), payload);
expect(service.getOptionsViaLoadOptions).toHaveBeenCalledWith(
loadOptions,
expect(service.getOptionsViaLoadOptionsByPath).toHaveBeenCalledWith(
'/test/path',
baseAdditionalData,
{ name: 'TestNode', version: 1 },
{},

View File

@ -52,8 +52,8 @@ export class DynamicNodeParametersController {
}
if (loadOptions) {
return await this.dynamicNodeParametersService.getOptionsViaLoadOptions(
loadOptions,
return await this.dynamicNodeParametersService.getOptionsViaLoadOptionsByPath(
path,
additionalData,
nodeTypeAndVersion,
currentNodeParameters,

View File

@ -1,6 +1,7 @@
import type { Agent as RuntimeAgent, StreamChunk } from '@n8n/agents';
import { N8N_CHAT_INTEGRATION_TYPE, type AgentJsonConfig } from '@n8n/api-types';
import { mockLogger } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import type { JSONSchema7 } from 'json-schema';
import { OperationalError, UserError } from 'n8n-workflow';
import type { ExecuteAgentWorkflowContext, IRunExecutionData } from 'n8n-workflow';
@ -23,6 +24,7 @@ import type { ToolRegistry } from '../tool-registry';
const agentId = 'agent-1';
const projectId = 'project-1';
const userId = 'user-1';
const user = mock<User>({ id: userId });
const schema: AgentJsonConfig = {
name: 'Support Agent',
@ -196,7 +198,7 @@ describe('AgentExecutionOrchestratorService', () => {
agentId,
projectId,
message: 'hello',
userId,
user,
memory: { threadId: 'thread-1', resourceId: 'resource-1' },
}),
);
@ -205,6 +207,7 @@ describe('AgentExecutionOrchestratorService', () => {
agentId,
projectId,
integrationType: N8N_CHAT_INTEGRATION_TYPE,
user,
});
expect(integrationMessageContextService.setLatest).toHaveBeenCalledWith(
'thread-1',

View File

@ -15,6 +15,7 @@ import { ChatIntegrationService } from '../integrations/chat-integration.service
import type { AgentHistoryRepository } from '../repositories/agent-history.repository';
import type { AgentTaskSnapshotRepository } from '../repositories/agent-task-snapshot.repository';
import type { AgentRepository } from '../repositories/agent.repository';
import type { SubAgentCleanupService } from '../sub-agents/sub-agent-cleanup.service';
const agentId = 'agent-1';
const projectId = 'project-1';
@ -95,6 +96,7 @@ function makeService() {
const runtimeCacheService = mock<AgentRuntimeCacheService>();
const chatIntegrationService = mock<ChatIntegrationService>();
const taskService = mock<AgentTaskService>();
const subAgentCleanupService = mock<SubAgentCleanupService>();
const { trx, taskRepo, transaction } = makeTransaction();
Object.defineProperty(agentRepository, 'manager', {
@ -108,6 +110,7 @@ function makeService() {
chatIntegrationService.disconnect.mockResolvedValue();
chatIntegrationService.disconnectChannel.mockResolvedValue();
taskService.requestReconcile.mockResolvedValue();
subAgentCleanupService.removeSubAgentFromParents.mockResolvedValue();
Container.set(ChatIntegrationService, chatIntegrationService);
Container.set(AgentTaskService, taskService);
@ -118,6 +121,7 @@ function makeService() {
taskSnapshotRepository,
customToolsService,
runtimeCacheService,
subAgentCleanupService,
);
return {
@ -129,6 +133,7 @@ function makeService() {
runtimeCacheService,
chatIntegrationService,
taskService,
subAgentCleanupService,
trx,
taskRepo,
};
@ -234,8 +239,13 @@ describe('AgentPublishService', () => {
});
it('avoids duplicate history inserts but creates a fresh version after unpublish', async () => {
const { service, agentRepository, agentHistoryRepository, chatIntegrationService } =
makeService();
const {
service,
agentRepository,
agentHistoryRepository,
chatIntegrationService,
subAgentCleanupService,
} = makeService();
const agent = makeAgent({
versionId: 'v1',
activeVersionId: 'v1',
@ -250,6 +260,10 @@ describe('AgentPublishService', () => {
await service.unpublishAgent(agentId, projectId);
expect(agent.activeVersionId).toBeNull();
expect(agent.versionId).not.toBe('v1');
expect(subAgentCleanupService.removeSubAgentFromParents).toHaveBeenCalledWith(
agentId,
projectId,
);
expect(chatIntegrationService.disconnectChannel).toHaveBeenCalledWith(
agentId,
{

View File

@ -2,6 +2,7 @@ import type { Mock } from 'vitest';
import type { Agent as RuntimeAgent } from '@n8n/agents';
import { mockLogger } from '@n8n/backend-test-utils';
import type { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import { OperationalError } from 'n8n-workflow';
@ -89,6 +90,7 @@ describe('AgentRuntimeCacheService', () => {
agent,
expect.anything(),
undefined,
undefined,
);
});
@ -118,6 +120,44 @@ describe('AgentRuntimeCacheService', () => {
agent,
expect.anything(),
'n8n_chat',
undefined,
);
});
it('keys draft runtimes by user id so different users get separate runtimes, reused per user', async () => {
const { service, agentRepository, reconstructionService } = makeService();
const agent = makeAgent();
const userARuntime = makeRuntime();
const userBRuntime = makeRuntime();
const userA = mock<User>({ id: 'user-a' });
const userB = mock<User>({ id: 'user-b' });
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
reconstructionService.reconstructFromAgentEntity
.mockResolvedValueOnce(userARuntime)
.mockResolvedValueOnce(userBRuntime);
const forUserA = await service.getRuntime({ agentId, projectId, user: userA });
const forUserAAgain = await service.getRuntime({ agentId, projectId, user: userA });
const forUserB = await service.getRuntime({ agentId, projectId, user: userB });
expect(forUserA.agent).toBe(userARuntime.agent);
expect(forUserAAgain.agent).toBe(userARuntime.agent);
expect(forUserB.agent).toBe(userBRuntime.agent);
expect(reconstructionService.reconstructFromAgentEntity).toHaveBeenCalledTimes(2);
expect(reconstructionService.reconstructFromAgentEntity).toHaveBeenNthCalledWith(
1,
agent,
expect.anything(),
undefined,
userA,
);
expect(reconstructionService.reconstructFromAgentEntity).toHaveBeenNthCalledWith(
2,
agent,
expect.anything(),
undefined,
userB,
);
});
@ -240,6 +280,7 @@ describe('AgentRuntimeCacheService', () => {
}),
expect.anything(),
'slack',
undefined,
);
});

View File

@ -19,6 +19,7 @@ import { Container } from '@n8n/di';
import { mock } from 'vitest-mock-extended';
import type { ActiveExecutions } from '@/active-executions';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service';
import type { EphemeralNodeExecutor } from '@/node-execution';
import type { OauthService } from '@/oauth/oauth.service';
@ -26,6 +27,7 @@ import type { Publisher } from '@/scaling/pubsub/publisher.service';
import type { AiService } from '@/services/ai.service';
import type { UrlService } from '@/services/url.service';
import type { Telemetry } from '@/telemetry';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { AgentConfigService } from '../agent-config.service';
import { AgentCustomToolsService } from '../agent-custom-tools.service';
@ -60,6 +62,7 @@ import type { AgentTaskRepository } from '../repositories/agent-task.repository'
import type { AgentRepository } from '../repositories/agent.repository';
import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
import { SubAgentForegroundRunner } from '../sub-agents/sub-agent-foreground-runner';
import type { SubAgentCleanupService } from '../sub-agents/sub-agent-cleanup.service';
const agentId = 'agent-1';
const projectId = 'project-1';
@ -123,6 +126,8 @@ function makeRuntimeReconstructionService(
mock<AgentKnowledgeSandboxService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<SsrfProtectionService>(),
mock<CredentialsFinderService>(),
mock<WorkflowFinderService>(),
);
}
@ -269,6 +274,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
agentTaskSnapshotRepository,
agentCustomToolsService,
runtimeCacheService,
mock<SubAgentCleanupService>(),
);
agentTestChatService = new AgentTestChatService(n8nMemory);
agentValidationService = new AgentValidationService(agentRepository, mock<AiService>());
@ -279,6 +285,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
agentKnowledgeService,
runtimeCacheService,
agentTestChatService,
mock<SubAgentCleanupService>(),
);
service = agentExecutionOrchestratorService;
markSharedTestSetupAsUsed(

View File

@ -0,0 +1,318 @@
import type * as agents from '@n8n/agents';
import type { CredentialProvider } from '@n8n/agents';
import type { AgentJsonConfig, AgentJsonToolConfig } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type {
CustomFetch,
HttpTransport,
OutboundHttp,
SsrfProtectionService,
} from '@n8n/backend-network';
import type { AgentsConfig, SsrfProtectionConfig } from '@n8n/config';
import type { CredentialsEntity, User, WorkflowEntity, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'vitest-mock-extended';
import type { ActiveExecutions } from '@/active-executions';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import type { EphemeralNodeExecutor } from '@/node-execution';
import type { OauthService } from '@/oauth/oauth.service';
import { userHasScopes } from '@/permissions.ee/check-access';
import type { AiService } from '@/services/ai.service';
import type { UrlService } from '@/services/url.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service';
import type { AgentKnowledgeSandboxService } from '../agent-knowledge-sandbox.service';
import type { Agent } from '../entities/agent.entity';
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
import type { N8nMemory } from '../integrations/n8n-memory';
import type * as FromJsonConfig from '../json-config/from-json-config';
import type { ToolExecutor } from '../json-config/from-json-config';
import type { AgentFileRepository } from '../repositories/agent-file.repository';
import type { AgentRepository } from '../repositories/agent.repository';
import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
import { SubAgentForegroundRunner } from '../sub-agents/sub-agent-foreground-runner';
vi.mock('@/permissions.ee/check-access', () => ({
userHasScopes: vi.fn(),
}));
const projectId = 'project-1';
const userId = 'user-1';
const testUser = mock<User>({ id: userId });
const nodeToolWithCredential: Extract<AgentJsonToolConfig, { type: 'node' }> = {
type: 'node',
name: 'Send Slack message',
node: {
nodeType: 'n8n-nodes-base.slack',
nodeTypeVersion: 1,
nodeParameters: {},
credentials: { slackApi: { id: 'cred-1', name: 'Prod Slack' } },
},
};
const nodeToolWithoutCredential: Extract<AgentJsonToolConfig, { type: 'node' }> = {
type: 'node',
name: 'Get date',
node: {
nodeType: 'n8n-nodes-base.dateTime',
nodeTypeVersion: 1,
nodeParameters: {},
},
};
const workflowTool: Extract<AgentJsonToolConfig, { type: 'workflow' }> = {
type: 'workflow',
workflow: 'Lookup customer',
};
const customTool: Extract<AgentJsonToolConfig, { type: 'custom' }> = {
type: 'custom',
id: 'custom_tool',
};
// Mock buildFromJson so reconstruction doesn't try to actually build an agent;
// capture the config it receives so tests can assert on which tool refs survived filtering.
const builtAgent = mock<agents.Agent>();
builtAgent.hasCheckpointStorage.mockReturnValue(true);
const buildFromJsonMock = vi.fn().mockImplementation(async () => builtAgent);
vi.mock('../json-config/from-json-config', async () => {
const actual = await vi.importActual<typeof FromJsonConfig>('../json-config/from-json-config');
return {
...actual,
buildFromJson: (...args: unknown[]) => buildFromJsonMock(...args),
};
});
function makeAgentEntity(tools: AgentJsonToolConfig[]): Agent {
const schema: AgentJsonConfig = {
name: 'Test Agent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Be helpful',
tools,
};
return {
id: 'agent-1',
projectId,
schema,
tools: { custom_tool: { descriptor: { name: 'custom_tool' }, code: '' } },
} as unknown as Agent;
}
function makeService(overrides: {
credentialsFinderService?: ReturnType<typeof mock<CredentialsFinderService>>;
workflowFinderService?: ReturnType<typeof mock<WorkflowFinderService>>;
workflowRepository?: ReturnType<typeof mock<WorkflowRepository>>;
}) {
const secureRuntime = mock<AgentSecureRuntime>();
secureRuntime.createToolExecutor.mockReturnValue(mock<ToolExecutor>());
const transport = mock<HttpTransport>();
transport.asCustomFetch.mockReturnValue(vi.fn() as unknown as CustomFetch);
const outboundHttp = mock<OutboundHttp>();
outboundHttp.transport.mockReturnValue(transport);
const credentialsFinderService =
overrides.credentialsFinderService ?? mock<CredentialsFinderService>();
const workflowFinderService = overrides.workflowFinderService ?? mock<WorkflowFinderService>();
const workflowRepository = overrides.workflowRepository ?? mock<WorkflowRepository>();
const service = new AgentRuntimeReconstructionService(
mock<Logger>(),
mock<AgentRepository>(),
mock<AgentFileRepository>(),
mock<ActiveExecutions>(),
workflowRepository,
mock<UrlService>(),
mock<N8NCheckpointStorage>(),
secureRuntime,
mock<EphemeralNodeExecutor>(),
mock<N8nMemory>(),
mock<OauthService>(),
{ modules: [] } as unknown as AgentsConfig,
mock<AiService>(),
outboundHttp,
mock<AgentKnowledgeSandboxService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<SsrfProtectionService>(),
credentialsFinderService,
workflowFinderService,
);
return { service, credentialsFinderService, workflowFinderService, workflowRepository };
}
function toolNamesPassedToBuildFromJson(): string[] {
const [config] = buildFromJsonMock.mock.calls.at(-1) as [AgentJsonConfig];
return (config.tools ?? []).map((ref) =>
ref.type === 'custom' ? ref.id : ref.type === 'workflow' ? ref.workflow : ref.name,
);
}
describe('AgentRuntimeReconstructionService — per-user tool filtering', () => {
beforeEach(() => {
vi.clearAllMocks();
builtAgent.hasCheckpointStorage.mockReturnValue(true);
Container.set(SubAgentForegroundRunner, mock<SubAgentForegroundRunner>());
});
afterEach(() => {
Container.reset();
});
it('does not filter tools when no user is supplied (published/integration runs)', async () => {
const { service } = makeService({});
const entity = makeAgentEntity([nodeToolWithCredential, workflowTool, customTool]);
const credentialProvider = mock<CredentialProvider>();
await service.reconstructFromAgentEntity(entity, credentialProvider);
expect(userHasScopes).not.toHaveBeenCalled();
expect(toolNamesPassedToBuildFromJson()).toEqual(
expect.arrayContaining(['Send Slack message', 'Lookup customer', 'custom_tool']),
);
});
it('drops node and workflow tools for a user without workflow:execute, keeps custom tools', async () => {
vi.mocked(userHasScopes).mockResolvedValue(false);
const { service } = makeService({});
const entity = makeAgentEntity([nodeToolWithCredential, workflowTool, customTool]);
const credentialProvider = mock<CredentialProvider>();
await service.reconstructFromAgentEntity(entity, credentialProvider, undefined, testUser);
expect(userHasScopes).toHaveBeenCalledWith(testUser, ['workflow:execute'], false, {
projectId,
});
expect(toolNamesPassedToBuildFromJson()).toEqual(['custom_tool']);
});
it('drops a node tool when the user lacks credential:read on a baked credential', async () => {
vi.mocked(userHasScopes).mockResolvedValue(true);
const credentialsFinderService = mock<CredentialsFinderService>();
credentialsFinderService.findCredentialForUser.mockResolvedValue(null);
const { service } = makeService({ credentialsFinderService });
const entity = makeAgentEntity([nodeToolWithCredential, nodeToolWithoutCredential]);
const credentialProvider = mock<CredentialProvider>();
await service.reconstructFromAgentEntity(entity, credentialProvider, undefined, testUser);
expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith(
'cred-1',
testUser,
['credential:read'],
);
expect(toolNamesPassedToBuildFromJson()).toEqual(['Get date']);
});
it('drops a workflow tool the user cannot access, keeps one they can', async () => {
vi.mocked(userHasScopes).mockResolvedValue(true);
const workflowRepository = mock<WorkflowRepository>();
workflowRepository.findOne.mockResolvedValue(mock<WorkflowEntity>({ id: 'wf-1' }));
const workflowFinderService = mock<WorkflowFinderService>();
workflowFinderService.findWorkflowForUser.mockResolvedValue(null);
const { service } = makeService({ workflowRepository, workflowFinderService });
const entity = makeAgentEntity([workflowTool]);
const credentialProvider = mock<CredentialProvider>();
await service.reconstructFromAgentEntity(entity, credentialProvider, undefined, testUser);
expect(workflowFinderService.findWorkflowForUser).toHaveBeenCalledWith('wf-1', testUser, [
'workflow:execute',
]);
expect(toolNamesPassedToBuildFromJson()).toEqual([]);
});
it('keeps node and workflow tools for a fully-privileged user', async () => {
vi.mocked(userHasScopes).mockResolvedValue(true);
const credentialsFinderService = mock<CredentialsFinderService>();
credentialsFinderService.findCredentialForUser.mockResolvedValue(
mock<CredentialsEntity>({ id: 'cred-1' }),
);
const workflowRepository = mock<WorkflowRepository>();
workflowRepository.findOne.mockResolvedValue(mock<WorkflowEntity>({ id: 'wf-1' }));
const workflowFinderService = mock<WorkflowFinderService>();
workflowFinderService.findWorkflowForUser.mockResolvedValue(
mock<Awaited<ReturnType<WorkflowFinderService['findWorkflowForUser']>>>({ id: 'wf-1' }),
);
const { service } = makeService({
credentialsFinderService,
workflowFinderService,
workflowRepository,
});
const entity = makeAgentEntity([nodeToolWithCredential, workflowTool, customTool]);
const credentialProvider = mock<CredentialProvider>();
await service.reconstructFromAgentEntity(entity, credentialProvider, undefined, testUser);
expect(toolNamesPassedToBuildFromJson()).toEqual(
expect.arrayContaining(['Send Slack message', 'Lookup customer', 'custom_tool']),
);
});
});
describe('AgentRuntimeReconstructionService.reconstructFromResolvedSource — per-user tool filtering', () => {
beforeEach(() => {
vi.clearAllMocks();
builtAgent.hasCheckpointStorage.mockReturnValue(true);
Container.set(SubAgentForegroundRunner, mock<SubAgentForegroundRunner>());
});
afterEach(() => {
Container.reset();
});
function makeResolvedSourceConfig(tools: AgentJsonToolConfig[]): AgentJsonConfig {
return {
name: 'Child Agent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Be helpful',
tools,
};
}
it('filters sub-agent tools by the delegating user access when a user is present', async () => {
vi.mocked(userHasScopes).mockResolvedValue(false);
const { service } = makeService({});
const config = makeResolvedSourceConfig([nodeToolWithCredential, workflowTool, customTool]);
await service.reconstructFromResolvedSource({
config,
memoryOwnerAgentId: 'child-1',
projectId,
credentialProvider: mock<CredentialProvider>(),
toolDescriptors: {},
toolCodeByName: {},
skills: {},
runtimeProfile: 'sub-agent',
user: testUser,
});
expect(userHasScopes).toHaveBeenCalledWith(testUser, ['workflow:execute'], false, {
projectId,
});
expect(toolNamesPassedToBuildFromJson()).toEqual(['custom_tool']);
});
it('does not filter sub-agent tools when no user is present (published/integration parents)', async () => {
const { service } = makeService({});
const config = makeResolvedSourceConfig([nodeToolWithCredential, workflowTool, customTool]);
await service.reconstructFromResolvedSource({
config,
memoryOwnerAgentId: 'child-1',
projectId,
credentialProvider: mock<CredentialProvider>(),
toolDescriptors: {},
toolCodeByName: {},
skills: {},
runtimeProfile: 'sub-agent',
});
expect(userHasScopes).not.toHaveBeenCalled();
expect(toolNamesPassedToBuildFromJson()).toEqual(
expect.arrayContaining(['Send Slack message', 'Lookup customer', 'custom_tool']),
);
});
});

View File

@ -1,6 +1,7 @@
import type { Mock } from 'vitest';
import type { Logger } from '@n8n/backend-common';
import type { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { InstanceSettings, ScheduledTaskManager } from 'n8n-core';
@ -724,18 +725,20 @@ describe('AgentTaskService', () => {
});
describe('runNow', () => {
const requestingUser = mock<User>({ id: 'user-9' });
it('runs the task immediately as the requesting user even when unpublished', async () => {
(taskRepository.findByIdAndAgentId as Mock).mockResolvedValue(makeTask());
(agentRepository.findOne as Mock).mockResolvedValue(makeAgent({ activeVersionId: null }));
(agentExecutionOrchestratorService.executeForTaskNow as Mock).mockReturnValue(emptyStream());
await service.runNow(AGENT_ID, 'task-1', 'user-9');
await service.runNow(AGENT_ID, 'task-1', requestingUser);
expect(agentExecutionOrchestratorService.executeForTaskNow).toHaveBeenCalledWith(
expect.objectContaining({
agentId: AGENT_ID,
projectId: 'project-1',
userId: 'user-9',
user: requestingUser,
taskId: 'task-1',
message: expect.stringContaining('Summarize messages'),
memory: expect.objectContaining({ resourceId: 'task:task-1' }),
@ -746,7 +749,9 @@ describe('AgentTaskService', () => {
it('throws NotFoundError when the task does not exist', async () => {
(taskRepository.findByIdAndAgentId as Mock).mockResolvedValue(null);
await expect(service.runNow(AGENT_ID, 'missing', 'user-9')).rejects.toThrow(NotFoundError);
await expect(service.runNow(AGENT_ID, 'missing', requestingUser)).rejects.toThrow(
NotFoundError,
);
expect(agentExecutionOrchestratorService.executeForTaskNow).not.toHaveBeenCalled();
});
@ -755,7 +760,7 @@ describe('AgentTaskService', () => {
(agentRepository.findOne as Mock).mockResolvedValue(makeAgent());
(agentExecutionOrchestratorService.executeForTaskNow as Mock).mockReturnValue(emptyStream());
await service.runNow(AGENT_ID, 'task-1', 'user-9');
await service.runNow(AGENT_ID, 'task-1', requestingUser);
await new Promise((resolve) => setImmediate(resolve));
expect(taskRepository.update).not.toHaveBeenCalled();

View File

@ -131,7 +131,7 @@ describe('AgentTasksController tasks', () => {
const result = await controller.runTaskNow(runReq, undefined as never, 'agent-1', 'task-1');
expect(agentTaskService.runNow).toHaveBeenCalledWith('agent-1', 'task-1', 'user-1');
expect(agentTaskService.runNow).toHaveBeenCalledWith('agent-1', 'task-1', { id: 'user-1' });
expect(result).toEqual({ success: true });
});

View File

@ -26,10 +26,12 @@ import { Container } from '@n8n/di';
import { mock } from 'vitest-mock-extended';
import type { ActiveExecutions } from '@/active-executions';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import type { EphemeralNodeExecutor } from '@/node-execution';
import type { OauthService } from '@/oauth/oauth.service';
import type { AiService } from '@/services/ai.service';
import type { UrlService } from '@/services/url.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service';
import type { AgentKnowledgeSandboxService } from '../agent-knowledge-sandbox.service';
@ -119,6 +121,8 @@ function makeReconstructionService(
mock<AgentKnowledgeSandboxService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<SsrfProtectionService>(),
mock<CredentialsFinderService>(),
mock<WorkflowFinderService>(),
);
}
@ -343,6 +347,50 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
]);
});
it('references a published sub-agent by id only, with no versionId pin', async () => {
const agentRepository = mock<AgentRepository>();
agentRepository.findByIdAndProjectId.mockResolvedValue({
id: 'agent-billing',
name: 'Billing Agent',
activeVersionId: 'version-billing',
} as Agent);
const service = makeReconstructionService([], { agentRepository });
const config: AgentJsonConfig = {
name: 'Test',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Be helpful',
subAgents: { agents: [{ agentId: 'agent-billing' }] },
};
const { sourcesById } = await service.createSubAgentDelegationConfig(config, 'project-1');
expect(sourcesById).toEqual({ 'agent-billing': { agentId: 'agent-billing' } });
});
it('omits an unpublished sub-agent from sourcesById and availableSubAgents', async () => {
const agentRepository = mock<AgentRepository>();
agentRepository.findByIdAndProjectId.mockResolvedValue({
id: 'agent-billing',
name: 'Billing Agent',
activeVersionId: null,
} as Agent);
const service = makeReconstructionService([], { agentRepository });
const config: AgentJsonConfig = {
name: 'Test',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Be helpful',
subAgents: { agents: [{ agentId: 'agent-billing' }] },
};
const { sourcesById, availableSubAgents } = await service.createSubAgentDelegationConfig(
config,
'project-1',
);
expect(sourcesById).toEqual({});
expect(availableSubAgents).toEqual([]);
});
it('resolves subAgents.modelsByDifficulty into delegate tool metadata', async () => {
const credentialProvider = mock<CredentialProvider>();
credentialProvider.resolve.mockImplementation(async (credentialId: string) => {

View File

@ -13,6 +13,7 @@ import { AgentsService } from '../agents.service';
import type { Agent } from '../entities/agent.entity';
import { ChatIntegrationService } from '../integrations/chat-integration.service';
import type { AgentRepository } from '../repositories/agent.repository';
import type { SubAgentCleanupService } from '../sub-agents/sub-agent-cleanup.service';
const agentId = 'agent-1';
const projectId = 'project-1';
@ -41,11 +42,13 @@ function makeService() {
const testChatService = mock<AgentTestChatService>();
const agentTaskService = mock<AgentTaskService>();
const chatIntegrationService = mock<ChatIntegrationService>();
const subAgentCleanupService = mock<SubAgentCleanupService>();
agentRepository.save.mockImplementation(async (agent) => agent as Agent);
agentTaskService.requestReconcile.mockResolvedValue();
chatIntegrationService.disconnectChannel.mockResolvedValue();
testChatService.clearAllTestChatMessages.mockResolvedValue();
subAgentCleanupService.removeSubAgentFromParents.mockResolvedValue();
Container.set(AgentTaskService, agentTaskService);
Container.set(ChatIntegrationService, chatIntegrationService);
@ -56,6 +59,7 @@ function makeService() {
agentKnowledgeService,
runtimeCacheService,
testChatService,
subAgentCleanupService,
);
return {
@ -66,6 +70,7 @@ function makeService() {
testChatService,
agentTaskService,
chatIntegrationService,
subAgentCleanupService,
};
}
@ -110,6 +115,7 @@ describe('AgentsService', () => {
testChatService,
agentTaskService,
chatIntegrationService,
subAgentCleanupService,
} = makeService();
const agent = makeAgent({
integrations: [
@ -136,6 +142,10 @@ describe('AgentsService', () => {
credentialId: 'telegram-1',
});
expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId);
expect(subAgentCleanupService.removeSubAgentFromParents).toHaveBeenCalledWith(
agentId,
projectId,
);
expect(agentTaskService.requestReconcile).toHaveBeenCalledWith(agentId);
expect(testChatService.clearAllTestChatMessages).toHaveBeenCalledWith(agentId);
expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(projectId, agentId);

View File

@ -91,7 +91,7 @@ export class AgentChatController {
agentId,
projectId,
message,
userId: req.user.id,
user: req.user,
memory: {
threadId,
resourceId: draftChatMemoryResourceId(req.user.id),
@ -130,7 +130,7 @@ export class AgentChatController {
runId,
toolCallId,
resumeData,
userId: req.user.id,
user: req.user,
usePublishedVersion: false,
integrationType: N8N_CHAT_INTEGRATION_TYPE,
}),

View File

@ -9,6 +9,7 @@ import type {
import type { AgentPersistedMessageDto } from '@n8n/api-types';
import { AGENT_WORKFLOW_TRIGGER_TYPE, N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { JSONSchema7 } from 'json-schema';
import {
@ -49,8 +50,13 @@ export interface ExecuteForChatConfig {
agentId: string;
projectId: string;
message: string;
/** n8n user ID — used for RBAC / credential resolution. */
userId: string;
/**
* The calling n8n user — used to gate node/workflow tools by their access,
* and for RBAC / credential resolution and telemetry attribution. Always
* present: the in-app test chat only runs behind an authenticated session
* (`AgentChatController.chat` always has `req.user`).
*/
user: User;
/** Memory scope — resourceId is the chat platform user (e.g. Slack / Telegram user ID). */
memory: AgentMemoryScope;
}
@ -62,6 +68,13 @@ export interface ExecuteForChatPublishedConfig {
/** Memory scope — resourceId is the chat platform user (e.g. Slack / Telegram user ID). */
memory: AgentMemoryScope;
integrationType?: string;
// No `user` field here: a published chat integration (Slack, Telegram, …)
// run is triggered by an inbound platform event, not an interactive n8n
// session — there is no n8n `User` to attach. This path keeps the
// project-scoped trust boundary that existed before per-user tool
// gating; the admin who published the agent is the one who approved its
// tools, and Layer A's node denylist (`EphemeralNodeExecutor`) still
// applies regardless.
}
export interface ResumeForChatConfig {
@ -70,8 +83,12 @@ export interface ResumeForChatConfig {
runId: string;
toolCallId: string;
resumeData: unknown;
/** n8n user ID for in-app preview chat resumes. */
userId?: string;
/**
* The calling n8n user for in-app preview chat resumes — used to gate
* node/workflow tools by their access. Absent for published/integration
* resumes, which keep today's project-scoped behavior.
*/
user?: User;
/** Defaults to true for external integrations; preview chat passes false. */
usePublishedVersion?: boolean;
/**
@ -93,13 +110,23 @@ export interface ExecuteForTaskPublishedConfig {
taskId: string;
/** Published agent_history version that supplied the scheduled task snapshot. */
taskVersionId: string;
// No `user` field here: this run is fired by `ScheduledTaskManager` on a
// cron tick — there is no human in the loop at all, let alone an n8n
// session, so there's nothing to gate tools against. Same project-scoped
// trust boundary as `ExecuteForChatPublishedConfig`.
}
export interface ExecuteForTaskNowConfig {
agentId: string;
projectId: string;
/** n8n user ID — used for RBAC / credential resolution and recorded on the session. */
userId: string;
/**
* The calling n8n user — used to gate node/workflow tools by their
* access, and for RBAC / credential resolution and recorded on the
* session. Always present: manual "Run now" is triggered by an authenticated
* `AgentTasksController.runTaskNow` request, threaded down via
* `AgentTaskService.runNow(agentId, taskId, user)`.
*/
user: User;
message: string;
/** Memory scope — resourceId isolates per-run memory. */
memory: AgentMemoryScope;
@ -222,7 +249,7 @@ export class AgentExecutionOrchestratorService {
toolCallId,
resumeData,
integrationType,
userId,
user,
usePublishedVersion = true,
} = config;
@ -247,6 +274,14 @@ export class AgentExecutionOrchestratorService {
projectId,
usePublishedVersion,
integrationType,
// `usePublishedVersion` defaults to true and is what platform
// integrations (Slack/Telegram HITL resume) use — those have no
// interactive n8n user, so `user` is force-undefined to keep the
// existing project-scoped runtime. Only the in-app draft/test-chat
// resume passes `usePublishedVersion: false` (see
// `AgentChatController.chatResume`), and only then does the caller's
// `user` actually reach the cache/reconstruction layer.
user: usePublishedVersion ? undefined : user,
});
const { agent: agentInstance, toolRegistry } = runtime;
@ -257,7 +292,7 @@ export class AgentExecutionOrchestratorService {
const resultStream = await agentInstance.resume('stream', resumeData, {
runId,
toolCallId,
executionCounter: this.createAgentExecutionCounter({ agentId, userId }),
executionCounter: this.createAgentExecutionCounter({ agentId, userId: user?.id }),
});
for await (const value of streamAgentChunks(resultStream.stream)) {
@ -301,19 +336,22 @@ export class AgentExecutionOrchestratorService {
* Execute an agent for the in-app test chat and yield stream chunks.
*/
async *executeForChat(config: ExecuteForChatConfig): AsyncGenerator<StreamChunk> {
const { agentId, projectId, message, userId, memory } = config;
const { agentId, projectId, message, user, memory } = config;
// `user` is always set (see ExecuteForChatConfig) — this builds/reuses a
// runtime scoped to this specific user's tool access.
const runtime = await this.runtimeCacheService.getRuntime({
agentId,
projectId,
integrationType: N8N_CHAT_INTEGRATION_TYPE,
user,
});
await this.integrationMessageContextService.setLatest(memory.threadId, memory.resourceId, {
integrationConnectionId: N8N_CHAT_INTEGRATION_TYPE,
platform: N8N_CHAT_INTEGRATION_TYPE,
target: { type: 'dm', userId, threadId: memory.threadId },
interactingUserId: userId,
target: { type: 'dm', userId: user.id, threadId: memory.threadId },
interactingUserId: user.id,
updatedAt: new Date().toISOString(),
});
@ -321,7 +359,7 @@ export class AgentExecutionOrchestratorService {
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
agentId,
userId,
userId: user.id,
message,
memory,
projectId: runtime.projectId,
@ -342,6 +380,10 @@ export class AgentExecutionOrchestratorService {
): AsyncGenerator<StreamChunk> {
const { agentId, projectId, message, memory, integrationType } = config;
// No `user` (see ExecuteForChatPublishedConfig): this is the shared,
// project-scoped runtime — every caller of this published agent through
// this integration reuses the same cache entry regardless of who
// triggered the platform event.
const runtime = await this.runtimeCacheService.getRuntime({
agentId,
projectId,
@ -373,6 +415,8 @@ export class AgentExecutionOrchestratorService {
): AsyncGenerator<StreamChunk> {
const { agentId, projectId, message, memory, taskId, taskVersionId } = config;
// No `user` (see ExecuteForTaskPublishedConfig): cron-fired, no human to
// attach — same shared, project-scoped runtime for every tick.
const runtime = await this.runtimeCacheService.getRuntime({
agentId,
projectId,
@ -402,18 +446,22 @@ export class AgentExecutionOrchestratorService {
* requesting user.
*/
async *executeForTaskNow(config: ExecuteForTaskNowConfig): AsyncGenerator<StreamChunk> {
const { agentId, projectId, userId, message, memory, taskId } = config;
const { agentId, projectId, user, message, memory, taskId } = config;
// `user` is always set (see ExecuteForTaskNowConfig) — manual "Run now"
// runs get a runtime scoped to the requesting user's tool access, same
// as the in-app test chat.
const runtime = await this.runtimeCacheService.getRuntime({
agentId,
projectId,
user,
});
yield* this.streamChatResponse({
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
agentId,
userId,
userId: user.id,
message,
memory,
projectId: runtime.projectId,
@ -518,6 +566,13 @@ export class AgentExecutionOrchestratorService {
}
try {
// No `user`: this path runs an agent invoked from inside a workflow
// execution (AI Agent node, or a "Message an Agent" tool call from
// another workflow) — there is no interactive n8n user, only a bare
// telemetry id (see `executeForWorkflow`'s `telemetryUserId`), and for
// webhook/trigger-fired executions even that can be absent. This
// runtime also isn't cached (see the docstring above), so there's no
// cache-key concern here either — just no per-user tool filtering.
const { agent: reconstructed } =
await this.agentRuntimeReconstructionService.reconstructFromAgentEntity(
agentEntity,

View File

@ -21,6 +21,7 @@ import { ChatIntegrationService } from './integrations/chat-integration.service'
import { AgentHistoryRepository } from './repositories/agent-history.repository';
import { AgentTaskSnapshotRepository } from './repositories/agent-task-snapshot.repository';
import { AgentRepository } from './repositories/agent.repository';
import { SubAgentCleanupService } from './sub-agents/sub-agent-cleanup.service';
import { getMissingSkillIds } from '@/modules/agents/utils/agent-missing-skill-ids';
export interface PublishAgentOptions {
@ -36,6 +37,7 @@ export class AgentPublishService {
private readonly agentTaskSnapshotRepository: AgentTaskSnapshotRepository,
private readonly customToolsService: AgentCustomToolsService,
private readonly runtimeCacheService: AgentRuntimeCacheService,
private readonly subAgentCleanupService: SubAgentCleanupService,
) {}
async publishAgent(
@ -134,6 +136,8 @@ export class AgentPublishService {
this.runtimeCacheService.clearRuntimes(agentId);
await this.subAgentCleanupService.removeSubAgentFromParents(agentId, projectId);
const chatIntegrationService = Container.get(ChatIntegrationService);
for (const integration of agent.integrations ?? []) {
await chatIntegrationService.disconnectChannel(agentId, integration, {

View File

@ -2,6 +2,7 @@ import type { Agent as RuntimeAgent } from '@n8n/agents';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type { User } from '@n8n/db';
import { OnPubSubEvent } from '@n8n/decorators';
import { Service } from '@n8n/di';
@ -26,6 +27,13 @@ export interface GetRuntimeParams {
integrationType?: string;
/** When true, load the published snapshot. */
usePublishedVersion?: boolean;
/**
* The calling n8n user. When present, the runtime is built with node/workflow
* tools filtered down to what this user can access, and the cache key is
* scoped to the user so different users never share a runtime. Absent for
* published/integration runs, which keep today's project-scoped runtime.
*/
user?: User;
}
export interface AgentRuntime {
@ -45,7 +53,7 @@ interface RuntimeInitialization {
export class AgentRuntimeCacheService {
/**
* Cached agent runtimes. Keys follow the pattern:
* Draft: `{agentId}:draft[:{integrationType}]`
* Draft: `{agentId}:draft[:{integrationType}][:user:{userId}]`
* Published: `{agentId}:published[:{integrationType}]`
*
* TTL = 30 minutes — entries are evicted when the agent is idle so that
@ -53,6 +61,14 @@ export class AgentRuntimeCacheService {
*
* Separating draft and published with explicit prefixes prevents a draft
* runtime from being mistakenly returned to a published-agent execution.
*
* The `:user:{userId}` suffix only ever appears on draft keys — published
* runs never carry a `user` (see `GetRuntimeParams.user`), since they have
* no interactive n8n session to gate tools against. A draft runtime's tool
* list is filtered per-user at build time (see
* `AgentRuntimeReconstructionService.reconstructFromAgentEntity`), so two
* different users hitting the same draft agent must never resolve to the
* same cache entry — that would leak one user's tool access to the other.
*/
private readonly runtimes = new TtlMap<string, AgentRuntime>(30 * Time.minutes.toMilliseconds);
@ -75,6 +91,10 @@ export class AgentRuntimeCacheService {
}
const parts = [params.agentId, 'draft'];
if (params.integrationType) parts.push(params.integrationType);
// Per-user runtimes have node/workflow tools filtered by that user's
// access — keying by user id keeps them from colliding with each other
// or with the unscoped (no-user) runtime.
if (params.user) parts.push(`user:${params.user.id}`);
return parts.join(':');
}
@ -188,7 +208,7 @@ export class AgentRuntimeCacheService {
}
private async reconstructRuntime(params: GetRuntimeParams): Promise<AgentRuntime> {
const { agentId, projectId, integrationType, usePublishedVersion } = params;
const { agentId, projectId, integrationType, usePublishedVersion, user } = params;
const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agentEntity) throw new NotFoundError(`Agent ${agentId} not found`);
@ -197,12 +217,23 @@ export class AgentRuntimeCacheService {
? getPublishedAgentSnapshot(agentEntity)
: agentEntity;
const credentialProvider = createAgentCredentialProvider(this.credentialsService, projectId);
// `user` here is whatever `computeRuntimeCacheKey` above already keyed
// this build on — undefined for published/integration runs, set for
// in-app chat/resume/task-now. Forwarded to both the credential provider
// (so credential lookups are scoped to what this user can access) and
// the reconstruction service (so node/workflow tools the user can't run
// are dropped before the runtime is built).
const credentialProvider = createAgentCredentialProvider(
this.credentialsService,
projectId,
user,
);
const { agent: agentInstance, toolRegistry } =
await this.agentRuntimeReconstructionService.reconstructFromAgentEntity(
agentData,
credentialProvider,
integrationType,
user,
);
return {

View File

@ -27,6 +27,7 @@ import {
import { Logger } from '@n8n/backend-common';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { AgentsConfig, SsrfProtectionConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { WorkflowRepository } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
@ -34,14 +35,19 @@ import { nanoid } from 'nanoid';
import { ActiveExecutions } from '@/active-executions';
import { N8N_VERSION } from '@/constants';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { EphemeralNodeExecutor } from '@/node-execution';
import { OauthService } from '@/oauth/oauth.service';
import { UrlService } from '@/services/url.service';
import { userHasScopes } from '@/permissions.ee/check-access';
import { AiService } from '@/services/ai.service';
import { ProxyTokenManager } from '@/services/proxy-token-manager';
import { UrlService } from '@/services/url.service';
import { createAiMcpFetch, createAiProxyFetch } from '@/utils/ai-proxy-fetch';
import { WorkflowRunner } from '@/workflow-runner';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { isAgentKnowledgeBaseEnabled } from './agent-knowledge-gate';
import { AgentKnowledgeSandboxService } from './agent-knowledge-sandbox.service';
import { Agent } from './entities/agent.entity';
import { ChatIntegrationRegistry } from './integrations/agent-chat-integration';
import {
@ -64,8 +70,6 @@ import { resolveCredentialAwareModelConfig } from './json-config/model-config';
import { AgentFileRepository } from './repositories/agent-file.repository';
import { AgentRepository } from './repositories/agent.repository';
import { AgentSecureRuntime } from './runtime/agent-secure-runtime';
import { isAgentKnowledgeBaseEnabled } from './agent-knowledge-gate';
import { AgentKnowledgeSandboxService } from './agent-knowledge-sandbox.service';
import { createN8nDelegateSubAgentTool } from './sub-agents/delegate-sub-agent-tool';
import { SubAgentForegroundRunner } from './sub-agents/sub-agent-foreground-runner';
import { buildToolRegistry, type ToolRegistry } from './tool-registry';
@ -93,10 +97,16 @@ export interface ReconstructAgentRuntimeParams {
integrationType?: string;
/** Top-level chat/integration runtimes only. */
credentialIntegrations?: AgentIntegrationConfig[];
/**
* The interactive n8n user of the delegating parent run, when there is one.
* When present, node/workflow tool refs are filtered by this user's access
* (same rules as reconstructFromAgentEntity). Absent for published/
* integration parents, which keep the project-scoped trust boundary.
*/
user?: User;
}
async function getChatIntegrationToolServices() {
// eslint-disable-next-line import-x/no-cycle
const { IntegrationMessageContextService } = await import(
'./integrations/integration-message-context.service'
);
@ -104,7 +114,6 @@ async function getChatIntegrationToolServices() {
const { ChatIntegrationActionExecutor } = await import(
'./integrations/integration-action-executor'
);
// eslint-disable-next-line import-x/no-cycle
const { ChatIntegrationContextQueryExecutor } = await import(
'./integrations/integration-context-query-executor'
);
@ -141,18 +150,33 @@ export class AgentRuntimeReconstructionService {
private readonly agentKnowledgeSandboxService: AgentKnowledgeSandboxService,
private readonly ssrfConfig: SsrfProtectionConfig,
private readonly ssrfProtectionService: SsrfProtectionService,
private readonly credentialsFinderService: CredentialsFinderService,
private readonly workflowFinderService: WorkflowFinderService,
) {}
async reconstructFromAgentEntity(
agentEntity: Agent,
credentialProvider: CredentialProvider,
integrationType?: string,
user?: User,
): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> {
const config = agentEntity.schema;
let config = agentEntity.schema;
if (!config) {
throw new UserError('Agent has no JSON config.');
}
// Published/integration runs have no interactive n8n user and keep
// today's project-scoped trust boundary. When a user is present (in-app
// chat, resume, task-now), drop node/workflow tools the user can't
// execute or lacks credential/workflow access to before the runtime is
// built, so denied tools never reach the LLM or the executor.
if (user && config.tools?.length) {
config = {
...config,
tools: await this.filterToolsForUser(config.tools, agentEntity.projectId, user),
};
}
const toolsByName: Record<string, string> = {};
const toolDescriptors: Record<string, ToolDescriptor> = {};
for (const [_toolId, toolEntry] of Object.entries(agentEntity.tools ?? {})) {
@ -178,19 +202,101 @@ export class AgentRuntimeReconstructionService {
integrationType,
credentialIntegrations: agentEntity.integrations ?? [],
subAgentDelegation,
user,
});
}
/**
* Drop node/workflow tool refs the calling user can't run: `workflow:execute`
* gates both kinds, and node tools additionally need `credential:read` on
* every credential baked into the ref. Filtering the config copy (rather
* than the resolved tools) means a denied ref never reaches
* `makeToolResolver`/`resolveToolRef`, so no inert marker tool is exposed to
* the LLM. Custom tools are untouched — they run n8n-authored code, not a
* caller-chosen node/workflow with baked credentials.
*/
private async filterToolsForUser(
tools: AgentJsonToolConfig[],
projectId: string,
user: User,
): Promise<AgentJsonToolConfig[]> {
const canExecute = await userHasScopes(user, ['workflow:execute'], false, { projectId });
const filtered: AgentJsonToolConfig[] = [];
for (const ref of tools) {
if (ref.type === 'custom') {
filtered.push(ref);
continue;
}
if (!canExecute) continue;
if (ref.type === 'node') {
const credentialIds = Object.values(ref.node.credentials ?? {})
.map((credential) => credential.id)
.filter((id): id is string => Boolean(id));
const accessibleCredentials = await Promise.all(
credentialIds.map(
async (id) =>
await this.credentialsFinderService.findCredentialForUser(id, user, [
'credential:read',
]),
),
);
if (accessibleCredentials.some((credential) => credential === null)) continue;
filtered.push(ref);
continue;
}
// ref.type === 'workflow'
const workflow = await this.workflowRepository.findOne({
where: { name: ref.workflow, shared: { projectId } },
relations: ['shared'],
});
if (!workflow) continue;
const accessibleWorkflow = await this.workflowFinderService.findWorkflowForUser(
workflow.id,
user,
['workflow:execute'],
);
if (!accessibleWorkflow) continue;
filtered.push(ref);
}
return filtered;
}
/**
* Build a sub-agent's runtime for a `delegate_subagent` call.
*
* When `params.user` is present (the delegating parent had an interactive
* n8n user), node/workflow tool refs are filtered by that user's access —
* same rules as `reconstructFromAgentEntity`. Absent for published/
* integration parents, which keep the project-scoped trust boundary. The
* sub-agent also inherits the parent's `credentialProvider` (user-scoped
* when the parent had a user), so raw credential access stays gated there
* regardless.
*/
async reconstructFromResolvedSource(
params: ReconstructAgentRuntimeParams,
): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> {
const subAgentDelegation = await this.createSubAgentDelegationConfig(
params.config,
params.projectId,
);
let config = params.config;
if (params.user && config.tools?.length) {
config = {
...config,
tools: await this.filterToolsForUser(config.tools, params.projectId, params.user),
};
}
const subAgentDelegation = await this.createSubAgentDelegationConfig(config, params.projectId);
return await this.reconstructRuntime({
...params,
config,
credentialIntegrations: [],
subAgentDelegation,
});
@ -209,6 +315,7 @@ export class AgentRuntimeReconstructionService {
integrationType?: string;
credentialIntegrations: AgentIntegrationConfig[];
subAgentDelegation: SubAgentDelegationConfig;
user?: User;
}): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> {
const {
config,
@ -223,6 +330,7 @@ export class AgentRuntimeReconstructionService {
integrationType,
credentialIntegrations,
subAgentDelegation,
user,
} = options;
const toolExecutor = this.secureRuntime.createToolExecutor(toolCodeByName);
@ -273,6 +381,7 @@ export class AgentRuntimeReconstructionService {
parentAgentIdForDelegation: parentAgentIdForDelegation ?? memoryOwnerAgentId,
integrationType,
credentialIntegrations,
user,
});
return { agent: reconstructed, toolRegistry: buildToolRegistry(resolvedTools) };
@ -293,7 +402,12 @@ export class AgentRuntimeReconstructionService {
})) {
if (!agent?.activeVersionId) continue;
sourcesById[agentId] = { agentId, versionId: agent.activeVersionId };
// No versionId pin here: the delegate closure lives inside the
// cached parent runtime, so pinning would freeze the child at
// whatever was published when the parent was last built. Leaving
// it out means SubAgentSourceResolver re-resolves the child's
// current activeVersion on every delegation.
sourcesById[agentId] = { agentId };
availableSubAgents.push({
id: agentId,
name: agent.name,
@ -381,6 +495,7 @@ export class AgentRuntimeReconstructionService {
parentAgentIdForDelegation: string;
integrationType?: string;
credentialIntegrations: AgentIntegrationConfig[];
user?: User;
}): Promise<void> {
const {
agent,
@ -393,6 +508,7 @@ export class AgentRuntimeReconstructionService {
parentAgentIdForDelegation,
integrationType,
credentialIntegrations,
user,
} = params;
agent.tool(createGetEnvironmentTool());
@ -479,6 +595,7 @@ export class AgentRuntimeReconstructionService {
projectId,
credentialProvider,
delegation: subAgentDelegation,
user,
});
this.attachWriteTodosTool(agent, agentId);
}
@ -495,8 +612,10 @@ export class AgentRuntimeReconstructionService {
projectId: string;
credentialProvider: CredentialProvider;
delegation: SubAgentDelegationConfig;
user?: User;
}): Promise<void> {
const { agent, config, parentAgentId, projectId, credentialProvider, delegation } = params;
const { agent, config, parentAgentId, projectId, credentialProvider, delegation, user } =
params;
const inlineSubAgentModelsByDifficulty = await this.resolveInlineSubAgentModelsByDifficulty(
config,
credentialProvider,
@ -508,6 +627,7 @@ export class AgentRuntimeReconstructionService {
projectId,
parentAgentId,
credentialProvider,
user,
policy: this.buildSubAgentPolicy(config),
...(inlineSubAgentModelsByDifficulty !== undefined
? { inlineSubAgentModelsByDifficulty }

View File

@ -1,6 +1,7 @@
import type { AgentTaskDto, CreateAgentTaskDto, UpdateAgentTaskDto } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { OnLeaderStepdown, OnLeaderTakeover, OnPubSubEvent, OnShutdown } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { IsNull, Not } from '@n8n/typeorm';
@ -518,17 +519,17 @@ export class AgentTaskService {
* agent run can be long, so it is kicked off in the background and surfaces
* as a session; only the lookup is awaited so a missing task still 404s.
*/
async runNow(agentId: string, taskId: string, userId: string): Promise<void> {
async runNow(agentId: string, taskId: string, user: User): Promise<void> {
const task = await this.getOrThrow(agentId, taskId);
const agent = await this.agentRepository.findOne({ where: { id: agentId } });
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
void this.executeNow(task, agent.projectId, userId);
void this.executeNow(task, agent.projectId, user);
}
private async executeNow(task: AgentTask, projectId: string, userId: string): Promise<void> {
private async executeNow(task: AgentTask, projectId: string, user: User): Promise<void> {
const { message, threadId } = this.buildTaskRunMessage(task.id, task.objective);
this.logger.info('[AgentTaskService] Manual task run started', {
@ -543,7 +544,7 @@ export class AgentTaskService {
this.agentExecutionOrchestratorService.executeForTaskNow({
agentId: task.agentId,
projectId,
userId,
user,
message,
memory: { threadId, resourceId: taskRunMemoryResourceId(task.id) },
taskId: task.id,

View File

@ -89,7 +89,7 @@ export class AgentTasksController {
@Param('taskId') taskId: string,
): Promise<{ success: true }> {
await this.getAgentOrThrow(agentId, req.params.projectId);
await this.agentTaskService.runNow(agentId, taskId, req.user.id);
await this.agentTaskService.runNow(agentId, taskId, req.user);
return { success: true };
}
}

View File

@ -10,6 +10,7 @@ import { AgentTestChatService } from './agent-test-chat.service';
import { Agent } from './entities/agent.entity';
import { ChatIntegrationService } from './integrations/chat-integration.service';
import { AgentRepository } from './repositories/agent.repository';
import { SubAgentCleanupService } from './sub-agents/sub-agent-cleanup.service';
@Service()
export class AgentsService {
@ -20,6 +21,7 @@ export class AgentsService {
private readonly agentKnowledgeService: AgentKnowledgeService,
private readonly runtimeCacheService: AgentRuntimeCacheService,
private readonly testChatService: AgentTestChatService,
private readonly subAgentCleanupService: SubAgentCleanupService,
) {}
async create(projectId: string, name: string): Promise<Agent> {
@ -127,6 +129,8 @@ export class AgentsService {
this.runtimeCacheService.clearRuntimes(agentId);
await this.subAgentCleanupService.removeSubAgentFromParents(agentId, projectId);
try {
const { AgentTaskService } = await import('./agent-task.service');
await Container.get(AgentTaskService).requestReconcile(agentId);

View File

@ -0,0 +1,143 @@
import { mockLogger } from '@n8n/backend-test-utils';
import type { AgentJsonConfig } from '@n8n/api-types';
import { mock } from 'vitest-mock-extended';
import type { AgentRuntimeCacheService } from '../../agent-runtime-cache.service';
import type { Agent } from '../../entities/agent.entity';
import type { AgentRepository } from '../../repositories/agent.repository';
import { SubAgentCleanupService } from '../sub-agent-cleanup.service';
const projectId = 'project-1';
const childAgentId = 'child-1';
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: 'parent-1',
projectId,
schema: {
name: 'Parent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Delegate work',
} as AgentJsonConfig,
...overrides,
} as unknown as Agent;
}
function makeService() {
const agentRepository = mock<AgentRepository>();
const runtimeCacheService = mock<AgentRuntimeCacheService>();
agentRepository.save.mockImplementation(async (agent) => agent as Agent);
const service = new SubAgentCleanupService(mockLogger(), agentRepository, runtimeCacheService);
return { service, agentRepository, runtimeCacheService };
}
describe('SubAgentCleanupService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('removes the sub-agent reference from a parent that configures it, preserving other refs', async () => {
const { service, agentRepository, runtimeCacheService } = makeService();
const parent = makeAgent({
id: 'parent-1',
versionId: 'v1',
activeVersionId: 'v1',
schema: {
name: 'Parent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Delegate work',
subAgents: {
maxChildren: 5,
agents: [
{ agentId: childAgentId, useWhen: 'Use for X' },
{ agentId: 'other-child', useWhen: 'Use for Y' },
],
},
} as AgentJsonConfig,
});
agentRepository.find.mockResolvedValue([parent]);
await service.removeSubAgentFromParents(childAgentId, projectId);
expect(agentRepository.find).toHaveBeenCalledWith({ where: { projectId } });
expect(parent.schema?.subAgents?.agents).toEqual([
{ agentId: 'other-child', useWhen: 'Use for Y' },
]);
expect(parent.schema?.subAgents?.maxChildren).toBe(5);
// markAgentDraftDirty bumps versionId away from the published version.
expect(parent.versionId).not.toBe('v1');
expect(agentRepository.save).toHaveBeenCalledWith(parent);
expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith('parent-1');
});
it('leaves a parent that does not reference the sub-agent untouched', async () => {
const { service, agentRepository, runtimeCacheService } = makeService();
const parent = makeAgent({
id: 'parent-2',
schema: {
name: 'Parent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Delegate work',
subAgents: { agents: [{ agentId: 'other-child' }] },
} as AgentJsonConfig,
});
agentRepository.find.mockResolvedValue([parent]);
await service.removeSubAgentFromParents(childAgentId, projectId);
expect(agentRepository.save).not.toHaveBeenCalled();
expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled();
});
it('skips the child agent itself and agents with no subAgents config', async () => {
const { service, agentRepository, runtimeCacheService } = makeService();
const child = makeAgent({
id: childAgentId,
schema: {
name: 'Child',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'x',
subAgents: { agents: [{ agentId: childAgentId }] },
} as AgentJsonConfig,
});
const noSubAgents = makeAgent({ id: 'parent-3', schema: null });
agentRepository.find.mockResolvedValue([child, noSubAgents]);
await service.removeSubAgentFromParents(childAgentId, projectId);
expect(agentRepository.save).not.toHaveBeenCalled();
expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled();
});
it('updates every matching parent when multiple agents reference the sub-agent', async () => {
const { service, agentRepository, runtimeCacheService } = makeService();
const parentA = makeAgent({
id: 'parent-a',
schema: {
name: 'Parent A',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'x',
subAgents: { agents: [{ agentId: childAgentId }] },
} as AgentJsonConfig,
});
const parentB = makeAgent({
id: 'parent-b',
schema: {
name: 'Parent B',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'x',
subAgents: { agents: [{ agentId: childAgentId }] },
} as AgentJsonConfig,
});
agentRepository.find.mockResolvedValue([parentA, parentB]);
await service.removeSubAgentFromParents(childAgentId, projectId);
expect(agentRepository.save).toHaveBeenCalledTimes(2);
expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith('parent-a');
expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith('parent-b');
});
});

View File

@ -11,6 +11,7 @@ import type {
SubAgentSpawnRequest,
} from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
@ -158,6 +159,7 @@ describe('SubAgentForegroundRunner', () => {
skills: runtimeSource.skills,
runtimeProfile: 'sub-agent',
parentAgentIdForDelegation: undefined,
user: undefined,
});
expect(childAgent.close).toHaveBeenCalledTimes(1);
expect(childAgent.stream).toHaveBeenCalledWith(
@ -187,6 +189,20 @@ describe('SubAgentForegroundRunner', () => {
);
});
it('filters sub-agent tools by the delegating user access when the parent run has a user', async () => {
const user = mock<User>({ id: 'user-1' });
await runner.runForeground(spawnRequest, {
projectId,
credentialProvider,
user,
});
expect(reconstructionService.reconstructFromResolvedSource).toHaveBeenCalledWith(
expect.objectContaining({ user }),
);
});
it('inherits the parent resource id as the child memory scope when provided', async () => {
const result = await runner.runForeground(
{ ...spawnRequest, parentResourceId: 'draft-chat:user-1' },

View File

@ -39,19 +39,6 @@ const customToolDescriptor: ToolDescriptor = {
providerOptions: null,
};
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: agentId,
projectId,
versionId,
schema: runnableConfig,
integrations: [],
tools: {},
skills: {},
...overrides,
} as unknown as Agent;
}
function makeAgentHistory(overrides: Partial<AgentHistory> = {}): AgentHistory {
return {
agentId,
@ -63,6 +50,21 @@ function makeAgentHistory(overrides: Partial<AgentHistory> = {}): AgentHistory {
} as unknown as AgentHistory;
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: agentId,
projectId,
versionId,
schema: runnableConfig,
integrations: [],
tools: {},
skills: {},
activeVersionId: versionId,
activeVersion: makeAgentHistory(),
...overrides,
} as unknown as Agent;
}
describe('SubAgentSourceResolver', () => {
let agentRepository: Mocked<AgentRepository>;
let agentHistoryRepository: Mocked<AgentHistoryRepository>;
@ -75,17 +77,14 @@ describe('SubAgentSourceResolver', () => {
resolver = new SubAgentSourceResolver(agentRepository, agentHistoryRepository);
});
it('resolves a saved draft n8n agent in the same project', async () => {
it('resolves the current published version of a saved n8n agent when no version is pinned', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent());
await expect(resolver.resolveForRuntime({ agentId }, { projectId })).resolves.toMatchObject({
source: {
sourceId: agentId,
versionId,
config: {
...runnableConfig,
integrations: [],
},
config: runnableConfig,
},
});
});
@ -105,22 +104,32 @@ describe('SubAgentSourceResolver', () => {
});
});
it('resolves runtime assets for saved n8n agent drafts', async () => {
it('resolves runtime assets from the published version, not the draft', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({
// Draft-only assets that must NOT be used for an unpinned delegation.
tools: {
tool_1: {
code: 'return input;',
descriptor: customToolDescriptor,
draft_only_tool: {
code: 'return "draft";',
descriptor: { ...customToolDescriptor, name: 'draft_only_tool' },
},
},
skills: {
skill_1: {
name: 'Skill 1',
description: 'Helps with tests',
instructions: 'Skill body',
skills: {},
activeVersion: makeAgentHistory({
tools: {
tool_1: {
code: 'return input;',
descriptor: customToolDescriptor,
},
},
},
skills: {
skill_1: {
name: 'Skill 1',
description: 'Helps with tests',
instructions: 'Skill body',
},
},
}),
}),
);
@ -152,11 +161,23 @@ describe('SubAgentSourceResolver', () => {
);
});
it('rejects n8n agents with no config', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ schema: null }));
it('rejects a sub-agent with no published version when no version is pinned', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({ activeVersionId: null, activeVersion: null }),
);
await expect(resolver.resolveForRuntime({ agentId }, { projectId })).rejects.toThrow(
`Agent "${agentId}" has no config`,
`Sub-agent "${agentId}" is not published`,
);
});
it('rejects a sub-agent whose published version has no schema', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({ activeVersion: makeAgentHistory({ schema: null }) }),
);
await expect(resolver.resolveForRuntime({ agentId }, { projectId })).rejects.toThrow(
`Sub-agent "${agentId}" is not published`,
);
});
@ -171,7 +192,9 @@ describe('SubAgentSourceResolver', () => {
it('rejects a resolved config that is not runnable', async () => {
const { credential: _credential, ...invalidConfig } = runnableConfig;
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ schema: invalidConfig }));
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({ activeVersion: makeAgentHistory({ schema: invalidConfig }) }),
);
await expect(resolver.resolveForRuntime({ agentId }, { projectId })).rejects.toThrow(
'Invalid sub-agent config',

View File

@ -0,0 +1,51 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { AgentRuntimeCacheService } from '../agent-runtime-cache.service';
import { AgentRepository } from '../repositories/agent.repository';
import { markAgentDraftDirty } from '../utils/agent-draft.utils';
@Service()
export class SubAgentCleanupService {
constructor(
private readonly logger: Logger,
private readonly agentRepository: AgentRepository,
private readonly runtimeCacheService: AgentRuntimeCacheService,
) {}
/**
* Remove a sub-agent reference from every parent agent's draft config.
* Called when the sub-agent is deleted or unpublished, so a parent never
* keeps delegating to (or silently reactivates delegation to) an agent
* that is no longer available as a sub-agent. Sub-agents are project-
* scoped, so only the child's own project needs scanning.
*/
async removeSubAgentFromParents(childAgentId: string, projectId: string): Promise<void> {
const agents = await this.agentRepository.find({ where: { projectId } });
for (const parent of agents) {
if (parent.id === childAgentId) continue;
const { schema } = parent;
const configuredAgents = schema?.subAgents?.agents;
if (!schema || !configuredAgents?.some((ref) => ref.agentId === childAgentId)) continue;
parent.schema = {
...schema,
subAgents: {
...schema.subAgents,
agents: configuredAgents.filter((ref) => ref.agentId !== childAgentId),
},
};
markAgentDraftDirty(parent);
await this.agentRepository.save(parent);
this.runtimeCacheService.clearRuntimes(parent.id);
this.logger.debug('Removed sub-agent reference from parent agent', {
childAgentId,
parentId: parent.id,
});
}
}
}

View File

@ -10,6 +10,7 @@ import {
} from '@n8n/agents';
import type { ResolvedSubAgentSource, SubAgentSpawnRequest } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
@ -29,6 +30,12 @@ export interface SubAgentForegroundRunContext {
executionCounter?: AgentExecutionCounter;
/** Parent run's abort signal — cancelling the parent cancels this child. */
abortSignal?: AbortSignal;
/**
* Interactive n8n user of the delegating parent run; used to filter the
* sub-agent's node/workflow tools by their access. Absent when the parent
* is a published/integration run.
*/
user?: User;
}
export interface SubAgentForegroundResult {
@ -91,6 +98,7 @@ export class SubAgentForegroundRunner {
skills: runtimeSource.skills,
runtimeProfile: 'sub-agent',
parentAgentIdForDelegation: context.parentAgentId,
user: context.user,
});
// Abort the child when the parent run is cancelled.

View File

@ -13,7 +13,6 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { AgentHistory } from '../entities/agent-history.entity';
import type { Agent } from '../entities/agent.entity';
import { composeJsonConfig } from '../json-config/agent-config-composition';
import { AgentHistoryRepository } from '../repositories/agent-history.repository';
import { AgentRepository } from '../repositories/agent.repository';
@ -36,8 +35,8 @@ export class SubAgentSourceResolver {
) {}
/**
* Resolve a saved n8n agent (its current draft, or a pinned published
* version) into a runnable config plus its tool/skill assets.
* Resolve a saved n8n agent (its current published version, or a pinned
* specific version) into a runnable config plus its tool/skill assets.
*/
async resolveForRuntime(
source: SubAgentSource,
@ -77,18 +76,23 @@ export class SubAgentSourceResolver {
};
}
const config = composeJsonConfig(agent);
if (!config) {
throw new UserError(`Agent "${source.agentId}" has no config`);
// No pinned version: resolve the child's current published version at
// delegation time, not its draft. Reading `agent.activeVersion` fresh
// (rather than trusting a version baked into the parent's cached
// runtime) means a re-publish of the child takes effect on the very
// next delegation, without needing to clear the parent's cache.
const publishedVersion = agent.activeVersion;
if (!agent.activeVersionId || !publishedVersion?.schema) {
throw new UserError(`Sub-agent "${source.agentId}" is not published`);
}
return {
source: {
sourceId: source.agentId,
versionId: agent.versionId ?? undefined,
config: this.toRunnableConfig(config),
versionId: agent.activeVersionId,
config: this.toRunnableConfig(publishedVersion.schema),
},
...getAgentRuntimeAssets(agent),
...getAgentRuntimeAssets(publishedVersion),
};
}

View File

@ -3,6 +3,7 @@ import { Service } from '@n8n/di';
import { McpSettingsService } from './mcp.settings.service';
import type { ProtectedResource } from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
import type { User } from '@n8n/db';
export const INSTANCE_MCP_RESOURCE_ID = 'instance-mcp';
@ -62,4 +63,11 @@ export class McpProtectedResource implements ProtectedResource {
async getAllowedRedirectUris(): Promise<string[]> {
return await this.mcpSettingsService.getAllowedRedirectUris();
}
async authorize(_user: User): Promise<boolean> {
// The instance MCP server has no per-user authorization rule: any
// authenticated user may access it while the server is enabled, and all
// users are denied when it is disabled.
return await this.mcpSettingsService.getEnabled();
}
}

View File

@ -80,6 +80,7 @@ describe('GET /rest/consent/details', () => {
displayName: 'My Named Workflow',
getResourceUrl: () => resourceUrl,
getAudiences: () => [resourceUrl],
authorize: async () => true,
scopes: [],
});

View File

@ -14,6 +14,8 @@ import {
type ProtectedResource,
} from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
import type { User } from '@n8n/db';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
const issuer = 'https://n8n.example.com';
@ -71,7 +73,7 @@ describe('OAuthConsentService', () => {
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
const result = await service.getConsentDetails(sessionToken);
const result = await service.getConsentDetails(sessionToken, mock<User>({ id: 'user-id' }));
expect(result).toEqual({
ok: true,
@ -98,7 +100,7 @@ describe('OAuthConsentService', () => {
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(null);
const result = await service.getConsentDetails(sessionToken);
const result = await service.getConsentDetails(sessionToken, mock<User>({ id: 'user-id' }));
expect(result).toBeNull();
});
@ -110,7 +112,7 @@ describe('OAuthConsentService', () => {
throw new Error('Invalid session');
});
const result = await service.getConsentDetails(sessionToken);
const result = await service.getConsentDetails(sessionToken, mock<User>({ id: 'user-id' }));
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith('Error getting consent details', {
@ -134,7 +136,7 @@ describe('OAuthConsentService', () => {
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
const result = await service.getConsentDetails(sessionToken);
const result = await service.getConsentDetails(sessionToken, mock<User>({ id: 'user-id' }));
expect(result).toEqual({
ok: true,
@ -158,10 +160,10 @@ describe('OAuthConsentService', () => {
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
protectedResourceRegistry.getByResourceUrl.mockResolvedValue(
mock<ProtectedResource>({ displayName: 'My Workflow' }),
mock<ProtectedResource>({ displayName: 'My Workflow', authorize: async () => true }),
);
const result = await service.getConsentDetails(sessionToken);
const result = await service.getConsentDetails(sessionToken, mock<User>({ id: 'user-id' }));
expect(result).toEqual({
ok: true,
@ -189,10 +191,14 @@ describe('OAuthConsentService', () => {
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
oauthClientRepository.findOne.mockResolvedValue(client);
protectedResourceRegistry.getByResourceUrl.mockResolvedValue(
mock<ProtectedResource>({ id: 'instance-mcp', displayName: undefined }),
mock<ProtectedResource>({
id: 'instance-mcp',
displayName: undefined,
authorize: async () => true,
}),
);
const result = await service.getConsentDetails(sessionToken);
const result = await service.getConsentDetails(sessionToken, mock<User>({ id: 'user-id' }));
expect(result).toEqual({
ok: true,
@ -218,7 +224,7 @@ describe('OAuthConsentService', () => {
oauthClientRepository.findOne.mockResolvedValue(client);
protectedResourceRegistry.getByResourceUrl.mockResolvedValue(undefined);
const result = await service.getConsentDetails(sessionToken);
const result = await service.getConsentDetails(sessionToken, mock<User>({ id: 'user-id' }));
expect(result).toEqual({ ok: false, reason: 'resource_unavailable' });
});
@ -237,7 +243,11 @@ describe('OAuthConsentService', () => {
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
const result = await service.handleConsentDecision(sessionToken, userId, false);
const result = await service.handleConsentDecision(
sessionToken,
mock<User>({ id: userId }),
false,
);
expect(result.redirectUrl).toContain('error=access_denied');
expect(result.redirectUrl).toContain(
@ -267,8 +277,15 @@ describe('OAuthConsentService', () => {
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
userConsentRepository.upsert.mockResolvedValue(mock());
authorizationCodeService.createAuthorizationCode.mockResolvedValue(authCode);
protectedResourceRegistry.getByResourceUrl.mockResolvedValue(
mock<ProtectedResource>({ authorize: async () => true }),
);
const result = await service.handleConsentDecision(sessionToken, userId, true);
const result = await service.handleConsentDecision(
sessionToken,
mock<User>({ id: userId }),
true,
);
expect(result.redirectUrl).toContain('code=generated-auth-code');
expect(result.redirectUrl).toContain('state=state-xyz');
@ -295,6 +312,29 @@ describe('OAuthConsentService', () => {
});
});
it('should throw ForbiddenError when the user is not authorized for the resource', async () => {
const sessionToken = 'valid-session-token';
const sessionPayload = {
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-abc',
state: 'state-xyz',
resource: 'https://n8n.example.com/mcp-server/http',
};
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
protectedResourceRegistry.getByResourceUrl.mockResolvedValue(
mock<ProtectedResource>({ authorize: async () => false }),
);
await expect(
service.handleConsentDecision(sessionToken, mock<User>({ id: 'user-123' }), true),
).rejects.toThrow(ForbiddenError);
expect(userConsentRepository.upsert).not.toHaveBeenCalled();
expect(authorizationCodeService.createAuthorizationCode).not.toHaveBeenCalled();
});
it('should handle approval without state parameter', async () => {
const sessionToken = 'valid-session-token';
const userId = 'user-123';
@ -310,7 +350,11 @@ describe('OAuthConsentService', () => {
userConsentRepository.upsert.mockResolvedValue(mock());
authorizationCodeService.createAuthorizationCode.mockResolvedValue(authCode);
const result = await service.handleConsentDecision(sessionToken, userId, true);
const result = await service.handleConsentDecision(
sessionToken,
mock<User>({ id: userId }),
true,
);
expect(result.redirectUrl).toContain('code=generated-auth-code');
expect(result.redirectUrl).not.toContain('state=');
@ -340,9 +384,9 @@ describe('OAuthConsentService', () => {
authorizationCodeService.createAuthorizationCode.mockResolvedValue(authCode);
// First authorization
await service.handleConsentDecision(sessionToken, userId, true);
await service.handleConsentDecision(sessionToken, mock<User>({ id: userId }), true);
// Re-authorization with same userId + clientId should not throw
await service.handleConsentDecision(sessionToken, userId, true);
await service.handleConsentDecision(sessionToken, mock<User>({ id: userId }), true);
expect(userConsentRepository.upsert).toHaveBeenCalledTimes(2);
expect(userConsentRepository.upsert).toHaveBeenCalledWith(
@ -363,9 +407,9 @@ describe('OAuthConsentService', () => {
throw new Error('Invalid session');
});
await expect(service.handleConsentDecision(sessionToken, userId, true)).rejects.toThrow(
'Invalid or expired session',
);
await expect(
service.handleConsentDecision(sessionToken, mock<User>({ id: userId }), true),
).rejects.toThrow('Invalid or expired session');
});
it('should handle denial without state parameter', async () => {
@ -380,7 +424,11 @@ describe('OAuthConsentService', () => {
oauthSessionService.verifySession.mockReturnValue(sessionPayload);
const result = await service.handleConsentDecision(sessionToken, userId, false);
const result = await service.handleConsentDecision(
sessionToken,
mock<User>({ id: userId }),
false,
);
expect(result.redirectUrl).toContain('error=access_denied');
expect(result.redirectUrl).not.toContain('state=');

View File

@ -49,6 +49,7 @@ describe('OAuthServerService', () => {
scopes: SUPPORTED_SCOPES,
isDefault: true,
getAllowedRedirectUris,
authorize: async () => true,
});
service = new OAuthServerService(
@ -1049,6 +1050,7 @@ describe('OAuthServerService', () => {
getAudiences: () => [TEST_RESOURCE_URL, 'mcp-server-api'],
scopes: SUPPORTED_SCOPES,
isDefault: true,
authorize: async () => true,
});
const secondResourceUrl = 'https://n8n.example.com/webhook/wf-1/mcp';
multiRegistry.register({
@ -1056,6 +1058,7 @@ describe('OAuthServerService', () => {
getResourceUrl: () => secondResourceUrl,
getAudiences: () => [secondResourceUrl],
scopes: [],
authorize: async () => true,
});
const multiResourceService = new OAuthServerService(

View File

@ -36,6 +36,7 @@ registry.register({
getAudiences: () => [TEST_RESOURCE_URL, LEGACY_AUDIENCE],
scopes: [],
isDefault: true,
authorize: async () => true,
});
describe('OAuthTokenService', () => {
@ -426,6 +427,21 @@ describe('OAuthTokenService', () => {
expect(result).toMatchObject({ user: null });
});
it('should deny when a resource-scoped audience cannot be resolved', async () => {
// Fail closed: the token carries an audience but no resource resolves for
// it (deleted, or a transient resolver failure the registry swallows), so
// the authorize gate cannot run and the token must be rejected.
const { accessToken } = service.generateTokenPair('user-123', 'client-456');
const result = await service.verifyOAuthAccessToken(
accessToken,
'https://unregistered.example.com/mcp',
);
expect(result.user).toBeNull();
expect(result.context?.reason).toBe('insufficient_scope');
});
});
describe('revokeAccessToken', () => {
@ -502,12 +518,14 @@ describe('OAuthTokenService', () => {
getResourceUrl: () => RESOURCE_A_URL,
getAudiences: () => [RESOURCE_A_URL, LEGACY_AUDIENCE],
scopes: [],
authorize: async () => true,
isDefault: true,
});
multiResourceRegistry.register({
id: 'workflow-trigger',
getResourceUrl: () => RESOURCE_B_URL,
getAudiences: () => [RESOURCE_B_URL],
authorize: async () => true,
scopes: [],
});

View File

@ -44,6 +44,7 @@ const resource = (scopes: string[]): ProtectedResource => ({
id: 'instance-mcp',
getResourceUrl: () => 'https://n8n.test/mcp-server/http',
getAudiences: () => ['https://n8n.test/mcp-server/http'],
authorize: async () => true,
scopes,
});

View File

@ -0,0 +1,224 @@
import {
createWorkflowWithHistory,
setActiveVersion,
shareWorkflowWithUsers,
testDb,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { WebhookRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import type { INode } from 'n8n-workflow';
import { randomUUID } from 'node:crypto';
import { createOwner, createMember, createAdmin } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import { MCP_TRIGGER_NODE_TYPE } from '@/constants';
import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service';
import { CacheService } from '@/services/cache/cache.service';
import { UrlService } from '@/services/url.service';
import { JwtService } from '@/services/jwt.service';
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
import type { OAuthSessionPayload } from '../oauth-session.service';
/**
* Authorization for OAuth-protected MCP Server Triggers. A workflow whose MCP
* trigger uses `n8nOAuth2` is a protected resource; only users with
* `workflow:execute` on that workflow may mint a token for it or invoke it.
* Three actors exercise the access-control system: no access (denied), execute
* via a project/resource role, and execute via a global role.
*/
const testServer = setupTestServer({ endpointGroups: ['mcp'], modules: ['oauth-server', 'mcp'] });
let owner: User;
let memberNoAccess: User;
let memberWithProjectAccess: User;
let adminWithGlobalAccess: User;
let tokenService: OAuthTokenService;
let jwtService: JwtService;
let oauthClientRepository: OAuthClientRepository;
let mcpEndpoint: string;
const webhookBaseUrl = () => Container.get(UrlService).getWebhookBaseUrl().replace(/\/$/, '');
const productionResourceUrl = (webhookPath: string) =>
`${webhookBaseUrl()}/${mcpEndpoint}/${webhookPath}`;
const mcpTriggerNode = (requireExecuteAccess?: boolean): INode => ({
id: randomUUID(),
name: 'MCP Server Trigger',
type: MCP_TRIGGER_NODE_TYPE,
typeVersion: 2,
position: [0, 0],
parameters: {
path: 'unused',
authentication: 'n8nOAuth2',
// Omitted when undefined so the "absent param ⇒ secure default" path is exercised too.
...(requireExecuteAccess === undefined ? {} : { requireExecuteAccess }),
},
});
/**
* Create an active, published workflow whose MCP trigger is protected with
* n8n OAuth2, plus the production webhook row the resolver matches on. Returns
* the canonical resource URL the OAuth server binds tokens and consent to.
*/
const createProtectedWorkflow = async (workflowName: string, requireExecuteAccess?: boolean) => {
const node = mcpTriggerNode(requireExecuteAccess);
const webhookPath = randomUUID();
const workflow = await createWorkflowWithHistory(
{ name: workflowName, active: true, nodes: [node] },
owner,
);
await setActiveVersion(workflow.id, workflow.versionId);
await Container.get(WebhookRepository).insert({
workflowId: workflow.id,
webhookPath,
method: 'POST',
node: node.name,
});
return { workflow, resourceUrl: productionResourceUrl(webhookPath) };
};
const registerClient = async () =>
(
await oauthClientRepository.save({
id: `client-${randomUUID()}`,
name: 'Test OAuth Client',
redirectUris: ['https://example.com/callback'],
grantTypes: ['authorization_code'],
tokenEndpointAuthMethod: 'none',
})
).id;
const mintAccessToken = async (userId: string, resourceUrl: string) => {
const clientId = await registerClient();
const pair = tokenService.generateTokenPair(userId, clientId, resourceUrl);
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, userId);
return pair.accessToken;
};
const sessionTokenFor = async (resourceUrl: string) => {
const clientId = await registerClient();
const payload: OAuthSessionPayload = {
clientId,
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge-string-that-is-long-enough',
state: 'test-state',
resource: resourceUrl,
};
return jwtService.sign(payload, { expiresIn: '10m' });
};
beforeAll(async () => {
owner = await createOwner();
memberNoAccess = await createMember();
memberWithProjectAccess = await createMember();
adminWithGlobalAccess = await createAdmin();
tokenService = Container.get(OAuthTokenService);
jwtService = Container.get(JwtService);
oauthClientRepository = Container.get(OAuthClientRepository);
mcpEndpoint = Container.get(GlobalConfig).endpoints.mcp;
});
afterEach(async () => {
await Container.get(CacheService).reset();
await testDb.truncate([
'AccessToken',
'RefreshToken',
'AuthorizationCode',
'OAuthClient',
'UserConsent',
'WebhookEntity',
'SharedWorkflow',
'WorkflowEntity',
'WorkflowHistory',
]);
});
describe('runtime gate: verifyOAuthAccessToken enforces workflow:execute', () => {
test('denies a user without execute access on the workflow', async () => {
const { resourceUrl } = await createProtectedWorkflow('Owner protected workflow');
const token = await mintAccessToken(memberNoAccess.id, resourceUrl);
const result = await tokenService.verifyOAuthAccessToken(token, resourceUrl);
expect(result.user).toBeNull();
expect(result.context?.reason).toBe('insufficient_scope');
});
test('allows a user granted execute via a project role', async () => {
const { workflow, resourceUrl } = await createProtectedWorkflow('Owner protected workflow');
await shareWorkflowWithUsers(workflow, [memberWithProjectAccess]);
const token = await mintAccessToken(memberWithProjectAccess.id, resourceUrl);
const result = await tokenService.verifyOAuthAccessToken(token, resourceUrl);
expect(result.user?.id).toBe(memberWithProjectAccess.id);
});
test('allows a user granted execute via a global role', async () => {
const { resourceUrl } = await createProtectedWorkflow('Owner protected workflow');
const token = await mintAccessToken(adminWithGlobalAccess.id, resourceUrl);
const result = await tokenService.verifyOAuthAccessToken(token, resourceUrl);
expect(result.user?.id).toBe(adminWithGlobalAccess.id);
});
test('allows any authenticated user when require-execute is turned off', async () => {
// requireExecuteAccess:false is the opt-out — the same no-access member who is denied
// by default must now be allowed, proving the resolver reads the per-trigger setting.
const { resourceUrl } = await createProtectedWorkflow('Open MCP server', false);
const token = await mintAccessToken(memberNoAccess.id, resourceUrl);
const result = await tokenService.verifyOAuthAccessToken(token, resourceUrl);
expect(result.user?.id).toBe(memberNoAccess.id);
});
});
describe('consent gate: workflow name and authorization code require execute access', () => {
test('does not reveal the workflow name to a user without execute access', async () => {
const workflowName = `Owner protected workflow ${randomUUID()}`;
const { resourceUrl } = await createProtectedWorkflow(workflowName);
const sessionToken = await sessionTokenFor(resourceUrl);
const response = await testServer
.authAgentFor(memberNoAccess)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(JSON.stringify(response.body)).not.toContain(workflowName);
});
test('reveals the workflow name to the owner', async () => {
const workflowName = `Owner protected workflow ${randomUUID()}`;
const { resourceUrl } = await createProtectedWorkflow(workflowName);
const sessionToken = await sessionTokenFor(resourceUrl);
const response = await testServer
.authAgentFor(owner)
.get('/consent/details')
.set('Cookie', `n8n-oauth-session=${sessionToken}`);
expect(response.body.data.resourceName).toBe(workflowName);
});
test('does not mint an authorization code for a user without execute access', async () => {
const { resourceUrl } = await createProtectedWorkflow('Owner protected workflow');
const sessionToken = await sessionTokenFor(resourceUrl);
const response = await testServer
.authAgentFor(memberNoAccess)
.post('/consent/approve')
.set('Cookie', `n8n-oauth-session=${sessionToken}`)
.send({ approved: true })
.expect(403);
expect(String(response.body.data?.redirectUrl ?? '')).not.toContain('code=');
});
});

View File

@ -6,6 +6,7 @@ import type { Response } from 'express';
import { ApproveConsentRequestDto } from './dto/approve-consent-request.dto';
import { OAuthConsentService } from './oauth-consent.service';
import { OAuthSessionService } from './oauth-session.service';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
@RestController('/consent')
export class OAuthConsentController {
@ -21,7 +22,7 @@ export class OAuthConsentController {
const sessionToken = this.getAndValidateSessionToken(req, res);
if (!sessionToken) return;
const consentDetails = await this.consentService.getConsentDetails(sessionToken);
const consentDetails = await this.consentService.getConsentDetails(sessionToken, req.user);
if (!consentDetails) {
this.sendInvalidSessionError(res, true);
@ -30,7 +31,15 @@ export class OAuthConsentController {
if (!consentDetails.ok) {
this.oauthSessionService.clearSession(res);
this.sendErrorResponse(res, 422, 'Authorization target is no longer available');
if (consentDetails.reason === 'forbidden') {
this.sendErrorResponse(
res,
403,
'You do not have sufficient permissions to authorize this request',
);
} else {
this.sendErrorResponse(res, 422, 'Authorization target is no longer available');
}
return;
}
@ -61,7 +70,7 @@ export class OAuthConsentController {
const result = await this.consentService.handleConsentDecision(
sessionToken,
req.user.id,
req.user,
payload.approved,
);
@ -76,6 +85,14 @@ export class OAuthConsentController {
} catch (error) {
this.logger.error('Failed to process consent', { error });
this.oauthSessionService.clearSession(res);
if (error instanceof ForbiddenError) {
this.sendErrorResponse(
res,
403,
'You do not have sufficient permissions to authorize this request',
);
return;
}
const message = error instanceof Error ? error.message : 'Failed to process authorization';
this.sendErrorResponse(res, 500, message);
}

View File

@ -1,4 +1,5 @@
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
@ -7,12 +8,14 @@ import { UserConsentRepository } from './database/repositories/oauth-user-consen
import { OAuthAuthorizationCodeService } from './oauth-authorization-code.service';
import { OAuthSessionService, type OAuthSessionPayload } from './oauth-session.service';
import { OAuthHelpers } from './oauth.helpers';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
type ConsentDetailsResult =
| { ok: true; clientName: string; clientId: string; resourceName?: string; redirectUri?: string }
| { ok: false; reason: 'resource_unavailable' };
| { ok: false; reason: 'resource_unavailable' }
| { ok: false; reason: 'forbidden' };
/**
* Manages the consent flow for the shared OAuth server.
@ -34,7 +37,7 @@ export class OAuthConsentService {
* Get consent details from session cookie
* Verifies JWT session token and returns client information
*/
async getConsentDetails(sessionToken: string): Promise<ConsentDetailsResult | null> {
async getConsentDetails(sessionToken: string, user: User): Promise<ConsentDetailsResult | null> {
try {
const sessionPayload = this.oauthSessionService.verifySession(sessionToken);
@ -55,6 +58,12 @@ export class OAuthConsentService {
return { ok: false, reason: 'resource_unavailable' };
}
if (!(await resource.authorize(user)))
return {
ok: false,
reason: 'forbidden',
};
return {
ok: true,
clientName: client.name,
@ -82,7 +91,7 @@ export class OAuthConsentService {
*/
async handleConsentDecision(
sessionToken: string,
userId: string,
user: User,
approved: boolean,
): Promise<{ redirectUrl: string }> {
let sessionPayload: OAuthSessionPayload;
@ -105,15 +114,34 @@ export class OAuthConsentService {
this.logger.info('Consent denied', {
clientId: sessionPayload.clientId,
userId,
userId: user.id,
});
return { redirectUrl };
}
if (sessionPayload.resource) {
const resource = await this.protectedResourceRegistry.getByResourceUrl(
sessionPayload.resource,
);
if (!resource) {
throw new UserError('Resource is not available for the requested authorization');
}
if (!(await resource.authorize(user))) {
this.logger.warn('User is not authorized for the requested resource', {
clientId: sessionPayload.clientId,
userId: user.id,
resourceUrl: sessionPayload.resource,
});
throw new ForbiddenError('User is not authorized for the requested resource');
}
}
await this.userConsentRepository.upsert(
{
userId,
userId: user.id,
clientId: sessionPayload.clientId,
grantedAt: Date.now(),
},
@ -122,7 +150,7 @@ export class OAuthConsentService {
const code = await this.authorizationCodeService.createAuthorizationCode(
sessionPayload.clientId,
userId,
user.id,
sessionPayload.redirectUri,
sessionPayload.codeChallenge,
sessionPayload.state,
@ -138,7 +166,7 @@ export class OAuthConsentService {
this.logger.info('Consent approved', {
clientId: sessionPayload.clientId,
userId,
userId: user.id,
});
return { redirectUrl: successRedirectUrl };

View File

@ -14,6 +14,8 @@ import { AccessToken } from './database/entities/oauth-access-token.entity';
import { RefreshToken } from './database/entities/oauth-refresh-token.entity';
import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository';
import { RefreshTokenRepository } from './database/repositories/oauth-refresh-token.repository';
import type { ProtectedResource } from '@/services/protected-resource.registry';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import { AccessTokenNotFoundError, JWTVerificationError } from './oauth.errors';
import { JwtService } from '@/services/jwt.service';
@ -21,7 +23,6 @@ import type {
OAuthTokenVerifier,
UserWithContext,
} from '@/services/oauth-token-verifier-proxy.service';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
/**
* Manages the OAuth 2.1 token lifecycle for the shared OAuth server.
@ -168,10 +169,19 @@ export class OAuthTokenService implements OAuthTokenVerifier {
}
async verifyAccessToken(token: string, expectedAudience?: string): Promise<AuthInfo> {
return await this.verifyTokenWithAudiences(
token,
await this.getAllowedAudiences(expectedAudience),
);
}
private async verifyTokenWithAudiences(
token: string,
allowedAudiences: string[],
): Promise<AuthInfo> {
let decoded: unknown;
try {
const allowedAudiences = await this.getAllowedAudiences(expectedAudience);
decoded = this.verifyJwtWithAllowedAudiences(token, allowedAudiences);
} catch (error) {
throw new JWTVerificationError();
@ -203,7 +213,19 @@ export class OAuthTokenService implements OAuthTokenVerifier {
async verifyOAuthAccessToken(token: string, expectedAudience?: string): Promise<UserWithContext> {
try {
const authInfo = await this.verifyAccessToken(token, expectedAudience);
const resource = await this.getResourceByAudience(expectedAudience);
// Fail closed: a token bearing a resource-scoped audience whose resource
// can't be resolved (deleted, or a transient resolver failure the registry
// swallows to `undefined`) must NOT bypass the authorize gate below.
if (expectedAudience && !resource) {
return { user: null, context: { reason: 'insufficient_scope', auth_type: 'oauth' } };
}
const authInfo = await this.verifyTokenWithAudiences(
token,
this.audiencesForResource(resource, expectedAudience),
);
const userId =
authInfo.extra && typeof authInfo.extra === 'object'
@ -222,6 +244,14 @@ export class OAuthTokenService implements OAuthTokenVerifier {
return { user: null, context: { reason: 'user_not_found', auth_type: 'oauth' } };
}
if (resource && !(await resource.authorize(user))) {
this.logger.warn('OAuth token denied: user lacks execute access', {
userId: user.id,
expectedAudience,
});
return { user: null, context: { reason: 'insufficient_scope', auth_type: 'oauth' } };
}
return { user, authType: 'oauth' };
} catch (error) {
const errorForSure = ensureError(error);
@ -282,8 +312,22 @@ export class OAuthTokenService implements OAuthTokenVerifier {
* pre-RFC-8707 `mcp-server-api`) stay scoped to their own resource this way.
*/
private async getAllowedAudiences(expectedAudience?: string): Promise<string[]> {
const resource = expectedAudience
? await this.resourceRegistry.getByResourceUrl(expectedAudience)
: undefined;
return this.audiencesForResource(resource, expectedAudience);
}
/**
* Derive the `aud` values accepted for a (possibly pre-resolved) resource.
* Single source of the audience-derivation rule shared by `getAllowedAudiences`
* and the resolve-once path in `verifyOAuthAccessToken`.
*/
private audiencesForResource(
resource: ProtectedResource | undefined,
expectedAudience?: string,
): string[] {
if (expectedAudience) {
const resource = await this.resourceRegistry.getByResourceUrl(expectedAudience);
return resource ? resource.getAudiences() : [expectedAudience];
}
@ -293,6 +337,15 @@ export class OAuthTokenService implements OAuthTokenVerifier {
return this.resourceRegistry.getAllAudiences();
}
private async getResourceByAudience(
expectedAudience?: string,
): Promise<ProtectedResource | undefined> {
if (!expectedAudience) {
return this.resourceRegistry.getDefaultResource();
}
return await this.resourceRegistry.getByResourceUrl(expectedAudience);
}
// TODO: drop legacy audiences and the per-audience fallback once all legacy
// tokens minted before n8n v2.19 have aged out (refresh-token lifespan).
private verifyJwtWithAllowedAudiences(token: string, audiences: string[]): unknown {

View File

@ -2,6 +2,7 @@ import { MCP_TRIGGER_NODE_TYPE } from '@/constants';
import type { ProtectedResourceResolver } from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
@ -12,6 +13,7 @@ import {
trimSlashes,
trimTrailingSlash,
} from './utils';
import { User } from '@n8n/db';
@Service()
export class WorkflowMcpTestTriggerResourceResolver implements ProtectedResourceResolver {
@ -20,6 +22,7 @@ export class WorkflowMcpTestTriggerResourceResolver implements ProtectedResource
private readonly registrations: TestWebhookRegistrationsService,
private readonly urlService: UrlService,
private readonly logger: Logger,
private readonly workflowFinderService: WorkflowFinderService,
) {}
readonly id = 'workflow-mcp-test-trigger';
@ -73,13 +76,26 @@ export class WorkflowMcpTestTriggerResourceResolver implements ProtectedResource
!node.disabled &&
node.parameters.authentication === 'n8nOAuth2'
) {
const resourceUrl = `${trimTrailingSlash(this.urlService.getTestWebhookBaseUrl())}/${this.config.endpoints.mcpTest}/${path}`;
const resourceUrl = `${trimTrailingSlash(this.urlService.getWebhookBaseUrl())}/${this.config.endpoints.mcpTest}/${path}`;
const requireExecute = node.parameters.requireExecuteAccess !== false;
return {
id: 'workflow-mcp-test:' + workflowEntity.id,
getResourceUrl: () => resourceUrl,
getAudiences: () => [resourceUrl],
scopes: WORKFLOW_MCP_TRIGGER_SCOPES,
displayName: workflowEntity.name,
authorize: async (user: User) => {
if (requireExecute) {
return (
await this.workflowFinderService.findWorkflowIdsWithScopeForUser(
[workflowEntity.id],
user,
['workflow:execute'],
)
).has(workflowEntity.id);
}
return true;
},
};
}

View File

@ -2,9 +2,10 @@ import { MCP_TRIGGER_NODE_TYPE } from '@/constants';
import type { ProtectedResourceResolver } from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
import { WebhookService } from '@/webhooks/webhook.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { WorkflowRepository } from '@n8n/db';
import { User, WorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import {
@ -22,6 +23,7 @@ export class WorkflowMcpTriggerResourceResolver implements ProtectedResourceReso
private readonly workflowRepository: WorkflowRepository,
private readonly urlService: UrlService,
private readonly logger: Logger,
private readonly workflowFinderService: WorkflowFinderService,
) {}
readonly id = 'workflow-mcp-trigger';
@ -94,12 +96,25 @@ export class WorkflowMcpTriggerResourceResolver implements ProtectedResourceReso
node.parameters.authentication === 'n8nOAuth2'
) {
const resourceUrl = `${trimTrailingSlash(this.urlService.getWebhookBaseUrl())}/${this.config.endpoints.mcp}/${path}`;
const requireExecute = node.parameters.requireExecuteAccess !== false;
return {
id: 'workflow-mcp:' + workflow.id,
getResourceUrl: () => resourceUrl,
getAudiences: () => [resourceUrl],
scopes: WORKFLOW_MCP_TRIGGER_SCOPES,
displayName: workflow.name,
authorize: async (user: User) => {
if (requireExecute) {
return (
await this.workflowFinderService.findWorkflowIdsWithScopeForUser(
[workflow.id],
user,
['workflow:execute'],
)
).has(workflow.id);
}
return true;
},
};
}

View File

@ -260,6 +260,19 @@ describe('ProvisioningService', () => {
});
it('should provision the instance role for the user', async () => {
const user = mock<User>({ role: { slug: 'global:member' } });
const roleSlug = 'global:admin';
roleRepository.findOneOrFail.mockResolvedValue(
mock<Role>({ slug: 'global:admin', roleType: 'global' }),
);
provisioningService['isInstanceRoleProvisioningEnabled'] = vi.fn().mockResolvedValue(true);
await provisioningService.provisionInstanceRoleForUser(user, roleSlug);
expect(userService.changeUserRole).toHaveBeenCalledWith(user, { newRoleName: roleSlug });
});
it('should not promote a non-owner user to global:owner', async () => {
const user = mock<User>({ role: { slug: 'global:member' } });
const roleSlug = 'global:owner';
roleRepository.findOneOrFail.mockResolvedValue(
@ -269,7 +282,12 @@ describe('ProvisioningService', () => {
await provisioningService.provisionInstanceRoleForUser(user, roleSlug);
expect(userService.changeUserRole).toHaveBeenCalledWith(user, { newRoleName: roleSlug });
expect(userService.changeUserRole).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('global:owner'), {
userId: user.id,
roleSlug: 'global:owner',
});
});
it('should do nothing if the role has not changed', async () => {
@ -306,10 +324,10 @@ describe('ProvisioningService', () => {
it('sends telemetry event', async () => {
const user = mock<User>({ id: 'user-123', role: { slug: 'global:member' } });
const roleSlug = 'global:owner';
const roleSlug = 'global:admin';
roleRepository.findOneOrFail.mockResolvedValue(
mock<Role>({ slug: 'global:owner', roleType: 'global' }),
mock<Role>({ slug: 'global:admin', roleType: 'global' }),
);
provisioningService['isInstanceRoleProvisioningEnabled'] = vi.fn().mockResolvedValue(true);
@ -974,6 +992,31 @@ describe('ProvisioningService', () => {
});
});
it('should not promote a non-owner user to global:owner via expression mapping', async () => {
roleResolverService.resolveRoles.mockResolvedValue({
instanceRole: {
role: 'global:owner',
matchedRuleId: 'rule-1',
expression: '{{ true }}',
isFallback: false,
},
projectRoles: new Map(),
});
roleRepository.findOneOrFail.mockResolvedValue(
mock<Role>({ slug: 'global:owner', roleType: 'global' }),
);
const context = { $claims: { role: 'owner' }, $provider: 'oidc' as const };
await provisioningService.provisionExpressionMappedRolesForUser(user, context);
expect(userService.changeUserRole).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('global:owner'),
expect.objectContaining({ userId: user.id }),
);
});
it('should not emit when expression mapping is disabled', async () => {
provisioningService['isExpressionMappingEnabled'] = vi.fn().mockResolvedValue(false);

View File

@ -14,6 +14,7 @@ import {
} from '@n8n/db';
import { OnPubSubEvent } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { GLOBAL_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { Not, In } from '@n8n/typeorm';
import { InstanceSettings } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
@ -68,7 +69,7 @@ export class ProvisioningService {
return;
}
const globalOwnerRoleSlug = 'global:owner';
const globalOwnerRoleSlug = GLOBAL_OWNER_ROLE_SLUG;
if (typeof roleSlug !== 'string') {
this.logger.warn(
@ -101,6 +102,14 @@ export class ProvisioningService {
return;
}
if (dbRole.slug === globalOwnerRoleSlug && user.role.slug !== globalOwnerRoleSlug) {
this.logger.warn(
`Skipping instance role provisioning. Cannot assign owner role: ${globalOwnerRoleSlug} to user: ${user.id}`,
{ userId: user.id, roleSlug },
);
return;
}
/*
* If the user is changing from an owner to a non-owner role,
* we need to check if they are the last owner to avoid an instance losing its only owner
@ -552,10 +561,17 @@ export class ProvisioningService {
return;
}
const globalOwnerRoleSlug = 'global:owner';
if (user.role.slug === globalOwnerRoleSlug && dbRole.slug !== globalOwnerRoleSlug) {
if (dbRole.slug === GLOBAL_OWNER_ROLE_SLUG && user.role.slug !== GLOBAL_OWNER_ROLE_SLUG) {
this.logger.warn(
`Skipping instance role provisioning. Cannot assign owner role: ${GLOBAL_OWNER_ROLE_SLUG} to user: ${user.id}`,
{ userId: user.id, roleSlug: GLOBAL_OWNER_ROLE_SLUG },
);
return;
}
if (user.role.slug === GLOBAL_OWNER_ROLE_SLUG && dbRole.slug !== GLOBAL_OWNER_ROLE_SLUG) {
const otherOwners = await this.userRepository.count({
where: { role: { slug: globalOwnerRoleSlug }, id: Not(user.id) },
where: { role: { slug: GLOBAL_OWNER_ROLE_SLUG }, id: Not(user.id) },
});
if (otherOwners === 0) {
this.logger.warn(

View File

@ -1,5 +1,5 @@
import type { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
import { ALL_API_KEY_SCOPES, type Scope as ScopeType } from '@n8n/permissions';
import { getApiKeyScopesForRole, type Scope as ScopeType } from '@n8n/permissions';
import type { InstanceSettings } from 'n8n-core';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
@ -16,14 +16,20 @@ const otherJwtService = new JwtService(
mock(),
);
/** Create a mock user with role.scopes pre-populated for scope assertions. */
function makeUser(id: string, scopeSlugs: string[] = [], disabled = false): User {
/** Create a mock user with role.slug and role.scopes pre-populated for scope assertions. */
function makeUser(
id: string,
scopeSlugs: string[] = [],
disabled = false,
roleSlug: User['role']['slug'] = 'global:member',
): User {
return {
...mock<User>(),
id,
disabled,
role: {
...mock<User['role']>(),
slug: roleSlug,
scopes: scopeSlugs.map((slug) => ({ ...mock(), slug: slug as ScopeType })),
},
};
@ -113,7 +119,21 @@ describe('ScopedJwtStrategy', () => {
expect(grant.subject).toBe(subject);
expect(grant.actor).toBeUndefined();
expect(grant.scopes).toEqual(['workflow:read', 'workflow:create']);
expect(grant.apiKeyScopes).toEqual(Array.from(ALL_API_KEY_SCOPES));
// apiKeyScopes are derived from the acting role, never the full set.
expect(grant.apiKeyScopes).toEqual(getApiKeyScopesForRole(subject));
for (const elevated of ['user:create', 'user:delete', 'user:changeRole'] as const) {
expect(grant.apiKeyScopes).not.toContain(elevated);
}
});
it('limits apiKeyScopes to those permitted by the acting role', async () => {
const subject = makeUser('subject-id', ['workflow:read'], false, 'global:chatUser');
userRepository.findOne.mockResolvedValue(subject);
const grant = await strategy.buildTokenGrant(makeTokenExchangeJwt());
if (!grant) throw new Error('expected grant');
expect(grant.apiKeyScopes).toEqual([]);
});
it('builds a grant with actor scopes when the act claim resolves', async () => {
@ -129,6 +149,7 @@ describe('ScopedJwtStrategy', () => {
expect(grant.actor).toBe(actor);
// Scopes come from the acting principal (actor) when one is resolved.
expect(grant.scopes).toEqual(['credential:read', 'credential:create']);
expect(grant.apiKeyScopes).toEqual(getApiKeyScopesForRole(actor));
});
it('falls back to subject scopes when the act claim is present but actor is not found', async () => {

View File

@ -1,7 +1,7 @@
import type { AuthenticatedRequest, TokenGrant, User } from '@n8n/db';
import { UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { ALL_API_KEY_SCOPES } from '@n8n/permissions';
import { getApiKeyScopesForRole } from '@n8n/permissions';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import type { AuthStrategy, AuthStrategyOptions } from '@/services/auth-strategy.types';
@ -65,7 +65,7 @@ export class ScopedJwtStrategy implements AuthStrategy {
// 6. Scopes come from the acting user's role (role.scopes is eager: true)
return {
scopes: actingUser.role.scopes.map((s) => s.slug),
apiKeyScopes: Array.from(ALL_API_KEY_SCOPES),
apiKeyScopes: getApiKeyScopesForRole(actingUser),
subject,
...(actor && { actor }),
};

View File

@ -20,6 +20,7 @@ import { NodeTypes } from '@/node-types';
import {
AGENT_PROVIDER_NODE_WHITELIST,
AGENT_TOOL_NODE_DENYLIST,
EphemeralNodeExecutor,
isAgentProviderNode,
isUsableAsAgentTool,
@ -184,6 +185,64 @@ describe('EphemeralNodeExecutor', () => {
expect(result.error).not.toMatch(/not usable as a tool/);
});
it.each([...AGENT_TOOL_NODE_DENYLIST])(
'rejects the denylisted node type "%s" even when it looks like a usable tool',
async (nodeType) => {
nodeTypes.getByNameAndVersion.mockReturnValue(
mockNodeType({ description: toolDescription }),
);
const result = await executor.executeInline({
nodeType,
nodeTypeVersion: 1,
nodeParameters: {},
inputData: [],
projectId: 'p-1',
});
expect(result.status).toBe('error');
expect(result.error).toContain('not permitted for agent tool execution');
// The denylist check must run before the node-types lookup for the
// base type, otherwise resolveToolNodeType's `*Tool` remapping in
// the caller could dodge it via a type that doesn't exist yet.
expect(nodeTypes.getByNameAndVersion).not.toHaveBeenCalled();
},
);
it.each([...AGENT_TOOL_NODE_DENYLIST])(
'rejects the `*Tool`-suffixed variant of the denylisted node type "%s"',
async (nodeType) => {
const result = await executor.executeInline({
nodeType: `${nodeType}Tool`,
nodeTypeVersion: 1,
nodeParameters: {},
inputData: [],
projectId: 'p-1',
});
expect(result.status).toBe('error');
expect(result.error).toContain('not permitted for agent tool execution');
},
);
it('still executes a normal tool node that is not on the denylist', async () => {
const execute = vi.fn().mockResolvedValue([[{ json: { ok: true } }]]);
nodeTypes.getByNameAndVersion.mockReturnValue({
description: toolDescription,
execute,
} as unknown as INodeType);
const result = await executor.executeInline({
nodeType: 'n8n-nodes-base.slack',
nodeTypeVersion: 1,
nodeParameters: {},
inputData: [],
projectId: 'p-1',
});
expect(result.status).toBe('success');
});
it('returns a structured error when the operation is on the blacklist (sendAndWait)', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(mockNodeType({ description: toolDescription }));

View File

@ -60,6 +60,30 @@ export interface NodeExecutionResult {
// send and wait requires persistent workflows to handle the wait logic
const OPERATION_BLACKLIST = [SEND_AND_WAIT_OPERATION, 'dispatchAndWait'];
/**
* Node types that must never run as an agent tool, regardless of RBAC —
* they grant command execution or arbitrary file-system access, which is a
* different trust tier than "call an API with a shared credential". This is
* a defense-in-depth backstop applied to every execution path (chat,
* published integrations, tasks, workflows), independent of the per-user
* access checks applied when building the tool list.
*/
export const AGENT_TOOL_NODE_DENYLIST = new Set<string>([
'n8n-nodes-base.executeCommand',
'n8n-nodes-base.ssh',
'n8n-nodes-base.readWriteFile',
]);
/**
* The node-types resolver may hand us the `*Tool` variant of a node
* (e.g. `executeCommand` -> `executeCommandTool`, see `resolveToolNodeType`
* in `node-tool-factory.ts`). Strip that suffix before checking the denylist
* so both the base and tool-wrapped forms are caught.
*/
function stripAgentToolSuffix(nodeType: string): string {
return nodeType.endsWith('Tool') ? nodeType.slice(0, -'Tool'.length) : nodeType;
}
/**
* Vendor-API nodes the agent runtime can execute even though they aren't
* marked `usableAsTool`. They expose the full provider API (image generation,
@ -212,6 +236,12 @@ export class EphemeralNodeExecutor {
typeVersion: number,
nodeParameters: INodeParameters,
): void {
if (AGENT_TOOL_NODE_DENYLIST.has(stripAgentToolSuffix(nodeType))) {
throw new UserError('Node type is not permitted for agent tool execution', {
extra: { nodeType },
});
}
const resolved = this.nodeTypes.getByNameAndVersion(nodeType, typeVersion);
if (!isUsableAsAgentTool(resolved.description) && !isAgentProviderNode(nodeType)) {

View File

@ -7,4 +7,5 @@ export {
EphemeralNodeExecutor,
isAgentProviderNode,
AGENT_PROVIDER_NODE_WHITELIST,
AGENT_TOOL_NODE_DENYLIST,
} from './ephemeral-node-executor';

View File

@ -2,26 +2,33 @@ import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { SharedWorkflowRepository } from '@n8n/db';
import type { User } from '@n8n/db';
import { RoutingNode } from 'n8n-core';
import {
type ILoadOptions,
type INodeParameters,
type INodeType,
type IWorkflowExecuteAdditionalData,
type ResourceMapperFields,
Expression,
} from 'n8n-workflow';
import type { MockInstance } from 'vitest';
import type { Mock, MockInstance } from 'vitest';
import { mock } from 'vitest-mock-extended';
vi.mock('n8n-core', async () => {
return {
...(await vi.importActual('n8n-core')),
RoutingNode: vi.fn(),
};
});
import { DynamicNodeParametersService } from '../dynamic-node-parameters.service';
import { WorkflowLoaderService } from '../workflow-loader.service';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NodeTypes } from '@/node-types';
import * as checkAccess from '@/permissions.ee/check-access';
import { DynamicNodeParametersService } from '../dynamic-node-parameters.service';
import { WorkflowLoaderService } from '../workflow-loader.service';
describe('DynamicNodeParametersService', () => {
const logger = mockInstance(Logger);
const nodeTypes = mockInstance(NodeTypes);
@ -222,7 +229,7 @@ describe('DynamicNodeParametersService', () => {
await expect(
service.getOptionsViaLoadOptions(
mock<ILoadOptions>(),
{ routing: { request: { url: '/v1/models' } } },
mock<IWorkflowExecuteAdditionalData>(),
{ name: 'TestNode', version: 1 },
mock<INodeParameters>(),
@ -231,6 +238,75 @@ describe('DynamicNodeParametersService', () => {
});
});
describe('getOptionsViaLoadOptionsByPath', () => {
it('should throw BadRequestError when no loadOptions routing exists at the parameter path', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(
mock<INodeType>({
description: {
name: 'TestNode',
properties: [],
requestDefaults: { baseURL: 'https://api.example.com' },
},
}),
);
await expect(
service.getOptionsViaLoadOptionsByPath(
'parameters.unknown',
mock<IWorkflowExecuteAdditionalData>(),
{ name: 'TestNode', version: 1 },
mock<INodeParameters>(),
),
).rejects.toThrow(BadRequestError);
});
it('should resolve routing from the node definition and run it', async () => {
const runNode = vi.fn().mockResolvedValue([[{ json: { name: 'opt', value: 'v' } }]]);
(RoutingNode as unknown as Mock).mockImplementation(function () {
return { runNode };
});
vi.spyOn(Expression.prototype, 'acquireIsolate').mockResolvedValue(true);
vi.spyOn(Expression.prototype, 'releaseIsolate').mockResolvedValue(undefined);
const nodeRouting = { request: { url: '/v1/models', method: 'GET' as const } };
// Plain object (not a deep mock) so Workflow's parameter resolution doesn't
// trip over auto-generated mock fields on the property.
nodeTypes.getByNameAndVersion.mockReturnValue({
description: {
name: 'TestNode',
displayName: 'TestNode',
group: [],
version: 1,
defaults: {},
inputs: [],
outputs: [],
properties: [
{
displayName: 'Model',
name: 'model',
type: 'options',
default: '',
options: [],
typeOptions: { loadOptions: { routing: nodeRouting } },
},
],
requestDefaults: { baseURL: 'https://api.example.com' },
},
} as unknown as INodeType);
const result = await service.getOptionsViaLoadOptionsByPath(
'parameters.model',
mock<IWorkflowExecuteAdditionalData>(),
{ name: 'TestNode', version: 1 },
{} as INodeParameters,
);
expect(RoutingNode).toHaveBeenCalled();
expect(runNode).toHaveBeenCalled();
expect(result).toEqual([{ name: 'opt', value: 'v' }]);
});
});
describe('getMethod', () => {
it('should throw BadRequestError when the requested method does not exist', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue({

View File

@ -9,6 +9,7 @@ const resourceA: ProtectedResource = {
getResourceUrl: () => 'https://n8n.example.com/mcp-server/http',
getAudiences: () => ['https://n8n.example.com/mcp-server/http', 'mcp-server-api'],
scopes: ['tool:listWorkflows', 'tool:getWorkflowDetails'],
authorize: async () => true,
isDefault: true,
};
@ -16,6 +17,7 @@ const resourceB: ProtectedResource = {
id: 'workflow-trigger',
getResourceUrl: () => 'https://n8n.example.com/webhook/wf-1/mcp',
getAudiences: () => ['https://n8n.example.com/webhook/wf-1/mcp'],
authorize: async () => true,
scopes: ['tool:listWorkflows', 'workflow:execute'],
};

View File

@ -22,7 +22,12 @@ import type {
ILocalLoadOptionsFunctions,
IExecuteData,
} from 'n8n-workflow';
import { Workflow, UnexpectedError, createEmptyRunExecutionData } from 'n8n-workflow';
import {
Workflow,
UnexpectedError,
createEmptyRunExecutionData,
findDisplayedProperty,
} from 'n8n-workflow';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@ -143,6 +148,43 @@ export class DynamicNodeParametersService {
});
}
/**
* Resolves the property's loadOptions routing from the node definition via the parameter path
* (not from the request body), then runs it.
*/
async getOptionsViaLoadOptionsByPath(
path: string,
additionalData: IWorkflowExecuteAdditionalData,
nodeTypeAndVersion: INodeTypeNameVersion,
currentNodeParameters: INodeParameters,
credentials?: INodeCredentials,
): Promise<INodePropertyOptions[]> {
const nodeType = this.getNodeType(nodeTypeAndVersion);
const property = findDisplayedProperty(
path,
nodeType.description.properties,
currentNodeParameters,
{ typeVersion: nodeTypeAndVersion.version },
nodeType.description,
);
const routing =
property && 'typeOptions' in property
? property.typeOptions?.loadOptions?.routing
: undefined;
if (!routing) {
throw new BadRequestError(
`Node type "${nodeType.description.name}" has no loadOptions routing for parameter path "${path}"`,
);
}
return await this.getOptionsViaLoadOptions(
{ routing },
additionalData,
nodeTypeAndVersion,
currentNodeParameters,
credentials,
);
}
/** Returns the available options via a loadOptions param */
async getOptionsViaLoadOptions(
loadOptions: ILoadOptions,
@ -153,12 +195,6 @@ export class DynamicNodeParametersService {
): Promise<INodePropertyOptions[]> {
const nodeType = this.getNodeType(nodeTypeAndVersion);
if (!nodeType.description.requestDefaults?.baseURL) {
// This is in here for now for security reasons.
// Background: As the full data for the request to make does get send, and the auth data
// will then be applied, would it be possible to retrieve that data like that. By at least
// requiring a baseURL to be defined can at least not a random server be called.
// In the future this code has to get improved that it does not use the request information from
// the request rather resolves it via the parameter-path and nodeType data.
throw new BadRequestError(
`Node type "${nodeType.description.name}" does not exist or does not have "requestDefaults.baseURL" defined!`,
);

View File

@ -10,7 +10,8 @@ export type AuthFailureReason =
| 'user_not_found'
| 'user_id_not_in_auth_info'
| 'unknown_error'
| 'verifier_not_registered';
| 'verifier_not_registered'
| 'insufficient_scope';
export type Mcpauth_type = 'oauth' | 'api_key' | 'unknown';

View File

@ -1,4 +1,5 @@
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { ensureError } from '@n8n/utils/errors/ensure-error';
@ -48,6 +49,15 @@ export interface ProtectedResource {
* registered-URIs match per RFC 6749 §3.1.2.4.
*/
getAllowedRedirectUris?(): Promise<string[]>;
/**
* Determine whether the given user is authorized to access this resource.
* Called during the consent flow to gate access to the resource.
*
* @param user The user to authorize
* @returns A promise that resolves to a boolean indicating whether the user is authorized
**/
authorize(user: User): Promise<boolean>;
}
/**

View File

@ -31,6 +31,7 @@ import type {
IWorkflowDataProxyAdditionalKeys,
IWorkflowExecuteAdditionalData,
WebhookResponseMode,
OAuth2FailureReason,
Workflow,
WorkflowExecuteMode,
IWorkflowExecutionDataProcess,
@ -522,6 +523,18 @@ export async function executeWebhook(
};
};
const translateAuthFailureReason = (reason?: AuthFailureReason): OAuth2FailureReason => {
switch (reason) {
case 'verifier_not_registered':
case 'unknown_error':
return 'verifier_unavailable';
case 'insufficient_scope':
return 'insufficient_scope';
default:
return 'invalid_token';
}
};
additionalData.validateN8nOAuth2Token = async (token: string, resourceUrl: string) => {
const oauthTokenVerifierProxy = Container.get(OAuthTokenVerifierProxy);
const result = await oauthTokenVerifierProxy.verifyOAuthAccessToken(token, resourceUrl);
@ -536,16 +549,10 @@ export async function executeWebhook(
},
};
}
const VERIFIER_UNAVAILABLE_REASONS: AuthFailureReason[] = [
'verifier_not_registered',
'unknown_error',
];
return {
valid: false,
reason:
result.context?.reason && VERIFIER_UNAVAILABLE_REASONS.includes(result.context.reason)
? 'verifier_unavailable'
: 'invalid_token',
reason: translateAuthFailureReason(result.context?.reason),
};
};

View File

@ -64,6 +64,7 @@ describe('DynamicNodeParametersController', () => {
ownerAgent = testServer.authAgentFor(owner);
const expectedResult = [{ name: 'Test Option', value: 'test' }];
vi.spyOn(service, 'getOptionsViaLoadOptionsByPath').mockResolvedValue(expectedResult);
vi.spyOn(service, 'getOptionsViaLoadOptions').mockResolvedValue(expectedResult);
const response = await ownerAgent

Some files were not shown because too many files have changed in this diff Show More