import Docker from 'dockerode'; import { config } from '../../config.js'; import { resolveWorkspacePath } from '../../lib/sandbox.js'; import { logger } from '../../lib/logger.js'; class DockerManager { docker; containers = new Map(); daemonReachable = null; constructor() { this.docker = new Docker(); } async ensureReachable() { if (this.daemonReachable === true) return; try { await this.docker.ping(); this.daemonReachable = true; } catch (error) { this.daemonReachable = false; throw new Error('Docker daemon is not reachable. Make sure Docker is running and the current user can access it. ' + `Original error: ${error instanceof Error ? error.message : String(error)}`); } } getDocker() { return this.docker; } track(containerId, image, name, ports) { if (this.containers.size >= config.maxConcurrentContainers) { throw new Error(`Refusing to start another container: already at the max of ${config.maxConcurrentContainers} concurrent containers.`); } const shortId = containerId.substring(0, 12); const managed = { id: shortId, image, name, startedAt: new Date().toISOString(), ports, }; this.containers.set(shortId, managed); return managed; } getTracked(id) { return this.containers.get(id) ?? this.containers.get(id.substring(0, 12)); } listTracked() { return Array.from(this.containers.values()); } untrack(id) { this.containers.delete(id.substring(0, 12)); } async cleanupAll() { for (const [id, managed] of this.containers.entries()) { try { const container = this.docker.getContainer(id); await container.stop({ t: 5 }).catch(() => null); await container.remove({ force: true }).catch(() => null); } catch (error) { logger.error(`failed to cleanup container ${managed.name} (${id})`, { error: error instanceof Error ? error.message : error, }); } finally { this.containers.delete(id); } } } } export const dockerManager = new DockerManager(); /** * Validates that a volume mount specification keeps the source path inside the * workspace root. Rejects Docker socket mounts, host-relative escapes, etc. */ export function validateVolumeMount(spec) { const parts = spec.split(':'); if (parts.length < 2) { throw new Error(`Invalid volume mount "${spec}": expected format "source:target[:mode]".`); } const source = parts[0] ?? ''; const target = parts[1] ?? ''; const mode = parts[2] ?? 'rw'; if (!source || !target) { throw new Error(`Invalid volume mount "${spec}": source and target are required.`); } const absoluteSource = resolveWorkspacePath(config.workspaceRoot, source); if (absoluteSource.toLowerCase().startsWith('\\\\.\\pipe\\docker_engine') || source.includes('/var/run/docker.sock')) { throw new Error(`Refusing to mount the Docker socket: "${source}".`); } return { source: absoluteSource, target, mode }; } export function isAllowListedImage(image) { const base = (image.split(':')[0] ?? '').split('/').pop()?.toLowerCase() ?? ''; return config.dockerAllowedBaseImages.some((allowed) => base === allowed || base.startsWith(allowed)); } export function isLocallyBuiltImage(image) { // Heuristic: an image built by this server via docker_build_image won't have // a registry prefix. We treat "my-app" or "web-dev-mcp" as local; anything // with a registry host (contains '.' or '/') is not. return !/[/.]/.test(image); } //# sourceMappingURL=dockerManager.js.map