chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,926 @@
|
||||
/**
|
||||
* 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<string, string> = 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<void> {
|
||||
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<boolean> {
|
||||
if (!this.dockerAvailable) {
|
||||
this.dockerAvailable = await this.checkDockerAvailable();
|
||||
}
|
||||
return this.dockerAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Docker 是否可用
|
||||
*/
|
||||
private async checkDockerAvailable(): Promise<boolean> {
|
||||
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<void> {
|
||||
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<Workspace> {
|
||||
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<void> {
|
||||
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<ExecuteResult> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<FileInfo[]> {
|
||||
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<void> {
|
||||
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<CleanupResult> {
|
||||
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<ContainerInfo[]> {
|
||||
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<string> {
|
||||
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<SandboxStatus> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
Reference in New Issue
Block a user