diff --git a/packages/cli/Dockerfile b/packages/cli/Dockerfile index 65244b9c..470cece8 100644 --- a/packages/cli/Dockerfile +++ b/packages/cli/Dockerfile @@ -1,5 +1,9 @@ FROM oven/bun:1 +# Build arguments for user/group creation +ARG HOST_UID=1000 +ARG HOST_GID=1001 + # Install system dependencies including build tools for native modules RUN apt-get update && apt-get install -y \ bash \ @@ -11,26 +15,41 @@ RUN apt-get update && apt-get install -y \ build-essential \ && rm -rf /var/lib/apt/lists/* +# Create non-root user and group with host UID/GID +# Remove existing user/group if they conflict, then create vibekit user +RUN (getent passwd ${HOST_UID} && userdel -r $(getent passwd ${HOST_UID} | cut -d: -f1) || true) && \ + (getent group ${HOST_GID} && groupdel $(getent group ${HOST_GID} | cut -d: -f1) || true) && \ + groupadd -g ${HOST_GID} vibekit && \ + useradd -m -u ${HOST_UID} -g ${HOST_GID} -s /bin/bash vibekit + +# Install Node.js for MCP server support +RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y nodejs + # Set up Bun environment -ENV BUN_INSTALL="/home/.bun" +ENV BUN_INSTALL="/home/vibekit/.bun" ENV PATH="$BUN_INSTALL/bin:$PATH" # Install coding agents globally RUN bun add -g @anthropic-ai/claude-code@latest || echo "Claude CLI install failed" -RUN bun add -g @openai/codex@latest || echo "Codex CLI install failed" +RUN bun add -g @openai/codex@latest || echo "Codex CLI install failed" RUN bun add -g @google/gemini-cli@latest || echo "Gemini CLI install failed" RUN bun add -g opencode-ai@latest || echo "OpenCode CLI install failed" RUN bun add -g @vibe-kit/grok-cli@latest || echo "Grok CLI install failed" # Try alternative Claude installation RUN curl -sSL https://install.anthropic.com | bash || echo "Alternative Claude install failed" -ENV PATH="/root/.local/bin:$BUN_INSTALL/bin:$PATH" +ENV PATH="/home/vibekit/.local/bin:$BUN_INSTALL/bin:$PATH" # Install vibekit CLI globally and create symlink RUN bun add -g vibekit || echo "Vibekit install failed" -# Create workspace directory -RUN mkdir -p /workspace +# Create workspace directory and Claude directories, set ownership +RUN mkdir -p /workspace /home/vibekit/.claude && \ + chown -R vibekit:vibekit /workspace /home/vibekit + +# Switch to non-root user +USER vibekit WORKDIR /workspace # Expose common local development ports diff --git a/packages/cli/src/auth/claude-auth-helper.js b/packages/cli/src/auth/claude-auth-helper.js index e870045e..947c78e8 100644 --- a/packages/cli/src/auth/claude-auth-helper.js +++ b/packages/cli/src/auth/claude-auth-helper.js @@ -108,7 +108,7 @@ export class ClaudeAuthHelper { * @returns {Object} Command modification object */ static createClaudeWrapper(credentials, args) { - // Create bash wrapper that deep merges our settings into /root/.claude.json + // Create bash wrapper that deep merges our settings into /home/vibekit/.claude.json const settingsJson = JSON.stringify(credentials.settings); // Use base64 encoding to avoid shell escaping issues with JSON content @@ -170,6 +170,38 @@ MERGE_EOF }; } + /** + * Extract project-specific MCP server configurations from .mcp.json file + * @param {string} projectPath - Path to the project directory + * @returns {Object} Project MCP server configurations or empty object + */ + static async extractProjectMcpServers(projectPath) { + try { + const mcpJsonFile = path.join(projectPath, '.mcp.json'); + + console.log(chalk.blue(`[auth] 🔍 Looking for project MCP servers in: ${mcpJsonFile}`)); + + if (await fs.pathExists(mcpJsonFile)) { + console.log(chalk.blue('[auth] 📁 Project .mcp.json file exists, reading...')); + const mcpConfig = await fs.readJson(mcpJsonFile); + + if (mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object') { + console.log(chalk.green(`[auth] ✅ Found ${Object.keys(mcpConfig.mcpServers).length} project-level MCP servers`)); + return mcpConfig.mcpServers; + } else { + console.log(chalk.yellow('[auth] ⚠️ No mcpServers found in project .mcp.json')); + } + } else { + console.log(chalk.blue('[auth] 📝 No project .mcp.json file found')); + } + } catch (error) { + console.log(chalk.red(`[auth] ❌ Error extracting project MCP servers: ${error.message}`)); + } + + console.log(chalk.blue('[auth] 📤 Returning empty project mcpServers object: {}')); + return {}; + } + /** * Extract MCP server configurations from host .claude.json file * @returns {Object} MCP server configurations or empty object @@ -189,7 +221,7 @@ MERGE_EOF // Extract user-scope MCP server configs if (hostConfig.mcpServers && typeof hostConfig.mcpServers === 'object') { - console.log(chalk.green(`[auth] ✅ Found root-level mcpServers: ${JSON.stringify(hostConfig.mcpServers)}`)); + console.log(chalk.green(`[auth] ✅ Found ${Object.keys(hostConfig.mcpServers).length} root-level MCP servers`)); return hostConfig.mcpServers; } else { console.log(chalk.yellow('[auth] ⚠️ No root-level mcpServers found in host .claude.json')); @@ -205,7 +237,6 @@ MERGE_EOF } catch (error) { // Log more detailed error information console.log(chalk.red(`[auth] ❌ Error extracting MCP servers from host .claude.json: ${error.message}`)); - console.log(chalk.red(`[auth] 📍 Error stack: ${error.stack}`)); } console.log(chalk.blue('[auth] 📤 Returning empty mcpServers object: {}')); @@ -215,11 +246,21 @@ MERGE_EOF /** * Generate Claude CLI settings for onboarding bypass * @param {Object} tokenData - Raw token data from ClaudeAuth + * @param {string} projectPath - Project directory path (defaults to process.cwd()) * @returns {Promise} Settings object for Claude CLI */ - static async generateClaudeSettings(tokenData) { - // Extract MCP server configurations from host file + static async generateClaudeSettings(tokenData, projectPath = null) { + // Use current working directory if no project path provided + const workingDir = projectPath || process.cwd(); + + // Extract MCP server configurations from host file and project file const hostMcpServers = await this.extractHostMcpServers(); + const projectMcpServers = await this.extractProjectMcpServers(workingDir); + + // Merge host and project MCP servers (project takes precedence) + const mergedMcpServers = { ...hostMcpServers, ...projectMcpServers }; + + console.log(chalk.green(`[auth] 🔗 Merged ${Object.keys(mergedMcpServers).length} MCP server(s)`)); return { hasCompletedOnboarding: true, // Skip first-time setup @@ -231,8 +272,8 @@ MERGE_EOF 'new-user-warmup': 1 }, firstStartTime: new Date().toISOString(), - // Always inject user-scope MCP server configurations from host (even if empty) - mcpServers: hostMcpServers, + // Always inject merged MCP server configurations (host + project) + mcpServers: mergedMcpServers, // Project-level configuration for /workspace projects: { "/workspace": { diff --git a/packages/cli/src/sandbox/docker-sandbox.js b/packages/cli/src/sandbox/docker-sandbox.js index 32a7dec1..d7ef151a 100644 --- a/packages/cli/src/sandbox/docker-sandbox.js +++ b/packages/cli/src/sandbox/docker-sandbox.js @@ -9,6 +9,7 @@ import SandboxUtils from './sandbox-utils.js'; import SandboxConfig from './sandbox-config.js'; const exec = promisify(execCallback); +const CONTAINER_HOME = '/home/vibekit'; /** * Docker-based sandbox implementation @@ -102,6 +103,8 @@ export class DockerSandbox { const buildArgs = [ 'build', '-t', this.imageName, + '--build-arg', `HOST_UID=${process.getuid()}`, + '--build-arg', `HOST_GID=${process.getgid()}`, '-f', dockerfilePath, packageRoot ]; @@ -148,6 +151,18 @@ export class DockerSandbox { env: containerEnv }); + // Clean up temp injection file after a short delay (container has read it) + if (this._injectionTempFile) { + setTimeout(async () => { + try { + await fs.unlink(this._injectionTempFile); + this._injectionTempFile = null; + } catch (error) { + // Ignore cleanup errors + } + }, 5000); + } + child.on('close', (code) => { resolve({ code }); }); @@ -211,6 +226,13 @@ export class DockerSandbox { containerArgs.push(...options.additionalContainerArgs); } + // Copy CLAUDE.md files and .claude/agents, commands directories into container during startup + // This must happen AFTER additionalContainerArgs (which contain auth credentials) + await this.injectClaudeFiles(containerArgs, os.homedir()); + + // Inject environment variables dynamically from .mcp.json + await this.injectEnvironmentVariables(containerArgs); + // Mount authentication files if they exist (always enabled for persistence) // This works alongside OAuth injection to provide hybrid authentication: // 1. Files are mounted for base authentication and persistence @@ -228,18 +250,18 @@ export class DockerSandbox { // accessible from the sandbox and thus would make no sense and even provide // additional attack vectors to parts of the filesystem in the sandbox that should // not be configured to do so. - // Instead, OAuth credentials, settings, and user-scope MCP server configs are + // Instead, OAuth credentials, settings, and user-scope MCP server configs are // extracted from the host file and injected via environment variables above. // Mount .anthropic directory if it exists if (await fs.pathExists(anthropicDir)) { - containerArgs.push('-v', `${anthropicDir}:/root/.anthropic`); + containerArgs.push('-v', `${anthropicDir}:${CONTAINER_HOME}/.anthropic`); } // Mount .config directory if it exists (for potential Claude config) const claudeConfigDir = path.join(configDir, 'claude'); if (await fs.pathExists(claudeConfigDir)) { - containerArgs.push('-v', `${claudeConfigDir}:/root/.config/claude`); + containerArgs.push('-v', `${claudeConfigDir}:${CONTAINER_HOME}/.config/claude`); } // Add security options @@ -248,13 +270,176 @@ export class DockerSandbox { // Add image name containerArgs.push(this.imageName); - // Add command and arguments - containerArgs.push(command); - containerArgs.push(...args); + // Wrap command to execute file injection if present + // This allows the VIBEKIT_FILE_INJECTION env var to be decoded and executed + containerArgs.push('bash', '-c'); + + // Escape arguments properly for shell execution + const escapedArgs = args.map(arg => { + // Escape single quotes by replacing ' with '\'' + const escaped = arg.replace(/'/g, "'\\''"); + return `'${escaped}'`; + }).join(' '); + + const wrappedCommand = ` +( + if [ -f /tmp/vibekit-inject.sh ]; then + bash /tmp/vibekit-inject.sh + fi +) && exec ${command} ${escapedArgs} +`.trim(); + + containerArgs.push(wrappedCommand); return containerArgs; } + + async injectEnvironmentVariables(containerArgs) { + const projectMcpConfig = path.join(this.projectRoot, '.mcp.json'); + + if (await fs.pathExists(projectMcpConfig)) { + try { + const mcpConfig = JSON.parse(await fs.readFile(projectMcpConfig, 'utf8')); + const envVarsToInject = new Set(); + this.extractEnvVarsFromMcpConfig(mcpConfig, envVarsToInject); + + for (const envVar of envVarsToInject) { + if (process.env[envVar]) { + containerArgs.push('-e', `${envVar}=${process.env[envVar]}`); + } + } + } catch (error) { + console.warn(`[vibekit] Failed to parse .mcp.json: ${error.message}`); + } + } + } + + extractEnvVarsFromMcpConfig(config, envVarsSet) { + const extractFromValue = (value) => { + if (typeof value === 'string') { + const envMatches = value.match(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g); + if (envMatches) { + envMatches.forEach(match => { + const envVar = match.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/, '$1'); + envVarsSet.add(envVar); + }); + } + } else if (typeof value === 'object' && value !== null) { + Object.values(value).forEach(extractFromValue); + } else if (Array.isArray(value)) { + value.forEach(extractFromValue); + } + }; + + extractFromValue(config); + } + + async injectClaudeFiles(containerArgs, homeDir) { + const filesToInject = []; + + // User scope: ~/.claude/ + const userClaudeMd = path.join(homeDir, '.claude', 'CLAUDE.md'); + if (await fs.pathExists(userClaudeMd)) { + const content = await fs.readFile(userClaudeMd, 'utf8'); + filesToInject.push({ + content: content, + targetPath: `${CONTAINER_HOME}/.claude/CLAUDE.md` + }); + } + + const userDirs = ['agents', 'commands', 'scripts']; + for (const dirName of userDirs) { + const userDir = path.join(homeDir, '.claude', dirName); + if (await fs.pathExists(userDir)) { + const files = await this.readDirectoryRecursive(userDir); + for (const file of files) { + const relativePath = path.relative(userDir, file.path); + filesToInject.push({ + content: file.content, + targetPath: `${CONTAINER_HOME}/.claude/${dirName}/${relativePath}` + }); + } + } + } + + // Project scope: project/.claude/ and project/CLAUDE.md + const projectClaudeMd = path.join(this.projectRoot, 'CLAUDE.md'); + if (await fs.pathExists(projectClaudeMd)) { + const content = await fs.readFile(projectClaudeMd, 'utf8'); + filesToInject.push({ + content: content, + targetPath: '/workspace/CLAUDE.md' + }); + } + + for (const dirName of userDirs) { + const projectDir = path.join(this.projectRoot, '.claude', dirName); + if (await fs.pathExists(projectDir)) { + const files = await this.readDirectoryRecursive(projectDir); + for (const file of files) { + const relativePath = path.relative(projectDir, file.path); + filesToInject.push({ + content: file.content, + targetPath: `/workspace/.claude/${dirName}/${relativePath}` + }); + } + } + } + + // Inject files if any exist + if (filesToInject.length > 0) { + const injectionScript = this.createFileInjectionScript(filesToInject); + + // Write script to temp file and mount it (avoids E2BIG error from large env vars) + const tempFile = path.join(os.tmpdir(), `vibekit-inject-${Date.now()}.sh`); + await fs.writeFile(tempFile, injectionScript, { mode: 0o755 }); + + // Mount the injection script into container + containerArgs.push('-v', `${tempFile}:/tmp/vibekit-inject.sh:ro`); + + // Store temp file path for cleanup after container starts + this._injectionTempFile = tempFile; + } + } + + async readDirectoryRecursive(dir) { + const files = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const subFiles = await this.readDirectoryRecursive(fullPath); + files.push(...subFiles); + } else if (entry.isFile()) { + const content = await fs.readFile(fullPath, 'utf8'); + files.push({ path: fullPath, content }); + } + } + + return files; + } + + createFileInjectionScript(filesToInject) { + let script = '#!/bin/bash\n'; + + for (const file of filesToInject) { + const contentBase64 = Buffer.from(file.content).toString('base64'); + const targetDir = path.dirname(file.targetPath); + + script += `mkdir -p "${targetDir}"\n`; + script += `echo '${contentBase64}' | base64 -d > "${file.targetPath}"\n`; + + // Set executable permission for scripts + if (file.targetPath.endsWith('.sh') || file.targetPath.includes('/scripts/') || file.targetPath.includes('/commands/')) { + script += `chmod +x "${file.targetPath}"\n`; + } + } + + return script; + } + /** * Check if sandbox is available */