use super::manager::{Session, SessionManager};
#[cfg(unix)]
use super::{
read::validate_session_id,
store::{
SESSION_FILE_MODE, open_existing_named, open_existing_primary, primary_path,
validate_session_root,
},
};
use crate::cancellation::AgentCancellation;
#[cfg(unix)]
use crate::persistence::CrossProcessFileLock;
#[cfg(unix)]
use crate::subagents::SubagentsOutput;
#[cfg(unix)]
use crate::{hex::lower_hex, output::sanitize_display_text};
#[cfg(unix)]
use serde::Serialize;
#[cfg(unix)]
use serde_json::Value;
#[cfg(unix)]
use sha2::{Digest, Sha256};
#[cfg(unix)]
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::{
collections::BTreeSet,
fs::{self, File, OpenOptions},
io::{self, BufRead, BufReader, Read, Seek, SeekFrom, Write},
sync::atomic::{AtomicU64, Ordering},
};
#[cfg(unix)]
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
#[cfg(unix)]
pub(crate) const SESSION_EXPORT_MAX_MEMBERS: usize = 10_000;
#[cfg(unix)]
pub(crate) const SESSION_EXPORT_MAX_SOURCE_BYTES: u64 = 1024 * 1024 * 1024;
#[cfg(unix)]
const SESSION_EXPORT_MAX_WARNINGS: usize = 64;
#[cfg(unix)]
const SESSION_EXPORT_MAX_WARNING_CHARS: usize = 500;
#[cfg(unix)]
const SESSION_EXPORT_MAX_DISCOVERY_LINE_BYTES: usize = 8 * 1024 * 1024;
#[cfg(unix)]
const SESSION_EXPORT_TEMP_ATTEMPTS: usize = 32;
#[cfg(unix)]
static NEXT_EXPORT_TEMP: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionExportReport {
pub(crate) destination: PathBuf,
pub(crate) member_count: usize,
pub(crate) source_bytes: u64,
pub(crate) warnings: Vec<String>,
}
#[cfg(unix)]
struct ExportManifest {
root_session_id: String,
members: Vec<SourceMember>,
relationships: BTreeSet<ExportRelationship>,
source_bytes: u64,
warnings: WarningCollector,
locks: Vec<CrossProcessFileLock>,
graph_nodes: usize,
}
#[cfg(unix)]
const SESSION_EXPORT_MAX_LOCKS: usize = 64;
#[cfg(unix)]
impl ExportManifest {
fn charge_graph_node(&mut self) -> anyhow::Result<()> {
self.graph_nodes = self.graph_nodes.saturating_add(1);
if self.graph_nodes > SESSION_EXPORT_MAX_MEMBERS {
anyhow::bail!(
"session export graph limit exceeded: maximum {} nodes",
SESSION_EXPORT_MAX_MEMBERS
)
}
Ok(())
}
fn reserve_lock(&self, count: usize) -> anyhow::Result<()> {
if self.locks.len().saturating_add(count) > SESSION_EXPORT_MAX_LOCKS {
anyhow::bail!(
"session export lock limit exceeded: maximum {SESSION_EXPORT_MAX_LOCKS} locks"
)
}
Ok(())
}
fn lock_session(
&mut self,
path: &Path,
cancellation: &AgentCancellation,
) -> anyhow::Result<()> {
self.reserve_lock(2)?;
validate_lock_target(path)?;
let active = super::manager::active_lease_target(path);
let deadline = std::time::Instant::now() + crate::persistence::LOCK_WAIT_TIMEOUT;
self.locks
.push(CrossProcessFileLock::acquire_until_cancellable(
&active,
deadline,
|| cancellation.is_canceled(),
)?);
cancellation.check()?;
let deadline = std::time::Instant::now() + crate::persistence::LOCK_WAIT_TIMEOUT;
self.locks
.push(CrossProcessFileLock::acquire_until_cancellable(
path,
deadline,
|| cancellation.is_canceled(),
)?);
validate_lock_target(path)?;
Ok(())
}
fn lock_jsonl(&mut self, path: &Path, cancellation: &AgentCancellation) -> anyhow::Result<()> {
self.reserve_lock(1)?;
validate_lock_target(path)?;
let deadline = std::time::Instant::now() + crate::persistence::LOCK_WAIT_TIMEOUT;
self.locks
.push(CrossProcessFileLock::acquire_until_cancellable(
path,
deadline,
|| cancellation.is_canceled(),
)?);
validate_lock_target(path)?;
Ok(())
}
}
#[cfg(unix)]
fn validate_lock_target(path: &Path) -> anyhow::Result<()> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
anyhow::bail!("session export source is unsafe or not a regular file")
}
Ok(())
}
#[cfg(unix)]
struct SourceMember {
archive_name: String,
file: File,
length: u64,
sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[cfg(unix)]
struct ExportRelationship {
#[serde(rename = "type")]
relationship_type: &'static str,
parent_session_id: String,
child_session_id: String,
}
#[derive(Serialize)]
#[cfg(unix)]
struct ExportManifestDocument {
schema: &'static str,
schema_version: u8,
root_session_id: String,
member_count: usize,
source_bytes: u64,
members: Vec<ExportManifestMember>,
relationships: Vec<ExportRelationship>,
completeness: ExportCompleteness,
}
#[derive(Serialize)]
#[cfg(unix)]
struct ExportManifestMember {
path: String,
bytes: u64,
sha256: String,
}
#[derive(Serialize)]
#[cfg(unix)]
struct ExportCompleteness {
complete: bool,
omissions: Vec<String>,
}
#[derive(Default, Clone)]
#[cfg(unix)]
struct WarningCollector {
warnings: Vec<String>,
omitted: usize,
}
#[cfg(unix)]
impl WarningCollector {
fn push(&mut self, message: impl AsRef<str>) {
let message = bounded_warning(message.as_ref());
if self.warnings.len() < SESSION_EXPORT_MAX_WARNINGS {
self.warnings.push(message);
} else {
self.omitted = self.omitted.saturating_add(1);
}
}
fn finish(&mut self) -> Vec<String> {
if self.omitted > 0 {
let omitted = bounded_warning(&format!(
"omitted {} additional session export warnings",
self.omitted
));
if self.warnings.len() == SESSION_EXPORT_MAX_WARNINGS {
self.warnings.pop();
}
self.warnings.push(omitted);
self.omitted = 0;
}
std::mem::take(&mut self.warnings)
}
}
#[cfg(unix)]
fn bounded_warning(message: &str) -> String {
let sanitized = sanitize_display_text(message);
let mut chars = sanitized.chars();
let mut bounded = chars
.by_ref()
.take(SESSION_EXPORT_MAX_WARNING_CHARS)
.collect::<String>();
if chars.next().is_some() {
bounded.push('…');
}
bounded
}
#[cfg(unix)]
struct ValidatedDestination {
path: PathBuf,
file_name: std::ffi::OsString,
directory: File,
}
#[cfg(unix)]
struct TemporaryArchiveGuard {
directory: File,
name: Option<std::ffi::OsString>,
}
#[cfg(unix)]
impl TemporaryArchiveGuard {
fn new(directory: &File, name: std::ffi::OsString) -> anyhow::Result<Self> {
Ok(Self {
directory: directory.try_clone()?,
name: Some(name),
})
}
fn disarm(&mut self) {
self.name = None;
}
fn cleanup(&mut self) -> anyhow::Result<()> {
let Some(name) = self.name.take() else {
return Ok(());
};
unlink_at(&self.directory, &name)
}
}
#[cfg(unix)]
impl Drop for TemporaryArchiveGuard {
fn drop(&mut self) {
let _ = self.cleanup();
}
}
pub(crate) fn export_session(
manager: &SessionManager,
session: &Session,
destination: &Path,
) -> anyhow::Result<SessionExportReport> {
export_session_with_cancellation(manager, session, destination, &AgentCancellation::default())
}
pub(crate) fn export_session_with_cancellation(
manager: &SessionManager,
session: &Session,
destination: &Path,
cancellation: &AgentCancellation,
) -> anyhow::Result<SessionExportReport> {
#[cfg(not(unix))]
{
let _ = (manager, session, destination, cancellation);
anyhow::bail!(
"session export is Unix-only because private permissions cannot be guaranteed"
)
}
#[cfg(unix)]
{
cancellation.check()?;
let root = &manager.root;
validate_session_root(root)?;
validate_session_id(session.id.clone())?;
let expected_primary = primary_path(root, &session.id)?;
if session.path != expected_primary {
anyhow::bail!("session has an unexpected primary path")
}
let destination = validate_destination(destination)?;
let mut manifest = ExportManifest {
root_session_id: session.id.clone(),
members: Vec::new(),
relationships: BTreeSet::new(),
source_bytes: 0,
warnings: WarningCollector::default(),
locks: Vec::new(),
graph_nodes: 0,
};
session.ensure_active_lease()?;
manifest.lock_jsonl(&expected_primary, cancellation)?;
let primary = open_existing_primary(root, &session.id)?
.ok_or_else(|| anyhow::anyhow!("session JSONL is missing"))?;
let primary_length = checked_source_length(primary.metadata()?.len())?;
let primary_children = discover_children(
&primary,
primary_length,
root,
&session.id,
&mut manifest,
cancellation,
)?;
add_member(
&mut manifest,
format!("{}.jsonl", session.id),
primary,
cancellation,
)?;
add_optional_member(
&mut manifest,
root,
&format!("{}.metadata.json", session.id),
format!("{}.metadata.json", session.id),
true,
cancellation,
)?;
let history_children = collect_history(
&mut manifest,
root,
&session.id,
".history",
true,
cancellation,
)?;
let child_root = root.join("subagents");
let mut pending = primary_children;
pending.extend(history_children);
let mut processed = BTreeSet::new();
while let Some(child_id) = pending.pop_first() {
cancellation.check()?;
manifest.charge_graph_node()?;
if !processed.insert(child_id.clone()) {
continue;
}
let Some(child_children) =
collect_child(&mut manifest, &child_root, root, &child_id, cancellation)?
else {
continue;
};
pending.extend(child_children);
}
cancellation.check()?;
let member_count = manifest.members.len();
let source_bytes = manifest.source_bytes;
let mut warnings = manifest.warnings.finish();
write_archive(
&mut manifest,
&warnings.clone(),
&destination,
cancellation,
&mut warnings,
)?;
Ok(SessionExportReport {
destination: destination.path,
member_count,
source_bytes,
warnings,
})
}
}
pub(crate) fn export_destination(root: &Path, session_id: &str) -> anyhow::Result<PathBuf> {
#[cfg(not(unix))]
{
let _ = (root, session_id);
anyhow::bail!(
"session export is Unix-only because private permissions cannot be guaranteed"
)
}
#[cfg(unix)]
{
validate_session_id(session_id.to_string())?;
let directory = prepare_export_directory(root)?;
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
for suffix in 0..SESSION_EXPORT_TEMP_ATTEMPTS {
let name = if suffix == 0 {
format!("{session_id}-{stamp}.zip")
} else {
format!("{session_id}-{stamp}-{suffix}.zip")
};
let path = directory.join(name);
if !path.exists() {
return Ok(path);
}
}
anyhow::bail!("could not allocate a unique session export destination")
}
}
#[cfg(unix)]
pub(crate) fn prepare_export_directory(root: &Path) -> anyhow::Result<PathBuf> {
ensure_export_root(root)?;
let exports = root.join("exports");
match fs::symlink_metadata(&exports) {
Ok(_) => validate_export_directory(&exports)?,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
use std::os::unix::fs::DirBuilderExt;
let mut builder = fs::DirBuilder::new();
builder.mode(0o700);
builder.create(&exports)?;
validate_export_directory(&exports)?;
}
Err(error) => return Err(error.into()),
}
Ok(exports)
}
#[cfg(unix)]
fn ensure_export_root(root: &Path) -> anyhow::Result<()> {
match fs::symlink_metadata(root) {
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
fs::create_dir_all(root)?;
}
Err(error) => return Err(error.into()),
}
let metadata = fs::symlink_metadata(root)?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
anyhow::bail!("session export storage root must be a non-symlink directory")
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let user_id = unsafe { libc::geteuid() };
if metadata.uid() != user_id {
anyhow::bail!("session export storage root owner is not current user")
}
if metadata.mode() & 0o022 != 0 {
anyhow::bail!("session export storage root is writable by group or other users")
}
}
Ok(())
}
#[cfg(unix)]
fn validate_export_directory(path: &Path) -> anyhow::Result<()> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
anyhow::bail!("session export directory must be a non-symlink directory")
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let user_id = unsafe { libc::geteuid() };
if metadata.uid() != user_id {
anyhow::bail!("session export directory owner is not current user")
}
if metadata.mode() & 0o077 != 0 {
anyhow::bail!("session export directory permissions are not owner-private")
}
}
Ok(())
}
#[cfg(unix)]
fn checked_source_length(length: u64) -> anyhow::Result<u64> {
if length > SESSION_EXPORT_MAX_SOURCE_BYTES {
anyhow::bail!(
"session export source-byte limit exceeded: {length} bytes (maximum {})",
SESSION_EXPORT_MAX_SOURCE_BYTES
)
}
Ok(length)
}
#[cfg(unix)]
fn hash_file(file: &File, length: u64, cancellation: &AgentCancellation) -> anyhow::Result<String> {
let mut source = file.try_clone()?;
source.seek(SeekFrom::Start(0))?;
let mut source = source.take(length);
let mut hasher = Sha256::new();
let mut copied = 0_u64;
let mut buffer = [0_u8; 64 * 1024];
loop {
cancellation.check()?;
let read = source.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
copied = copied
.checked_add(read as u64)
.ok_or_else(|| anyhow::anyhow!("session export hash length overflowed"))?;
}
cancellation.check()?;
if copied != length || file.metadata()?.len() != length {
anyhow::bail!("session export source changed while hashing")
}
Ok(lower_hex(hasher.finalize()))
}
#[cfg(unix)]
fn add_member(
manifest: &mut ExportManifest,
archive_name: String,
file: File,
cancellation: &AgentCancellation,
) -> anyhow::Result<()> {
if manifest.members.len() >= SESSION_EXPORT_MAX_MEMBERS {
anyhow::bail!(
"session export member limit exceeded: maximum {} members",
SESSION_EXPORT_MAX_MEMBERS
)
}
if manifest
.members
.iter()
.any(|member| member.archive_name == archive_name)
{
anyhow::bail!("session export contains duplicate archive member")
}
let length = checked_source_length(file.metadata()?.len())?;
let sha256 = hash_file(&file, length, cancellation)?;
let source_bytes = manifest
.source_bytes
.checked_add(length)
.ok_or_else(|| anyhow::anyhow!("session export source-byte count overflowed"))?;
if source_bytes > SESSION_EXPORT_MAX_SOURCE_BYTES {
anyhow::bail!(
"session export source-byte limit exceeded: maximum {} bytes",
SESSION_EXPORT_MAX_SOURCE_BYTES
)
}
manifest.source_bytes = source_bytes;
manifest.members.push(SourceMember {
archive_name,
file,
length,
sha256,
});
Ok(())
}
#[cfg(unix)]
fn add_optional_member(
manifest: &mut ExportManifest,
root: &Path,
source_name: &str,
archive_name: String,
fatal: bool,
cancellation: &AgentCancellation,
) -> anyhow::Result<()> {
match open_existing_named(root, source_name) {
Ok(Some(file)) => {
if let Err(error) = add_member(manifest, archive_name, file, cancellation) {
if is_export_limit_error(&error) || fatal {
return Err(error);
}
manifest.warnings.push(format!(
"skipped unreadable session export member {source_name}"
));
}
}
Ok(None) => {}
Err(_) if fatal => {
anyhow::bail!("session export member {source_name} is unsafe or unreadable")
}
Err(_) => manifest.warnings.push(format!(
"skipped unsafe or unreadable session export member {source_name}"
)),
}
Ok(())
}
#[cfg(unix)]
fn is_export_limit_error(error: &anyhow::Error) -> bool {
let message = error.to_string();
message.contains("session export member limit")
|| message.contains("session export source-byte")
}
#[cfg(unix)]
fn optional_child_failure(
manifest: &mut ExportManifest,
child_id: &str,
cancellation: &AgentCancellation,
error: anyhow::Error,
) -> anyhow::Result<Option<BTreeSet<String>>> {
if cancellation.is_canceled() || is_export_limit_error(&error) {
return Err(error);
}
manifest.warnings.push(format!(
"skipped child session {child_id}: unsafe, locked, or unreadable"
));
Ok(None)
}
#[cfg(unix)]
fn collect_child(
manifest: &mut ExportManifest,
child_root: &Path,
sessions_root: &Path,
child_id: &str,
cancellation: &AgentCancellation,
) -> anyhow::Result<Option<BTreeSet<String>>> {
if validate_session_root(child_root).is_err() {
manifest.warnings.push(format!(
"child session {child_id} root is missing or unsafe"
));
return Ok(None);
}
let child_path = child_root.join(format!("{child_id}.jsonl"));
match fs::symlink_metadata(&child_path) {
Ok(metadata) if metadata.file_type().is_file() => {}
Ok(_) => {
manifest
.warnings
.push(format!("child session {child_id} is unsafe"));
return Ok(None);
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
manifest
.warnings
.push(format!("child session {child_id} is missing"));
return Ok(None);
}
Err(_) => {
manifest
.warnings
.push(format!("child session {child_id} is unreadable"));
return Ok(None);
}
}
let child = match (|| -> anyhow::Result<Option<(File, u64, BTreeSet<String>)>> {
manifest.lock_session(&child_path, cancellation)?;
let Some(child) = open_existing_named(child_root, &format!("{child_id}.jsonl"))? else {
return Ok(None);
};
let child_length = checked_source_length(child.metadata()?.len())?;
let child_children = discover_children(
&child,
child_length,
sessions_root,
child_id,
manifest,
cancellation,
)?;
Ok(Some((child, child_length, child_children)))
})() {
Ok(Some(value)) => value,
Ok(None) => {
manifest
.warnings
.push(format!("child session {child_id} is missing"));
return Ok(None);
}
Err(error) => return optional_child_failure(manifest, child_id, cancellation, error),
};
let (child, _child_length, child_children) = child;
if let Err(error) = add_member(
manifest,
format!("subagents/{child_id}.jsonl"),
child,
cancellation,
) {
return optional_child_failure(manifest, child_id, cancellation, error);
}
add_optional_member(
manifest,
child_root,
&format!("{child_id}.metadata.json"),
format!("subagents/{child_id}.metadata.json"),
false,
cancellation,
)?;
let history_children = collect_history(
manifest,
child_root,
child_id,
"subagents/.history",
false,
cancellation,
)?;
Ok(Some(
child_children.into_iter().chain(history_children).collect(),
))
}
#[cfg(unix)]
fn collect_history(
manifest: &mut ExportManifest,
root: &Path,
session_id: &str,
archive_prefix: &str,
fatal: bool,
cancellation: &AgentCancellation,
) -> anyhow::Result<BTreeSet<String>> {
let mut discovered = BTreeSet::new();
let history_parent = root.join(".history");
let history_parent_exists = match fs::symlink_metadata(&history_parent) {
Ok(_) => true,
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
Err(_) => {
return history_failure(
fatal,
&mut manifest.warnings,
format!("session {session_id} history is unreadable"),
);
}
};
if !history_parent_exists {
return Ok(discovered);
}
if validate_session_root(&history_parent).is_err() {
return history_failure(
fatal,
&mut manifest.warnings,
format!("session {session_id} history is unsafe"),
);
}
let history_root = history_parent.join(session_id);
match fs::symlink_metadata(&history_root) {
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(discovered),
Err(_) => {
return history_failure(
fatal,
&mut manifest.warnings,
format!("session {session_id} history is unreadable"),
);
}
}
let mut entries = Vec::new();
let read_dir = match fs::read_dir(&history_root) {
Ok(entries) => entries,
Err(_error) => {
return history_failure(
fatal,
&mut manifest.warnings,
format!("session {session_id} history is unreadable"),
);
}
};
for entry in read_dir {
if entries.len() >= SESSION_EXPORT_MAX_MEMBERS {
anyhow::bail!(
"session export member limit exceeded: maximum {} members",
SESSION_EXPORT_MAX_MEMBERS
);
}
entries.push(entry?);
}
entries.sort_by_key(|entry| entry.file_name());
cancellation.check()?;
for entry in entries {
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
let Some(generation) = name
.strip_suffix(".jsonl")
.and_then(|value| value.parse::<u64>().ok())
else {
continue;
};
let file = match open_existing_named(&history_root, &name) {
Ok(Some(file)) => file,
Ok(None) => continue,
Err(_error) if fatal => {
anyhow::bail!("session export history generation {generation} is unsafe")
}
Err(_error) => {
manifest.warnings.push(format!(
"skipped unsafe session {session_id} history generation {generation}"
));
continue;
}
};
let length = checked_source_length(file.metadata()?.len())?;
cancellation.check()?;
let children = discover_children(
&file,
length,
if archive_prefix == "subagents/.history" {
root.parent().unwrap_or(root)
} else {
root
},
session_id,
manifest,
cancellation,
)?;
discovered.extend(children);
let archive_name = format!("{archive_prefix}/{session_id}/{name}");
if let Err(error) = add_member(manifest, archive_name, file, cancellation) {
if is_export_limit_error(&error) || fatal {
return Err(error);
}
manifest.warnings.push(format!(
"skipped unreadable session {session_id} history generation {generation}"
));
}
}
Ok(discovered)
}
#[cfg(unix)]
fn history_failure(
fatal: bool,
warnings: &mut WarningCollector,
message: String,
) -> anyhow::Result<BTreeSet<String>> {
if fatal {
anyhow::bail!(message)
}
warnings.push(message);
Ok(BTreeSet::new())
}
#[cfg(unix)]
fn discover_children(
file: &File,
length: u64,
sessions_root: &Path,
parent_session_id: &str,
manifest: &mut ExportManifest,
cancellation: &AgentCancellation,
) -> anyhow::Result<BTreeSet<String>> {
let mut scan_file = file.try_clone()?;
scan_file.seek(SeekFrom::Start(0))?;
let mut reader = BufReader::new(scan_file.take(length));
let mut children = BTreeSet::new();
let mut total_read = 0u64;
fn read_bounded_line<R: BufRead>(reader: &mut R) -> io::Result<(Option<Vec<u8>>, usize)> {
let mut line = Vec::new();
let mut total = 0usize;
let mut oversized = false;
loop {
let buffer = reader.fill_buf()?;
if buffer.is_empty() {
return Ok((
if total == 0 || oversized {
None
} else {
Some(line)
},
total,
));
}
let newline = buffer.iter().position(|byte| *byte == b'\n');
let consumed = newline.map_or(buffer.len(), |index| index + 1);
if !oversized {
if line.len().saturating_add(consumed) <= SESSION_EXPORT_MAX_DISCOVERY_LINE_BYTES {
line.extend_from_slice(&buffer[..consumed]);
} else {
oversized = true;
line.clear();
}
}
reader.consume(consumed);
total = total.saturating_add(consumed);
if newline.is_some() {
return Ok((if oversized { None } else { Some(line) }, total));
}
}
}
loop {
cancellation.check()?;
let (line, bytes_read) = read_bounded_line(&mut reader)?;
if bytes_read == 0 {
break;
}
total_read = total_read.saturating_add(bytes_read as u64);
if let Some(line) = line
&& let Ok(value) = serde_json::from_slice::<Value>(&line)
{
extract_child_references(
&value,
sessions_root,
parent_session_id,
&mut children,
&mut manifest.relationships,
&mut manifest.warnings,
&mut manifest.graph_nodes,
);
}
}
if total_read != length {
anyhow::bail!("session JSONL changed while preparing export")
}
Ok(children)
}
#[cfg(unix)]
fn extract_child_references(
event: &Value,
sessions_root: &Path,
parent_session_id: &str,
children: &mut BTreeSet<String>,
relationships: &mut BTreeSet<ExportRelationship>,
warnings: &mut WarningCollector,
graph_nodes: &mut usize,
) {
if event.get("event_type").and_then(Value::as_str) != Some("tool_result") {
return;
}
let Some(result) = event.get("payload").and_then(|p| p.get("result")) else {
return;
};
if result.get("tool_name").and_then(Value::as_str) != Some("subagents") {
return;
}
let Some(content) = result.get("content").and_then(Value::as_str) else {
return;
};
let Ok(output) = serde_json::from_str::<SubagentsOutput>(content) else {
warnings.push("skipped malformed child session reference");
return;
};
for result in output.results {
let (Some(id), Some(path)) = (result.session_id.as_deref(), result.session_path.as_deref())
else {
warnings.push("skipped child session reference without session_id or session_path");
continue;
};
let Ok(session_id) = validate_session_id(id.to_string()) else {
warnings.push("skipped child session reference with invalid session_id");
continue;
};
let expected = sessions_root
.join("subagents")
.join(format!("{session_id}.jsonl"));
if path != expected {
warnings.push("skipped child session reference with unexpected session_path");
continue;
}
*graph_nodes = graph_nodes.saturating_add(1);
if *graph_nodes > SESSION_EXPORT_MAX_MEMBERS {
continue;
}
relationships.insert(ExportRelationship {
relationship_type: "child_session",
parent_session_id: parent_session_id.to_string(),
child_session_id: session_id.clone(),
});
children.insert(session_id);
}
}
#[cfg(unix)]
fn validate_destination(destination: &Path) -> anyhow::Result<ValidatedDestination> {
let file_name = destination
.file_name()
.filter(|name| *name != std::ffi::OsStr::new("."))
.ok_or_else(|| anyhow::anyhow!("session export destination must name a file"))?
.to_os_string();
let parent = destination.parent().unwrap_or_else(|| Path::new("."));
validate_destination_parent(parent)?;
let directory = open_directory_fd(parent)?;
let path = parent.join(&file_name);
match fs::symlink_metadata(&path) {
Ok(_) => anyhow::bail!(
"session export destination already exists: {}",
path.display()
),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(ValidatedDestination {
path,
file_name,
directory,
}),
Err(error) => Err(anyhow::anyhow!(
"could not inspect session export destination {}: {error}",
path.display()
)),
}
}
#[cfg(unix)]
fn validate_destination_parent(parent: &Path) -> anyhow::Result<()> {
let metadata = fs::symlink_metadata(parent)?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
anyhow::bail!("session export destination parent must be a directory")
}
{
use std::os::unix::fs::MetadataExt;
let user_id = unsafe { libc::geteuid() };
if metadata.uid() != user_id {
anyhow::bail!("session export destination parent owner is not current user")
}
if metadata.mode() & 0o022 != 0 {
anyhow::bail!("session export destination parent is writable by group or other users")
}
}
Ok(())
}
#[cfg(unix)]
fn open_directory_fd(path: &Path) -> anyhow::Result<File> {
use std::os::unix::fs::OpenOptionsExt;
let mut options = OpenOptions::new();
options
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW);
Ok(options.open(path)?)
}
#[cfg(unix)]
fn unlink_at(directory: &File, name: &std::ffi::OsStr) -> anyhow::Result<()> {
use std::os::unix::ffi::OsStrExt;
let name = std::ffi::CString::new(name.as_bytes())?;
let result = unsafe { libc::unlinkat(directory.as_raw_fd(), name.as_ptr(), 0) };
if result == 0 || io::Error::last_os_error().kind() == io::ErrorKind::NotFound {
Ok(())
} else {
Err(io::Error::last_os_error().into())
}
}
#[cfg(unix)]
fn link_at(
directory: &File,
source: &std::ffi::OsStr,
destination: &std::ffi::OsStr,
) -> io::Result<()> {
use std::os::unix::ffi::OsStrExt;
let source = std::ffi::CString::new(source.as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in temporary name"))?;
let destination = std::ffi::CString::new(destination.as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in destination name"))?;
let result = unsafe {
libc::linkat(
directory.as_raw_fd(),
source.as_ptr(),
directory.as_raw_fd(),
destination.as_ptr(),
0,
)
};
if result == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
#[cfg(unix)]
fn create_temporary_archive(directory: &File) -> anyhow::Result<(File, std::ffi::OsString)> {
for _ in 0..SESSION_EXPORT_TEMP_ATTEMPTS {
let sequence = NEXT_EXPORT_TEMP.fetch_add(1, Ordering::Relaxed);
let name = std::ffi::OsString::from(format!(
".magi-code-session-export-{}-{sequence}.tmp",
std::process::id()
));
match openat_file(directory, &name) {
Ok(file) => return Ok((file, name)),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
}
anyhow::bail!("could not allocate a temporary session export archive")
}
#[cfg(unix)]
fn openat_file(directory: &File, name: &std::ffi::OsStr) -> io::Result<File> {
use std::os::unix::{
ffi::OsStrExt,
io::{AsRawFd, FromRawFd},
};
let c_name = std::ffi::CString::new(name.as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in name"))?;
let fd = unsafe {
libc::openat(
directory.as_raw_fd(),
c_name.as_ptr(),
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
0o600,
)
};
if fd < 0 {
return Err(io::Error::last_os_error());
}
Ok(unsafe { File::from_raw_fd(fd) })
}
#[cfg(unix)]
fn serialize_manifest(manifest: &ExportManifest, omissions: &[String]) -> anyhow::Result<Vec<u8>> {
let document = ExportManifestDocument {
schema: "magi-code.session_export",
schema_version: 1,
root_session_id: manifest.root_session_id.clone(),
member_count: manifest.members.len(),
source_bytes: manifest.source_bytes,
members: manifest
.members
.iter()
.map(|member| ExportManifestMember {
path: member.archive_name.clone(),
bytes: member.length,
sha256: member.sha256.clone(),
})
.collect(),
relationships: manifest.relationships.iter().cloned().collect(),
completeness: ExportCompleteness {
complete: omissions.is_empty(),
omissions: omissions.to_vec(),
},
};
let mut bytes = serde_json::to_vec_pretty(&document)?;
bytes.push(b'\n');
Ok(bytes)
}
#[cfg(unix)]
fn write_archive(
manifest: &mut ExportManifest,
omissions: &[String],
destination: &ValidatedDestination,
cancellation: &AgentCancellation,
warnings: &mut Vec<String>,
) -> anyhow::Result<()> {
let (temporary, temporary_name) = create_temporary_archive(&destination.directory)?;
let mut cleanup = TemporaryArchiveGuard::new(&destination.directory, temporary_name.clone())?;
let result = write_archive_inner(
manifest,
omissions,
destination,
temporary,
&temporary_name,
&mut cleanup,
cancellation,
warnings,
);
match result {
Ok(()) => Ok(()),
Err(error) => match cleanup.cleanup() {
Ok(()) => Err(error),
Err(cleanup_error) => Err(anyhow::anyhow!(
"{error}; session export cleanup diagnostic: {cleanup_error}"
)),
},
}
}
#[allow(clippy::too_many_arguments)]
#[cfg(unix)]
fn write_archive_inner(
manifest: &mut ExportManifest,
omissions: &[String],
destination: &ValidatedDestination,
temporary: File,
temporary_name: &std::ffi::OsStr,
cleanup: &mut TemporaryArchiveGuard,
cancellation: &AgentCancellation,
warnings: &mut Vec<String>,
) -> anyhow::Result<()> {
let mut archive = ZipWriter::new(temporary);
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(SESSION_FILE_MODE);
cancellation.check()?;
manifest
.members
.sort_by(|left, right| left.archive_name.cmp(&right.archive_name));
let manifest_bytes = serialize_manifest(manifest, omissions)?;
archive.start_file("manifest.json", options)?;
cancellation.check()?;
archive.write_all(&manifest_bytes)?;
for member in &mut manifest.members {
cancellation.check()?;
cancellation.check()?;
archive.start_file(member.archive_name.clone(), options)?;
member.file.seek(SeekFrom::Start(0))?;
let mut source = (&mut member.file).take(member.length);
let mut hasher = Sha256::new();
let mut copied = 0_u64;
let mut buffer = [0_u8; 64 * 1024];
loop {
cancellation.check()?;
let read = source.read(&mut buffer)?;
if read == 0 {
break;
}
archive.write_all(&buffer[..read])?;
hasher.update(&buffer[..read]);
copied = copied
.checked_add(read as u64)
.ok_or_else(|| anyhow::anyhow!("session export source-byte count overflowed"))?;
}
if copied != member.length
|| member.file.metadata()?.len() != member.length
|| lower_hex(hasher.finalize()) != member.sha256
{
anyhow::bail!(
"session export source changed while reading {}",
member.archive_name
)
}
}
cancellation.check()?;
let mut temporary = archive.finish()?;
temporary.flush()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
temporary.set_permissions(fs::Permissions::from_mode(SESSION_FILE_MODE))?;
}
temporary.sync_all()?;
drop(temporary);
cancellation.check()?;
match link_at(
&destination.directory,
temporary_name,
&destination.file_name,
) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => anyhow::bail!(
"session export destination already exists: {}",
destination.path.display()
),
Err(error) => {
return Err(anyhow::anyhow!(
"could not publish session export {}: {error}",
destination.path.display()
));
}
}
let cleanup_result = cleanup.cleanup();
cleanup.disarm();
if let Err(error) = cleanup_result {
warnings.push(bounded_warning(&format!(
"published archive temporary file cleanup failed; sensitive temporary data may remain: {error}"
)));
}
if let Err(error) = destination.directory.sync_all() {
warnings.push(bounded_warning(&format!(
"published archive directory durability uncertain: {error}"
)));
}
Ok(())
}
#[cfg(unix)]
#[cfg(test)]
mod tests {
use super::*;
use crate::{
sessions::{SessionManager, store::secure_test_session_root},
test_support::env::env_lock,
};
use serde_json::json;
use std::io::Read;
use tempfile::TempDir;
use zip::ZipArchive;
fn session_fixture() -> (TempDir, SessionManager, Session, Vec<u8>) {
let temp = TempDir::new().unwrap();
let root = temp.path().join("sessions");
let manager = SessionManager::new(root.clone());
let session = manager.create().unwrap();
let child_root = root.join("subagents");
fs::create_dir_all(&child_root).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&child_root, fs::Permissions::from_mode(0o700)).unwrap();
}
(temp, manager, session, Vec::new())
}
fn write_private(path: &Path, bytes: &[u8]) {
fs::write(path, bytes).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
}
}
fn archive_members(path: &Path) -> Vec<(String, Vec<u8>)> {
let file = File::open(path).unwrap();
let mut archive = ZipArchive::new(file).unwrap();
let mut members = Vec::new();
for index in 0..archive.len() {
let mut entry = archive.by_index(index).unwrap();
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).unwrap();
members.push((entry.name().to_string(), bytes));
}
members
}
#[test]
fn export_preserves_raw_members_and_history_layout() {
let (temp, manager, session, _) = session_fixture();
let root = manager.root.clone();
let child_path = root.join("subagents/child.jsonl");
let child_output = json!({
"summary": {"total": 1, "completed": 1, "failed": 0},
"results": [{
"id": "task-1", "status": "completed", "intent": "child task",
"agent": null, "identity": null, "cwd": root, "session_id": "child",
"session_path": child_path, "changed_files": [], "output": "ok",
"output_truncated": false, "error": null
}]
});
let primary = format!(
"not-json-but-raw\n{}\n",
json!({
"event_type": "tool_result",
"payload": {
"result": {
"tool_name": "subagents",
"content": serde_json::to_string(&child_output).unwrap()
}
}
})
)
.into_bytes();
write_private(session.path(), &primary);
write_private(
&root.join(format!("{}.metadata.json", session.id())),
b"metadata with exact spacing\n",
);
write_private(&child_path, b"child\x00raw\n");
write_private(
&root.join("subagents/child.metadata.json"),
b"child metadata\n",
);
let history = root.join(".history").join(session.id());
fs::create_dir_all(&history).unwrap();
let child_history = root.join("subagents/.history/child");
fs::create_dir_all(&child_history).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(root.join(".history"), fs::Permissions::from_mode(0o700)).unwrap();
fs::set_permissions(&history, fs::Permissions::from_mode(0o700)).unwrap();
fs::set_permissions(
root.join("subagents/.history"),
fs::Permissions::from_mode(0o700),
)
.unwrap();
fs::set_permissions(&child_history, fs::Permissions::from_mode(0o700)).unwrap();
}
write_private(&history.join("2.jsonl"), b"primary history 2\n");
write_private(&history.join("1.jsonl"), b"primary history 1\n");
write_private(&child_history.join("1.jsonl"), b"child history\n");
secure_test_session_root(&root);
let destination = temp.path().join("export.zip");
let report = export_session(&manager, &session, &destination).unwrap();
let members = archive_members(&destination);
assert_eq!(report.member_count, 7);
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
let manifest_index = members
.iter()
.position(|(path, _)| path == "manifest.json")
.unwrap();
let manifest_bytes = members[manifest_index].1.clone();
let manifest: Value = serde_json::from_slice(&manifest_bytes).unwrap();
let mut source_members = members;
source_members.remove(manifest_index);
assert_eq!(manifest["schema"], "magi-code.session_export");
assert_eq!(manifest["schema_version"], 1);
assert_eq!(manifest["root_session_id"], session.id());
assert_eq!(manifest["member_count"], report.member_count);
assert_eq!(manifest["source_bytes"], report.source_bytes);
assert_eq!(manifest["completeness"]["complete"], true);
assert!(
manifest["completeness"]["omissions"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(
manifest["relationships"],
json!([{
"type": "child_session",
"parent_session_id": session.id(),
"child_session_id": "child"
}])
);
for (path, bytes) in &source_members {
let listed = manifest["members"]
.as_array()
.unwrap()
.iter()
.find(|member| member["path"] == *path)
.unwrap();
assert_eq!(listed["bytes"], bytes.len() as u64);
assert_eq!(listed["sha256"], lower_hex(Sha256::digest(bytes)));
}
assert_eq!(
report.source_bytes,
source_members
.iter()
.map(|(_, bytes)| bytes.len() as u64)
.sum::<u64>()
);
assert_eq!(
source_members,
vec![
(
format!(".history/{}/1.jsonl", session.id()),
b"primary history 1\n".to_vec(),
),
(
format!(".history/{}/2.jsonl", session.id()),
b"primary history 2\n".to_vec(),
),
(format!("{}.jsonl", session.id()), primary),
(
format!("{}.metadata.json", session.id()),
b"metadata with exact spacing\n".to_vec(),
),
(
"subagents/.history/child/1.jsonl".to_string(),
b"child history\n".to_vec(),
),
(
"subagents/child.jsonl".to_string(),
b"child\0raw\n".to_vec()
),
(
"subagents/child.metadata.json".to_string(),
b"child metadata\n".to_vec(),
),
]
);
}
#[test]
fn missing_child_is_a_bounded_warning_and_does_not_abort_export() {
let (_temp, manager, session, _) = session_fixture();
let root = manager.root.clone();
let missing_path = root.join("subagents/missing.jsonl");
let child_output = json!({
"summary": {"total": 1, "completed": 1, "failed": 0},
"results": [{"id":"task-1","status":"completed","intent":"x","agent":null,"identity":null,"cwd":root,"session_id":"missing","session_path":missing_path,"changed_files":[],"output":"","output_truncated":false,"error":null}]
});
let primary = json!({
"event_type": "tool_result",
"payload": {
"result": {
"tool_name": "subagents",
"content": serde_json::to_string(&child_output).unwrap()
}
}
});
write_private(session.path(), format!("{}\n", primary).as_bytes());
secure_test_session_root(&root);
let destination = root.parent().unwrap().join("missing-child.zip");
let report = export_session(&manager, &session, &destination).unwrap();
assert_eq!(report.member_count, 1);
assert_eq!(report.warnings, vec!["child session missing is missing"]);
assert!(destination.exists());
}
#[cfg(unix)]
#[test]
fn unsafe_child_permissions_are_a_partial_omission() {
use std::os::unix::fs::PermissionsExt;
let (_temp, manager, session, _) = session_fixture();
let root = manager.root.clone();
let child_path = root.join("subagents/unsafe.jsonl");
write_private(&child_path, b"child\n");
fs::set_permissions(&child_path, fs::Permissions::from_mode(0o644)).unwrap();
let child_output = json!({
"summary": {"total": 1, "completed": 1, "failed": 0},
"results": [{"id":"task-1","status":"completed","intent":"x","agent":null,"identity":null,"cwd":root,"session_id":"unsafe","session_path":child_path,"changed_files":[],"output":"","output_truncated":false,"error":null}]
});
let primary = json!({"event_type":"tool_result","payload":{"result":{"tool_name":"subagents","content":serde_json::to_string(&child_output).unwrap()}}});
write_private(session.path(), format!("{}\n", primary).as_bytes());
secure_test_session_root(&root);
let destination = root.parent().unwrap().join("unsafe-child.zip");
let report = export_session(&manager, &session, &destination).unwrap();
assert!(
report
.warnings
.iter()
.any(|warning| warning.contains("unsafe"))
);
assert_eq!(report.member_count, 1);
}
#[test]
fn unsafe_primary_fails_before_creating_destination() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("sessions");
let manager = SessionManager::new(root.clone());
let session = manager.create().unwrap();
let destination = temp.path().join("unsafe.zip");
#[cfg(unix)]
std::os::unix::fs::symlink(temp.path().join("outside"), session.path()).unwrap();
#[cfg(not(unix))]
write_private(session.path(), b"not present");
let error = export_session(&manager, &session, &destination).unwrap_err();
assert!(!destination.exists());
assert!(!error.to_string().is_empty());
}
#[test]
fn destination_is_not_overwritten() {
let (temp, manager, session, _) = session_fixture();
write_private(session.path(), b"primary\n");
secure_test_session_root(&manager.root);
let destination = temp.path().join("existing.zip");
write_private(&destination, b"keep me");
let error = export_session(&manager, &session, &destination).unwrap_err();
assert!(error.to_string().contains("already exists"));
assert_eq!(fs::read(destination).unwrap(), b"keep me");
}
#[cfg(unix)]
#[test]
fn published_archive_is_owner_private() {
use std::os::unix::fs::PermissionsExt;
let (temp, manager, session, _) = session_fixture();
write_private(session.path(), b"primary\n");
secure_test_session_root(&manager.root);
let destination = temp.path().join("private.zip");
export_session(&manager, &session, &destination).unwrap();
assert_eq!(
fs::metadata(destination).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn source_limit_is_checked_before_archive_creation() {
let (temp, manager, session, _) = session_fixture();
let file = OpenOptions::new()
.write(true)
.open(session.path())
.unwrap_or_else(|_| {
let mut options = OpenOptions::new();
options.write(true).create(true);
options.open(session.path()).unwrap()
});
file.set_len(SESSION_EXPORT_MAX_SOURCE_BYTES + 1).unwrap();
secure_test_session_root(&manager.root);
let destination = temp.path().join("too-large.zip");
let error = export_session(&manager, &session, &destination).unwrap_err();
assert!(error.to_string().contains("source-byte limit"));
assert!(!destination.exists());
}
#[test]
fn export_zip_is_deterministic_for_unchanged_sources() {
let (temp, manager, session, _) = session_fixture();
write_private(session.path(), b"primary\n");
secure_test_session_root(&manager.root);
let first = temp.path().join("first.zip");
let second = temp.path().join("second.zip");
export_session(&manager, &session, &first).unwrap();
export_session(&manager, &session, &second).unwrap();
assert_eq!(fs::read(first).unwrap(), fs::read(second).unwrap());
}
#[test]
fn malformed_child_reference_requires_both_identity_fields() {
let (_temp, manager, session, _) = session_fixture();
let primary = json!({
"event_type": "tool_result",
"payload": {"result": {"tool_name": "subagents", "content": serde_json::to_string(&json!({
"summary": {"total": 1, "completed": 1, "failed": 0},
"results": [{"id":"task-1","status":"completed","intent":"x","agent":null,"identity":null,"cwd":".","session_id":"child","changed_files":[],"output":"","output_truncated":false,"error":null}]
})).unwrap()}}
});
write_private(session.path(), format!("{}\n", primary).as_bytes());
secure_test_session_root(&manager.root);
let destination = manager.root.parent().unwrap().join("malformed.zip");
let report = export_session(&manager, &session, &destination).unwrap();
assert_eq!(report.member_count, 1);
assert_eq!(
report.warnings,
vec!["skipped child session reference without session_id or session_path"]
);
}
#[test]
fn warning_collector_caps_and_sanitizes_diagnostics() {
let mut collector = WarningCollector::default();
for _ in 0..(SESSION_EXPORT_MAX_WARNINGS + 10) {
collector.push("token=secret\x1b[31m".to_string() + &"x".repeat(600));
}
let warnings = collector.finish();
assert_eq!(warnings.len(), SESSION_EXPORT_MAX_WARNINGS);
assert!(
warnings
.iter()
.all(|warning| warning.len() <= SESSION_EXPORT_MAX_WARNING_CHARS + 3)
);
assert!(warnings.iter().all(|warning| !warning.contains("secret")));
}
#[test]
fn export_does_not_require_environment_or_provider_state() {
let _env = env_lock();
let (_temp, manager, session, _) = session_fixture();
write_private(session.path(), b"primary\n");
secure_test_session_root(&manager.root);
let destination = manager.root.parent().unwrap().join("idle.zip");
assert!(export_session(&manager, &session, &destination).is_ok());
}
}