@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
JavaScript
"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