use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; pub use crate::cli_agent_runtime::AgentStreamRequest; use std::path::{Path, PathBuf}; use std::process::Stdio; pub fn check_cli() -> AiAgentAvailability { let binary = match find_codex_binary() { Ok(binary) => binary, Err(_) => { return AiAgentAvailability { installed: false, version: None, } } }; AiAgentAvailability { installed: true, version: crate::cli_agent_runtime::version_for_binary(&binary), } } pub fn run_agent_stream(request: AgentStreamRequest, emit: F) -> Result where F: FnMut(AiAgentStreamEvent), { let binary = find_codex_binary()?; run_agent_stream_with_binary(&binary, request, emit) } fn find_codex_binary() -> Result { if let Some(binary) = find_codex_binary_on_path() { return Ok(binary); } if let Some(binary) = find_codex_binary_in_user_shell() { return Ok(binary); } if let Some(binary) = find_existing_binary(codex_binary_candidates())? { return Ok(binary); } Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()) } fn find_codex_binary_on_path() -> Option { crate::hidden_command("which") .arg("codex") .output() .ok() .and_then(|output| path_from_successful_output(&output)) } fn find_codex_binary_in_user_shell() -> Option { user_shell_candidates() .into_iter() .filter(|shell| shell.exists()) .find_map(|shell| command_path_from_shell(&shell, "codex")) } fn user_shell_candidates() -> Vec { let mut shells = Vec::new(); if let Some(shell) = std::env::var_os("SHELL") { if !shell.is_empty() { shells.push(PathBuf::from(shell)); } } shells.push(PathBuf::from("/bin/zsh")); shells.push(PathBuf::from("/bin/bash")); shells } fn command_path_from_shell(shell: &Path, command: &str) -> Option { crate::hidden_command(shell) .arg("-lc") .arg(format!("command -v {command}")) .output() .ok() .and_then(|output| path_from_successful_output(&output)) } fn path_from_successful_output(output: &std::process::Output) -> Option { if output.status.success() { first_existing_path(&String::from_utf8_lossy(&output.stdout)) } else { None } } fn first_existing_path(stdout: &str) -> Option { stdout.lines().find_map(|line| { let trimmed = line.trim(); if trimmed.is_empty() { return None; } let candidate = PathBuf::from(trimmed); candidate.exists().then_some(candidate) }) } fn codex_binary_candidates() -> Vec { dirs::home_dir() .map(|home| codex_binary_candidates_for_home(&home)) .unwrap_or_default() } fn codex_binary_candidates_for_home(home: &Path) -> Vec { let mut candidates = vec![ home.join(".local/bin/codex"), home.join(".codex/bin/codex"), home.join(".local/share/mise/shims/codex"), home.join(".asdf/shims/codex"), home.join(".npm-global/bin/codex"), home.join(".npm/bin/codex"), home.join(".bun/bin/codex"), home.join(".linuxbrew/bin/codex"), PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"), PathBuf::from("/usr/local/bin/codex"), PathBuf::from("/opt/homebrew/bin/codex"), PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"), ]; candidates.extend(nvm_node_binary_candidates_for_home(home, "codex")); candidates } fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec { let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else { return Vec::new(); }; let mut candidates = entries .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| path.is_dir()) .map(|path| path.join("bin").join(binary_name)) .collect::>(); candidates.sort(); candidates } fn find_existing_binary(candidates: Vec) -> Result, String> { crate::cli_agent_runtime::find_executable_binary_candidate(candidates, "Codex CLI") } fn run_agent_stream_with_binary( binary: &Path, request: AgentStreamRequest, emit: F, ) -> Result where F: FnMut(AiAgentStreamEvent), { let args = build_codex_args(&request)?; let prompt = build_codex_prompt(&request); let command = build_codex_command(binary, args, prompt, &request.vault_path); crate::cli_agent_runtime::run_ai_agent_json_stream( command, "codex", emit, codex_session_id, dispatch_codex_event, format_codex_error, ) } fn build_codex_command( binary: &Path, args: Vec, prompt: String, vault_path: &str, ) -> std::process::Command { let mut command = crate::hidden_command(binary); command .args(args) .arg(prompt) .current_dir(vault_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()); command } fn build_codex_args(request: &AgentStreamRequest) -> Result, String> { let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; Ok(vec![ "--sandbox".into(), "workspace-write".into(), "--ask-for-approval".into(), "never".into(), "exec".into(), "--json".into(), "-C".into(), request.vault_path.clone(), "-c".into(), r#"mcp_servers.tolaria.command="node""#.into(), "-c".into(), format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path), "-c".into(), format!( r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#, request.vault_path ), ]) } fn build_codex_prompt(request: &AgentStreamRequest) -> String { crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref()) } fn codex_session_id(json: &serde_json::Value) -> Option<&str> { json["thread_id"].as_str() } fn dispatch_codex_event(json: &serde_json::Value, emit: &mut F) where F: FnMut(AiAgentStreamEvent), { match json["type"].as_str().unwrap_or_default() { "thread.started" => { if let Some(thread_id) = json["thread_id"].as_str() { emit(AiAgentStreamEvent::Init { session_id: thread_id.to_string(), }); } } "item.started" => emit_codex_item_event(json, false, emit), "item.completed" => emit_codex_item_event(json, true, emit), _ => {} } } fn emit_codex_item_event(json: &serde_json::Value, completed: bool, emit: &mut F) where F: FnMut(AiAgentStreamEvent), { let item = &json["item"]; let item_type = item["type"].as_str().unwrap_or_default(); let item_id = item["id"].as_str().unwrap_or_default(); match item_type { "command_execution" => { if completed { emit(AiAgentStreamEvent::ToolDone { tool_id: item_id.to_string(), output: item["aggregated_output"] .as_str() .map(|output| output.to_string()), }); } else { emit(AiAgentStreamEvent::ToolStart { tool_name: "Bash".into(), tool_id: item_id.to_string(), input: item["command"] .as_str() .map(|command| serde_json::json!({ "command": command }).to_string()), }); } } "agent_message" if completed => { if let Some(text) = item["text"].as_str() { emit(AiAgentStreamEvent::TextDelta { text: text.to_string(), }); } } _ => {} } } fn format_codex_error(stderr_output: String, status: String) -> String { let lower = stderr_output.to_ascii_lowercase(); if is_codex_auth_error(&lower) { return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into(); } if is_codex_write_permission_error(&lower) { return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into(); } if stderr_output.trim().is_empty() { format!("codex exited with status {status}") } else { stderr_output.lines().take(3).collect::>().join("\n") } } fn is_codex_auth_error(lower: &str) -> bool { ["auth", "login", "sign in"] .iter() .any(|pattern| lower.contains(pattern)) } fn is_codex_write_permission_error(lower: &str) -> bool { [ "read-only sandbox", "writing is blocked", "rejected by user approval", "rejected by the environment", ] .iter() .any(|pattern| lower.contains(pattern)) } #[cfg(test)] mod tests { use super::*; use crate::ai_agents::AiAgentPermissionMode; use std::ffi::OsStr; #[cfg(unix)] fn executable_script(dir: &Path, name: &str, body: &str) -> PathBuf { use std::os::unix::fs::PermissionsExt; let script = dir.join(name); std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); script } fn codex_request( vault_path: &Path, permission_mode: AiAgentPermissionMode, ) -> AgentStreamRequest { AgentStreamRequest { message: "Summarize".into(), system_prompt: None, vault_path: vault_path.to_string_lossy().into_owned(), permission_mode, } } fn assert_codex_workspace_write_contract(args: &[String]) { let prefix = [ "--sandbox", "workspace-write", "--ask-for-approval", "never", ]; assert_eq!(&args[..prefix.len()], prefix); assert!(!args.iter().any(|arg| arg == "danger-full-access")); assert!(!args .iter() .any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox")); } #[cfg(unix)] fn run_codex_script(body: &str) -> (String, Vec) { let dir = tempfile::tempdir().unwrap(); let vault = tempfile::tempdir().unwrap(); let binary = executable_script(dir.path(), "codex", body); let mut events = Vec::new(); let thread_id = run_agent_stream_with_binary( &binary, codex_request(vault.path(), AiAgentPermissionMode::Safe), |event| events.push(event), ) .unwrap(); (thread_id, events) } fn assert_codex_text_flow(events: &[AiAgentStreamEvent], session: &str, text_delta: &str) { assert!(matches!( &events[0], AiAgentStreamEvent::Init { session_id } if session_id == session )); assert!(matches!( &events[1], AiAgentStreamEvent::TextDelta { text } if text == text_delta )); assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); } #[test] fn build_codex_prompt_keeps_system_prompt_first() { let prompt = build_codex_prompt(&AgentStreamRequest { message: "Rename the note".into(), system_prompt: Some("Be concise".into()), vault_path: "/tmp/vault".into(), permission_mode: AiAgentPermissionMode::Safe, }); assert!(prompt.starts_with("System instructions:\nBe concise")); assert!(prompt.contains("User request:\nRename the note")); } #[test] fn build_codex_args_uses_safe_default_permissions() { if let Ok(args) = build_codex_args(&AgentStreamRequest { message: "Rename the note".into(), system_prompt: None, vault_path: "/tmp/vault".into(), permission_mode: AiAgentPermissionMode::Safe, }) { assert_eq!(args[4], "exec"); assert_codex_workspace_write_contract(&args); assert!(args.contains(&"--json".to_string())); assert!(args.contains(&"-C".to_string())); } } #[test] fn codex_permission_modes_keep_workspace_write_without_dangerous_bypass() { for permission_mode in [ AiAgentPermissionMode::Safe, AiAgentPermissionMode::PowerUser, ] { if let Ok(args) = build_codex_args(&AgentStreamRequest { message: "Rename the note".into(), system_prompt: None, vault_path: "/tmp/vault".into(), permission_mode, }) { assert_codex_workspace_write_contract(&args); } } } #[test] fn build_codex_command_keeps_agent_process_contract() { let binary = PathBuf::from("codex"); let args = vec!["exec".to_string(), "--json".to_string()]; let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault"); let actual_args: Vec<&OsStr> = command.get_args().collect(); assert_eq!(command.get_program(), OsStr::new("codex")); assert_eq!( actual_args, vec![ OsStr::new("exec"), OsStr::new("--json"), OsStr::new("Summarize") ] ); assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); } #[cfg(unix)] #[test] fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() { let (thread_id, events) = run_codex_script( r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Done"}}' "#, ); assert_eq!(thread_id, "thread_1"); assert_codex_text_flow(&events, "thread_1", "Done"); } #[cfg(unix)] #[test] fn run_codex_agent_stream_reports_nonzero_exit_errors() { let (thread_id, events) = run_codex_script( r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' printf '%s\n' 'login required' >&2 exit 2 "#, ); assert_eq!(thread_id, "thread_1"); assert!(events.iter().any(|event| matches!( event, AiAgentStreamEvent::Error { message } if message.contains("not authenticated") ))); assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); } #[test] fn codex_binary_candidates_include_supported_macos_installs() { let home = PathBuf::from("/Users/alex"); let candidates = codex_binary_candidates_for_home(&home); let expected = [ home.join(".local/bin/codex"), home.join(".codex/bin/codex"), home.join(".local/share/mise/shims/codex"), home.join(".asdf/shims/codex"), home.join(".npm-global/bin/codex"), home.join(".bun/bin/codex"), PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"), ]; for candidate in expected { assert!( candidates.contains(&candidate), "missing {}", candidate.display() ); } } #[test] fn codex_binary_candidates_include_linuxbrew_installs() { let home = PathBuf::from("/home/alex"); let candidates = codex_binary_candidates_for_home(&home); let expected = [ home.join(".linuxbrew/bin/codex"), PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"), ]; for candidate in expected { assert!( candidates.contains(&candidate), "missing {}", candidate.display() ); } } #[test] fn codex_binary_candidates_include_nvm_managed_node_installs() { let home = tempfile::tempdir().unwrap(); let codex = home.path().join(".nvm/versions/node/v22.12.0/bin/codex"); std::fs::create_dir_all(codex.parent().unwrap()).unwrap(); std::fs::write(&codex, "#!/bin/sh\n").unwrap(); let candidates = codex_binary_candidates_for_home(home.path()); assert!(candidates.contains(&codex), "missing {}", codex.display()); } #[test] fn first_existing_path_skips_empty_and_missing_lines() { let dir = tempfile::tempdir().unwrap(); let missing = dir.path().join("missing-codex"); let codex = dir.path().join("codex"); std::fs::write(&codex, "#!/bin/sh\n").unwrap(); let stdout = format!("\n{}\n{}\n", missing.display(), codex.display()); assert_eq!(first_existing_path(&stdout), Some(codex)); } #[cfg(unix)] #[test] fn command_path_from_shell_finds_codex_from_login_shell() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let codex = dir.path().join("codex"); std::fs::write(&codex, "#!/bin/sh\n").unwrap(); std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap(); let shell = dir.path().join("shell"); std::fs::write( &shell, format!( "#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n", codex.display() ), ) .unwrap(); std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex)); } #[test] fn dispatch_codex_command_events_maps_to_bash_events() { let mut events = Vec::new(); let started = serde_json::json!({ "type": "item.started", "item": { "id": "item_1", "type": "command_execution", "command": "/bin/zsh -lc pwd" } }); let completed = serde_json::json!({ "type": "item.completed", "item": { "id": "item_1", "type": "command_execution", "aggregated_output": "/private/tmp\n" } }); dispatch_codex_event(&started, &mut |event| events.push(event)); dispatch_codex_event(&completed, &mut |event| events.push(event)); assert!(matches!( &events[0], AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "Bash" && tool_id == "item_1" )); assert!(matches!( &events[1], AiAgentStreamEvent::ToolDone { tool_id, output } if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n") )); } #[test] fn dispatch_codex_agent_message_maps_to_text_delta() { let mut events = Vec::new(); let completed = serde_json::json!({ "type": "item.completed", "item": { "id": "item_2", "type": "agent_message", "text": "All set" } }); dispatch_codex_event(&completed, &mut |event| events.push(event)); assert!(matches!( &events[0], AiAgentStreamEvent::TextDelta { text } if text == "All set" )); } #[test] fn format_codex_error_explains_vault_write_permission_failures() { let message = format_codex_error( "The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(), "exit status: 1".into(), ); assert!(message.contains("active vault")); assert!(message.contains("writable")); assert!(message.contains("outside")); } }