From 7af340216b97f0f46139cbea264c3fe633ac10c5 Mon Sep 17 00:00:00 2001 From: Joshua Austill Date: Sun, 1 Feb 2026 09:19:48 -0700 Subject: [PATCH 01/10] Add Linux Docker socket support and fix OAuth credential discovery - Add rootless Docker socket paths ($XDG_RUNTIME_DIR/docker.sock, /run/user//docker.sock) - Add Docker Desktop for Linux socket path (~/.docker/desktop/docker.sock) - Add Colima socket paths for macOS (~/.colima/default/docker.sock, ~/.docker/run/docker.sock) - Fix OAuth credential detection to find ~/.claude/.credentials.json with claudeAiOauth key - Exclude actively-written directories when copying .claude config to prevent tar errors Co-Authored-By: Claude Opus 4.5 --- src/container.ts | 15 ++++++++++++++- src/credentials.ts | 14 ++++++++++---- src/docker-config.ts | 17 +++++++++++++++-- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/container.ts b/src/container.ts index 4b2fa92..2f98a62 100644 --- a/src/container.ts +++ b/src/container.ts @@ -790,7 +790,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`, { 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"), From b6917048eacc453ef73c131506e397ac8a9d39e7 Mon Sep 17 00:00:00 2001 From: Joshua Austill Date: Sun, 1 Feb 2026 16:24:25 +0000 Subject: [PATCH 02/10] Auto-accept bypass permissions prompt on container startup Configure Claude Code settings to use bypassPermissions mode by default, eliminating the "By proceeding, you accept all responsibility" confirmation prompt when starting Claude inside the container. Co-Authored-By: Claude Opus 4.5 --- src/container.ts | 75 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/container.ts b/src/container.ts index 4b2fa92..16540b5 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) { @@ -829,6 +832,78 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ } } + private async _setupBypassPermissions( + container: Docker.Container, + ): Promise { + try { + console.log(chalk.blue("• Configuring bypass permissions mode...")); + + // Create or update settings.json to enable bypass permissions mode + // This skips the "By proceeding, you accept all responsibility" confirmation prompt + const settingsContent = JSON.stringify( + { + permissions: { + defaultMode: "bypassPermissions", + }, + }, + null, + 2, + ); + + const setupExec = await container.exec({ + Cmd: [ + "/bin/bash", + "-c", + ` + # Ensure .claude directory exists + mkdir -p /home/claude/.claude && + + # Check if settings.json exists + if [ -f /home/claude/.claude/settings.json ]; then + # Merge with existing settings using jq if available, otherwise replace + if command -v jq &> /dev/null; then + # Use jq to merge settings, preserving existing values + jq '.permissions.defaultMode = "bypassPermissions"' /home/claude/.claude/settings.json > /tmp/settings.json.tmp && + mv /tmp/settings.json.tmp /home/claude/.claude/settings.json + else + # No jq, just overwrite (existing settings will be lost) + echo '${settingsContent}' > /home/claude/.claude/settings.json + fi + else + # Create new settings file + echo '${settingsContent}' > /home/claude/.claude/settings.json + fi && + + # Fix permissions + chown -R claude:claude /home/claude/.claude && + chmod 700 /home/claude/.claude && + chmod 600 /home/claude/.claude/settings.json + `, + ], + AttachStdout: true, + AttachStderr: true, + }); + + const stream = await setupExec.start({}); + + // Wait for completion + await new Promise((resolve, reject) => { + 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"); From 782efebbc6b659f067d50b34e21d0be1c13506c0 Mon Sep 17 00:00:00 2001 From: Joshua Austill Date: Sun, 1 Feb 2026 09:49:34 -0700 Subject: [PATCH 03/10] Add --no-web terminal mode and fix bypass permissions stream handling - Add --no-web CLI option for terminal-only mode (no browser) - Implement terminal attach with proper PTY size handling and resize support - Fix bypass permissions setup to consume stream data (prevents hanging) - Use settings.local.json with correct format for bypass mode config Co-Authored-By: Claude Opus 4.5 --- src/cli.ts | 2 + src/container.ts | 44 ++++++++++--------- src/index.ts | 107 ++++++++++++++++++++++++++++++++++++++++------- src/types.ts | 1 + 4 files changed, 117 insertions(+), 37 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 07c1996..85a806c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -125,6 +125,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 +137,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(); } diff --git a/src/container.ts b/src/container.ts index 697ee44..68184a5 100644 --- a/src/container.ts +++ b/src/container.ts @@ -851,18 +851,7 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ try { console.log(chalk.blue("• Configuring bypass permissions mode...")); - // Create or update settings.json to enable bypass permissions mode - // This skips the "By proceeding, you accept all responsibility" confirmation prompt - const settingsContent = JSON.stringify( - { - permissions: { - defaultMode: "bypassPermissions", - }, - }, - null, - 2, - ); - + // The settings need to have defaultMode at the root level per Claude Code docs const setupExec = await container.exec({ Cmd: [ "/bin/bash", @@ -871,26 +860,34 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ # Ensure .claude directory exists mkdir -p /home/claude/.claude && - # Check if settings.json exists - if [ -f /home/claude/.claude/settings.json ]; then - # Merge with existing settings using jq if available, otherwise replace + # 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 - # Use jq to merge settings, preserving existing values - jq '.permissions.defaultMode = "bypassPermissions"' /home/claude/.claude/settings.json > /tmp/settings.json.tmp && - mv /tmp/settings.json.tmp /home/claude/.claude/settings.json + jq '. + {"defaultMode": "bypassPermissions"}' "$SETTINGS_FILE" > /tmp/settings.tmp && mv /tmp/settings.tmp "$SETTINGS_FILE" else - # No jq, just overwrite (existing settings will be lost) - echo '${settingsContent}' > /home/claude/.claude/settings.json + # 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 '${settingsContent}' > /home/claude/.claude/settings.json + echo '{"defaultMode": "bypassPermissions"}' > "$SETTINGS_FILE" fi && # Fix permissions chown -R claude:claude /home/claude/.claude && chmod 700 /home/claude/.claude && - chmod 600 /home/claude/.claude/settings.json + chmod 600 "$SETTINGS_FILE" `, ], AttachStdout: true, @@ -899,8 +896,9 @@ exec claude --dangerously-skip-permissions' > /start-claude.sh && \\ const stream = await setupExec.start({}); - // Wait for completion + // 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); }); diff --git a/src/index.ts b/src/index.ts index 9f860a5..9519538 100644 --- a/src/index.ts +++ b/src/index.ts @@ -156,25 +156,33 @@ 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 to web server + this.webServer.setRepoInfo(process.cwd(), branchName); - 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 + // 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")); + + await this.attachToContainer(containerId); + } } catch (error) { console.error(chalk.red("Error:"), error); throw error; @@ -267,6 +275,77 @@ export class ClaudeSandbox { await this.webServer.stop(); } } + + 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/types.ts b/src/types.ts index 5c641be..8e00441 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,7 @@ export interface SandboxConfig { remoteBranch?: string; prNumber?: string; dockerSocketPath?: string; + useWebUI?: boolean; } export interface Credentials { From 8d0086077024c64d8552ac5a35eff52e29c02941 Mon Sep 17 00:00:00 2001 From: Joshua Austill Date: Sun, 1 Feb 2026 11:54:53 -0700 Subject: [PATCH 04/10] Add global config support (~/.config/claude-sandbox/config.json) Config priority: defaults < global < local project config This allows setting dockerImage globally without modifying code: mkdir -p ~/.config/claude-sandbox echo '{"dockerImage": "docker-devbox:latest"}' > ~/.config/claude-sandbox/config.json Co-Authored-By: Claude Opus 4.5 --- src/config.ts | 45 ++++++++++++++++++++++++++++++++------------- src/index.ts | 2 +- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/config.ts b/src/config.ts index 71cda73..af4cf53 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,14 @@ 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" +); + const DEFAULT_CONFIG: SandboxConfig = { dockerImage: "claude-code-sandbox:latest", autoPush: true, @@ -17,23 +25,30 @@ const DEFAULT_CONFIG: SandboxConfig = { // 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 +56,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/index.ts b/src/index.ts index 9519538..e9b9e46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -212,7 +212,7 @@ export class ClaudeSandbox { credentials, workDir, repoName, - dockerImage: this.config.dockerImage || "claude-sandbox:latest", + dockerImage: this.config.dockerImage || "claude-code-sandbox:latest", prFetchRef, remoteFetchRef, }; From 526f6602066144c8cb220451b6e563dcbfa606c7 Mon Sep 17 00:00:00 2001 From: Joshua Austill Date: Sun, 1 Feb 2026 12:04:10 -0700 Subject: [PATCH 05/10] Update docs: global config, Linux socket paths, credential locations Co-Authored-By: Claude Opus 4.5 --- README.md | 52 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 11 deletions(-) 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/`) From 1df8c37ed0ef473c13af4c96c4fcc185ab1d1b4c Mon Sep 17 00:00:00 2001 From: Joshua Austill Date: Sun, 1 Feb 2026 12:17:26 -0700 Subject: [PATCH 06/10] Update package-lock.json peer dependencies Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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", From 8b8e14475abcd12aaf1f30f5cf25d684d5c7787b Mon Sep 17 00:00:00 2001 From: Joshua Austill Date: Thu, 5 Feb 2026 17:54:34 -0700 Subject: [PATCH 07/10] Fix plugin paths in container to resolve from /home/claude Plugin registry files (installed_plugins.json, known_marketplaces.json) contain absolute paths from the host user's home directory. When copied into the container where the user is claude (/home/claude), these paths don't resolve and all plugins fail to load. Rewrite the paths after copying the .claude directory into the container. Co-Authored-By: Claude Opus 4.6 --- src/container.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/container.ts b/src/container.ts index 68184a5..8eda8c5 100644 --- a/src/container.ts +++ b/src/container.ts @@ -821,6 +821,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({ From ecda67953c2f9fbc5ca9f78297213b7bb4d5ae3b Mon Sep 17 00:00:00 2001 From: Joshua Austill Date: Fri, 6 Feb 2026 14:17:20 -0700 Subject: [PATCH 08/10] Add session persistence through reboots with five defensive layers Persistent shadow repos (~/.cache instead of /tmp), Docker restart policy (unless-stopped), session store with atomic writes, auto-commit inside containers, and recovery command to re-attach or extract work from crashed sessions. Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 171 +++++++++++++++++++++++++-- src/config.ts | 17 +++ src/container.ts | 23 +++- src/git/shadow-repository.ts | 18 ++- src/index.ts | 57 ++++++++- src/recover.ts | 223 +++++++++++++++++++++++++++++++++++ src/session-store.ts | 130 ++++++++++++++++++++ src/types.ts | 4 + src/web-server.ts | 134 +++++++++++++++++++-- 9 files changed, 744 insertions(+), 33 deletions(-) create mode 100644 src/recover.ts create mode 100644 src/session-store.ts diff --git a/src/cli.ts b/src/cli.ts index 85a806c..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 @@ -339,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(); @@ -351,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); @@ -436,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`, ), ); } @@ -471,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 af4cf53..b0862c2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,20 @@ const GLOBAL_CONFIG_PATH = path.join( "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, @@ -21,6 +35,9 @@ 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 }; diff --git a/src/container.ts b/src/container.ts index 8eda8c5..50e6486 100644 --- a/src/container.ts +++ b/src/container.ts @@ -259,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"], @@ -1208,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/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 e9b9e46..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); @@ -160,8 +189,15 @@ export class ClaudeSandbox { if (this.config.useWebUI !== false) { this.webServer = new WebUIServer(this.docker); - // Pass repo info to web server + // 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(); @@ -268,11 +304,24 @@ 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(), + }); + } } } 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 8e00441..e6e2ad9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,10 @@ export interface SandboxConfig { 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..372a338 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,11 +472,14 @@ 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), - }); + const shadowRepo = new ShadowRepository( + { + originalRepo: this.originalRepo || process.cwd(), + claudeBranch: this.currentBranch || "claude-changes", + sessionId: containerId.substring(0, 12), + }, + this.shadowBasePath, + ); this.shadowRepos.set(containerId, shadowRepo); isNewShadowRepo = true; @@ -808,10 +820,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 -A stages all changes; git diff --cached --quiet exits 1 when staged changes exist + const commitExec = await container.exec({ + Cmd: [ + "/bin/bash", + "-c", + `cd /workspace && git add -A && 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 From 9f2ed4a28086d2b52a886b1adadc9ab07da6ed66 Mon Sep 17 00:00:00 2001 From: Joshua Austill Date: Fri, 6 Feb 2026 14:19:08 -0700 Subject: [PATCH 09/10] Use git add -u for auto-commit to only save tracked files Co-Authored-By: Claude Opus 4.6 --- src/web-server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web-server.ts b/src/web-server.ts index 372a338..bdf9f98 100644 --- a/src/web-server.ts +++ b/src/web-server.ts @@ -866,12 +866,12 @@ export class WebUIServer { const container = this.docker.getContainer(containerId); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - // git add -A stages all changes; git diff --cached --quiet exits 1 when staged changes exist + // 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 -A && git diff --cached --quiet || git commit -m "[auto-save] ${timestamp}"`, + `cd /workspace && git add -u && git diff --cached --quiet || git commit -m "[auto-save] ${timestamp}"`, ], AttachStdout: true, AttachStderr: true, From 43cf4babc013ba0c9d5673e2aaa074683d6c6ffa Mon Sep 17 00:00:00 2001 From: Joshua Austill Date: Fri, 6 Feb 2026 14:37:58 -0700 Subject: [PATCH 10/10] Fix shadow repo init retry by deferring map insertion until success Co-Authored-By: Claude Opus 4.6 --- src/web-server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/web-server.ts b/src/web-server.ts index bdf9f98..2ee9007 100644 --- a/src/web-server.ts +++ b/src/web-server.ts @@ -480,11 +480,12 @@ export class WebUIServer { }, this.shadowBasePath, ); - this.shadowRepos.set(containerId, shadowRepo); 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)