opencode-box
Version:
A secure, lightweight Docker environment for AI-assisted development with OpenCode
459 lines (383 loc) • 16.1 kB
JavaScript
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
const url = require('url');
// Simple colored output functions
const log = {
info: (msg) => console.log(`\x1b[34m[INFO]\x1b[0m ${msg}`),
success: (msg) => console.log(`\x1b[32m[SUCCESS]\x1b[0m ${msg}`),
warning: (msg) => console.log(`\x1b[33m[WARNING]\x1b[0m ${msg}`),
error: (msg) => console.log(`\x1b[31m[ERROR]\x1b[0m ${msg}`)
};
// Security validation functions
function validateRepositoryUrl(repoUrl) {
if (!repoUrl || typeof repoUrl !== 'string') {
throw new Error('Repository URL is required and must be a string');
}
// Remove any potential command injection characters
const sanitized = repoUrl.trim();
// Check for dangerous characters that could be used for command injection
const dangerousChars = /[;&|`$(){}[\]<>]/;
if (dangerousChars.test(sanitized)) {
throw new Error('Repository URL contains invalid characters');
}
// Validate URL format
try {
const parsedUrl = new URL(sanitized);
// Only allow specific protocols
if (!['https:', 'ssh:', 'git:'].includes(parsedUrl.protocol)) {
throw new Error('Only HTTPS, SSH, and Git protocols are allowed');
}
// Validate hostname for trusted Git providers
const trustedHosts = [
'github.com',
'gitlab.com',
'bitbucket.org',
'dev.azure.com',
'ssh.dev.azure.com'
];
if (!trustedHosts.includes(parsedUrl.hostname)) {
throw new Error(`Untrusted hostname: ${parsedUrl.hostname}. Only ${trustedHosts.join(', ')} are allowed`);
}
return sanitized;
} catch (urlError) {
// Try SSH format (git@github.com:user/repo.git)
const sshPattern = /^git@([a-zA-Z0-9.-]+):([a-zA-Z0-9._/-]+)\.git$/;
const sshMatch = sanitized.match(sshPattern);
if (sshMatch) {
const hostname = sshMatch[1];
const trustedSshHosts = ['github.com', 'gitlab.com', 'bitbucket.org'];
if (!trustedSshHosts.includes(hostname)) {
throw new Error(`Untrusted SSH hostname: ${hostname}`);
}
return sanitized;
}
throw new Error('Invalid repository URL format');
}
}
function validateBranchName(branchName) {
if (!branchName || typeof branchName !== 'string') {
throw new Error('Branch name is required and must be a string');
}
const sanitized = branchName.trim();
// Check for dangerous characters and git-specific invalid characters
const invalidChars = /[;&|`$(){}[\]<>~^:?*\\\s]/;
if (invalidChars.test(sanitized)) {
throw new Error('Branch name contains invalid characters');
}
// Additional git branch name validation
if (sanitized.startsWith('-') || sanitized.endsWith('.') || sanitized.includes('..')) {
throw new Error('Invalid branch name format');
}
// Limit length to prevent buffer overflow attacks
if (sanitized.length > 250) {
throw new Error('Branch name too long');
}
return sanitized;
}
function validateRepoName(repoName) {
if (!repoName || typeof repoName !== 'string') {
throw new Error('Repository name is required and must be a string');
}
const sanitized = repoName.trim();
// Only allow alphanumeric, hyphens, underscores, and dots
const validPattern = /^[a-zA-Z0-9._-]+$/;
if (!validPattern.test(sanitized)) {
throw new Error('Repository name contains invalid characters');
}
// Prevent directory traversal
if (sanitized.includes('..') || sanitized.startsWith('.')) {
throw new Error('Invalid repository name format');
}
// Limit length
if (sanitized.length > 100) {
throw new Error('Repository name too long');
}
return sanitized;
}
function checkRequirements() {
log.info('Checking system requirements...');
// Check if Docker is installed and accessible
try {
const dockerVersion = execSync('docker --version', { encoding: 'utf8', timeout: 5000 });
log.info(`Docker found: ${dockerVersion.trim()}`);
} catch (error) {
log.error('Docker is not installed, not in PATH, or not accessible');
log.error('Please install Docker and ensure it\'s running');
process.exit(1);
}
// Check Docker daemon is running
try {
execSync('docker info', { stdio: 'ignore', timeout: 5000 });
log.info('Docker daemon is running');
} catch (error) {
log.error('Docker daemon is not running. Please start Docker');
process.exit(1);
}
// Check if we're in a git repository
try {
execSync('git rev-parse --git-dir', { stdio: 'ignore', timeout: 5000 });
log.info('Git repository detected');
} catch (error) {
log.error('Not in a git repository. Please run opencodebox from inside a git project.');
process.exit(1);
}
// Check SSH agent or credentials
if (!process.env.SSH_AUTH_SOCK) {
log.error('SSH agent not found. Please start ssh-agent and add your SSH keys.');
if (process.platform === 'darwin') {
log.info('On macOS, try: eval "$(ssh-agent -s)" && ssh-add --apple-use-keychain ~/.ssh/id_rsa');
} else {
log.info('Run: eval "$(ssh-agent -s)" && ssh-add ~/.ssh/id_rsa');
}
log.info('Verify keys are loaded with: ssh-add -l');
process.exit(1);
}
// Verify SSH agent is accessible
try {
if (!fs.existsSync(process.env.SSH_AUTH_SOCK)) {
throw new Error('SSH socket does not exist');
}
log.info('SSH agent is accessible');
} catch (error) {
log.error(`SSH agent socket is not accessible: ${error.message}`);
if (process.platform === 'darwin') {
log.info('On macOS, SSH agent issues are common. Try restarting your terminal or running:');
log.info('eval "$(ssh-agent -s)" && ssh-add --apple-use-keychain');
} else {
log.info('Try: eval "$(ssh-agent -s)" && ssh-add ~/.ssh/id_rsa');
}
log.info('Verify keys are loaded with: ssh-add -l');
log.info('Test GitHub access with: ssh -T git@github.com');
process.exit(1);
}
// Verify SSH agent has keys loaded
try {
const sshKeys = execSync('ssh-add -l', { encoding: 'utf8', timeout: 5000 });
if (sshKeys.includes('no identities') || sshKeys.trim() === '') {
throw new Error('No SSH keys loaded in agent');
}
log.info('SSH keys are loaded in agent');
} catch (error) {
log.error('SSH agent has no keys loaded');
if (process.platform === 'darwin') {
log.info('Add keys with: ssh-add --apple-use-keychain ~/.ssh/id_rsa');
} else {
log.info('Add keys with: ssh-add ~/.ssh/id_rsa');
}
log.info('Verify with: ssh-add -l');
process.exit(1);
}
log.success('All requirements satisfied');
}
function getRepoInfo() {
try {
// Get remote URL
const remoteUrl = execSync('git config --get remote.origin.url', { encoding: 'utf8' }).trim();
// Get current branch
const currentBranch = execSync('git branch --show-current', { encoding: 'utf8' }).trim();
// Get repository name from URL
const repoName = path.basename(remoteUrl, '.git');
// Validate all inputs
const validatedUrl = validateRepositoryUrl(remoteUrl);
const validatedBranch = validateBranchName(currentBranch);
const validatedName = validateRepoName(repoName);
return {
url: validatedUrl,
name: validatedName,
branch: validatedBranch
};
} catch (error) {
log.error(`Failed to get repository information: ${error.message}`);
process.exit(1);
}
}
function buildDockerImage() {
const imageName = 'opencode-box';
// Check if image exists
try {
const images = execSync(`docker images ${imageName} --format "{{.Repository}}"`, { encoding: 'utf8' });
if (images.includes(imageName)) {
log.info(`Docker image '${imageName}' already exists, skipping build`);
return;
}
} catch (error) {
// Image doesn't exist, proceed with build
}
log.info('Building Docker image...');
try {
const dockerfilePath = path.join(__dirname, '..', 'Dockerfile');
const contextPath = path.dirname(dockerfilePath);
execSync(`docker build -t ${imageName} "${contextPath}"`, {
stdio: 'inherit',
cwd: contextPath
});
log.success('Docker image built successfully');
} catch (error) {
log.error('Failed to build Docker image');
process.exit(1);
}
}
function findOpenCodeConfigs() {
const homeDir = os.homedir();
const potentialPaths = [
path.join(homeDir, '.local', 'share', 'opencode'),
path.join(homeDir, '.config', 'opencode'),
path.join(homeDir, '.shared', 'opencode'), // Alternative path mentioned by user
path.join(homeDir, '.opencode'),
path.join(homeDir, '.local', 'opencode'),
path.join(homeDir, '.config', 'opencode-ai')
];
const foundConfigs = [];
potentialPaths.forEach(configPath => {
if (fs.existsSync(configPath)) {
log.success(`Found OpenCode config at: ${configPath}`);
foundConfigs.push(configPath);
}
});
if (foundConfigs.length === 0) {
log.warning('No OpenCode configuration directories found');
}
return {
localShare: foundConfigs.find(p => p.includes('.local/share/opencode')),
config: foundConfigs.find(p => p.includes('.config/opencode')),
alternative: foundConfigs.find(p => p.includes('.shared/opencode')),
all: foundConfigs
};
} function runContainer(repoInfo) {
// Use timestamp to ensure unique container names for each run
const timestamp = Date.now();
const containerName = `opencode-box-container-${timestamp}`;
log.info('Starting container with secure credential forwarding...');
const dockerArgs = [
'run', '-it', '--rm', // --rm ensures automatic cleanup when container exits
'--name', containerName,
// Security hardening
'--security-opt', 'no-new-privileges:true', // Prevent privilege escalation
'--cap-drop', 'ALL', // Drop all capabilities
'--cap-add', 'DAC_OVERRIDE', // Only add necessary capabilities for file access
'--cap-add', 'SETGID', // Add capability for SSH socket access
'--cap-add', 'SETUID', // Add capability for user switching if needed
'--cap-add', 'CHOWN', // Add capability for changing file ownership
// Network security
'--network', 'bridge', // Use default bridge network
// SSH and Git configuration - mount SSH socket and directory
'-v', `${process.env.SSH_AUTH_SOCK}:/ssh-agent`, // Mount to a predictable path
'-e', 'SSH_AUTH_SOCK=/ssh-agent', // Set the socket path inside container
// Environment variables (validated inputs)
'-e', `REPO_URL=${repoInfo.url}`,
'-e', `REPO_NAME=${repoInfo.name}`,
'-e', `REPO_BRANCH=${repoInfo.branch}`
];
// SSH access is handled via SSH agent forwarding only
// No SSH directory mounting for security reasons
// Conditionally mount git config if it exists
const gitConfig = path.join(os.homedir(), '.gitconfig');
if (fs.existsSync(gitConfig)) {
dockerArgs.push('-v', `${gitConfig}:/home/node/.gitconfig:ro`);
}
// Find and copy OpenCode config files from host to container
const openCodeConfigs = findOpenCodeConfigs();
// Mount host config directories as read-only so they can be copied inside container
if (openCodeConfigs.localShare) {
dockerArgs.push('-v', `${openCodeConfigs.localShare}:/tmp/host-opencode-local-share:ro`);
dockerArgs.push('-e', 'HOST_OPENCODE_LOCAL_SHARE=/tmp/host-opencode-local-share');
log.info(`Will copy OpenCode local/share config from: ${openCodeConfigs.localShare}`);
}
if (openCodeConfigs.config) {
dockerArgs.push('-v', `${openCodeConfigs.config}:/tmp/host-opencode-config:ro`);
dockerArgs.push('-e', 'HOST_OPENCODE_CONFIG=/tmp/host-opencode-config');
log.info(`Will copy OpenCode config from: ${openCodeConfigs.config}`);
}
if (openCodeConfigs.all.length === 0) {
log.warning('No OpenCode configurations found on host - container will start with default settings');
}
// Create dedicated writable volumes for container operation
const stateVolume = `opencode-box-state-${timestamp}`;
const workspaceVolume = `opencode-box-workspace-${timestamp}`;
dockerArgs.push('-v', `${stateVolume}:/home/node/.local/state`);
dockerArgs.push('-v', `${workspaceVolume}:/workspace`);
// Add the image and command
dockerArgs.push('opencode-box', '/app/entrypoint.sh');
try {
log.info(`Starting OpenCode environment...`);
log.info(`Repository: ${repoInfo.name} (${repoInfo.branch})`);
const child = spawn('docker', dockerArgs, {
stdio: 'inherit',
detached: false
});
child.on('exit', (code) => {
if (code === 0) {
log.success('OpenCode Box session completed successfully');
} else {
log.error(`OpenCode Box session ended with exit code ${code}`);
}
// Clean up the temporary volumes
const volumes = [stateVolume, workspaceVolume];
volumes.forEach(volume => {
try {
execSync(`docker volume rm ${volume}`, { stdio: 'pipe', timeout: 10000 });
log.info(`Cleaned up temporary volume: ${volume}`);
} catch (cleanupError) {
log.warning(`Failed to clean up volume ${volume}: ${cleanupError.message}`);
}
});
});
// Handle process termination gracefully
process.on('SIGINT', () => {
log.info('Received SIGINT, stopping container...');
try {
execSync(`docker stop ${containerName}`, { stdio: 'pipe', timeout: 10000 });
} catch (stopError) {
log.warning('Failed to stop container gracefully');
}
});
process.on('SIGTERM', () => {
log.info('Received SIGTERM, stopping container...');
try {
execSync(`docker stop ${containerName}`, { stdio: 'pipe', timeout: 10000 });
} catch (stopError) {
log.warning('Failed to stop container gracefully');
}
});
} catch (error) {
log.error('Failed to run container');
console.error(error.message);
process.exit(1);
}
}
// Main execution
function main() {
// Handle help and version flags
if (process.argv.includes('--help') || process.argv.includes('-h')) {
console.log(`
OpenCode Box - A secure Docker environment for AI-assisted development with OpenCode
Usage: opencodebox
Requirements:
- Docker installed and running
- Git repository (run from inside a git project)
- SSH agent with credentials loaded
Example:
cd /path/to/your/git/project
opencodebox
`);
return;
}
if (process.argv.includes('--version') || process.argv.includes('-v')) {
console.log('opencodebox version 1.0.0');
return;
}
log.info('Starting OpenCode Box...');
// Check requirements
checkRequirements();
// Get repository information
const repoInfo = getRepoInfo();
// Build Docker image if needed
buildDockerImage();
// Run container
runContainer(repoInfo);
}
// Run the tool
main();