From 3efdab36ca61a6795454668881d5b925abe22bd3 Mon Sep 17 00:00:00 2001 From: soryu Date: Thu, 15 Jan 2026 11:57:43 +0000 Subject: Fixup: Add cleanup and isolation features to makima Add comprehensive CLI documentation - Create makima/docs/CLI.md with complete command reference for: - makima server: HTTP/WebSocket server options - makima daemon: Worker daemon configuration - makima supervisor: Contract orchestration commands - makima contract: Task-contract interaction commands - Include configuration file examples and environment variables - Add usage workflows for common scenarios - Update makima/README.md with CLI overview and link to docs Add GitHub Actions release workflow for v0.1.0 Creates automated release workflow that: - Triggers on v* tag pushes - Builds binaries for Linux x86_64, macOS x86_64, and macOS ARM64 - Uses Rust nightly toolchain (required for edition 2024) - Packages binaries as .tar.gz archives - Creates GitHub release with installation instructions fix(ci): update macOS runner for x86_64 builds Replace deprecated macos-13 runner with macos-15-intel for x86_64-apple-darwin target. The macos-13 runner has been retired by GitHub Actions. Co-Authored-By: Claude Opus 4.5 Add dismissing notifications and fix CLI task ID arg Add worktree cleanup when contracts complete or are deleted (#21) - Add CleanupWorktree daemon command variant - Handle CleanupWorktree in daemon task manager - Add cleanup_contract_worktrees helper function - Trigger cleanup when contract status becomes 'completed' - Trigger cleanup before contract deletion Add Autonomous Loop Mode for persistent task completion (#20) Implements the "Autonomous Loop Mode" feature inspired by Ralph for Claude Code. This enables tasks to automatically restart and continue working until they explicitly signal completion via a COMPLETION_GATE block. Key features: - Exit confirmation via COMPLETION_GATE: Tasks must output a block with `ready: true` to signal completion. Without this, the task auto-restarts using `claude --continue` to resume the conversation. - Circuit breaker: Prevents infinite loops by detecting: * Maximum iteration limit (default: 10) * No progress for N consecutive iterations (default: 3) * Same error repeated N times (default: 5) - spawn_continue: New ProcessManager method to spawn Claude with the `--continue` flag, resuming from the previous session state. Toggle: Enable via `autonomous_loop` flag on contracts. When set, all tasks spawned for that contract will run in autonomous loop mode. Files changed: - completion_gate.rs: COMPLETION_GATE parser and CircuitBreaker logic - claude.rs: spawn_continue() for --continue mode spawning - manager.rs: Autonomous loop iteration logic in run_task() - protocol.rs: autonomousLoop field in DaemonCommand::SpawnTask - models.rs/repository.rs: autonomous_loop column on contracts/tasks - Migration: Adds autonomous_loop columns to contracts and tasks tables Add get-task and output commands to supervisor CLI (#24) Add two new supervisor subcommands: - `makima supervisor task ` - Get individual task details - `makima supervisor output ` - Get task output/claude log This allows supervisors to fetch task details and claude output directly from the CLI instead of using curl to call the task API. Add optional bubblewrap sandboxing for Claude processes (#23) Add --bubblewrap flag and process.bubblewrap config section to enable running Claude Code in a bubblewrap sandbox for process isolation. When enabled, claude processes run with filesystem restrictions: - Root filesystem mounted read-only - Working directory (worktree) mounted read-write - Fresh /dev, /proc, /tmp - Network access preserved for API calls Co-authored-by: Claude Opus 4.5 --- makima/src/daemon/process/claude.rs | 373 ++++++++++++++++++++++++++++++++++-- 1 file changed, 358 insertions(+), 15 deletions(-) (limited to 'makima/src/daemon/process/claude.rs') diff --git a/makima/src/daemon/process/claude.rs b/makima/src/daemon/process/claude.rs index 536d883..f3aa421 100644 --- a/makima/src/daemon/process/claude.rs +++ b/makima/src/daemon/process/claude.rs @@ -1,7 +1,7 @@ //! Claude Code process management. use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; @@ -11,6 +11,7 @@ use tokio::process::{Child, ChildStdin, Command}; use tokio::sync::{mpsc, Mutex}; use super::claude_protocol::ClaudeInputMessage; +use crate::daemon::config::BubblewrapConfig; /// Errors that can occur during Claude process management. #[derive(Debug, thiserror::Error)] @@ -26,6 +27,9 @@ pub enum ClaudeProcessError { #[error("Failed to read output: {0}")] OutputRead(String), + + #[error("Bubblewrap (bwrap) not found. Install bubblewrap or disable the --bubblewrap flag.")] + BubblewrapNotFound, } /// A line of output from Claude Code. @@ -234,6 +238,8 @@ pub struct ProcessManager { disable_verbose: bool, /// Default environment variables to pass. default_env: HashMap, + /// Bubblewrap sandbox configuration. + bubblewrap: Option, } impl Default for ProcessManager { @@ -252,6 +258,7 @@ impl ProcessManager { enable_permissions: false, disable_verbose: false, default_env: HashMap::new(), + bubblewrap: None, } } @@ -264,6 +271,7 @@ impl ProcessManager { enable_permissions: false, disable_verbose: false, default_env: HashMap::new(), + bubblewrap: None, } } @@ -297,11 +305,147 @@ impl ProcessManager { self } + /// Configure bubblewrap sandboxing. + /// + /// When enabled, Claude processes will be spawned inside a bubblewrap sandbox + /// with filesystem isolation. + pub fn with_bubblewrap(mut self, config: Option) -> Self { + self.bubblewrap = config; + self + } + /// Get the claude command path. pub fn claude_command(&self) -> &str { &self.claude_command } + /// Check if bubblewrap (bwrap) is available on the system. + /// + /// Returns the bwrap version string if available. + pub async fn check_bwrap_available(&self) -> Result { + let bwrap_cmd = self + .bubblewrap + .as_ref() + .map(|c| c.bwrap_command.as_str()) + .unwrap_or("bwrap"); + + let output = Command::new(bwrap_cmd) + .arg("--version") + .output() + .await + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ClaudeProcessError::BubblewrapNotFound + } else { + ClaudeProcessError::SpawnFailed(e) + } + })?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + Err(ClaudeProcessError::BubblewrapNotFound) + } + } + + /// Build bwrap command arguments for sandboxing. + /// + /// Returns a tuple of (bwrap_command, bwrap_args) where bwrap_args includes + /// all the sandbox flags followed by "--" and the actual command to run. + fn build_bwrap_args( + &self, + working_dir: &Path, + claude_command: &str, + claude_args: &[String], + config: &BubblewrapConfig, + ) -> (String, Vec) { + let mut args = Vec::new(); + + // Unshare all namespaces except user (for unprivileged use) + args.push("--unshare-all".to_string()); + + // Share network if enabled (needed for API calls) + if config.network { + args.push("--share-net".to_string()); + } + + // Safety flags + args.push("--die-with-parent".to_string()); + args.push("--new-session".to_string()); + + // Bind root filesystem read-only + args.push("--ro-bind".to_string()); + args.push("/".to_string()); + args.push("/".to_string()); + + // Mount fresh /dev + args.push("--dev".to_string()); + args.push("/dev".to_string()); + + // Mount fresh /proc + args.push("--proc".to_string()); + args.push("/proc".to_string()); + + // Fresh /tmp + args.push("--tmpfs".to_string()); + args.push("/tmp".to_string()); + + // Bind working directory (worktree) read-write + let working_dir_str = working_dir.to_string_lossy().to_string(); + args.push("--bind".to_string()); + args.push(working_dir_str.clone()); + args.push(working_dir_str); + + // Bind ~/.claude read-write if it exists (for Claude config) + if let Ok(home) = std::env::var("HOME") { + let claude_config_dir = PathBuf::from(&home).join(".claude"); + if claude_config_dir.exists() { + let claude_config_str = claude_config_dir.to_string_lossy().to_string(); + args.push("--bind".to_string()); + args.push(claude_config_str.clone()); + args.push(claude_config_str); + } + + // Also bind ~/.config/claude if it exists (alternative config location) + let claude_config_alt = PathBuf::from(&home).join(".config").join("claude"); + if claude_config_alt.exists() { + let config_str = claude_config_alt.to_string_lossy().to_string(); + args.push("--bind".to_string()); + args.push(config_str.clone()); + args.push(config_str); + } + } + + // Additional read-only binds from config + for path in &config.ro_bind { + if path.exists() { + let path_str = path.to_string_lossy().to_string(); + args.push("--ro-bind".to_string()); + args.push(path_str.clone()); + args.push(path_str); + } + } + + // Additional read-write binds from config + for path in &config.rw_bind { + if path.exists() { + let path_str = path.to_string_lossy().to_string(); + args.push("--bind".to_string()); + args.push(path_str.clone()); + args.push(path_str); + } + } + + // Separator before the actual command + args.push("--".to_string()); + + // Add the claude command and its arguments + args.push(claude_command.to_string()); + args.extend(claude_args.iter().cloned()); + + (config.bwrap_command.clone(), args) + } + /// Spawn a Claude Code process to execute a plan. /// /// The process runs in the specified working directory with stream-json output format. @@ -327,11 +471,25 @@ impl ProcessManager { extra_env: Option>, system_prompt: Option<&str>, ) -> Result { + // Check if bubblewrap is enabled and available + let use_bubblewrap = if let Some(ref bwrap_config) = self.bubblewrap { + if bwrap_config.enabled { + // Verify bwrap is available before proceeding + self.check_bwrap_available().await?; + true + } else { + false + } + } else { + false + }; + tracing::info!( working_dir = %working_dir.display(), plan_len = plan.len(), plan_preview = %if plan.len() > 200 { &plan[..200] } else { plan }, has_system_prompt = system_prompt.is_some(), + bubblewrap_enabled = use_bubblewrap, "Spawning Claude Code process" ); @@ -350,37 +508,52 @@ impl ProcessManager { env.extend(extra); } - // Build arguments list - let mut args = Vec::new(); + // Build Claude arguments list + let mut claude_args = Vec::new(); // Pre-args (before defaults) - args.extend(self.claude_pre_args.clone()); + claude_args.extend(self.claude_pre_args.clone()); // Required arguments for stream-json protocol - args.push("--output-format=stream-json".to_string()); - args.push("--input-format=stream-json".to_string()); + claude_args.push("--output-format=stream-json".to_string()); + claude_args.push("--input-format=stream-json".to_string()); // Optional default arguments if !self.disable_verbose { - args.push("--verbose".to_string()); + claude_args.push("--verbose".to_string()); } if !self.enable_permissions { - args.push("--dangerously-skip-permissions".to_string()); + claude_args.push("--dangerously-skip-permissions".to_string()); } // System prompt - passed via --system-prompt flag for system-level constraints if let Some(prompt) = system_prompt { - args.push("--system-prompt".to_string()); - args.push(prompt.to_string()); + claude_args.push("--system-prompt".to_string()); + claude_args.push(prompt.to_string()); } // Additional user-configured arguments - args.extend(self.claude_args.clone()); - - tracing::debug!(args = ?args, "Claude command arguments"); + claude_args.extend(self.claude_args.clone()); + + // Determine the actual command and arguments to spawn + let (command, args) = if use_bubblewrap { + let bwrap_config = self.bubblewrap.as_ref().unwrap(); + let (bwrap_cmd, bwrap_args) = + self.build_bwrap_args(working_dir, &self.claude_command, &claude_args, bwrap_config); + tracing::info!( + bwrap_command = %bwrap_cmd, + bwrap_args_count = bwrap_args.len(), + "Running Claude in bubblewrap sandbox" + ); + tracing::debug!(bwrap_args = ?bwrap_args, "Bubblewrap arguments"); + (bwrap_cmd, bwrap_args) + } else { + tracing::debug!(args = ?claude_args, "Claude command arguments"); + (self.claude_command.clone(), claude_args) + }; // Spawn the process - let mut child = Command::new(&self.claude_command) + let mut child = Command::new(&command) .args(&args) .current_dir(working_dir) .envs(env) @@ -391,7 +564,11 @@ impl ProcessManager { .spawn() .map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { - ClaudeProcessError::CommandNotFound(self.claude_command.clone()) + if use_bubblewrap { + ClaudeProcessError::BubblewrapNotFound + } else { + ClaudeProcessError::CommandNotFound(self.claude_command.clone()) + } } else { ClaudeProcessError::SpawnFailed(e) } @@ -487,6 +664,172 @@ impl ProcessManager { Ok(process) } + /// Spawn a Claude Code process in continuation mode. + /// + /// This is used for the autonomous loop feature where we need to continue + /// a previous conversation. The --continue flag tells Claude to resume + /// from the previous session state. + pub async fn spawn_continue( + &self, + working_dir: &Path, + continuation_prompt: &str, + extra_env: Option>, + system_prompt: Option<&str>, + ) -> Result { + tracing::info!( + working_dir = %working_dir.display(), + prompt_len = continuation_prompt.len(), + has_system_prompt = system_prompt.is_some(), + "Spawning Claude Code process in continuation mode" + ); + + // Verify working directory exists + if !working_dir.exists() { + tracing::error!(working_dir = %working_dir.display(), "Working directory does not exist!"); + return Err(ClaudeProcessError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Working directory does not exist: {}", working_dir.display()), + ))); + } + + // Build environment + let mut env = self.default_env.clone(); + if let Some(extra) = extra_env { + env.extend(extra); + } + + // Build arguments list + let mut args = Vec::new(); + + // Pre-args (before defaults) + args.extend(self.claude_pre_args.clone()); + + // Required arguments for stream-json protocol + args.push("--output-format=stream-json".to_string()); + args.push("--input-format=stream-json".to_string()); + + // The key flag for continuation mode + args.push("--continue".to_string()); + + // Optional default arguments + if !self.disable_verbose { + args.push("--verbose".to_string()); + } + if !self.enable_permissions { + args.push("--dangerously-skip-permissions".to_string()); + } + + // System prompt - passed via --system-prompt flag for system-level constraints + if let Some(prompt) = system_prompt { + args.push("--system-prompt".to_string()); + args.push(prompt.to_string()); + } + + // Additional user-configured arguments + args.extend(self.claude_args.clone()); + + tracing::debug!(args = ?args, "Claude continue command arguments"); + + // Spawn the process + let mut child = Command::new(&self.claude_command) + .args(&args) + .current_dir(working_dir) + .envs(env) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ClaudeProcessError::CommandNotFound(self.claude_command.clone()) + } else { + ClaudeProcessError::SpawnFailed(e) + } + })?; + + // Create output channel + let (tx, rx) = mpsc::channel(1000); + + // Take stdout, stderr, and stdin + let stdin = child.stdin.take(); + let stdin = Arc::new(Mutex::new(stdin)); + + let stdout = child.stdout.take().expect("stdout should be piped"); + let stderr = child.stderr.take().expect("stderr should be piped"); + + // Spawn task to read stdout + let tx_stdout = tx.clone(); + tokio::spawn(async move { + use tokio::io::AsyncReadExt; + let mut reader = BufReader::new(stdout); + let mut buffer = vec![0u8; 4096]; + let mut line_buffer = String::new(); + + loop { + match tokio::time::timeout( + tokio::time::Duration::from_secs(5), + reader.read(&mut buffer) + ).await { + Ok(Ok(0)) => { + tracing::debug!("Claude stdout EOF (continue mode)"); + if !line_buffer.is_empty() { + let _ = tx_stdout.send(OutputLine::stdout(line_buffer)).await; + } + break; + } + Ok(Ok(n)) => { + let chunk = String::from_utf8_lossy(&buffer[..n]); + line_buffer.push_str(&chunk); + while let Some(newline_pos) = line_buffer.find('\n') { + let line = line_buffer[..newline_pos].to_string(); + line_buffer = line_buffer[newline_pos + 1..].to_string(); + if tx_stdout.send(OutputLine::stdout(line)).await.is_err() { + return; + } + } + } + Ok(Err(e)) => { + tracing::error!(error = %e, "Error reading Claude stdout (continue mode)"); + break; + } + Err(_) => { + tracing::warn!("No stdout data from Claude for 5 seconds (continue mode)"); + } + } + } + tracing::debug!("Claude stdout reader task ended (continue mode)"); + }); + + // Spawn task to read stderr + let tx_stderr = tx; + tokio::spawn(async move { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::debug!(line = %line, "Claude stderr (continue mode)"); + if tx_stderr.send(OutputLine::stderr(line)).await.is_err() { + break; + } + } + tracing::debug!("Claude stderr reader task ended (continue mode)"); + }); + + tracing::info!("Claude Code process spawned successfully in continue mode"); + + let process = ClaudeProcess { + child, + output_rx: rx, + stdin, + }; + + // Send the continuation prompt as a user message + tracing::info!(prompt_len = continuation_prompt.len(), "Sending continuation prompt to Claude via stdin"); + process.send_user_message(continuation_prompt).await?; + + Ok(process) + } + /// Check if the claude command is available. pub async fn check_claude_available(&self) -> Result { let output = Command::new(&self.claude_command) -- cgit v1.2.3