Skip to content
This repository was archived by the owner on Feb 20, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 41 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -237,26 +262,27 @@ 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
{
"dockerSocketPath": "/run/user/1000/podman/podman.sock"
}
```

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

Expand Down Expand Up @@ -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/`)
Expand Down
13 changes: 11 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

173 changes: 160 additions & 13 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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..."));

Expand All @@ -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();
}
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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`,
),
);
}
Expand Down Expand Up @@ -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();
Loading