Skip to main content

lucy/
session.rs

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/// A bounded, secret-safe observation retained for a canceled tool call.
78#[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/// The safe observations written when a user stops an active turn.
86#[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/// The ordered, replayable records after a session header.
101#[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}