diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox new file mode 100644 index 0000000..57f1302 --- /dev/null +++ b/Dockerfile.sandbox @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +# Install git, curl, and npm (which pulls nodejs natively) +RUN apt-get update && apt-get install -y git curl npm && rm -rf /var/lib/apt/lists/* + +# Install claude-code globally +RUN npm install -g @anthropic-ai/claude-code + +# Install uv for fast Python packaging (requested by user) +RUN pip install uv + +# Create a non-root runtime user (UID/GID 1000 for keep-id mapping) +RUN groupadd -g 1000 node && useradd -m -u 1000 -g 1000 node + +# Prepare Claude config for non-interactive execution +RUN mkdir -p /home/node/.claude && \ + echo '{"theme":"light","autoUpdaterStatus":"disabled","optedIntoTengu":true,"optedIntoMacApp":true}' > /home/node/.claude/config.json && \ + chown -R node:node /home/node + +USER node +ENV CI=1 +ENV HOME=/home/node +WORKDIR /workspace diff --git a/config/settings.yaml.example b/config/settings.yaml.example index eef7f68..bd548c8 100644 --- a/config/settings.yaml.example +++ b/config/settings.yaml.example @@ -100,7 +100,7 @@ nightwire_assistant: # Runs Claude task execution inside a Docker container for isolation # sandbox: # enabled: false -# image: "python:3.11-slim" +# image: "nightwire-sandbox:latest" # network: false # Deny network access in sandbox # memory_limit: "2g" # cpu_limit: 2.0 diff --git a/docker-compose.yml b/docker-compose.yml index d05b9e0..d524e63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,6 @@ services: ports: - "127.0.0.1:8080:8080" volumes: - - ./signal-data:/home/.local/share/signal-cli + - ./signal-data:/home/.local/share/signal-cli:Z environment: - MODE=json-rpc diff --git a/install.sh b/install.sh index 3aa414d..e929371 100755 --- a/install.sh +++ b/install.sh @@ -714,7 +714,7 @@ if [ "$SKIP_SIGNAL" = false ]; then --name signal-api \ --restart unless-stopped \ -p "$SIGNAL_BIND:8080:8080" \ - -v "$SIGNAL_DATA_DIR:/home/.local/share/signal-cli" \ + -v "$SIGNAL_DATA_DIR:/home/.local/share/signal-cli:Z" \ -e MODE=native \ bbernhard/signal-cli-rest-api:latest @@ -817,7 +817,7 @@ if command -v docker &> /dev/null && docker info &> /dev/null; then --name signal-api \ --restart unless-stopped \ -p "127.0.0.1:8080:8080" \ - -v "$SIGNAL_DATA_DIR:/home/.local/share/signal-cli" \ + -v "$SIGNAL_DATA_DIR:/home/.local/share/signal-cli:Z" \ -e MODE=json-rpc \ bbernhard/signal-cli-rest-api:latest diff --git a/nightwire/claude_runner.py b/nightwire/claude_runner.py index a0793c8..17060a7 100644 --- a/nightwire/claude_runner.py +++ b/nightwire/claude_runner.py @@ -23,6 +23,7 @@ class ErrorCategory(str, Enum): """Classification of Claude CLI errors for retry decisions.""" + TRANSIENT = "transient" PERMANENT = "permanent" INFRASTRUCTURE = "infrastructure" @@ -52,9 +53,15 @@ def classify_error(return_code: int, output: str, error_text: str) -> ErrorCateg # Rate limit errors - check for subscription-level patterns first if "rate limit" in combined or "429" in combined: subscription_patterns = ( - "usage limit", "daily limit", "capacity", "overloaded", - "too many requests", "try again later", "quota exceeded", - "hourly limit", "subscription", + "usage limit", + "daily limit", + "capacity", + "overloaded", + "too many requests", + "try again later", + "quota exceeded", + "hourly limit", + "subscription", ) for pattern in subscription_patterns: if pattern in combined: @@ -101,6 +108,7 @@ def _load_guidelines(self) -> str: def set_project(self, project_path: Path): """Set the current project directory.""" from .security import validate_project_path + validated = validate_project_path(str(project_path)) if validated is None: raise ValueError(f"Project path validation failed: access denied") @@ -132,6 +140,7 @@ async def run_claude( """ # Check cooldown before doing any work from .rate_limit_cooldown import get_cooldown_manager + cooldown = get_cooldown_manager() if cooldown.is_active: state = cooldown.get_state() @@ -170,14 +179,15 @@ async def run_claude( "--print", "--dangerously-skip-permissions", "--verbose", - "--max-turns", str(self.config.claude_max_turns), + "--max-turns", + str(self.config.claude_max_turns), ] logger.info( "claude_run_start", project=str(effective_project), prompt_length=len(prompt), - timeout=timeout + timeout=timeout, ) last_error = "" @@ -271,9 +281,7 @@ async def send_progress_updates(): elapsed_min = elapsed // 60 if progress_callback: try: - await progress_callback( - f"Still working... ({elapsed_min} min elapsed)" - ) + await progress_callback(f"Still working... ({elapsed_min} min elapsed)") except Exception as e: logger.warning("progress_callback_error", error=str(e)) @@ -291,11 +299,12 @@ async def send_progress_updates(): # Optionally wrap in Docker sandbox from .sandbox import build_sandbox_command, SandboxConfig + sandbox_settings = self.config.sandbox_config if sandbox_settings.get("enabled", False): sandbox_cfg = SandboxConfig( enabled=True, - image=sandbox_settings.get("image", "python:3.11-slim"), + image=sandbox_settings.get("image", "nightwire-sandbox:latest"), network=sandbox_settings.get("network", False), memory_limit=sandbox_settings.get("memory_limit", "2g"), cpu_limit=sandbox_settings.get("cpu_limit", 2.0), @@ -308,7 +317,7 @@ async def send_progress_updates(): stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - env=_subprocess_env + env=_subprocess_env, ) if progress_callback: @@ -316,10 +325,7 @@ async def send_progress_updates(): try: stdout, stderr = await asyncio.wait_for( - self._running_process.communicate( - input=prompt.encode("utf-8") - ), - timeout=timeout + self._running_process.communicate(input=prompt.encode("utf-8")), timeout=timeout ) except asyncio.TimeoutError: self._running_process.kill() @@ -351,7 +357,10 @@ async def send_progress_updates(): category = classify_error(return_code, output, errors) combined_output = output + errors - if "prompt is too long" in combined_output or "Conversation too long" in combined_output: + if ( + "prompt is too long" in combined_output + or "Conversation too long" in combined_output + ): logger.warning("claude_token_limit", output=combined_output[:500]) return ( False, diff --git a/nightwire/sandbox.py b/nightwire/sandbox.py index 9f9f9e9..cfe0b3e 100644 --- a/nightwire/sandbox.py +++ b/nightwire/sandbox.py @@ -12,8 +12,9 @@ @dataclass class SandboxConfig: """Configuration for Docker sandbox.""" + enabled: bool = False - image: str = "python:3.11-slim" + image: str = "nightwire-sandbox:latest" network: bool = False memory_limit: str = "2g" cpu_limit: float = 2.0 @@ -33,29 +34,41 @@ def build_sandbox_command( if not config.enabled: return cmd + container_cmd = list(cmd) + # The host path to claude won't exist in the container. + # We assume 'claude' is in the container's PATH. + if "claude" in Path(container_cmd[0]).name: + container_cmd[0] = "claude" + docker_cmd = [ - "docker", "run", + "docker", + "run", "--rm", "--interactive", + "--userns=keep-id", f"--memory={config.memory_limit}", f"--cpus={config.cpu_limit}", - "--tmpfs", f"/tmp:size={config.tmpfs_size}", - "-v", f"{project_path}:{project_path}:rw", - "-w", str(project_path), + "--tmpfs", + f"/tmp:size={config.tmpfs_size}", + "-v", + f"{project_path}:{project_path}:rw,z", + "-w", + str(project_path), ] if not config.network: docker_cmd.append("--network=none") - # Pass through essential env vars - docker_cmd.extend([ - "-e", "HOME", - "-e", "PATH", - "-e", "ANTHROPIC_API_KEY", - ]) + # Pass through essential env vars but omit PATH to avoid overriding container's paths + docker_cmd.extend( + [ + "-e", + "ANTHROPIC_API_KEY", + ] + ) docker_cmd.append(config.image) - docker_cmd.extend(cmd) + docker_cmd.extend(container_cmd) logger.info("sandbox_command_built", project=str(project_path), network=config.network)