fix(core): Remove LocalFilesystemProvider, require computer use for filesystem access (no-changelog) (#28297)

This commit is contained in:
Albert Alises 2026-04-10 12:14:20 +02:00 committed by GitHub
parent d7d18a04c8
commit 7614712a15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 54 additions and 1527 deletions

View file

@ -278,9 +278,7 @@ export type FrontendModuleSettings = {
*/
'instance-ai'?: {
enabled: boolean;
localGateway: boolean;
localGatewayDisabled: boolean;
localGatewayFallbackDirectory: string | null;
proxyEnabled: boolean;
};

View file

@ -416,9 +416,7 @@ per-user.
```typescript
{
enabled: boolean; // Model is configured and usable
localGateway: boolean; // Local filesystem path is configured
localGatewayDisabled: boolean; // Admin/user opt-out flag
localGatewayFallbackDirectory: string | null; // Configured fallback path
}
```

View file

@ -90,10 +90,6 @@ export class InstanceAiConfig {
@Env('N8N_INSTANCE_AI_SEARXNG_URL')
searxngUrl: string = '';
/** Base directory for server-side filesystem access. Empty = filesystem access disabled. */
@Env('N8N_INSTANCE_AI_FILESYSTEM_PATH')
filesystemPath: string = '';
/** Optional static API key for the filesystem gateway. When set, accepted alongside per-user pairing/session keys. */
@Env('N8N_INSTANCE_AI_GATEWAY_API_KEY')
gatewayApiKey: string = '';

View file

@ -281,7 +281,6 @@ describe('GlobalConfig', () => {
sandboxTimeout: 300000,
braveSearchApiKey: '',
searxngUrl: '',
filesystemPath: '',
gatewayApiKey: '',
threadTtlDays: 90,
snapshotPruneInterval: 3_600_000,

View file

@ -55,9 +55,7 @@ graph TB
end
subgraph Filesystem ["Filesystem Access"]
Service -->|auto-detect| FSProvider{Provider}
FSProvider -->|bare metal| LocalFS[LocalFilesystemProvider]
FSProvider -->|container/cloud| Gateway[LocalGateway]
Service --> Gateway[LocalGateway]
Gateway -->|SSE + HTTP POST| Daemon["@n8n/computer-use daemon"]
end
@ -221,9 +219,8 @@ The n8n integration layer.
- **Settings service** — admin settings (model, MCP, sandbox), user preferences
- **Event bus** — in-process EventEmitter (single instance) or Redis Pub/Sub
(queue mode), with thread storage for event persistence and replay (max 500 events or 2 MB per thread)
- **Filesystem**`LocalFilesystemProvider` (bare metal) and `LocalGateway`
(remote daemon via SSE protocol). Auto-detected based on runtime environment
(see `docs/filesystem-access.md`)
- **Filesystem**`LocalGateway` (remote daemon via SSE protocol).
See `docs/filesystem-access.md`
- **Entities** — TypeORM entities for thread, message, memory, snapshots, iteration logs
- **Repositories** — data access layer (7 TypeORM repositories)

View file

@ -29,16 +29,10 @@ All Instance AI configuration is done via environment variables.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `N8N_INSTANCE_AI_FILESYSTEM_PATH` | string | `''` | Restrict local filesystem access to this directory. When empty, bare-metal installs can read any path the n8n process has access to. When set, `path.resolve()` + `fs.realpath()` containment prevents directory traversal and symlink escape. |
| `N8N_INSTANCE_AI_GATEWAY_API_KEY` | string | `''` | Static API key for the filesystem gateway. Used by the `@n8n/computer-use` daemon to authenticate SSE and HTTP POST requests. When empty, the dynamic pairing token flow is used instead. |
**Auto-detection** (no boolean flag needed):
1. `N8N_INSTANCE_AI_FILESYSTEM_PATH` explicitly set → local FS (restricted to that path)
2. Container detected (Docker, Kubernetes, systemd-nspawn) → gateway only
3. Bare metal (default) → local FS (unrestricted)
**Provider priority**: Gateway > Local > None — when both are available, gateway
wins so the daemon's targeted project directory is preferred.
Filesystem access requires the `@n8n/computer-use` gateway daemon. The user
runs `npx @n8n/computer-use serve` on their machine to connect.
See `docs/filesystem-access.md` for the full architecture, gateway protocol spec,
and security model.
@ -190,15 +184,7 @@ N8N_INSTANCE_AI_SANDBOX_PROVIDER=n8n-sandbox
N8N_SANDBOX_SERVICE_URL=https://sandbox.example.com
N8N_SANDBOX_SERVICE_API_KEY=sandbox-key
# With filesystem access (bare metal — zero config, auto-detected)
N8N_INSTANCE_AI_MODEL=anthropic/claude-sonnet-4-6
# Nothing else needed! Local filesystem is auto-detected on bare metal.
# With filesystem access (restricted to a specific directory)
N8N_INSTANCE_AI_MODEL=anthropic/claude-sonnet-4-6
N8N_INSTANCE_AI_FILESYSTEM_PATH=/home/user/my-project
# With filesystem gateway (Docker/cloud — user runs daemon on their machine)
# With filesystem gateway (user runs daemon on their machine)
N8N_INSTANCE_AI_MODEL=anthropic/claude-sonnet-4-6
N8N_INSTANCE_AI_GATEWAY_API_KEY=my-secret-key
# User runs: npx @n8n/computer-use

View file

@ -1,7 +1,7 @@
# Filesystem Access for Instance AI
> **ADR**: ADR-024 (local filesystem), ADR-025 (gateway protocol), ADR-026 (auto-detect), ADR-027 (auto-connect UX)
> **Status**: Implemented — two modes: local filesystem + gateway (auto-detected)
> **ADR**: ADR-025 (gateway protocol), ADR-027 (auto-connect UX)
> **Status**: Implemented — gateway-only architecture via `@n8n/computer-use` daemon
> **Depends on**: ADR-002 (interface boundary)
## Problem
@ -13,55 +13,28 @@ the project precisely.
## Architecture Overview
Two modes provide filesystem access depending on where n8n runs:
Filesystem access is provided exclusively through the **gateway protocol**
a lightweight daemon (`@n8n/computer-use`) runs on the user's machine and
bridges file access to the n8n server via SSE.
```
┌─────────────────────────────────┐
│ AI Agent Tools │
list-files · read-file · ...
(created from MCP server)
└──────────────┬──────────────────┘
│ calls
┌──────────────▼──────────────────┐
InstanceAiFilesystemService │ ← interface boundary
│ (listFiles, readFile, ...)
LocalMcpServer │ ← interface boundary
│ (getAvailableTools, callTool)
└──────────────┬──────────────────┘
│ implemented by
┌───────┴────────┐
▼ ▼
LocalFsProvider LocalGateway
(bare metal) (any remote client)
LocalGateway
(@n8n/computer-use daemon)
```
The agent never knows which path is active. It calls service interfaces, and
the transport is invisible.
**Provider priority**: `Gateway > Local Filesystem > None` — when both are
available, gateway wins so the daemon's targeted project directory is preferred
over unrestricted local FS.
### 1. Local Filesystem (auto-detected)
`LocalFilesystemProvider` reads files directly from disk using Node.js
`fs/promises`. **Auto-detected** — no boolean flag needed.
Detection heuristic:
1. `N8N_INSTANCE_AI_FILESYSTEM_PATH` explicitly set → local FS (restricted to that path)
2. Container detected (Docker, Kubernetes, systemd-nspawn) → gateway only
3. Bare metal (default) → local FS (unrestricted)
Container detection checks: `/.dockerenv` exists, `KUBERNETES_SERVICE_HOST`
env var, or `container` env var (systemd-nspawn/podman).
- **Zero configuration** — works out of the box when n8n runs on bare metal
- Optional `N8N_INSTANCE_AI_FILESYSTEM_PATH` to restrict access to a
specific directory (with symlink escape protection)
- Entry count cap of **200** in tree walks to prevent large responses
### 2. Gateway Protocol (cloud/Docker/remote)
For n8n running on a remote server or in Docker, the **gateway protocol**
provides filesystem access via a lightweight daemon running on the user's
machine.
The gateway protocol provides filesystem access via a lightweight daemon
running on the user's machine.
The protocol is simple:
1. **Daemon connects** to `GET /instance-ai/gateway/events` (SSE)
@ -92,102 +65,32 @@ upgraded to a session key on init (see [Authentication](#authentication) below).
Defined in `packages/@n8n/instance-ai/src/types.ts`:
```typescript
interface InstanceAiFilesystemService {
listFiles(
dirPath: string,
opts?: {
pattern?: string;
maxResults?: number;
type?: 'file' | 'directory' | 'all';
recursive?: boolean;
},
): Promise<FileEntry[]>;
readFile(
filePath: string,
opts?: { maxLines?: number; startLine?: number },
): Promise<FileContent>;
searchFiles(
dirPath: string,
opts: {
query: string;
filePattern?: string;
ignoreCase?: boolean;
maxResults?: number;
},
): Promise<FileSearchResult>;
getFileTree(
dirPath: string,
opts?: { maxDepth?: number; exclude?: string[] },
): Promise<string>;
interface LocalMcpServer {
getAvailableTools(): McpTool[];
getToolsByCategory(category: string): McpTool[];
callTool(req: McpToolCallRequest): Promise<McpToolCallResult>;
}
```
The `filesystemService` field in `InstanceAiContext` is **optional** — when no
filesystem is available, the filesystem tools are not registered with the agent.
The `localMcpServer` field in `InstanceAiContext` is **optional** — when no
gateway is connected, filesystem tools are not registered with the agent.
---
## Tools
Tools are **conditionally registered** — only when `filesystemService` is
present on the context. Each tool throws a clear error if the service is missing.
### get-file-tree
Get a shallow directory tree as indented text. Start low and drill into
subdirectories for deeper exploration.
| Parameter | Type | Default | Max | Description |
|-----------|------|---------|-----|-------------|
| `dirPath` | string | — | — | Absolute path or `~/relative` |
| `maxDepth` | number | 2 | 5 | Directory depth to show |
### list-files
List files and/or directories matching optional filters.
| Parameter | Type | Default | Max | Description |
|-----------|------|---------|-----|-------------|
| `dirPath` | string | — | — | Absolute path or `~/relative` |
| `pattern` | string | — | — | Glob pattern (e.g. `**/*.ts`) |
| `type` | enum | `all` | — | `file`, `directory`, or `all` |
| `recursive` | boolean | `true` | — | Recurse into subdirectories |
| `maxResults` | number | 200 | 1000 | Maximum entries to return |
### read-file
Read the contents of a file with optional line range.
| Parameter | Type | Default | Max | Description |
|-----------|------|---------|-----|-------------|
| `filePath` | string | — | — | Absolute path or `~/relative` |
| `startLine` | number | 1 | — | 1-indexed start line |
| `maxLines` | number | 200 | 500 | Lines to read |
### search-files
Search file contents for a text pattern or regex.
| Parameter | Type | Default | Max | Description |
|-----------|------|---------|-----|-------------|
| `dirPath` | string | — | — | Absolute path or `~/relative` |
| `query` | string | — | — | Regex pattern |
| `filePattern` | string | — | — | File filter (e.g. `*.ts`) |
| `ignoreCase` | boolean | `true` | — | Case-insensitive search |
| `maxResults` | number | 50 | 100 | Maximum matching lines |
Tools are **dynamically created** from the MCP server's advertised capabilities
when a gateway is connected. See `create-tools-from-mcp-server.ts`.
---
## Frontend UX (ADR-027)
The `InstanceAiDirectoryShare` component has 3 states:
The `LocalGatewaySection` component has 3 states:
| State | Condition | UI |
|-------|-----------|-----|
| **Connected** | `isGatewayConnected \|\| isLocalFilesystemEnabled` | Green indicator: "Files connected" |
| **Connected** | `isGatewayConnected` | Green indicator with connected host and capabilities |
| **Connecting** | `isDaemonConnecting` | Spinner: "Connecting..." |
| **Setup needed** | Default | `npx @n8n/computer-use` command + copy button + waiting spinner |
@ -220,25 +123,10 @@ subsequent communication uses a session key.
### Auto-connect by deployment scenario
#### Bare metal / self-hosted on the same machine
#### Self-hosted (bare metal / Docker / Kubernetes)
This is the **zero-config** path. When n8n runs directly on the user's machine
(not in a container), the system auto-detects this and uses **direct access**
the agent reads the filesystem through local providers without any gateway,
daemon, or pairing.
- The UI immediately shows **"Connected"** (green indicator).
- No `npx @n8n/computer-use` needed.
- If `N8N_INSTANCE_AI_FILESYSTEM_PATH` is set, access is sandboxed to that
directory. Otherwise it is unrestricted.
**Detection logic:** if no container markers are found (Docker, K8s), the
system assumes bare metal and enables direct access automatically.
#### Self-hosted in Docker / Kubernetes
n8n runs inside a container and **cannot** directly read files on the host
machine. The gateway bridge is required.
Whether n8n runs on bare metal or inside a container, it **cannot** directly
read files from the user's project directory. The gateway bridge is required.
```mermaid
sequenceDiagram
@ -299,8 +187,7 @@ firewall rules — SSE is a regular outbound connection.
| Deployment | Access path | Daemon needed? | User action |
|------------|-------------|----------------|-------------|
| Bare metal | Direct (local providers) | No | None — auto-detected |
| Docker / K8s | Gateway bridge | Yes | `npx @n8n/computer-use` on host |
| Self-hosted | Gateway bridge | Yes | `npx @n8n/computer-use` on host |
| n8n Cloud | Gateway bridge | Yes | `npx @n8n/computer-use` on local machine |
Alternatively, setting `N8N_INSTANCE_AI_GATEWAY_API_KEY` on both the n8n
@ -461,26 +348,20 @@ are client-agnostic.
### Directory exclusions
Excluded directories differ slightly between server-side and daemon-side:
The daemon excludes common non-essential directories from the tree scan:
**LocalFilesystemProvider** (server, 12 dirs):
`node_modules`, `.git`, `dist`, `.next`, `__pycache__`, `.cache`, `.turbo`,
`coverage`, `.venv`, `venv`, `.idea`, `.vscode`
**Tree scanner & local reader** (daemon, 16 dirs — adds 4 more):
All of the above plus: `build`, `.nuxt`, `.output`, `.svelte-kit`
`node_modules`, `.git`, `dist`, `build`, `.next`, `.nuxt`, `__pycache__`,
`.cache`, `.turbo`, `coverage`, `.venv`, `venv`, `.idea`, `.vscode`,
`.output`, `.svelte-kit`
### Entry count caps
| Component | Max entries | Default depth |
|-----------|-------------|---------------|
| LocalFilesystemProvider (server) | 200 | 2 |
| Tree scanner (daemon) | 10,000 | 8 |
| `get-file-tree` tool | — | 2 (max 5) |
The daemon scans more broadly (10,000 entries, depth 8) because it uploads
the full tree on init for cached queries. The server-side provider uses a
smaller cap (200) because it builds tree text on-the-fly per tool call.
The daemon scans broadly (10,000 entries, depth 8) because it uploads
the full tree on init for cached queries.
---
@ -488,11 +369,10 @@ smaller cap (200) because it builds tree text on-the-fly per tool call.
| Env var | Default | Purpose |
|---------|---------|---------|
| `N8N_INSTANCE_AI_FILESYSTEM_PATH` | none | Restrict direct filesystem access to this directory |
| `N8N_INSTANCE_AI_GATEWAY_API_KEY` | none | Static auth key for gateway (skips pairing flow) |
No env vars needed for most deployments. Bare metal auto-detects direct access.
Cloud/Docker auto-connects via the pairing flow.
No env vars needed for most deployments. The browser auto-connects the daemon
via the pairing flow.
See `docs/configuration.md` for the full configuration reference.
@ -503,7 +383,7 @@ See `docs/configuration.md` for the full configuration reference.
| Package | Responsibility |
|---------|----------------|
| `@n8n/instance-ai` | Agent core: service interfaces, tool definitions, data shapes. Framework-agnostic, zero n8n dependencies. |
| `packages/cli/.../instance-ai/` | n8n backend: HTTP endpoints, gateway singleton, local providers, auto-detect logic, event bus. |
| `packages/cli/.../instance-ai/` | n8n backend: HTTP endpoints, gateway singleton, event bus. |
| `@n8n/computer-use` | Reference gateway client: standalone CLI daemon. HTTP server, SSE client, local file reader, directory scanner. Independently installable via npx. |
### Tree scanner behavior

View file

@ -639,18 +639,11 @@ plain text / markdown → passthrough.
---
## Filesystem Tools (4, conditional)
## Filesystem Tools (dynamic, conditional)
Only registered when `filesystemService` is present. Auto-detected based on
runtime: bare metal → local FS, containers → gateway, cloud → nothing unless
daemon connects. See `docs/filesystem-access.md`.
| Tool | Description |
|------|-------------|
| `list-files` | List files matching a glob pattern (max 1000 results) |
| `read-file` | Read file contents with optional line range (max 512KB) |
| `search-files` | Search for text/regex across files (max 100 results) |
| `get-file-tree` | Get directory structure as indented tree (max 500 entries) |
Only registered when a `localMcpServer` (computer-use gateway) is connected.
Tools are dynamically created from the MCP server's advertised capabilities.
See `docs/filesystem-access.md`.
---

View file

@ -85,7 +85,6 @@ describe('createInstanceAgent', () => {
localGatewayStatus: undefined,
licenseHints: undefined,
localMcpServer: undefined,
filesystemService: undefined,
},
orchestrationContext: {
runId,

View file

@ -217,7 +217,7 @@ export async function createInstanceAgent(options: CreateInstanceAgentOptions):
const systemPrompt = getSystemPrompt({
researchMode: orchestrationContext?.researchMode,
webhookBaseUrl: orchestrationContext?.webhookBaseUrl,
filesystemAccess: !!(context.localMcpServer ?? context.filesystemService),
filesystemAccess: (context.localMcpServer?.getToolsByCategory('filesystem').length ?? 0) > 0,
localGateway: context.localGatewayStatus,
toolSearchEnabled: hasDeferrableTools,
licenseHints: context.licenseHints,

View file

@ -169,11 +169,6 @@ export type {
WebSearchResult,
WebSearchResponse,
InstanceAiWebResearchService,
InstanceAiFilesystemService,
FileEntry,
FileContent,
FileSearchMatch,
FileSearchResult,
InstanceAiWorkspaceService,
ProjectSummary,
FolderSummary,

View file

@ -1,84 +0,0 @@
import { createTool } from '@mastra/core/tools';
import { instanceAiConfirmationSeveritySchema } from '@n8n/api-types';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import type { InstanceAiContext } from '../../types';
export const getFileTreeInputSchema = z.object({
dirPath: z
.string()
.describe(
'Absolute directory path or ~/relative path (e.g. "/home/user/project" or "~/project/src"). Call with subdirectory paths to explore deeper.',
),
maxDepth: z
.number()
.int()
.positive()
.max(5)
.default(2)
.optional()
.describe(
'Maximum directory depth to show (default 2, max 5). Start low and increase only if needed.',
),
});
export const getFileTreeResumeSchema = z.object({
approved: z.boolean(),
});
export function createGetFileTreeTool(context: InstanceAiContext) {
return createTool({
id: 'get-file-tree',
description:
'Get a shallow directory tree. Start at depth 1-2 for an overview, then call again on specific subdirectories to drill deeper. Always use absolute paths or ~/relative paths.',
inputSchema: getFileTreeInputSchema,
outputSchema: z.object({
tree: z.string().describe('Directory tree as indented text'),
truncated: z.boolean(),
denied: z.boolean().optional(),
reason: z.string().optional(),
}),
suspendSchema: z.object({
requestId: z.string(),
message: z.string(),
severity: instanceAiConfirmationSeveritySchema,
}),
resumeSchema: getFileTreeResumeSchema,
execute: async ({ dirPath, maxDepth }: z.infer<typeof getFileTreeInputSchema>, ctx) => {
const resumeData = ctx?.agent?.resumeData as
| z.infer<typeof getFileTreeResumeSchema>
| undefined;
const suspend = ctx?.agent?.suspend;
if (context.permissions?.readFilesystem === 'blocked') {
return { tree: '', truncated: false, denied: true, reason: 'Action blocked by admin' };
}
const needsApproval = context.permissions?.readFilesystem !== 'always_allow';
if (needsApproval && (resumeData === undefined || resumeData === null)) {
await suspend?.({
requestId: nanoid(),
message: `Read filesystem tree at "${dirPath}"?`,
severity: 'info' as const,
});
return { tree: '', truncated: false };
}
if (resumeData !== undefined && resumeData !== null && !resumeData.approved) {
return { tree: '', truncated: false, denied: true, reason: 'User denied the action' };
}
if (!context.filesystemService) {
throw new Error('No filesystem access available.');
}
const tree = await context.filesystemService.getFileTree(dirPath, {
maxDepth: maxDepth ?? 2,
});
const truncated =
tree.includes('call get-file-tree on a subdirectory') || tree.includes('... (truncated at');
return { tree, truncated };
},
});
}

View file

@ -1,133 +0,0 @@
import { createTool } from '@mastra/core/tools';
import { instanceAiConfirmationSeveritySchema } from '@n8n/api-types';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import type { InstanceAiContext } from '../../types';
export const listFilesInputSchema = z.object({
dirPath: z
.string()
.describe(
'Absolute directory path or ~/relative path (e.g. "/home/user/project" or "~/project"). Do NOT use bare relative paths.',
),
pattern: z
.string()
.optional()
.describe('Glob pattern to filter files (e.g. "**/*.ts", "src/**/*.json")'),
type: z
.enum(['file', 'directory', 'all'])
.default('all')
.optional()
.describe(
'Filter by entry type: "file" for files only, "directory" for folders only, "all" for both (default "all")',
),
recursive: z
.boolean()
.default(true)
.optional()
.describe(
'Whether to recurse into subdirectories (default true). Set to false for a shallow listing of immediate children only.',
),
maxResults: z
.number()
.int()
.positive()
.max(1000)
.default(200)
.optional()
.describe('Maximum number of results to return (default 200, max 1000)'),
});
export const listFilesResumeSchema = z.object({
approved: z.boolean(),
});
export function createListFilesTool(context: InstanceAiContext) {
return createTool({
id: 'list-files',
description:
"List files and/or directories matching optional filters. Use this to explore what exists in a directory. To see only top-level folders, set type='directory' and recursive=false. Always use absolute paths or ~/relative paths.",
inputSchema: listFilesInputSchema,
outputSchema: z.object({
files: z.array(
z.object({
path: z.string(),
type: z.enum(['file', 'directory']),
sizeBytes: z.number().optional(),
}),
),
truncated: z.boolean(),
totalCount: z.number(),
denied: z.boolean().optional(),
reason: z.string().optional(),
}),
suspendSchema: z.object({
requestId: z.string(),
message: z.string(),
severity: instanceAiConfirmationSeveritySchema,
}),
resumeSchema: listFilesResumeSchema,
execute: async (
{ dirPath, pattern, maxResults, type, recursive }: z.infer<typeof listFilesInputSchema>,
ctx,
) => {
const resumeData = ctx?.agent?.resumeData as
| z.infer<typeof listFilesResumeSchema>
| undefined;
const suspend = ctx?.agent?.suspend;
if (context.permissions?.readFilesystem === 'blocked') {
return {
files: [],
truncated: false,
totalCount: 0,
denied: true,
reason: 'Action blocked by admin',
};
}
const needsApproval = context.permissions?.readFilesystem !== 'always_allow';
if (needsApproval && (resumeData === undefined || resumeData === null)) {
await suspend?.({
requestId: nanoid(),
message: `List files in "${dirPath}"?`,
severity: 'info' as const,
});
return { files: [], truncated: false, totalCount: 0 };
}
if (resumeData !== undefined && resumeData !== null && !resumeData.approved) {
return {
files: [],
truncated: false,
totalCount: 0,
denied: true,
reason: 'User denied the action',
};
}
if (!context.filesystemService) {
throw new Error('No filesystem access available.');
}
const limit = maxResults ?? 200;
const typeFilter = type ?? 'all';
const isRecursive = recursive ?? true;
// Fetch one extra to detect truncation without false positives
const fetched = await context.filesystemService.listFiles(dirPath, {
pattern: pattern ?? undefined,
maxResults: limit + 1,
type: typeFilter,
recursive: isRecursive,
});
const truncated = fetched.length > limit;
const files = truncated ? fetched.slice(0, limit) : fetched;
return {
files,
truncated,
totalCount: files.length,
};
},
});
}

View file

@ -1,107 +0,0 @@
import { createTool } from '@mastra/core/tools';
import { instanceAiConfirmationSeveritySchema } from '@n8n/api-types';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import type { InstanceAiContext } from '../../types';
import { wrapUntrustedData } from '../web-research/sanitize-web-content';
export const readFileInputSchema = z.object({
filePath: z
.string()
.describe(
'Absolute file path or ~/relative path (e.g. "/home/user/project/file.ts" or "~/project/file.ts"). Do NOT use bare relative paths.',
),
startLine: z
.number()
.int()
.positive()
.optional()
.describe('Start reading from this line (1-indexed, default: 1)'),
maxLines: z
.number()
.int()
.positive()
.max(500)
.default(200)
.optional()
.describe('Maximum number of lines to read (default 200, max 500)'),
});
export const readFileResumeSchema = z.object({
approved: z.boolean(),
});
export function createReadFileTool(context: InstanceAiContext) {
return createTool({
id: 'read-file',
description:
'Read the contents of a file. Returns the text content with optional line range. Use after list-files or search-files to read specific files. Always use absolute paths or ~/relative paths.',
inputSchema: readFileInputSchema,
outputSchema: z.object({
path: z.string(),
content: z.string(),
truncated: z.boolean(),
totalLines: z.number(),
denied: z.boolean().optional(),
reason: z.string().optional(),
}),
suspendSchema: z.object({
requestId: z.string(),
message: z.string(),
severity: instanceAiConfirmationSeveritySchema,
}),
resumeSchema: readFileResumeSchema,
execute: async (
{ filePath, startLine, maxLines }: z.infer<typeof readFileInputSchema>,
ctx,
) => {
const resumeData = ctx?.agent?.resumeData as z.infer<typeof readFileResumeSchema> | undefined;
const suspend = ctx?.agent?.suspend;
if (context.permissions?.readFilesystem === 'blocked') {
return {
path: '',
content: '',
truncated: false,
totalLines: 0,
denied: true,
reason: 'Action blocked by admin',
};
}
const needsApproval = context.permissions?.readFilesystem !== 'always_allow';
if (needsApproval && (resumeData === undefined || resumeData === null)) {
await suspend?.({
requestId: nanoid(),
message: `Read file "${filePath}"?`,
severity: 'info' as const,
});
return { path: '', content: '', truncated: false, totalLines: 0 };
}
if (resumeData !== undefined && resumeData !== null && !resumeData.approved) {
return {
path: '',
content: '',
truncated: false,
totalLines: 0,
denied: true,
reason: 'User denied the action',
};
}
if (!context.filesystemService) {
throw new Error('No filesystem access available.');
}
const result = await context.filesystemService.readFile(filePath, {
startLine: startLine ?? undefined,
maxLines: maxLines ?? undefined,
});
return {
...result,
content: wrapUntrustedData(result.content, 'file', filePath),
};
},
});
}

View file

@ -1,132 +0,0 @@
import { createTool } from '@mastra/core/tools';
import { instanceAiConfirmationSeveritySchema } from '@n8n/api-types';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import type { InstanceAiContext } from '../../types';
import { wrapUntrustedData } from '../web-research/sanitize-web-content';
export const searchFilesInputSchema = z.object({
dirPath: z
.string()
.describe(
'Absolute directory path or ~/relative path (e.g. "/home/user/project" or "~/project"). Do NOT use bare relative paths.',
),
query: z.string().describe('Search query — supports regex patterns'),
filePattern: z
.string()
.optional()
.describe('File pattern to restrict search (e.g. "*.ts", "*.json")'),
ignoreCase: z
.boolean()
.default(true)
.optional()
.describe('Case-insensitive search (default: true)'),
maxResults: z
.number()
.int()
.positive()
.max(100)
.default(50)
.optional()
.describe('Maximum number of matching lines to return (default 50, max 100)'),
});
export const searchFilesResumeSchema = z.object({
approved: z.boolean(),
});
export function createSearchFilesTool(context: InstanceAiContext) {
return createTool({
id: 'search-files',
description:
'Search file contents for a text pattern or regex across a directory. Returns matching lines with file paths and line numbers. Always use absolute paths or ~/relative paths.',
inputSchema: searchFilesInputSchema,
outputSchema: z.object({
query: z.string(),
matches: z.array(
z.object({
path: z.string(),
lineNumber: z.number(),
line: z.string(),
}),
),
truncated: z.boolean(),
totalMatches: z.number(),
denied: z.boolean().optional(),
reason: z.string().optional(),
}),
suspendSchema: z.object({
requestId: z.string(),
message: z.string(),
severity: instanceAiConfirmationSeveritySchema,
}),
resumeSchema: searchFilesResumeSchema,
execute: async (
{
dirPath,
query,
filePattern,
ignoreCase,
maxResults,
}: z.infer<typeof searchFilesInputSchema>,
ctx,
) => {
const resumeData = ctx?.agent?.resumeData as
| z.infer<typeof searchFilesResumeSchema>
| undefined;
const suspend = ctx?.agent?.suspend;
if (context.permissions?.readFilesystem === 'blocked') {
return {
query,
matches: [],
truncated: false,
totalMatches: 0,
denied: true,
reason: 'Action blocked by admin',
};
}
const needsApproval = context.permissions?.readFilesystem !== 'always_allow';
if (needsApproval && (resumeData === undefined || resumeData === null)) {
await suspend?.({
requestId: nanoid(),
message: `Search files in "${dirPath}" for "${query}"?`,
severity: 'info' as const,
});
return { query, matches: [], truncated: false, totalMatches: 0 };
}
if (resumeData !== undefined && resumeData !== null && !resumeData.approved) {
return {
query,
matches: [],
truncated: false,
totalMatches: 0,
denied: true,
reason: 'User denied the action',
};
}
if (!context.filesystemService) {
throw new Error('No filesystem access available.');
}
const result = await context.filesystemService.searchFiles(dirPath, {
query,
filePattern: filePattern ?? undefined,
ignoreCase: ignoreCase ?? undefined,
maxResults: maxResults ?? undefined,
});
return {
...result,
matches: result.matches.map(
(match: { path: string; lineNumber: number; line: string }) => ({
...match,
line: wrapUntrustedData(match.line, 'file', `${match.path}:${match.lineNumber}`),
}),
),
};
},
});
}

View file

@ -24,10 +24,6 @@ import { createListExecutionsTool } from './executions/list-executions.tool';
import { createRunWorkflowTool } from './executions/run-workflow.tool';
import { createStopExecutionTool } from './executions/stop-execution.tool';
import { createToolsFromLocalMcpServer } from './filesystem/create-tools-from-mcp-server';
import { createGetFileTreeTool } from './filesystem/get-file-tree.tool';
import { createListFilesTool } from './filesystem/list-files.tool';
import { createReadFileTool } from './filesystem/read-file.tool';
import { createSearchFilesTool } from './filesystem/search-files.tool';
import { createExploreNodeResourcesTool } from './nodes/explore-node-resources.tool';
import { createGetNodeDescriptionTool } from './nodes/get-node-description.tool';
import { createGetNodeTypeDefinitionTool } from './nodes/get-node-type-definition.tool';
@ -146,16 +142,7 @@ export function createAllTools(context: InstanceAiContext) {
: {}),
}
: {}),
...(context.localMcpServer
? createToolsFromLocalMcpServer(context.localMcpServer)
: context.filesystemService
? {
'list-files': createListFilesTool(context),
'read-file': createReadFileTool(context),
'search-files': createSearchFilesTool(context),
'get-file-tree': createGetFileTreeTool(context),
}
: {}),
...(context.localMcpServer ? createToolsFromLocalMcpServer(context.localMcpServer) : {}),
};
}

View file

@ -433,65 +433,6 @@ export interface InstanceAiWebResearchService {
): Promise<FetchedPage>;
}
// ── Filesystem data shapes ───────────────────────────────────────────────────
export interface FileEntry {
path: string;
type: 'file' | 'directory';
sizeBytes?: number;
}
export interface FileContent {
path: string;
content: string;
truncated: boolean;
totalLines: number;
}
export interface FileSearchMatch {
path: string;
lineNumber: number;
line: string;
}
export interface FileSearchResult {
query: string;
matches: FileSearchMatch[];
truncated: boolean;
totalMatches: number;
}
// ── Filesystem service ──────────────────────────────────────────────────────
export interface InstanceAiFilesystemService {
listFiles(
dirPath: string,
opts?: {
pattern?: string;
maxResults?: number;
type?: 'file' | 'directory' | 'all';
recursive?: boolean;
},
): Promise<FileEntry[]>;
readFile(
filePath: string,
opts?: { maxLines?: number; startLine?: number },
): Promise<FileContent>;
searchFiles(
dirPath: string,
opts: {
query: string;
filePattern?: string;
ignoreCase?: boolean;
maxResults?: number;
},
): Promise<FileSearchResult>;
getFileTree(dirPath: string, opts?: { maxDepth?: number; exclude?: string[] }): Promise<string>;
}
// ── Filesystem MCP server ────────────────────────────────────────────────────
/**
@ -563,10 +504,9 @@ export interface InstanceAiContext {
nodeService: InstanceAiNodeService;
dataTableService: InstanceAiDataTableService;
webResearchService?: InstanceAiWebResearchService;
filesystemService?: InstanceAiFilesystemService;
workspaceService?: InstanceAiWorkspaceService;
/**
* Connected remote MCP server (e.g. computer-use daemon). When set, dynamic tools are created from its advertised capabilities. Takes precedence over `filesystemService`.
* Connected remote MCP server (e.g. computer-use daemon). When set, dynamic tools are created from its advertised capabilities.
*/
localMcpServer?: LocalMcpServer;
/** Connection state of the local gateway — drives system prompt guidance. */

View file

@ -1,350 +0,0 @@
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { LocalFilesystemProvider } from '../filesystem/local-fs-provider';
describe('LocalFilesystemProvider', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'local-fs-test-'));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
/** Helper: create a file in the temp directory with optional content. */
async function createFile(relativePath: string, content = ''): Promise<void> {
const fullPath = path.join(tmpDir, relativePath);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, content);
}
describe('getFileTree', () => {
it('should return a structured tree', async () => {
await createFile('src/index.ts', 'export {};');
await createFile('src/utils.ts', 'export {};');
await createFile('package.json', '{}');
const provider = new LocalFilesystemProvider(tmpDir);
const tree = await provider.getFileTree('.');
expect(tree).toContain('src/');
expect(tree).toContain('index.ts');
expect(tree).toContain('utils.ts');
expect(tree).toContain('package.json');
});
it('should respect maxDepth', async () => {
await createFile('a/b/c/deep.ts', '');
const provider = new LocalFilesystemProvider(tmpDir);
const tree = await provider.getFileTree('.', { maxDepth: 1 });
expect(tree).toContain('a/');
expect(tree).not.toContain('deep.ts');
});
it('should exclude default directories', async () => {
await createFile('node_modules/pkg/index.js', '');
await createFile('.git/config', '');
await createFile('src/index.ts', '');
const provider = new LocalFilesystemProvider(tmpDir);
const tree = await provider.getFileTree('.');
expect(tree).not.toContain('node_modules');
expect(tree).not.toContain('.git');
expect(tree).toContain('index.ts');
});
it('should support custom exclusions', async () => {
await createFile('build/output.js', '');
await createFile('src/index.ts', '');
const provider = new LocalFilesystemProvider(tmpDir);
const tree = await provider.getFileTree('.', { exclude: ['build'] });
expect(tree).not.toContain('build');
expect(tree).toContain('index.ts');
});
});
describe('listFiles', () => {
it('should recursively list files', async () => {
await createFile('src/index.ts', '');
await createFile('src/lib/helper.ts', '');
await createFile('package.json', '');
const provider = new LocalFilesystemProvider(tmpDir);
const files = await provider.listFiles('.', { type: 'file' });
expect(files.length).toBe(3);
expect(files.map((f) => f.path)).toEqual(
expect.arrayContaining(['src/index.ts', 'src/lib/helper.ts', 'package.json']),
);
});
it('should list only directories when type is directory', async () => {
await createFile('src/index.ts', '');
await createFile('src/lib/helper.ts', '');
await createFile('package.json', '');
const provider = new LocalFilesystemProvider(tmpDir);
const dirs = await provider.listFiles('.', { type: 'directory' });
expect(dirs.every((d) => d.type === 'directory')).toBe(true);
expect(dirs.map((d) => d.path)).toEqual(expect.arrayContaining(['src', 'src/lib']));
});
it('should list only immediate children when recursive is false', async () => {
await createFile('src/index.ts', '');
await createFile('src/lib/helper.ts', '');
await createFile('package.json', '');
const provider = new LocalFilesystemProvider(tmpDir);
const entries = await provider.listFiles('.', { recursive: false });
expect(entries.map((e) => e.path)).toEqual(expect.arrayContaining(['src', 'package.json']));
expect(entries.map((e) => e.path)).not.toEqual(
expect.arrayContaining([expect.stringContaining('/')]),
);
});
it('should filter by glob pattern', async () => {
await createFile('src/index.ts', '');
await createFile('src/styles.css', '');
await createFile('README.md', '');
const provider = new LocalFilesystemProvider(tmpDir);
const files = await provider.listFiles('.', { pattern: '**/*.ts' });
expect(files.length).toBe(1);
expect(files[0].path).toBe('src/index.ts');
});
it('should respect maxResults', async () => {
for (let i = 0; i < 10; i++) {
await createFile(`file${i}.txt`, '');
}
const provider = new LocalFilesystemProvider(tmpDir);
const files = await provider.listFiles('.', { maxResults: 3 });
expect(files.length).toBe(3);
});
it('should include file size', async () => {
await createFile('test.txt', 'hello world');
const provider = new LocalFilesystemProvider(tmpDir);
const files = await provider.listFiles('.');
expect(files[0].sizeBytes).toBe(11);
});
it('should exclude node_modules', async () => {
await createFile('node_modules/pkg/index.js', '');
await createFile('src/index.ts', '');
const provider = new LocalFilesystemProvider(tmpDir);
const files = await provider.listFiles('.');
expect(files.map((f) => f.path)).not.toEqual(
expect.arrayContaining([expect.stringContaining('node_modules')]),
);
});
});
describe('readFile', () => {
it('should read file content', async () => {
await createFile('test.ts', 'line1\nline2\nline3');
const provider = new LocalFilesystemProvider(tmpDir);
const result = await provider.readFile('test.ts');
expect(result.content).toBe('line1\nline2\nline3');
expect(result.totalLines).toBe(3);
expect(result.truncated).toBe(false);
});
it('should support line slicing', async () => {
const lines = Array.from({ length: 50 }, (_, i) => `line ${i + 1}`);
await createFile('large.ts', lines.join('\n'));
const provider = new LocalFilesystemProvider(tmpDir);
const result = await provider.readFile('large.ts', { startLine: 10, maxLines: 5 });
expect(result.content).toBe('line 10\nline 11\nline 12\nline 13\nline 14');
expect(result.truncated).toBe(true);
expect(result.totalLines).toBe(50);
});
it('should truncate at default max lines', async () => {
const lines = Array.from({ length: 300 }, (_, i) => `line ${i + 1}`);
await createFile('huge.ts', lines.join('\n'));
const provider = new LocalFilesystemProvider(tmpDir);
const result = await provider.readFile('huge.ts');
const outputLines = result.content.split('\n');
expect(outputLines.length).toBe(200);
expect(result.truncated).toBe(true);
});
it('should reject binary files', async () => {
const binary = Buffer.from([0x00, 0x01, 0x02, 0xff]);
await fs.writeFile(path.join(tmpDir, 'image.bin'), binary);
const provider = new LocalFilesystemProvider(tmpDir);
const result = await provider.readFile('image.bin');
expect(result.content).toContain('Binary file');
});
it('should reject files exceeding size cap', async () => {
const large = Buffer.alloc(600 * 1024, 'a');
await fs.writeFile(path.join(tmpDir, 'big.dat'), large);
const provider = new LocalFilesystemProvider(tmpDir);
const result = await provider.readFile('big.dat');
expect(result.content).toContain('too large');
expect(result.truncated).toBe(true);
});
});
describe('searchFiles', () => {
it('should find matching lines', async () => {
await createFile('src/index.ts', 'const foo = 1;\nconst bar = 2;\nfoo();');
const provider = new LocalFilesystemProvider(tmpDir);
const result = await provider.searchFiles('.', { query: 'foo' });
expect(result.matches.length).toBe(2);
expect(result.matches[0].lineNumber).toBe(1);
expect(result.matches[1].lineNumber).toBe(3);
});
it('should support case-insensitive search', async () => {
await createFile('test.ts', 'Hello World\nhello world');
const provider = new LocalFilesystemProvider(tmpDir);
const result = await provider.searchFiles('.', { query: 'hello', ignoreCase: true });
expect(result.matches.length).toBe(2);
});
it('should filter by filePattern', async () => {
await createFile('src/index.ts', 'match here');
await createFile('src/styles.css', 'match here too');
const provider = new LocalFilesystemProvider(tmpDir);
const result = await provider.searchFiles('.', {
query: 'match',
filePattern: '**/*.ts',
});
expect(result.matches.length).toBe(1);
expect(result.matches[0].path).toBe('src/index.ts');
});
it('should respect maxResults', async () => {
const lines = Array.from({ length: 100 }, () => 'match');
await createFile('many.ts', lines.join('\n'));
const provider = new LocalFilesystemProvider(tmpDir);
const result = await provider.searchFiles('.', { query: 'match', maxResults: 5 });
expect(result.matches.length).toBe(5);
expect(result.truncated).toBe(true);
expect(result.totalMatches).toBe(100);
});
it('should handle invalid regex gracefully', async () => {
await createFile('test.ts', 'foo(bar');
const provider = new LocalFilesystemProvider(tmpDir);
const result = await provider.searchFiles('.', { query: 'foo(' });
// Should fall back to literal match
expect(result.matches.length).toBe(1);
});
});
describe('path containment (with basePath)', () => {
it('should reject traversal attempts', async () => {
const provider = new LocalFilesystemProvider(tmpDir);
await expect(provider.readFile('../../etc/passwd')).rejects.toThrow(
'outside the allowed directory',
);
});
it('should reject symlink escape', async () => {
// Create a symlink pointing outside tmpDir
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'outside-'));
await fs.writeFile(path.join(outsideDir, 'secret.txt'), 'secret');
try {
await fs.symlink(outsideDir, path.join(tmpDir, 'escape-link'));
const provider = new LocalFilesystemProvider(tmpDir);
await expect(provider.readFile('escape-link/secret.txt')).rejects.toThrow(
'outside the allowed directory',
);
} finally {
await fs.rm(outsideDir, { recursive: true, force: true });
}
});
it('should allow paths within basePath', async () => {
await createFile('valid/file.ts', 'ok');
const provider = new LocalFilesystemProvider(tmpDir);
const result = await provider.readFile('valid/file.ts');
expect(result.content).toBe('ok');
});
});
describe('no basePath', () => {
it('should accept absolute paths freely', async () => {
await createFile('test.ts', 'content');
const provider = new LocalFilesystemProvider();
const result = await provider.readFile(path.join(tmpDir, 'test.ts'));
expect(result.content).toBe('content');
});
});
describe('tilde expansion', () => {
it('should expand ~ to home directory in paths', async () => {
// Create a file in a predictable location under home
const homeRelPath = path.relative(os.homedir(), tmpDir);
await createFile('tilde-test.txt', 'tilde content');
const provider = new LocalFilesystemProvider();
const result = await provider.readFile(`~/${homeRelPath}/tilde-test.txt`);
expect(result.content).toBe('tilde content');
});
it('should expand ~ in dirPath for listFiles', async () => {
await createFile('a.ts', '');
const homeRelPath = path.relative(os.homedir(), tmpDir);
const provider = new LocalFilesystemProvider();
const files = await provider.listFiles(`~/${homeRelPath}`);
expect(files.map((f) => f.path)).toContain('a.ts');
});
});
});

View file

@ -1,4 +1,3 @@
export { LocalGateway } from './local-gateway';
export type { LocalGatewayEvent } from './local-gateway';
export { LocalGatewayRegistry } from './local-gateway-registry';
export { LocalFilesystemProvider } from './local-fs-provider';

View file

@ -1,366 +0,0 @@
import type { Dirent } from 'node:fs';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import type {
InstanceAiFilesystemService,
FileEntry,
FileContent,
FileSearchResult,
FileSearchMatch,
} from '@n8n/instance-ai';
const DEFAULT_MAX_DEPTH = 2;
const DEFAULT_MAX_LINES = 200;
const DEFAULT_MAX_RESULTS = 100;
const DEFAULT_SEARCH_MAX_RESULTS = 50;
const MAX_FILE_SIZE_BYTES = 512 * 1024; // 512 KB
const BINARY_CHECK_BYTES = 8192;
const MAX_ENTRY_COUNT = 200;
const EXCLUDED_DIRS = new Set([
'node_modules',
'.git',
'dist',
'.next',
'__pycache__',
'.cache',
'.turbo',
'coverage',
'.venv',
'venv',
'.idea',
'.vscode',
]);
/**
* Server-side filesystem provider that reads files directly from disk
* using Node.js `fs/promises`. Replaces the browser-mediated bridge
* when local filesystem access is auto-detected as available.
*
* Security model:
* - No basePath (default): agent reads any path the n8n process can access
* - With basePath: path.resolve() + fs.realpath() containment check prevents
* traversal and symlink escape
*/
export class LocalFilesystemProvider implements InstanceAiFilesystemService {
private readonly basePath: string | undefined;
constructor(basePath?: string) {
this.basePath = basePath && basePath.trim() !== '' ? basePath : undefined;
}
async getFileTree(
dirPath: string,
opts?: { maxDepth?: number; exclude?: string[] },
): Promise<string> {
const resolvedDir = await this.resolve(dirPath);
const maxDepth = opts?.maxDepth ?? DEFAULT_MAX_DEPTH;
const exclude = new Set([...EXCLUDED_DIRS, ...(opts?.exclude ?? [])]);
const lines: string[] = [];
let entryCount = 0;
const walk = async (dir: string, prefix: string, depth: number): Promise<void> => {
if (depth > maxDepth || entryCount >= MAX_ENTRY_COUNT) return;
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf-8' });
} catch {
return;
}
entries.sort((a, b) => {
// Directories first, then alphabetical
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
for (const entry of entries) {
if (entryCount >= MAX_ENTRY_COUNT) break;
if (exclude.has(entry.name)) continue;
entryCount++;
if (entry.isDirectory()) {
lines.push(`${prefix}${entry.name}/`);
await walk(path.join(dir, entry.name), `${prefix} `, depth + 1);
} else {
lines.push(`${prefix}${entry.name}`);
}
}
};
const dirName = path.basename(resolvedDir) || resolvedDir;
lines.push(`${dirName}/`);
await walk(resolvedDir, ' ', 1);
if (entryCount >= MAX_ENTRY_COUNT) {
lines.push(` ... (truncated at ${MAX_ENTRY_COUNT} entries)`);
}
return lines.join('\n');
}
async listFiles(
dirPath: string,
opts?: {
pattern?: string;
maxResults?: number;
type?: 'file' | 'directory' | 'all';
recursive?: boolean;
},
): Promise<FileEntry[]> {
const resolvedDir = await this.resolve(dirPath);
const maxResults = opts?.maxResults ?? DEFAULT_MAX_RESULTS;
const regex = opts?.pattern ? globToRegex(opts.pattern) : undefined;
const typeFilter = opts?.type ?? 'all';
const recursive = opts?.recursive ?? true;
const results: FileEntry[] = [];
const walk = async (dir: string): Promise<void> => {
if (results.length >= maxResults) return;
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf-8' });
} catch {
return;
}
for (const entry of entries) {
if (results.length >= maxResults) break;
if (EXCLUDED_DIRS.has(entry.name)) continue;
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(resolvedDir, fullPath);
if (entry.isDirectory()) {
if (typeFilter !== 'file') {
if (!regex || regex.test(relativePath)) {
results.push({ path: relativePath, type: 'directory' });
}
}
if (recursive) {
await walk(fullPath);
}
} else {
if (typeFilter !== 'directory') {
if (regex && !regex.test(relativePath)) continue;
let sizeBytes: number | undefined;
try {
const stat = await fs.stat(fullPath);
sizeBytes = stat.size;
} catch {
// skip inaccessible files
}
results.push({ path: relativePath, type: 'file', sizeBytes });
}
}
}
};
await walk(resolvedDir);
return results;
}
async readFile(
filePath: string,
opts?: { maxLines?: number; startLine?: number },
): Promise<FileContent> {
const resolvedPath = await this.resolve(filePath);
const stat = await fs.stat(resolvedPath);
if (stat.size > MAX_FILE_SIZE_BYTES) {
return {
path: filePath,
content: `[File too large: ${Math.round(stat.size / 1024)}KB exceeds ${MAX_FILE_SIZE_BYTES / 1024}KB limit]`,
truncated: true,
totalLines: 0,
};
}
// Binary detection: check first 8KB for null bytes
const checkBuffer = Buffer.alloc(Math.min(BINARY_CHECK_BYTES, stat.size));
const fh = await fs.open(resolvedPath, 'r');
try {
await fh.read(checkBuffer, 0, checkBuffer.length, 0);
} finally {
await fh.close();
}
if (checkBuffer.includes(0)) {
return {
path: filePath,
content: '[Binary file — cannot display]',
truncated: false,
totalLines: 0,
};
}
const raw = await fs.readFile(resolvedPath, 'utf-8');
const allLines = raw.split('\n');
const totalLines = allLines.length;
const startLine = opts?.startLine ?? 1;
const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES;
const startIdx = Math.max(0, startLine - 1);
const sliced = allLines.slice(startIdx, startIdx + maxLines);
const truncated = startIdx + maxLines < totalLines;
return {
path: filePath,
content: sliced.join('\n'),
truncated,
totalLines,
};
}
async searchFiles(
dirPath: string,
opts: {
query: string;
filePattern?: string;
ignoreCase?: boolean;
maxResults?: number;
},
): Promise<FileSearchResult> {
const resolvedDir = await this.resolve(dirPath);
const maxResults = opts.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS;
const flags = opts.ignoreCase ? 'i' : '';
let regex: RegExp;
try {
regex = new RegExp(opts.query, flags);
} catch {
// Treat as literal if invalid regex
regex = new RegExp(escapeRegex(opts.query), flags);
}
const filePatternRegex = opts.filePattern ? globToRegex(opts.filePattern) : undefined;
const matches: FileSearchMatch[] = [];
let totalMatches = 0;
const walk = async (dir: string): Promise<void> => {
if (matches.length >= maxResults) return;
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf-8' });
} catch {
return;
}
for (const entry of entries) {
if (matches.length >= maxResults) break;
if (EXCLUDED_DIRS.has(entry.name)) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
continue;
}
const relativePath = path.relative(resolvedDir, fullPath);
if (filePatternRegex && !filePatternRegex.test(relativePath)) continue;
let content: string;
try {
const stat = await fs.stat(fullPath);
if (stat.size > MAX_FILE_SIZE_BYTES) continue;
content = await fs.readFile(fullPath, 'utf-8');
} catch {
continue;
}
// Skip binary files
if (content.includes('\0')) continue;
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i])) {
totalMatches++;
if (matches.length < maxResults) {
matches.push({
path: relativePath,
lineNumber: i + 1,
line: lines[i].substring(0, 500),
});
}
}
}
}
};
await walk(resolvedDir);
return {
query: opts.query,
matches,
truncated: totalMatches > maxResults,
totalMatches,
};
}
// ── Path resolution & containment ────────────────────────────────────
private async resolve(inputPath: string): Promise<string> {
const expanded = expandTilde(inputPath);
if (!this.basePath) {
return path.resolve(expanded);
}
const resolved = path.resolve(this.basePath, expanded);
// Use realpath to resolve symlinks, then check containment
let real: string;
try {
real = await fs.realpath(resolved);
} catch {
// Path doesn't exist yet — verify the resolved path is still under basePath
if (!resolved.startsWith(this.basePath + path.sep) && resolved !== this.basePath) {
throw new Error(`Path "${inputPath}" is outside the allowed directory`);
}
return resolved;
}
const realBase = await fs.realpath(this.basePath);
if (!real.startsWith(realBase + path.sep) && real !== realBase) {
throw new Error(`Path "${inputPath}" is outside the allowed directory`);
}
return real;
}
}
/** Convert a simple glob pattern to a regex (supports * and **). */
function globToRegex(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '{{GLOBSTAR}}')
.replace(/\*/g, '[^/]*')
.replace(/\{\{GLOBSTAR\}\}/g, '.*');
return new RegExp(`^${escaped}$`);
}
/** Escape special regex characters in a string. */
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Expand leading `~` or `~/` to the current user's home directory. */
function expandTilde(p: string): string {
if (p === '~') return os.homedir();
if (p.startsWith('~/') || p.startsWith('~\\')) {
return path.join(os.homedir(), p.slice(2));
}
return p;
}

View file

@ -9,7 +9,6 @@ import type {
InstanceAiNodeService,
InstanceAiDataTableService,
InstanceAiWebResearchService,
InstanceAiFilesystemService,
FetchedPage,
WebSearchResponse,
DataTableSummary,
@ -188,13 +187,12 @@ export class InstanceAiAdapterService {
createContext(
user: User,
options?: {
filesystemService?: InstanceAiFilesystemService;
searchProxyConfig?: ServiceProxyConfig;
pushRef?: string;
threadId?: string;
},
): InstanceAiContext {
const { filesystemService, searchProxyConfig, pushRef, threadId } = options ?? {};
const { searchProxyConfig, pushRef, threadId } = options ?? {};
return {
userId: user.id,
workflowService: this.createWorkflowAdapter(user, threadId),
@ -205,7 +203,6 @@ export class InstanceAiAdapterService {
webResearchService: this.createWebResearchAdapter(user, searchProxyConfig),
workspaceService: this.createWorkspaceAdapter(user),
licenseHints: this.buildLicenseHints(),
...(filesystemService ? { filesystemService } : {}),
};
}

View file

@ -39,14 +39,10 @@ export class InstanceAiModule implements ModuleInterface {
const service = Container.get(InstanceAiService);
const settingsService = Container.get(InstanceAiSettingsService);
const enabled = service.isEnabled();
const localGateway = service.isLocalFilesystemAvailable();
const localGatewayDisabled = settingsService.isLocalGatewayDisabled();
const localGatewayFallbackDirectory = service.getLocalFilesystemDirectory();
return {
enabled,
localGateway,
localGatewayDisabled,
localGatewayFallbackDirectory,
proxyEnabled: service.isProxyEnabled(),
};
}

View file

@ -70,7 +70,7 @@ import { Push } from '@/push';
import { Telemetry } from '@/telemetry';
import { InProcessEventBus } from './event-bus/in-process-event-bus';
import type { LocalGateway } from './filesystem';
import { LocalGatewayRegistry, LocalFilesystemProvider } from './filesystem';
import { LocalGatewayRegistry } from './filesystem';
import { InstanceAiSettingsService } from './instance-ai-settings.service';
import { InstanceAiAdapterService } from './instance-ai.adapter.service';
import { AUTO_FOLLOW_UP_MESSAGE } from './internal-messages';
@ -132,9 +132,6 @@ export class InstanceAiService {
}
>();
/** Singleton local filesystem provider — created lazily when filesystem config is enabled. */
private localFsProvider?: LocalFilesystemProvider;
/** Per-user Local Gateway connections. Handles pairing tokens, session keys, and tool dispatch. */
private readonly gatewayRegistry = new LocalGatewayRegistry();
@ -312,15 +309,6 @@ export class InstanceAiService {
return new BuilderSandboxFactory(config);
}
/** Lazily create the local filesystem provider (singleton). */
private getLocalFsProvider(): LocalFilesystemProvider {
if (!this.localFsProvider) {
const basePath = this.instanceAiConfig.filesystemPath || undefined;
this.localFsProvider = new LocalFilesystemProvider(basePath);
}
return this.localFsProvider;
}
/** Get or create a sandbox + workspace for a thread. Returns undefined when sandbox is disabled. */
private async getOrCreateWorkspace(threadId: string, user: User) {
const existing = this.sandboxes.get(threadId);
@ -478,17 +466,6 @@ export class InstanceAiService {
return this.settingsService.isAgentEnabled() && !!this.instanceAiConfig.model;
}
/** Local filesystem is only available when an explicit base path is configured. */
isLocalFilesystemAvailable(): boolean {
return !!this.instanceAiConfig.filesystemPath?.trim();
}
/** Return the configured filesystem root directory, or null if not configured. */
getLocalFilesystemDirectory(): string | null {
const basePath = this.instanceAiConfig.filesystemPath?.trim();
return basePath || null;
}
hasActiveRun(threadId: string): boolean {
return this.runState.hasLiveRun(threadId);
}
@ -1100,15 +1077,10 @@ export class InstanceAiService {
) {
const localGatewayDisabled = this.settingsService.isLocalGatewayDisabled();
const userGateway = this.gatewayRegistry.findGateway(user.id);
const localFilesystemService =
!localGatewayDisabled && !userGateway?.isConnected && this.isLocalFilesystemAvailable()
? this.getLocalFsProvider()
: undefined;
// Each resolve*() call fetches a separate proxy token for audit tracking (see getProxyAuth)
const searchProxyConfig = await this.resolveSearchProxyConfig(user);
const tracingProxyConfig = await this.resolveTracingProxyConfig(user);
const context = this.adapterService.createContext(user, {
filesystemService: localFilesystemService,
searchProxyConfig,
pushRef,
threadId,

View file

@ -135,15 +135,8 @@ onMounted(() => {
</div>
</div>
<!-- Local filesystem (no gateway) -->
<!-- No gateway connected show setup instructions -->
<template v-else>
<div v-if="store.isLocalGatewayEnabled" :class="$style.statusRow">
<span :class="[$style.dot, $style.dotLocal]" />
<N8nText size="small" color="text-light">
{{ store.localGatewayFallbackDirectory }}
</N8nText>
</div>
<!-- Daemon connecting -->
<div v-if="store.isDaemonConnecting" :class="$style.connectingRow">
<span :class="$style.spinner" />
@ -256,10 +249,6 @@ onMounted(() => {
background: var(--color--success);
}
.dotLocal {
background: var(--color--warning);
}
@keyframes pulse {
0%,
100% {

View file

@ -2,7 +2,6 @@ import { defineStore } from 'pinia';
import { ref, computed, triggerRef } from 'vue';
import { v4 as uuidv4 } from 'uuid';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useToast } from '@/app/composables/useToast';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { ResponseError } from '@n8n/rest-api-client';
@ -121,7 +120,6 @@ let sseGeneration = 0;
export const useInstanceAiStore = defineStore('instanceAi', () => {
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const instanceAiSettingsStore = useInstanceAiSettingsStore();
const toast = useToast();
const telemetry = useTelemetry();
@ -150,17 +148,9 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
// --- Computed ---
const isStreaming = computed(() => activeRunId.value !== null);
const hasMessages = computed(() => messages.value.length > 0);
const isLocalGatewayEnabled = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.localGateway === true,
);
const isGatewayConnected = computed(() => instanceAiSettingsStore.isGatewayConnected);
const gatewayDirectory = computed(() => instanceAiSettingsStore.gatewayDirectory);
const localGatewayFallbackDirectory = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.localGatewayFallbackDirectory ?? null,
);
const activeDirectory = computed(
() => gatewayDirectory.value ?? localGatewayFallbackDirectory.value,
);
const activeDirectory = computed(() => gatewayDirectory.value);
// Resource registry — maps known resource names to their types & IDs
const workflowsListStore = useWorkflowsListStore();
@ -1009,10 +999,8 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
// Computed
isStreaming,
hasMessages,
isLocalGatewayEnabled,
isGatewayConnected,
gatewayDirectory,
localGatewayFallbackDirectory,
activeDirectory,
contextualSuggestion,
currentTasks,

View file

@ -44,20 +44,12 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const setupCommand = ref<string | null>(null);
const isGatewayPolling = ref(false);
const isLocalGatewayEnabled = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.localGateway === true,
);
const gatewayConnected = ref(false);
const gatewayDirectory = ref<string | null>(null);
const gatewayHostIdentifier = ref<string | null>(null);
const gatewayToolCategories = ref<ToolCategory[]>([]);
const isGatewayConnected = computed(() => gatewayConnected.value);
const localGatewayFallbackDirectory = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.localGatewayFallbackDirectory ?? null,
);
const activeDirectory = computed(
() => gatewayDirectory.value ?? localGatewayFallbackDirectory.value,
);
const activeDirectory = computed(() => gatewayDirectory.value);
const isLocalGatewayDisabled = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.localGatewayDisabled === true,
);
@ -356,12 +348,10 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
isDaemonConnecting,
setupCommand,
isGatewayPolling,
isLocalGatewayEnabled,
isGatewayConnected,
gatewayDirectory,
gatewayHostIdentifier,
gatewayToolCategories,
localGatewayFallbackDirectory,
activeDirectory,
isLocalGatewayDisabled,
isProxyEnabled,