mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
refactor(instance-ai): use native workspace providers
This commit is contained in:
parent
2179ae6479
commit
00b9331dab
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -890,7 +890,7 @@ export async function startBuildWorkflowAgentTask(
|
|||
},
|
||||
model: context.modelId,
|
||||
tools: tracedBuilderTools,
|
||||
workspace,
|
||||
workspace: workspace as never,
|
||||
memory: shouldUseBuilderMemory ? context.memory : undefined,
|
||||
});
|
||||
mergeTraceRunInputs(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
281
packages/@n8n/instance-ai/src/workspace/daytona-sandbox.ts
Normal file
281
packages/@n8n/instance-ai/src/workspace/daytona-sandbox.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
233
packages/@n8n/instance-ai/src/workspace/local-filesystem.ts
Normal file
233
packages/@n8n/instance-ai/src/workspace/local-filesystem.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
158
packages/@n8n/instance-ai/src/workspace/local-sandbox.ts
Normal file
158
packages/@n8n/instance-ai/src/workspace/local-sandbox.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user