mirror of
https://github.com/textcortex/claude-code-sandbox.git
synced 2025-07-07 13:25:10 +00:00
parent
4237d506d0
commit
55a36fcf1f
9 changed files with 411 additions and 4 deletions
42
.claude/settings.json
Normal file
42
.claude/settings.json
Normal 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
|
||||
}
|
24
README.md
24
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:
|
||||
|
|
|
@ -27,5 +27,6 @@
|
|||
"maxThinkingTokens": 100000,
|
||||
"bashTimeout": 600000,
|
||||
"containerPrefix": "claude-code-sandbox",
|
||||
"claudeConfigPath": "~/.claude.json"
|
||||
"claudeConfigPath": "~/.claude.json",
|
||||
"dockerSocketPath": null
|
||||
}
|
||||
|
|
34
src/cli.ts
34
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 <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
61
src/docker-config.ts
Normal 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;
|
||||
}
|
10
src/index.ts
10
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);
|
||||
|
|
|
@ -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
147
test/docker-config.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
93
test/integration/podman-support.test.js
Normal file
93
test/integration/podman-support.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue