927 lines
25 KiB
TypeScript
927 lines
25 KiB
TypeScript
/**
|
|
* 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;
|