refactor(instance-ai): use native workspace providers

This commit is contained in:
Oleg Ivaniv 2026-05-05 11:22:33 +02:00
parent 2179ae6479
commit 00b9331dab
No known key found for this signature in database
13 changed files with 734 additions and 183 deletions

View File

@ -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.

View File

@ -890,7 +890,7 @@ export async function startBuildWorkflowAgentTask(
},
model: context.modelId,
tools: tracedBuilderTools,
workspace,
workspace: workspace as never,
memory: shouldUseBuilderMemory ? context.memory : undefined,
});
mergeTraceRunInputs(

View File

@ -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,

View File

@ -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<string, unknown>),
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();
});

View File

@ -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<string, unknown>) {}
}
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<string, unknown>) {}
}
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<T> {
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<string, unknown>,
) => MockWithOpts<Record<string, unknown>>;
type MockDaytonaFilesystemCtor = new (sandbox: unknown) => { sandbox: unknown };
type MockN8nSandboxCtor = new (
opts: Record<string, unknown>,
) => MockWithOpts<Record<string, unknown>>;
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<string, unknown> {
if (!value || typeof value !== 'object') return {};
const options = (value as Record<PropertyKey, unknown>).options;
return options && typeof options === 'object' ? (options as Record<string, unknown>) : {};
}
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<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>).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<typeof createWorkspace>[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<typeof createWorkspace>[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<typeof createWorkspace>[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);
});
});

View File

@ -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<voi
// does not keep stdout/stderr listener closures alive after builder cleanup.
for (const process of processes) {
try {
if (process.running) {
if (process.exitCode === undefined) {
await processManager.kill(process.pid);
} else {
await processManager.get(process.pid);
@ -244,8 +246,8 @@ export class BuilderSandboxFactory {
};
try {
// Wrap raw Sandbox in DaytonaSandbox for Mastra Workspace compatibility.
// DaytonaSandbox.start() reconnects to the existing sandbox by ID.
// Wrap raw Sandbox in the native provider; start() reconnects to
// the existing sandbox by ID.
// Use the same apiKey source as getDaytona() — fresh token in proxy mode, static key in direct mode.
const apiKey = config.getAuthToken ? await config.getAuthToken() : config.daytonaApiKey;
const daytonaSandbox = new DaytonaSandbox({
@ -258,7 +260,7 @@ export class BuilderSandboxFactory {
const workspace = new Workspace({
sandbox: daytonaSandbox,
filesystem: new DaytonaFilesystem(daytonaSandbox) as unknown as LocalFilesystem,
filesystem: new DaytonaFilesystem(daytonaSandbox),
});
await workspace.init();
@ -312,8 +314,8 @@ export class BuilderSandboxFactory {
try {
const workspace = new Workspace({
sandbox: sandbox as never,
filesystem: new N8nSandboxFilesystem(sandbox) as unknown as LocalFilesystem,
sandbox,
filesystem: new N8nSandboxFilesystem(sandbox),
});
await workspace.init();

View File

@ -1,7 +1,9 @@
import { Workspace, LocalFilesystem, LocalSandbox } from '@mastra/core/workspace';
import { DaytonaSandbox } from '@mastra/daytona';
import { Workspace } from '@n8n/agents';
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';
@ -116,13 +118,13 @@ export function createWorkspace(
if (sandbox instanceof N8nSandboxServiceSandbox) {
return new Workspace({
sandbox: sandbox as never,
filesystem: new N8nSandboxFilesystem(sandbox) as unknown as LocalFilesystem,
sandbox,
filesystem: new N8nSandboxFilesystem(sandbox),
});
}
return new Workspace({
sandbox,
filesystem: new DaytonaFilesystem(sandbox) as unknown as LocalFilesystem,
filesystem: new DaytonaFilesystem(sandbox),
});
}

View File

@ -20,7 +20,8 @@ import type {
ProviderStatus,
} from '@n8n/agents';
import { BaseFilesystem } from '@n8n/agents';
import type { DaytonaSandbox } from '@mastra/daytona';
import type { DaytonaSandbox } from './daytona-sandbox';
/**
* A native agents filesystem implementation that delegates to the Daytona SDK's

View File

@ -0,0 +1,281 @@
import { Daytona, DaytonaError, DaytonaNotFoundError, SandboxState } from '@daytonaio/sdk';
import type {
CreateSandboxBaseParams,
CreateSandboxFromImageParams,
CreateSandboxFromSnapshotParams,
DaytonaConfig,
Resources,
Sandbox,
VolumeMount,
} from '@daytonaio/sdk';
import type {
CommandResult,
ExecuteCommandOptions,
ProviderStatus,
SandboxInfo,
} from '@n8n/agents';
import { BaseSandbox } from '@n8n/agents';
import { randomUUID } from 'node:crypto';
export interface DaytonaSandboxOptions {
id?: string;
apiKey?: string;
apiUrl?: string;
target?: string;
timeout?: number;
language?: 'typescript' | 'javascript' | 'python';
resources?: Resources;
env?: Record<string, string>;
labels?: Record<string, string>;
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>([
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<void> {
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<void> {
if (!this.sandbox) return;
await this.sandbox.stop(Math.ceil(this.timeout / 1000));
this.sandbox = undefined;
}
override async destroy(): Promise<void> {
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<CommandResult> {
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<Sandbox | null> {
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<void> {
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<string, string> | 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;
}
}

View File

@ -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<void> {
await mkdir(this.basePath, { recursive: true });
}
async readFile(path: string, options?: ReadOptions): Promise<string | Buffer> {
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<void> {
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<void> {
await this.ensureReady();
this.assertWritable();
const filePath = this.resolvePath(path);
await writeFile(filePath, toBuffer(content), { flag: 'a' });
}
async deleteFile(path: string, options?: RemoveOptions): Promise<void> {
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<void> {
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<void> {
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<void> {
await this.ensureReady();
this.assertWritable();
await mkdir(this.resolvePath(path), { recursive: options?.recursive ?? false });
}
async rmdir(path: string, options?: RemoveOptions): Promise<void> {
await this.deleteFile(path, { recursive: options?.recursive, force: options?.force });
}
async readdir(path: string, options?: ListOptions): Promise<FileEntry[]> {
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<boolean> {
await this.ensureReady();
try {
await access(this.resolvePath(path));
return true;
} catch {
return false;
}
}
async stat(path: string): Promise<FileStat> {
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<FileEntry[]> {
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;
}
}

View File

@ -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<void> {
await mkdir(this.workingDirectory, { recursive: true });
}
override async stop(): Promise<void> {}
override async destroy(): Promise<void> {}
override async executeCommand(
command: string,
args: string[] = [],
options?: ExecuteCommandOptions,
): Promise<CommandResult> {
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<CommandResult> {
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<CommandResult>((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,
});
});
});
}
}

View File

@ -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;

View File

@ -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,