diff --git a/package-lock.json b/package-lock.json index 3b6dc2e..8087df8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1760,6 +1760,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -1777,6 +1778,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1844,6 +1846,7 @@ "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.43.0", "@typescript-eslint/types": "8.43.0", @@ -2183,6 +2186,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3388,6 +3392,7 @@ "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3690,50 +3695,6 @@ "node": ">=6" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/express-rate-limit": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", @@ -4243,16 +4204,6 @@ "node": ">= 0.4" } }, - "node_modules/hono": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", - "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -6048,6 +5999,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7107,6 +7059,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7568,6 +7521,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -7636,6 +7590,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/env/service-manager.ts b/src/env/service-manager.ts index 733f549..2302040 100644 --- a/src/env/service-manager.ts +++ b/src/env/service-manager.ts @@ -1,4 +1,4 @@ -import { execSync, spawn } from 'child_process'; +import { execFileSync, spawn } from 'child_process'; import { createConnection } from 'net'; import { setTimeout as sleep } from 'timers/promises'; import { CodexSynapticError, ErrorCode } from '../core/errors.js'; @@ -37,6 +37,14 @@ export interface EnsureServiceOptions { allowFilesystemWrite?: boolean; } +interface ComposeCommand { + bin: string; + args: string[]; +} + +/** Maximum time (ms) to wait for `docker compose up -d` before killing the process. */ +const COMPOSE_UP_TIMEOUT_MS = 300_000; // 5 minutes + const PROFILES: Record = { observability: { description: 'Prometheus/Grafana stack with exporters', @@ -76,7 +84,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.github.yml', services: ['mcp-github'], port: 7010, - dockerImages: ['ghcr.io/context-labs/github-mcp:latest'], + dockerImages: ['ghcr.io/context-labs/github-mcp:v1.0.0'], requiredEnv: ['GITHUB_TOKEN'], codexName: 'github' }, @@ -85,7 +93,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.context7.yml', services: ['mcp-context7'], port: 7020, - dockerImages: ['ghcr.io/context-labs/context7-mcp:latest'], + dockerImages: ['ghcr.io/context-labs/context7-mcp:v1.0.0'], requiredEnv: ['CONTEXT7_API_KEY'], codexName: 'context7' }, @@ -94,7 +102,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.playwright.yml', services: ['mcp-playwright'], port: 7030, - dockerImages: ['mcp/playwright:latest'], + dockerImages: ['mcp/playwright:v1.0.0'], codexName: 'playwright-local' }, 'mcp-filesystem': { @@ -102,7 +110,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.filesystem.yml', services: ['mcp-filesystem'], port: 7040, - dockerImages: ['ghcr.io/context-labs/filesystem-mcp:latest'], + dockerImages: ['ghcr.io/context-labs/filesystem-mcp:v1.0.0'], codexName: 'filesystem-local' }, 'mcp-desktop-commander': { @@ -110,7 +118,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.desktop-commander.yml', services: ['mcp-desktop-commander'], port: 7070, - dockerImages: ['ghcr.io/wonderwhy-er/desktop-commander:latest'], + dockerImages: ['ghcr.io/wonderwhy-er/desktop-commander:v1.0.0'], codexName: 'desktop-commander' }, 'mcp-tavily': { @@ -118,7 +126,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.tavily.yml', services: ['mcp-tavily'], port: 7050, - dockerImages: ['ghcr.io/context-labs/tavily-mcp:latest'], + dockerImages: ['ghcr.io/context-labs/tavily-mcp:v1.0.0'], requiredEnv: ['TAVILY_API_KEY'], codexName: 'tavily' }, @@ -127,15 +135,26 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.firecrawl.yml', services: ['mcp-firecrawl'], port: 7060, - dockerImages: ['ghcr.io/firecrawl/firecrawl-mcp:latest'], + dockerImages: ['ghcr.io/firecrawl/firecrawl-mcp:v1.0.0'], requiredEnv: ['FIRECRAWL_API_KEY'], codexName: 'firecrawl' } }; -function composeCommand(profile: ServiceProfile, command: string, services?: string[]): string { - const serviceArgs = services && services.length ? ` ${services.join(' ')}` : ''; - return `docker compose -f ${profile.composeFile} ${command}${serviceArgs}`; +/** + * Builds a structured {@link ComposeCommand} for the given profile and compose sub-command. + * + * @param command - A simple space-separated compose command string (e.g. `'up -d'`, `'down'`, `'ps'`). + * Arguments containing spaces or special characters are not supported; all current callers pass + * literal, shell-safe strings. + */ +function composeCommand(profile: ServiceProfile, command: string, services?: string[]): ComposeCommand { + const cmdArgs = command.trim().split(/\s+/); + const serviceArgs = services && services.length ? services : []; + return { + bin: 'docker', + args: ['compose', '-f', profile.composeFile, ...cmdArgs, ...serviceArgs] + }; } class ServiceManager { @@ -190,14 +209,15 @@ class ServiceManager { async ensureService(name: string, options?: EnsureServiceOptions): Promise { const profile = this.getProfile(name); - const cmd = composeCommand(profile, 'up -d', profile.services); + const composed = composeCommand(profile, 'up -d', profile.services); + const cmdString = [composed.bin, ...composed.args].join(' '); const env = this.resolveExecEnv(name, options); - this.logger.info('env', `Starting service ${name}`, { command: cmd }); + this.logger.info('env', `Starting service ${name}`, { command: cmdString }); try { - await this.runComposeUp(cmd, env); + await this.runComposeUp(composed, env); } catch (error) { - throw this.wrapComposeStartError(name, profile, cmd, error); + throw this.wrapComposeStartError(name, profile, cmdString, error); } if (options?.waitForHealth !== false) { @@ -207,14 +227,14 @@ class ServiceManager { stopService(name: string): void { const profile = this.getProfile(name); - const cmd = composeCommand(profile, 'down', profile.services); - this.logger.info('env', `Stopping service ${name}`, { command: cmd }); - execSync(cmd, { stdio: 'inherit' }); + const { bin, args } = composeCommand(profile, 'down', profile.services); + this.logger.info('env', `Stopping service ${name}`, { command: [bin, ...args].join(' ') }); + execFileSync(bin, args, { stdio: 'inherit' }); } async status(name: string): Promise { const profile = this.getProfile(name); - const cmd = composeCommand(profile, 'ps'); + const { bin, args } = composeCommand(profile, 'ps'); const diagnostics: string[] = []; for (const required of profile.requiredEnv ?? []) { @@ -224,7 +244,7 @@ class ServiceManager { } try { - const output = execSync(cmd, { stdio: 'pipe' }).toString(); + const output = execFileSync(bin, args, { stdio: 'pipe' }).toString(); const running = /\bUp\b/.test(output); if (!running) { @@ -371,35 +391,65 @@ class ServiceManager { * Runs `docker compose up -d` streaming stdout to the terminal (avoiding * ENOBUFS on large image pulls) while capturing stderr for error * classification by {@link wrapComposeStartError}. + * + * A process-level timeout of {@link COMPOSE_UP_TIMEOUT_MS} is applied: if + * the child process does not exit within that window it is killed and the + * returned Promise is rejected with a descriptive timeout error. */ - private runComposeUp(cmd: string, env: NodeJS.ProcessEnv): Promise { + private runComposeUp(composed: ComposeCommand, env: NodeJS.ProcessEnv): Promise { return new Promise((resolve, reject) => { - const proc = spawn(cmd, { shell: true, stdio: ['ignore', 'inherit', 'pipe'], env }); + const proc = spawn(composed.bin, composed.args, { shell: false, stdio: ['ignore', 'inherit', 'pipe'], env }); const stderrChunks: Buffer[] = []; + let settled = false; + + const settle = (fn: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + + const timer = setTimeout(() => { + proc.kill(); + settle(() => { + reject( + Object.assign( + new Error(`docker compose timed out after ${COMPOSE_UP_TIMEOUT_MS}ms`), + { status: null, stdout: '', stderr: `Process timed out after ${COMPOSE_UP_TIMEOUT_MS}ms` }, + ), + ); + }); + }, COMPOSE_UP_TIMEOUT_MS); proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); proc.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - const stderr = Buffer.concat(stderrChunks).toString('utf8'); - const err = Object.assign(new Error(`docker compose exited with code ${code}`), { - status: code, - stdout: '', - stderr, - }); - reject(err); - } + settle(() => { + if (code === 0) { + resolve(); + } else { + const stderr = Buffer.concat(stderrChunks).toString('utf8'); + reject( + Object.assign(new Error(`docker compose exited with code ${code}`), { + status: code, + stdout: '', + stderr, + }), + ); + } + }); }); proc.on('error', (spawnErr) => { - const err = Object.assign(spawnErr, { - status: null, - stdout: '', - stderr: spawnErr.message, + settle(() => { + reject( + Object.assign(spawnErr, { + status: null, + stdout: '', + stderr: spawnErr.message, + }), + ); }); - reject(err); }); }); }