1use std::collections::HashSet;
2use std::fs::{self, File, OpenOptions};
3use std::io::{self, BufRead, BufReader, Write};
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde::de::{self, DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::config::{
13 ensure_not_symlink, ensure_private_dir, ensure_private_file, lucy_dir, LlmSettings,
14};
15use crate::model::{ChatMessage, ChatToolCall};
16use crate::redaction::{conflicts_with_protected_literal, redact_secret};
17
18#[cfg(unix)]
19use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
20
21static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
22
23#[derive(Debug)]
24pub struct SessionError(String);
25
26impl SessionError {
27 fn new(message: impl Into<String>) -> Self {
28 Self(message.into())
29 }
30}
31
32impl std::fmt::Display for SessionError {
33 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 formatter.write_str(&self.0)
35 }
36}
37
38impl std::error::Error for SessionError {}
39
40impl From<io::Error> for SessionError {
41 fn from(_error: io::Error) -> Self {
42 Self::new("session storage error")
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(tag = "record")]
48enum SessionRecord {
49 #[serde(rename = "session")]
50 Session {
51 version: u8,
52 session_id: String,
53 created_at: u64,
54 cwd: String,
55 boot_system_prompt: String,
56 llm: LlmSettings,
57 },
58 #[serde(rename = "message")]
59 Message {
60 timestamp: u64,
61 message: ChatMessage,
62 },
63 #[serde(rename = "interruption")]
64 Interruption {
65 timestamp: u64,
66 reason: String,
67 phase: String,
68 #[serde(default)]
69 assistant_text: String,
70 #[serde(default)]
71 tool_calls: Vec<ChatToolCall>,
72 #[serde(default)]
73 tool_results: Vec<SessionToolResult>,
74 },
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct SessionToolResult {
80 pub id: String,
81 pub name: String,
82 pub result: Value,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
87pub struct InterruptionRecord {
88 #[serde(default)]
89 pub timestamp: u64,
90 pub reason: String,
91 pub phase: String,
92 #[serde(default)]
93 pub assistant_text: String,
94 #[serde(default)]
95 pub tool_calls: Vec<ChatToolCall>,
96 #[serde(default)]
97 pub tool_results: Vec<SessionToolResult>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
102#[serde(tag = "record")]
103pub enum SessionHistoryRecord {
104 #[serde(rename = "message")]
105 Message {
106 timestamp: u64,
107 message: ChatMessage,
108 },
109 #[serde(rename = "interruption")]
110 Interruption {
111 timestamp: u64,
112 reason: String,
113 phase: String,
114 assistant_text: String,
115 tool_calls: Vec<ChatToolCall>,
116 tool_results: Vec<SessionToolResult>,
117 },
118}
119
120#[derive(Debug, Clone)]
121pub struct Session {
122 pub id: String,
123 pub path: PathBuf,
124 pub cwd: PathBuf,
125 pub boot_system_prompt: String,
126 pub llm: LlmSettings,
127 pub created_at: u64,
128 pub updated_at: u64,
129 pub messages: Vec<ChatMessage>,
130 pub history: Vec<SessionHistoryRecord>,
131 secret: Option<String>,
132}
133
134#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
135pub struct SessionMetadata {
136 #[serde(rename = "type")]
137 pub record_type: &'static str,
138 pub session_id: String,
139 pub created_at: u64,
140 pub updated_at: u64,
141 pub first_message: Option<String>,
142 pub last_message: Option<String>,
143}
144
145impl Session {
146 pub fn create(
147 home: &Path,
148 cwd: &Path,
149 boot_system_prompt: String,
150 llm: LlmSettings,
151 ) -> Result<Self, SessionError> {
152 let secret = std::env::var(&llm.api_key_env).ok();
153 Self::create_with_secret(home, cwd, boot_system_prompt, llm, secret.as_deref())
154 }
155
156 pub fn create_with_secret(
157 home: &Path,
158 cwd: &Path,
159 boot_system_prompt: String,
160 llm: LlmSettings,
161 secret: Option<&str>,
162 ) -> Result<Self, SessionError> {
163 let cwd = fs::canonicalize(cwd)
164 .map_err(|_error| SessionError::new("unable to resolve session cwd"))?;
165 let sessions_directory = sessions_dir(home);
166 ensure_private_dir(&lucy_dir(home))?;
167 ensure_private_dir(&sessions_directory)?;
168 let created_at = now();
169
170 if let Some(secret) = secret {
171 if conflicts_with_protected_literal(secret) {
172 return Err(session_header_rejected(secret));
173 }
174 }
175
176 for _ in 0..16 {
177 let id = new_session_id();
178 let path = sessions_directory.join(format!("{id}.jsonl"));
179 let record = SessionRecord::Session {
180 version: 1,
181 session_id: id.clone(),
182 created_at,
183 cwd: cwd.display().to_string(),
184 boot_system_prompt: boot_system_prompt.clone(),
185 llm: llm.clone(),
186 };
187 if let Some(secret) = secret {
188 if record_contains_secret(&record, secret) {
189 return Err(session_header_rejected(secret));
190 }
191 }
192
193 let mut options = OpenOptions::new();
194 options.write(true).create_new(true);
195 #[cfg(unix)]
196 {
197 use std::os::unix::fs::OpenOptionsExt;
198 options.mode(0o600);
199 }
200 match options.open(&path) {
201 Ok(mut file) => {
202 ensure_private_file(&path)?;
203 write_record(&mut file, &record)?;
204 return Ok(Self {
205 id,
206 path,
207 cwd,
208 boot_system_prompt,
209 llm,
210 created_at,
211 updated_at: created_at,
212 messages: Vec::new(),
213 history: Vec::new(),
214 secret: secret.map(str::to_owned),
215 });
216 }
217 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
218 Err(_error) => return Err(SessionError::new("unable to create session file")),
219 }
220 }
221 Err(SessionError::new("unable to allocate a unique session id"))
222 }
223
224 pub fn resume(home: &Path, id: &str) -> Result<Self, SessionError> {
225 validate_session_id(id)?;
226 let directory = sessions_dir(home);
227 let lucy_directory = lucy_dir(home);
228 ensure_not_symlink(&lucy_directory)?;
229 if lucy_directory.is_dir() {
230 ensure_private_dir(&lucy_directory)?;
231 }
232 ensure_not_symlink(&directory)?;
233 if directory.is_dir() {
234 ensure_private_dir(&directory)?;
235 }
236 let path = directory.join(format!("{id}.jsonl"));
237 ensure_not_symlink(&path)?;
238 if !path.is_file() {
239 return Err(SessionError::new("session not found"));
240 }
241
242 ensure_private_file(&path)?;
243 let raw =
244 fs::read(&path).map_err(|_error| SessionError::new("unable to read session file"))?;
245 let active_secret = session_header_secret(&raw);
246 if let Some(secret) = active_secret.as_deref() {
247 if conflicts_with_protected_literal(secret) || bytes_contain_secret(&raw, secret) {
248 return Err(session_header_rejected(secret));
249 }
250 }
251
252 let reader = BufReader::new(raw.as_slice());
253 let mut header = None;
254 let mut messages = Vec::new();
255 let mut history = Vec::new();
256 let mut updated_at = None;
257
258 for (line_number, line) in reader.lines().enumerate() {
259 let line = line.map_err(|_error| {
260 session_error("unable to read session file", active_secret.as_deref())
261 })?;
262 if line.trim().is_empty() {
263 continue;
264 }
265 let value = parse_json_value(&line).map_err(|_error| {
266 session_error(
267 format!("invalid session record at line {}", line_number + 1),
268 active_secret.as_deref(),
269 )
270 })?;
271 if let Some(secret) = active_secret.as_deref() {
272 if json_value_contains_secret(&value, secret) {
273 return Err(session_header_rejected(secret));
274 }
275 }
276 let record: SessionRecord = serde_json::from_value(value).map_err(|_error| {
277 session_error(
278 format!("invalid session record at line {}", line_number + 1),
279 active_secret.as_deref(),
280 )
281 })?;
282 if let Some(secret) = active_secret.as_deref() {
283 if record_contains_secret(&record, secret) {
284 return Err(session_header_rejected(secret));
285 }
286 }
287 match record {
288 SessionRecord::Session {
289 version,
290 session_id,
291 created_at,
292 cwd,
293 boot_system_prompt,
294 llm,
295 } => {
296 if version != 1 || session_id != id || header.is_some() {
297 return Err(session_error(
298 "invalid session header",
299 active_secret.as_deref(),
300 ));
301 }
302 header = Some((created_at, cwd, boot_system_prompt, llm));
303 }
304 SessionRecord::Message { timestamp, message } => {
305 if header.is_none() {
306 return Err(session_error(
307 "session message precedes header",
308 active_secret.as_deref(),
309 ));
310 }
311 updated_at = Some(timestamp);
312 history.push(SessionHistoryRecord::Message {
313 timestamp,
314 message: message.clone(),
315 });
316 messages.push(message);
317 }
318 SessionRecord::Interruption {
319 timestamp,
320 reason,
321 phase,
322 assistant_text,
323 tool_calls,
324 tool_results,
325 } => {
326 if header.is_none() {
327 return Err(session_error(
328 "session interruption precedes header",
329 active_secret.as_deref(),
330 ));
331 }
332 updated_at = Some(timestamp);
333 history.push(SessionHistoryRecord::Interruption {
334 timestamp,
335 reason,
336 phase,
337 assistant_text,
338 tool_calls,
339 tool_results,
340 });
341 }
342 }
343 }
344
345 let Some((created_at, cwd, boot_system_prompt, llm)) = header else {
346 return Err(session_error(
347 "session has no header",
348 active_secret.as_deref(),
349 ));
350 };
351 let cwd = PathBuf::from(cwd);
352 Ok(Self {
353 id: id.to_owned(),
354 path,
355 cwd,
356 boot_system_prompt,
357 llm,
358 created_at,
359 updated_at: updated_at.unwrap_or(created_at),
360 messages,
361 history,
362 secret: active_secret,
363 })
364 }
365
366 pub fn append_message(&mut self, message: ChatMessage) -> Result<(), SessionError> {
367 let timestamp = now();
368 let record = SessionRecord::Message {
369 timestamp,
370 message: message.clone(),
371 };
372 if let Some(secret) = self.secret.as_deref() {
373 if record_contains_secret(&record, secret) {
374 return Err(session_record_rejected(secret));
375 }
376 }
377 let mut file = open_session_for_append(&self.path)?;
378 write_record(&mut file, &record)?;
379 self.messages.push(message.clone());
380 self.history
381 .push(SessionHistoryRecord::Message { timestamp, message });
382 self.updated_at = timestamp;
383 Ok(())
384 }
385
386 pub fn append_interruption(
387 &mut self,
388 mut interruption: InterruptionRecord,
389 ) -> Result<(), SessionError> {
390 let timestamp = now();
391 interruption.timestamp = timestamp;
392 let record = SessionRecord::Interruption {
393 timestamp,
394 reason: interruption.reason.clone(),
395 phase: interruption.phase.clone(),
396 assistant_text: interruption.assistant_text.clone(),
397 tool_calls: interruption.tool_calls.clone(),
398 tool_results: interruption.tool_results.clone(),
399 };
400 if let Some(secret) = self.secret.as_deref() {
401 if record_contains_secret(&record, secret) {
402 return Err(session_record_rejected(secret));
403 }
404 }
405 let mut file = open_session_for_append(&self.path)?;
406 write_record(&mut file, &record)?;
407 self.history.push(SessionHistoryRecord::Interruption {
408 timestamp,
409 reason: interruption.reason,
410 phase: interruption.phase,
411 assistant_text: interruption.assistant_text,
412 tool_calls: interruption.tool_calls,
413 tool_results: interruption.tool_results,
414 });
415 self.updated_at = timestamp;
416 Ok(())
417 }
418
419 pub fn provider_messages(&self) -> Vec<ChatMessage> {
420 let interruption_results = self
421 .history
422 .iter()
423 .filter_map(|record| match record {
424 SessionHistoryRecord::Interruption {
425 phase,
426 tool_results,
427 ..
428 } if phase == "cmd" => Some(tool_results),
429 _ => None,
430 })
431 .flatten()
432 .count();
433 let mut messages = Vec::with_capacity(self.messages.len() + 1 + interruption_results);
434 let mut declared_tool_calls = HashSet::new();
435 let mut completed_tool_calls = HashSet::new();
436 messages.push(ChatMessage::system(self.boot_system_prompt.clone()));
437 for record in &self.history {
438 match record {
439 SessionHistoryRecord::Message { message, .. } => {
440 if message.role == "assistant" {
441 declared_tool_calls
442 .extend(message.tool_calls.iter().map(|call| call.id.clone()));
443 }
444 if message.role == "tool" {
445 if let Some(id) = message.tool_call_id.as_deref() {
446 completed_tool_calls.insert(id.to_owned());
447 }
448 }
449 messages.push(message.clone());
450 }
451 SessionHistoryRecord::Interruption {
452 phase,
453 tool_results,
454 ..
455 } if phase == "cmd" => {
456 for observation in tool_results {
457 if !declared_tool_calls.contains(&observation.id)
458 || completed_tool_calls.contains(&observation.id)
459 {
460 continue;
461 }
462 let Ok(content) = serde_json::to_string(&observation.result) else {
463 continue;
464 };
465 messages.push(ChatMessage::tool(
466 observation.id.clone(),
467 observation.name.clone(),
468 content,
469 ));
470 completed_tool_calls.insert(observation.id.clone());
471 }
472 }
473 SessionHistoryRecord::Interruption { .. } => {}
474 }
475 }
476 messages
477 }
478
479 pub fn list(home: &Path) -> Result<Vec<SessionMetadata>, SessionError> {
480 let directory = sessions_dir(home);
481 let lucy_directory = lucy_dir(home);
482 ensure_not_symlink(&lucy_directory)?;
483 if lucy_directory.is_dir() {
484 ensure_private_dir(&lucy_directory)?;
485 }
486 ensure_not_symlink(&directory)?;
487 if directory.is_dir() {
488 ensure_private_dir(&directory)?;
489 }
490 let entries = match fs::read_dir(&directory) {
491 Ok(entries) => entries,
492 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
493 Err(_error) => return Err(SessionError::new("unable to list sessions")),
494 };
495
496 let mut paths = Vec::new();
497 for entry in entries {
498 let entry = entry?;
499 let path = entry.path();
500 let metadata = match fs::symlink_metadata(&path) {
501 Ok(metadata) => metadata,
502 Err(_) => continue,
503 };
504 if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl")
505 && metadata.is_file()
506 {
507 paths.push(path);
508 }
509 }
510 paths.sort();
511
512 let mut metadata = Vec::new();
513 for path in paths {
514 let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
515 continue;
516 };
517 let Ok(session) = Self::resume(home, id) else {
518 continue;
519 };
520 metadata.push(SessionMetadata {
521 record_type: "session_metadata",
522 session_id: session.id,
523 created_at: session.created_at,
524 updated_at: session.updated_at,
525 first_message: session.messages.first().map(safe_message_summary),
526 last_message: session.messages.last().map(safe_message_summary),
527 });
528 }
529 Ok(metadata)
530 }
531}
532
533struct DuplicateKeyValue(Value);
534
535impl<'de> Deserialize<'de> for DuplicateKeyValue {
536 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
537 where
538 D: Deserializer<'de>,
539 {
540 deserializer
541 .deserialize_any(DuplicateKeyValueVisitor)
542 .map(Self)
543 }
544}
545
546struct DuplicateKeyValueSeed;
547
548impl<'de> DeserializeSeed<'de> for DuplicateKeyValueSeed {
549 type Value = Value;
550
551 fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
552 where
553 D: Deserializer<'de>,
554 {
555 deserializer.deserialize_any(DuplicateKeyValueVisitor)
556 }
557}
558
559struct DuplicateKeyValueVisitor;
560
561impl<'de> Visitor<'de> for DuplicateKeyValueVisitor {
562 type Value = Value;
563
564 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565 formatter.write_str("a valid JSON value")
566 }
567
568 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
569 Ok(Value::Bool(value))
570 }
571
572 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
573 Ok(Value::Number(value.into()))
574 }
575
576 fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
577 where
578 E: de::Error,
579 {
580 serde_json::Number::from_i128(value)
581 .map(Value::Number)
582 .ok_or_else(|| de::Error::custom("JSON number out of range"))
583 }
584
585 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
586 Ok(Value::Number(value.into()))
587 }
588
589 fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
590 where
591 E: de::Error,
592 {
593 serde_json::Number::from_u128(value)
594 .map(Value::Number)
595 .ok_or_else(|| de::Error::custom("JSON number out of range"))
596 }
597
598 fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
599 where
600 E: de::Error,
601 {
602 Ok(serde_json::Number::from_f64(value).map_or(Value::Null, Value::Number))
603 }
604
605 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
606 Ok(Value::String(value.to_owned()))
607 }
608
609 fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
610 Ok(Value::String(value))
611 }
612
613 fn visit_none<E>(self) -> Result<Self::Value, E> {
614 Ok(Value::Null)
615 }
616
617 fn visit_unit<E>(self) -> Result<Self::Value, E> {
618 Ok(Value::Null)
619 }
620
621 fn visit_seq<A>(self, mut access: A) -> Result<Self::Value, A::Error>
622 where
623 A: SeqAccess<'de>,
624 {
625 let mut values = Vec::new();
626 while let Some(value) = access.next_element_seed(DuplicateKeyValueSeed)? {
627 values.push(value);
628 }
629 Ok(Value::Array(values))
630 }
631
632 fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
633 where
634 A: MapAccess<'de>,
635 {
636 let mut values = serde_json::Map::new();
637 while let Some(key) = access.next_key::<String>()? {
638 if values.contains_key(&key) {
639 return Err(de::Error::custom("duplicate object key"));
640 }
641 let value = access.next_value_seed(DuplicateKeyValueSeed)?;
642 values.insert(key, value);
643 }
644 Ok(Value::Object(values))
645 }
646}
647
648fn parse_json_value(line: &str) -> Result<Value, serde_json::Error> {
649 serde_json::from_str::<DuplicateKeyValue>(line).map(|value| value.0)
650}
651
652fn session_header_secret(raw: &[u8]) -> Option<String> {
653 let line = raw
654 .split(|byte| *byte == b'\n')
655 .find(|line| !line.iter().all(|byte| byte.is_ascii_whitespace()))?;
656 let value = parse_json_value(std::str::from_utf8(line).ok()?).ok()?;
657 let api_key_env = value
658 .get("llm")
659 .and_then(|llm| llm.get("api_key_env"))
660 .and_then(Value::as_str)?;
661 let secret = std::env::var(api_key_env).ok()?;
662 (!secret.is_empty()).then_some(secret)
663}
664
665fn bytes_contain_secret(raw: &[u8], secret: &str) -> bool {
666 let secret = secret.as_bytes();
667 !secret.is_empty() && raw.windows(secret.len()).any(|window| window == secret)
668}
669
670fn record_contains_secret(record: &SessionRecord, secret: &str) -> bool {
671 if secret.is_empty() {
672 return false;
673 }
674 if serde_json::to_vec(record)
675 .ok()
676 .is_some_and(|serialized| bytes_contain_secret(&serialized, secret))
677 {
678 return true;
679 }
680 match record {
681 SessionRecord::Session {
682 version,
683 session_id,
684 created_at,
685 cwd,
686 boot_system_prompt,
687 llm,
688 } => {
689 version.to_string().contains(secret)
690 || session_id.contains(secret)
691 || created_at.to_string().contains(secret)
692 || cwd.contains(secret)
693 || boot_system_prompt.contains(secret)
694 || llm.base_url.contains(secret)
695 || llm.model.contains(secret)
696 || llm.api_key_env.contains(secret)
697 }
698 SessionRecord::Message { timestamp, message } => {
699 timestamp.to_string().contains(secret) || message_contains_secret(message, secret)
700 }
701 SessionRecord::Interruption {
702 timestamp,
703 reason,
704 phase,
705 assistant_text,
706 tool_calls,
707 tool_results,
708 } => {
709 timestamp.to_string().contains(secret)
710 || reason.contains(secret)
711 || phase.contains(secret)
712 || assistant_text.contains(secret)
713 || tool_calls.iter().any(|call| {
714 call.id.contains(secret)
715 || call.name.contains(secret)
716 || call.arguments.contains(secret)
717 })
718 || tool_results.iter().any(|observation| {
719 observation.id.contains(secret)
720 || observation.name.contains(secret)
721 || json_value_contains_secret(&observation.result, secret)
722 })
723 }
724 }
725}
726
727fn message_contains_secret(message: &ChatMessage, secret: &str) -> bool {
728 message.role.contains(secret)
729 || message
730 .content
731 .as_deref()
732 .is_some_and(|content| content.contains(secret))
733 || message.reasoning_details.as_ref().is_some_and(|details| {
734 details
735 .iter()
736 .any(|detail| json_value_contains_secret(detail, secret))
737 })
738 || message
739 .name
740 .as_deref()
741 .is_some_and(|name| name.contains(secret))
742 || message
743 .tool_call_id
744 .as_deref()
745 .is_some_and(|id| id.contains(secret))
746 || message.tool_calls.iter().any(|call| {
747 call.id.contains(secret)
748 || call.name.contains(secret)
749 || call.arguments.contains(secret)
750 || tool_arguments_contain_secret(&call.arguments, secret)
751 })
752}
753
754fn tool_arguments_contain_secret(arguments: &str, secret: &str) -> bool {
755 serde_json::from_str::<Value>(arguments)
756 .ok()
757 .is_some_and(|value| json_value_contains_secret(&value, secret))
758}
759
760fn json_value_contains_secret(value: &Value, secret: &str) -> bool {
761 match value {
762 Value::String(text) => text.contains(secret),
763 Value::Array(values) => values
764 .iter()
765 .any(|value| json_value_contains_secret(value, secret)),
766 Value::Object(object) => {
767 object.keys().any(|key| key.contains(secret))
768 || object
769 .values()
770 .any(|value| json_value_contains_secret(value, secret))
771 }
772 Value::Number(number) => number.to_string().contains(secret),
773 Value::Bool(_) | Value::Null => false,
774 }
775}
776
777fn session_error(message: impl Into<String>, secret: Option<&str>) -> SessionError {
778 let message = message.into();
779 SessionError::new(redact_secret(&message, secret))
780}
781
782fn session_header_rejected(secret: &str) -> SessionError {
783 session_error("session header rejected", Some(secret))
784}
785
786fn session_record_rejected(secret: &str) -> SessionError {
787 session_error("session record rejected", Some(secret))
788}
789
790fn open_session_for_append(path: &Path) -> Result<File, SessionError> {
791 let mut options = OpenOptions::new();
792 options.write(true).append(true);
793 #[cfg(unix)]
794 options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
795 #[cfg(not(unix))]
796 ensure_not_symlink(path)?;
797
798 let file = options.open(path)?;
799 let metadata = file.metadata()?;
800 if !metadata.is_file() {
801 return Err(SessionError::new(
802 "session file is not a regular private file",
803 ));
804 }
805 #[cfg(unix)]
806 if metadata.permissions().mode() & 0o777 != 0o600 {
807 return Err(SessionError::new(
808 "session file is not a regular private file",
809 ));
810 }
811 Ok(file)
812}
813
814fn write_record(file: &mut File, record: &SessionRecord) -> Result<(), SessionError> {
815 let line = serde_json::to_string(record)
816 .map_err(|error| SessionError::new(format!("unable to encode session record: {error}")))?;
817 file.write_all(line.as_bytes())?;
818 file.write_all(b"\n")?;
819 file.flush()?;
820 Ok(())
821}
822
823fn safe_message_summary(message: &ChatMessage) -> String {
824 let role = message.role.as_str();
825 let text = message
826 .content
827 .as_deref()
828 .or_else(|| message.tool_calls.first().map(|call| call.name.as_str()))
829 .unwrap_or("");
830 let mut summary = text.chars().take(120).collect::<String>();
831 if text.chars().count() > 120 {
832 summary.push('…');
833 }
834 format!("{role}: {summary}")
835}
836
837pub fn sessions_dir(home: &Path) -> PathBuf {
838 home.join(".lucy").join("sessions")
839}
840
841pub fn validate_session_id(id: &str) -> Result<(), SessionError> {
842 if id.is_empty()
843 || !id.chars().all(|character| {
844 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
845 })
846 {
847 return Err(SessionError::new("session id contains invalid characters"));
848 }
849 Ok(())
850}
851
852fn new_session_id() -> String {
853 let timestamp = now();
854 let counter = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
855 format!("{timestamp}-{}-{counter}", std::process::id())
856}
857
858fn now() -> u64 {
859 SystemTime::now()
860 .duration_since(UNIX_EPOCH)
861 .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64)
862 .unwrap_or(0)
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868 use crate::config::LlmSettings;
869 #[cfg(unix)]
870 use std::ffi::CString;
871 #[cfg(unix)]
872 use std::os::unix::ffi::OsStrExt;
873 #[cfg(unix)]
874 use std::os::unix::fs::{symlink, PermissionsExt};
875 use std::sync::atomic::{AtomicU64, Ordering};
876 use std::time::{SystemTime, UNIX_EPOCH};
877
878 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
879
880 fn temporary_home() -> PathBuf {
881 loop {
882 let stamp = SystemTime::now()
883 .duration_since(UNIX_EPOCH)
884 .expect("clock")
885 .as_nanos();
886 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
887 let path = std::env::temp_dir().join(format!(
888 "lucy-session-{stamp}-{}-{counter}",
889 std::process::id()
890 ));
891 match fs::create_dir(&path) {
892 Ok(()) => return path,
893 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
894 Err(error) => panic!("temp home: {error}"),
895 }
896 }
897 }
898
899 #[cfg(unix)]
900 #[test]
901 fn append_rejects_a_non_private_opened_session_file_without_chmod() {
902 let home = temporary_home();
903 let cwd = std::env::current_dir().expect("cwd");
904 let llm = LlmSettings {
905 base_url: "http://localhost".to_owned(),
906 model: "model".to_owned(),
907 api_key_env: "LUCY_APPEND_TEST_KEY".to_owned(),
908 effort: None,
909 };
910 let mut session =
911 Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
912 fs::set_permissions(&session.path, fs::Permissions::from_mode(0o644))
913 .expect("make session group-readable");
914
915 let error = session
916 .append_message(ChatMessage::user("must not append".to_owned()))
917 .expect_err("unsafe permissions should be rejected");
918 assert!(error.to_string().contains("private"));
919 assert_eq!(
920 fs::metadata(&session.path)
921 .expect("session metadata")
922 .permissions()
923 .mode()
924 & 0o777,
925 0o644
926 );
927
928 fs::remove_dir_all(home).expect("remove temp home");
929 }
930
931 #[cfg(unix)]
932 #[test]
933 fn append_rejects_a_symlinked_session_path() {
934 let home = temporary_home();
935 let cwd = std::env::current_dir().expect("cwd");
936 let llm = LlmSettings {
937 base_url: "http://localhost".to_owned(),
938 model: "model".to_owned(),
939 api_key_env: "LUCY_APPEND_LINK_KEY".to_owned(),
940 effort: None,
941 };
942 let mut session =
943 Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
944 let target = home.join("append-target.jsonl");
945 fs::write(&target, "target\n").expect("target file");
946 fs::remove_file(&session.path).expect("remove session path");
947 symlink(&target, &session.path).expect("session symlink");
948
949 session
950 .append_message(ChatMessage::user("must not append".to_owned()))
951 .expect_err("symlink should be rejected");
952 assert_eq!(
953 fs::read_to_string(&target).expect("target contents"),
954 "target\n"
955 );
956
957 fs::remove_dir_all(home).expect("remove temp home");
958 }
959
960 #[cfg(unix)]
961 #[test]
962 fn append_rejects_a_fifo_without_blocking() {
963 let home = temporary_home();
964 let cwd = std::env::current_dir().expect("cwd");
965 let llm = LlmSettings {
966 base_url: "http://localhost".to_owned(),
967 model: "model".to_owned(),
968 api_key_env: "LUCY_APPEND_FIFO_KEY".to_owned(),
969 effort: None,
970 };
971 let mut session =
972 Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
973 fs::remove_file(&session.path).expect("remove session path");
974 let fifo_path = CString::new(session.path.as_os_str().as_bytes()).expect("FIFO path");
975 let result = unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) };
976 assert_eq!(result, 0, "mkfifo: {:?}", io::Error::last_os_error());
977
978 session
979 .append_message(ChatMessage::user("must not append".to_owned()))
980 .expect_err("FIFO should be rejected without a blocking open");
981
982 fs::remove_dir_all(home).expect("remove temp home");
983 }
984
985 #[cfg(unix)]
986 #[test]
987 fn rejects_symlinked_session_files_and_directories() {
988 let home = temporary_home();
989 let directory = home.join(".lucy/sessions");
990 fs::create_dir_all(&directory).expect("sessions directory");
991 let target = home.join("session-target.jsonl");
992 fs::write(&target, "not a session\n").expect("target session");
993 let path = directory.join("linked.jsonl");
994 symlink(&target, &path).expect("session symlink");
995 assert!(Session::resume(&home, "linked").is_err());
996 assert!(Session::list(&home).expect("list sessions").is_empty());
997 fs::remove_file(path).expect("remove session symlink");
998 fs::remove_file(target).expect("remove target session");
999 fs::remove_dir_all(home).expect("remove temp home");
1000
1001 let home = temporary_home();
1002 let lucy = home.join(".lucy");
1003 fs::create_dir(&lucy).expect("Lucy directory");
1004 let target = home.join("sessions-target");
1005 fs::create_dir(&target).expect("target sessions directory");
1006 symlink(&target, lucy.join("sessions")).expect("sessions directory symlink");
1007 assert!(Session::list(&home).is_err());
1008 fs::remove_file(lucy.join("sessions")).expect("remove sessions directory symlink");
1009 fs::remove_dir(target).expect("remove target sessions directory");
1010 fs::remove_dir(lucy).expect("remove Lucy directory");
1011 fs::remove_dir(home).expect("remove temp home");
1012 }
1013
1014 #[test]
1015 fn resume_rejects_duplicate_header_as_an_invalid_record() {
1016 let home = temporary_home();
1017 let sessions = home.join(".lucy/sessions");
1018 fs::create_dir_all(&sessions).expect("sessions");
1019 let id = "duplicate-header";
1020 let environment = format!("LUCY_DUPLICATE_HEADER_{}", std::process::id());
1021 let secret = "provider-secret";
1022 std::env::set_var(&environment, secret);
1023 let header = format!(
1024 r#"{{"record":"session","version":1,"session_id":"{id}","created_at":1,"cwd":".","boot_system_prompt":"{secret}","boot_system_prompt":"safe","llm":{{"base_url":"http://localhost","model":"model","api_key_env":"{environment}"}}}}"#
1025 );
1026 fs::write(sessions.join(format!("{id}.jsonl")), format!("{header}\n"))
1027 .expect("duplicate header");
1028
1029 let error = Session::resume(&home, id).expect_err("duplicate header should be rejected");
1030 assert_eq!(error.to_string(), "invalid session record at line 1");
1031
1032 std::env::remove_var(environment);
1033 fs::remove_dir_all(home).expect("cleanup");
1034 }
1035
1036 #[test]
1037 fn creates_appends_resumes_and_lists_jsonl_session() {
1038 let home = temporary_home();
1039 let cwd = std::env::current_dir().expect("cwd");
1040 let llm = LlmSettings {
1041 base_url: "http://localhost:1234/api/v1".to_owned(),
1042 model: "test-model".to_owned(),
1043 api_key_env: "TEST_KEY".to_owned(),
1044 effort: None,
1045 };
1046 let mut session =
1047 Session::create(&home, &cwd, "stable prompt".to_owned(), llm.clone()).expect("create");
1048 #[cfg(unix)]
1049 {
1050 use std::os::unix::fs::PermissionsExt;
1051 assert_eq!(
1052 fs::metadata(sessions_dir(&home))
1053 .expect("sessions directory metadata")
1054 .permissions()
1055 .mode()
1056 & 0o777,
1057 0o700
1058 );
1059 assert_eq!(
1060 fs::metadata(&session.path)
1061 .expect("session file metadata")
1062 .permissions()
1063 .mode()
1064 & 0o777,
1065 0o600
1066 );
1067 }
1068 let id = session.id.clone();
1069 session
1070 .append_message(ChatMessage::user("first".to_owned()))
1071 .expect("append user");
1072 session
1073 .append_message(ChatMessage::assistant("last".to_owned(), Vec::new()))
1074 .expect("append assistant");
1075
1076 let resumed = Session::resume(&home, &id).expect("resume");
1077 assert_eq!(resumed.boot_system_prompt, "stable prompt");
1078 assert_eq!(resumed.llm, llm);
1079 assert_eq!(resumed.messages.len(), 2);
1080 assert_eq!(resumed.cwd, fs::canonicalize(cwd).expect("canonical cwd"));
1081 let listed = Session::list(&home).expect("list");
1082 assert_eq!(listed.len(), 1);
1083 assert_eq!(listed[0].session_id, id);
1084 assert!(listed[0]
1085 .first_message
1086 .as_deref()
1087 .is_some_and(|summary| summary.contains("first")));
1088 assert!(Session::resume(&home, "missing").is_err());
1089
1090 let file = fs::read_to_string(resumed.path).expect("session file");
1091 assert!(file.lines().count() >= 3);
1092 assert!(!file.contains("TEST_KEY_VALUE"));
1093 fs::remove_dir_all(home).expect("remove temp home");
1094 }
1095
1096 #[test]
1097 fn reasoning_details_round_trip_through_session_and_provider_history() {
1098 let home = temporary_home();
1099 let cwd = std::env::current_dir().expect("cwd");
1100 let llm = LlmSettings {
1101 base_url: "http://localhost".to_owned(),
1102 model: "model".to_owned(),
1103 api_key_env: "LUCY_REASONING_DETAILS_KEY".to_owned(),
1104 effort: None,
1105 };
1106 let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
1107 .expect("create");
1108 let details = vec![serde_json::json!({
1109 "type": "reasoning.text",
1110 "text": "provider detail"
1111 })];
1112 let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
1113 assistant.reasoning_details = Some(details.clone());
1114 session.append_message(assistant).expect("assistant");
1115
1116 let resumed = Session::resume(&home, &session.id).expect("resume");
1117 assert_eq!(resumed.messages[0].reasoning_details, Some(details.clone()));
1118 let provider_assistant = resumed
1119 .provider_messages()
1120 .into_iter()
1121 .find(|message| message.role == "assistant")
1122 .expect("provider assistant");
1123 assert_eq!(provider_assistant.reasoning_details, Some(details));
1124 fs::remove_dir_all(home).expect("remove temp home");
1125 }
1126
1127 #[test]
1128 fn append_rejects_secrets_nested_in_reasoning_details() {
1129 let home = temporary_home();
1130 let cwd = std::env::current_dir().expect("cwd");
1131 let llm = LlmSettings {
1132 base_url: "http://localhost".to_owned(),
1133 model: "model".to_owned(),
1134 api_key_env: "LUCY_REASONING_SECRET_KEY".to_owned(),
1135 effort: None,
1136 };
1137 let mut session = Session::create_with_secret(
1138 &home,
1139 &cwd,
1140 "prompt".to_owned(),
1141 llm,
1142 Some("provider-secret"),
1143 )
1144 .expect("create");
1145 let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
1146 assistant.reasoning_details = Some(vec![serde_json::json!({
1147 "type": "reasoning.text",
1148 "text": "provider-secret"
1149 })]);
1150 let error = session
1151 .append_message(assistant)
1152 .expect_err("secret reasoning details");
1153 assert_eq!(error.to_string(), "session record rejected");
1154 fs::remove_dir_all(home).expect("remove temp home");
1155 }
1156
1157 #[test]
1158 fn interruption_records_are_valid_and_resume_in_file_order_without_provider_fragments() {
1159 let home = temporary_home();
1160 let cwd = std::env::current_dir().expect("cwd");
1161 let llm = LlmSettings {
1162 base_url: "http://localhost".to_owned(),
1163 model: "model".to_owned(),
1164 api_key_env: "LUCY_NO_SESSION_KEY".to_owned(),
1165 effort: None,
1166 };
1167 let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
1168 .expect("create");
1169 session
1170 .append_message(ChatMessage::user("hello".to_owned()))
1171 .expect("user");
1172 session
1173 .append_interruption(InterruptionRecord {
1174 timestamp: 0,
1175 reason: "user_cancelled".to_owned(),
1176 phase: "provider_stream".to_owned(),
1177 assistant_text: "partial answer".to_owned(),
1178 tool_calls: vec![ChatToolCall {
1179 id: "partial-call".to_owned(),
1180 name: "cmd".to_owned(),
1181 arguments: "{\"command\":".to_owned(),
1182 }],
1183 tool_results: Vec::new(),
1184 })
1185 .expect("interruption");
1186
1187 session
1188 .append_message(ChatMessage::assistant(
1189 String::new(),
1190 vec![ChatToolCall {
1191 id: "call-1".to_owned(),
1192 name: "cmd".to_owned(),
1193 arguments: r#"{"command":"sleep 1"}"#.to_owned(),
1194 }],
1195 ))
1196 .expect("assistant tool call");
1197 session
1198 .append_interruption(InterruptionRecord {
1199 timestamp: 0,
1200 reason: "user_cancelled".to_owned(),
1201 phase: "cmd".to_owned(),
1202 assistant_text: String::new(),
1203 tool_calls: Vec::new(),
1204 tool_results: vec![SessionToolResult {
1205 id: "call-1".to_owned(),
1206 name: "cmd".to_owned(),
1207 result: serde_json::json!({"canceled": true}),
1208 }],
1209 })
1210 .expect("command interruption");
1211
1212 let raw = fs::read_to_string(&session.path).expect("session JSONL");
1213 for line in raw.lines() {
1214 serde_json::from_str::<Value>(line).expect("valid JSONL record");
1215 }
1216 let resumed = Session::resume(&home, &session.id).expect("resume");
1217 assert_eq!(resumed.history.len(), 4);
1218 assert!(matches!(
1219 resumed.history[0],
1220 SessionHistoryRecord::Message { .. }
1221 ));
1222 assert!(matches!(
1223 resumed.history[1],
1224 SessionHistoryRecord::Interruption { .. }
1225 ));
1226 assert_eq!(resumed.messages.len(), 2);
1227 let provider_messages = resumed.provider_messages();
1228 assert_eq!(provider_messages.len(), 4);
1229 assert!(provider_messages.iter().any(|message| {
1230 message.role == "tool" && message.tool_call_id.as_deref() == Some("call-1")
1231 }));
1232 assert!(!resumed.provider_messages().iter().any(|message| {
1233 message
1234 .tool_calls
1235 .iter()
1236 .any(|call| call.id == "partial-call")
1237 }));
1238 fs::remove_dir_all(home).expect("remove temp home");
1239 }
1240}