diff options
Diffstat (limited to 'makima/src/daemon/process/claude.rs')
| -rw-r--r-- | makima/src/daemon/process/claude.rs | 373 |
1 files changed, 358 insertions, 15 deletions
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<String, String>, + /// Bubblewrap sandbox configuration. + bubblewrap: Option<BubblewrapConfig>, } 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<BubblewrapConfig>) -> 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<String, ClaudeProcessError> { + 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<String>) { + 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<HashMap<String, String>>, system_prompt: Option<&str>, ) -> Result<ClaudeProcess, ClaudeProcessError> { + // 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<HashMap<String, String>>, + system_prompt: Option<&str>, + ) -> Result<ClaudeProcess, ClaudeProcessError> { + 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<String, ClaudeProcessError> { let output = Command::new(&self.claude_command) |
