2026-04-29 04:47:16 +02:00
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 > {
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 ) ;
}
2026-04-30 15:01:35 +02:00
if let Some ( binary ) = find_existing_binary ( codex_binary_candidates ( ) ) ? {
2026-04-29 04:47:16 +02:00
return Ok ( binary ) ;
}
Err ( " Codex CLI not found. Install it: https://developers.openai.com/codex/cli " . into ( ) )
}
fn find_codex_binary_on_path ( ) -> Option < PathBuf > {
crate ::hidden_command ( " which " )
. arg ( " codex " )
. output ( )
. ok ( )
. and_then ( | output | path_from_successful_output ( & output ) )
}
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 ( " .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 " ) ,
2026-04-30 15:01:35 +02:00
home . join ( " .linuxbrew/bin/codex " ) ,
PathBuf ::from ( " /home/linuxbrew/.linuxbrew/bin/codex " ) ,
2026-04-29 04:47:16 +02:00
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
}
2026-04-30 15:01:35 +02:00
fn find_existing_binary ( candidates : Vec < PathBuf > ) -> Result < Option < PathBuf > , String > {
crate ::cli_agent_runtime ::find_executable_binary_candidate ( candidates , " Codex CLI " )
2026-04-29 04:47:16 +02:00
}
fn run_agent_stream_with_binary < F > (
binary : & Path ,
request : AgentStreamRequest ,
emit : F ,
) -> Result < String , String >
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 < String > ,
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 < Vec < String > , 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 < 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 ( ) ) ,
} ) ;
}
}
" 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 ::< 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_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 < 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: \n Be concise " ) ) ;
assert! ( prompt . contains ( " User request: \n Rename 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 ) ,
2026-04-30 15:01:35 +02:00
" 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 ) ,
2026-04-29 04:47:16 +02:00
" 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 \n if [ \" $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 " ) ) ;
}
}