diff --git a/packages/@n8n/instance-ai/SPECS.md b/packages/@n8n/instance-ai/SPECS.md index f54d9902fe8..a6003fc1bc9 100644 --- a/packages/@n8n/instance-ai/SPECS.md +++ b/packages/@n8n/instance-ai/SPECS.md @@ -465,7 +465,7 @@ pnpm typecheck - [ ] Remove `TypeORMWorkflowsStorage` from the active runtime path. - [ ] Remove observational memory runtime usage. - [ ] Keep or replace compaction as the operational long-context mechanism. -- [ ] Rewrite workspace providers against native agents workspace interfaces. +- [x] Rewrite workspace providers against native agents workspace interfaces. - [ ] Replace Mastra MCP usage with native MCP or native dynamic tools. - [ ] Update LangSmith tracing to native telemetry/events where possible. - [ ] Update docs that mention Mastra runtime behavior. diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts index 22f28f69af6..d34c11327f8 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/build-workflow-agent.tool.ts @@ -890,7 +890,7 @@ export async function startBuildWorkflowAgentTask( }, model: context.modelId, tools: tracedBuilderTools, - workspace, + workspace: workspace as never, memory: shouldUseBuilderMemory ? context.memory : undefined, }); mergeTraceRunInputs( diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 1357e77a7da..1118e0c9360 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -1,8 +1,8 @@ import type { LanguageModelV2 } from '@ai-sdk/provider-v5'; import type { ToolsInput } from '@mastra/core/agent'; import type { MastraCompositeStore } from '@mastra/core/storage'; -import type { Workspace } from '@mastra/core/workspace'; import type { Memory } from '@mastra/memory'; +import type { Workspace } from '@n8n/agents'; import type { TaskList, InstanceAiAttachment, diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/builder-sandbox-factory.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/builder-sandbox-factory.test.ts index e543a8f579b..05d1e334a82 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/builder-sandbox-factory.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/builder-sandbox-factory.test.ts @@ -1,6 +1,6 @@ // Mock external SDKs and other workspace modules so we can drive the factory // end-to-end in Jest without touching real sandboxes, filesystems, or the -// Mastra runtime. +// native workspace runtime. interface DaytonaCreateParams { snapshot?: string; @@ -44,36 +44,9 @@ jest.mock('@daytonaio/sdk', () => { return { Daytona, DaytonaError, Image }; }); -jest.mock('@mastra/core/workspace', () => { - class LocalSandbox { - constructor(public opts: unknown) {} - } - class LocalFilesystem { - constructor(public opts: unknown) {} - } - class Workspace { - sandbox: { processes?: { list: jest.Mock; kill: jest.Mock; get: jest.Mock } } & { - [k: string]: unknown; - }; - filesystem: { writeFile: jest.Mock }; - constructor(public opts: { sandbox: unknown; filesystem: unknown }) { - this.sandbox = { - ...(opts.sandbox as Record), - processes: { - list: jest.fn().mockResolvedValue([]), - kill: jest.fn().mockResolvedValue(undefined), - get: jest.fn().mockResolvedValue(undefined), - }, - }; - this.filesystem = { writeFile: jest.fn().mockResolvedValue(undefined) }; - } - init = jest.fn().mockResolvedValue(undefined); - } - return { LocalSandbox, LocalFilesystem, Workspace }; -}); - -jest.mock('@mastra/daytona', () => { +jest.mock('../daytona-sandbox', () => { class DaytonaSandbox { + start = jest.fn().mockResolvedValue(undefined); constructor(public opts: unknown) {} } return { DaytonaSandbox }; @@ -100,6 +73,9 @@ const capturedSandboxes: MockN8nSandbox[] = []; jest.mock('../n8n-sandbox-sandbox', () => ({ N8nSandboxServiceSandbox: class { + start = jest.fn(async () => { + await Promise.resolve(); + }); destroy = jest.fn(async () => { await Promise.resolve(); }); diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/create-workspace.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/create-workspace.test.ts index 6b07ec9d3e9..e489808bc14 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/create-workspace.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/create-workspace.test.ts @@ -1,101 +1,18 @@ -jest.mock('@mastra/core/workspace', () => { - class LocalSandbox { - readonly type = 'local'; - constructor(public opts: { workingDirectory: string }) {} - } - class LocalFilesystem { - readonly type = 'local-fs'; - constructor(public opts: { basePath: string }) {} - } - class MockWorkspace { - constructor(public opts: { sandbox: unknown; filesystem: unknown }) {} - } - return { LocalSandbox, LocalFilesystem, Workspace: MockWorkspace }; -}); - -jest.mock('@mastra/daytona', () => { - class DaytonaSandbox { - readonly type = 'daytona'; - constructor(public opts: Record) {} - } - return { DaytonaSandbox }; -}); - -jest.mock('../daytona-filesystem', () => { - class DaytonaFilesystem { - readonly type = 'daytona-fs'; - constructor(public sandbox: unknown) {} - } - return { DaytonaFilesystem }; -}); - -jest.mock('../n8n-sandbox-sandbox', () => { - class N8nSandboxServiceSandbox { - readonly type = 'n8n-sandbox'; - constructor(public opts: Record) {} - } - return { N8nSandboxServiceSandbox }; -}); - -jest.mock('../n8n-sandbox-filesystem', () => { - class N8nSandboxFilesystem { - readonly type = 'n8n-sandbox-fs'; - constructor(public sandbox: unknown) {} - } - return { N8nSandboxFilesystem }; -}); - -// --------------------------------------------------------------------------- -// Typed mock classes — avoids `any` from jest.requireMock -// --------------------------------------------------------------------------- - -interface MockWithOpts { - opts: T; -} - -type MockLocalSandboxCtor = new (opts: { - workingDirectory: string; -}) => MockWithOpts<{ workingDirectory: string }>; -type MockLocalFilesystemCtor = new (opts: { basePath: string }) => MockWithOpts<{ - basePath: string; -}>; -type MockWorkspaceCtor = new (opts: { - sandbox: unknown; - filesystem: unknown; -}) => MockWithOpts<{ sandbox: unknown; filesystem: unknown }>; -type MockDaytonaSandboxCtor = new ( - opts: Record, -) => MockWithOpts>; -type MockDaytonaFilesystemCtor = new (sandbox: unknown) => { sandbox: unknown }; -type MockN8nSandboxCtor = new ( - opts: Record, -) => MockWithOpts>; -type MockN8nFilesystemCtor = new (sandbox: unknown) => { sandbox: unknown }; - -const { - LocalSandbox, - LocalFilesystem, - Workspace: WorkspaceMock, -}: { - LocalSandbox: MockLocalSandboxCtor; - LocalFilesystem: MockLocalFilesystemCtor; - Workspace: MockWorkspaceCtor; -} = jest.requireMock('@mastra/core/workspace'); - -const { DaytonaSandbox }: { DaytonaSandbox: MockDaytonaSandboxCtor } = - jest.requireMock('@mastra/daytona'); - -const { DaytonaFilesystem }: { DaytonaFilesystem: MockDaytonaFilesystemCtor } = - jest.requireMock('../daytona-filesystem'); - -const { N8nSandboxServiceSandbox }: { N8nSandboxServiceSandbox: MockN8nSandboxCtor } = - jest.requireMock('../n8n-sandbox-sandbox'); - -const { N8nSandboxFilesystem }: { N8nSandboxFilesystem: MockN8nFilesystemCtor } = jest.requireMock( - '../n8n-sandbox-filesystem', -); +import { Workspace } from '@n8n/agents'; import { type SandboxConfig, createSandbox, createWorkspace } from '../create-workspace'; +import { DaytonaFilesystem } from '../daytona-filesystem'; +import { DaytonaSandbox } from '../daytona-sandbox'; +import { LocalFilesystem } from '../local-filesystem'; +import { LocalSandbox } from '../local-sandbox'; +import { N8nSandboxFilesystem } from '../n8n-sandbox-filesystem'; +import { N8nSandboxServiceSandbox } from '../n8n-sandbox-sandbox'; + +function getPrivateOptions(value: unknown): Record { + if (!value || typeof value !== 'object') return {}; + const options = (value as Record).options; + return options && typeof options === 'object' ? (options as Record) : {}; +} describe('createSandbox', () => { const originalEnv = process.env.NODE_ENV; @@ -125,7 +42,7 @@ describe('createSandbox', () => { const result = await createSandbox(config); expect(result).toBeInstanceOf(DaytonaSandbox); - expect((result as unknown as MockWithOpts>).opts).toEqual( + expect(getPrivateOptions(result)).toEqual( expect.objectContaining({ apiKey: 'test-key', apiUrl: 'https://api.daytona.io', @@ -150,7 +67,7 @@ describe('createSandbox', () => { expect(getAuthToken).toHaveBeenCalledTimes(1); expect(result).toBeInstanceOf(DaytonaSandbox); - expect((result as unknown as MockWithOpts>).opts).toEqual( + expect(getPrivateOptions(result)).toEqual( expect.objectContaining({ apiKey: 'jwt-token-123', apiUrl: 'https://proxy.example.com', @@ -167,7 +84,7 @@ describe('createSandbox', () => { const result = await createSandbox(config); expect(result).toBeInstanceOf(DaytonaSandbox); - expect((result as unknown as MockWithOpts>).opts.timeout).toBe(300_000); + expect(getPrivateOptions(result).timeout).toBe(300_000); }); it('should not include image in DaytonaSandbox config when not specified', async () => { @@ -179,9 +96,7 @@ describe('createSandbox', () => { const result = await createSandbox(config); expect(result).toBeInstanceOf(DaytonaSandbox); - expect((result as unknown as MockWithOpts>).opts).not.toHaveProperty( - 'image', - ); + expect(getPrivateOptions(result)).not.toHaveProperty('image'); }); it('should return a LocalSandbox for "local" provider in non-production', async () => { @@ -191,9 +106,8 @@ describe('createSandbox', () => { const result = await createSandbox(config); expect(result).toBeInstanceOf(LocalSandbox); - expect((result as unknown as MockWithOpts<{ workingDirectory: string }>).opts).toEqual({ - workingDirectory: './workspace', - }); + if (!(result instanceof LocalSandbox)) throw new Error('Expected LocalSandbox'); + expect(result.workingDirectory).toMatch(/workspace$/); }); it('should throw in production when provider is "local"', async () => { @@ -217,7 +131,7 @@ describe('createSandbox', () => { const result = await createSandbox(config); expect(result).toBeInstanceOf(N8nSandboxServiceSandbox); - expect((result as unknown as MockWithOpts>).opts).toEqual({ + expect(getPrivateOptions(result)).toEqual({ serviceUrl: 'https://sandbox.example.com', apiKey: 'sandbox-key', timeout: 45_000, @@ -235,45 +149,30 @@ describe('createWorkspace', () => { it('should wrap LocalSandbox with LocalFilesystem', () => { const sandbox = new LocalSandbox({ workingDirectory: './workspace' }); - const result = createWorkspace(sandbox as unknown as Parameters[0]); + const result = createWorkspace(sandbox); - expect(result).toBeDefined(); - expect(result).toBeInstanceOf(WorkspaceMock); - const workspace = result as unknown as MockWithOpts<{ - sandbox: unknown; - filesystem: unknown; - }>; - expect(workspace.opts.sandbox).toBe(sandbox); - expect(workspace.opts.filesystem).toBeInstanceOf(LocalFilesystem); + expect(result).toBeInstanceOf(Workspace); + expect(result?.sandbox).toBe(sandbox); + expect(result?.filesystem).toBeInstanceOf(LocalFilesystem); }); it('should wrap DaytonaSandbox with DaytonaFilesystem', () => { const sandbox = new DaytonaSandbox({ apiKey: 'key' }); - const result = createWorkspace(sandbox as unknown as Parameters[0]); + const result = createWorkspace(sandbox); - expect(result).toBeDefined(); - expect(result).toBeInstanceOf(WorkspaceMock); - const workspace = result as unknown as MockWithOpts<{ - sandbox: unknown; - filesystem: unknown; - }>; - expect(workspace.opts.sandbox).toBe(sandbox); - expect(workspace.opts.filesystem).toBeInstanceOf(DaytonaFilesystem); + expect(result).toBeInstanceOf(Workspace); + expect(result?.sandbox).toBe(sandbox); + expect(result?.filesystem).toBeInstanceOf(DaytonaFilesystem); }); it('should wrap N8nSandboxServiceSandbox with N8nSandboxFilesystem', () => { const sandbox = new N8nSandboxServiceSandbox({ apiKey: 'key' }); - const result = createWorkspace(sandbox as unknown as Parameters[0]); + const result = createWorkspace(sandbox); - expect(result).toBeDefined(); - expect(result).toBeInstanceOf(WorkspaceMock); - const workspace = result as unknown as MockWithOpts<{ - sandbox: unknown; - filesystem: unknown; - }>; - expect(workspace.opts.sandbox).toBe(sandbox); - expect(workspace.opts.filesystem).toBeInstanceOf(N8nSandboxFilesystem); + expect(result).toBeInstanceOf(Workspace); + expect(result?.sandbox).toBe(sandbox); + expect(result?.filesystem).toBeInstanceOf(N8nSandboxFilesystem); }); }); diff --git a/packages/@n8n/instance-ai/src/workspace/builder-sandbox-factory.ts b/packages/@n8n/instance-ai/src/workspace/builder-sandbox-factory.ts index 39d976cf92e..d78956afea7 100644 --- a/packages/@n8n/instance-ai/src/workspace/builder-sandbox-factory.ts +++ b/packages/@n8n/instance-ai/src/workspace/builder-sandbox-factory.ts @@ -8,14 +8,16 @@ */ import { Daytona } from '@daytonaio/sdk'; -import { Workspace, LocalFilesystem, LocalSandbox } from '@mastra/core/workspace'; -import { DaytonaSandbox } from '@mastra/daytona'; +import { Workspace } from '@n8n/agents'; import assert from 'node:assert/strict'; import { join as posixJoin } from 'node:path/posix'; import type { ErrorReporter, Logger } from '../logger'; import type { SandboxConfig } from './create-workspace'; import { DaytonaFilesystem } from './daytona-filesystem'; +import { DaytonaSandbox } from './daytona-sandbox'; +import { LocalFilesystem } from './local-filesystem'; +import { LocalSandbox } from './local-sandbox'; import { N8nSandboxFilesystem } from './n8n-sandbox-filesystem'; import { N8nSandboxImageManager } from './n8n-sandbox-image-manager'; import { N8nSandboxServiceSandbox } from './n8n-sandbox-sandbox'; @@ -56,7 +58,7 @@ async function cleanupTrackedSandboxProcesses(workspace: Workspace): Promise; + labels?: Record; + snapshot?: string; + image?: string; + ephemeral?: boolean; + autoStopInterval?: number; + autoArchiveInterval?: number; + autoDeleteInterval?: number; + volumes?: VolumeMount[]; + name?: string; + user?: string; + public?: boolean; + networkBlockAll?: boolean; + networkAllowList?: string; +} + +function shellEscape(value: string): string { + return /^[A-Za-z0-9_./:=@+-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`; +} + +function toShellCommand(command: string, args: string[]): string { + if (args.length === 0) return command; + return [command, ...args.map((arg) => shellEscape(arg))].join(' '); +} + +function isAuthError(error: unknown): boolean { + return error instanceof DaytonaError && (error.statusCode === 401 || error.statusCode === 403); +} + +export class DaytonaSandbox extends BaseSandbox { + private static readonly DEAD_STATES = new Set([ + SandboxState.DESTROYED, + SandboxState.DESTROYING, + SandboxState.ERROR, + SandboxState.BUILD_FAILED, + ]); + + readonly id: string; + readonly name = 'DaytonaSandbox'; + readonly provider = 'daytona'; + status: ProviderStatus = 'pending'; + + private readonly timeout: number; + private readonly language: 'typescript' | 'javascript' | 'python'; + private readonly createdAt = new Date(); + private readonly connection: DaytonaConfig; + private readonly sandboxName: string; + private daytonaClient?: Daytona; + private sandbox?: Sandbox; + private workingDirectory?: string; + + constructor(private readonly options: DaytonaSandboxOptions = {}) { + super(); + this.id = options.id ?? `daytona-sandbox-${randomUUID()}`; + this.timeout = options.timeout ?? 300_000; + this.language = options.language ?? 'typescript'; + this.sandboxName = options.name ?? this.id; + this.connection = {}; + if (options.apiKey !== undefined) this.connection.apiKey = options.apiKey; + if (options.apiUrl !== undefined) this.connection.apiUrl = options.apiUrl; + if (options.target !== undefined) this.connection.target = options.target; + } + + get instance(): Sandbox { + if (!this.sandbox) { + throw new Error(`Daytona sandbox "${this.id}" is not running`); + } + return this.sandbox; + } + + override async start(): Promise { + if (this.sandbox) return; + + const client = this.getDaytona(); + const existing = await this.findExistingSandbox(client); + if (existing) { + this.sandbox = existing; + await this.detectWorkingDirectory(); + return; + } + + this.sandbox = await client.create(this.createSandboxParams()); + await this.detectWorkingDirectory(); + } + + override async stop(): Promise { + if (!this.sandbox) return; + await this.sandbox.stop(Math.ceil(this.timeout / 1000)); + this.sandbox = undefined; + } + + override async destroy(): Promise { + if (this.sandbox) { + await this.sandbox.delete(Math.ceil(this.timeout / 1000)); + this.sandbox = undefined; + return; + } + + try { + const existing = await this.getDaytona().get(this.sandboxName); + await existing.delete(Math.ceil(this.timeout / 1000)); + } catch (error) { + if (error instanceof DaytonaNotFoundError) return; + throw error; + } + } + + override async executeCommand( + command: string, + args: string[] = [], + options?: ExecuteCommandOptions, + ): Promise { + await this.ensureRunning(); + const startedAt = Date.now(); + const fullCommand = toShellCommand(command, args); + const result = await this.instance.process.executeCommand( + fullCommand, + options?.cwd, + this.compactEnv(options?.env), + Math.ceil((options?.timeout ?? this.timeout) / 1000), + ); + const stdout = result.artifacts?.stdout ?? result.result ?? ''; + if (stdout) options?.onStdout?.(stdout); + + return { + command, + args, + success: result.exitCode === 0, + exitCode: result.exitCode, + stdout, + stderr: '', + executionTimeMs: Date.now() - startedAt, + }; + } + + getInfo(): SandboxInfo { + return { + id: this.id, + name: this.name, + provider: this.provider, + status: this.status, + createdAt: this.createdAt, + resources: this.sandbox + ? { + cpuCores: this.sandbox.cpu, + memoryMB: this.sandbox.memory * 1024, + } + : undefined, + metadata: { + language: this.language, + workingDirectory: this.workingDirectory, + target: this.sandbox?.target, + remoteSandboxId: this.sandbox?.id, + }, + }; + } + + override getInstructions(): string { + const parts = [`Cloud sandbox with isolated execution (${this.language} runtime).`]; + if (this.workingDirectory) { + parts.push(`Default working directory: ${this.workingDirectory}.`); + } + parts.push(`Command timeout: ${Math.ceil(this.timeout / 1000)}s.`); + return parts.join(' '); + } + + private getDaytona(): Daytona { + this.daytonaClient ??= new Daytona(this.connection); + return this.daytonaClient; + } + + private async findExistingSandbox(client: Daytona): Promise { + try { + const sandbox = await client.get(this.sandboxName); + if (sandbox.state && this.isDeadState(sandbox.state)) { + await sandbox.delete(Math.ceil(this.timeout / 1000)); + return null; + } + if (sandbox.state !== SandboxState.STARTED) { + await sandbox.start(Math.ceil(this.timeout / 1000)); + } + return sandbox; + } catch (error) { + if (error instanceof DaytonaNotFoundError) return null; + if (isAuthError(error)) throw error; + return null; + } + } + + private createSandboxParams(): CreateSandboxFromImageParams | CreateSandboxFromSnapshotParams { + const base: CreateSandboxBaseParams = { + language: this.language, + labels: { + ...this.options.labels, + 'n8n-instance-ai-sandbox-id': this.id, + }, + autoStopInterval: this.options.autoStopInterval ?? 15, + name: this.sandboxName, + }; + if (this.options.ephemeral !== undefined) base.ephemeral = this.options.ephemeral; + if (this.options.autoArchiveInterval !== undefined) { + base.autoArchiveInterval = this.options.autoArchiveInterval; + } + if (this.options.autoDeleteInterval !== undefined) { + base.autoDeleteInterval = this.options.autoDeleteInterval; + } + if (this.options.volumes !== undefined) base.volumes = this.options.volumes; + if (this.options.user !== undefined) base.user = this.options.user; + if (this.options.public !== undefined) base.public = this.options.public; + if (this.options.networkBlockAll !== undefined) { + base.networkBlockAll = this.options.networkBlockAll; + } + if (this.options.networkAllowList !== undefined) { + base.networkAllowList = this.options.networkAllowList; + } + if (this.options.env !== undefined) base.envVars = this.options.env; + + if (this.options.image && !this.options.snapshot) { + return { + ...base, + image: this.options.image, + resources: this.options.resources, + }; + } + + return { + ...base, + snapshot: this.options.snapshot, + }; + } + + private async detectWorkingDirectory(): Promise { + try { + this.workingDirectory = await this.instance.getWorkDir(); + } catch { + this.workingDirectory = undefined; + } + } + + private isDeadState(state: SandboxState): boolean { + return DaytonaSandbox.DEAD_STATES.has(state); + } + + private compactEnv(env: NodeJS.ProcessEnv | undefined): Record | undefined { + const merged = { + ...this.options.env, + ...env, + }; + const entries = Object.entries(merged).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; + } +} diff --git a/packages/@n8n/instance-ai/src/workspace/local-filesystem.ts b/packages/@n8n/instance-ai/src/workspace/local-filesystem.ts new file mode 100644 index 00000000000..ad082328687 --- /dev/null +++ b/packages/@n8n/instance-ai/src/workspace/local-filesystem.ts @@ -0,0 +1,233 @@ +import type { + CopyOptions, + FileContent, + FileEntry, + FileStat, + ListOptions, + ProviderStatus, + ReadOptions, + RemoveOptions, + WriteOptions, +} from '@n8n/agents'; +import { BaseFilesystem } from '@n8n/agents'; +import { + access, + copyFile, + cp, + mkdir, + readdir, + readFile, + rename, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; + +export interface LocalFilesystemOptions { + id?: string; + basePath: string; + contained?: boolean; + readOnly?: boolean; + instructions?: string; +} + +function toBuffer(content: FileContent): Buffer { + return typeof content === 'string' ? Buffer.from(content, 'utf-8') : Buffer.from(content); +} + +function isPathInside(childPath: string, parentPath: string): boolean { + const rel = relative(parentPath, childPath); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +} + +export class LocalFilesystem extends BaseFilesystem { + readonly id: string; + readonly name = 'LocalFilesystem'; + readonly provider = 'local'; + readonly readOnly?: boolean; + readonly basePath: string; + status: ProviderStatus = 'pending'; + + private readonly contained: boolean; + private readonly instructions?: string; + + constructor(options: LocalFilesystemOptions) { + super(); + this.id = options.id ?? `local-fs-${Buffer.from(resolve(options.basePath)).toString('hex')}`; + this.basePath = resolve(options.basePath); + this.contained = options.contained ?? true; + this.readOnly = options.readOnly; + this.instructions = options.instructions; + } + + override async init(): Promise { + await mkdir(this.basePath, { recursive: true }); + } + + async readFile(path: string, options?: ReadOptions): Promise { + await this.ensureReady(); + const content = await readFile(this.resolvePath(path)); + return options?.encoding ? content.toString(options.encoding) : content; + } + + async writeFile(path: string, content: FileContent, options?: WriteOptions): Promise { + await this.ensureReady(); + this.assertWritable(); + const filePath = this.resolvePath(path); + if (options?.recursive) { + await mkdir(dirname(filePath), { recursive: true }); + } + if (options?.overwrite === false && (await this.exists(path))) { + throw new Error(`File already exists: ${path}`); + } + await writeFile(filePath, toBuffer(content)); + } + + async appendFile(path: string, content: FileContent): Promise { + await this.ensureReady(); + this.assertWritable(); + const filePath = this.resolvePath(path); + await writeFile(filePath, toBuffer(content), { flag: 'a' }); + } + + async deleteFile(path: string, options?: RemoveOptions): Promise { + await this.ensureReady(); + this.assertWritable(); + await rm(this.resolvePath(path), { + recursive: options?.recursive ?? false, + force: options?.force ?? false, + }); + } + + async copyFile(src: string, dest: string, options?: CopyOptions): Promise { + await this.ensureReady(); + this.assertWritable(); + const srcPath = this.resolvePath(src); + const destPath = this.resolvePath(dest); + const srcStat = await stat(srcPath); + if (options?.recursive || srcStat.isDirectory()) { + await cp(srcPath, destPath, { + recursive: true, + force: options?.overwrite ?? true, + errorOnExist: options?.overwrite === false, + }); + return; + } + await mkdir(dirname(destPath), { recursive: true }); + if (options?.overwrite === false && (await this.exists(dest))) { + throw new Error(`File already exists: ${dest}`); + } + await copyFile(srcPath, destPath); + } + + async moveFile(src: string, dest: string, options?: CopyOptions): Promise { + await this.ensureReady(); + this.assertWritable(); + const destPath = this.resolvePath(dest); + if (options?.overwrite === false && (await this.exists(dest))) { + throw new Error(`Path already exists: ${dest}`); + } + await mkdir(dirname(destPath), { recursive: true }); + await rename(this.resolvePath(src), destPath); + } + + async mkdir(path: string, options?: { recursive?: boolean }): Promise { + await this.ensureReady(); + this.assertWritable(); + await mkdir(this.resolvePath(path), { recursive: options?.recursive ?? false }); + } + + async rmdir(path: string, options?: RemoveOptions): Promise { + await this.deleteFile(path, { recursive: options?.recursive, force: options?.force }); + } + + async readdir(path: string, options?: ListOptions): Promise { + await this.ensureReady(); + const entries = await this.readDirectory(this.resolvePath(path), options?.recursive ?? false); + const extension = options?.extension + ? options.extension.startsWith('.') + ? options.extension + : `.${options.extension}` + : undefined; + + return entries.filter( + (entry) => !extension || entry.type === 'directory' || entry.name.endsWith(extension), + ); + } + + async exists(path: string): Promise { + await this.ensureReady(); + try { + await access(this.resolvePath(path)); + return true; + } catch { + return false; + } + } + + async stat(path: string): Promise { + await this.ensureReady(); + const filePath = this.resolvePath(path); + const info = await stat(filePath); + return { + name: basename(filePath), + path, + type: info.isDirectory() ? 'directory' : 'file', + size: info.size, + createdAt: info.birthtime, + modifiedAt: info.mtime, + }; + } + + getMountConfig(): { type: 'local'; basePath: string } { + return { type: 'local', basePath: this.basePath }; + } + + getInstructions(): string { + return ( + this.instructions ?? + `Local filesystem rooted at ${this.basePath}. Use paths relative to this directory.` + ); + } + + private resolvePath(inputPath: string): string { + const filePath = isAbsolute(inputPath) + ? resolve(inputPath) + : resolve(join(this.basePath, inputPath)); + if (this.contained && !isPathInside(filePath, this.basePath)) { + throw new Error(`Path escapes local workspace root: ${inputPath}`); + } + return filePath; + } + + private assertWritable(): void { + if (this.readOnly) { + throw new Error(`Filesystem "${this.id}" is read-only`); + } + } + + private async readDirectory(path: string, recursive: boolean): Promise { + const dirents = await readdir(path, { withFileTypes: true }); + const entries: FileEntry[] = []; + for (const dirent of dirents) { + const entryPath = join(path, dirent.name); + const info = await stat(entryPath); + entries.push({ + name: dirent.name, + type: dirent.isDirectory() ? 'directory' : 'file', + size: info.size, + }); + if (recursive && dirent.isDirectory()) { + const nested = await this.readDirectory(entryPath, true); + entries.push( + ...nested.map((entry) => ({ + ...entry, + name: join(dirent.name, entry.name), + })), + ); + } + } + return entries; + } +} diff --git a/packages/@n8n/instance-ai/src/workspace/local-sandbox.ts b/packages/@n8n/instance-ai/src/workspace/local-sandbox.ts new file mode 100644 index 00000000000..e6e7b39dd5d --- /dev/null +++ b/packages/@n8n/instance-ai/src/workspace/local-sandbox.ts @@ -0,0 +1,158 @@ +import type { + CommandResult, + ExecuteCommandOptions, + ProviderStatus, + SandboxInfo, +} from '@n8n/agents'; +import { BaseSandbox } from '@n8n/agents'; +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +export interface LocalSandboxOptions { + id?: string; + workingDirectory?: string; + env?: NodeJS.ProcessEnv; + timeout?: number; + instructions?: string; +} + +function shellEscape(value: string): string { + return /^[A-Za-z0-9_./:=@+-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`; +} + +function toShellCommand(command: string, args: string[] = []): string { + if (args.length === 0) return command; + return [command, ...args.map((arg) => shellEscape(arg))].join(' '); +} + +export class LocalSandbox extends BaseSandbox { + readonly id: string; + readonly name = 'LocalSandbox'; + readonly provider = 'local'; + status: ProviderStatus = 'pending'; + readonly workingDirectory: string; + + private readonly env?: NodeJS.ProcessEnv; + private readonly timeout?: number; + private readonly instructions?: string; + private readonly createdAt = new Date(); + + constructor(options: LocalSandboxOptions = {}) { + super(); + this.id = options.id ?? `local-sandbox-${randomUUID()}`; + this.workingDirectory = resolve(options.workingDirectory ?? './workspace'); + this.env = options.env; + this.timeout = options.timeout; + this.instructions = options.instructions; + } + + override async start(): Promise { + await mkdir(this.workingDirectory, { recursive: true }); + } + + override async stop(): Promise {} + + override async destroy(): Promise {} + + override async executeCommand( + command: string, + args: string[] = [], + options?: ExecuteCommandOptions, + ): Promise { + await this.ensureRunning(); + return await this.runCommand(toShellCommand(command, args), options); + } + + getInfo(): SandboxInfo { + return { + id: this.id, + name: this.name, + provider: this.provider, + status: this.status, + createdAt: this.createdAt, + metadata: { + workingDirectory: this.workingDirectory, + }, + }; + } + + override getInstructions(): string { + return ( + this.instructions ?? + `Local sandbox executing host commands in ${this.workingDirectory}. This provider is for development only.` + ); + } + + private async runCommand( + command: string, + options?: ExecuteCommandOptions, + ): Promise { + const startedAt = Date.now(); + const cwd = options?.cwd ?? this.workingDirectory; + const env: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + ...this.env, + ...options?.env, + }; + + return await new Promise((resolveResult, reject) => { + const child = spawn(command, { + shell: true, + cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let timedOut = false; + let killed = false; + + const timeoutMs = options?.timeout ?? this.timeout; + const timeoutHandle = + timeoutMs === undefined + ? undefined + : setTimeout(() => { + timedOut = true; + killed = child.kill('SIGTERM'); + }, timeoutMs); + + const abort = () => { + killed = child.kill('SIGTERM'); + }; + options?.abortSignal?.addEventListener('abort', abort, { once: true }); + + child.stdout.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf-8'); + stdout += text; + options?.onStdout?.(text); + }); + + child.stderr.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf-8'); + stderr += text; + options?.onStderr?.(text); + }); + + child.on('error', reject); + + child.on('close', (code) => { + if (timeoutHandle) clearTimeout(timeoutHandle); + options?.abortSignal?.removeEventListener('abort', abort); + const exitCode = code ?? (timedOut ? 124 : 1); + resolveResult({ + command, + success: exitCode === 0 && !timedOut, + exitCode, + stdout, + stderr, + executionTimeMs: Date.now() - startedAt, + timedOut, + killed, + }); + }); + }); + } +} diff --git a/packages/@n8n/instance-ai/src/workspace/n8n-sandbox-client.ts b/packages/@n8n/instance-ai/src/workspace/n8n-sandbox-client.ts index b05fed9558a..afecc373ad7 100644 --- a/packages/@n8n/instance-ai/src/workspace/n8n-sandbox-client.ts +++ b/packages/@n8n/instance-ai/src/workspace/n8n-sandbox-client.ts @@ -38,7 +38,7 @@ export interface N8nSandboxFileEntry { modTime: string; } -/** File stat payload mapped into Mastra-friendly shape. */ +/** File stat payload mapped into the native workspace shape. */ export interface N8nSandboxFileStat { name: string; path: string; diff --git a/packages/@n8n/instance-ai/src/workspace/sandbox-fs.ts b/packages/@n8n/instance-ai/src/workspace/sandbox-fs.ts index 40066ee5444..179a062e1e0 100644 --- a/packages/@n8n/instance-ai/src/workspace/sandbox-fs.ts +++ b/packages/@n8n/instance-ai/src/workspace/sandbox-fs.ts @@ -38,8 +38,7 @@ export interface SandboxWorkspace { /** * Execute a shell command in the sandbox and wait for completion. - * Tries `executeCommand` first (auto-generated by MastraSandbox when processes - * are provided), falls back to `processes.spawn` + wait. + * Tries `executeCommand` first, falls back to `processes.spawn` + wait. */ export async function runInSandbox( workspace: SandboxWorkspace,