1use crate::backend::{
16 AgentBackend, AgentEvent, AgentSession, PromptMode, SessionExit, SessionSpec,
17};
18#[cfg(unix)]
19use crate::backend_claude::kill_group;
20#[cfg(windows)]
21use crate::backend_claude::win_job;
22use crate::cost;
23use crate::error::{EngineError, Result};
24use crate::stream_bounds::{drain_to_tail, BoundedLines, STDERR_TAIL_CAP};
25use crate::types::TokenUsage;
26use serde_json::{json, Value};
27use std::collections::VecDeque;
28use std::io::Write;
29use std::path::{Path, PathBuf};
30use std::process::Stdio;
31use std::sync::{Arc, Mutex};
32use tokio::process::{Child, ChildStdout};
33use tokio::task::JoinHandle;
34
35const SUMMARY_MAX_CHARS: usize = 200;
37const STDERR_TAIL_CHARS: usize = 500;
39
40const CODEX_AUTH_ENV: &str = "OPENAI_API_KEY";
43
44const CODEX_SEED_ENTRIES: &[&str] = &["auth.json", "config.toml"];
51
52fn codex_child_env(spec: &SessionSpec) -> std::collections::HashMap<String, String> {
61 if spec.env.contains_key("HOME") {
62 return crate::agent_env::agent_session_env(
63 &spec.env,
64 &spec.session_id,
65 Some(CODEX_AUTH_ENV),
66 );
67 }
68 let real_home = std::env::var_os("HOME").map(PathBuf::from);
69 let scratch_root = crate::backend_claude::scratch_home_root(&spec.session_id);
70 match seed_codex_scratch_home(&scratch_root, real_home.as_deref()) {
71 Ok(home) => {
72 tracing::info!(
73 session_id = %spec.session_id,
74 decision = "scratch-seeded",
75 "session spec carried no relocated HOME; spawning into a seeded scratch \
76 HOME (.codex minimal auth/config set)"
77 );
78 crate::agent_env::session_env_with_home(
79 &spec.env,
80 &spec.session_id,
81 Some(CODEX_AUTH_ENV),
82 &home,
83 )
84 }
85 Err(e) => {
86 tracing::warn!(
87 session_id = %spec.session_id,
88 error = %e,
89 "codex scratch HOME seeding failed; session spawns into an empty scratch \
90 HOME and will fail auth loudly if OPENAI_API_KEY is not injected"
91 );
92 crate::agent_env::agent_session_env(&spec.env, &spec.session_id, Some(CODEX_AUTH_ENV))
93 }
94 }
95}
96
97fn seed_codex_scratch_home(
102 scratch_root: &Path,
103 real_home: Option<&Path>,
104) -> std::io::Result<PathBuf> {
105 let home = scratch_root.join("home");
106 let codex_dir = home.join(".codex");
107 std::fs::create_dir_all(&codex_dir)?;
108 restrict_to_owner(&[scratch_root, &home, &codex_dir])?;
114 if let Some(real_home) = real_home {
115 let source = real_home.join(".codex");
116 for entry in CODEX_SEED_ENTRIES {
117 let src = source.join(entry);
118 let dst = codex_dir.join(entry);
119 if src.is_file() {
120 let mut source = std::fs::File::open(&src)?;
136 let mut options = std::fs::OpenOptions::new();
137 options.write(true).create_new(true);
138 #[cfg(unix)]
139 {
140 use std::os::unix::fs::OpenOptionsExt as _;
141 options.mode(0o600);
142 }
143 let mut target = options.open(&dst)?;
144 std::io::copy(&mut source, &mut target)?;
145 target.flush()?;
146 #[cfg(unix)]
147 {
148 use std::os::unix::fs::PermissionsExt as _;
149 std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o600))?;
150 }
151 }
152 }
153 }
154 Ok(home)
155}
156
157#[cfg(unix)]
162fn restrict_to_owner(dirs: &[&Path]) -> std::io::Result<()> {
163 use std::os::unix::fs::PermissionsExt as _;
164 for dir in dirs {
165 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
166 }
167 Ok(())
168}
169
170#[cfg(not(unix))]
171fn restrict_to_owner(_dirs: &[&Path]) -> std::io::Result<()> {
172 Ok(())
173}
174
175pub fn discover_codex_binary(configured: Option<&str>) -> Result<PathBuf> {
192 if let Some(env_bin) = std::env::var_os("KRANZ_CODEX_BIN") {
193 if !env_bin.is_empty() {
194 let candidate = PathBuf::from(env_bin);
195 return match probe_version(&candidate) {
196 Ok(_version) => Ok(candidate),
197 Err(why) => Err(EngineError::Config(format!(
198 "KRANZ_CODEX_BIN points at {} which did not work: {why}",
199 candidate.display()
200 ))),
201 };
202 }
203 }
204
205 let mut candidates: Vec<PathBuf> = Vec::new();
206 if let Some(configured) = configured {
207 candidates.push(PathBuf::from(configured));
208 }
209 candidates.push(PathBuf::from("codex"));
212 #[cfg(windows)]
213 {
214 candidates.push(PathBuf::from("codex.cmd"));
215 candidates.push(PathBuf::from("codex.exe"));
216 }
217 candidates.extend(fallback_candidates());
218
219 let mut deduped: Vec<PathBuf> = Vec::new();
221 for candidate in candidates {
222 if !deduped.contains(&candidate) {
223 deduped.push(candidate);
224 }
225 }
226
227 let mut attempts: Vec<String> = Vec::new();
228 for candidate in deduped {
229 match probe_version(&candidate) {
230 Ok(_version) => return Ok(candidate),
231 Err(why) => attempts.push(format!("{} ({why})", candidate.display())),
232 }
233 }
234 Err(EngineError::Config(format!(
235 "no working codex binary found; tried: {}. Install Codex CLI \
236 (npm install -g @openai/codex) or point kranz at it via the \
237 validatorScrutiny.codexBinary config field or the KRANZ_CODEX_BIN \
238 environment variable.",
239 attempts.join(", ")
240 )))
241}
242
243#[cfg(not(windows))]
245fn fallback_candidates() -> Vec<PathBuf> {
246 let home = std::env::var_os("HOME").map(PathBuf::from);
247 let mut out = Vec::new();
248 if let Some(home) = &home {
249 out.push(home.join(".npm-global").join("bin").join("codex"));
250 }
251 out.push(PathBuf::from("/opt/homebrew/bin/codex"));
252 out.push(PathBuf::from("/usr/local/bin/codex"));
253 if let Some(home) = &home {
254 out.push(home.join(".local").join("bin").join("codex"));
255 }
256 out
257}
258
259#[cfg(windows)]
261fn fallback_candidates() -> Vec<PathBuf> {
262 let mut out = Vec::new();
263 if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) {
264 for dir in [
265 profile.join("AppData").join("Roaming").join("npm"),
266 profile.join(".npm-global").join("bin"),
267 profile.join(".local").join("bin"),
268 ] {
269 for name in ["codex.cmd", "codex.exe", "codex"] {
270 out.push(dir.join(name));
271 }
272 }
273 }
274 out
275}
276
277const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
281
282fn probe_version(binary: &Path) -> std::result::Result<String, String> {
285 crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
286}
287
288fn effective_prompt(spec: &SessionSpec) -> String {
297 let prompt_text = match &spec.prompt {
298 PromptMode::SingleShot(text) => text.as_str(),
299 PromptMode::Streaming(text) => text.as_str(),
300 };
301 match &spec.append_system_prompt {
302 Some(system) if !system.is_empty() => format!("{system}\n\n{prompt_text}"),
303 _ => prompt_text.to_string(),
304 }
305}
306
307fn toml_basic_string(s: &str) -> String {
310 let mut out = String::with_capacity(s.len() + 2);
311 out.push('"');
312 for c in s.chars() {
313 match c {
314 '\\' => out.push_str("\\\\"),
315 '"' => out.push_str("\\\""),
316 '\n' => out.push_str("\\n"),
317 '\r' => out.push_str("\\r"),
318 '\t' => out.push_str("\\t"),
319 c => out.push(c),
320 }
321 }
322 out.push('"');
323 out
324}
325
326pub fn build_args(spec: &SessionSpec) -> Vec<String> {
333 let sandbox = if spec.writable {
334 "workspace-write"
335 } else {
336 "read-only"
337 };
338 let mut args = vec![
339 "exec".into(),
340 "--json".into(),
341 "--sandbox".into(),
342 sandbox.into(),
343 ];
344 if spec.writable {
348 let root = toml_basic_string(&spec.cwd.display().to_string());
351 args.push("-c".into());
352 args.push(format!("sandbox_workspace_write.writable_roots=[{root}]"));
353 }
354 args.push("--model".into());
355 args.push(spec.model.clone());
356 args.push(effective_prompt(spec));
357 args
358}
359
360pub fn parse_codex_line(line: &str, model: &str) -> Vec<AgentEvent> {
372 match serde_json::from_str::<Value>(line) {
373 Ok(value) => parse_codex_value(value, model),
374 Err(_) => vec![AgentEvent::Other {
375 raw: json!({ "unparsed": line }),
376 }],
377 }
378}
379
380pub fn parse_codex_value(value: Value, model: &str) -> Vec<AgentEvent> {
383 let line_type = value.get("type").and_then(Value::as_str).unwrap_or("");
384 match line_type {
385 "thread.started" => vec![AgentEvent::Init {
386 session_id: str_field(&value, "thread_id"),
387 model: value
388 .get("model")
389 .and_then(Value::as_str)
390 .unwrap_or(model)
391 .to_string(),
392 raw: value,
393 }],
394 "item.started" if item_type(&value) == "command_execution" => {
395 let command = value
396 .pointer("/item/command")
397 .and_then(Value::as_str)
398 .unwrap_or("");
399 vec![AgentEvent::ToolUse {
400 tool: "command_execution".to_string(),
401 summary: truncate_chars(command, SUMMARY_MAX_CHARS),
402 raw: value,
403 }]
404 }
405 "item.completed" if item_type(&value) == "command_execution" => {
406 let output = value
407 .pointer("/item/aggregated_output")
408 .and_then(Value::as_str)
409 .unwrap_or("");
410 let exit_code_is_null = value
415 .pointer("/item/exit_code")
416 .map(Value::is_null)
417 .unwrap_or(true);
418 let status = value
419 .pointer("/item/status")
420 .and_then(Value::as_str)
421 .unwrap_or("");
422 let denied = exit_code_is_null && status == "failed";
423 vec![AgentEvent::ToolResult {
424 tool: Some("command_execution".to_string()),
425 denied,
426 summary: truncate_chars(output, SUMMARY_MAX_CHARS),
427 raw: value,
428 }]
429 }
430 "item.completed" if item_type(&value) == "agent_message" => {
431 let text = value
432 .pointer("/item/text")
433 .and_then(Value::as_str)
434 .unwrap_or("");
435 if text.is_empty() {
436 vec![AgentEvent::Other { raw: value }]
437 } else {
438 vec![AgentEvent::Text {
439 text: text.to_string(),
440 raw: value,
441 }]
442 }
443 }
444 "turn.completed" => vec![parse_terminal(value, model)],
445 _ => vec![AgentEvent::Other { raw: value }],
446 }
447}
448
449fn item_type(value: &Value) -> &str {
450 value
451 .pointer("/item/type")
452 .and_then(Value::as_str)
453 .unwrap_or("")
454}
455
456fn str_field(value: &Value, key: &str) -> String {
457 value
458 .get(key)
459 .and_then(Value::as_str)
460 .unwrap_or_default()
461 .to_string()
462}
463
464#[derive(Debug, Default)]
477pub struct CodexStreamParser {
478 last_text: Option<String>,
479}
480
481impl CodexStreamParser {
482 pub fn new() -> Self {
483 CodexStreamParser::default()
484 }
485
486 pub fn push(&mut self, line: &str, model: &str) -> Vec<AgentEvent> {
489 parse_codex_line(line, model)
490 .into_iter()
491 .map(|event| self.observe(event))
492 .collect()
493 }
494
495 fn observe(&mut self, event: AgentEvent) -> AgentEvent {
496 match event {
497 AgentEvent::Text { text, raw } => {
498 self.last_text = Some(text.clone());
499 AgentEvent::Text { text, raw }
500 }
501 AgentEvent::Result {
502 text,
503 is_error,
504 usage,
505 cost_usd,
506 num_turns,
507 raw,
508 } if text.is_empty() => AgentEvent::Result {
509 text: self.last_text.take().unwrap_or_default(),
510 is_error,
511 usage,
512 cost_usd,
513 num_turns,
514 raw,
515 },
516 other => other,
517 }
518 }
519}
520
521fn parse_terminal(value: Value, model: &str) -> AgentEvent {
522 let usage_field = |key: &str| {
523 value
524 .pointer(&format!("/usage/{key}"))
525 .and_then(Value::as_u64)
526 .unwrap_or(0)
527 };
528 let cache_read = usage_field("cached_input_tokens");
532 let cache_write = usage_field("cache_write_input_tokens");
533 let usage = TokenUsage {
534 input: usage_field("input_tokens")
538 .saturating_sub(cache_read)
539 .saturating_sub(cache_write),
540 output: usage_field("output_tokens") + usage_field("reasoning_output_tokens"),
541 cache_read,
542 cache_write,
543 };
544 let cost_usd = value
545 .get("total_cost_usd")
546 .and_then(Value::as_f64)
547 .or_else(|| value.get("cost_usd").and_then(Value::as_f64))
548 .or_else(|| Some(cost::usage_cost_usd(&usage, model)));
549 AgentEvent::Result {
550 text: String::new(),
551 is_error: value
552 .get("is_error")
553 .and_then(Value::as_bool)
554 .unwrap_or(false),
555 usage,
556 cost_usd,
557 num_turns: Some(1),
558 raw: value,
559 }
560}
561
562fn truncate_chars(text: &str, max: usize) -> String {
564 if text.chars().count() <= max {
565 text.to_string()
566 } else {
567 text.chars().take(max).collect()
568 }
569}
570
571fn last_chars(text: &str, max: usize) -> String {
573 let chars: Vec<char> = text.chars().collect();
574 let start = chars.len().saturating_sub(max);
575 chars[start..].iter().collect()
576}
577
578#[derive(Debug, Clone)]
585pub struct CodexBackend {
586 binary: PathBuf,
587}
588
589impl CodexBackend {
590 pub fn new(binary: impl Into<PathBuf>) -> Self {
592 CodexBackend {
593 binary: binary.into(),
594 }
595 }
596
597 pub fn discover(configured: Option<&str>) -> Result<Self> {
599 Ok(CodexBackend {
600 binary: discover_codex_binary(configured)?,
601 })
602 }
603
604 pub fn binary(&self) -> &Path {
606 &self.binary
607 }
608}
609
610#[async_trait::async_trait]
611impl AgentBackend for CodexBackend {
612 async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
613 if spec.resume.is_some() {
614 return Err(EngineError::Backend(
615 "codex backend is single-shot only; resume is unsupported".to_string(),
616 ));
617 }
618 let model = spec.model.clone();
619 let args = build_args(&spec);
620
621 let mut command = tokio::process::Command::new(&self.binary);
622 command
623 .args(&args)
624 .current_dir(&spec.cwd)
625 .env_clear()
629 .envs(codex_child_env(&spec))
630 .stdin(Stdio::null())
631 .stdout(Stdio::piped())
632 .stderr(Stdio::piped())
633 .kill_on_drop(true);
634 #[cfg(unix)]
637 command.process_group(0);
638
639 let mut child = command.spawn().map_err(|e| {
640 EngineError::Backend(format!("failed to spawn {}: {e}", self.binary.display()))
641 })?;
642
643 #[cfg(windows)]
645 let job = match child.raw_handle() {
646 Some(handle) => match win_job::JobHandle::create_and_assign(handle) {
647 Ok(job) => Some(job),
648 Err(e) => {
649 tracing::warn!(error = %e, "failed to create Job Object for codex child; \
650 tree-kill on abort will be unavailable");
651 None
652 }
653 },
654 None => None,
655 };
656
657 let stdout = child
658 .stdout
659 .take()
660 .ok_or_else(|| EngineError::Backend("codex child has no stdout pipe".to_string()))?;
661 let stderr = child
662 .stderr
663 .take()
664 .ok_or_else(|| EngineError::Backend("codex child has no stderr pipe".to_string()))?;
665
666 let stderr_buf = Arc::new(Mutex::new(String::new()));
671 let stderr_task = {
672 let buf = Arc::clone(&stderr_buf);
673 tokio::spawn(async move {
674 let tail = drain_to_tail(stderr, STDERR_TAIL_CAP).await;
675 *buf.lock().expect("stderr buffer lock") = tail;
676 })
677 };
678
679 Ok(Box::new(CodexSession {
680 session_id: spec.session_id.clone(),
681 model,
682 child,
683 #[cfg(windows)]
684 job,
685 lines: BoundedLines::new(stdout),
686 stderr_buf,
687 stderr_task: Some(stderr_task),
688 queue: VecDeque::new(),
689 stream_parser: CodexStreamParser::new(),
690 saw_result: false,
691 saw_success_result: false,
692 exit: None,
693 }))
694 }
695}
696
697pub struct CodexSession {
706 session_id: String,
707 model: String,
708 child: Child,
709 #[cfg(windows)]
710 job: Option<win_job::JobHandle>,
711 lines: BoundedLines<ChildStdout>,
712 stderr_buf: Arc<Mutex<String>>,
713 stderr_task: Option<JoinHandle<()>>,
714 queue: VecDeque<AgentEvent>,
716 stream_parser: CodexStreamParser,
717 saw_result: bool,
718 saw_success_result: bool,
719 exit: Option<SessionExit>,
720}
721
722#[cfg(unix)]
723impl Drop for CodexSession {
724 fn drop(&mut self) {
725 crate::backend_claude::kill_unreaped_group(&self.child);
726 }
727}
728
729impl CodexSession {
730 fn observe(&mut self, event: &AgentEvent) {
731 match event {
732 AgentEvent::Init { session_id, .. } => {
733 self.session_id = session_id.clone();
734 }
735 AgentEvent::Result { is_error, .. } => {
736 self.saw_result = true;
737 if !is_error {
738 self.saw_success_result = true;
739 }
740 }
741 _ => {}
742 }
743 }
744
745 async fn kill_child(&mut self) {
750 #[cfg(unix)]
751 {
752 let pgid = self
753 .child
754 .id()
755 .and_then(|pid| i32::try_from(pid).ok())
756 .filter(|pid| *pid > 0);
757 let group_killed = matches!(pgid, Some(pgid) if kill_group(pgid));
758 if !group_killed {
759 let _ = self.child.start_kill();
760 }
761 let _ = self.child.wait().await;
762 if group_killed {
763 if let Some(pgid) = pgid {
764 let _ = kill_group(pgid);
765 }
766 }
767 }
768 #[cfg(windows)]
769 {
770 match &self.job {
771 Some(job) => job.kill(),
772 None => {
773 let _ = self.child.start_kill();
774 }
775 }
776 let _ = self.child.wait().await;
777 }
778 #[cfg(all(not(unix), not(windows)))]
779 {
780 let _ = self.child.start_kill();
781 let _ = self.child.wait().await;
782 }
783 if let Some(task) = self.stderr_task.take() {
784 let _ = task.await;
785 }
786 }
787
788 async fn finish_at_eof(&mut self) {
789 let status = self.child.wait().await;
790 if let Some(task) = self.stderr_task.take() {
791 let _ = task.await;
792 }
793 let exit = match status {
794 Ok(status) if status.success() && self.saw_result => SessionExit::Completed,
795 Ok(status) => SessionExit::Failed(format!(
796 "codex exited with {status}{}; stderr tail: {}",
797 if self.saw_result {
798 ""
799 } else {
800 " without emitting a terminal event"
801 },
802 self.stderr_tail(),
803 )),
804 Err(e) => SessionExit::Failed(format!(
805 "failed to reap codex process: {e}; stderr tail: {}",
806 self.stderr_tail(),
807 )),
808 };
809 self.exit = Some(exit);
810 }
811
812 fn stderr_tail(&self) -> String {
813 let captured = self
814 .stderr_buf
815 .lock()
816 .map(|guard| guard.clone())
817 .unwrap_or_default();
818 last_chars(captured.trim_end(), STDERR_TAIL_CHARS)
819 }
820}
821
822#[async_trait::async_trait]
823impl AgentSession for CodexSession {
824 fn session_id(&self) -> String {
825 self.session_id.clone()
826 }
827
828 async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
829 loop {
830 if let Some(event) = self.queue.pop_front() {
831 return Ok(Some(event));
832 }
833 if self.exit.is_some() {
834 return Ok(None);
835 }
836 let line = match self.lines.next_line().await {
837 Ok(Some(line)) => line,
838 Ok(None) => {
839 self.finish_at_eof().await;
840 return Ok(None);
841 }
842 Err(e) => {
843 self.kill_child().await;
844 self.exit = Some(SessionExit::Failed(format!(
845 "error reading codex stdout: {e}; stderr tail: {}",
846 self.stderr_tail(),
847 )));
848 return Ok(None);
849 }
850 };
851 if line.trim().is_empty() {
852 continue;
853 }
854 let events = self.stream_parser.push(&line, &self.model);
855 for event in &events {
856 self.observe(event);
857 }
858 self.queue.extend(events);
859 }
860 }
861
862 async fn send_user_message(&mut self, _text: &str) -> Result<()> {
863 Err(EngineError::Backend(
864 "codex backend is single-shot only; send_user_message is unsupported".to_string(),
865 ))
866 }
867
868 async fn abort(&mut self) -> Result<()> {
869 let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
870 self.kill_child().await;
871 if self.saw_success_result && already_exited {
872 self.exit = Some(SessionExit::Completed);
873 } else {
874 self.exit = Some(SessionExit::Aborted);
875 }
876 Ok(())
877 }
878
879 fn exit_status(&self) -> Option<SessionExit> {
880 self.exit.clone()
881 }
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887 use crate::cost::DEFAULT_CODEX_MODEL;
888
889 #[test]
890 #[cfg(unix)]
891 fn probe_version_kills_a_hung_binary_within_the_deadline() {
892 use std::os::unix::fs::PermissionsExt;
893 let dir = tempfile::tempdir().unwrap();
894 let stub = dir.path().join("hung-codex");
895 std::fs::write(&stub, "#!/bin/sh\nsleep 30\n").unwrap();
896 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
897
898 let start = std::time::Instant::now();
899 let result = probe_version(&stub);
900
901 let error = result.expect_err("a hung probe must be reported as broken");
902 assert!(error.contains("did not exit"), "{error}");
903 assert!(
904 start.elapsed() < std::time::Duration::from_secs(10),
905 "probe returned within the deadline, not after the stub's sleep"
906 );
907 }
908
909 fn fixture_lines_named(name: &str) -> Vec<String> {
910 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
911 .join("tests")
912 .join("fixtures")
913 .join(name);
914 std::fs::read_to_string(path)
915 .expect("read fixture")
916 .lines()
917 .filter(|line| !line.trim().is_empty())
918 .map(|line| line.to_string())
919 .collect()
920 }
921
922 fn fixture_lines() -> Vec<String> {
923 fixture_lines_named("codex_exec_scrutiny.jsonl")
924 }
925
926 #[test]
927 fn backend_codex_parse_fixture() {
928 let mut events: Vec<AgentEvent> = Vec::new();
929 for line in fixture_lines() {
930 events.extend(parse_codex_line(&line, DEFAULT_CODEX_MODEL));
931 }
932
933 assert!(
934 events.iter().any(
935 |e| matches!(e, AgentEvent::Init { session_id, .. } if !session_id.is_empty())
936 ),
937 "expected an Init event with a non-empty session id"
938 );
939 assert!(
940 events
941 .iter()
942 .any(|e| matches!(e, AgentEvent::Text { text, .. } if !text.is_empty())),
943 "expected at least one Text event"
944 );
945 assert!(
946 events.iter().any(
947 |e| matches!(e, AgentEvent::ToolUse { tool, .. } if tool == "command_execution")
948 ),
949 "expected a ToolUse event with tool == \"command_execution\""
950 );
951 assert!(
952 events.iter().any(
953 |e| matches!(e, AgentEvent::ToolResult { tool, .. } if tool.as_deref() == Some("command_execution"))
954 ),
955 "expected a ToolResult event with tool == Some(\"command_execution\")"
956 );
957
958 let terminal = events
959 .iter()
960 .find_map(|e| match e {
961 AgentEvent::Result {
962 usage,
963 cost_usd,
964 num_turns,
965 ..
966 } => Some((usage, cost_usd, num_turns)),
967 _ => None,
968 })
969 .expect("expected a terminal Result event");
970 let (usage, cost_usd, num_turns) = terminal;
971 assert!(
972 usage.input > 0 || usage.output > 0 || usage.cache_read > 0,
973 "expected non-zero usage on the terminal Result"
974 );
975 assert!(cost_usd.is_some(), "expected cost_usd to be Some");
976 assert_eq!(
977 *num_turns,
978 Some(1),
979 "expected the terminal Result's num_turns to be Some(1)"
980 );
981 }
982
983 #[test]
984 fn command_execution_denied_derives_from_structured_fields_not_output_text() {
985 let completed = json!({
986 "type": "item.completed",
987 "item": {
988 "type": "command_execution",
989 "command": "grep foo bar.txt",
990 "aggregated_output": "",
991 "exit_code": 0,
992 "status": "completed"
993 }
994 });
995 let events = parse_codex_value(completed, DEFAULT_CODEX_MODEL);
996 match &events[0] {
997 AgentEvent::ToolResult { denied, .. } => {
998 assert!(
999 !denied,
1000 "a real exit_code with status completed must not be denied"
1001 )
1002 }
1003 other => panic!("expected ToolResult, got {other:?}"),
1004 }
1005
1006 let refused = json!({
1007 "type": "item.completed",
1008 "item": {
1009 "type": "command_execution",
1010 "command": "rm -rf /",
1011 "aggregated_output": "",
1012 "exit_code": null,
1013 "status": "failed"
1014 }
1015 });
1016 let events = parse_codex_value(refused, DEFAULT_CODEX_MODEL);
1017 match &events[0] {
1018 AgentEvent::ToolResult { denied, .. } => {
1019 assert!(
1020 *denied,
1021 "a null exit_code with status failed must be denied"
1022 )
1023 }
1024 other => panic!("expected ToolResult, got {other:?}"),
1025 }
1026 }
1027
1028 #[test]
1029 fn backend_codex_stream_parser_stitches_terminal_text() {
1030 let mut parser = CodexStreamParser::new();
1031 let mut events: Vec<AgentEvent> = Vec::new();
1032 for line in fixture_lines() {
1033 events.extend(parser.push(&line, DEFAULT_CODEX_MODEL));
1034 }
1035
1036 let terminal_text = events
1037 .iter()
1038 .find_map(|e| match e {
1039 AgentEvent::Result { text, .. } => Some(text.clone()),
1040 _ => None,
1041 })
1042 .expect("expected a terminal Result event");
1043 assert!(
1044 !terminal_text.is_empty(),
1045 "expected the terminal Result text to be stitched from the last agent_message"
1046 );
1047
1048 let report = crate::runner::parse_validator_report(&terminal_text)
1049 .expect("terminal text should parse as a ValidatorReport");
1050 assert!(
1051 !report.findings.is_empty(),
1052 "expected the fixture's ValidatorReport to have findings"
1053 );
1054 }
1055
1056 #[test]
1057 fn backend_codex_parses_gpt_5_6_sol_probe_fixture() {
1058 let mut parser = CodexStreamParser::new();
1059 let events = fixture_lines_named("codex_exec_gpt_5_6_sol_probe.jsonl")
1060 .into_iter()
1061 .flat_map(|line| parser.push(&line, DEFAULT_CODEX_MODEL))
1062 .collect::<Vec<_>>();
1063
1064 assert!(events.iter().any(|event| {
1065 matches!(event, AgentEvent::Init { model, .. } if model == "gpt-5.6-sol")
1066 }));
1067 assert!(events.iter().any(|event| {
1068 matches!(event, AgentEvent::Text { text, .. } if text == "KRANZ_PROBE_OK")
1069 }));
1070
1071 let (usage, cost) = events
1072 .iter()
1073 .find_map(|event| match event {
1074 AgentEvent::Result {
1075 usage,
1076 cost_usd: Some(cost),
1077 ..
1078 } => Some((usage, cost)),
1079 _ => None,
1080 })
1081 .expect("Sol probe must produce a priced terminal event");
1082 assert_eq!(usage.input, 4_811);
1083 assert_eq!(usage.cache_read, 9_984);
1084 assert_eq!(usage.output, 10);
1085
1086 let expected =
1087 4_811.0 / 1_000_000.0 * 4.0 + 9_984.0 / 1_000_000.0 * 0.4 + 10.0 / 1_000_000.0 * 20.0;
1088 assert!(
1089 (*cost - expected).abs() < 1e-9,
1090 "got {cost}, expected {expected}"
1091 );
1092 }
1093
1094 #[test]
1095 fn backend_codex_keeps_cache_read_write_and_uncached_input_disjoint() {
1096 let event = parse_terminal(
1097 json!({
1098 "type": "turn.completed",
1099 "usage": {
1100 "input_tokens": 100,
1101 "cached_input_tokens": 30,
1102 "cache_write_input_tokens": 20,
1103 "output_tokens": 4,
1104 "reasoning_output_tokens": 2
1105 }
1106 }),
1107 DEFAULT_CODEX_MODEL,
1108 );
1109 match event {
1110 AgentEvent::Result { usage, .. } => {
1111 assert_eq!(usage.input, 50);
1112 assert_eq!(usage.cache_read, 30);
1113 assert_eq!(usage.cache_write, 20);
1114 assert_eq!(usage.output, 6);
1115 }
1116 other => panic!("expected terminal result, got {other:?}"),
1117 }
1118 }
1119
1120 #[test]
1121 fn seed_codex_scratch_home_copies_the_minimal_auth_config_set() {
1122 let real_home = tempfile::tempdir().unwrap();
1123 let codex = real_home.path().join(".codex");
1124 std::fs::create_dir_all(&codex).unwrap();
1125 std::fs::write(codex.join("auth.json"), "{}").unwrap();
1126 std::fs::write(codex.join("config.toml"), "model = \"gpt-5\"").unwrap();
1127 std::fs::create_dir_all(codex.join("sessions")).unwrap();
1129 std::fs::write(codex.join("sessions").join("s1.jsonl"), "{}").unwrap();
1130 let scratch = tempfile::tempdir().unwrap();
1131
1132 let home = seed_codex_scratch_home(scratch.path(), Some(real_home.path())).unwrap();
1133
1134 let seeded = home.join(".codex");
1135 assert!(seeded.join("auth.json").is_file());
1136 assert!(seeded.join("config.toml").is_file());
1137 assert!(
1138 !seeded.join("sessions").exists(),
1139 "per-session transcripts are never seeded"
1140 );
1141 }
1142
1143 #[cfg(unix)]
1151 #[test]
1152 fn seed_codex_scratch_home_writes_owner_only_credentials_and_dirs() {
1153 use std::os::unix::fs::PermissionsExt as _;
1154
1155 let real_home = tempfile::tempdir().unwrap();
1156 let codex = real_home.path().join(".codex");
1157 std::fs::create_dir_all(&codex).unwrap();
1158 std::fs::write(codex.join("auth.json"), "{\"token\":\"secret\"}").unwrap();
1159 std::fs::write(codex.join("config.toml"), "model = \"gpt-5\"").unwrap();
1160 let scratch = tempfile::tempdir().unwrap();
1161 let scratch_root = scratch.path().join("kranz-worker-home-abc");
1162 std::fs::create_dir_all(&scratch_root).unwrap();
1163
1164 let home = seed_codex_scratch_home(&scratch_root, Some(real_home.path())).unwrap();
1165
1166 let mode =
1167 |path: &std::path::Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
1168 for entry in ["auth.json", "config.toml"] {
1169 assert_eq!(
1170 mode(&home.join(".codex").join(entry)),
1171 0o600,
1172 "{entry} must be owner-only"
1173 );
1174 }
1175 for dir in [&scratch_root, &home, &home.join(".codex")] {
1178 assert_eq!(mode(dir), 0o700, "{} must be owner-only", dir.display());
1179 }
1180 }
1181
1182 #[test]
1183 fn seed_codex_scratch_home_without_a_source_yields_an_empty_seed() {
1184 let real_home = tempfile::tempdir().unwrap();
1185 let scratch = tempfile::tempdir().unwrap();
1186
1187 let home = seed_codex_scratch_home(scratch.path(), Some(real_home.path())).unwrap();
1188
1189 let seeded = home.join(".codex");
1190 assert!(seeded.is_dir());
1191 assert_eq!(std::fs::read_dir(&seeded).unwrap().count(), 0);
1192 }
1193
1194 #[test]
1195 fn build_args_ignores_claude_only_fields() {
1196 let spec = SessionSpec {
1197 cwd: PathBuf::from("."),
1198 prompt: PromptMode::SingleShot("do the thing".to_string()),
1199 append_system_prompt: Some("be terse".to_string()),
1200 model: "gpt-5-codex".to_string(),
1201 effort: "high".to_string(),
1202 session_id: "sess-1".to_string(),
1203 resume: None,
1204 permission_mode: Some("acceptEdits".to_string()),
1205 allowed_tools: vec!["Bash(npm test*)".to_string()],
1206 disallowed_tools: vec!["Bash(git push*)".to_string()],
1207 tools: vec!["Bash".to_string()],
1208 writable: false,
1209 settings_json: Some(json!({"hooks": {}})),
1210 json_schema: Some(json!({"type": "object"})),
1211 max_budget_usd: Some(5.0),
1212 max_turns: Some(10),
1213 env: Default::default(),
1214 sandbox: None,
1215 hook_status: None,
1216 };
1217 let args = build_args(&spec);
1218 assert_eq!(
1219 args,
1220 vec![
1221 "exec".to_string(),
1222 "--json".to_string(),
1223 "--sandbox".to_string(),
1224 "read-only".to_string(),
1225 "--model".to_string(),
1226 "gpt-5-codex".to_string(),
1227 "be terse\n\ndo the thing".to_string(),
1228 ]
1229 );
1230 }
1231
1232 #[test]
1233 fn build_args_uses_workspace_write_for_writable_sessions() {
1234 let spec = SessionSpec {
1235 cwd: PathBuf::from("."),
1236 prompt: PromptMode::SingleShot("do the thing".to_string()),
1237 append_system_prompt: None,
1238 model: "gpt-5-codex".to_string(),
1239 effort: "high".to_string(),
1240 session_id: "sess-1".to_string(),
1241 resume: None,
1242 permission_mode: None,
1243 allowed_tools: vec![],
1244 disallowed_tools: vec![],
1245 tools: vec![],
1246 writable: true,
1247 settings_json: None,
1248 json_schema: None,
1249 max_budget_usd: None,
1250 max_turns: None,
1251 env: Default::default(),
1252 sandbox: None,
1253 hook_status: None,
1254 };
1255 let args = build_args(&spec);
1256 assert_eq!(
1257 args,
1258 vec![
1259 "exec".to_string(),
1260 "--json".to_string(),
1261 "--sandbox".to_string(),
1262 "workspace-write".to_string(),
1263 "-c".to_string(),
1264 "sandbox_workspace_write.writable_roots=[\".\"]".to_string(),
1265 "--model".to_string(),
1266 "gpt-5-codex".to_string(),
1267 "do the thing".to_string(),
1268 ]
1269 );
1270 }
1271}