UNPKG

@grasplabs/grasp

Version:

TypeScript SDK for browser automation and secure command execution in highly available and scalable cloud browser environments

712 lines 26.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SandboxService = exports.CommandEventEmitter = void 0; const e2b_1 = require("e2b"); const fs_1 = require("fs"); const promises_1 = require("timers/promises"); const events_1 = require("events"); const node_path_1 = __importDefault(require("node:path")); const logger_1 = require("../utils/logger"); const config_1 = require("../utils/config"); const auth_1 = require("../utils/auth"); /** * Extended EventEmitter for background command execution * Emits 'stdout', 'stderr', and 'exit' events */ class CommandEventEmitter extends events_1.EventEmitter { constructor(handle = null) { super(); this.isKilled = false; this.handle = handle; this.on('error', () => { }); // ignore default error } /** * Set the command handle (used internally) * @param handle - Command handle to set */ setHandle(handle) { this.handle = handle; if (this.isKilled) { this.handle.kill(); } } /** * Wait for command to complete * @returns Promise with command result */ async wait() { do { await (0, promises_1.setTimeout)(100); } while (!this.handle); try { return this.handle.wait(); } catch (ex) { // console.log(ex.result); return ex.result; } } /** * Kill the running command */ async kill() { this.isKilled = true; if (this.handle) { await this.handle.kill(); } } /** * Get the original command handle */ getHandle() { return this.handle; } } exports.CommandEventEmitter = CommandEventEmitter; /** * E2B Sandbox service for managing sandbox lifecycle and operations */ class SandboxService { /** * Gets or creates a default logger instance * @returns Logger instance */ getDefaultLogger() { try { return (0, logger_1.getLogger)().child('SandboxService'); } catch (error) { // If logger is not initialized, create a default one const defaultLogger = new logger_1.Logger({ level: this.config.debug ? 'debug' : 'info', console: true, }); return defaultLogger.child('SandboxService'); } } constructor(config) { this.sandbox = null; this.status = 'stopped'; this.config = config; this.logger = this.getDefaultLogger(); } get id() { return this.sandbox?.sandboxId; } get workspace() { return this.config.key.slice(3, 19); } get isDebug() { return !!this.config.debug; } get timeout() { return this.config.timeout; } async connectSandbox(sandboxId) { try { this.status = 'creating'; this.logger.info(`Connection Grasp sandbox: ${sandboxId}`); const res = await (0, auth_1.verify)(this.config); if (!res.success) { throw new Error('Authorization failed.'); } const apiKey = res.data.token; this.sandbox = await e2b_1.Sandbox.connect(sandboxId, { apiKey, }); this.status = 'running'; const config = await this.sandbox.files.read('/home/user/.grasp-config.json'); const timeout = this.config.timeout; this.config = JSON.parse(config); if (timeout > 0 && timeout !== this.config.timeout) { this.sandbox.setTimeout(timeout); } this.logger.info('Grasp sandbox connected', { sandboxId: this.sandbox.sandboxId, }); } catch (error) { this.status = 'error'; this.logger.error('Failed to create Grasp sandbox', error); throw new Error(`Failed to create sandbox: ${error}`); } } /** * Creates and starts a new sandbox * @returns Promise that resolves when sandbox is ready * @throws {Error} If sandbox creation fails */ async createSandbox(templateId, envs = {}) { try { this.status = 'creating'; const res = await (0, auth_1.verify)(this.config); if (!res.success) { throw new Error('Authorization failed.'); } const apiKey = res.data.token; this.logger.info(`Creating Grasp sandbox: ${templateId}`); this.sandbox = await e2b_1.Sandbox.create(templateId, { apiKey, timeoutMs: this.config.timeout, envs, }); // console.log('timeoutMS', this.config.timeout); await this.sandbox.files.write('/home/user/.grasp-config.json', JSON.stringify({ id: this.sandbox.sandboxId, ...this.config, })); this.status = 'running'; this.logger.info('Grasp sandbox created successfully', { sandboxId: this.sandbox.sandboxId, }); } catch (error) { this.status = 'error'; this.logger.error('Failed to create Grasp sandbox', error); throw new Error(`Failed to create sandbox: ${error}`); } } /** * Runs a command in the sandbox * @param command - Command to execute * @param options - Execution options * @returns Promise with execution result or CommandEventEmitter for background execution * @throws {Error} If sandbox is not running or command fails */ async runCommand(command, options = {}, quiet = false) { if (!this.sandbox || this.status !== 'running') { throw new Error('Sandbox is not running. Call createSandbox() first.'); } const cwd = options.cwd || config_1.DEFAULT_CONFIG.WORKING_DIRECTORY; options.timeoutMs = options.timeoutMs || 0; const timeout = options.timeoutMs || this.config.timeout; const useNohup = options.nohup || false; options = { ...options }; delete options.nohup; try { this.logger.debug('Running command in sandbox', { command: command, cwd, timeout, nohup: useNohup, }); const background = options.background; if (!background) { // 对于前台执行,如果使用nohup,修改命令 let finalCommand = command; if (useNohup) { // 创建日志目录 await this.sandbox.commands.run('mkdir -p ~/logs/grasp', { background: false, }); // 使用sandbox id生成日志文件名 const logFile = `~/logs/grasp/log-${this.id}.log`; finalCommand = `nohup ${command} > ${logFile} 2>&1 &`; } const result = await this.sandbox.commands.run(finalCommand, options); if (result.error) { this.logger.error(result.stderr); } return !useNohup ? result : void 0; } else { const sandbox = this.sandbox; if (useNohup) { // 创建日志目录 await sandbox.commands.run('mkdir -p ~/logs/grasp', { background: false, }); // 使用sandbox id生成日志文件名 const logFile = `~/logs/grasp/log-${this.id}.log`; // 启动nohup命令,输出到指定日志文件,并限制文件大小 const nohupCommand = `nohup ${command} > ${logFile} 2>&1 &`; await sandbox.commands.run(nohupCommand, options); return; } // 创建 CommandEventEmitter 来处理后台执行 const eventEmitter = new CommandEventEmitter(); // 使用 process.nextTick 确保用户有机会注册事件监听器 process.nextTick(async () => { // 正常的后台执行 const handle = await sandbox.commands.run(command, { ...options, background: true, onStdout: (data) => { // this.logger.debug('Command stdout', { data }); eventEmitter.emit('stdout', data); }, onStderr: (data) => { // this.logger.debug('Command stderr', { data }); eventEmitter.emit('stderr', data); }, }); // 设置 handle eventEmitter.setHandle(handle); // 监听命令完成(仅对非nohup命令) if (!useNohup) { handle .wait() .then(result => { eventEmitter.emit('exit', result.exitCode, result); }) .catch(error => { // 不再直接输出到控制台,而是通过事件发送错误 eventEmitter.emit('error', error); }); } }); return eventEmitter; } } catch (error) { if (!quiet) this.logger.error('Command execution failed', error); return { exitCode: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), }; } } /** * Runs JavaScript code in the sandbox * @param code - JavaScript code to execute * @param options - Script execution options * @returns Promise with execution result or CommandEventEmitter for background execution * @throws {Error} If sandbox is not running or script execution fails */ async runScript(code, options = { type: 'cjs' }) { if (!this.sandbox || this.status !== 'running') { throw new Error('Sandbox is not running. Call createSandbox() first.'); } try { // 生成临时文件名,放在工作目录下以确保能正确解析 npm 包 const timestamp = Date.now(); let scriptPath = code; if (!code.startsWith('/home/user/')) { let extension; if (options.type === 'py') { extension = 'py'; } else if (options.type === 'esm') { extension = 'mjs'; } else { extension = 'js'; } const workingDir = options.cwd || config_1.DEFAULT_CONFIG.WORKING_DIRECTORY; scriptPath = `${workingDir}/script_${timestamp}.${extension}`; // 写入代码到临时文件 await this.sandbox.files.write(scriptPath, code); } this.logger.debug('Running script in sandbox', { type: options.type, scriptPath, }); // 根据类型选择执行命令 let command; if (options.type === 'py') { command = `${options.preCommand ?? ''}python3 ${scriptPath}`; } else { command = `${options.preCommand ?? ''}node ${scriptPath}`; } // 执行脚本 const result = await this.runCommand(command, { cwd: options.cwd, timeoutMs: options.timeoutMs, background: options.background, nohup: options.nohup, envs: { ...options.envs, }, }); return result; } catch (error) { this.logger.error('Script execution failed', error); throw new Error(`Failed to execute script: ${error}`); } } /** * Check if a file is binary based on its extension * @param filePath - File path to check * @returns True if file is binary, false otherwise */ isBinaryFile(filePath) { const binaryExtensions = [ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.ico', '.pdf', '.zip', '.tar', '.gz', '.rar', '.7z', '.mp3', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite', ]; const ext = filePath.toLowerCase().substring(filePath.lastIndexOf('.')); return binaryExtensions.includes(ext); } encodeContent(data, encoding) { if (encoding === 'utf8') { return Buffer.from(data).toString('utf-8'); } else if (encoding === 'base64') { return Buffer.from(data).toString('base64'); } return Buffer.from(data); } async readFileFromSandbox(remotePath, options = {}) { if (!this.sandbox || this.status !== 'running') { throw new Error('Sandbox is not running. Call createSandbox() first.'); } if (!options.encoding) { options.encoding = this.isBinaryFile(remotePath) ? 'binary' : 'utf8'; } const buffer = await this.sandbox.files.read(remotePath, { format: 'bytes', }); return this.encodeContent(buffer, options.encoding); } async writeFileToSandbox(remotePath, content) { if (!this.sandbox || this.status !== 'running') { throw new Error('Sandbox is not running. Call createSandbox() first.'); } return this.sandbox.files.write(remotePath, content); } /** * Copy file from sandbox to local filesystem * @param remotePath - File path in sandbox * @param localPath - Local destination path * @returns Promise that resolves when file is copied * @throws {Error} If sandbox is not running or copy fails */ async copyFileFromSandbox(remotePath, localPath) { if (!this.sandbox || this.status !== 'running') { throw new Error('Sandbox is not running. Call createSandbox() first.'); } try { this.logger.debug('Copying file from sandbox', { remotePath, localPath, }); // 检查是否为二进制文件(通过文件扩展名判断) const isBinaryFile = this.isBinaryFile(remotePath); // 新 SDK 中使用 files API if (isBinaryFile) { // 对于二进制文件,读取为 buffer const fileContent = await this.sandbox.files.read(remotePath, { format: 'bytes', }); await fs_1.promises.writeFile(localPath, fileContent); } else { // 对于文本文件,读取为字符串 const fileContent = await this.sandbox.files.read(remotePath, { format: 'text', }); await fs_1.promises.writeFile(localPath, fileContent, 'utf8'); } this.logger.debug(`File copied from sandbox: ${remotePath} -> ${localPath}`); } catch (error) { this.logger.error(`Failed to copy file: ${error}`); throw error; } } /** * Uploads a file to the sandbox * @param localPath - Local file path * @param remotePath - Destination path in sandbox * @returns Promise that resolves when file is uploaded * @throws {Error} If sandbox is not running or upload fails */ async uploadFileToSandbox(localPath, remotePath) { if (!this.sandbox || this.status !== 'running') { throw new Error('Sandbox is not running. Call createSandbox() first.'); } try { this.logger.debug('Uploading file to sandbox', { localPath, remotePath, }); const fileContent = await fs_1.promises.readFile(localPath); await this.sandbox.files.write(remotePath, fileContent); this.logger.debug('File uploaded successfully', { localPath, remotePath, }); } catch (error) { this.logger.error('Failed to upload file to sandbox', error); throw new Error(`Failed to upload file: ${error}`); } } /** * Lists files in a sandbox directory * @param path - Directory path in sandbox * @returns Promise with list of files * @throws {Error} If sandbox is not running or listing fails */ async listFiles(path) { if (!this.sandbox || this.status !== 'running') { throw new Error('Sandbox is not running. Call createSandbox() first.'); } try { this.logger.debug('Listing files in sandbox', { path }); const result = (await this.runCommand(`ls -la "${path}"`)); if (result.exitCode) { throw new Error(`Failed to list files: ${result.stderr}`); } // Parse ls output to extract filenames const files = result.stdout ?.split('\n') .slice(1) // Skip first line (total) .filter(line => line.trim()) .map(line => line.split(/\s+/).pop()) .filter(name => name && name !== '.' && name !== '..') || []; return files; } catch (error) { this.logger.error('Failed to list files', error); throw new Error(`Failed to list files: ${error}`); } } /** * 验证路径是否安全,只允许同步 /home/user 目录下的子目录 * @param path - 要验证的路径 * @returns 是否为安全路径 * @private */ validateSafePath(path) { // 规范化路径 const normalizedPath = path.replace(/\/+/g, '/').replace(/\/$/, ''); // 检查是否为绝对路径且在 /home/user 下 if (normalizedPath.startsWith('/')) { // 绝对路径必须在 /home/user 目录下,且不能是 /home/user 本身 return (normalizedPath.startsWith('/home/user/') && normalizedPath !== '/home/user'); } // 相对路径检查 // 不允许包含 .. 来访问上级目录 if (normalizedPath.includes('..')) { return false; } // 不允许同步当前目录本身(. 或空字符串) if (normalizedPath === '.' || normalizedPath === '' || normalizedPath === './') { return false; } // 相对路径必须是子目录(以 ./ 开头或直接是目录名) return normalizedPath.startsWith('./') || !normalizedPath.startsWith('/'); } /** * 检查远程路径是否为目录 * @param remotePath - 远程路径 * @returns 是否为目录 * @private */ async isDirectory(remotePath) { if (!this.sandbox || this.status !== 'running') { throw new Error('Sandbox is not running. Call createSandbox() first.'); } try { const result = (await this.runCommand(`test -d "${remotePath}" && echo "true" || echo "false"`)); return result.stdout?.trim() === 'true'; } catch (error) { this.logger.debug('Error checking if path is directory', { remotePath, error, }); return false; } } /** * 递归同步目录 * @param remotePath - 远程目录路径 * @param localPath - 本地目录路径 * @returns 同步的文件数量 * @private */ async syncDirectoryRecursive(remotePath, localPath) { let syncedCount = 0; try { // 获取目录中的所有文件和子目录 const items = await this.listFiles(remotePath); if (items.length === 0) { this.logger.debug('No items found in directory', { remotePath }); return 0; } // 确保本地目录存在 await fs_1.promises.mkdir(localPath, { recursive: true }); // 处理每个项目 for (const item of items) { const remoteItemPath = `${remotePath}/${item}`; const localItemPath = node_path_1.default.join(localPath, item); // 检查是否为目录 const isDir = await this.isDirectory(remoteItemPath); if (isDir) { this.logger.debug('Syncing subdirectory', { from: remoteItemPath, to: localItemPath, }); // 递归同步子目录 const subSyncedCount = await this.syncDirectoryRecursive(remoteItemPath, localItemPath); syncedCount += subSyncedCount; } else { this.logger.debug('Syncing file', { from: remoteItemPath, to: localItemPath, }); // 同步文件 await this.copyFileFromSandbox(remoteItemPath, localItemPath); syncedCount++; } } return syncedCount; } catch (error) { this.logger.error('Error in recursive directory sync', { remotePath, localPath, error, }); throw error; } } /** * 同步 sandbox 中的 downloads 目录到本地(递归同步所有子目录) * @param dist - 本地目标目录路径,默认为 '/tmp/grasp/downloads' * @param src - 远程源目录路径,默认为 './downloads' * @returns 本地同步目录路径 * @throws {Error} 当路径不安全时抛出错误 */ async syncDownloadsDirectory(dist = '/tmp/grasp/downloads', src = './downloads') { // 验证路径安全性 if (!this.validateSafePath(src)) { const error = new Error(`Unsafe path detected: ${src}. Only subdirectories under /home/user are allowed.`); this.logger.error('Path validation failed', { path: src, error: error.message, }); throw error; } const remotePath = src; const localPath = dist; try { this.logger.info('🗂️ Starting recursive directory sync', { remotePath, localPath, }); // 检查远程目录是否存在 const dirExists = await this.isDirectory(remotePath); if (!dirExists) { // 尝试作为文件处理 const files = await this.listFiles(node_path_1.default.dirname(remotePath)); const fileName = node_path_1.default.basename(remotePath); if (!files.includes(fileName)) { this.logger.debug('Remote path does not exist', { remotePath }); return ''; } } // 递归同步目录 const syncedCount = await this.syncDirectoryRecursive(remotePath, localPath); if (syncedCount === 0) { this.logger.debug('No files found to sync'); return ''; } this.logger.info(`🎉 Successfully synced ${syncedCount} files recursively from ${remotePath}`); return localPath; } catch (error) { this.logger.error('Error syncing downloads directory:', error); throw error; } } /** * Gets the current sandbox status * @returns Current sandbox status */ getStatus() { return this.status; } /** * Gets sandbox ID if available * @returns Sandbox ID or null */ getSandboxId() { return this.sandbox?.sandboxId || null; } /** * Gets the external host address for a specific port * @param port - Port number to get host for * @returns External host address or null if sandbox not available */ getSandboxHost(port) { if (!this.sandbox || this.status !== 'running') { this.logger.warn('Cannot get sandbox host: sandbox not running'); return null; } try { // 新 SDK 中可能需要不同的方式获取主机地址 // 暂时返回 localhost,具体实现需要根据新 SDK 文档调整 const host = this.sandbox.getHost(port).replace('e2b.app', 'grasps.io'); this.logger.debug('Got sandbox host for port', { port, host }); return host; } catch (error) { this.logger.error('Failed to get sandbox host', { port, error }); return null; } } /** * Destroys the sandbox and cleans up resources * @returns Promise that resolves when sandbox is destroyed */ async destroy() { if (this.sandbox) { try { this.logger.info('Destroying Grasp sandbox', { sandboxId: this.sandbox.sandboxId, }); if (await this.sandbox.isRunning()) { await this.sandbox.kill(); } this.sandbox = null; this.status = 'stopped'; this.logger.info('Grasp sandbox destroyed successfully'); } catch (error) { this.logger.error('Failed to destroy sandbox', error); throw new Error(`Failed to destroy sandbox: ${error}`); } } } } exports.SandboxService = SandboxService; //# sourceMappingURL=sandbox.service.js.map