Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 156 additions & 10 deletions src-tauri/src/claude_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:*)",
Expand Down Expand Up @@ -272,13 +275,79 @@ fn claude_project_dir() -> Option<std::path::PathBuf> {
.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::<Value>(&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
// keeps subscription auth while excluding user hooks, plugins, MCPs and
// 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")
Expand All @@ -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 {
Expand Down Expand Up @@ -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(_)) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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");
Expand All @@ -1355,6 +1499,8 @@ mod tests {
[
"-p",
"--safe-mode",
"--permission-mode",
"acceptEdits",
"--no-session-persistence",
"--prompt-suggestions",
"false",
Expand Down
84 changes: 83 additions & 1 deletion src-tauri/src/cli_locator.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<OsString> {
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<OsString> {
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<Item = PathBuf>) -> Option<OsString> {
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) {
Expand Down Expand Up @@ -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<_>>(),
vec![cli_dir, PathBuf::from("/usr/bin")]
);
}
}