Checkpoint

This commit is contained in:
Onur Solmaz 2025-05-28 15:06:34 +02:00
parent 9909168f72
commit 8db92c8b01
7 changed files with 166 additions and 361 deletions

View file

@ -47,15 +47,17 @@ program
.description("Run Claude Code in isolated Docker containers")
.version("0.1.0");
// Default command (start with web UI)
// Default command (always web UI)
program
.action(async () => {
.option("--shell <shell>", "Start with 'claude' or 'bash' shell", /^(claude|bash)$/i)
.action(async (options) => {
console.log(chalk.blue("🚀 Starting Claude Sandbox..."));
const config = await loadConfig("./claude-sandbox.config.json");
config.webUI = true; // Always use web UI by default
config.detached = true; // Web UI requires detached mode
config.includeUntracked = false; // Don't include untracked files by default
config.includeUntracked = false;
if (options.shell) {
config.defaultShell = options.shell.toLowerCase();
}
const sandbox = new ClaudeSandbox(config);
await sandbox.run();
@ -67,22 +69,23 @@ program
.description("Start a new Claude Sandbox container")
.option("-c, --config <path>", "Configuration file", "./claude-sandbox.config.json")
.option("-n, --name <name>", "Container name prefix")
.option("--no-web", "Disable web UI (use terminal attach)")
.option("--no-push", "Disable automatic branch pushing")
.option("--no-pr", "Disable automatic PR creation")
.option("--include-untracked", "Include untracked files when copying to container")
.option("-b, --branch <branch>", "Switch to specific branch on container start (creates if doesn't exist)")
.option("--shell <shell>", "Start with 'claude' or 'bash' shell", /^(claude|bash)$/i)
.action(async (options) => {
console.log(chalk.blue("🚀 Starting new Claude Sandbox container..."));
const config = await loadConfig(options.config);
config.webUI = options.web !== false;
config.detached = config.webUI; // Web UI requires detached
config.containerPrefix = options.name || config.containerPrefix;
config.autoPush = options.push !== false;
config.autoCreatePR = options.pr !== false;
config.includeUntracked = options.includeUntracked || false;
config.targetBranch = options.branch;
if (options.shell) {
config.defaultShell = options.shell.toLowerCase();
}
const sandbox = new ClaudeSandbox(config);
await sandbox.run();
@ -92,8 +95,7 @@ program
program
.command("attach [container-id]")
.description("Attach to an existing Claude Sandbox container")
.option("--no-web", "Use terminal attach instead of web UI")
.action(async (containerId, options) => {
.action(async (containerId) => {
const spinner = ora("Looking for containers...").start();
try {
@ -111,56 +113,20 @@ program
}
}
spinner.text = "Attaching to container...";
spinner.text = "Launching web UI...";
if (options.web !== false) {
// Launch web UI for existing container
const webServer = new WebUIServer(docker);
const url = await webServer.start();
const fullUrl = `${url}?container=${targetContainerId}`;
spinner.succeed(chalk.green(`Web UI available at: ${fullUrl}`));
await webServer.openInBrowser(fullUrl);
console.log(chalk.yellow("Keep this terminal open to maintain the session"));
// Keep process running
await new Promise(() => {});
} else {
// Direct terminal attach
spinner.stop();
console.log(chalk.blue(`Attaching to container ${targetContainerId.substring(0, 12)}...`));
const container = docker.getContainer(targetContainerId);
const stream = await container.attach({
stream: true,
stdin: true,
stdout: true,
stderr: true
});
// Set up TTY
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
// Connect streams
stream.pipe(process.stdout);
process.stdin.pipe(stream);
// Handle cleanup
const cleanup = () => {
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
stream.end();
};
process.on('SIGINT', cleanup);
stream.on('end', cleanup);
}
// Always launch web UI
const webServer = new WebUIServer(docker);
const url = await webServer.start();
const fullUrl = `${url}?container=${targetContainerId}`;
spinner.succeed(chalk.green(`Web UI available at: ${fullUrl}`));
await webServer.openInBrowser(fullUrl);
console.log(chalk.yellow("Keep this terminal open to maintain the session"));
// Keep process running
await new Promise(() => {});
} catch (error: any) {
spinner.fail(chalk.red(`Failed: ${error.message}`));
process.exit(1);

View file

@ -5,10 +5,10 @@ import { SandboxConfig } from "./types";
const DEFAULT_CONFIG: SandboxConfig = {
dockerImage: "claude-code-sandbox:latest",
detached: false,
autoPush: true,
autoCreatePR: true,
autoStartClaude: true,
defaultShell: "claude", // Default to Claude mode for backward compatibility
claudeConfigPath: path.join(os.homedir(), ".claude.json"),
setupCommands: [], // Example: ["npm install", "pip install -r requirements.txt"]
allowedTools: ["*"], // All tools allowed in sandbox

View file

@ -49,6 +49,9 @@ export class ContainerManager {
await new Promise((resolve) => setTimeout(resolve, 500));
console.log(chalk.green("✓ Container ready"));
// Set up git branch and startup script
await this.setupGitAndStartupScript(container, containerConfig.branchName);
// Run setup commands
await this.runSetupCommands(container);
@ -487,231 +490,6 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\
return volumes;
}
async attach(containerId: string, branchName?: string): Promise<void> {
const container = this.containers.get(containerId);
if (!container) {
throw new Error("Container not found");
}
console.log(chalk.blue("• Connecting to container..."));
// Use provided branch name, config target branch, or generate one
const targetBranch =
branchName ||
this.config.targetBranch ||
`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.blue("• Setting up git branch and startup script..."));
// Create different startup scripts based on autoStartClaude setting
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 "Welcome to Claude Code Sandbox!"
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",
`
cd /workspace &&
sudo chown -R claude:claude /workspace &&
git config --global --add safe.directory /workspace &&
# Clean up macOS resource fork files in git pack directory
find .git/objects/pack -name "._pack-*.idx" -type f -delete 2>/dev/null || true &&
# Configure git to use GitHub token if available
if [ -n "$GITHUB_TOKEN" ]; then
git config --global url."https://\${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
git config --global url."https://\${GITHUB_TOKEN}@github.com/".insteadOf "git@github.com:"
echo "✓ Configured git to use GitHub token"
fi &&
# Try to checkout existing branch first, then create new if it doesn't exist
if git show-ref --verify --quiet refs/heads/"${targetBranch}"; then
git checkout "${targetBranch}" &&
echo "✓ Switched to existing branch: ${targetBranch}"
else
git checkout -b "${targetBranch}" &&
echo "✓ Created new branch: ${targetBranch}"
fi &&
echo '${startupScript}' > /home/claude/start-session.sh &&
chmod +x /home/claude/start-session.sh &&
echo "✓ Startup script created"
`,
],
AttachStdout: true,
AttachStderr: true,
});
const setupStream = await setupExec.start({});
// Wait for setup to complete
await new Promise<void>((resolve, reject) => {
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")
) {
resolve();
} else {
reject(new Error("Setup failed"));
}
});
setupStream.on("error", reject);
});
console.log(chalk.green("✓ Container setup completed"));
// Execute setup commands
await this.runSetupCommands(container);
} catch (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..."));
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"),
);
} else {
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: ["/home/claude/start-session.sh"],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
WorkingDir: "/workspace",
});
// Start the exec with hijack mode for proper TTY
const stream = await exec.start({
hijack: true,
stdin: true,
});
// Set up TTY properly
const originalRawMode = process.stdin.isRaw;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
// Resize handler
const resize = async () => {
try {
await exec.resize({
w: process.stdout.columns || 80,
h: process.stdout.rows || 24,
});
} catch (e) {
// Ignore resize errors
}
};
// Initial resize
await resize();
process.stdout.on("resize", resize);
// Connect streams bidirectionally
stream.pipe(process.stdout);
process.stdin.pipe(stream);
// Set up proper cleanup
const cleanup = () => {
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") {
stream.end();
}
};
// Return a promise that resolves when the session ends
return new Promise<void>((resolve, reject) => {
let sessionEnded = false;
const handleEnd = () => {
if (!sessionEnded) {
sessionEnded = true;
console.log(chalk.yellow("\nContainer session ended"));
cleanup();
resolve();
}
};
const handleError = (err: Error) => {
if (!sessionEnded) {
sessionEnded = true;
console.error(chalk.red("Stream error:"), err);
cleanup();
reject(err);
}
};
stream.on("end", handleEnd);
stream.on("close", handleEnd);
stream.on("error", handleError);
// Also monitor the exec process
const checkExec = async () => {
try {
const info = await exec.inspect();
if (info.ExitCode !== null && !sessionEnded) {
handleEnd();
}
} catch (e) {
// Exec might be gone
if (!sessionEnded) {
handleEnd();
}
}
};
// Check exec status periodically
const statusInterval = setInterval(checkExec, 1000);
// Clean up interval when session ends
stream.on("end", () => clearInterval(statusInterval));
stream.on("close", () => clearInterval(statusInterval));
});
}
private async _copyWorkingDirectory(
container: Docker.Container,
workDir: string,
@ -1087,6 +865,96 @@ exec /bin/bash`;
}
}
private async setupGitAndStartupScript(container: any, branchName: string): Promise<void> {
console.log(chalk.blue("• Setting up git branch and startup script..."));
// Determine what to show in the web UI
const defaultShell = this.config.defaultShell || "claude";
// Startup script that keeps session alive
const startupScript = defaultShell === "claude"
? `#!/bin/bash
echo "🚀 Starting Claude Code..."
echo "Press Ctrl+C to drop to bash shell"
echo ""
# Run Claude but don't replace the shell process
claude --dangerously-skip-permissions
# After Claude exits, drop to bash
echo ""
echo "Claude exited. You're now in bash shell."
echo "Type 'claude --dangerously-skip-permissions' to restart Claude"
echo "Type 'exit' to end the session"
echo ""
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 ""
exec /bin/bash`;
const setupExec = await container.exec({
Cmd: [
"/bin/bash",
"-c",
`
cd /workspace &&
sudo chown -R claude:claude /workspace &&
git config --global --add safe.directory /workspace &&
# Clean up macOS resource fork files in git pack directory
find .git/objects/pack -name "._pack-*.idx" -type f -delete 2>/dev/null || true &&
# Configure git to use GitHub token if available
if [ -n "$GITHUB_TOKEN" ]; then
git config --global url."https://\${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
git config --global url."https://\${GITHUB_TOKEN}@github.com/".insteadOf "git@github.com:"
echo "✓ Configured git to use GitHub token"
fi &&
# Try to checkout existing branch first, then create new if it doesn't exist
if git show-ref --verify --quiet refs/heads/"${branchName}"; then
git checkout "${branchName}" &&
echo "✓ Switched to existing branch: ${branchName}"
else
git checkout -b "${branchName}" &&
echo "✓ Created new branch: ${branchName}"
fi &&
cat > /home/claude/start-session.sh << 'EOF'
${startupScript}
EOF
chmod +x /home/claude/start-session.sh &&
echo "✓ Startup script created"
`,
],
AttachStdout: true,
AttachStderr: true,
});
const setupStream = await setupExec.start({});
// Wait for setup to complete
await new Promise<void>((resolve, reject) => {
let output = "";
setupStream.on("data", (chunk: any) => {
output += chunk.toString();
process.stdout.write(chunk);
});
setupStream.on("end", () => {
if (
(output.includes("✓ Created new branch") || output.includes("✓ Switched to existing branch")) &&
output.includes("✓ Startup script created")
) {
resolve();
} else {
reject(new Error("Setup failed"));
}
});
setupStream.on("error", reject);
});
console.log(chalk.green("✓ Git and startup script setup completed"));
}
private async runSetupCommands(container: any): Promise<void> {
// Execute custom setup commands if provided
if (this.config.setupCommands && this.config.setupCommands.length > 0) {

View file

@ -476,11 +476,36 @@ export class ShadowRepository {
async cleanup(): Promise<void> {
if (await fs.pathExists(this.shadowPath)) {
await fs.remove(this.shadowPath);
console.log(chalk.gray('🧹 Shadow repository cleaned up'));
try {
// Try to force remove with rm -rf first
await execAsync(`rm -rf "${this.shadowPath}"`);
console.log(chalk.gray('🧹 Shadow repository cleaned up'));
} catch (error) {
// Fallback to fs.remove with retry logic
let retries = 3;
while (retries > 0) {
try {
await fs.remove(this.shadowPath);
console.log(chalk.gray('🧹 Shadow repository cleaned up'));
break;
} catch (err) {
retries--;
if (retries === 0) {
console.error(chalk.yellow('⚠ Failed to cleanup shadow repository:'), err);
} else {
// Wait a bit before retry
await new Promise(resolve => setTimeout(resolve, 100));
}
}
}
}
}
if (await fs.pathExists(this.rsyncExcludeFile)) {
await fs.remove(this.rsyncExcludeFile);
try {
await fs.remove(this.rsyncExcludeFile);
} catch (error) {
// Ignore exclude file cleanup errors
}
}
}

View file

@ -71,76 +71,23 @@ export class ClaudeSandbox {
await this.gitMonitor.start(branchName);
console.log(chalk.blue("✓ Git monitoring started"));
// Launch web UI if requested
if (this.config.webUI) {
this.webServer = new WebUIServer(this.docker);
// Pass repo info to web server
this.webServer.setRepoInfo(process.cwd(), branchName);
const webUrl = await this.webServer.start();
// Open browser to the web UI with container ID
const fullUrl = `${webUrl}?container=${containerId}`;
await this.webServer.openInBrowser(fullUrl);
console.log(chalk.green(`\n✓ Web UI available at: ${fullUrl}`));
console.log(chalk.yellow("Keep this terminal open to maintain the session"));
// Keep the process running
await new Promise(() => {}); // This will keep the process alive
}
// Attach to container or run detached
else if (!this.config.detached) {
console.log(chalk.blue("Preparing to attach to container..."));
// Set up cleanup handler
const cleanup = async () => {
console.log(chalk.blue("\nShutting down..."));
await this.cleanup();
process.exit(0);
};
// Handle process signals
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
try {
console.log(chalk.gray("About to call attach method..."));
await this.containerManager.attach(containerId, branchName);
console.log(chalk.gray("Attach method completed"));
} catch (error) {
console.error(chalk.red("Failed to attach to container:"), error);
await this.cleanup();
throw error;
}
} else {
console.log(
chalk.blue(
"Running in detached mode. Container is running in the background.",
),
);
console.log(chalk.gray(`Container ID: ${containerId}`));
console.log(chalk.yellow("\nTo connect to the container, run:"));
console.log(
chalk.white(` docker attach ${containerId.substring(0, 12)}`),
);
console.log(chalk.yellow("\nOr use docker exec for a new shell:"));
console.log(
chalk.white(
` docker exec -it ${containerId.substring(0, 12)} /bin/bash`,
),
);
console.log(chalk.yellow("\nTo stop the container:"));
console.log(
chalk.white(` docker stop ${containerId.substring(0, 12)}`),
);
console.log(
chalk.gray(
"\nThe container will continue running until you stop it manually.",
),
);
}
// Always launch web UI
this.webServer = new WebUIServer(this.docker);
// Pass repo info to web server
this.webServer.setRepoInfo(process.cwd(), branchName);
const webUrl = await this.webServer.start();
// Open browser to the web UI with container ID
const fullUrl = `${webUrl}?container=${containerId}`;
await this.webServer.openInBrowser(fullUrl);
console.log(chalk.green(`\n✓ Web UI available at: ${fullUrl}`));
console.log(chalk.yellow("Keep this terminal open to maintain the session"));
// Keep the process running
await new Promise(() => {}); // This will keep the process alive
} catch (error) {
console.error(chalk.red("Error:"), error);
throw error;

View file

@ -7,11 +7,11 @@ export interface VolumeMount {
export interface SandboxConfig {
dockerImage?: string;
dockerfile?: string;
detached?: boolean;
containerPrefix?: string;
autoPush?: boolean;
autoCreatePR?: boolean;
autoStartClaude?: boolean;
defaultShell?: "claude" | "bash";
claudeConfigPath?: string;
setupCommands?: string[];
environment?: Record<string, string>;
@ -21,7 +21,6 @@ export interface SandboxConfig {
allowedTools?: string[];
maxThinkingTokens?: number;
bashTimeout?: number;
webUI?: boolean;
includeUntracked?: boolean;
targetBranch?: string;
}

View file

@ -91,7 +91,7 @@ export class WebUIServer {
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: ['claude', '--dangerously-skip-permissions'],
Cmd: ['/home/claude/start-session.sh'],
WorkingDir: '/workspace',
User: 'claude',
Env: [