From 51d1e061f810c2a7879ed91a9933905812a450b2 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Fri, 13 Mar 2026 13:05:26 -0700 Subject: [PATCH] tui: encode queued resume paths losslessly Co-authored-by: Codex --- codex-rs/tui/src/chatwidget.rs | 80 ++++++++++++++++++++++++---- codex-rs/tui/src/chatwidget/tests.rs | 25 +++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 72eb278da3..55d704b223 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -29,6 +29,7 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; +use std::ffi::OsString; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; @@ -38,6 +39,16 @@ use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; +use base64::Engine; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; +#[cfg(windows)] +use std::os::windows::ffi::OsStringExt; + use self::realtime::PendingSteerCompareKey; use crate::app_event::RealtimeAudioDeviceKind; use crate::app_event::WindowsSandboxEnableMode; @@ -5327,7 +5338,9 @@ impl ChatWidget { ); } }; - if !(args.len() == 1 || (args.len() == 3 && args[1] == "--path")) { + if !(args.len() == 1 + || (args.len() == 3 && matches!(args[1].as_str(), "--path" | "--path-base64"))) + { return self.restore_invalid_inline_slash_command( cmd, args_message, @@ -5337,11 +5350,44 @@ impl ChatWidget { match ThreadId::from_string(&args[0]) { Ok(thread_id) => { if let Some(path) = args.get(2) { + let path = match args[1].as_str() { + "--path" => PathBuf::from(path), + "--path-base64" => { + let Ok(bytes) = + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(path) + else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Invalid encoded rollout path for /resume.".to_string(), + ); + }; + #[cfg(unix)] + let path = PathBuf::from(OsString::from_vec(bytes)); + #[cfg(windows)] + let path = { + let mut chunks = bytes.chunks_exact(2); + if !chunks.remainder().is_empty() { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Invalid encoded rollout path for /resume." + .to_string(), + ); + } + let wide = chunks + .by_ref() + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + PathBuf::from(OsString::from_wide(&wide)) + }; + path + } + _ => unreachable!("validated resume path flag"), + }; self.app_event_tx.send(AppEvent::ResumeSessionTarget( - crate::resume_picker::SessionTarget { - path: PathBuf::from(path), - thread_id, - }, + crate::resume_picker::SessionTarget { path, thread_id }, )); } else { self.app_event_tx.send(AppEvent::ResumeSession(thread_id)); @@ -5720,13 +5766,27 @@ impl ChatWidget { pub(crate) fn resume_selection_draft( target_session: &crate::resume_picker::SessionTarget, ) -> UserMessage { + let (path_flag, path_value) = if let Some(path) = target_session.path.to_str() { + ("--path".to_string(), path.to_string()) + } else { + #[cfg(unix)] + let bytes = target_session.path.as_os_str().as_bytes().to_vec(); + #[cfg(windows)] + let bytes = target_session + .path + .as_os_str() + .encode_wide() + .flat_map(u16::to_le_bytes) + .collect::>(); + + ( + "--path-base64".to_string(), + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes), + ) + }; SlashCommandInvocation::with_args( SlashCommand::Resume, - [ - target_session.thread_id.to_string(), - "--path".to_string(), - target_session.path.display().to_string(), - ], + [target_session.thread_id.to_string(), path_flag, path_value], ) .into_user_message() } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 9a01505291..7cefb76f92 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -129,6 +129,10 @@ use pretty_assertions::assert_eq; use serial_test::serial; use std::collections::BTreeMap; use std::collections::HashSet; +#[cfg(unix)] +use std::ffi::OsString; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; use std::path::PathBuf; use tempfile::NamedTempFile; use tempfile::tempdir; @@ -5736,6 +5740,27 @@ async fn queued_resume_picker_selection_replays_exact_targets_in_order() { assert_matches!(rx.try_recv(), Ok(AppEvent::ResumeSessionTarget(target)) if target == second); } +#[cfg(unix)] +#[tokio::test] +async fn queued_resume_picker_selection_preserves_non_utf8_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_task_running(true); + + let target = crate::resume_picker::SessionTarget { + path: PathBuf::from(OsString::from_vec(b"/tmp/non-utf8-\xff.rollout".to_vec())), + thread_id: ThreadId::new(), + }; + + let draft = ChatWidget::resume_selection_draft(&target); + assert!(draft.text.contains("--path-base64")); + + chat.handle_serialized_slash_command(draft); + chat.bottom_pane.set_task_running(false); + chat.drain_queued_inputs_until_blocked(); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ResumeSessionTarget(replayed)) if replayed == target); +} + #[tokio::test] async fn collaboration_modes_defaults_to_code_on_startup() { let codex_home = tempdir().expect("tempdir");