Checkpoint

This commit is contained in:
Onur Solmaz 2025-05-26 12:01:38 +02:00
parent 45606f3a1a
commit e8c7c9c9ff

View file

@ -1,7 +1,7 @@
import Docker from 'dockerode';
import path from 'path';
import { SandboxConfig, Credentials } from './types';
import chalk from 'chalk';
import Docker from "dockerode";
import path from "path";
import { SandboxConfig, Credentials } from "./types";
import chalk from "chalk";
export class ContainerManager {
private docker: Docker;
@ -23,19 +23,18 @@ export class ContainerManager {
// Start container
await container.start();
console.log(chalk.green('✓ Container started successfully'));
console.log(chalk.green("✓ Container started successfully"));
// Copy working directory into container
console.log(chalk.blue('Copying files into container...'));
console.log(chalk.blue("Copying files into container..."));
try {
await this._copyWorkingDirectory(container, containerConfig.workDir);
console.log(chalk.green('✓ Files copied successfully'));
console.log(chalk.green("✓ Files copied successfully"));
// Copy Claude configuration if it exists
await this._copyClaudeConfig(container);
} catch (error) {
console.error(chalk.red('File copy failed:'), error);
console.error(chalk.red("File copy failed:"), error);
// Clean up container on failure
await container.stop().catch(() => {});
await container.remove().catch(() => {});
@ -44,14 +43,18 @@ export class ContainerManager {
}
// Give the container a moment to initialize
await new Promise(resolve => setTimeout(resolve, 500));
console.log(chalk.green('Container initialization complete, returning container ID...'));
await new Promise((resolve) => setTimeout(resolve, 500));
console.log(
chalk.green(
"Container initialization complete, returning container ID..."
)
);
return container.id;
}
private async ensureImage(): Promise<void> {
const imageName = this.config.dockerImage || 'claude-code-sandbox:latest';
const imageName = this.config.dockerImage || "claude-code-sandbox:latest";
// Check if image already exists
try {
@ -102,11 +105,28 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | s
# Install Claude Code
RUN npm install -g @anthropic-ai/claude-code@latest
# Create workspace directory
RUN mkdir -p /workspace
# Create a non-root user with sudo privileges
RUN useradd -m -s /bin/bash claude && \\
echo 'claude ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers && \\
usermod -aG sudo claude
# Create workspace directory and set ownership
RUN mkdir -p /workspace && \\
chown -R claude:claude /workspace
# Switch to non-root user
USER claude
WORKDIR /workspace
# Create a wrapper script for git that prevents branch switching
RUN sudo mv /usr/bin/git /usr/bin/git.real && \
echo -e '#!/bin/bash\\nif [ ! -f /tmp/.branch-created ]; then\\n /usr/bin/git.real "$@"\\n if [[ "$1" == "checkout" ]] && [[ "$2" == "-b" ]]; then\\n touch /tmp/.branch-created\\n fi\\nelse\\n if [[ "$1" == "checkout" ]] && [[ "$2" != "-b" ]]; then\\n echo "Branch switching is disabled in claude-code-sandbox"\\n exit 1\\n fi\\n if [[ "$1" == "switch" ]]; then\\n echo "Branch switching is disabled in claude-code-sandbox"\\n exit 1\\n fi\\n /usr/bin/git.real "$@"\\nfi' | sudo tee /usr/bin/git > /dev/null && \
sudo chmod +x /usr/bin/git
# Set up entrypoint
ENTRYPOINT ["/bin/bash", "-c"]
`;
/*
RUN echo '#!/bin/bash\\n\\
# Allow the initial branch creation\\n\\
if [ ! -f /tmp/.branch-created ]; then\\n\\
@ -127,7 +147,6 @@ else\\n\\
/usr/bin/git "$@"\\n\\
fi' > /usr/local/bin/git && \\
chmod +x /usr/local/bin/git
# Create startup script
RUN echo '#!/bin/bash\\n\\
echo "Waiting for attachment..."\\n\\
@ -136,28 +155,23 @@ cd /workspace\\n\\
git checkout -b "$1"\\n\\
echo "Starting Claude Code on branch $1..."\\n\\
exec claude --dangerously-skip-permissions' > /start-claude.sh && \\
chmod +x /start-claude.sh
# Set up entrypoint
ENTRYPOINT ["/bin/bash", "-c"]
`;
chmod +x /start-claude.sh */
// Build image from string
const tarStream = require('tar-stream');
const tarStream = require("tar-stream");
const pack = tarStream.pack();
// Add Dockerfile to tar
pack.entry({ name: 'Dockerfile' }, dockerfile, (err: any) => {
pack.entry({ name: "Dockerfile" }, dockerfile, (err: any) => {
if (err) throw err;
pack.finalize();
});
// Convert to buffer for docker
const chunks: Buffer[] = [];
pack.on('data', (chunk: any) => chunks.push(chunk));
pack.on("data", (chunk: any) => chunks.push(chunk));
await new Promise((resolve) => {
pack.on('end', resolve);
pack.on("end", resolve);
});
const tarBuffer = Buffer.concat(chunks);
@ -167,41 +181,57 @@ ENTRYPOINT ["/bin/bash", "-c"]
// Wait for build to complete
await new Promise((resolve, reject) => {
this.docker.modem.followProgress(buildStream as any, (err: any, res: any) => {
if (err) reject(err);
else resolve(res);
}, (event: any) => {
if (event.stream) {
process.stdout.write(event.stream);
this.docker.modem.followProgress(
buildStream as any,
(err: any, res: any) => {
if (err) reject(err);
else resolve(res);
},
(event: any) => {
if (event.stream) {
process.stdout.write(event.stream);
}
}
});
);
});
}
private async buildImage(dockerfilePath: string, imageName: string): Promise<void> {
private async buildImage(
dockerfilePath: string,
imageName: string
): Promise<void> {
const buildContext = path.dirname(dockerfilePath);
const buildStream = await this.docker.buildImage({
context: buildContext,
src: [path.basename(dockerfilePath)],
}, {
dockerfile: path.basename(dockerfilePath),
t: imageName,
});
const buildStream = await this.docker.buildImage(
{
context: buildContext,
src: [path.basename(dockerfilePath)],
},
{
dockerfile: path.basename(dockerfilePath),
t: imageName,
}
);
await new Promise((resolve, reject) => {
this.docker.modem.followProgress(buildStream as any, (err: any, res: any) => {
if (err) reject(err);
else resolve(res);
}, (event: any) => {
if (event.stream) {
process.stdout.write(event.stream);
this.docker.modem.followProgress(
buildStream as any,
(err: any, res: any) => {
if (err) reject(err);
else resolve(res);
},
(event: any) => {
if (event.stream) {
process.stdout.write(event.stream);
}
}
});
);
});
}
private async createContainer(containerConfig: any): Promise<Docker.Container> {
private async createContainer(
containerConfig: any
): Promise<Docker.Container> {
const { credentials, workDir } = containerConfig;
// Prepare environment variables
@ -212,16 +242,18 @@ ENTRYPOINT ["/bin/bash", "-c"]
// Create container
const container = await this.docker.createContainer({
Image: this.config.dockerImage || 'claude-code-sandbox:latest',
name: `${this.config.containerPrefix || 'claude-code-sandbox'}-${Date.now()}`,
Image: this.config.dockerImage || "claude-code-sandbox:latest",
name: `${
this.config.containerPrefix || "claude-code-sandbox"
}-${Date.now()}`,
Env: env,
HostConfig: {
Binds: volumes,
AutoRemove: false,
NetworkMode: 'bridge',
NetworkMode: "bridge",
},
WorkingDir: '/workspace',
Cmd: ['/bin/bash', '-l'],
WorkingDir: "/workspace",
Cmd: ["/bin/bash", "-l"],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
@ -239,17 +271,17 @@ ENTRYPOINT ["/bin/bash", "-c"]
// Claude credentials from discovery
if (credentials.claude) {
switch (credentials.claude.type) {
case 'api_key':
case "api_key":
env.push(`ANTHROPIC_API_KEY=${credentials.claude.value}`);
break;
case 'bedrock':
env.push('CLAUDE_CODE_USE_BEDROCK=1');
case "bedrock":
env.push("CLAUDE_CODE_USE_BEDROCK=1");
if (credentials.claude.region) {
env.push(`AWS_REGION=${credentials.claude.region}`);
}
break;
case 'vertex':
env.push('CLAUDE_CODE_USE_VERTEX=1');
case "vertex":
env.push("CLAUDE_CODE_USE_VERTEX=1");
if (credentials.claude.project) {
env.push(`GOOGLE_CLOUD_PROJECT=${credentials.claude.project}`);
}
@ -266,7 +298,7 @@ ENTRYPOINT ["/bin/bash", "-c"]
}
// Additional config
env.push('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1');
env.push("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1");
if (this.config.maxThinkingTokens) {
env.push(`MAX_THINKING_TOKENS=${this.config.maxThinkingTokens}`);
}
@ -290,12 +322,12 @@ ENTRYPOINT ["/bin/bash", "-c"]
// Mount SSH keys if available
if (credentials.github?.sshKey) {
volumes.push(`${process.env.HOME}/.ssh:/root/.ssh:ro`);
volumes.push(`${process.env.HOME}/.ssh:/home/claude/.ssh:ro`);
}
// Mount git config if available
if (credentials.github?.gitConfig) {
volumes.push(`${process.env.HOME}/.gitconfig:/root/.gitconfig:ro`);
volumes.push(`${process.env.HOME}/.gitconfig:/home/claude/.gitconfig:ro`);
}
// Add custom volumes
@ -309,44 +341,55 @@ ENTRYPOINT ["/bin/bash", "-c"]
async attach(containerId: string, branchName?: string): Promise<void> {
const container = this.containers.get(containerId);
if (!container) {
throw new Error('Container not found');
throw new Error("Container not found");
}
console.log(chalk.blue('Connecting to container...'));
console.log(chalk.blue("Connecting to container..."));
// Use provided branch name or generate one
const targetBranch = branchName || `claude/${new Date().toISOString().replace(/[:.]/g, '-').split('T')[0]}-${Date.now()}`;
const targetBranch =
branchName ||
`claude/${
new Date().toISOString().replace(/[:.]/g, "-").split("T")[0]
}-${Date.now()}`;
// First, set up the git branch and create startup script
try {
console.log(chalk.green('Setting up git branch and startup script...'));
console.log(chalk.green("Setting up git branch and startup script..."));
// Create different startup scripts based on autoStartClaude setting
const startupScript = this.config.autoStartClaude ? `#!/bin/bash
const startupScript = this.config.autoStartClaude
? `#!/bin/bash
echo "🚀 Starting Claude Code automatically..."
echo "Press Ctrl+C to interrupt and access shell"
echo ""
claude --dangerously-skip-permissions
echo ""
echo "Claude exited. You now have access to the shell."
echo "Type \\"claude --dangerously-skip-permissions\\" to restart Claude"
echo "Type \\"exit\\" to end the session"
exec /bin/bash` : `#!/bin/bash
echo "Type \"claude --dangerously-skip-permissions\" to restart Claude"
echo "Type \"exit\" to end the session"
exec /bin/bash`
: `#!/bin/bash
echo "Welcome to Claude Code Sandbox!"
echo "Type \\"claude --dangerously-skip-permissions\\" to start Claude Code"
echo "Type \\"exit\\" to end the session"
echo "Type \"claude --dangerously-skip-permissions\" to start Claude Code"
echo "Type \"exit\" to end the session"
exec /bin/bash`;
const setupExec = await container.exec({
Cmd: ['/bin/bash', '-c', `
Cmd: [
"/bin/bash",
"-c",
`
cd /workspace &&
sudo chown -R claude:claude /workspace &&
git config --global --add safe.directory /workspace &&
git checkout -b "${targetBranch}" &&
echo "✓ Created branch: ${targetBranch}" &&
echo '${startupScript}' > /start-session.sh &&
chmod +x /start-session.sh &&
echo '${startupScript}' > /home/claude/start-session.sh &&
chmod +x /home/claude/start-session.sh &&
echo "✓ Startup script created"
`],
`,
],
AttachStdout: true,
AttachStderr: true,
});
@ -355,45 +398,53 @@ exec /bin/bash`;
// Wait for setup to complete
await new Promise<void>((resolve, reject) => {
let output = '';
setupStream.on('data', (chunk) => {
let output = "";
setupStream.on("data", (chunk) => {
output += chunk.toString();
process.stdout.write(chunk);
});
setupStream.on('end', () => {
if (output.includes('✓ Created branch') && output.includes('✓ Startup script created')) {
setupStream.on("end", () => {
if (
output.includes("✓ Created branch") &&
output.includes("✓ Startup script created")
) {
resolve();
} else {
reject(new Error('Setup failed'));
reject(new Error("Setup failed"));
}
});
setupStream.on('error', reject);
setupStream.on("error", reject);
});
console.log(chalk.green('✓ Container setup completed'));
console.log(chalk.green("✓ Container setup completed"));
} catch (error) {
console.error(chalk.red('Setup failed:'), error);
console.error(chalk.red("Setup failed:"), error);
throw error;
}
// Now create an interactive session that runs our startup script
console.log(chalk.blue('Starting interactive session...'));
console.log(chalk.blue("Starting interactive session..."));
if (this.config.autoStartClaude) {
console.log(chalk.yellow('Claude Code will start automatically'));
console.log(chalk.yellow('Press Ctrl+C to interrupt Claude and access the shell'));
console.log(chalk.yellow("Claude Code will start automatically"));
console.log(
chalk.yellow("Press Ctrl+C to interrupt Claude and access the shell")
);
} else {
console.log(chalk.yellow('Type "claude --dangerously-skip-permissions" to start Claude Code'));
console.log(
chalk.yellow(
'Type "claude --dangerously-skip-permissions" to start Claude Code'
)
);
}
console.log(chalk.yellow('Press Ctrl+D or type "exit" to end the session'));
const exec = await container.exec({
Cmd: ['/start-session.sh'],
Cmd: ["/home/claude/start-session.sh"],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
WorkingDir: '/workspace',
WorkingDir: "/workspace",
});
// Start the exec with hijack mode for proper TTY
@ -423,7 +474,7 @@ exec /bin/bash`;
// Initial resize
await resize();
process.stdout.on('resize', resize);
process.stdout.on("resize", resize);
// Connect streams bidirectionally
stream.pipe(process.stdout);
@ -431,13 +482,13 @@ exec /bin/bash`;
// Set up proper cleanup
const cleanup = () => {
console.log(chalk.yellow('\nCleaning up session...'));
console.log(chalk.yellow("\nCleaning up session..."));
if (process.stdin.isTTY) {
process.stdin.setRawMode(originalRawMode);
}
process.stdin.pause();
process.stdout.removeListener('resize', resize);
if (stream && typeof stream.end === 'function') {
process.stdout.removeListener("resize", resize);
if (stream && typeof stream.end === "function") {
stream.end();
}
};
@ -449,7 +500,7 @@ exec /bin/bash`;
const handleEnd = () => {
if (!sessionEnded) {
sessionEnded = true;
console.log(chalk.yellow('\nContainer session ended'));
console.log(chalk.yellow("\nContainer session ended"));
cleanup();
resolve();
}
@ -458,15 +509,15 @@ exec /bin/bash`;
const handleError = (err: Error) => {
if (!sessionEnded) {
sessionEnded = true;
console.error(chalk.red('Stream error:'), err);
console.error(chalk.red("Stream error:"), err);
cleanup();
reject(err);
}
};
stream.on('end', handleEnd);
stream.on('close', handleEnd);
stream.on('error', handleError);
stream.on("end", handleEnd);
stream.on("close", handleEnd);
stream.on("error", handleError);
// Also monitor the exec process
const checkExec = async () => {
@ -487,27 +538,39 @@ exec /bin/bash`;
const statusInterval = setInterval(checkExec, 1000);
// Clean up interval when session ends
stream.on('end', () => clearInterval(statusInterval));
stream.on('close', () => clearInterval(statusInterval));
stream.on("end", () => clearInterval(statusInterval));
stream.on("close", () => clearInterval(statusInterval));
});
}
private async _copyWorkingDirectory(container: Docker.Container, workDir: string): Promise<void> {
const { execSync } = require('child_process');
const fs = require('fs');
private async _copyWorkingDirectory(
container: Docker.Container,
workDir: string
): Promise<void> {
const { execSync } = require("child_process");
const fs = require("fs");
try {
// Get list of git-tracked files (including uncommitted changes)
const trackedFiles = execSync('git ls-files', {
const trackedFiles = execSync("git ls-files", {
cwd: workDir,
encoding: 'utf-8'
}).trim().split('\n').filter((f: string) => f);
encoding: "utf-8",
})
.trim()
.split("\n")
.filter((f: string) => f);
// Get list of untracked files that aren't ignored
const untrackedFiles = execSync('git ls-files --others --exclude-standard', {
cwd: workDir,
encoding: 'utf-8'
}).trim().split('\n').filter((f: string) => f);
const untrackedFiles = execSync(
"git ls-files --others --exclude-standard",
{
cwd: workDir,
encoding: "utf-8",
}
)
.trim()
.split("\n")
.filter((f: string) => f);
// Combine all files
const allFiles = [...trackedFiles, ...untrackedFiles];
@ -517,23 +580,23 @@ exec /bin/bash`;
// Create tar archive using git archive for tracked files + untracked files
const tarFile = `/tmp/claude-sandbox-${Date.now()}.tar`;
console.log(chalk.green('Creating archive of tracked files...'));
console.log(chalk.green("Creating archive of tracked files..."));
// First create archive of tracked files using git archive
execSync(`git archive --format=tar -o "${tarFile}" HEAD`, {
cwd: workDir,
stdio: 'pipe'
stdio: "pipe",
});
// Add untracked files if any
if (untrackedFiles.length > 0) {
// Create a file list for tar
const fileListPath = `/tmp/claude-sandbox-files-${Date.now()}.txt`;
fs.writeFileSync(fileListPath, untrackedFiles.join('\n'));
fs.writeFileSync(fileListPath, untrackedFiles.join("\n"));
// Append untracked files to the tar
execSync(`tar -rf "${tarFile}" --files-from="${fileListPath}"`, {
cwd: workDir,
stdio: 'pipe'
stdio: "pipe",
});
fs.unlinkSync(fileListPath);
@ -542,36 +605,36 @@ exec /bin/bash`;
// Read and copy the tar file in chunks to avoid memory issues
const stream = fs.createReadStream(tarFile);
console.log(chalk.green('Uploading files to container...'));
console.log(chalk.green("Uploading files to container..."));
// Add timeout for putArchive
const uploadPromise = container.putArchive(stream, {
path: '/workspace'
path: "/workspace",
});
// Wait for both upload and stream to complete
await Promise.all([
uploadPromise,
new Promise<void>((resolve, reject) => {
stream.on('end', () => {
console.log(chalk.green('Stream ended'));
stream.on("end", () => {
console.log(chalk.green("Stream ended"));
resolve();
});
stream.on('error', reject);
})
stream.on("error", reject);
}),
]);
console.log(chalk.green('Upload completed'));
console.log(chalk.green("Upload completed"));
// Clean up
fs.unlinkSync(tarFile);
// Also copy .git directory to preserve git history
console.log(chalk.green('Copying git history...'));
console.log(chalk.green("Copying git history..."));
const gitTarFile = `/tmp/claude-sandbox-git-${Date.now()}.tar`;
execSync(`tar -cf "${gitTarFile}" .git`, {
cwd: workDir,
stdio: 'pipe'
stdio: "pipe",
});
try {
@ -579,17 +642,16 @@ exec /bin/bash`;
// Upload git archive
await container.putArchive(gitStream, {
path: '/workspace'
path: "/workspace",
});
console.log(chalk.green('Git history upload completed'));
console.log(chalk.green("Git history upload completed"));
// Clean up
fs.unlinkSync(gitTarFile);
console.log(chalk.green('File copy completed'));
console.log(chalk.green("File copy completed"));
} catch (error) {
console.error(chalk.red('Git history copy failed:'), error);
console.error(chalk.red("Git history copy failed:"), error);
// Clean up the tar file even if upload failed
try {
fs.unlinkSync(gitTarFile);
@ -598,15 +660,14 @@ exec /bin/bash`;
}
throw error;
}
} catch (error) {
console.error(chalk.red('Failed to copy files:'), error);
console.error(chalk.red("Failed to copy files:"), error);
throw error;
}
}
private async _copyClaudeConfig(container: Docker.Container): Promise<void> {
const fs = require('fs');
const fs = require("fs");
if (!this.config.claudeConfigPath) {
return;
@ -615,51 +676,77 @@ exec /bin/bash`;
try {
// Check if the Claude config file exists
if (!fs.existsSync(this.config.claudeConfigPath)) {
console.log(chalk.yellow(`Claude config not found at ${this.config.claudeConfigPath}, skipping...`));
console.log(
chalk.yellow(
`Claude config not found at ${this.config.claudeConfigPath}, skipping...`
)
);
return;
}
console.log(chalk.blue('Copying Claude configuration...'));
console.log(chalk.blue("Copying Claude configuration..."));
// Read the Claude config file
const configContent = fs.readFileSync(this.config.claudeConfigPath, 'utf-8');
const configContent = fs.readFileSync(
this.config.claudeConfigPath,
"utf-8"
);
// Create a temporary tar file with the Claude config
const tarFile = `/tmp/claude-config-${Date.now()}.tar`;
const tarStream = require('tar-stream');
const tarStream = require("tar-stream");
const pack = tarStream.pack();
// Add the .claude.json file to the tar
pack.entry({ name: '.claude.json', mode: 0o600 }, configContent, (err: any) => {
if (err) throw err;
pack.finalize();
});
pack.entry(
{ name: ".claude.json", mode: 0o600 },
configContent,
(err: any) => {
if (err) throw err;
pack.finalize();
}
);
// Write the tar to a file
const chunks: Buffer[] = [];
pack.on('data', (chunk: any) => chunks.push(chunk));
pack.on("data", (chunk: any) => chunks.push(chunk));
await new Promise<void>((resolve, reject) => {
pack.on('end', () => {
pack.on("end", () => {
fs.writeFileSync(tarFile, Buffer.concat(chunks));
resolve();
});
pack.on('error', reject);
pack.on("error", reject);
});
// Copy the tar file to the container's root home directory
// Copy the tar file to the container's claude user home directory
const stream = fs.createReadStream(tarFile);
await container.putArchive(stream, {
path: '/root' // Copy to root's home directory
path: "/home/claude", // Copy to claude user's home directory
});
// Clean up
fs.unlinkSync(tarFile);
console.log(chalk.green('✓ Claude configuration copied successfully'));
// Fix permissions on the copied file
const fixPermsExec = await container.exec({
Cmd: [
"/bin/bash",
"-c",
"sudo chown claude:claude /home/claude/.claude.json && chmod 600 /home/claude/.claude.json",
],
AttachStdout: false,
AttachStderr: false,
});
await fixPermsExec.start({});
console.log(chalk.green("✓ Claude configuration copied successfully"));
} catch (error) {
console.error(chalk.yellow('Warning: Failed to copy Claude configuration:'), error);
console.error(
chalk.yellow("Warning: Failed to copy Claude configuration:"),
error
);
// Don't throw - this is not critical for container operation
}
}
@ -675,4 +762,4 @@ exec /bin/bash`;
}
this.containers.clear();
}
}
}