summaryrefslogtreecommitdiff
path: root/makima/src/daemon/process/claude.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon/process/claude.rs')
-rw-r--r--makima/src/daemon/process/claude.rs373
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)