Files
mcp_web_dev_server/dist/tools/docker/dockerManager.js
T
root 843fa559bb
Tests / test (push) Failing after 34s
Tests / build-and-push-image (push) Has been skipped
add pipeline
2026-07-31 13:51:39 -04:00

105 lines
4.0 KiB
JavaScript

import Docker from 'dockerode';
import { config } from '../../config.js';
import { resolveWorkspacePath, SandboxViolationError } 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 normalizedSource = source.toLowerCase().replaceAll('/', '\\');
if (normalizedSource.startsWith('\\\\.\\pipe\\docker_engine') ||
normalizedSource.startsWith('\\.\\pipe\\docker_engine') ||
source.includes('/var/run/docker.sock')) {
throw new SandboxViolationError(`Refusing to mount the Docker socket: "${source}".`);
}
const absoluteSource = resolveWorkspacePath(config.workspaceRoot, 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