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 > {
2026-04-30 21:01:09 +02:00
find_codex_binary_on_path ( )
2026-05-07 10:15:23 +02:00
. filter ( | binary | is_usable_codex_binary ( binary ) )
. or_else ( | | {
find_codex_binary_in_user_shell ( ) . filter ( | binary | is_usable_codex_binary ( binary ) )
} )
2026-04-30 21:01:09 +02:00
. 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 ( )
} )
2026-04-29 04:47:16 +02:00
}
fn find_codex_binary_on_path ( ) -> Option < PathBuf > {
2026-05-02 18:41:38 +02:00
crate ::hidden_command ( codex_path_lookup_command ( ) )
2026-04-29 04:47:16 +02:00
. arg ( " codex " )
. output ( )
. ok ( )
. and_then ( | output | path_from_successful_output ( & output ) )
}
2026-05-02 18:41:38 +02:00
fn codex_path_lookup_command ( ) -> & 'static str {
if cfg! ( windows ) {
" where "
} else {
" which "
}
}
2026-04-29 04:47:16 +02:00
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 " ) ,
2026-05-02 18:41:38 +02:00
home . join ( " .local/bin/codex.exe " ) ,
2026-04-29 04:47:16 +02:00
home . join ( " .codex/bin/codex " ) ,
2026-05-02 18:41:38 +02:00
home . join ( " .codex/bin/codex.exe " ) ,
2026-04-29 04:47:16 +02:00
home . join ( " .local/share/mise/shims/codex " ) ,
2026-05-02 18:41:38 +02:00
home . join ( " .local/share/mise/shims/codex.exe " ) ,
2026-04-29 04:47:16 +02:00
home . join ( " .asdf/shims/codex " ) ,
2026-05-02 18:41:38 +02:00
home . join ( " .asdf/shims/codex.exe " ) ,
2026-04-29 04:47:16 +02:00
home . join ( " .npm-global/bin/codex " ) ,
2026-05-02 18:41:38 +02:00
home . join ( " .npm-global/bin/codex.cmd " ) ,
home . join ( " .npm-global/bin/codex.exe " ) ,
2026-04-29 04:47:16 +02:00
home . join ( " .npm/bin/codex " ) ,
2026-05-02 18:41:38 +02:00
home . join ( " .npm/bin/codex.cmd " ) ,
home . join ( " .npm/bin/codex.exe " ) ,
2026-04-29 04:47:16 +02:00
home . join ( " .bun/bin/codex " ) ,
2026-05-02 18:41:38 +02:00
home . join ( " .bun/bin/codex.exe " ) ,
2026-04-30 15:01:35 +02:00
home . join ( " .linuxbrew/bin/codex " ) ,
2026-05-02 18:41:38 +02:00
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 " ) ,
2026-04-30 15:01:35 +02:00
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 21:01:09 +02:00
fn find_usable_codex_binary ( candidates : Vec < PathBuf > ) -> Option < PathBuf > {
2026-05-07 10:15:23 +02:00
candidates
. into_iter ( )
. find ( | binary | is_usable_codex_binary ( binary ) )
2026-04-30 21:01:09 +02:00
}
2026-05-07 10:15:23 +02:00
fn is_usable_codex_binary ( binary : & Path ) -> bool {
2026-04-30 21:01:09 +02:00
crate ::cli_agent_runtime ::version_for_binary ( binary ) . is_some ( )
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 ) ,
{
2026-05-01 01:29:46 +02:00
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 ) ) ? ;
2026-04-29 04:47:16 +02:00
let prompt = build_codex_prompt ( & request ) ;
2026-05-05 11:45:56 +02:00
let command = build_codex_command ( binary , args , prompt , & request . vault_path ) ? ;
2026-05-01 01:29:46 +02:00
let emit = with_codex_last_message_fallback ( emit , last_message_path ) ;
2026-04-29 04:47:16 +02:00
crate ::cli_agent_runtime ::run_ai_agent_json_stream (
command ,
" codex " ,
emit ,
codex_session_id ,
dispatch_codex_event ,
format_codex_error ,
)
}
2026-05-01 01:29:46 +02:00
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 ) ;
}
}
2026-04-29 04:47:16 +02:00
fn build_codex_command (
binary : & Path ,
args : Vec < String > ,
prompt : String ,
vault_path : & str ,
2026-05-05 11:45:56 +02:00
) -> Result < std ::process ::Command , String > {
2026-05-07 10:15:23 +02:00
let target = crate ::cli_agent_runtime ::command_target_avoiding_windows_cmd_shim ( binary ) ? ;
2026-05-05 11:45:56 +02:00
let mut command = crate ::hidden_command ( & target . program ) ;
2026-05-02 01:08:09 +02:00
crate ::cli_agent_runtime ::configure_agent_command_environment ( & mut command , binary ) ;
2026-05-05 11:45:56 +02:00
if let Some ( first_arg ) = target . first_arg {
command . arg ( first_arg ) ;
}
2026-04-29 04:47:16 +02:00
command
. args ( args )
. arg ( prompt )
. current_dir ( vault_path )
2026-05-02 19:47:33 +02:00
. stdin ( Stdio ::null ( ) )
2026-04-29 04:47:16 +02:00
. stdout ( Stdio ::piped ( ) )
. stderr ( Stdio ::piped ( ) ) ;
2026-05-05 11:45:56 +02:00
Ok ( command )
}
2026-05-01 01:29:46 +02:00
fn build_codex_args (
request : & AgentStreamRequest ,
last_message_path : Option < & Path > ,
) -> Result < Vec < String > , String > {
2026-04-29 04:47:16 +02:00
let mcp_server_path = crate ::cli_agent_runtime ::mcp_server_path_string ( ) ? ;
2026-05-02 19:47:33 +02:00
let node_path = crate ::mcp ::find_node ( ) ? ;
2026-04-29 04:47:16 +02:00
2026-05-01 01:29:46 +02:00
let mut args = vec! [
2026-04-29 04:47:16 +02:00
" --sandbox " . into ( ) ,
2026-04-30 21:01:09 +02:00
codex_sandbox ( request . permission_mode ) . into ( ) ,
2026-04-29 04:47:16 +02:00
" --ask-for-approval " . into ( ) ,
2026-04-30 21:01:09 +02:00
codex_approval_policy ( request . permission_mode ) . into ( ) ,
2026-04-29 04:47:16 +02:00
" exec " . into ( ) ,
" --json " . into ( ) ,
" -C " . into ( ) ,
request . vault_path . clone ( ) ,
" -c " . into ( ) ,
2026-05-02 19:47:33 +02:00
codex_config_string ( " mcp_servers.tolaria.command " , & node_path . to_string_lossy ( ) ) ,
2026-04-29 04:47:16 +02:00
" -c " . into ( ) ,
2026-05-02 19:47:33 +02:00
codex_config_string_list ( " mcp_servers.tolaria.args " , & [ mcp_server_path . as_str ( ) ] ) ,
2026-04-29 04:47:16 +02:00
" -c " . into ( ) ,
2026-05-02 19:47:33 +02:00
codex_mcp_env_config ( & request . vault_path ) ,
2026-05-01 01:29:46 +02:00
] ;
if let Some ( path ) = last_message_path {
args . push ( " --output-last-message " . into ( ) ) ;
args . push ( path . to_string_lossy ( ) . into_owned ( ) ) ;
}
Ok ( args )
2026-04-29 04:47:16 +02:00
}
2026-05-02 19:47:33 +02:00
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 # "\""# )
}
2026-04-30 21:01:09 +02:00
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 " ,
}
}
2026-04-29 04:47:16 +02:00
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 ( ) ) ,
} ) ;
}
}
2026-05-01 01:29:46 +02:00
" mcp_tool_call " = > emit_codex_mcp_tool_event ( item , item_id , completed , emit ) ,
2026-04-29 04:47:16 +02:00
" agent_message " if completed = > {
if let Some ( text ) = item [ " text " ] . as_str ( ) {
emit ( AiAgentStreamEvent ::TextDelta {
text : text . to_string ( ) ,
} ) ;
}
}
_ = > { }
}
}
2026-05-01 01:29:46 +02:00
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 ( ) )
}
2026-04-29 04:47:16 +02:00
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 ) {
2026-04-30 21:01:09 +02:00
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 ( ) ;
2026-04-29 04:47:16 +02:00
}
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 ,
}
}
2026-04-30 21:01:09 +02:00
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 ] ;
2026-04-29 04:47:16 +02:00
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 ( ) {
2026-05-01 01:29:46 +02:00
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 ,
) {
2026-04-29 04:47:16 +02:00
assert_eq! ( args [ 4 ] , " exec " ) ;
2026-04-30 21:01:09 +02:00
assert_codex_permission_contract ( & args , AiAgentPermissionMode ::Safe ) ;
2026-04-29 04:47:16 +02:00
assert! ( args . contains ( & " --json " . to_string ( ) ) ) ;
assert! ( args . contains ( & " -C " . to_string ( ) ) ) ;
}
}
#[ test ]
2026-04-30 21:01:09 +02:00
fn codex_power_user_keeps_workspace_write_without_dangerous_bypass ( ) {
2026-05-01 01:29:46 +02:00
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 ,
) {
2026-04-30 21:01:09 +02:00
assert_codex_permission_contract ( & args , AiAgentPermissionMode ::PowerUser ) ;
2026-04-29 04:47:16 +02:00
}
}
2026-05-01 01:29:46 +02:00
#[ 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 " ,
] ) ) ;
}
}
2026-05-02 19:47:33 +02:00
#[ 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""# ) ) ) ;
}
2026-04-29 04:47:16 +02:00
#[ test ]
fn build_codex_command_keeps_agent_process_contract ( ) {
let binary = PathBuf ::from ( " codex " ) ;
let args = vec! [ " exec " . to_string ( ) , " --json " . to_string ( ) ] ;
2026-05-05 11:45:56 +02:00
let command = build_codex_command ( & binary , args , " Summarize " . into ( ) , " /tmp/vault " ) . unwrap ( ) ;
2026-04-29 04:47:16 +02:00
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 " ) ) ) ;
}
2026-05-02 01:08:09 +02:00
#[ 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 " ,
2026-05-05 11:45:56 +02:00
)
. unwrap ( ) ;
2026-05-02 01:08:09 +02:00
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:?} "
) ;
}
2026-05-05 11:45:56 +02:00
#[ 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% \n ode_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 " ) ) ) ;
}
2026-04-29 04:47:16 +02:00
#[ 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 " ) ;
}
2026-05-01 01:29:46 +02:00
#[ 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 " ) ;
}
2026-04-29 04:47:16 +02:00
#[ 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 ) ) ) ;
}
2026-05-02 19:47:33 +02:00
#[ 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} \n stdout: \n {stdout_text} \n stderr: \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 ) ) ) ;
}
2026-04-29 04:47:16 +02:00
#[ 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-05-02 18:41:38 +02:00
" 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 ) ,
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 ( ) ) ;
}
2026-04-30 21:01:09 +02:00
#[ 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 ) ) ;
}
2026-04-29 04:47:16 +02:00
#[ 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 ]
2026-05-01 01:29:46 +02:00
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 ]
2026-04-29 04:47:16 +02:00
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 " ) ) ;
}
}