diff --git a/README.md b/README.md index 1c76f51..0b5d1bd 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,31 @@ claude-sandbox config ### Configuration +Configuration is loaded from multiple sources (later overrides earlier): + +1. **Built-in defaults** +2. **Global config**: `~/.config/claude-sandbox/config.json` +3. **Project config**: `./claude-sandbox.config.json` + +#### Global Configuration + +Set defaults for all projects by creating a global config file: + +```bash +mkdir -p ~/.config/claude-sandbox +cat > ~/.config/claude-sandbox/config.json << 'EOF' +{ + "dockerImage": "my-custom-image:latest", + "autoPush": false, + "defaultShell": "bash" +} +EOF +``` + +Project-specific configs can still override these settings. + +#### Project Configuration + Create a `claude-sandbox.config.json` file (see `claude-sandbox.config.example.json` for reference): ```json @@ -237,15 +262,19 @@ Example use cases: ## Features -### Podman Support +### Docker, Colima & Podman Support + +Claude Code Sandbox automatically detects your container runtime by checking for available socket paths: -Claude Code Sandbox now supports Podman as an alternative to Docker. The tool automatically detects whether you're using Docker or Podman by checking for available socket paths: +**Detected socket paths (in order):** -- **Automatic detection**: The tool checks for Docker and Podman sockets in standard locations -- **Custom socket paths**: Use the `dockerSocketPath` configuration option to specify a custom socket -- **Environment variable**: Set `DOCKER_HOST` to override socket detection +- `/var/run/docker.sock` (Docker standard) +- `$XDG_RUNTIME_DIR/docker.sock` (Docker rootless) +- `~/.docker/desktop/docker.sock` (Docker Desktop for Linux) +- `~/.colima/default/docker.sock` (Colima on macOS) +- `$XDG_RUNTIME_DIR/podman/podman.sock` (Podman rootless) -Example configuration for Podman: +**Custom socket path:** ```json { @@ -253,10 +282,7 @@ Example configuration for Podman: } ``` -The tool will automatically detect and use Podman if: - -- Docker socket is not available -- Podman socket is found at standard locations (`/run/podman/podman.sock` or `$XDG_RUNTIME_DIR/podman/podman.sock`) +Or set the `DOCKER_HOST` environment variable to override detection. ### Web UI Terminal @@ -286,7 +312,11 @@ Claude Code Sandbox automatically discovers and forwards: **Claude Credentials:** - Anthropic API keys (`ANTHROPIC_API_KEY`) -- macOS Keychain credentials (Claude Code) +- OAuth credentials from: + - `~/.claude/.credentials.json` (Linux) + - `~/.claude/auth.json` + - `~/.config/claude/auth.json` + - `~/Library/Application Support/Claude/auth.json` (macOS) - AWS Bedrock credentials - Google Vertex credentials - Claude configuration files (`.claude.json`, `.claude/`) diff --git a/package-lock.json b/package-lock.json index 3664937..783ea11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,6 +97,7 @@ "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1848,6 +1849,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2030,6 +2032,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2532,6 +2535,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", @@ -3223,7 +3227,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1452169.tgz", "integrity": "sha512-FOFDVMGrAUNp0dDKsAU1TorWJUx2JOU1k9xdgBKKJF3IBh/Uhl2yswG5r3TEAOrCiGY2QRp1e6LVDQrCsTKO4g==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff-sequences": { "version": "29.6.3", @@ -3645,6 +3650,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5056,6 +5062,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -7921,6 +7928,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8151,7 +8159,8 @@ "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/xterm-addon-fit": { "version": "0.8.0", diff --git a/src/cli.ts b/src/cli.ts index 07c1996..ad98c14 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,10 @@ import { ClaudeSandbox } from "./index"; import { loadConfig } from "./config"; import { WebUIServer } from "./web-server"; import { getDockerConfig, isPodman } from "./docker-config"; +import { SessionStore } from "./session-store"; +import { SHADOW_BASE_PATH } from "./config"; +import { recoverSession } from "./recover"; +import * as fsExtra from "fs-extra"; import ora from "ora"; // Initialize Docker with config - will be updated after loading config if needed @@ -125,6 +129,7 @@ program "Start with 'claude' or 'bash' shell", /^(claude|bash)$/i, ) + .option("--no-web", "Disable web UI (use terminal attach)") .action(async (options) => { console.log(chalk.blue("šŸš€ Starting new Claude Sandbox container...")); @@ -136,6 +141,7 @@ program config.targetBranch = options.branch; config.remoteBranch = options.remoteBranch; config.prNumber = options.pr; + config.useWebUI = options.web !== false; if (options.shell) { config.defaultShell = options.shell.toLowerCase(); } @@ -337,8 +343,9 @@ program // Clean command - remove stopped containers program .command("clean") - .description("Remove all stopped Claude Sandbox containers") + .description("Remove all stopped Claude Sandbox containers and orphaned data") .option("-f, --force", "Remove all containers (including running)") + .option("--shadows", "Also clean orphaned shadow repos") .action(async (options) => { await ensureDockerConfig(); const spinner = ora("Cleaning up containers...").start(); @@ -349,21 +356,65 @@ program ? containers : containers.filter((c) => c.State !== "running"); - if (targetContainers.length === 0) { - spinner.info("No containers to clean up."); - return; + let removed = 0; + if (targetContainers.length > 0) { + for (const c of targetContainers) { + const container = docker.getContainer(c.Id); + if (c.State === "running" && options.force) { + await container.stop(); + } + await container.remove(); + spinner.text = `Removed ${c.Id.substring(0, 12)}`; + removed++; + } } - for (const c of targetContainers) { - const container = docker.getContainer(c.Id); - if (c.State === "running" && options.force) { - await container.stop(); + // Clean session records for containers that no longer exist + spinner.text = "Cleaning session records..."; + const store = new SessionStore(); + const sessions = await store.load(); + let sessionsRemoved = 0; + for (const session of sessions) { + try { + const container = docker.getContainer(session.containerId); + await container.inspect(); + // Container exists — keep the record + } catch { + // Container gone — remove the record + await store.removeSession(session.containerId); + sessionsRemoved++; } - await container.remove(); - spinner.text = `Removed ${c.Id.substring(0, 12)}`; } - spinner.succeed(`Cleaned up ${targetContainers.length} container(s)`); + // Clean orphaned shadow repos if requested + let shadowsRemoved = 0; + if (options.shadows && (await fsExtra.pathExists(SHADOW_BASE_PATH))) { + spinner.text = "Cleaning orphaned shadow repos..."; + const entries = await fsExtra.readdir(SHADOW_BASE_PATH); + const activeSessions = await store.load(); + const activeSessionIds = new Set( + activeSessions.map((s) => s.sessionId), + ); + + for (const entry of entries) { + if (!activeSessionIds.has(entry)) { + const shadowPath = `${SHADOW_BASE_PATH}/${entry}`; + await fsExtra.remove(shadowPath); + shadowsRemoved++; + } + } + } + + const parts = []; + if (removed > 0) parts.push(`${removed} container(s)`); + if (sessionsRemoved > 0) parts.push(`${sessionsRemoved} session record(s)`); + if (shadowsRemoved > 0) parts.push(`${shadowsRemoved} shadow repo(s)`); + + if (parts.length > 0) { + spinner.succeed(`Cleaned up ${parts.join(", ")}`); + } else { + spinner.info("Nothing to clean up."); + } } catch (error: any) { spinner.fail(chalk.red(`Failed: ${error.message}`)); process.exit(1); @@ -434,12 +485,27 @@ program } } + // Clear all session records + spinner.text = "Clearing session records..."; + const store = new SessionStore(); + await store.clearAll(); + + // Clear all shadow repos + spinner.text = "Clearing shadow repos..."; + if (await fsExtra.pathExists(SHADOW_BASE_PATH)) { + await fsExtra.remove(SHADOW_BASE_PATH); + } + if (removed === containers.length) { - spinner.succeed(chalk.green(`āœ“ Purged all ${removed} container(s)`)); + spinner.succeed( + chalk.green( + `āœ“ Purged all ${removed} container(s), session records, and shadow repos`, + ), + ); } else { spinner.warn( chalk.yellow( - `Purged ${removed} of ${containers.length} container(s)`, + `Purged ${removed} of ${containers.length} container(s), plus session records and shadow repos`, ), ); } @@ -469,4 +535,85 @@ program } }); +// Recover command - recover sessions after crash/reboot +program + .command("recover") + .description("Recover Claude Sandbox sessions after a crash or reboot") + .option("-l, --list", "List recoverable sessions without recovering") + .action(async (options) => { + await ensureDockerConfig(); + const spinner = ora("Scanning for recoverable sessions...").start(); + + try { + const store = new SessionStore(); + const sessions = await store.getRecoverableSessions(docker); + + spinner.stop(); + + if (sessions.length === 0) { + console.log(chalk.yellow("No recoverable sessions found.")); + return; + } + + console.log( + chalk.blue(`Found ${sessions.length} recoverable session(s):\n`), + ); + + for (const session of sessions) { + const age = getAge(session.startTime); + const stateColor = + session.containerState === "running" + ? chalk.green + : session.containerState === "stopped" + ? chalk.yellow + : chalk.red; + + console.log( + ` ${chalk.cyan(session.sessionId)} | ` + + `${chalk.white(session.branchName)} | ` + + `${stateColor(session.containerState)} | ` + + `shadow: ${session.shadowExists ? chalk.green("yes") : chalk.red("no")} | ` + + `${chalk.gray(age)} | ` + + `${chalk.gray(session.repoPath)}`, + ); + } + console.log(); + + if (options.list) { + return; + } + + // Interactive selection + const choices = sessions.map((s) => ({ + name: `${s.sessionId} - ${s.branchName} (${s.containerState}, shadow: ${s.shadowExists ? "yes" : "no"}) - ${getAge(s.startTime)}`, + value: s.containerId, + })); + + const { selectedId } = await inquirer.prompt([ + { + type: "list", + name: "selectedId", + message: "Select a session to recover:", + choices, + }, + ]); + + const session = sessions.find((s) => s.containerId === selectedId)!; + await recoverSession(docker, session, store); + } catch (error: any) { + spinner.fail(chalk.red(`Failed: ${error.message}`)); + process.exit(1); + } + }); + +function getAge(isoTimestamp: string): string { + const diff = Date.now() - new Date(isoTimestamp).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + program.parse(); diff --git a/src/config.ts b/src/config.ts index 71cda73..b0862c2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,28 @@ import path from "path"; import os from "os"; import { SandboxConfig } from "./types"; +// Global config location: ~/.config/claude-sandbox/config.json +const GLOBAL_CONFIG_PATH = path.join( + os.homedir(), + ".config", + "claude-sandbox", + "config.json" +); + +// Persistent data locations +export const SHADOW_BASE_PATH = path.join( + os.homedir(), + ".cache", + "claude-sandbox", + "shadows" +); +export const SESSION_STORE_PATH = path.join( + os.homedir(), + ".cache", + "claude-sandbox", + "sessions.json" +); + const DEFAULT_CONFIG: SandboxConfig = { dockerImage: "claude-code-sandbox:latest", autoPush: true, @@ -13,27 +35,37 @@ const DEFAULT_CONFIG: SandboxConfig = { setupCommands: [], // Example: ["npm install", "pip install -r requirements.txt"] allowedTools: ["*"], // All tools allowed in sandbox includeUntracked: false, // Don't include untracked files by default + restartPolicy: "unless-stopped", + autoCommit: true, + autoCommitIntervalMinutes: 5, // maxThinkingTokens: 100000, // bashTimeout: 600000, // 10 minutes }; -export async function loadConfig(configPath: string): Promise { +async function loadJsonFile(filePath: string): Promise | null> { try { - const fullPath = path.resolve(configPath); - const configContent = await fs.readFile(fullPath, "utf-8"); - const userConfig = JSON.parse(configContent); - - // Merge with defaults - return { - ...DEFAULT_CONFIG, - ...userConfig, - }; - } catch (error) { - // Config file not found or invalid, use defaults - return DEFAULT_CONFIG; + const content = await fs.readFile(filePath, "utf-8"); + return JSON.parse(content); + } catch { + return null; } } +export async function loadConfig(configPath: string): Promise { + // Load global config first (if exists) + const globalConfig = await loadJsonFile(GLOBAL_CONFIG_PATH); + + // Load local project config (if exists) + const localConfig = await loadJsonFile(path.resolve(configPath)); + + // Merge: defaults < global < local + return { + ...DEFAULT_CONFIG, + ...(globalConfig || {}), + ...(localConfig || {}), + }; +} + export async function saveConfig( config: SandboxConfig, configPath: string, @@ -41,3 +73,7 @@ export async function saveConfig( const fullPath = path.resolve(configPath); await fs.writeFile(fullPath, JSON.stringify(config, null, 2)); } + +export function getGlobalConfigPath(): string { + return GLOBAL_CONFIG_PATH; +} diff --git a/src/container.ts b/src/container.ts index 4b2fa92..50e6486 100644 --- a/src/container.ts +++ b/src/container.ts @@ -34,6 +34,9 @@ export class ContainerManager { // Copy Claude configuration if it exists await this._copyClaudeConfig(container); + // Configure bypass permissions mode to skip confirmation prompt + await this._setupBypassPermissions(container); + // Copy git configuration if it exists await this._copyGitConfig(container); } catch (error) { @@ -256,6 +259,10 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ Binds: volumes, AutoRemove: false, NetworkMode: "bridge", + RestartPolicy: { + Name: this.config.restartPolicy || "unless-stopped", + MaximumRetryCount: this.config.restartPolicy === "on-failure" ? 5 : 0, + }, }, WorkingDir: "/workspace", Cmd: ["/bin/bash", "-l"], @@ -790,7 +797,20 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ const tarFlags = getTarFlags(); // On macOS, also exclude extended attributes that cause Docker issues const additionalFlags = (process.platform as string) === "darwin" ? "--no-xattrs --no-fflags" : ""; - const combinedFlags = `${tarFlags} ${additionalFlags}`.trim(); + // Exclude directories that are large, temporary, or actively written to + const excludeFlags = [ + "--exclude=.claude/debug", + "--exclude=.claude/cache", + "--exclude=.claude/file-history", + "--exclude=.claude/session-env", + "--exclude=.claude/tasks", + "--exclude=.claude/paste-cache", + "--exclude=.claude/shell-snapshots", + "--exclude=.claude/telemetry", + "--exclude=.claude/todos", + "--exclude=.claude/statsig", + ].join(" "); + const combinedFlags = `${tarFlags} ${additionalFlags} ${excludeFlags}`.trim(); execSync( `tar -cf "${tarFile}" ${combinedFlags} -C "${os.homedir()}" .claude`, { @@ -805,6 +825,29 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ fs.unlinkSync(tarFile); + // Fix plugin paths: installed_plugins.json contains absolute paths + // from the host (e.g. /home/linux/.claude/plugins/...) which won't + // resolve inside the container where home is /home/claude + const hostHome = os.homedir(); + await container + .exec({ + Cmd: [ + "/bin/bash", + "-c", + ` + # Rewrite absolute home paths in plugin registry files + for f in /home/claude/.claude/plugins/installed_plugins.json /home/claude/.claude/plugins/known_marketplaces.json; do + if [ -f "$f" ]; then + sed -i 's|${hostHome}/|/home/claude/|g' "$f" + fi + done + `, + ], + AttachStdout: false, + AttachStderr: false, + }) + .then((exec) => exec.start({})); + // Fix permissions recursively await container .exec({ @@ -829,6 +872,76 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ } } + private async _setupBypassPermissions( + container: Docker.Container, + ): Promise { + try { + console.log(chalk.blue("• Configuring bypass permissions mode...")); + + // The settings need to have defaultMode at the root level per Claude Code docs + const setupExec = await container.exec({ + Cmd: [ + "/bin/bash", + "-c", + ` + # Ensure .claude directory exists + mkdir -p /home/claude/.claude && + + # Update settings.local.json to set bypass permissions mode + # Using settings.local.json as it takes precedence for user preferences + SETTINGS_FILE="/home/claude/.claude/settings.local.json" + + if [ -f "$SETTINGS_FILE" ]; then + # File exists - try to merge with jq, fallback to simple approach + if command -v jq &> /dev/null; then + jq '. + {"defaultMode": "bypassPermissions"}' "$SETTINGS_FILE" > /tmp/settings.tmp && mv /tmp/settings.tmp "$SETTINGS_FILE" + else + # No jq - use python if available + python3 -c " +import json +with open('$SETTINGS_FILE', 'r') as f: + data = json.load(f) +data['defaultMode'] = 'bypassPermissions' +with open('$SETTINGS_FILE', 'w') as f: + json.dump(data, f, indent=2) +" 2>/dev/null || echo '{"defaultMode": "bypassPermissions"}' > "$SETTINGS_FILE" + fi + else + # Create new settings file + echo '{"defaultMode": "bypassPermissions"}' > "$SETTINGS_FILE" + fi && + + # Fix permissions + chown -R claude:claude /home/claude/.claude && + chmod 700 /home/claude/.claude && + chmod 600 "$SETTINGS_FILE" + `, + ], + AttachStdout: true, + AttachStderr: true, + }); + + const stream = await setupExec.start({}); + + // Wait for completion (must consume stream data for it to end) + await new Promise((resolve, reject) => { + stream.on("data", () => {}); // Consume data to allow stream to end + stream.on("end", resolve); + stream.on("error", reject); + }); + + console.log( + chalk.green("āœ“ Bypass permissions mode configured (no confirmation prompt)"), + ); + } catch (error) { + console.error( + chalk.yellow("⚠ Failed to configure bypass permissions:"), + error, + ); + // Don't throw - Claude will still work, just with the confirmation prompt + } + } + private async _copyGitConfig(container: Docker.Container): Promise { const fs = require("fs"); const os = require("os"); @@ -1099,11 +1212,24 @@ EOF } } - async cleanup(): Promise { + async cleanup(intentional: boolean = true): Promise { for (const [, container] of this.containers) { try { - await container.stop(); - await container.remove(); + if (intentional) { + // On intentional exit: disable restart policy, stop, and remove + try { + await container.update({ + RestartPolicy: { Name: "no", MaximumRetryCount: 0 }, + }); + } catch { + // Ignore update errors (container may already be stopped) + } + await container.stop(); + await container.remove(); + } else { + // On non-intentional exit: just stop, leave container for Docker to restart + await container.stop(); + } } catch (error) { // Container might already be stopped } diff --git a/src/credentials.ts b/src/credentials.ts index 198b8a1..8798bbc 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -85,9 +85,13 @@ export class CredentialManager { } private async findOAuthToken(): Promise { - // Check common locations for Claude OAuth tokens + // Check common locations for Claude OAuth tokens/credentials const possiblePaths = [ + // Linux/cross-platform credential locations + path.join(os.homedir(), ".claude", ".credentials.json"), path.join(os.homedir(), ".claude", "auth.json"), + path.join(os.homedir(), ".config", "claude", "auth.json"), + // macOS credential locations path.join( os.homedir(), "Library", @@ -95,15 +99,17 @@ export class CredentialManager { "Claude", "auth.json", ), - path.join(os.homedir(), ".config", "claude", "auth.json"), ]; for (const authPath of possiblePaths) { try { const content = await fs.readFile(authPath, "utf-8"); const auth = JSON.parse(content); - if (auth.access_token) { - return auth.access_token; + // Check various token field names used by Claude Code + if (auth.claudeAiOauth || auth.accessToken || auth.access_token) { + // Return indicator that OAuth credentials exist + // The actual credentials will be copied via _copyClaudeConfig + return "oauth-credentials-found"; } } catch { // Continue checking other paths diff --git a/src/docker-config.ts b/src/docker-config.ts index a68c162..f19afdf 100644 --- a/src/docker-config.ts +++ b/src/docker-config.ts @@ -1,12 +1,13 @@ import * as fs from "fs"; import * as path from "path"; +import * as os from "os"; interface DockerConfig { socketPath?: string; } /** - * Detects whether Docker or Podman is available and returns appropriate configuration + * Detects whether Docker, Colima, or Podman is available and returns appropriate configuration * @param customSocketPath - Optional custom socket path from configuration */ export function getDockerConfig(customSocketPath?: string): DockerConfig { @@ -22,9 +23,21 @@ export function getDockerConfig(customSocketPath?: string): DockerConfig { // Common socket paths to check const socketPaths = [ - // Docker socket paths + // Docker standard socket paths "/var/run/docker.sock", + // Docker rootless socket paths (Linux) + process.env.XDG_RUNTIME_DIR && + path.join(process.env.XDG_RUNTIME_DIR, "docker.sock"), + `/run/user/${process.getuid?.() || 1000}/docker.sock`, + + // Docker Desktop for Linux + path.join(os.homedir(), ".docker", "desktop", "docker.sock"), + + // Colima socket paths (macOS) + path.join(os.homedir(), ".colima", "default", "docker.sock"), + path.join(os.homedir(), ".docker", "run", "docker.sock"), + // Podman rootless socket paths process.env.XDG_RUNTIME_DIR && path.join(process.env.XDG_RUNTIME_DIR, "podman", "podman.sock"), diff --git a/src/git/shadow-repository.ts b/src/git/shadow-repository.ts index c2f144b..49e2e70 100644 --- a/src/git/shadow-repository.ts +++ b/src/git/shadow-repository.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import * as fs from 'fs-extra'; import { exec } from 'child_process'; import { promisify } from 'util'; +import os from 'os'; import chalk from 'chalk'; const execAsync = promisify(exec); @@ -19,7 +20,7 @@ export class ShadowRepository { constructor( private options: ShadowRepoOptions, - private basePath: string = '/tmp/claude-shadows' + private basePath: string = path.join(os.homedir(), '.cache', 'claude-sandbox', 'shadows') ) { this.shadowPath = path.join(this.basePath, this.options.sessionId); this.rsyncExcludeFile = path.join( @@ -741,7 +742,20 @@ export class ShadowRepository { console.log(stdout); } - async cleanup(): Promise { + async cleanup(preserve: boolean = false): Promise { + if (preserve) { + console.log(chalk.gray('šŸ’¾ Shadow repository preserved at: ' + this.shadowPath)); + // Still clean up the exclude file + if (await fs.pathExists(this.rsyncExcludeFile)) { + try { + await fs.remove(this.rsyncExcludeFile); + } catch (error) { + // Ignore exclude file cleanup errors + } + } + return; + } + if (await fs.pathExists(this.shadowPath)) { try { // Try to force remove with rm -rf first diff --git a/src/index.ts b/src/index.ts index 9f860a5..fbc9302 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ import { UIManager } from "./ui"; import { WebUIServer } from "./web-server"; import { SandboxConfig } from "./types"; import { getDockerConfig, isPodman } from "./docker-config"; +import { SHADOW_BASE_PATH } from "./config"; +import { SessionStore } from "./session-store"; import path from "path"; export class ClaudeSandbox { @@ -19,6 +21,8 @@ export class ClaudeSandbox { private containerManager: ContainerManager; private ui: UIManager; private webServer?: WebUIServer; + private containerId?: string; + private sessionStore: SessionStore; constructor(config: SandboxConfig) { this.config = config; @@ -35,6 +39,7 @@ export class ClaudeSandbox { this.gitMonitor = new GitMonitor(this.git); this.containerManager = new ContainerManager(this.docker, config); this.ui = new UIManager(); + this.sessionStore = new SessionStore(); } async run(): Promise { @@ -144,10 +149,34 @@ export class ClaudeSandbox { // Start container const containerId = await this.containerManager.start(containerConfig); + this.containerId = containerId; console.log( chalk.green(`āœ“ Started container: ${containerId.substring(0, 12)}`), ); + // Record session for recovery + const shadowBasePath = this.config.shadowBasePath || SHADOW_BASE_PATH; + const sessionId = containerId.substring(0, 12); + await this.sessionStore.addSession({ + containerId, + containerName: `${this.config.containerPrefix || "claude-code-sandbox"}-${Date.now()}`, + sessionId, + repoPath: process.cwd(), + branchName, + originalBranch: currentBranch.current, + shadowRepoPath: path.join(shadowBasePath, sessionId), + startTime: new Date().toISOString(), + lastActivityTime: new Date().toISOString(), + status: "active", + config: { + dockerImage: this.config.dockerImage, + defaultShell: this.config.defaultShell, + autoCommit: this.config.autoCommit, + autoCommitIntervalMinutes: this.config.autoCommitIntervalMinutes, + restartPolicy: this.config.restartPolicy, + }, + }); + // Start monitoring for commits this.gitMonitor.on("commit", async (commit) => { await this.handleCommit(commit); @@ -156,25 +185,40 @@ export class ClaudeSandbox { await this.gitMonitor.start(branchName); console.log(chalk.blue("āœ“ Git monitoring started")); - // Always launch web UI - this.webServer = new WebUIServer(this.docker); + // Launch web UI or attach to terminal directly + if (this.config.useWebUI !== false) { + this.webServer = new WebUIServer(this.docker); - // Pass repo info to web server - this.webServer.setRepoInfo(process.cwd(), branchName); + // Pass repo info and persistence config to web server + this.webServer.setRepoInfo(process.cwd(), branchName); + this.webServer.setShadowBasePath( + this.config.shadowBasePath || SHADOW_BASE_PATH, + ); + this.webServer.setAutoCommitConfig( + this.config.autoCommit !== false, + this.config.autoCommitIntervalMinutes || 5, + ); - const webUrl = await this.webServer.start(); + 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); + // 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"), - ); + 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 + } else { + // Terminal mode - attach directly to container + console.log(chalk.green("\nāœ“ Attaching to container terminal...")); + console.log(chalk.yellow("Press Ctrl+P, Ctrl+Q to detach\n")); - // Keep the process running - await new Promise(() => {}); // This will keep the process alive + await this.attachToContainer(containerId); + } } catch (error) { console.error(chalk.red("Error:"), error); throw error; @@ -204,7 +248,7 @@ export class ClaudeSandbox { credentials, workDir, repoName, - dockerImage: this.config.dockerImage || "claude-sandbox:latest", + dockerImage: this.config.dockerImage || "claude-code-sandbox:latest", prFetchRef, remoteFetchRef, }; @@ -260,13 +304,97 @@ export class ClaudeSandbox { } } - private async cleanup(): Promise { + private async cleanup(intentional: boolean = true): Promise { await this.gitMonitor.stop(); - await this.containerManager.cleanup(); + await this.containerManager.cleanup(intentional); if (this.webServer) { - await this.webServer.stop(); + await this.webServer.stop(intentional); + } + + // Update session store + if (this.containerId) { + if (intentional) { + await this.sessionStore.removeSession(this.containerId); + } else { + await this.sessionStore.updateSession(this.containerId, { + status: "stopped", + exitType: "crash", + lastActivityTime: new Date().toISOString(), + }); + } } } + + private async attachToContainer(containerId: string): Promise { + const container = this.docker.getContainer(containerId); + + // Get current terminal size + const getTerminalSize = () => ({ + h: process.stdout.rows || 24, + w: process.stdout.columns || 80, + }); + + const termSize = getTerminalSize(); + + // Execute the startup script in an interactive session + const dockerExec = await container.exec({ + Cmd: ["/bin/bash", "-l", "-c", "/home/claude/start-session.sh"], + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + }); + + const stream = await dockerExec.start({ + hijack: true, + stdin: true, + Tty: true, + }); + + // Set initial terminal size + await dockerExec.resize(termSize); + + // Handle terminal resize events + const resizeHandler = async () => { + try { + await dockerExec.resize(getTerminalSize()); + } catch { + // Ignore resize errors (exec might have ended) + } + }; + process.stdout.on("resize", resizeHandler); + + // Set up raw mode for proper terminal handling + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + + // Pipe streams + process.stdin.pipe(stream); + stream.pipe(process.stdout); + + // Handle stream end + stream.on("end", async () => { + process.stdout.off("resize", resizeHandler); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.pause(); + await this.cleanup(); + process.exit(0); + }); + + // Handle Ctrl+C gracefully + process.on("SIGINT", async () => { + process.stdout.off("resize", resizeHandler); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + await this.cleanup(); + process.exit(0); + }); + } } export * from "./types"; diff --git a/src/recover.ts b/src/recover.ts new file mode 100644 index 0000000..fe79708 --- /dev/null +++ b/src/recover.ts @@ -0,0 +1,223 @@ +import Docker from "dockerode"; +import chalk from "chalk"; +import inquirer from "inquirer"; +import { exec } from "child_process"; +import { promisify } from "util"; +import * as fsExtra from "fs-extra"; +import path from "path"; +import { SessionStore, SessionRecord } from "./session-store"; +import { WebUIServer } from "./web-server"; + +const execAsync = promisify(exec); + +type RecoverableSession = SessionRecord & { + containerState: "running" | "stopped" | "gone"; + shadowExists: boolean; +}; + +export async function recoverSession( + docker: Docker, + session: RecoverableSession, + store: SessionStore, +): Promise { + const shortId = session.sessionId; + + if (session.containerState === "running") { + // Container is already running — launch WebUI and re-attach + console.log( + chalk.green(`Container ${shortId} is running. Launching Web UI...`), + ); + + const webServer = new WebUIServer(docker); + const url = await webServer.start(); + const fullUrl = `${url}?container=${session.containerId}`; + console.log(chalk.green(`Web UI available at: ${fullUrl}`)); + await webServer.openInBrowser(fullUrl); + + // Update session + await store.updateSession(session.containerId, { + status: "active", + lastActivityTime: new Date().toISOString(), + }); + + console.log( + chalk.yellow("Keep this terminal open to maintain the session"), + ); + // Keep process running + await new Promise(() => {}); + } else if (session.containerState === "stopped") { + // Container exists but is stopped — restart and re-attach + console.log(chalk.blue(`Restarting container ${shortId}...`)); + + const container = docker.getContainer(session.containerId); + await container.start(); + + console.log(chalk.green(`Container ${shortId} restarted. Launching Web UI...`)); + + const webServer = new WebUIServer(docker); + const url = await webServer.start(); + const fullUrl = `${url}?container=${session.containerId}`; + console.log(chalk.green(`Web UI available at: ${fullUrl}`)); + await webServer.openInBrowser(fullUrl); + + // Update session + await store.updateSession(session.containerId, { + status: "active", + lastActivityTime: new Date().toISOString(), + }); + + console.log( + chalk.yellow("Keep this terminal open to maintain the session"), + ); + // Keep process running + await new Promise(() => {}); + } else if (session.shadowExists) { + // Container is gone but shadow repo exists + console.log( + chalk.yellow( + `Container ${shortId} is gone, but shadow repo exists at:`, + ), + ); + console.log(chalk.white(` ${session.shadowRepoPath}`)); + + const { action } = await inquirer.prompt([ + { + type: "list", + name: "action", + message: "What would you like to do with the shadow repo?", + choices: [ + { + name: "Push to remote (if remote is configured)", + value: "push", + }, + { + name: "Copy to a local path", + value: "copy", + }, + { + name: "Show the shadow repo path (and keep it)", + value: "show", + }, + { + name: "Discard (delete shadow repo and session record)", + value: "discard", + }, + ], + }, + ]); + + switch (action) { + case "push": { + try { + // Check if remote is configured + const { stdout: remoteOutput } = await execAsync("git remote -v", { + cwd: session.shadowRepoPath, + }); + + if (!remoteOutput.includes("origin")) { + console.log( + chalk.red("No remote 'origin' configured in shadow repo."), + ); + console.log( + chalk.yellow( + `Shadow repo path: ${session.shadowRepoPath}`, + ), + ); + break; + } + + // Stage, commit any remaining changes, and push + await execAsync("git add -A", { cwd: session.shadowRepoPath }); + try { + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-"); + await execAsync( + `git commit -m "[recovery] Recovered changes from ${timestamp}"`, + { cwd: session.shadowRepoPath }, + ); + } catch { + // Nothing to commit — that's fine + } + + const { stdout: branchOutput } = await execAsync( + "git branch --show-current", + { cwd: session.shadowRepoPath }, + ); + const branch = branchOutput.trim(); + + await execAsync(`git push -u origin ${branch}`, { + cwd: session.shadowRepoPath, + }); + console.log( + chalk.green(`Pushed branch '${branch}' to remote.`), + ); + await store.removeSession(session.containerId); + } catch (error: any) { + console.error(chalk.red("Push failed:"), error.message); + console.log( + chalk.yellow( + `Shadow repo preserved at: ${session.shadowRepoPath}`, + ), + ); + } + break; + } + case "copy": { + const { destPath } = await inquirer.prompt([ + { + type: "input", + name: "destPath", + message: "Enter destination path:", + default: path.join( + process.cwd(), + `recovered-${session.sessionId}`, + ), + }, + ]); + + const resolvedDest = path.resolve(destPath); + await fsExtra.copy(session.shadowRepoPath, resolvedDest); + console.log(chalk.green(`Copied to: ${resolvedDest}`)); + await store.removeSession(session.containerId); + break; + } + case "show": { + console.log( + chalk.blue(`Shadow repo path: ${session.shadowRepoPath}`), + ); + console.log(chalk.gray("Session record preserved.")); + break; + } + case "discard": { + const { confirmDiscard } = await inquirer.prompt([ + { + type: "confirm", + name: "confirmDiscard", + message: + "Are you sure? This will delete the shadow repo permanently.", + default: false, + }, + ]); + + if (confirmDiscard) { + await fsExtra.remove(session.shadowRepoPath); + await store.removeSession(session.containerId); + console.log(chalk.gray("Shadow repo and session record removed.")); + } else { + console.log(chalk.gray("Discard cancelled.")); + } + break; + } + } + } else { + // Both container and shadow repo are gone + console.log( + chalk.red( + `Session ${shortId} is unrecoverable (container and shadow repo both gone).`, + ), + ); + await store.removeSession(session.containerId); + console.log(chalk.gray("Session record cleaned up.")); + } +} diff --git a/src/session-store.ts b/src/session-store.ts new file mode 100644 index 0000000..b57caae --- /dev/null +++ b/src/session-store.ts @@ -0,0 +1,130 @@ +import fs from "fs/promises"; +import path from "path"; +import * as fsExtra from "fs-extra"; +import Docker from "dockerode"; +import { SESSION_STORE_PATH } from "./config"; + +export interface SessionRecord { + containerId: string; + containerName: string; + sessionId: string; // first 12 chars of containerId + repoPath: string; // host repo path + branchName: string; // branch in container + originalBranch: string; // host branch at start + shadowRepoPath: string; + startTime: string; // ISO 8601 + lastActivityTime: string; + status: "active" | "stopped" | "exited"; + exitType?: "intentional" | "crash" | "unknown"; + webUIPort?: number; + config: { + dockerImage?: string; + defaultShell?: string; + autoCommit?: boolean; + autoCommitIntervalMinutes?: number; + restartPolicy?: string; + }; +} + +interface SessionStoreData { + sessions: SessionRecord[]; +} + +export class SessionStore { + private storePath: string; + + constructor(storePath: string = SESSION_STORE_PATH) { + this.storePath = storePath; + } + + async load(): Promise { + try { + const content = await fs.readFile(this.storePath, "utf-8"); + const data: SessionStoreData = JSON.parse(content); + return data.sessions || []; + } catch { + return []; + } + } + + private async save(sessions: SessionRecord[]): Promise { + const dir = path.dirname(this.storePath); + await fsExtra.ensureDir(dir); + + const data: SessionStoreData = { sessions }; + const tmpPath = this.storePath + ".tmp"; + await fs.writeFile(tmpPath, JSON.stringify(data, null, 2)); + await fs.rename(tmpPath, this.storePath); + } + + async addSession(session: SessionRecord): Promise { + const sessions = await this.load(); + // Remove any existing session with same containerId + const filtered = sessions.filter( + (s) => s.containerId !== session.containerId, + ); + filtered.push(session); + await this.save(filtered); + } + + async updateSession( + containerId: string, + updates: Partial, + ): Promise { + const sessions = await this.load(); + const idx = sessions.findIndex((s) => s.containerId === containerId); + if (idx >= 0) { + sessions[idx] = { ...sessions[idx], ...updates }; + await this.save(sessions); + } + } + + async removeSession(containerId: string): Promise { + const sessions = await this.load(); + const filtered = sessions.filter((s) => s.containerId !== containerId); + await this.save(filtered); + } + + async getRecoverableSessions( + docker: Docker, + ): Promise< + Array< + SessionRecord & { + containerState: "running" | "stopped" | "gone"; + shadowExists: boolean; + } + > + > { + const sessions = await this.load(); + const results: Array< + SessionRecord & { + containerState: "running" | "stopped" | "gone"; + shadowExists: boolean; + } + > = []; + + for (const session of sessions) { + let containerState: "running" | "stopped" | "gone" = "gone"; + try { + const container = docker.getContainer(session.containerId); + const info = await container.inspect(); + containerState = info.State.Running ? "running" : "stopped"; + } catch { + containerState = "gone"; + } + + const shadowExists = await fsExtra.pathExists(session.shadowRepoPath); + + // A session is recoverable if the container still exists or the shadow repo is present + if (containerState !== "gone" || shadowExists) { + results.push({ ...session, containerState, shadowExists }); + } + } + + return results; + } + + async clearAll(): Promise { + await this.save([]); + } +} diff --git a/src/types.ts b/src/types.ts index 5c641be..e6e2ad9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,11 @@ export interface SandboxConfig { remoteBranch?: string; prNumber?: string; dockerSocketPath?: string; + useWebUI?: boolean; + restartPolicy?: "no" | "always" | "unless-stopped" | "on-failure"; + autoCommit?: boolean; + autoCommitIntervalMinutes?: number; + shadowBasePath?: string; } export interface Credentials { diff --git a/src/web-server.ts b/src/web-server.ts index 2bcc1a7..2ee9007 100644 --- a/src/web-server.ts +++ b/src/web-server.ts @@ -8,6 +8,7 @@ import chalk from "chalk"; import { exec } from "child_process"; import { promisify } from "util"; import { ShadowRepository } from "./git/shadow-repository"; +import { SHADOW_BASE_PATH } from "./config"; const execAsync = promisify(exec); @@ -31,6 +32,10 @@ export class WebUIServer { private originalRepo: string = ""; private currentBranch: string = "main"; private fileWatchers: Map = new Map(); // container -> monitor (inotify stream or interval) + private shadowBasePath: string = SHADOW_BASE_PATH; + private autoCommitTimers: Map = new Map(); + private autoCommitEnabled: boolean = true; + private autoCommitIntervalMs: number = 5 * 60 * 1000; // 5 minutes default constructor(docker: Docker) { this.docker = docker; @@ -257,12 +262,13 @@ export class WebUIServer { connectedSocket.emit("container-disconnected"); } } - // Stop continuous monitoring + // Stop continuous monitoring and auto-commit this.stopContinuousMonitoring(containerId); - // Clean up session and shadow repo + this.stopAutoCommit(containerId); + // Clean up session but preserve shadow repo for recovery this.sessions.delete(containerId); if (this.shadowRepos.has(containerId)) { - this.shadowRepos.get(containerId)?.cleanup(); + this.shadowRepos.get(containerId)?.cleanup(true); this.shadowRepos.delete(containerId); } }); @@ -271,6 +277,9 @@ export class WebUIServer { // Start continuous monitoring for this container this.startContinuousMonitoring(containerId); + + // Start auto-commit for this container + this.startAutoCommit(containerId); } else { // Add this socket to the existing session console.log(chalk.blue("Reconnecting to existing Claude session")); @@ -463,16 +472,20 @@ export class WebUIServer { // Initialize shadow repo if not exists let isNewShadowRepo = false; if (!this.shadowRepos.has(containerId)) { - const shadowRepo = new ShadowRepository({ - originalRepo: this.originalRepo || process.cwd(), - claudeBranch: this.currentBranch || "claude-changes", - sessionId: containerId.substring(0, 12), - }); - this.shadowRepos.set(containerId, shadowRepo); + const shadowRepo = new ShadowRepository( + { + originalRepo: this.originalRepo || process.cwd(), + claudeBranch: this.currentBranch || "claude-changes", + sessionId: containerId.substring(0, 12), + }, + this.shadowBasePath, + ); isNewShadowRepo = true; // Reset shadow repo to match container's branch (important for PR/remote branch scenarios) + // Only add to map after successful initialization to allow retry on failure await shadowRepo.resetToContainerBranch(containerId); + this.shadowRepos.set(containerId, shadowRepo); } // Sync files from container (inotify already told us there are changes) @@ -808,10 +821,110 @@ export class WebUIServer { this.currentBranch = branch; } - async stop(): Promise { - // Clean up shadow repos + setShadowBasePath(basePath: string): void { + this.shadowBasePath = basePath; + } + + setAutoCommitConfig(enabled: boolean, intervalMinutes: number): void { + this.autoCommitEnabled = enabled; + this.autoCommitIntervalMs = intervalMinutes * 60 * 1000; + } + + private startAutoCommit(containerId: string): void { + if (!this.autoCommitEnabled) return; + + // Clear existing timer if any + this.stopAutoCommit(containerId); + + console.log( + chalk.blue( + `[AUTO-COMMIT] Starting auto-commit every ${this.autoCommitIntervalMs / 60000} minutes for ${containerId.substring(0, 12)}`, + ), + ); + + const timer = setInterval(async () => { + await this.performAutoCommit(containerId); + }, this.autoCommitIntervalMs); + + this.autoCommitTimers.set(containerId, timer); + } + + private stopAutoCommit(containerId: string): void { + const timer = this.autoCommitTimers.get(containerId); + if (timer) { + clearInterval(timer); + this.autoCommitTimers.delete(containerId); + console.log( + chalk.gray( + `[AUTO-COMMIT] Stopped for ${containerId.substring(0, 12)}`, + ), + ); + } + } + + private async performAutoCommit(containerId: string): Promise { + try { + const container = this.docker.getContainer(containerId); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + + // git add -u stages only tracked files; git diff --cached --quiet exits 1 when staged changes exist + const commitExec = await container.exec({ + Cmd: [ + "/bin/bash", + "-c", + `cd /workspace && git add -u && git diff --cached --quiet || git commit -m "[auto-save] ${timestamp}"`, + ], + AttachStdout: true, + AttachStderr: true, + WorkingDir: "/workspace", + User: "claude", + }); + + const stream = await commitExec.start({}); + + // Consume stream and check output + let output = ""; + await new Promise((resolve) => { + stream.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + stream.on("end", resolve); + stream.on("error", () => resolve()); + }); + + if (output.includes("[auto-save]")) { + console.log( + chalk.cyan( + `[AUTO-COMMIT] Committed changes in ${containerId.substring(0, 12)}`, + ), + ); + + // Trigger a sync so shadow repo picks up the commit + await this.performSync(containerId); + } else { + console.log( + chalk.gray( + `[AUTO-COMMIT] No changes to commit in ${containerId.substring(0, 12)}`, + ), + ); + } + } catch (error) { + console.error( + chalk.yellow(`[AUTO-COMMIT] Failed for ${containerId.substring(0, 12)}:`), + error, + ); + } + } + + async stop(intentional: boolean = true): Promise { + // Clean up auto-commit timers + for (const [containerId] of this.autoCommitTimers) { + this.stopAutoCommit(containerId); + } + + // Clean up shadow repos (preserve on non-intentional stop) for (const [, shadowRepo] of this.shadowRepos) { - await shadowRepo.cleanup(); + await shadowRepo.cleanup(!intentional); } // Clean up all sessions