Add Podman support (#12)

* Add Podman support

* Add tests
This commit is contained in:
Onur Solmaz 2025-06-12 16:59:16 +02:00 committed by GitHub
parent 4237d506d0
commit 55a36fcf1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 411 additions and 4 deletions

42
.claude/settings.json Normal file
View file

@ -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
}

View file

@ -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:

View file

@ -27,5 +27,6 @@
"maxThinkingTokens": 100000,
"bashTimeout": 600000,
"containerPrefix": "claude-code-sandbox",
"claudeConfigPath": "~/.claude.json"
"claudeConfigPath": "~/.claude.json",
"dockerSocketPath": null
}

View file

@ -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 <lines>", "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) {

61
src/docker-config.ts Normal file
View file

@ -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;
}

View file

@ -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);

View file

@ -25,6 +25,7 @@ export interface SandboxConfig {
targetBranch?: string;
remoteBranch?: string;
prNumber?: string;
dockerSocketPath?: string;
}
export interface Credentials {

147
test/docker-config.test.js Normal file
View file

@ -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);
});
});
});

View file

@ -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');
});
});
});