feat: add session deletion with per-provider cleanup and path safety

Add delete_session Tauri command dispatching to provider-specific deletion
logic for all 5 providers (Claude, Codex, Gemini, OpenCode, OpenClaw).
Includes path traversal protection via canonicalize + starts_with validation,
session ID verification against file contents, frontend confirmation dialog
with optimistic cache updates, i18n keys (zh/en/ja), and component tests.
This commit is contained in:
Jason
2026-03-06 23:09:38 +08:00
parent e18db31752
commit 8c3f18a9bd
17 changed files with 1043 additions and 15 deletions
+17
View File
@@ -57,3 +57,20 @@ pub async fn launch_session_terminal(
Ok(true)
}
#[tauri::command]
pub async fn delete_session(
providerId: String,
sessionId: String,
sourcePath: String,
) -> Result<bool, String> {
let provider_id = providerId.clone();
let session_id = sessionId.clone();
let source_path = sourcePath.clone();
tauri::async_runtime::spawn_blocking(move || {
session_manager::delete_session(&provider_id, &session_id, &source_path)
})
.await
.map_err(|e| format!("Failed to delete session: {e}"))?
}
+1
View File
@@ -1038,6 +1038,7 @@ pub fn run() {
// Session manager
commands::list_sessions,
commands::get_session_messages,
commands::delete_session,
commands::launch_session_terminal,
commands::get_tool_versions,
// Provider terminal
+88 -1
View File
@@ -2,7 +2,7 @@ pub mod providers;
pub mod terminal;
use serde::Serialize;
use std::path::Path;
use std::path::{Path, PathBuf};
use providers::{claude, codex, gemini, openclaw, opencode};
@@ -79,3 +79,90 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
_ => Err(format!("Unsupported provider: {provider_id}")),
}
}
pub fn delete_session(
provider_id: &str,
session_id: &str,
source_path: &str,
) -> Result<bool, String> {
let root = provider_root(provider_id)?;
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
}
fn delete_session_with_root(
provider_id: &str,
session_id: &str,
source_path: &Path,
root: &Path,
) -> Result<bool, String> {
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) {
return Err(format!(
"Session source path is outside provider root: {}",
source_path.display()
));
}
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),
_ => Err(format!("Unsupported provider: {provider_id}")),
}
}
fn provider_root(provider_id: &str) -> Result<PathBuf, String> {
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"),
_ => return Err(format!("Unsupported provider: {provider_id}")),
};
Ok(root)
}
fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf, String> {
if !path.exists() {
return Err(format!("{label} not found: {}", path.display()));
}
path.canonicalize()
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn rejects_source_path_outside_provider_root() {
let root = tempdir().expect("tempdir");
let outside = tempdir().expect("tempdir");
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");
assert!(err.contains("outside provider root"));
}
#[test]
fn rejects_missing_source_path() {
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");
assert!(err.contains("session source not found"));
}
}
@@ -70,6 +70,41 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
Ok(messages)
}
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
let meta = parse_session(path).ok_or_else(|| {
format!(
"Failed to parse Claude session metadata: {}",
path.display()
)
})?;
if meta.session_id != session_id {
return Err(format!(
"Claude session ID mismatch: expected {session_id}, found {}",
meta.session_id
));
}
if let Some(stem) = path.file_stem() {
let sibling = path.parent().unwrap_or_else(|| Path::new("")).join(stem);
remove_path_if_exists(&sibling).map_err(|e| {
format!(
"Failed to delete Claude session sidecar {}: {e}",
sibling.display()
)
})?;
}
std::fs::remove_file(path).map_err(|e| {
format!(
"Failed to delete Claude session file {}: {e}",
path.display()
)
})?;
Ok(true)
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
if is_agent_session(path) {
return None;
@@ -187,3 +222,50 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
}
}
}
fn remove_path_if_exists(path: &Path) -> std::io::Result<()> {
match std::fs::metadata(path) {
Ok(meta) => {
if meta.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn delete_session_removes_main_file_and_sidecar_directory() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("abc123-session.jsonl");
let sidecar = temp.path().join("abc123-session");
let subagents = sidecar.join("subagents");
let tool_results = sidecar.join("tool-results");
std::fs::create_dir_all(&subagents).expect("create subagents");
std::fs::create_dir_all(&tool_results).expect("create tool-results");
std::fs::write(subagents.join("agent-1.jsonl"), "{}").expect("write subagent");
std::fs::write(tool_results.join("tool-1.txt"), "result").expect("write tool result");
std::fs::write(
&path,
concat!(
"{\"sessionId\":\"session-123\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
),
)
.expect("write session");
delete_session(temp.path(), &path, "session-123").expect("delete session");
assert!(!path.exists());
assert!(!sidecar.exists());
}
}
@@ -81,6 +81,27 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
Ok(messages)
}
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
let meta = parse_session(path)
.ok_or_else(|| format!("Failed to parse Codex session metadata: {}", path.display()))?;
if meta.session_id != session_id {
return Err(format!(
"Codex session ID mismatch: expected {session_id}, found {}",
meta.session_id
));
}
std::fs::remove_file(path).map_err(|e| {
format!(
"Failed to delete Codex session file {}: {e}",
path.display()
)
})?;
Ok(true)
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
@@ -192,3 +213,30 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn delete_session_removes_jsonl_file() {
let temp = tempdir().expect("tempdir");
let path = temp
.path()
.join("rollout-2026-03-06T21-50-12-019cc369-bd7c-7891-b371-7b20b4fe0b18.jsonl");
std::fs::write(
&path,
concat!(
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"019cc369-bd7c-7891-b371-7b20b4fe0b18\",\"cwd\":\"/tmp/project\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}}\n"
),
)
.expect("write session");
delete_session(temp.path(), &path, "019cc369-bd7c-7891-b371-7b20b4fe0b18")
.expect("delete session");
assert!(!path.exists());
}
}
@@ -80,6 +80,31 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
Ok(result)
}
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
let meta = parse_session(path).ok_or_else(|| {
format!(
"Failed to parse Gemini session metadata: {}",
path.display()
)
})?;
if meta.session_id != session_id {
return Err(format!(
"Gemini session ID mismatch: expected {session_id}, found {}",
meta.session_id
));
}
std::fs::remove_file(path).map_err(|e| {
format!(
"Failed to delete Gemini session file {}: {e}",
path.display()
)
})?;
Ok(true)
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
let data = std::fs::read_to_string(path).ok()?;
let value: Value = serde_json::from_str(&data).ok()?;
@@ -115,3 +140,36 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
resume_command: Some(format!("gemini --resume {session_id}")),
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn delete_session_removes_json_file() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-2026-03-06T10-17-test.json");
std::fs::write(
&path,
r#"{
"sessionId": "gemini-session-123",
"startTime": "2026-03-06T10:17:58.000Z",
"lastUpdated": "2026-03-06T10:20:00.000Z",
"messages": [
{
"id": "msg-1",
"timestamp": "2026-03-06T10:17:58.000Z",
"type": "user",
"content": "hello"
}
]
}"#,
)
.expect("write session");
delete_session(temp.path(), &path, "gemini-session-123").expect("delete session");
assert!(!path.exists());
}
}
@@ -5,7 +5,10 @@ use std::path::Path;
use serde_json::Value;
use crate::openclaw_config::get_openclaw_dir;
use crate::session_manager::{SessionMessage, SessionMeta};
use crate::{
config::write_json_file,
session_manager::{SessionMessage, SessionMeta},
};
use super::utils::{
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
@@ -115,6 +118,37 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
Ok(messages)
}
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
let meta = parse_session(path).ok_or_else(|| {
format!(
"Failed to parse OpenClaw session metadata: {}",
path.display()
)
})?;
if meta.session_id != session_id {
return Err(format!(
"OpenClaw session ID mismatch: expected {session_id}, found {}",
meta.session_id
));
}
let index_path = path
.parent()
.unwrap_or_else(|| Path::new(""))
.join("sessions.json");
prune_sessions_index(&index_path, session_id, path)?;
std::fs::remove_file(path).map_err(|e| {
format!(
"Failed to delete OpenClaw session file {}: {e}",
path.display()
)
})?;
Ok(true)
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
@@ -206,3 +240,92 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume
})
}
fn prune_sessions_index(
index_path: &Path,
session_id: &str,
source_path: &Path,
) -> Result<(), String> {
if !index_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(index_path).map_err(|e| {
format!(
"Failed to read OpenClaw sessions index {}: {e}",
index_path.display()
)
})?;
let mut index: serde_json::Map<String, Value> =
serde_json::from_str(&content).map_err(|e| {
format!(
"Failed to parse OpenClaw sessions index {}: {e}",
index_path.display()
)
})?;
let source = source_path.to_string_lossy();
index.retain(|_, entry| {
let same_id = entry.get("sessionId").and_then(Value::as_str) == Some(session_id);
let same_file = entry.get("sessionFile").and_then(Value::as_str) == Some(source.as_ref());
!(same_id || same_file)
});
write_json_file(index_path, &index).map_err(|e| {
format!(
"Failed to update OpenClaw sessions index {}: {e}",
index_path.display()
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn delete_session_updates_index_and_removes_jsonl() {
let temp = tempdir().expect("tempdir");
let sessions_dir = temp.path().join("main").join("sessions");
std::fs::create_dir_all(&sessions_dir).expect("create sessions dir");
let session_path = sessions_dir.join("session-123.jsonl");
std::fs::write(
&session_path,
concat!(
"{\"type\":\"session\",\"id\":\"session-123\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
),
)
.expect("write session");
std::fs::write(
sessions_dir.join("sessions.json"),
format!(
r#"{{
"agent:main:main": {{
"sessionId": "session-123",
"sessionFile": "{}"
}},
"agent:main:other": {{
"sessionId": "session-456",
"sessionFile": "{}/session-456.jsonl"
}}
}}"#,
session_path.display(),
sessions_dir.display()
),
)
.expect("write index");
delete_session(temp.path(), &session_path, "session-123").expect("delete session");
assert!(!session_path.exists());
let updated: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(sessions_dir.join("sessions.json")).expect("read index"),
)
.expect("parse index");
assert!(updated.get("agent:main:main").is_none());
assert!(updated.get("agent:main:other").is_some());
}
}
@@ -12,7 +12,7 @@ const PROVIDER_ID: &str = "opencode";
///
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
/// `~/.local/share/opencode/storage/`.
fn get_opencode_data_dir() -> PathBuf {
pub(crate) fn get_opencode_data_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("opencode").join("storage");
@@ -111,6 +111,71 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
Ok(messages)
}
pub fn delete_session(storage: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
if path.file_name().and_then(|name| name.to_str()) != Some(session_id) {
return Err(format!(
"OpenCode session path does not match session ID: expected {session_id}, found {}",
path.display()
));
}
let mut message_files = Vec::new();
collect_json_files(path, &mut message_files);
let mut message_ids = Vec::new();
for message_path in &message_files {
let data = match std::fs::read_to_string(message_path) {
Ok(data) => data,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&data) {
Ok(value) => value,
Err(_) => continue,
};
if let Some(message_id) = value.get("id").and_then(Value::as_str) {
message_ids.push(message_id.to_string());
}
}
for message_id in &message_ids {
let part_dir = storage.join("part").join(message_id);
remove_dir_all_if_exists(&part_dir).map_err(|e| {
format!(
"Failed to delete OpenCode part directory {}: {e}",
part_dir.display()
)
})?;
}
let session_diff_path = storage
.join("session_diff")
.join(format!("{session_id}.json"));
remove_file_if_exists(&session_diff_path).map_err(|e| {
format!(
"Failed to delete OpenCode session diff {}: {e}",
session_diff_path.display()
)
})?;
remove_dir_all_if_exists(path).map_err(|e| {
format!(
"Failed to delete OpenCode message directory {}: {e}",
path.display()
)
})?;
if let Some(session_file) = find_session_file(storage, session_id) {
remove_file_if_exists(&session_file).map_err(|e| {
format!(
"Failed to delete OpenCode session file {}: {e}",
session_file.display()
)
})?;
}
Ok(true)
}
fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
let data = std::fs::read_to_string(path).ok()?;
let value: Value = serde_json::from_str(&data).ok()?;
@@ -274,3 +339,97 @@ fn collect_json_files(root: &Path, files: &mut Vec<PathBuf>) {
}
}
}
fn find_session_file(storage: &Path, session_id: &str) -> Option<PathBuf> {
let session_root = storage.join("session");
let mut files = Vec::new();
collect_json_files(&session_root, &mut files);
let expected = format!("{session_id}.json");
files
.into_iter()
.find(|path| path.file_name().and_then(|name| name.to_str()) == Some(expected.as_str()))
}
fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
fn remove_dir_all_if_exists(path: &Path) -> std::io::Result<()> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn delete_session_removes_session_diff_messages_and_parts() {
let temp = tempdir().expect("tempdir");
let storage = temp.path();
let project_id = "project-123";
let session_id = "ses_123";
let session_dir = storage.join("session").join(project_id);
let message_dir = storage.join("message").join(session_id);
let session_diff = storage
.join("session_diff")
.join(format!("{session_id}.json"));
let part_dir = storage.join("part").join("msg_1");
let session_file = session_dir.join(format!("{session_id}.json"));
std::fs::create_dir_all(&session_dir).expect("create session dir");
std::fs::create_dir_all(&message_dir).expect("create message dir");
std::fs::create_dir_all(&part_dir).expect("create part dir");
std::fs::create_dir_all(storage.join("project")).expect("create project dir");
std::fs::create_dir_all(storage.join("session_diff")).expect("create session diff dir");
std::fs::write(
&session_file,
format!(
r#"{{
"id": "{session_id}",
"projectID": "{project_id}",
"directory": "/tmp/project",
"time": {{ "created": 1, "updated": 2 }}
}}"#
),
)
.expect("write session file");
std::fs::write(
message_dir.join("msg_1.json"),
format!(r#"{{"id":"msg_1","sessionID":"{session_id}","role":"user"}}"#),
)
.expect("write message file");
std::fs::write(
part_dir.join("prt_1.json"),
r#"{"id":"prt_1","messageID":"msg_1"}"#,
)
.expect("write part file");
std::fs::write(&session_diff, "[]").expect("write session diff");
std::fs::write(
storage.join("project").join(format!("{project_id}.json")),
r#"{"id":"project-123"}"#,
)
.expect("write project file");
delete_session(storage, &message_dir, session_id).expect("delete session");
assert!(!session_file.exists());
assert!(!message_dir.exists());
assert!(!session_diff.exists());
assert!(!part_dir.exists());
assert!(storage
.join("project")
.join(format!("{project_id}.json"))
.exists());
}
}