From e9d84af5002a7c3a71fcca943173bd8c7ba4c05f Mon Sep 17 00:00:00 2001 From: nanmen2 <33722445+nanmen2@users.noreply.github.com> Date: Wed, 27 May 2026 11:16:50 +0800 Subject: [PATCH] fix(session): include Codex archived sessions (#2861) Scan Codex archived_sessions alongside active sessions so archived conversations appear in the session manager. Allow deletion from any validated Codex session root while keeping path safety checks in place. --- src-tauri/src/session_manager/mod.rs | 119 +++++++++++++----- .../src/session_manager/providers/codex.rs | 54 +++++++- 2 files changed, 140 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index 2a630c98c..c7ca1f714 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -124,8 +124,8 @@ pub fn delete_session( return hermes::delete_session_sqlite(session_id, source_path); } - let root = provider_root(provider_id)?; - delete_session_with_root(provider_id, session_id, Path::new(source_path), &root) + let roots = provider_roots(provider_id)?; + delete_session_with_roots(provider_id, session_id, Path::new(source_path), &roots) } pub fn delete_sessions(requests: &[DeleteSessionRequest]) -> Vec { @@ -138,45 +138,67 @@ pub fn delete_sessions(requests: &[DeleteSessionRequest]) -> Vec Result { - let validated_root = canonicalize_existing_path(root, "session root")?; let validated_source = canonicalize_existing_path(source_path, "session source")?; - if !validated_source.starts_with(&validated_root) { + let mut saw_existing_root = false; + for root in roots { + if !root.exists() { + continue; + } + + saw_existing_root = true; + let validated_root = canonicalize_existing_path(root, "session root")?; + if validated_source.starts_with(&validated_root) { + return match provider_id { + "codex" => codex::delete_session(&validated_root, &validated_source, session_id), + "claude" => claude::delete_session(&validated_root, &validated_source, session_id), + "opencode" => { + opencode::delete_session(&validated_root, &validated_source, session_id) + } + "openclaw" => { + openclaw::delete_session(&validated_root, &validated_source, session_id) + } + "gemini" => gemini::delete_session(&validated_root, &validated_source, session_id), + "hermes" => hermes::delete_session(&validated_root, &validated_source, session_id), + _ => Err(format!("Unsupported provider: {provider_id}")), + }; + } + } + + if !saw_existing_root { return Err(format!( - "Session source path is outside provider root: {}", - source_path.display() + "Session root not found for provider {provider_id}: {}", + roots + .first() + .map(|root| root.display().to_string()) + .unwrap_or_else(|| "".to_string()) )); } - match provider_id { - "codex" => codex::delete_session(&validated_root, &validated_source, session_id), - "claude" => claude::delete_session(&validated_root, &validated_source, session_id), - "opencode" => opencode::delete_session(&validated_root, &validated_source, session_id), - "openclaw" => openclaw::delete_session(&validated_root, &validated_source, session_id), - "gemini" => gemini::delete_session(&validated_root, &validated_source, session_id), - "hermes" => hermes::delete_session(&validated_root, &validated_source, session_id), - _ => Err(format!("Unsupported provider: {provider_id}")), - } + Err(format!( + "Session source path is outside provider roots: {}", + source_path.display() + )) } -fn provider_root(provider_id: &str) -> Result { - let root = match provider_id { - "codex" => crate::codex_config::get_codex_config_dir().join("sessions"), - "claude" => crate::config::get_claude_config_dir().join("projects"), - "opencode" => opencode::get_opencode_data_dir(), - "openclaw" => crate::openclaw_config::get_openclaw_dir().join("agents"), - "gemini" => crate::gemini_config::get_gemini_dir().join("tmp"), - "hermes" => crate::hermes_config::get_hermes_dir().join("sessions"), +fn provider_roots(provider_id: &str) -> Result, String> { + let roots = match provider_id { + "codex" => codex::session_roots(), + "claude" => vec![crate::config::get_claude_config_dir().join("projects")], + "opencode" => vec![opencode::get_opencode_data_dir()], + "openclaw" => vec![crate::openclaw_config::get_openclaw_dir().join("agents")], + "gemini" => vec![crate::gemini_config::get_gemini_dir().join("tmp")], + "hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")], _ => return Err(format!("Unsupported provider: {provider_id}")), }; - Ok(root) + Ok(roots) } fn canonicalize_existing_path(path: &Path, label: &str) -> Result { @@ -228,6 +250,39 @@ mod tests { use super::*; use tempfile::tempdir; + fn write_codex_session(path: &Path, session_id: &str) { + std::fs::write( + path, + format!( + "{{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"/tmp/project\"}}}}\n\ + {{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}}}}\n", + ), + ) + .expect("write source"); + } + + #[test] + fn accepts_source_path_under_any_allowed_provider_root() { + let active_root = tempdir().expect("active root"); + let archived_root = tempdir().expect("archived root"); + let source = archived_root.path().join("session.jsonl"); + write_codex_session(&source, "archived-session"); + + let deleted = delete_session_with_roots( + "codex", + "archived-session", + &source, + &[ + active_root.path().to_path_buf(), + archived_root.path().to_path_buf(), + ], + ) + .expect("delete archived session"); + + assert!(deleted); + assert!(!source.exists()); + } + #[test] fn rejects_source_path_outside_provider_root() { let root = tempdir().expect("tempdir"); @@ -235,10 +290,11 @@ mod tests { let source = outside.path().join("session.jsonl"); std::fs::write(&source, "{}").expect("write source"); - let err = delete_session_with_root("codex", "session-1", &source, root.path()) - .expect_err("expected outside-root path to be rejected"); + let err = + delete_session_with_roots("codex", "session-1", &source, &[root.path().to_path_buf()]) + .expect_err("expected outside-root path to be rejected"); - assert!(err.contains("outside provider root")); + assert!(err.contains("outside provider roots")); } #[test] @@ -246,8 +302,9 @@ mod tests { let root = tempdir().expect("tempdir"); let missing = root.path().join("missing.jsonl"); - let err = delete_session_with_root("codex", "session-1", &missing, root.path()) - .expect_err("expected missing source path to fail"); + let err = + delete_session_with_roots("codex", "session-1", &missing, &[root.path().to_path_buf()]) + .expect_err("expected missing source path to fail"); assert!(err.contains("session source not found")); } diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index 05775ebdc..5d8a99264 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -22,9 +22,23 @@ static UUID_RE: LazyLock = LazyLock::new(|| { }); pub fn scan_sessions() -> Vec { - let root = get_codex_config_dir().join("sessions"); + let roots = session_roots(); + scan_sessions_in_roots(&roots) +} + +pub fn session_roots() -> Vec { + let config_dir = get_codex_config_dir(); + vec![ + config_dir.join("sessions"), + config_dir.join("archived_sessions"), + ] +} + +fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec { let mut files = Vec::new(); - collect_jsonl_files(&root, &mut files); + for root in roots { + collect_jsonl_files(root, &mut files); + } let mut sessions = Vec::new(); for path in files { @@ -282,6 +296,42 @@ mod tests { use super::*; use tempfile::tempdir; + fn write_codex_session(path: &Path, session_id: &str, message: &str) { + std::fs::write( + path, + format!( + "{{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"/tmp/project\"}}}}\n\ + {{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":\"{message}\"}}}}\n", + ), + ) + .expect("write session"); + } + + #[test] + fn scan_sessions_in_roots_includes_active_and_archived_files() { + let temp = tempdir().expect("tempdir"); + let active = temp.path().join("sessions"); + let archived = temp.path().join("archived_sessions"); + std::fs::create_dir_all(&active).expect("active dir"); + std::fs::create_dir_all(&archived).expect("archived dir"); + + write_codex_session(&active.join("active.jsonl"), "active-id", "Active session"); + write_codex_session( + &archived.join("archived.jsonl"), + "archived-id", + "Archived session", + ); + + let sessions = scan_sessions_in_roots(&[active, archived]); + let ids = sessions + .into_iter() + .map(|session| session.session_id) + .collect::>(); + + assert!(ids.contains(&"active-id".to_string())); + assert!(ids.contains(&"archived-id".to_string())); + } + #[test] fn delete_session_removes_jsonl_file() { let temp = tempdir().expect("tempdir");