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 { find_codex_binary_on_path() .filter(is_usable_codex_binary) .or_else(|| find_codex_binary_in_user_shell().filter(is_usable_codex_binary)) .or_else(|| find_usable_codex_binary(codex_binary_candidates())) .ok_or_else(|| { "Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into() }) } fn find_codex_binary_on_path() -> Option { crate::hidden_command(codex_path_lookup_command()) .arg("codex") .output() .ok() .and_then(|output| path_from_successful_output(&output)) } fn codex_path_lookup_command() -> &'static str { if cfg!(windows) { "where" } else { "which" } } 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(".local/bin/codex.exe"), home.join(".codex/bin/codex"), home.join(".codex/bin/codex.exe"), home.join(".local/share/mise/shims/codex"), home.join(".local/share/mise/shims/codex.exe"), home.join(".asdf/shims/codex"), home.join(".asdf/shims/codex.exe"), home.join(".npm-global/bin/codex"), home.join(".npm-global/bin/codex.cmd"), home.join(".npm-global/bin/codex.exe"), home.join(".npm/bin/codex"), home.join(".npm/bin/codex.cmd"), home.join(".npm/bin/codex.exe"), home.join(".bun/bin/codex"), home.join(".bun/bin/codex.exe"), home.join(".linuxbrew/bin/codex"), home.join("AppData/Roaming/npm/codex.cmd"), home.join("AppData/Roaming/npm/codex.exe"), home.join("AppData/Local/pnpm/codex.cmd"), home.join("AppData/Local/pnpm/codex.exe"), home.join("scoop/shims/codex.exe"), 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_usable_codex_binary(candidates: Vec) -> Option { candidates.into_iter().find(is_usable_codex_binary) } fn is_usable_codex_binary(binary: &PathBuf) -> bool { crate::cli_agent_runtime::version_for_binary(binary).is_some() } fn run_agent_stream_with_binary( binary: &Path, request: AgentStreamRequest, emit: F, ) -> Result where F: FnMut(AiAgentStreamEvent), { let last_message_dir = tempfile::Builder::new() .prefix("tolaria-codex-last-message-") .tempdir() .map_err(|error| format!("Failed to create Codex output directory: {error}"))?; let last_message_path = last_message_dir.path().join("last-message.txt"); let args = build_codex_args(&request, Some(&last_message_path))?; let prompt = build_codex_prompt(&request); let command = build_codex_command(binary, args, prompt, &request.vault_path); let emit = with_codex_last_message_fallback(emit, last_message_path); crate::cli_agent_runtime::run_ai_agent_json_stream( command, "codex", emit, codex_session_id, dispatch_codex_event, format_codex_error, ) } fn with_codex_last_message_fallback( mut emit: F, last_message_path: PathBuf, ) -> impl FnMut(AiAgentStreamEvent) where F: FnMut(AiAgentStreamEvent), { let mut text_emitted = false; move |event| { match &event { AiAgentStreamEvent::TextDelta { text } if !text.trim().is_empty() => { text_emitted = true; } AiAgentStreamEvent::Done if !text_emitted => { if let Some(text) = read_codex_last_message(&last_message_path) { text_emitted = true; emit(AiAgentStreamEvent::TextDelta { text }); } } _ => {} } emit(event); } } fn build_codex_command( binary: &Path, args: Vec, prompt: String, vault_path: &str, ) -> std::process::Command { let mut command = crate::hidden_command(binary); crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary); command .args(args) .arg(prompt) .current_dir(vault_path) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); command } fn build_codex_args( request: &AgentStreamRequest, last_message_path: Option<&Path>, ) -> Result, String> { let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; let node_path = crate::mcp::find_node()?; let mut args = vec![ "--sandbox".into(), codex_sandbox(request.permission_mode).into(), "--ask-for-approval".into(), codex_approval_policy(request.permission_mode).into(), "exec".into(), "--json".into(), "-C".into(), request.vault_path.clone(), "-c".into(), codex_config_string("mcp_servers.tolaria.command", &node_path.to_string_lossy()), "-c".into(), codex_config_string_list("mcp_servers.tolaria.args", &[mcp_server_path.as_str()]), "-c".into(), codex_mcp_env_config(&request.vault_path), ]; if let Some(path) = last_message_path { args.push("--output-last-message".into()); args.push(path.to_string_lossy().into_owned()); } Ok(args) } fn codex_config_string(key: &str, value: &str) -> String { format!(r#"{key}="{}""#, toml_escape(value)) } fn codex_config_string_list(key: &str, values: &[&str]) -> String { let values = values .iter() .map(|value| format!(r#""{}""#, toml_escape(value))) .collect::>() .join(","); format!("{key}=[{values}]") } fn codex_mcp_env_config(vault_path: &str) -> String { format!( r#"mcp_servers.tolaria.env={{VAULT_PATH="{}",WS_UI_PORT="9711"}}"#, toml_escape(vault_path) ) } fn toml_escape(value: &str) -> String { value.replace('\\', r#"\\"#).replace('"', r#"\""#) } fn codex_sandbox(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str { match permission_mode { crate::ai_agents::AiAgentPermissionMode::Safe => "read-only", crate::ai_agents::AiAgentPermissionMode::PowerUser => "workspace-write", } } fn codex_approval_policy(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str { match permission_mode { crate::ai_agents::AiAgentPermissionMode::Safe => "untrusted", crate::ai_agents::AiAgentPermissionMode::PowerUser => "never", } } 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()), }); } } "mcp_tool_call" => emit_codex_mcp_tool_event(item, item_id, completed, emit), "agent_message" if completed => { if let Some(text) = item["text"].as_str() { emit(AiAgentStreamEvent::TextDelta { text: text.to_string(), }); } } _ => {} } } fn emit_codex_mcp_tool_event( item: &serde_json::Value, item_id: &str, completed: bool, emit: &mut F, ) where F: FnMut(AiAgentStreamEvent), { if completed { emit(AiAgentStreamEvent::ToolDone { tool_id: item_id.to_string(), output: codex_tool_output(item), }); return; } let tool_name = item["tool"].as_str().unwrap_or("MCP tool"); let input = json_field_to_string(&item["arguments"]); emit(AiAgentStreamEvent::ToolStart { tool_name: tool_name.to_string(), tool_id: item_id.to_string(), input, }); } fn codex_tool_output(item: &serde_json::Value) -> Option { item["error"]["message"] .as_str() .map(|message| format!("Error: {message}")) .or_else(|| json_field_to_string(&item["result"])) } fn json_field_to_string(value: &serde_json::Value) -> Option { if value.is_null() { None } else { value .as_str() .map(str::to_string) .or_else(|| Some(value.to_string())) } } fn read_codex_last_message(path: &Path) -> Option { std::fs::read_to_string(path) .ok() .map(|text| text.trim().to_string()) .filter(|text| !text.is_empty()) } 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. Vault Safe uses a read-only Codex sandbox; switch to Power User for shell-backed local writes, or 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_permission_contract(args: &[String], permission_mode: AiAgentPermissionMode) { let sandbox = codex_sandbox(permission_mode); let approval = codex_approval_policy(permission_mode); let prefix = ["--sandbox", sandbox, "--ask-for-approval", approval]; 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, }, None, ) { assert_eq!(args[4], "exec"); assert_codex_permission_contract(&args, AiAgentPermissionMode::Safe); assert!(args.contains(&"--json".to_string())); assert!(args.contains(&"-C".to_string())); } } #[test] fn codex_power_user_keeps_workspace_write_without_dangerous_bypass() { if let Ok(args) = build_codex_args( &AgentStreamRequest { message: "Rename the note".into(), system_prompt: None, vault_path: "/tmp/vault".into(), permission_mode: AiAgentPermissionMode::PowerUser, }, None, ) { assert_codex_permission_contract(&args, AiAgentPermissionMode::PowerUser); } } #[test] fn build_codex_args_can_request_last_message_output_file() { 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, }, Some(Path::new("/tmp/tolaria-codex-last-message.txt")), ) { assert!(args.windows(2).any(|window| window == [ "--output-last-message", "/tmp/tolaria-codex-last-message.txt", ])); } } #[test] fn build_codex_args_uses_resolved_mcp_node_and_ui_bridge_env() { let args = build_codex_args( &AgentStreamRequest { message: "Read [[Test note]]".into(), system_prompt: None, vault_path: "/tmp/vault".into(), permission_mode: AiAgentPermissionMode::Safe, }, None, ) .unwrap(); let command_override = args .iter() .find(|arg| arg.starts_with("mcp_servers.tolaria.command=")) .expect("Codex should receive a transient Tolaria MCP command"); assert!( !command_override.ends_with(r#""node""#), "Codex MCP command should use Tolaria's resolved Node path, got {command_override}" ); assert!( command_override.contains('/'), "Codex MCP command should be an absolute Node path, got {command_override}" ); assert!(args.iter().any(|arg| arg.contains(r#"WS_UI_PORT="9711""#))); } #[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"))); } #[test] fn build_codex_command_extends_path_with_resolved_homebrew_bin() { let binary = PathBuf::from("/opt/homebrew/bin/codex"); let command = build_codex_command( &binary, vec!["exec".to_string(), "--json".to_string()], "Summarize".into(), "/tmp/vault", ); let path_value = command .get_envs() .find(|(key, _)| *key == OsStr::new("PATH")) .and_then(|(_, value)| value) .expect("PATH should be set"); let paths = std::env::split_paths(path_value).collect::>(); assert!( paths.contains(&PathBuf::from("/opt/homebrew/bin")), "PATH should include the resolved Codex binary directory, got {paths:?}" ); } #[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_uses_last_message_file_when_stream_has_no_text() { let (thread_id, events) = run_codex_script( r#"last_message="" while [ "$#" -gt 0 ]; do if [ "$1" = "--output-last-message" ]; then shift last_message="$1" fi shift done printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' printf '%s' 'Recovered final answer' > "$last_message" "#, ); assert_eq!(thread_id, "thread_1"); assert_codex_text_flow(&events, "thread_1", "Recovered final answer"); } #[cfg(unix)] #[test] fn run_codex_agent_stream_does_not_duplicate_last_message_file_after_text_event() { let (thread_id, events) = run_codex_script( r#"last_message="" while [ "$#" -gt 0 ]; do if [ "$1" = "--output-last-message" ]; then shift last_message="$1" fi shift done printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Streamed answer"}}' printf '%s' 'Recovered final answer' > "$last_message" "#, ); let text_events = events .iter() .filter(|event| matches!(event, AiAgentStreamEvent::TextDelta { .. })) .count(); assert_eq!(thread_id, "thread_1"); assert_eq!(text_events, 1); assert_codex_text_flow(&events, "thread_1", "Streamed answer"); } #[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))); } #[cfg(unix)] #[test] fn run_codex_agent_stream_closes_stdin_even_when_parent_stdin_pipe_is_open() { use std::io::Read; use std::time::{Duration, Instant}; let mut child = std::process::Command::new(std::env::current_exe().unwrap()) .arg("codex_stdin_probe_parent_child") .arg("--ignored") .arg("--nocapture") .env("TOLARIA_CODEX_STDIN_PROBE_PARENT_CHILD", "1") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let child_stdin = child.stdin.take().unwrap(); let mut stdout = child.stdout.take().unwrap(); let mut stderr = child.stderr.take().unwrap(); let deadline = Instant::now() + Duration::from_secs(5); let status = loop { if let Some(status) = child.try_wait().unwrap() { break status; } if Instant::now() >= deadline { child.kill().unwrap(); drop(child_stdin); panic!("Codex stdin probe child timed out"); } std::thread::sleep(Duration::from_millis(10)); }; drop(child_stdin); let mut stdout_text = String::new(); let mut stderr_text = String::new(); stdout.read_to_string(&mut stdout_text).unwrap(); stderr.read_to_string(&mut stderr_text).unwrap(); assert!( status.success(), "Codex stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}" ); } #[cfg(unix)] #[ignore = "spawned by run_codex_agent_stream_closes_stdin_even_when_parent_stdin_pipe_is_open"] #[test] fn codex_stdin_probe_parent_child() { if std::env::var_os("TOLARIA_CODEX_STDIN_PROBE_PARENT_CHILD").is_none() { return; } let dir = tempfile::tempdir().unwrap(); let vault = tempfile::tempdir().unwrap(); let binary = executable_script( dir.path(), "codex", r#"stdin="$(cat)" if [ -n "$stdin" ]; then echo "stdin was not closed" >&2 exit 9 fi printf '%s\n' '{"type":"thread.started","thread_id":"stdin-ok"}' printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"stdin closed"}}' "#, ); let mut events = Vec::new(); let result = run_agent_stream_with_binary( &binary, codex_request(vault.path(), AiAgentPermissionMode::Safe), |event| events.push(event), ); assert_eq!(result.unwrap(), "stdin-ok"); assert!(events.iter().any(|event| matches!( event, AiAgentStreamEvent::TextDelta { text } if text == "stdin closed" ))); 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_windows_npm_and_toolchain_shims() { let home = PathBuf::from("C:/Users/alex"); let candidates = codex_binary_candidates_for_home(&home); let expected = [ home.join(".local/bin/codex.exe"), home.join(".local/share/mise/shims/codex.exe"), home.join(".asdf/shims/codex.exe"), home.join(".npm-global/bin/codex.cmd"), home.join(".npm-global/bin/codex.exe"), home.join(".npm/bin/codex.cmd"), home.join(".npm/bin/codex.exe"), home.join("AppData/Roaming/npm/codex.cmd"), home.join("AppData/Roaming/npm/codex.exe"), home.join("AppData/Local/pnpm/codex.cmd"), home.join("AppData/Local/pnpm/codex.exe"), home.join("scoop/shims/codex.exe"), ]; 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()); } #[cfg(unix)] #[test] fn usable_codex_binary_skips_broken_shims() { let dir = tempfile::tempdir().unwrap(); let broken = executable_script(dir.path(), "broken-codex", "exit 1\n"); let working = executable_script(dir.path(), "codex", "echo codex-cli 0.124.0-alpha.2\n"); let found = find_usable_codex_binary(vec![broken, working.clone()]); assert_eq!(found, Some(working)); } #[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_mcp_tool_call_maps_to_tool_events() { let mut events = Vec::new(); let started = serde_json::json!({ "type": "item.started", "item": { "id": "item_1", "type": "mcp_tool_call", "server": "tolaria", "tool": "search_notes", "arguments": { "query": "meeting", "limit": 5 }, "status": "in_progress" } }); let completed = serde_json::json!({ "type": "item.completed", "item": { "id": "item_1", "type": "mcp_tool_call", "server": "tolaria", "tool": "search_notes", "arguments": { "query": "meeting", "limit": 5 }, "result": [{ "title": "Meeting notes" }], "status": "completed" } }); 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, input } if tool_name == "search_notes" && tool_id == "item_1" && input.as_deref().is_some_and(|value| value.contains("meeting")) )); assert!(matches!( &events[1], AiAgentStreamEvent::ToolDone { tool_id, output } if tool_id == "item_1" && output.as_deref().is_some_and(|value| value.contains("Meeting notes")) )); } #[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")); } }