diff --git a/.env.example b/.env.example index 533ee3a..49cb2be 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,4 @@ CEREBRAS_API_KEY= MISTRAL_API_KEY= DAYTONA_API_KEY= E2B_API_KEY= +LEAP0_API_KEY= diff --git a/README.md b/README.md index c36676c..ad8fac2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This template demonstrates how to build an AI coding assistant that can work wit ## Prerequisites - Node.js 22.13.0 or later -- API key for your chosen sandbox provider ([Daytona](https://www.daytona.io/) or [E2B](https://e2b.dev)) +- API key for your chosen sandbox provider ([Daytona](https://www.daytona.io/), [E2B](https://e2b.dev), or [Leap0](https://leap0.dev)) - API key for your chosen model provider ## Setup @@ -48,6 +48,8 @@ This template demonstrates how to build an AI coding assistant that can work wit # Option 2: Use E2B (set E2B_API_KEY) # E2B_API_KEY="your-e2b-api-key-here" + # Option 3: Use Leap0 (set LEAP0_API_KEY; see https://github.com/leap0-dev/leap0-js) + # LEAP0_API_KEY="your-leap0-api-key-here" # Model provider (required) OPENAI_API_KEY="your-openai-api-key-here" @@ -94,7 +96,7 @@ Complete toolkit for sandbox interaction with support for multiple providers: **Provider Selection:** -- Automatically uses **Daytona** or **E2B** based on which API key you set +- Automatically uses **Daytona**, **E2B**, or **Leap0** based on which API key you set **Sandbox Management:** @@ -149,6 +151,9 @@ DAYTONA_API_KEY=your_daytona_api_key_here # Option 2: E2B E2B_API_KEY=your_e2b_api_key_here +# Option 3: Leap0 +LEAP0_API_KEY=your_leap0_api_key_here + ``` > [!Note] @@ -179,10 +184,10 @@ export const codingAgent = new Agent({ ## Common Issues -### "Please set either DAYTONA_API_KEY or E2B_API_KEY environment variable" +### "Please set either DAYTONA_API_KEY or E2B_API_KEY or LEAP0_API_KEY environment variable" - You need to configure a sandbox provider by setting one of the API keys -- Add either `DAYTONA_API_KEY` or `E2B_API_KEY` to your `.env` file +- Add either `DAYTONA_API_KEY` or `E2B_API_KEY` or `LEAP0_API_KEY` to your `.env` file - Only set ONE provider API key (not both) - Restart the development server after adding the key @@ -222,5 +227,8 @@ src/mastra/ daytona/ tools.ts # Daytona sandbox implementation utils.ts # Daytona helper functions + leap0/ + tools.ts # Leap0 sandbox implementation (leap0-js) + utils.ts # Leap0 client and path helpers index.ts # Mastra configuration with storage and logging ``` diff --git a/package.json b/package.json index defd095..b953547 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@mastra/mcp": "latest", "@mastra/memory": "latest", "@mastra/observability": "latest", + "leap0": "^0.5.0", "supports-color": "^10.2.2", "zod": "^3.25.76" }, diff --git a/src/mastra/tools/index.ts b/src/mastra/tools/index.ts index 631f1fe..e8ecefa 100644 --- a/src/mastra/tools/index.ts +++ b/src/mastra/tools/index.ts @@ -1,34 +1,63 @@ import * as e2bTools from './e2b'; import * as daytonaTools from './daytona/tools'; +import * as leap0Tools from './leap0/tools'; -function getProvider() { +type SandboxProvider = 'daytona' | 'e2b' | 'leap0'; + +function getProvider(): SandboxProvider { if (process.env.DAYTONA_API_KEY) { return 'daytona'; - } else if (process.env.E2B_API_KEY) { + } + if (process.env.E2B_API_KEY) { return 'e2b'; - } else { - throw new Error( - 'No sandbox provider configured. Please set either DAYTONA_API_KEY or E2B_API_KEY environment variable.', - ); } + if (process.env.LEAP0_API_KEY) { + return 'leap0'; + } + throw new Error( + 'No sandbox provider configured. Please set DAYTONA_API_KEY, E2B_API_KEY, or LEAP0_API_KEY environment variable.', + ); } const provider = getProvider(); -// Helper function to select the right tool (bundler can inline this) -// Using 'as any' because E2B and Daytona have slightly different schemas -const selectTool = (daytonaTool: any, e2bTool: any) => (provider === 'daytona' ? daytonaTool : e2bTool); +// Using 'as any' because providers have slightly different output schemas for some tools. +const pickTool = (daytonaTool: any, e2bTool: any, leap0Tool: any) => { + if (provider === 'daytona') { + return daytonaTool; + } + if (provider === 'e2b') { + return e2bTool; + } + return leap0Tool; +}; -export const createSandbox = selectTool(daytonaTools.createSandbox, e2bTools.createSandbox); -export const runCode = selectTool(daytonaTools.runCode, e2bTools.runCode); -export const readFile = selectTool(daytonaTools.readFile, e2bTools.readFile); -export const writeFile = selectTool(daytonaTools.writeFile, e2bTools.writeFile); -export const writeFiles = selectTool(daytonaTools.writeFiles, e2bTools.writeFiles); -export const listFiles = selectTool(daytonaTools.listFiles, e2bTools.listFiles); -export const deleteFile = selectTool(daytonaTools.deleteFile, e2bTools.deleteFile); -export const createDirectory = selectTool(daytonaTools.createDirectory, e2bTools.createDirectory); -export const getFileInfo = selectTool(daytonaTools.getFileInfo, e2bTools.getFileInfo); -export const checkFileExists = selectTool(daytonaTools.checkFileExists, e2bTools.checkFileExists); -export const getFileSize = selectTool(daytonaTools.getFileSize, e2bTools.getFileSize); -export const watchDirectory = selectTool(daytonaTools.watchDirectory, e2bTools.watchDirectory); -export const runCommand = selectTool(daytonaTools.runCommand, e2bTools.runCommand); +export const createSandbox = pickTool( + daytonaTools.createSandbox, + e2bTools.createSandbox, + leap0Tools.createSandbox, +); +export const runCode = pickTool(daytonaTools.runCode, e2bTools.runCode, leap0Tools.runCode); +export const readFile = pickTool(daytonaTools.readFile, e2bTools.readFile, leap0Tools.readFile); +export const writeFile = pickTool(daytonaTools.writeFile, e2bTools.writeFile, leap0Tools.writeFile); +export const writeFiles = pickTool(daytonaTools.writeFiles, e2bTools.writeFiles, leap0Tools.writeFiles); +export const listFiles = pickTool(daytonaTools.listFiles, e2bTools.listFiles, leap0Tools.listFiles); +export const deleteFile = pickTool(daytonaTools.deleteFile, e2bTools.deleteFile, leap0Tools.deleteFile); +export const createDirectory = pickTool( + daytonaTools.createDirectory, + e2bTools.createDirectory, + leap0Tools.createDirectory, +); +export const getFileInfo = pickTool(daytonaTools.getFileInfo, e2bTools.getFileInfo, leap0Tools.getFileInfo); +export const checkFileExists = pickTool( + daytonaTools.checkFileExists, + e2bTools.checkFileExists, + leap0Tools.checkFileExists, +); +export const getFileSize = pickTool(daytonaTools.getFileSize, e2bTools.getFileSize, leap0Tools.getFileSize); +export const watchDirectory = pickTool( + daytonaTools.watchDirectory, + e2bTools.watchDirectory, + leap0Tools.watchDirectory, +); +export const runCommand = pickTool(daytonaTools.runCommand, e2bTools.runCommand, leap0Tools.runCommand); diff --git a/src/mastra/tools/leap0/tools.ts b/src/mastra/tools/leap0/tools.ts new file mode 100644 index 0000000..60da92d --- /dev/null +++ b/src/mastra/tools/leap0/tools.ts @@ -0,0 +1,635 @@ +import { createTool } from '@mastra/core/tools'; +import z from 'zod'; +import { + CodeLanguage, + DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME, + Leap0NotFoundError, +} from 'leap0'; +import { + getLeap0Client, + getSandboxAndNormalizedListPath, + getSandboxWithWorkdir, + normalizeSandboxPath, +} from './utils'; + +/** Leap0 sandbox idle timeout: seconds, SDK allows 1–28800. */ +const SANDBOX_IDLE_TIMEOUT_SEC_MIN = 1; +const SANDBOX_IDLE_TIMEOUT_SEC_MAX = 28_800; + +function sandboxIdleTimeoutSecondsFromMs(ms: number): number { + const seconds = Math.ceil(ms / 1000); + return Math.min(SANDBOX_IDLE_TIMEOUT_SEC_MAX, Math.max(SANDBOX_IDLE_TIMEOUT_SEC_MIN, seconds)); +} + +function timeoutMsToSeconds(timeoutMs: number | undefined): number | undefined { + if (timeoutMs === undefined) { + return undefined; + } + return Math.max(1, Math.ceil(timeoutMs / 1000)); +} + +function serializeCodeResult(payload: unknown): string { + return JSON.stringify(payload); +} + +function toError(e: unknown): { error: string } { + if (e instanceof Error) { + return { error: e.message }; + } + try { + return { error: typeof e === 'string' ? e : JSON.stringify(e) ?? String(e) }; + } catch { + return { error: String(e) }; + } +} + +function formatBytes(bytes: number): string { + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + if (bytes === 0) { + return '0 B'; + } + const rawIndex = Math.floor(Math.log(bytes) / Math.log(1024)); + const i = Math.min(rawIndex, sizes.length - 1); + const size = (bytes / Math.pow(1024, i)).toFixed(1); + return `${size} ${sizes[i]}`; +} + +export const createSandbox = createTool({ + id: 'createSandbox', + description: 'Create a sandbox', + inputSchema: z.object({ + metadata: z.record(z.string()).optional().describe('Custom metadata (ignored for Leap0; reserved for cross-provider parity)'), + envs: z.record(z.string()).optional().describe(` + Custom environment variables for the sandbox. + Used when executing commands and code in the sandbox. + `), + timeoutMS: z.number().optional().describe(` + Timeout for the sandbox in **milliseconds** (converted to seconds for Leap0; clamped to 1–28800s per SDK). + @default 300_000 // 5 minutes (300s) + `), + memoryMiB: z + .number() + .int() + .min(512) + .max(8192) + .refine((n) => n % 2 === 0, { message: 'memoryMiB must be even (Leap0 requirement)' }) + .optional() + .describe('Sandbox memory in **MiB** (Leap0: even integer 512–8192). Omit for SDK default (1024).'), + vcpu: z + .number() + .int() + .min(1) + .max(8) + .optional() + .describe('Sandbox vCPUs (Leap0: 1–8). Omit for SDK default (1).'), + }), + outputSchema: z + .object({ + sandboxId: z.string(), + }) + .or( + z.object({ + error: z.string(), + }), + ), + execute: async ({ envs, timeoutMS, memoryMiB, vcpu }) => { + try { + const client = getLeap0Client(); + const timeout = sandboxIdleTimeoutSecondsFromMs(timeoutMS ?? 300_000); + const sandbox = await client.sandboxes.create({ + templateName: DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME, + envVars: envs, + timeout, + ...(memoryMiB !== undefined ? { memory: memoryMiB } : {}), + ...(vcpu !== undefined ? { vcpu } : {}), + }); + return { sandboxId: sandbox.id }; + } catch (e) { + return toError(e); + } + }, +}); + +export const runCode = createTool({ + id: 'runCode', + description: 'Run code in a sandbox', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to run the code'), + code: z.string().describe('The code to run in the sandbox'), + runCodeOpts: z + .object({ + language: z + .enum(['ts', 'js', 'python']) + .default('python') + .describe('Language used for code execution. Leap0 code interpreter supports python and typescript; javascript runs via Node.'), + envs: z.record(z.string()).optional().describe('Custom environment variables for code execution.'), + timeoutMS: z.number().optional().describe('Timeout for the code execution in **milliseconds**.'), + requestTimeoutMs: z.number().optional().describe('Unused for Leap0.'), + contextId: z + .string() + .optional() + .describe( + 'Existing Leap0 code interpreter context id (python/typescript only). When set, execution reuses that context and it is not deleted when the tool returns. When omitted, a one-shot context is removed after execution.', + ), + }) + .optional() + .describe('Run code options'), + }), + outputSchema: z + .object({ + execution: z.string().describe('Serialized representation of the execution results'), + contextId: z + .string() + .optional() + .describe( + 'Code interpreter context id (python/typescript only); omitted when code runs via Node. Present for correlation; ephemeral runs delete the context after the tool returns.', + ), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed execution'), + }), + ), + execute: async ({ sandboxId, code, runCodeOpts }) => { + try { + const { sandbox, workspaceRoot } = await getSandboxWithWorkdir(sandboxId); + const opts = runCodeOpts ?? {}; + const language = opts.language ?? 'python'; + const timeoutMs = opts.timeoutMS; + const envVars = opts.envs; + + if (language === 'js') { + const tmpPath = normalizeSandboxPath( + `.mastra-js-${Date.now()}-${Math.random().toString(36).slice(2, 10)}.js`, + workspaceRoot, + ); + await sandbox.filesystem.writeFile(tmpPath, code); + try { + const proc = await sandbox.process.execute({ + command: `node ${tmpPath}`, + timeout: timeoutMsToSeconds(timeoutMs), + env: envVars, + }); + return { + execution: serializeCodeResult({ + language: 'js', + exitCode: proc.exitCode, + stdout: proc.stdout, + stderr: proc.stderr, + }), + }; + } finally { + await sandbox.filesystem.delete({ path: tmpPath, recursive: false }).catch(() => {}); + } + } + + const leapLang = + language === 'python' ? CodeLanguage.PYTHON : CodeLanguage.TYPESCRIPT; + + const reuseContextId = opts.contextId; + const execution = await sandbox.codeInterpreter.execute({ + code, + language: leapLang, + envVars, + timeoutMs, + contextId: reuseContextId, + }); + + try { + return { + execution: serializeCodeResult(execution), + contextId: execution.contextId, + }; + } finally { + if (reuseContextId === undefined) { + await sandbox.codeInterpreter.deleteContext(execution.contextId).catch(() => {}); + } + } + } catch (e) { + return toError(e); + } + }, +}); + +export const readFile = createTool({ + id: 'readFile', + description: 'Read a file from the sandbox', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to read the file from'), + path: z.string().describe('The path to the file to read'), + }), + outputSchema: z + .object({ + content: z.string().describe('The content of the file'), + path: z.string().describe('The path of the file that was read'), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed file read'), + }), + ), + execute: async ({ sandboxId, path }) => { + try { + const { sandbox, workspaceRoot } = await getSandboxWithWorkdir(sandboxId); + const normalizedPath = normalizeSandboxPath(path, workspaceRoot); + const content = await sandbox.filesystem.readFile(normalizedPath); + return { content, path: normalizedPath }; + } catch (e) { + return toError(e); + } + }, +}); + +export const writeFile = createTool({ + id: 'writeFile', + description: 'Write a single file to the sandbox', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to write the file to'), + path: z.string().describe('The path where the file should be written'), + content: z.string().describe('The content to write to the file'), + }), + outputSchema: z + .object({ + success: z.boolean().describe('Whether the file was written successfully'), + path: z.string().describe('The path where the file was written'), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed file write'), + }), + ), + execute: async ({ sandboxId, path, content }) => { + try { + const { sandbox, workspaceRoot } = await getSandboxWithWorkdir(sandboxId); + const normalizedPath = normalizeSandboxPath(path, workspaceRoot); + await sandbox.filesystem.writeFile(normalizedPath, content); + return { success: true, path: normalizedPath }; + } catch (e) { + return toError(e); + } + }, +}); + +export const writeFiles = createTool({ + id: 'writeFiles', + description: 'Write multiple files to the sandbox', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to write the files to'), + files: z + .array( + z.object({ + path: z.string().describe('The path where the file should be written'), + data: z.string().describe('The content to write to the file'), + }), + ) + .describe('Array of files to write, each with path and data'), + }), + outputSchema: z + .object({ + success: z.boolean().describe('Whether all files were written successfully'), + filesWritten: z.array(z.string()).describe('Array of file paths that were written'), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed files write'), + }), + ), + execute: async ({ sandboxId, files }) => { + try { + const { sandbox, workspaceRoot } = await getSandboxWithWorkdir(sandboxId); + const record: Record = {}; + const written: string[] = []; + for (const file of files) { + const p = normalizeSandboxPath(file.path, workspaceRoot); + record[p] = file.data; + written.push(p); + } + await sandbox.filesystem.writeFiles(record); + return { success: true, filesWritten: written }; + } catch (e) { + return toError(e); + } + }, +}); + +export const listFiles = createTool({ + id: 'listFiles', + description: 'List files and directories in a path within the sandbox', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to list files from'), + path: z + .string() + .optional() + .describe( + 'Directory to list. Omit to list the sandbox workdir (Leap0 getWorkdir, often under /home/user). Use "/" for filesystem root. Relative paths resolve from the workdir.', + ), + }), + outputSchema: z + .object({ + files: z + .array( + z.object({ + name: z.string().describe('The name of the file or directory'), + path: z.string().describe('The full path of the file or directory'), + isDirectory: z.boolean().describe('Whether this is a directory'), + }), + ) + .describe('Array of files and directories'), + path: z.string().describe('The path that was listed'), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed file listing'), + }), + ), + execute: async ({ sandboxId, path }) => { + try { + const { sandbox, normalizedPath } = await getSandboxAndNormalizedListPath(sandboxId, path); + const listing = await sandbox.filesystem.ls(normalizedPath, { recursive: false }); + return { + files: listing.items.map((item) => ({ + name: item.name, + path: item.path, + isDirectory: item.isDir, + })), + path: normalizedPath, + }; + } catch (e) { + return toError(e); + } + }, +}); + +export const deleteFile = createTool({ + id: 'deleteFile', + description: 'Delete a file or directory from the sandbox', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to delete the file from'), + path: z.string().describe('The path to the file or directory to delete'), + }), + outputSchema: z + .object({ + success: z.boolean().describe('Whether the file was deleted successfully'), + path: z.string().describe('The path that was deleted'), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed file deletion'), + }), + ), + execute: async ({ sandboxId, path }) => { + try { + const { sandbox, workspaceRoot } = await getSandboxWithWorkdir(sandboxId); + const normalizedPath = normalizeSandboxPath(path, workspaceRoot); + const info = await sandbox.filesystem.stat(normalizedPath); + await sandbox.filesystem.delete({ path: normalizedPath, recursive: info.isDir }); + return { success: true, path: normalizedPath }; + } catch (e) { + return toError(e); + } + }, +}); + +export const createDirectory = createTool({ + id: 'createDirectory', + description: 'Create a directory in the sandbox', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to create the directory in'), + path: z.string().describe('The path where the directory should be created'), + }), + outputSchema: z + .object({ + success: z.boolean().describe('Whether the directory was created successfully'), + path: z.string().describe('The path where the directory was created'), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed directory creation'), + }), + ), + execute: async ({ sandboxId, path }) => { + try { + const { sandbox, workspaceRoot } = await getSandboxWithWorkdir(sandboxId); + const normalizedPath = normalizeSandboxPath(path, workspaceRoot); + await sandbox.filesystem.mkdir(normalizedPath, { recursive: true }); + return { success: true, path: normalizedPath }; + } catch (e) { + return toError(e); + } + }, +}); + +export const getFileInfo = createTool({ + id: 'getFileInfo', + description: 'Get detailed information about a file or directory in the sandbox', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to get file information from'), + path: z.string().describe('The path to the file or directory to get information about'), + }), + outputSchema: z + .object({ + name: z.string().describe('The name of the file or directory'), + path: z.string().describe('The full path of the file or directory'), + isDirectory: z.boolean().describe('Whether this is a directory'), + size: z.number().describe('The size of the file or directory in bytes'), + mode: z.string().describe('The file mode / permissions string from the sandbox'), + owner: z.string().describe('The owner of the file or directory'), + group: z.string().describe('The group of the file or directory'), + modifiedTimeMs: z.number().describe('Last modified time as Unix ms (from sandbox mtime)'), + symlinkTarget: z.string().optional().describe('Symlink target when applicable'), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed file info request'), + }), + ), + execute: async ({ sandboxId, path }) => { + try { + const { sandbox, workspaceRoot } = await getSandboxWithWorkdir(sandboxId); + const normalizedPath = normalizeSandboxPath(path, workspaceRoot); + const info = await sandbox.filesystem.stat(normalizedPath); + return { + name: info.name, + path: info.path, + isDirectory: info.isDir, + size: info.size, + mode: info.mode, + owner: info.owner, + group: info.group, + modifiedTimeMs: info.mtime * 1000, + symlinkTarget: info.linkTarget, + }; + } catch (e) { + return toError(e); + } + }, +}); + +export const checkFileExists = createTool({ + id: 'checkFileExists', + description: 'Check if a file or directory exists in the sandbox', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to check file existence in'), + path: z.string().describe('The path to check for existence'), + }), + outputSchema: z + .object({ + exists: z.boolean().describe('Whether the file or directory exists'), + path: z.string().describe('The path that was checked'), + isDirectory: z.boolean().optional().describe('If the path exists, whether it is a directory'), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed existence check'), + }), + ), + execute: async ({ sandboxId, path }) => { + let normalizedPath = path; + try { + const { sandbox, workspaceRoot } = await getSandboxWithWorkdir(sandboxId); + normalizedPath = normalizeSandboxPath(path, workspaceRoot); + const exists = await sandbox.filesystem.exists(normalizedPath); + if (!exists) { + return { exists: false, path: normalizedPath }; + } + const info = await sandbox.filesystem.stat(normalizedPath); + return { exists: true, path: normalizedPath, isDirectory: info.isDir }; + } catch (e) { + if (e instanceof Leap0NotFoundError) { + return { exists: false, path: normalizedPath }; + } + return toError(e); + } + }, +}); + +export const getFileSize = createTool({ + id: 'getFileSize', + description: 'Get the size of a file or directory in the sandbox', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to get file size from'), + path: z.string().describe('The path to the file or directory'), + humanReadable: z + .boolean() + .default(false) + .describe("Whether to return size in human-readable format (e.g., '1.5 KB', '2.3 MB')"), + }), + outputSchema: z + .object({ + size: z.number().describe('The size in bytes'), + humanReadableSize: z.string().optional().describe('Human-readable size string if requested'), + path: z.string().describe('The path that was checked'), + isDirectory: z.boolean().describe('Whether this is a directory'), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed size check'), + }), + ), + execute: async ({ sandboxId, path, humanReadable }) => { + try { + const { sandbox, workspaceRoot } = await getSandboxWithWorkdir(sandboxId); + const normalizedPath = normalizeSandboxPath(path, workspaceRoot); + const info = await sandbox.filesystem.stat(normalizedPath); + const humanReadableSize = humanReadable ? formatBytes(info.size) : undefined; + return { + size: info.size, + humanReadableSize, + path: normalizedPath, + isDirectory: info.isDir, + }; + } catch (e) { + return toError(e); + } + }, +}); + +export const watchDirectory = createTool({ + id: 'watchDirectory', + description: + '⚠️ NOT SUPPORTED - Directory watching is not exposed by the Leap0 JS SDK in this integration. Do not rely on this tool.', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to watch directory in'), + path: z.string().describe('The directory path to watch for changes'), + recursive: z.boolean().default(false).describe('Whether to watch subdirectories recursively'), + watchDuration: z + .number() + .default(30000) + .describe('How long to watch for changes in milliseconds (default 30 seconds)'), + }), + outputSchema: z + .object({ + watchStarted: z.boolean().describe('Whether the watch was started successfully'), + path: z.string().describe('The path that was watched'), + events: z + .array( + z.object({ + type: z.string().describe('The type of filesystem event'), + name: z.string().describe('The name of the file that changed'), + timestamp: z.string().describe('When the event occurred'), + }), + ) + .describe('Array of filesystem events that occurred during the watch period'), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed directory watch'), + }), + ), + execute: async () => ({ + error: 'Directory watching is not supported for the Leap0 sandbox provider.', + }), +}); + +export const runCommand = createTool({ + id: 'runCommand', + description: 'Run a shell command in the sandbox', + inputSchema: z.object({ + sandboxId: z.string().describe('The sandboxId for the sandbox to run the command in'), + command: z.string().describe('The shell command to execute'), + envs: z.record(z.string()).optional().describe('Environment variables to set for the command'), + workingDirectory: z.string().optional().describe('The working directory to run the command in'), + timeoutMs: z.number().default(30000).describe('Timeout for the command execution in milliseconds'), + captureOutput: z.boolean().default(true).describe('Whether to capture stdout and stderr output'), + }), + outputSchema: z + .object({ + success: z.boolean().describe('Whether the command executed successfully'), + exitCode: z.number().describe('The exit code of the command'), + stdout: z.string().describe('The standard output from the command'), + stderr: z.string().describe('The standard error from the command'), + command: z.string().describe('The command that was executed'), + executionTime: z.number().describe('How long the command took to execute in milliseconds'), + }) + .or( + z.object({ + error: z.string().describe('The error from a failed command execution'), + }), + ), + execute: async ({ sandboxId, command, envs, workingDirectory, timeoutMs, captureOutput }) => { + try { + const { sandbox, workspaceRoot } = await getSandboxWithWorkdir(sandboxId); + const startTime = Date.now(); + const timeoutSeconds = timeoutMsToSeconds(timeoutMs ?? 30000); + const cwd = workingDirectory ? normalizeSandboxPath(workingDirectory, workspaceRoot) : undefined; + const result = await sandbox.process.execute({ + command, + cwd, + timeout: timeoutSeconds, + env: envs, + }); + const executionTime = Date.now() - startTime; + const capture = captureOutput ?? true; + return { + success: result.exitCode === 0, + exitCode: result.exitCode, + stdout: capture ? result.stdout : '', + stderr: capture ? result.stderr : '', + command, + executionTime, + }; + } catch (e) { + return toError(e); + } + }, +}); diff --git a/src/mastra/tools/leap0/utils.ts b/src/mastra/tools/leap0/utils.ts new file mode 100644 index 0000000..b8785dd --- /dev/null +++ b/src/mastra/tools/leap0/utils.ts @@ -0,0 +1,73 @@ +import path from 'node:path'; + +import { Leap0Client, Sandbox } from 'leap0'; + +let clientInstance: Leap0Client | null = null; + +export const getLeap0Client = (): Leap0Client => { + if (!clientInstance) { + clientInstance = new Leap0Client(); + } + return clientInstance; +}; + +export const getSandboxById = async (sandboxId: string): Promise => { + const client = getLeap0Client(); + return client.sandboxes.get(sandboxId); +}; + +/** Fetches the sandbox and its configured workdir (from the Leap0 API). */ +export const getSandboxWithWorkdir = async ( + sandboxId: string, +): Promise<{ sandbox: Sandbox; workspaceRoot: string }> => { + const sandbox = await getSandboxById(sandboxId); + const workspaceRoot = path.posix.resolve(await sandbox.getWorkdir()); + return { sandbox, workspaceRoot }; +}; + +export const normalizeSandboxPath = (inputPath: string, workspaceRoot: string): string => { + const workspaceRootResolved = path.posix.resolve(workspaceRoot); + + const isUnderWorkspace = (resolved: string): boolean => + resolved === workspaceRootResolved || resolved.startsWith(`${workspaceRootResolved}/`); + + const trimmed = inputPath.trim(); + if (trimmed === '' || trimmed === '/') { + return workspaceRootResolved; + } + + const resolved = trimmed.startsWith('/') + ? path.posix.resolve(trimmed) + : path.posix.resolve(workspaceRootResolved, trimmed); + + if (!isUnderWorkspace(resolved)) { + throw new Error(`Path is outside workspace (${workspaceRootResolved}): ${inputPath}`); + } + + return resolved; +}; + +/** + * List path resolution: omitted or empty path lists the configured workdir (`getWorkdir`). + * `"/"` lists the sandbox filesystem root. Other absolute paths are POSIX-resolved without + * an extra `getWorkdir` when not needed. Relative paths resolve under the workdir. + */ +export const getSandboxAndNormalizedListPath = async ( + sandboxId: string, + rawPath: string | undefined, +): Promise<{ sandbox: Sandbox; normalizedPath: string }> => { + const sandbox = await getSandboxById(sandboxId); + if (rawPath === undefined || rawPath.trim() === '') { + const workspaceRoot = path.posix.resolve(await sandbox.getWorkdir()); + return { sandbox, normalizedPath: workspaceRoot }; + } + const trimmed = rawPath.trim(); + if (trimmed === '/') { + return { sandbox, normalizedPath: '/' }; + } + if (trimmed.startsWith('/')) { + return { sandbox, normalizedPath: path.posix.resolve(trimmed) }; + } + const workspaceRoot = path.posix.resolve(await sandbox.getWorkdir()); + return { sandbox, normalizedPath: normalizeSandboxPath(trimmed, workspaceRoot) }; +};