/** * Docker 沙箱实现 * * 使用 Docker 容器提供隔离的执行环境 * * @version 1.0.0 * @updated 2026-03-22 */ import { exec, spawn } from "child_process"; import { promisify } from "util"; import * as path from "path"; import * as fs from "fs/promises"; import * as os from "os"; import log from "electron-log"; import { SandboxManager } from "./SandboxManager"; import type { SandboxConfig, Workspace, ExecuteOptions, ExecuteResult, FileInfo, SandboxStatus, CleanupResult, ContainerInfo, } from "@shared/types/sandbox"; import { SandboxError, SandboxErrorCode, ExecutionError, FileOperationError, WorkspaceError, toSandboxError, } from "@shared/errors/sandbox"; const execAsync = promisify(exec); /** * Docker 沙箱配置 */ export interface DockerSandboxConfig extends SandboxConfig { /** Docker 镜像名称 */ dockerImage: string; /** Docker Host 地址(可选) */ dockerHost?: string; } /** * Docker 沙箱实现 */ export class DockerSandbox extends SandboxManager { private containerIds: Map = new Map(); private dockerConfig: DockerSandboxConfig; private dockerAvailable: boolean = false; private dockerVersion: string | null = null; constructor(config: DockerSandboxConfig) { super(config); this.dockerConfig = config; // 验证配置 if (config.type !== "docker") { throw new SandboxError( 'DockerSandbox requires type to be "docker"', SandboxErrorCode.CONFIG_INVALID, ); } } // ============================================================================ // 初始化与可用性检查 // ============================================================================ async init(): Promise { log.info("[DockerSandbox] Initializing Docker sandbox..."); log.debug("[DockerSandbox] config:", { type: this.config.type, platform: this.config.platform, enabled: this.config.enabled, workspaceRoot: this.config.workspaceRoot, memoryLimit: this.config.memoryLimit, cpuLimit: this.config.cpuLimit, networkEnabled: this.config.networkEnabled, dockerImage: this.dockerConfig.dockerImage, dockerHost: this.dockerConfig.dockerHost, }); try { // 检查 Docker 是否可用 this.dockerAvailable = await this.checkDockerAvailable(); if (!this.dockerAvailable) { throw new SandboxError( "Docker is not available; ensure Docker Desktop is installed and running", SandboxErrorCode.DOCKER_UNAVAILABLE, ); } // 拉取镜像(如果需要) await this.ensureImageExists(); this.initialized = true; log.info("[DockerSandbox] Initialization complete"); } catch (error) { log.error("[DockerSandbox] Initialization failed:", error); throw toSandboxError( error, "Docker sandbox initialization failed", SandboxErrorCode.SANDBOX_UNAVAILABLE, ); } } async isAvailable(): Promise { if (!this.dockerAvailable) { this.dockerAvailable = await this.checkDockerAvailable(); } return this.dockerAvailable; } /** * 检查 Docker 是否可用 */ private async checkDockerAvailable(): Promise { try { const { stdout: versionOut } = await execAsync("docker --version", { timeout: 5000, }); // 提取版本号 const match = versionOut.match(/Docker version ([\d.]+)/); this.dockerVersion = match ? match[1] : versionOut.trim(); log.debug("[DockerSandbox] docker --version:", versionOut.trim()); // 检查 Docker daemon 是否运行 const { stdout: infoOut } = await execAsync("docker info", { timeout: 5000, }); log.debug( "[DockerSandbox] docker info (first 500 chars):", infoOut.slice(0, 500), ); log.info( "[DockerSandbox] Docker available, version:", this.dockerVersion, ); return true; } catch (error) { log.warn("[DockerSandbox] Docker unavailable:", error); return false; } } /** * 验证 Docker 镜像名称 * 只允许字母、数字、冒号、斜杠、点、下划线和连字符 */ private validateImageName(image: string): void { if (!/^[a-zA-Z0-9_/.:-]+$/.test(image)) { throw new SandboxError( `Invalid Docker image name: ${image}`, SandboxErrorCode.CONFIG_INVALID, ); } } /** * 验证容器 ID * 只允许字母、数字和连字符 */ private validateContainerId(containerId: string): void { if (!/^[a-zA-Z0-9-]+$/.test(containerId)) { throw new SandboxError( `Invalid container ID: ${containerId}`, SandboxErrorCode.CONTAINER_OPERATION_FAILED, ); } } /** * 确保镜像存在 */ private async ensureImageExists(): Promise { const image = this.dockerConfig.dockerImage; this.validateImageName(image); try { // 检查镜像是否存在 await execAsync(`docker image inspect ${image}`, { timeout: 10000 }); log.info("[DockerSandbox] Image already exists:", image); } catch { // 镜像不存在,尝试拉取 log.info("[DockerSandbox] Pulling image:", image); await execAsync(`docker pull ${image}`, { timeout: 300000 }); // 5 分钟超时 log.info("[DockerSandbox] Image pull complete:", image); } } // ============================================================================ // 工作区管理 // ============================================================================ async createWorkspace(sessionId: string): Promise { log.info("[DockerSandbox] Creating workspace:", sessionId); // 检查是否已存在 if (this.workspaces.has(sessionId)) { throw new WorkspaceError( `Workspace already exists: ${sessionId}`, SandboxErrorCode.WORKSPACE_EXISTS, { sessionId }, ); } const workspaceId = this.generateWorkspaceId(sessionId); const workspaceRoot = path.join(this.config.workspaceRoot, workspaceId); try { // 创建工作区目录 await this.createWorkspaceDirectories(workspaceRoot); // 启动 Docker 容器 const containerId = await this.startContainer(sessionId, workspaceRoot); this.containerIds.set(sessionId, containerId); // 创建工作区对象 const workspace: Workspace = { id: workspaceId, sessionId, rootPath: workspaceRoot, projectsPath: path.join(workspaceRoot, "projects"), nodeModulesPath: path.join(workspaceRoot, "node_modules"), pythonEnvPath: path.join(workspaceRoot, "python-env"), binPath: path.join(workspaceRoot, "bin"), cachePath: path.join(workspaceRoot, "cache"), sandboxConfig: this.config, createdAt: new Date(), lastAccessedAt: new Date(), retentionPolicy: this.createDefaultRetentionPolicy(), status: "active", }; this.workspaces.set(sessionId, workspace); // 发出事件 this.emitEvent("workspace:created", { workspace }); log.info( "[DockerSandbox] Workspace created:", sessionId, "container:", containerId, ); return workspace; } catch (error) { // 清理已创建的资源 await this.cleanupFailedWorkspace(sessionId, workspaceRoot); throw toSandboxError( error, "Failed to create workspace", SandboxErrorCode.WORKSPACE_CREATE_FAILED, { sessionId, }, ); } } async destroyWorkspace(sessionId: string): Promise { log.info("[DockerSandbox] Destroying workspace:", sessionId); const workspace = this.validateWorkspaceExists(sessionId); try { workspace.status = "destroying"; // 停止并删除容器 const containerId = this.containerIds.get(sessionId); if (containerId) { await this.stopContainer(containerId); this.containerIds.delete(sessionId); } // 删除工作区目录(根据保留策略) if (workspace.retentionPolicy.mode !== "always") { await this.deleteWorkspaceDirectory(workspace.rootPath); } // 从映射中移除 this.workspaces.delete(sessionId); // 发出事件 this.emitEvent("workspace:destroyed", { workspaceId: workspace.id, sessionId, }); log.info("[DockerSandbox] Workspace destruction complete:", sessionId); } catch (error) { workspace.status = "error"; throw toSandboxError( error, "Failed to destroy workspace", SandboxErrorCode.WORKSPACE_DESTROY_FAILED, { sessionId, workspaceId: workspace.id, }, ); } } // ============================================================================ // 命令执行 // ============================================================================ async execute( sessionId: string, command: string, args: string[] = [], options: ExecuteOptions = {}, ): Promise { const workspace = this.validateWorkspaceExists(sessionId); this.updateLastAccessed(sessionId); const containerId = this.containerIds.get(sessionId); if (!containerId) { throw new ExecutionError( "Container is not running", SandboxErrorCode.CONTAINER_OPERATION_FAILED, { sessionId, }, ); } const startTime = Date.now(); const timeout = options.timeout || 300000; // 默认 5 分钟 log.info( "[DockerSandbox] Executing command:", sessionId, command, args.join(" "), ); log.debug("[DockerSandbox] execute detail:", { sessionId, command, args, timeout, cwd: options.cwd, envKeys: options.env ? Object.keys(options.env) : [], }); // 发出执行开始事件 this.emitEvent("execute:start", { sessionId, command, args }); try { // 构建 docker exec 命令 const dockerArgs = this.buildDockerExecArgs( containerId, command, args, options, ); const result = await this.executeDockerCommand(dockerArgs, timeout); const executeResult: ExecuteResult = { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, timedOut: result.timedOut, duration: Date.now() - startTime, command, args, }; // 发出执行完成事件 this.emitEvent("execute:complete", { sessionId, result: executeResult }); return executeResult; } catch (error) { const executeResult: ExecuteResult = { stdout: "", stderr: error instanceof Error ? error.message : String(error), exitCode: 1, timedOut: false, duration: Date.now() - startTime, command, args, }; this.emitEvent("execute:error", { sessionId, error: error instanceof Error ? error.message : String(error), command, }); throw toSandboxError( error, "Command execution failed", SandboxErrorCode.EXECUTION_FAILED, { sessionId, }, ); } } // ============================================================================ // 文件操作 // ============================================================================ async readFile(sessionId: string, filePath: string): Promise { const workspace = this.validateWorkspaceExists(sessionId); this.validatePathInWorkspace(workspace, filePath); try { // 直接从主机文件系统读取(因为目录已挂载) const content = await fs.readFile(filePath, "utf-8"); this.updateLastAccessed(sessionId); return content; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { throw new FileOperationError( `File not found: ${filePath}`, SandboxErrorCode.FILE_NOT_FOUND, { sessionId, cause: error as Error, }, ); } throw new FileOperationError( `File read failed: ${filePath}`, SandboxErrorCode.FILE_READ_FAILED, { sessionId, cause: error as Error, }, ); } } async writeFile( sessionId: string, filePath: string, content: string, ): Promise { const workspace = this.validateWorkspaceExists(sessionId); this.validatePathInWorkspace(workspace, filePath); try { // 确保目录存在 const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); // 写入文件 await fs.writeFile(filePath, content, "utf-8"); this.updateLastAccessed(sessionId); } catch (error) { throw new FileOperationError( `File write failed: ${filePath}`, SandboxErrorCode.FILE_WRITE_FAILED, { sessionId, cause: error as Error }, ); } } async readDir(sessionId: string, dirPath: string): Promise { const workspace = this.validateWorkspaceExists(sessionId); this.validatePathInWorkspace(workspace, dirPath); try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); const fileInfos: FileInfo[] = []; for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); const stats = await fs.stat(fullPath); fileInfos.push({ name: entry.name, path: fullPath, isDirectory: entry.isDirectory(), size: stats.size, modifiedAt: stats.mtime, }); } this.updateLastAccessed(sessionId); return fileInfos; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { throw new FileOperationError( `Directory not found: ${dirPath}`, SandboxErrorCode.FILE_NOT_FOUND, { sessionId, cause: error as Error }, ); } throw new FileOperationError( `Directory read failed: ${dirPath}`, SandboxErrorCode.DIRECTORY_OPERATION_FAILED, { sessionId, cause: error as Error }, ); } } async deleteFile(sessionId: string, filePath: string): Promise { const workspace = this.validateWorkspaceExists(sessionId); this.validatePathInWorkspace(workspace, filePath); try { await fs.unlink(filePath); this.updateLastAccessed(sessionId); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { throw new FileOperationError( `File not found: ${filePath}`, SandboxErrorCode.FILE_NOT_FOUND, { sessionId, cause: error as Error, }, ); } throw new FileOperationError( `File delete failed: ${filePath}`, SandboxErrorCode.FILE_DELETE_FAILED, { sessionId, cause: error as Error }, ); } } // ============================================================================ // 清理 // ============================================================================ async cleanup(): Promise { log.info("[DockerSandbox] Starting cleanup..."); const result: CleanupResult = { deletedCount: 0, freedSpace: 0, errors: [], }; // 停止所有容器 for (const [sessionId, containerId] of this.containerIds) { try { await this.stopContainer(containerId); result.deletedCount++; } catch (error) { result.errors.push(`Failed to stop container ${sessionId}: ${error}`); } } this.containerIds.clear(); // 清理工作区目录 for (const [sessionId, workspace] of this.workspaces) { try { if (workspace.retentionPolicy.mode !== "always") { const size = await this.getDirectorySize(workspace.rootPath); await this.deleteWorkspaceDirectory(workspace.rootPath); result.freedSpace += size; } } catch (error) { result.errors.push(`Failed to clean workspace ${sessionId}: ${error}`); } } this.workspaces.clear(); log.info("[DockerSandbox] Cleanup complete:", result); this.emitEvent("cleanup:complete", { result }); return result; } // ============================================================================ // Docker 特有方法 // ============================================================================ /** * 列出所有容器 */ async listContainers(): Promise { try { const { stdout } = await execAsync( 'docker ps -a --filter "name=qiming-sandbox-" --format "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.CreatedAt}}"', ); const lines = stdout.trim().split("\n").filter(Boolean); const containers: ContainerInfo[] = []; for (const line of lines) { const [id, name, image, status, createdAt] = line.split("\t"); // 从容器名称中提取 sessionId const sessionMatch = name.match(/qiming-sandbox-(.+)/); const sessionId = sessionMatch ? sessionMatch[1] : undefined; containers.push({ id, name, image, status: this.parseDockerStatus(status), sessionId, createdAt: new Date(createdAt), }); } return containers; } catch (error) { log.error("[DockerSandbox] Failed to list containers:", error); return []; } } /** * 获取容器日志 */ async getContainerLogs(sessionId: string): Promise { const containerId = this.containerIds.get(sessionId); if (!containerId) { throw new SandboxError( "Container not found", SandboxErrorCode.CONTAINER_OPERATION_FAILED, { sessionId, }, ); } this.validateContainerId(containerId); try { const { stdout } = await execAsync(`docker logs ${containerId}`, { timeout: 10000, }); return stdout; } catch (error) { log.error("[DockerSandbox] Failed to get container logs:", error); return ""; } } /** * 获取沙箱状态(包含 Docker 信息) */ async getStatus(): Promise { const baseStatus = await super.getStatus(); return { ...baseStatus, docker: { running: this.dockerAvailable, containerCount: this.containerIds.size, version: this.dockerVersion || undefined, }, }; } // ============================================================================ // 私有方法 // ============================================================================ /** * 创建工作区目录结构 */ private async createWorkspaceDirectories( workspaceRoot: string, ): Promise { const directories = [ workspaceRoot, path.join(workspaceRoot, "projects"), path.join(workspaceRoot, "node_modules"), path.join(workspaceRoot, "python-env"), path.join(workspaceRoot, "bin"), path.join(workspaceRoot, "cache"), ]; for (const dir of directories) { await fs.mkdir(dir, { recursive: true }); } } /** * 启动 Docker 容器 */ private async startContainer( sessionId: string, workspaceRoot: string, ): Promise { const containerName = `qiming-sandbox-${sessionId}`; const image = this.dockerConfig.dockerImage; // 构建容器参数 const args = [ "run", "-d", "--name", containerName, // 挂载工作区 "-v", `${workspaceRoot}:/workspace`, // 内存限制 "--memory", this.config.memoryLimit || "2g", // CPU 限制 "--cpus", String(this.config.cpuLimit || 2), // 禁用网络(如果配置) ...(this.config.networkEnabled === false ? ["--network", "none"] : []), // 工作目录 "-w", "/workspace", // 环境变量 "-e", "HOME=/workspace", "-e", "PATH=/usr/local/bin:/usr/bin:/bin:/workspace/bin", // 镜像 image, // 保持容器运行 "tail", "-f", "/dev/null", ]; log.debug("[DockerSandbox] startContainer args:", args.join(" ")); const { stdout } = await execAsync(`docker ${args.join(" ")}`, { timeout: 60000, }); const containerId = stdout.trim(); log.info( "[DockerSandbox] Container started:", containerId, "name:", containerName, ); return containerId; } /** * 停止并删除容器 */ private async stopContainer(containerId: string): Promise { this.validateContainerId(containerId); try { await execAsync(`docker stop ${containerId}`, { timeout: 30000 }); await execAsync(`docker rm ${containerId}`, { timeout: 30000 }); log.info("[DockerSandbox] Container stopped and removed:", containerId); } catch (error) { log.error("[DockerSandbox] Failed to stop container:", error); throw error; } } /** * 构建 docker exec 参数 */ private buildDockerExecArgs( containerId: string, command: string, args: string[], options: ExecuteOptions, ): string[] { const dockerArgs = ["exec"]; // 工作目录 if (options.cwd) { dockerArgs.push("-w", options.cwd); } // 环境变量 if (options.env) { for (const [key, value] of Object.entries(options.env)) { dockerArgs.push("-e", `${key}=${value}`); } } // 执行命令 dockerArgs.push(containerId, command, ...args); log.debug("[DockerSandbox] docker exec args:", dockerArgs.join(" ")); return dockerArgs; } /** * 执行 Docker 命令 */ private async executeDockerCommand( dockerArgs: string[], timeout: number, ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean; }> { return new Promise((resolve, reject) => { const proc = spawn("docker", dockerArgs, { shell: false }); let stdout = ""; let stderr = ""; let timedOut = false; proc.stdout?.on("data", (data) => { stdout += data.toString(); }); proc.stderr?.on("data", (data) => { stderr += data.toString(); }); // 超时处理 const timer = setTimeout(() => { timedOut = true; proc.kill("SIGKILL"); }, timeout); proc.on("close", (code) => { clearTimeout(timer); log.debug("[DockerSandbox] docker exec completed:", { exitCode: code, timedOut, stdoutLen: stdout.length, stderrLen: stderr.length, stderrPreview: stderr.slice(0, 200), }); resolve({ stdout, stderr, exitCode: code ?? (timedOut ? 137 : 1), timedOut, }); }); proc.on("error", (error) => { clearTimeout(timer); log.debug("[DockerSandbox] docker exec error:", error.message); reject(error); }); }); } /** * 解析 Docker 状态字符串 */ private parseDockerStatus( status: string, ): "running" | "exited" | "paused" | "created" { if (status.includes("Up")) return "running"; if (status.includes("Exited")) return "exited"; if (status.includes("Paused")) return "paused"; return "created"; } /** * 获取目录大小 */ private async getDirectorySize(dirPath: string): Promise { try { let totalSize = 0; const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { totalSize += await this.getDirectorySize(fullPath); } else { const stats = await fs.stat(fullPath); totalSize += stats.size; } } return totalSize; } catch { return 0; } } /** * 删除工作区目录 */ private async deleteWorkspaceDirectory(dirPath: string): Promise { try { await fs.rm(dirPath, { recursive: true, force: true }); } catch (error) { log.error("[DockerSandbox] Failed to delete directory:", dirPath, error); throw error; } } /** * 清理失败的工作区 */ private async cleanupFailedWorkspace( sessionId: string, workspaceRoot: string, ): Promise { try { // 停止可能已创建的容器 const containerId = this.containerIds.get(sessionId); if (containerId) { await this.stopContainer(containerId); this.containerIds.delete(sessionId); } // 删除目录 await this.deleteWorkspaceDirectory(workspaceRoot); } catch (error) { log.error("[DockerSandbox] Error cleaning failed workspace:", error); } } } export default DockerSandbox;