11use crate :: exec:: ExecToolCallOutput ;
22use crate :: token_data:: KnownPlan ;
33use crate :: token_data:: PlanType ;
4+ use crate :: truncate:: truncate_middle;
45use codex_protocol:: ConversationId ;
56use codex_protocol:: protocol:: RateLimitSnapshot ;
67use reqwest:: StatusCode ;
@@ -12,6 +13,9 @@ use tokio::task::JoinError;
1213
1314pub type Result < T > = std:: result:: Result < T , CodexErr > ;
1415
16+ /// Limit UI error messages to a reasonable size while keeping useful context.
17+ const ERROR_MESSAGE_UI_MAX_BYTES : usize = 2 * 1024 ; // 4 KiB
18+
1519#[ derive( Error , Debug ) ]
1620pub enum SandboxErr {
1721 /// Error from sandbox execution
@@ -304,21 +308,44 @@ impl CodexErr {
304308}
305309
306310pub fn get_error_message_ui ( e : & CodexErr ) -> String {
307- match e {
308- CodexErr :: Sandbox ( SandboxErr :: Denied { output } ) => output. stderr . text . clone ( ) ,
311+ let message = match e {
312+ CodexErr :: Sandbox ( SandboxErr :: Denied { output } ) => {
313+ let aggregated = output. aggregated_output . text . trim ( ) ;
314+ if !aggregated. is_empty ( ) {
315+ output. aggregated_output . text . clone ( )
316+ } else {
317+ let stderr = output. stderr . text . trim ( ) ;
318+ let stdout = output. stdout . text . trim ( ) ;
319+ match ( stderr. is_empty ( ) , stdout. is_empty ( ) ) {
320+ ( false , false ) => format ! ( "{stderr}\n {stdout}" ) ,
321+ ( false , true ) => output. stderr . text . clone ( ) ,
322+ ( true , false ) => output. stdout . text . clone ( ) ,
323+ ( true , true ) => format ! (
324+ "command failed inside sandbox with exit code {}" ,
325+ output. exit_code
326+ ) ,
327+ }
328+ }
329+ }
309330 // Timeouts are not sandbox errors from a UX perspective; present them plainly
310- CodexErr :: Sandbox ( SandboxErr :: Timeout { output } ) => format ! (
311- "error: command timed out after {} ms" ,
312- output. duration. as_millis( )
313- ) ,
331+ CodexErr :: Sandbox ( SandboxErr :: Timeout { output } ) => {
332+ format ! (
333+ "error: command timed out after {} ms" ,
334+ output. duration. as_millis( )
335+ )
336+ }
314337 _ => e. to_string ( ) ,
315- }
338+ } ;
339+
340+ truncate_middle ( & message, ERROR_MESSAGE_UI_MAX_BYTES ) . 0
316341}
317342
318343#[ cfg( test) ]
319344mod tests {
320345 use super :: * ;
346+ use crate :: exec:: StreamOutput ;
321347 use codex_protocol:: protocol:: RateLimitWindow ;
348+ use pretty_assertions:: assert_eq;
322349
323350 fn rate_limit_snapshot ( ) -> RateLimitSnapshot {
324351 RateLimitSnapshot {
@@ -348,6 +375,73 @@ mod tests {
348375 ) ;
349376 }
350377
378+ #[ test]
379+ fn sandbox_denied_uses_aggregated_output_when_stderr_empty ( ) {
380+ let output = ExecToolCallOutput {
381+ exit_code : 77 ,
382+ stdout : StreamOutput :: new ( String :: new ( ) ) ,
383+ stderr : StreamOutput :: new ( String :: new ( ) ) ,
384+ aggregated_output : StreamOutput :: new ( "aggregate detail" . to_string ( ) ) ,
385+ duration : Duration :: from_millis ( 10 ) ,
386+ timed_out : false ,
387+ } ;
388+ let err = CodexErr :: Sandbox ( SandboxErr :: Denied {
389+ output : Box :: new ( output) ,
390+ } ) ;
391+ assert_eq ! ( get_error_message_ui( & err) , "aggregate detail" ) ;
392+ }
393+
394+ #[ test]
395+ fn sandbox_denied_reports_both_streams_when_available ( ) {
396+ let output = ExecToolCallOutput {
397+ exit_code : 9 ,
398+ stdout : StreamOutput :: new ( "stdout detail" . to_string ( ) ) ,
399+ stderr : StreamOutput :: new ( "stderr detail" . to_string ( ) ) ,
400+ aggregated_output : StreamOutput :: new ( String :: new ( ) ) ,
401+ duration : Duration :: from_millis ( 10 ) ,
402+ timed_out : false ,
403+ } ;
404+ let err = CodexErr :: Sandbox ( SandboxErr :: Denied {
405+ output : Box :: new ( output) ,
406+ } ) ;
407+ assert_eq ! ( get_error_message_ui( & err) , "stderr detail\n stdout detail" ) ;
408+ }
409+
410+ #[ test]
411+ fn sandbox_denied_reports_stdout_when_no_stderr ( ) {
412+ let output = ExecToolCallOutput {
413+ exit_code : 11 ,
414+ stdout : StreamOutput :: new ( "stdout only" . to_string ( ) ) ,
415+ stderr : StreamOutput :: new ( String :: new ( ) ) ,
416+ aggregated_output : StreamOutput :: new ( String :: new ( ) ) ,
417+ duration : Duration :: from_millis ( 8 ) ,
418+ timed_out : false ,
419+ } ;
420+ let err = CodexErr :: Sandbox ( SandboxErr :: Denied {
421+ output : Box :: new ( output) ,
422+ } ) ;
423+ assert_eq ! ( get_error_message_ui( & err) , "stdout only" ) ;
424+ }
425+
426+ #[ test]
427+ fn sandbox_denied_reports_exit_code_when_no_output_available ( ) {
428+ let output = ExecToolCallOutput {
429+ exit_code : 13 ,
430+ stdout : StreamOutput :: new ( String :: new ( ) ) ,
431+ stderr : StreamOutput :: new ( String :: new ( ) ) ,
432+ aggregated_output : StreamOutput :: new ( String :: new ( ) ) ,
433+ duration : Duration :: from_millis ( 5 ) ,
434+ timed_out : false ,
435+ } ;
436+ let err = CodexErr :: Sandbox ( SandboxErr :: Denied {
437+ output : Box :: new ( output) ,
438+ } ) ;
439+ assert_eq ! (
440+ get_error_message_ui( & err) ,
441+ "command failed inside sandbox with exit code 13"
442+ ) ;
443+ }
444+
351445 #[ test]
352446 fn usage_limit_reached_error_formats_free_plan ( ) {
353447 let err = UsageLimitReachedError {
0 commit comments