diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..07c06aa --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,42 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)", + "Bash(npm run build:*)", + "Bash(grep:*)", + "Bash(ls:*)", + "Bash(docker logs:*)", + "Bash(docker kill:*)", + "Bash(docker rm:*)", + "Bash(npx tsc:*)", + "Bash(npm install:*)", + "Bash(node:*)", + "Bash(timeout:*)", + "Bash(true)", + "Bash(docker stop:*)", + "Bash(mv:*)", + "Bash(curl:*)", + "WebFetch(domain:localhost)", + "Bash(pkill:*)", + "Bash(docker exec:*)", + "Bash(npx ts-node:*)", + "Bash(docker pull:*)", + "Bash(rg:*)", + "Bash(npm start)", + "Bash(find:*)", + "Bash(npm run lint)", + "Bash(sed:*)", + "Bash(npx claude-sandbox purge:*)", + "Bash(docker cp:*)", + "Bash(npm run test:e2e:*)", + "Bash(gh pr list:*)", + "Bash(kill:*)", + "Bash(npm start:*)", + "Bash(npm run purge-containers:*)", + "Bash(claude-sandbox start:*)", + "Bash(npm run lint)" + ], + "deny": [] + }, + "enableAllProjectMcpServers": false +} diff --git a/README.md b/README.md index 014db36..1c76f51 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ npm install -g @textcortex/claude-code-sandbox ### Prerequisites - Node.js >= 18.0.0 -- Docker +- Docker or Podman - Git - Claude Code (`npm install -g @anthropic-ai/claude-code@latest`) @@ -218,6 +218,7 @@ Create a `claude-sandbox.config.json` file (see `claude-sandbox.config.example.j - `bashTimeout`: Timeout for bash commands in milliseconds - `containerPrefix`: Custom prefix for container names - `claudeConfigPath`: Path to Claude configuration file +- `dockerSocketPath`: Custom Docker/Podman socket path (auto-detected by default) #### Mount Configuration @@ -236,6 +237,27 @@ Example use cases: ## Features +### Podman Support + +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: + +- **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 + +Example configuration for Podman: + +```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`) + ### Web UI Terminal Launch a browser-based terminal interface to interact with Claude Code: diff --git a/claude-sandbox.config.example.json b/claude-sandbox.config.example.json index ba63ddf..af97d27 100644 --- a/claude-sandbox.config.example.json +++ b/claude-sandbox.config.example.json @@ -27,5 +27,6 @@ "maxThinkingTokens": 100000, "bashTimeout": 600000, "containerPrefix": "claude-code-sandbox", - "claudeConfigPath": "~/.claude.json" + "claudeConfigPath": "~/.claude.json", + "dockerSocketPath": null } diff --git a/src/cli.ts b/src/cli.ts index b308e41..07c1996 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,11 +6,37 @@ import Docker from "dockerode"; import { ClaudeSandbox } from "./index"; import { loadConfig } from "./config"; import { WebUIServer } from "./web-server"; +import { getDockerConfig, isPodman } from "./docker-config"; import ora from "ora"; -const docker = new Docker(); +// Initialize Docker with config - will be updated after loading config if needed +let dockerConfig = getDockerConfig(); +let docker = new Docker(dockerConfig); const program = new Command(); +// Helper function to reinitialize Docker with custom socket path +function reinitializeDocker(socketPath?: string) { + if (socketPath) { + dockerConfig = getDockerConfig(socketPath); + docker = new Docker(dockerConfig); + + // Log if using Podman + if (isPodman(dockerConfig)) { + console.log(chalk.blue("Detected Podman socket")); + } + } +} + +// Helper to ensure Docker is initialized with config +async function ensureDockerConfig() { + try { + const config = await loadConfig("./claude-sandbox.config.json"); + reinitializeDocker(config.dockerSocketPath); + } catch (error) { + // Config loading failed, continue with default Docker config + } +} + // Helper function to get Claude Sandbox containers async function getClaudeSandboxContainers() { const containers = await docker.listContainers({ all: true }); @@ -123,6 +149,7 @@ program .command("attach [container-id]") .description("Attach to an existing Claude Sandbox container") .action(async (containerId) => { + await ensureDockerConfig(); const spinner = ora("Looking for containers...").start(); try { @@ -169,6 +196,7 @@ program .description("List all Claude Sandbox containers") .option("-a, --all", "Show all containers (including stopped)") .action(async (options) => { + await ensureDockerConfig(); const spinner = ora("Fetching containers...").start(); try { @@ -211,6 +239,7 @@ program .description("Stop Claude Sandbox container(s)") .option("-a, --all", "Stop all Claude Sandbox containers") .action(async (containerId, options) => { + await ensureDockerConfig(); const spinner = ora("Stopping containers...").start(); try { @@ -272,6 +301,7 @@ program .option("-n, --tail ", "Number of lines to show from the end", "50") .action(async (containerId, options) => { try { + await ensureDockerConfig(); let targetContainerId = containerId; if (!targetContainerId) { @@ -310,6 +340,7 @@ program .description("Remove all stopped Claude Sandbox containers") .option("-f, --force", "Remove all containers (including running)") .action(async (options) => { + await ensureDockerConfig(); const spinner = ora("Cleaning up containers...").start(); try { @@ -346,6 +377,7 @@ program .option("-y, --yes", "Skip confirmation prompt") .action(async (options) => { try { + await ensureDockerConfig(); const containers = await getClaudeSandboxContainers(); if (containers.length === 0) { diff --git a/src/docker-config.ts b/src/docker-config.ts new file mode 100644 index 0000000..a68c162 --- /dev/null +++ b/src/docker-config.ts @@ -0,0 +1,61 @@ +import * as fs from "fs"; +import * as path from "path"; + +interface DockerConfig { + socketPath?: string; +} + +/** + * Detects whether Docker or Podman is available and returns appropriate configuration + * @param customSocketPath - Optional custom socket path from configuration + */ +export function getDockerConfig(customSocketPath?: string): DockerConfig { + // Allow override via environment variable + if (process.env.DOCKER_HOST) { + return {}; // dockerode will use DOCKER_HOST automatically + } + + // Use custom socket path if provided + if (customSocketPath) { + return { socketPath: customSocketPath }; + } + + // Common socket paths to check + const socketPaths = [ + // Docker socket paths + "/var/run/docker.sock", + + // Podman rootless socket paths + process.env.XDG_RUNTIME_DIR && + path.join(process.env.XDG_RUNTIME_DIR, "podman", "podman.sock"), + `/run/user/${process.getuid?.() || 1000}/podman/podman.sock`, + + // Podman root socket path + "/run/podman/podman.sock", + ].filter(Boolean) as string[]; + + // Check each socket path + for (const socketPath of socketPaths) { + try { + if (fs.existsSync(socketPath)) { + const stats = fs.statSync(socketPath); + if (stats.isSocket()) { + return { socketPath }; + } + } + } catch (error) { + // Socket might exist but not be accessible, continue checking + continue; + } + } + + // No socket found, return empty config and let dockerode use its defaults + return {}; +} + +/** + * Checks if we're using Podman based on the socket path + */ +export function isPodman(config: DockerConfig): boolean { + return config.socketPath?.includes("podman") ?? false; +} diff --git a/src/index.ts b/src/index.ts index 517f821..9f860a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { ContainerManager } from "./container"; import { UIManager } from "./ui"; import { WebUIServer } from "./web-server"; import { SandboxConfig } from "./types"; +import { getDockerConfig, isPodman } from "./docker-config"; import path from "path"; export class ClaudeSandbox { @@ -21,7 +22,14 @@ export class ClaudeSandbox { constructor(config: SandboxConfig) { this.config = config; - this.docker = new Docker(); + const dockerConfig = getDockerConfig(config.dockerSocketPath); + this.docker = new Docker(dockerConfig); + + // Log if using Podman + if (isPodman(dockerConfig)) { + console.log(chalk.blue("Detected Podman socket")); + } + this.git = simpleGit(); this.credentialManager = new CredentialManager(); this.gitMonitor = new GitMonitor(this.git); diff --git a/src/types.ts b/src/types.ts index 2eb5f8c..5c641be 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,7 @@ export interface SandboxConfig { targetBranch?: string; remoteBranch?: string; prNumber?: string; + dockerSocketPath?: string; } export interface Credentials { diff --git a/test/docker-config.test.js b/test/docker-config.test.js new file mode 100644 index 0000000..beccffe --- /dev/null +++ b/test/docker-config.test.js @@ -0,0 +1,147 @@ +const { getDockerConfig, isPodman } = require('../dist/docker-config'); +const fs = require('fs'); +const path = require('path'); + +describe('Docker/Podman Configuration', () => { + let originalEnv; + + beforeEach(() => { + // Save original environment + originalEnv = { ...process.env }; + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + }); + + describe('getDockerConfig', () => { + it('should return empty config when DOCKER_HOST is set', () => { + process.env.DOCKER_HOST = 'tcp://localhost:2375'; + const config = getDockerConfig(); + expect(config).toEqual({}); + }); + + it('should return custom socket path when provided', () => { + const customPath = '/custom/socket/path'; + const config = getDockerConfig(customPath); + expect(config).toEqual({ socketPath: customPath }); + }); + + it('should detect Docker socket at default location', () => { + // Mock fs.existsSync and fs.statSync + jest.spyOn(fs, 'existsSync').mockImplementation((path) => { + return path === '/var/run/docker.sock'; + }); + jest.spyOn(fs, 'statSync').mockImplementation(() => ({ + isSocket: () => true + })); + + const config = getDockerConfig(); + expect(config).toEqual({ socketPath: '/var/run/docker.sock' }); + + fs.existsSync.mockRestore(); + fs.statSync.mockRestore(); + }); + + it('should detect Podman rootless socket', () => { + const expectedPath = `/run/user/${process.getuid?.() || 1000}/podman/podman.sock`; + + jest.spyOn(fs, 'existsSync').mockImplementation((path) => { + return path === expectedPath; + }); + jest.spyOn(fs, 'statSync').mockImplementation(() => ({ + isSocket: () => true + })); + + const config = getDockerConfig(); + expect(config).toEqual({ socketPath: expectedPath }); + + fs.existsSync.mockRestore(); + fs.statSync.mockRestore(); + }); + + it('should detect Podman root socket', () => { + jest.spyOn(fs, 'existsSync').mockImplementation((path) => { + return path === '/run/podman/podman.sock'; + }); + jest.spyOn(fs, 'statSync').mockImplementation(() => ({ + isSocket: () => true + })); + + const config = getDockerConfig(); + expect(config).toEqual({ socketPath: '/run/podman/podman.sock' }); + + fs.existsSync.mockRestore(); + fs.statSync.mockRestore(); + }); + + it('should use XDG_RUNTIME_DIR for Podman socket if available', () => { + process.env.XDG_RUNTIME_DIR = '/run/user/1000'; + const expectedPath = '/run/user/1000/podman/podman.sock'; + + jest.spyOn(fs, 'existsSync').mockImplementation((path) => { + return path === expectedPath; + }); + jest.spyOn(fs, 'statSync').mockImplementation(() => ({ + isSocket: () => true + })); + + const config = getDockerConfig(); + expect(config).toEqual({ socketPath: expectedPath }); + + fs.existsSync.mockRestore(); + fs.statSync.mockRestore(); + }); + + it('should return empty config when no socket is found', () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + + const config = getDockerConfig(); + expect(config).toEqual({}); + + fs.existsSync.mockRestore(); + }); + + it('should handle file system errors gracefully', () => { + jest.spyOn(fs, 'existsSync').mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const config = getDockerConfig(); + expect(config).toEqual({}); + + fs.existsSync.mockRestore(); + }); + }); + + describe('isPodman', () => { + it('should return true for Podman socket paths', () => { + expect(isPodman({ socketPath: '/run/podman/podman.sock' })).toBe(true); + expect(isPodman({ socketPath: '/run/user/1000/podman/podman.sock' })).toBe(true); + expect(isPodman({ socketPath: '/var/lib/podman/podman.sock' })).toBe(true); + }); + + it('should return false for Docker socket paths', () => { + expect(isPodman({ socketPath: '/var/run/docker.sock' })).toBe(false); + expect(isPodman({ socketPath: '/custom/docker.sock' })).toBe(false); + }); + + it('should return false when no socket path is provided', () => { + expect(isPodman({})).toBe(false); + expect(isPodman({ socketPath: undefined })).toBe(false); + }); + }); + + describe('Integration with configuration', () => { + it('should properly integrate with SandboxConfig', () => { + const sandboxConfig = { + dockerSocketPath: '/custom/podman/socket' + }; + + const dockerConfig = getDockerConfig(sandboxConfig.dockerSocketPath); + expect(dockerConfig).toEqual({ socketPath: '/custom/podman/socket' }); + expect(isPodman(dockerConfig)).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/test/integration/podman-support.test.js b/test/integration/podman-support.test.js new file mode 100644 index 0000000..41413b2 --- /dev/null +++ b/test/integration/podman-support.test.js @@ -0,0 +1,93 @@ +const { loadConfig } = require('../../dist/config'); +const { getDockerConfig, isPodman } = require('../../dist/docker-config'); +const Docker = require('dockerode'); +const fs = require('fs'); +const path = require('path'); + +describe('Podman Support Integration', () => { + const testConfigPath = path.join(__dirname, 'test-podman-config.json'); + + afterEach(() => { + // Clean up test config file + if (fs.existsSync(testConfigPath)) { + fs.unlinkSync(testConfigPath); + } + }); + + it('should load Podman socket path from configuration', async () => { + // Create test config with Podman socket + const testConfig = { + dockerSocketPath: '/run/user/1000/podman/podman.sock', + dockerImage: 'claude-code-sandbox:latest' + }; + fs.writeFileSync(testConfigPath, JSON.stringify(testConfig, null, 2)); + + // Load config + const config = await loadConfig(testConfigPath); + expect(config.dockerSocketPath).toBe('/run/user/1000/podman/podman.sock'); + + // Get Docker config + const dockerConfig = getDockerConfig(config.dockerSocketPath); + expect(dockerConfig.socketPath).toBe('/run/user/1000/podman/podman.sock'); + expect(isPodman(dockerConfig)).toBe(true); + }); + + it('should create Docker client with Podman socket', async () => { + const podmanSocketPath = '/custom/podman/podman.sock'; + const dockerConfig = getDockerConfig(podmanSocketPath); + + // Create Docker client + const docker = new Docker(dockerConfig); + + // Verify the client has the correct socket path + expect(docker.modem.socketPath).toBe(podmanSocketPath); + }); + + it('should fallback to auto-detection when no socket path in config', async () => { + // Create config without dockerSocketPath + const testConfig = { + dockerImage: 'claude-code-sandbox:latest' + }; + fs.writeFileSync(testConfigPath, JSON.stringify(testConfig, null, 2)); + + // Load config + const config = await loadConfig(testConfigPath); + expect(config.dockerSocketPath).toBeUndefined(); + + // Get Docker config - should auto-detect + const dockerConfig = getDockerConfig(config.dockerSocketPath); + + // Should have detected something (Docker or Podman) + if (dockerConfig.socketPath) { + expect(typeof dockerConfig.socketPath).toBe('string'); + } + }); + + describe('Environment variable support', () => { + let originalDockerHost; + + beforeEach(() => { + originalDockerHost = process.env.DOCKER_HOST; + }); + + afterEach(() => { + if (originalDockerHost) { + process.env.DOCKER_HOST = originalDockerHost; + } else { + delete process.env.DOCKER_HOST; + } + }); + + it('should respect DOCKER_HOST environment variable', () => { + process.env.DOCKER_HOST = 'tcp://podman.local:2376'; + + const dockerConfig = getDockerConfig(); + expect(dockerConfig).toEqual({}); + + // dockerode will handle DOCKER_HOST internally + const docker = new Docker(dockerConfig); + expect(docker.modem.host).toBe('podman.local'); + expect(docker.modem.port).toBe('2376'); + }); + }); +}); \ No newline at end of file