Skip to content

Commit 0db13fb

Browse files
committed
Fix HTTP session persistence
1 parent 76690b4 commit 0db13fb

6 files changed

Lines changed: 180 additions & 111 deletions

File tree

crates/core/src/agent/mod.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ pub use sanitize::{
2727
pub use session::{
2828
DEFAULT_AGENT_ID, Session, SessionInfo, SessionMessage, SessionSearchResult, SessionStatus,
2929
get_last_session_id, get_last_session_id_for_agent, get_sessions_dir_for_agent, get_state_dir,
30-
list_sessions, list_sessions_for_agent, recover_orphaned_sessions, search_sessions,
31-
search_sessions_for_agent,
30+
is_valid_session_id, list_sessions, list_sessions_for_agent, recover_orphaned_sessions,
31+
search_sessions, search_sessions_for_agent,
3232
};
3333
pub use session_pruning::{PruneResult, preview_prune, prune_all_agents, prune_sessions};
3434
pub use session_store::{SessionEntry, SessionStore};
@@ -731,7 +731,16 @@ impl Agent {
731731
}
732732

733733
pub async fn new_session(&mut self) -> Result<()> {
734-
self.session = Session::new();
734+
self.start_new_session(Session::new()).await
735+
}
736+
737+
pub async fn new_session_with_id(&mut self, session_id: &str) -> Result<()> {
738+
self.start_new_session(Session::new_with_id(session_id.to_string())?)
739+
.await
740+
}
741+
742+
async fn start_new_session(&mut self, session: Session) -> Result<()> {
743+
self.session = session;
735744
self.search_queries = 0;
736745
self.search_cached_hits = 0;
737746
self.search_cost_usd = 0.0;
@@ -783,6 +792,16 @@ impl Agent {
783792
Ok(())
784793
}
785794

795+
pub async fn resume_session_for_agent(
796+
&mut self,
797+
session_id: &str,
798+
agent_id: &str,
799+
) -> Result<()> {
800+
self.session = Session::load_for_agent(session_id, agent_id)?;
801+
info!("Resumed session {} for agent {}", session_id, agent_id);
802+
Ok(())
803+
}
804+
786805
pub async fn chat(&mut self, message: &str) -> Result<String> {
787806
self.chat_with_images(message, Vec::new()).await
788807
}

crates/core/src/agent/session.rs

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,29 @@ use super::providers::{LLMProvider, Message, Role, ToolCall, Usage};
1818
/// Current session format version (matches Pi)
1919
pub const CURRENT_SESSION_VERSION: u32 = 1;
2020

21+
/// Maximum accepted length for externally supplied session IDs.
22+
pub const MAX_SESSION_ID_LEN: usize = 128;
23+
24+
/// Validate a session ID before it is used as a filename.
25+
pub fn is_valid_session_id(session_id: &str) -> bool {
26+
!session_id.is_empty()
27+
&& session_id.len() <= MAX_SESSION_ID_LEN
28+
&& session_id
29+
.chars()
30+
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
31+
}
32+
33+
fn validate_session_id(session_id: &str) -> Result<()> {
34+
if is_valid_session_id(session_id) {
35+
Ok(())
36+
} else {
37+
anyhow::bail!(
38+
"Invalid session ID: use 1-{} ASCII letters, numbers, hyphens, or underscores",
39+
MAX_SESSION_ID_LEN
40+
)
41+
}
42+
}
43+
2144
/// Session state (internal representation)
2245
#[derive(Debug, Clone)]
2346
pub struct Session {
@@ -137,9 +160,25 @@ impl Session {
137160
)
138161
}
139162

163+
pub fn new_with_id(id: impl Into<String>) -> Result<Self> {
164+
let id = id.into();
165+
validate_session_id(&id)?;
166+
167+
Ok(Self::new_with_id_and_cwd(
168+
id,
169+
std::env::current_dir()
170+
.map(|p| p.to_string_lossy().to_string())
171+
.unwrap_or_else(|_| ".".to_string()),
172+
))
173+
}
174+
140175
pub fn new_with_cwd(cwd: String) -> Self {
176+
Self::new_with_id_and_cwd(Uuid::new_v4().to_string(), cwd)
177+
}
178+
179+
fn new_with_id_and_cwd(id: String, cwd: String) -> Self {
141180
Self {
142-
id: Uuid::new_v4().to_string(),
181+
id,
143182
created_at: Utc::now(),
144183
cwd,
145184
messages: Vec::new(),
@@ -300,6 +339,7 @@ impl Session {
300339

301340
/// Save session in Pi-compatible JSONL format
302341
pub fn save(&self) -> Result<PathBuf> {
342+
validate_session_id(&self.id)?;
303343
let dir = get_sessions_dir()?;
304344
fs::create_dir_all(&dir)?;
305345

@@ -309,6 +349,7 @@ impl Session {
309349
}
310350

311351
pub fn save_for_agent(&self, agent_id: &str) -> Result<PathBuf> {
352+
validate_session_id(&self.id)?;
312353
let dir = get_sessions_dir_for_agent(agent_id)?;
313354
fs::create_dir_all(&dir)?;
314355

@@ -435,6 +476,7 @@ impl Session {
435476
session_id: &str,
436477
encryption_key: Option<&crate::security::encrypt::EncryptionKey>,
437478
) -> Result<Self> {
479+
validate_session_id(session_id)?;
438480
let enc_path = base_path.with_extension("jsonl.enc");
439481

440482
// Try encrypted file first
@@ -465,6 +507,7 @@ impl Session {
465507

466508
/// Load session from an in-memory JSONL string.
467509
fn load_from_string(content: &str, session_id: &str) -> Result<Self> {
510+
validate_session_id(session_id)?;
468511
let mut session = Session {
469512
id: session_id.to_string(),
470513
created_at: Utc::now(),
@@ -574,6 +617,7 @@ impl Session {
574617

575618
/// Load session (supports both old and Pi formats)
576619
pub fn load(session_id: &str) -> Result<Self> {
620+
validate_session_id(session_id)?;
577621
let dir = get_sessions_dir()?;
578622
let path = dir.join(format!("{}.jsonl", session_id));
579623

@@ -586,6 +630,7 @@ impl Session {
586630

587631
/// Load a session for a specific agent ID.
588632
pub fn load_for_agent(session_id: &str, agent_id: &str) -> Result<Self> {
633+
validate_session_id(session_id)?;
589634
let dir = get_sessions_dir_for_agent(agent_id)?;
590635
let path = dir.join(format!("{}.jsonl", session_id));
591636

@@ -626,6 +671,7 @@ impl Session {
626671
}
627672

628673
fn load_from_path(path: &PathBuf, session_id: &str) -> Result<Self> {
674+
validate_session_id(session_id)?;
629675
let file = File::open(path)?;
630676
let reader = BufReader::new(file);
631677

@@ -846,6 +892,10 @@ pub fn recover_orphaned_sessions(agent_id: &str) -> Result<usize> {
846892
None => continue,
847893
};
848894

895+
if !is_valid_session_id(&session_id) {
896+
continue;
897+
}
898+
849899
if let Ok(mut session) = Session::load_from_path(&path, &session_id)
850900
&& session.aborted_last_run
851901
{
@@ -897,7 +947,7 @@ pub fn list_sessions_for_agent(agent_id: &str) -> Result<Vec<SessionInfo>> {
897947

898948
let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
899949

900-
if filename.len() < 32 {
950+
if !is_valid_session_id(filename) {
901951
continue;
902952
}
903953

@@ -1023,6 +1073,7 @@ pub fn search_sessions_for_agent(agent_id: &str, query: &str) -> Result<Vec<Sess
10231073

10241074
if path.extension().map(|e| e == "jsonl").unwrap_or(false)
10251075
&& let Some(filename) = path.file_stem().and_then(|s| s.to_str())
1076+
&& is_valid_session_id(filename)
10261077
&& let Ok(content) = fs::read_to_string(&path)
10271078
{
10281079
let content_lower = content.to_lowercase();
@@ -1086,6 +1137,32 @@ mod tests {
10861137
assert_eq!(session.compaction_count(), 0);
10871138
}
10881139

1140+
#[test]
1141+
fn test_session_new_with_id() {
1142+
let session = Session::new_with_id("http-session_123").unwrap();
1143+
assert_eq!(session.id(), "http-session_123");
1144+
}
1145+
1146+
#[test]
1147+
fn test_session_rejects_invalid_ids() {
1148+
assert!(Session::new_with_id("").is_err());
1149+
assert!(Session::new_with_id("../escape").is_err());
1150+
assert!(Session::new_with_id("has space").is_err());
1151+
assert!(Session::new_with_id("unicode-\u{2603}").is_err());
1152+
}
1153+
1154+
#[test]
1155+
fn test_session_save_load_preserves_custom_id() {
1156+
let tmp = tempfile::TempDir::new().unwrap();
1157+
let path = tmp.path().join("custom-session_1.jsonl");
1158+
let session = Session::new_with_id("custom-session_1").unwrap();
1159+
1160+
session.save_to_path(&path).unwrap();
1161+
let loaded = Session::load_from_path(&path, "custom-session_1").unwrap();
1162+
1163+
assert_eq!(loaded.id(), "custom-session_1");
1164+
}
1165+
10891166
#[test]
10901167
fn test_message_usage_from() {
10911168
let usage = Usage {

crates/core/src/media/cache.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,7 @@ mod tests {
168168
#[test]
169169
fn test_cache_eviction_on_full() {
170170
let tmp = TempDir::new().unwrap();
171-
// Very small cache: 1MB max but effectively tiny for test
172171
let cache_dir = tmp.path().join("cache");
173-
let cache = MediaCache::new(cache_dir.clone(), 0); // 0 MB = no limit test
174-
// Actually test with a real limit
175172
let cache = MediaCache::new(cache_dir, 1); // 1 MB
176173

177174
let file = tmp.path().join("test.txt");

0 commit comments

Comments
 (0)