diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 526efe650f..cfb70b5768 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -869,6 +869,7 @@ dependencies = [ "shlex", "strum 0.27.2", "strum_macros 0.27.2", + "supports-color", "textwrap 0.16.2", "tokio", "tracing", @@ -2338,6 +2339,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -4379,6 +4386,15 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 5301f0220d..dce02cc5e2 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -140,11 +140,7 @@ pub async fn process_exec_tool_call( let exit_code = raw_output.exit_status.code().unwrap_or(-1); - // NOTE(ragona): This is much less restrictive than the previous check. If we exec - // a command, and it returns anything other than success, we assume that it may have - // been a sandboxing error and allow the user to retry. (The user of course may choose - // not to retry, or in a non-interactive mode, would automatically reject the approval.) - if exit_code != 0 && sandbox_type != SandboxType::None { + if exit_code != 0 && is_likely_sandbox_denied(sandbox_type, exit_code) { return Err(CodexErr::Sandbox(SandboxErr::Denied( exit_code, stdout, stderr, ))); @@ -223,6 +219,26 @@ fn create_linux_sandbox_command_args( linux_cmd } +/// We don't have a fully deterministic way to tell if our command failed +/// because of the sandbox - a command in the user's zshrc file might hit an +/// error, but the command itself might fail or succeed for other reasons. +/// For now, we conservatively check for 'command not found' (exit code 127), +/// and can add additional cases as necessary. +fn is_likely_sandbox_denied(sandbox_type: SandboxType, exit_code: i32) -> bool { + if sandbox_type == SandboxType::None { + return false; + } + + // Quick rejects: well-known non-sandbox shell exit codes + // 127: command not found, 2: misuse of shell builtins + if exit_code == 127 { + return false; + } + + // For all other cases, we assume the sandbox is the cause + true +} + #[derive(Debug)] pub struct RawExecToolCallOutput { pub exit_status: ExitStatus, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index a33e185afb..80f9014954 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -38,7 +38,7 @@ pub mod plan_tool; mod project_doc; pub mod protocol; mod rollout; -mod safety; +pub(crate) mod safety; pub mod seatbelt; pub mod shell; pub mod spawn; @@ -47,3 +47,4 @@ pub mod util; pub use apply_patch::CODEX_APPLY_PATCH_ARG1; pub use client_common::model_supports_reasoning_summaries; +pub use safety::get_platform_sandbox; diff --git a/codex-rs/core/tests/exec.rs b/codex-rs/core/tests/exec.rs new file mode 100644 index 0000000000..da169296ed --- /dev/null +++ b/codex-rs/core/tests/exec.rs @@ -0,0 +1,69 @@ +#![cfg(target_os = "macos")] +#![expect(clippy::expect_used)] + +use std::collections::HashMap; +use std::sync::Arc; + +use codex_core::exec::ExecParams; +use codex_core::exec::SandboxType; +use codex_core::exec::process_exec_tool_call; +use codex_core::protocol::SandboxPolicy; +use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; +use tempfile::TempDir; +use tokio::sync::Notify; + +use codex_core::get_platform_sandbox; + +async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>, should_be_ok: bool) { + if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) { + eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test."); + return; + } + + let sandbox_type = get_platform_sandbox().expect("should be able to get sandbox type"); + assert_eq!(sandbox_type, SandboxType::MacosSeatbelt); + + let params = ExecParams { + command: cmd.iter().map(|s| s.to_string()).collect(), + cwd: tmp.path().to_path_buf(), + timeout_ms: Some(1000), + env: HashMap::new(), + }; + + let ctrl_c = Arc::new(Notify::new()); + let policy = SandboxPolicy::new_read_only_policy(); + + let result = process_exec_tool_call(params, sandbox_type, ctrl_c, &policy, &None, None).await; + + assert!(result.is_ok() == should_be_ok); +} + +/// Command succeeds with exit code 0 normally +#[tokio::test] +async fn exit_code_0_succeeds() { + let tmp = TempDir::new().expect("should be able to create temp dir"); + let cmd = vec!["echo", "hello"]; + + run_test_cmd(tmp, cmd, true).await +} + +/// Command not found returns exit code 127, this is not considered a sandbox error +#[tokio::test] +async fn exit_command_not_found_is_ok() { + let tmp = TempDir::new().expect("should be able to create temp dir"); + let cmd = vec!["/bin/bash", "-c", "nonexistent_command_12345"]; + run_test_cmd(tmp, cmd, true).await +} + +/// Writing a file fails and should be considered a sandbox error +#[tokio::test] +async fn write_file_fails_as_sandbox_error() { + let tmp = TempDir::new().expect("should be able to create temp dir"); + let path = tmp.path().join("test.txt"); + let cmd = vec![ + "/user/bin/touch", + path.to_str().expect("should be able to get path"), + ]; + + run_test_cmd(tmp, cmd, false).await; +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 5cbb60499c..041679d6cf 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -52,6 +52,7 @@ serde_json = { version = "1", features = ["preserve_order"] } shlex = "1.3.0" strum = "0.27.2" strum_macros = "0.27.2" +supports-color = "3.0.2" textwrap = "0.16.2" tokio = { version = "1", features = [ "io-std", diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index b9ee06ad6d..91f6a666a5 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -11,6 +11,7 @@ use std::time::Duration; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; +use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::text::Line; use ratatui::text::Span; @@ -65,7 +66,7 @@ impl StatusIndicatorWidget { thread::spawn(move || { let mut counter = 0usize; while running_clone.load(Ordering::Relaxed) { - std::thread::sleep(Duration::from_millis(33)); + std::thread::sleep(Duration::from_millis(100)); counter = counter.wrapping_add(1); frame_idx_clone.store(counter, Ordering::Relaxed); app_event_tx_clone.send(AppEvent::RequestRedraw); @@ -170,18 +171,65 @@ impl WidgetRef for StatusIndicatorWidget { if area.height == 0 || area.width == 0 { return; } - // Plain rendering: no borders or padding so the live cell is visually - // indistinguishable from terminal scrollback. No left bar. + + // Build animated gradient header for the word "Working". + let idx = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); + let header_text = "Working"; + let header_chars: Vec = header_text.chars().collect(); + let padding = 4usize; // virtual padding around the word for smoother loop + let period = header_chars.len() + padding * 2; + let pos = idx % period; + let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout) + .map(|level| level.has_16m) + .unwrap_or(false); + let band_half_width = 2.0; // width of the bright band in characters + + let mut header_spans: Vec> = Vec::new(); + for (i, ch) in header_chars.iter().enumerate() { + let i_pos = i as isize + padding as isize; + let pos = pos as isize; + let dist = (i_pos - pos).abs() as f32; + + let t = if dist <= band_half_width { + let x = std::f32::consts::PI * (dist / band_half_width); + 0.5 * (1.0 + x.cos()) + } else { + 0.0 + }; + + let brightness = 0.4 + 0.6 * t; + let level = (brightness * 255.0).clamp(0.0, 255.0) as u8; + let style = if has_true_color { + Style::default() + .fg(Color::Rgb(level, level, level)) + .add_modifier(Modifier::BOLD) + } else { + // Bold makes dark gray and gray look the same, so don't use it when true color is not supported. + Style::default().fg(color_for_level(level)) + }; + + header_spans.push(Span::styled(ch.to_string(), style)); + } + + // Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback. let inner_width = area.width as usize; // Compose a single status line like: "▌ Working [·] waiting for model" let mut spans: Vec> = Vec::new(); spans.push(Span::styled("▌ ", Style::default().fg(Color::Cyan))); - spans.push(Span::raw("Working ")); + // Gradient header + spans.extend(header_spans); + // Space after header + spans.push(Span::styled( + " ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )); // Append animated dot in brackets. const ANIM: [usize; 9] = [0, 1, 2, 3, 4, 3, 2, 1, 0]; - const DOTS: [&str; 5] = ["·", "•", "●", "◉", "⬤"]; // small → large + const DOTS: [&str; 5] = ["·", "•", "●", "◉", "○"]; const DOT_SLOWDOWN: usize = 6; // slow down animation relative to frame tick let frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); let idx = (frame / DOT_SLOWDOWN) % ANIM.len(); @@ -225,6 +273,16 @@ impl WidgetRef for StatusIndicatorWidget { } } +fn color_for_level(level: u8) -> Color { + if level < 128 { + Color::DarkGray + } else if level < 192 { + Color::Gray + } else { + Color::White + } +} + #[cfg(test)] mod tests { use super::*;