diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 4cf6e26..cae8366 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -225,6 +225,9 @@ pub struct ClaudePrintResult { /// and passes it through `--settings`. This is intentionally narrow: it /// covers the repo's quality gate, not arbitrary commands. const INJECTED_CLAUDE_ALLOW: &[&str] = &[ + "WebSearch", + "WebFetch", + "Bash(curl:*)", "Bash(bun run lint)", "Bash(bun run test)", "Bash(bun run test:*)", @@ -272,6 +275,68 @@ fn claude_project_dir() -> Option { .then_some(cwd) } +/// Mark a LoomRouter-selected workspace as trusted before a non-interactive +/// Claude turn starts. Print mode cannot answer Claude Code's trust dialog, +/// so leaving this false makes the CLI silently ignore project permissions. +fn trust_claude_project(project_dir: &std::path::Path) -> anyhow::Result<()> { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?; + trust_claude_project_in(&home.join(".claude.json"), project_dir) +} + +fn trust_claude_project_in( + config_path: &std::path::Path, + project_dir: &std::path::Path, +) -> anyhow::Result<()> { + static CONFIG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut config = if config_path.is_file() { + serde_json::from_slice::(&std::fs::read(config_path)?).map_err(|e| { + anyhow::anyhow!( + "could not parse Claude config {}: {e}", + config_path.display() + ) + })? + } else { + json!({}) + }; + let root = config + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("Claude config root must be a JSON object"))?; + let projects = root + .entry("projects") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("Claude config projects must be a JSON object"))?; + let project_key = project_dir + .canonicalize() + .unwrap_or_else(|_| project_dir.to_path_buf()) + .to_string_lossy() + .into_owned(); + let project = projects.entry(project_key).or_insert_with(|| json!({})); + let project = project + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("Claude project config must be a JSON object"))?; + if project + .get("hasTrustDialogAccepted") + .and_then(Value::as_bool) + == Some(true) + { + return Ok(()); + } + project.insert("hasTrustDialogAccepted".to_string(), Value::Bool(true)); + let encoded = serde_json::to_vec_pretty(&config)?; + crate::secure_fs::write_private_with_backup(config_path, &encoded)?; + Ok(()) +} + +fn configure_claude_project(cmd: &mut std::process::Command) -> anyhow::Result<()> { + if let Some(dir) = claude_project_dir() { + trust_claude_project(&dir)?; + cmd.current_dir(dir); + } + Ok(()) +} + fn configure_print_command(cmd: &mut std::process::Command, model: &str) { // Proxy turns are stateless, so persisting them only pollutes Claude Code's // session history with entries grouped under the app process cwd. Safe mode @@ -279,6 +344,10 @@ fn configure_print_command(cmd: &mut std::process::Command, model: &str) { // memory that do not belong in a routed model call. cmd.arg("-p") .arg("--safe-mode") + // Print mode has no interactive permission prompt. Accept workspace + // edits, while commands and network access remain explicitly allowlisted. + .arg("--permission-mode") + .arg("acceptEdits") .arg("--no-session-persistence") .arg("--prompt-suggestions") .arg("false") @@ -287,6 +356,12 @@ fn configure_print_command(cmd: &mut std::process::Command, model: &str) { .env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1"); } +fn configure_child_environment(cmd: &mut std::process::Command, bin: &std::path::Path) { + if let Some(path) = crate::cli_locator::child_path(bin) { + cmd.env("PATH", path); + } +} + /// One turn's input to the CLI: a flat prompt, or Claude Code's stream-json /// message protocol when the turn carries images. pub enum ClaudeTurnInput { @@ -342,9 +417,8 @@ pub fn stream_print_turn( let mut cmd = std::process::Command::new(&bin); crate::cli_locator::hide_console_window(&mut cmd); - if let Some(dir) = claude_project_dir() { - cmd.current_dir(dir); - } + configure_child_environment(&mut cmd, &bin); + configure_claude_project(&mut cmd)?; configure_print_command(&mut cmd, &model); cmd.arg("--settings").arg(&injected); if matches!(input, ClaudeTurnInput::StreamJson(_)) { @@ -659,9 +733,8 @@ pub async fn run_print_turn( tokio::task::spawn_blocking(move || { let mut cmd = std::process::Command::new(&bin); crate::cli_locator::hide_console_window(&mut cmd); - if let Some(dir) = claude_project_dir() { - cmd.current_dir(dir); - } + configure_child_environment(&mut cmd, &bin); + configure_claude_project(&mut cmd)?; configure_print_command(&mut cmd, &model); cmd.arg("--settings") .arg(&injected) @@ -717,9 +790,8 @@ pub async fn run_print_turn_stream_json( tokio::task::spawn_blocking(move || { let mut cmd = std::process::Command::new(&bin); crate::cli_locator::hide_console_window(&mut cmd); - if let Some(dir) = claude_project_dir() { - cmd.current_dir(dir); - } + configure_child_environment(&mut cmd, &bin); + configure_claude_project(&mut cmd)?; configure_print_command(&mut cmd, &model); cmd.arg("--settings") .arg(&injected) @@ -1339,11 +1411,83 @@ mod tests { let settings: serde_json::Value = serde_json::from_str(&raw).unwrap(); let allow = settings["permissions"]["allow"].as_array().unwrap(); assert_eq!(allow.len(), INJECTED_CLAUDE_ALLOW.len()); - assert_eq!(allow[0], "Bash(bun run lint)"); + assert_eq!(allow[0], "WebSearch"); + assert!(allow.contains(&json!("WebFetch"))); + assert!(allow.contains(&json!("Bash(curl:*)"))); assert!(!raw.contains("Bash(*)")); std::fs::remove_file(path).unwrap(); } + #[test] + fn trusting_project_preserves_existing_claude_config() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".claude.json"); + let project_dir = dir.path().join("workspace"); + std::fs::create_dir(&project_dir).unwrap(); + let project_key = project_dir + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + std::fs::write( + &config_path, + serde_json::to_vec(&json!({ + "theme": "dark", + "projects": { + (project_key.clone()): { + "allowedTools": ["Read"], + "hasTrustDialogAccepted": false + } + } + })) + .unwrap(), + ) + .unwrap(); + + trust_claude_project_in(&config_path, &project_dir).unwrap(); + + let config: Value = serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); + assert_eq!(config["theme"], "dark"); + assert_eq!(config["projects"][&project_key]["allowedTools"][0], "Read"); + assert_eq!( + config["projects"][&project_key]["hasTrustDialogAccepted"], + true + ); + assert!(dir.path().join(".claude.json.bak").is_file()); + } + + #[test] + fn trusting_new_project_creates_projects_map() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".claude.json"); + let project_dir = dir.path().join("workspace"); + std::fs::create_dir(&project_dir).unwrap(); + + trust_claude_project_in(&config_path, &project_dir).unwrap(); + + let config: Value = serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); + let project_key = project_dir + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + assert_eq!( + config["projects"][&project_key]["hasTrustDialogAccepted"], + true + ); + } + + #[test] + fn invalid_claude_config_is_never_overwritten() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".claude.json"); + std::fs::write(&config_path, b"not-json").unwrap(); + + assert!(trust_claude_project_in(&config_path, dir.path()).is_err()); + assert_eq!(std::fs::read(&config_path).unwrap(), b"not-json"); + assert!(!dir.path().join(".claude.json.bak").exists()); + } + #[test] fn proxy_print_turns_do_not_persist_claude_sessions() { let mut command = std::process::Command::new("claude"); @@ -1355,6 +1499,8 @@ mod tests { [ "-p", "--safe-mode", + "--permission-mode", + "acceptEdits", "--no-session-persistence", "--prompt-suggestions", "false", diff --git a/src-tauri/src/cli_locator.rs b/src-tauri/src/cli_locator.rs index 2c6b925..3308c2c 100644 --- a/src-tauri/src/cli_locator.rs +++ b/src-tauri/src/cli_locator.rs @@ -1,4 +1,4 @@ -use std::ffi::OsStr; +use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; /// Walk `PATH` for a binary name, returning the first matching file. @@ -87,6 +87,75 @@ where validate(&path).then_some(path) } +/// Return the PATH configured by the user's Unix login shell. +/// +/// Finder and other GUI launchers inherit launchd's minimal PATH. The CLI may +/// still be found through a login-shell lookup, but its child tools need that +/// same PATH to find package-manager binaries such as bun. +#[cfg(unix)] +pub(crate) fn login_shell_path() -> Option { + use std::io::Read; + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + let mut child = std::process::Command::new(&shell) + .args(["-lic", "printf '%s\\n' \"$PATH\""]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + tracing::warn!("the login shell did not answer in time; skipping its PATH"); + return None; + } + Err(_) => return None, + } + } + + let mut output = String::new(); + child.stdout.take()?.read_to_string(&mut output).ok()?; + Some(OsString::from(last_non_empty_line(&output)?)) +} + +/// Build a subprocess PATH that includes the selected CLI's directory, the +/// user's Unix shell PATH, and the launcher PATH, without duplicate entries. +pub(crate) fn child_path(cli_bin: &Path) -> Option { + let mut entries = Vec::new(); + if let Some(parent) = cli_bin.parent() { + entries.push(parent.to_path_buf()); + } + #[cfg(unix)] + if let Some(login_path) = login_shell_path() { + entries.extend(std::env::split_paths(&login_path)); + } + if let Some(inherited) = std::env::var_os("PATH") { + entries.extend(std::env::split_paths(&inherited)); + } + join_unique_paths(entries) +} + +fn join_unique_paths(paths: impl IntoIterator) -> Option { + let mut unique = Vec::new(); + for path in paths { + if !path.as_os_str().is_empty() && !unique.contains(&path) { + unique.push(path); + } + } + (!unique.is_empty()) + .then(|| std::env::join_paths(unique).ok()) + .flatten() +} + // Windows subprocesses need CREATE_NO_WINDOW to avoid flashing a console. #[cfg(windows)] pub(crate) fn hide_console_window(command: &mut std::process::Command) { @@ -156,4 +225,17 @@ mod tests { ); assert_eq!(last_non_empty_line("\n \n"), None); } + + #[test] + fn child_path_keeps_the_cli_directory_and_deduplicates() { + let cli_dir = PathBuf::from("/opt/loom/bin"); + let joined = + join_unique_paths([cli_dir.clone(), PathBuf::from("/usr/bin"), cli_dir.clone()]) + .unwrap(); + + assert_eq!( + std::env::split_paths(&joined).collect::>(), + vec![cli_dir, PathBuf::from("/usr/bin")] + ); + } }