1172 lines
38 KiB
Rust
1172 lines
38 KiB
Rust
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<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
|
where
|
|
F: FnMut(AiAgentStreamEvent),
|
|
{
|
|
let binary = find_codex_binary()?;
|
|
run_agent_stream_with_binary(&binary, request, emit)
|
|
}
|
|
|
|
fn find_codex_binary() -> Result<PathBuf, String> {
|
|
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<PathBuf> {
|
|
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<PathBuf> {
|
|
user_shell_candidates()
|
|
.into_iter()
|
|
.filter(|shell| shell.exists())
|
|
.find_map(|shell| command_path_from_shell(&shell, "codex"))
|
|
}
|
|
|
|
fn user_shell_candidates() -> Vec<PathBuf> {
|
|
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<PathBuf> {
|
|
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<PathBuf> {
|
|
if output.status.success() {
|
|
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
|
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<PathBuf> {
|
|
dirs::home_dir()
|
|
.map(|home| codex_binary_candidates_for_home(&home))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
|
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<PathBuf> {
|
|
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::<Vec<_>>();
|
|
candidates.sort();
|
|
candidates
|
|
}
|
|
|
|
fn find_usable_codex_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
|
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<F>(
|
|
binary: &Path,
|
|
request: AgentStreamRequest,
|
|
emit: F,
|
|
) -> Result<String, String>
|
|
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<F>(
|
|
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<String>,
|
|
prompt: String,
|
|
vault_path: &str,
|
|
) -> Result<std::process::Command, String> {
|
|
let target = codex_command_target(binary)?;
|
|
let mut command = crate::hidden_command(&target.program);
|
|
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
|
|
if let Some(first_arg) = target.first_arg {
|
|
command.arg(first_arg);
|
|
}
|
|
command
|
|
.args(args)
|
|
.arg(prompt)
|
|
.current_dir(vault_path)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
Ok(command)
|
|
}
|
|
|
|
struct CodexCommandTarget {
|
|
program: PathBuf,
|
|
first_arg: Option<PathBuf>,
|
|
}
|
|
|
|
fn codex_command_target(binary: &Path) -> Result<CodexCommandTarget, String> {
|
|
if is_windows_batch_shim(binary) {
|
|
if let Some(script) = node_script_from_windows_cmd_shim(binary) {
|
|
return Ok(CodexCommandTarget {
|
|
program: crate::mcp::find_node()?,
|
|
first_arg: Some(script),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(CodexCommandTarget {
|
|
program: binary.to_path_buf(),
|
|
first_arg: None,
|
|
})
|
|
}
|
|
|
|
fn is_windows_batch_shim(binary: &Path) -> bool {
|
|
binary
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.is_some_and(|extension| {
|
|
extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat")
|
|
})
|
|
}
|
|
|
|
fn node_script_from_windows_cmd_shim(binary: &Path) -> Option<PathBuf> {
|
|
let contents = std::fs::read_to_string(binary).ok()?;
|
|
contents
|
|
.split('"')
|
|
.skip(1)
|
|
.step_by(2)
|
|
.filter(|token| is_node_script_token(token))
|
|
.find_map(|token| resolve_cmd_shim_script_path(binary, token))
|
|
}
|
|
|
|
fn is_node_script_token(token: &str) -> bool {
|
|
let lower = token.to_ascii_lowercase();
|
|
(lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs"))
|
|
&& (lower.starts_with("%dp0%") || lower.starts_with("%~dp0"))
|
|
}
|
|
|
|
fn resolve_cmd_shim_script_path(binary: &Path, token: &str) -> Option<PathBuf> {
|
|
let relative = token
|
|
.strip_prefix("%dp0%")
|
|
.or_else(|| token.strip_prefix("%~dp0"))?
|
|
.trim_start_matches(['\\', '/']);
|
|
let mut script = binary.parent()?.to_path_buf();
|
|
for part in relative.split(['\\', '/']).filter(|part| !part.is_empty()) {
|
|
script.push(part);
|
|
}
|
|
script.is_file().then_some(script)
|
|
}
|
|
|
|
fn build_codex_args(
|
|
request: &AgentStreamRequest,
|
|
last_message_path: Option<&Path>,
|
|
) -> Result<Vec<String>, 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::<Vec<_>>()
|
|
.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<F>(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<F>(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<F>(
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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::<Vec<_>>().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<AiAgentStreamEvent>) {
|
|
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").unwrap();
|
|
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",
|
|
)
|
|
.unwrap();
|
|
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::<Vec<_>>();
|
|
|
|
assert!(
|
|
paths.contains(&PathBuf::from("/opt/homebrew/bin")),
|
|
"PATH should include the resolved Codex binary directory, got {paths:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_codex_command_avoids_windows_cmd_shim_for_complex_args() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let shim = dir.path().join("codex.cmd");
|
|
let script = dir
|
|
.path()
|
|
.join("node_modules")
|
|
.join("@openai")
|
|
.join("codex")
|
|
.join("bin")
|
|
.join("codex.js");
|
|
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
|
|
std::fs::write(&script, "console.log('codex')\n").unwrap();
|
|
std::fs::write(
|
|
&shim,
|
|
r#"@ECHO off
|
|
"%_prog%" "%dp0%\node_modules\@openai\codex\bin\codex.js" %*
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let command = build_codex_command(
|
|
&shim,
|
|
vec![
|
|
"exec".to_string(),
|
|
"-c".to_string(),
|
|
r#"mcp_servers.tolaria.command="C:\\Program Files\\node.exe""#.to_string(),
|
|
],
|
|
"Summarize".into(),
|
|
"/tmp/vault",
|
|
)
|
|
.unwrap();
|
|
|
|
assert_ne!(
|
|
command.get_program(),
|
|
shim.as_os_str(),
|
|
"Codex npm .cmd shims cannot safely receive quoted -c args directly"
|
|
);
|
|
let actual_args = command.get_args().collect::<Vec<_>>();
|
|
assert_eq!(actual_args.first().copied(), Some(script.as_os_str()));
|
|
assert!(actual_args
|
|
.iter()
|
|
.any(|arg| *arg == OsStr::new("Summarize")));
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|