use std::path::Path;
use std::sync::OnceLock;
use tracing::{debug, info, warn};
use super::manager::ManagedTmuxDriver;
use super::record::SessionRecord;
const SCROLLBACK_LINES: usize = 5_000;
const SCROLLBACK_SUBPATH: &str = ".trusty-mpm/scrollback.txt";
static REDACT_RE: OnceLock<regex::Regex> = OnceLock::new();
pub async fn capture_into(record: &mut SessionRecord, driver: &dyn ManagedTmuxDriver) {
capture_into_pane(record, driver, None).await;
}
pub async fn capture_into_pane(
record: &mut SessionRecord,
driver: &dyn ManagedTmuxDriver,
pane_id: Option<&str>,
) {
let name = &record.tmux_name;
if let Some(ws) = record.workspace_path.as_ref() {
if !workspace_root_exists(ws).await {
warn!(
name = %name,
path = %ws.display(),
"snapshot: workspace root missing — refusing to recreate; \
skipping scrollback write (#3715)"
);
} else {
let captured = match pane_id {
Some(pane) => driver.capture_pane(name, pane, SCROLLBACK_LINES),
None => driver.capture(name, SCROLLBACK_LINES),
};
match captured {
Ok(text) => {
let dest = ws.join(SCROLLBACK_SUBPATH);
match write_scrollback(ws, &dest, &text).await {
Ok(ScrollbackWriteOutcome::Written { created_dir }) => {
if created_dir {
info!(
name = %name,
path = %dest.display(),
"snapshot: created .trusty-mpm/ dir and wrote scrollback ({} bytes)",
text.len()
);
} else {
debug!(name = %name, path = %dest.display(), "snapshot: scrollback written ({} bytes)", text.len());
}
record.scrollback_path = Some(dest);
}
Ok(ScrollbackWriteOutcome::RootVanished) => {
warn!(
name = %name,
path = %ws.display(),
"snapshot: workspace root vanished between the pre-capture \
check and the scrollback write — refusing to recreate; \
skipping (#3715)"
);
}
Err(e) => {
warn!(name = %name, path = %dest.display(), "snapshot: scrollback write failed: {e}");
}
}
}
Err(e) => {
warn!(name = %name, "snapshot: capture-pane failed (non-fatal): {e}");
}
}
}
} else {
debug!(name = %name, "snapshot: no workspace_path; skipping scrollback write");
}
if let Some(cwd) = driver.get_pane_cwd(name) {
debug!(name = %name, cwd = %cwd.display(), "snapshot: last_cwd captured");
record.last_cwd = Some(cwd);
}
}
async fn workspace_root_exists(root: &Path) -> bool {
tokio::fs::metadata(root).await.is_ok()
}
enum ScrollbackWriteOutcome {
Written { created_dir: bool },
RootVanished,
}
async fn write_scrollback(
root: &Path,
dest: &Path,
content: &str,
) -> std::io::Result<ScrollbackWriteOutcome> {
let parent = dest.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"scrollback path has no parent directory",
)
})?;
if !workspace_root_exists(root).await {
return Ok(ScrollbackWriteOutcome::RootVanished);
}
let parent_existed = tokio::fs::try_exists(parent).await.unwrap_or(false);
tokio::fs::create_dir_all(parent).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = tokio::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).await;
}
let redacted = redact_secrets(content);
#[cfg(unix)]
{
let dest = dest.to_path_buf();
let data = redacted.into_bytes();
tokio::task::spawn_blocking(move || secure_write(&dest, &data))
.await
.map_err(std::io::Error::other)??;
}
#[cfg(not(unix))]
tokio::fs::write(dest, redacted.as_bytes()).await?;
Ok(ScrollbackWriteOutcome::Written {
created_dir: !parent_existed,
})
}
#[cfg(unix)]
fn secure_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(data)
}
fn redact_secrets(content: &str) -> String {
let re = REDACT_RE.get_or_init(|| {
regex::Regex::new(concat!(
r"(?i)",
r"((?:anthropic|openrouter)_api_key\s*=\s*)(\S+)",
r"|",
r"(sk-)([A-Za-z0-9_-]+)",
r"|",
r"(bearer\s+)([A-Za-z0-9._-]+)",
r"|",
r"((?:token|secret|password|api[_-]?key)\s*[:=]\s*)(\S+)",
))
.expect("REDACT_RE must compile — static pattern")
});
re.replace_all(content, |caps: ®ex::Captures| {
let prefix = if caps.get(1).is_some() {
caps.get(1).map_or("", |m| m.as_str())
} else if caps.get(3).is_some() {
caps.get(3).map_or("", |m| m.as_str())
} else if caps.get(5).is_some() {
caps.get(5).map_or("", |m| m.as_str())
} else {
caps.get(7).map_or("", |m| m.as_str())
};
format!("{prefix}***REDACTED***")
})
.into_owned()
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use chrono::Utc;
use crate::session_manager::manager::{ManagedError, ManagedTmuxDriver};
use crate::session_manager::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
use super::*;
fn make_record(workspace: Option<PathBuf>) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "tmpm-snap-test".into(),
cwd: PathBuf::from("/tmp/cwd"),
task: "test".into(),
state: ManagedSessionState::Active,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: workspace,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
struct StubDriver {
capture_result: Result<String, ManagedError>,
cwd: Option<PathBuf>,
}
impl ManagedTmuxDriver for StubDriver {
fn create_session(&self, _: &str, _: &str) -> Result<(), ManagedError> {
Ok(())
}
fn kill_session(&self, _: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_line(&self, _: &str, _: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _: &str, _: usize) -> Result<String, ManagedError> {
self.capture_result
.as_ref()
.map(|s| s.clone())
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(vec![])
}
fn get_pane_cwd(&self, _: &str) -> Option<PathBuf> {
self.cwd.clone()
}
}
#[tokio::test]
async fn capture_into_no_workspace_skips_write() {
let mut record = make_record(None);
let driver = StubDriver {
capture_result: Ok("some output".into()),
cwd: Some(PathBuf::from("/tmp/src")),
};
capture_into(&mut record, &driver).await;
assert!(
record.scrollback_path.is_none(),
"no workspace → no scrollback file"
);
assert_eq!(record.last_cwd, Some(PathBuf::from("/tmp/src")));
}
#[tokio::test]
async fn capture_into_driver_failure_is_nonfatal() {
let tmp = tempfile::TempDir::new().unwrap();
let mut record = make_record(Some(tmp.path().to_path_buf()));
let driver = StubDriver {
capture_result: Err(ManagedError::TmuxUnavailable("tmux gone".into())),
cwd: None,
};
capture_into(&mut record, &driver).await;
assert!(
record.scrollback_path.is_none(),
"capture failure → no file"
);
assert!(record.last_cwd.is_none(), "cwd None from driver stays None");
}
#[tokio::test]
async fn capture_into_missing_workspace_root_refuses_to_recreate() {
let tmp = tempfile::TempDir::new().unwrap();
let missing_root = tmp.path().join("vanished-workspace");
assert!(!missing_root.exists(), "precondition: root must not exist");
let mut record = make_record(Some(missing_root.clone()));
let driver = StubDriver {
capture_result: Ok("some output".into()),
cwd: Some(PathBuf::from("/tmp/src")),
};
capture_into(&mut record, &driver).await;
assert!(
!missing_root.exists(),
"capture_into must NOT recreate a vanished workspace root (#3715)"
);
assert!(
record.scrollback_path.is_none(),
"missing root → scrollback write skipped, not attempted"
);
assert_eq!(record.last_cwd, Some(PathBuf::from("/tmp/src")));
}
#[tokio::test]
async fn capture_into_creates_trusty_mpm_subdir_when_root_exists() {
let tmp = tempfile::TempDir::new().unwrap();
assert!(
!tmp.path().join(".trusty-mpm").exists(),
"precondition: .trusty-mpm/ must not exist yet"
);
let mut record = make_record(Some(tmp.path().to_path_buf()));
let driver = StubDriver {
capture_result: Ok("first write".into()),
cwd: None,
};
capture_into(&mut record, &driver).await;
assert!(
tmp.path().join(".trusty-mpm").is_dir(),
"root exists → .trusty-mpm/ subdir creation is the normal case"
);
let path = record
.scrollback_path
.expect("scrollback_path should be set");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first write");
}
#[tokio::test]
async fn capture_into_writes_scrollback_file() {
let tmp = tempfile::TempDir::new().unwrap();
let mut record = make_record(Some(tmp.path().to_path_buf()));
let driver = StubDriver {
capture_result: Ok("hello output".into()),
cwd: Some(PathBuf::from("/repo/src")),
};
capture_into(&mut record, &driver).await;
let path = record
.scrollback_path
.expect("scrollback_path should be set");
let on_disk = std::fs::read_to_string(&path).expect("file must exist");
assert_eq!(on_disk, "hello output");
assert_eq!(record.last_cwd, Some(PathBuf::from("/repo/src")));
}
#[cfg(unix)]
#[tokio::test]
async fn scrollback_file_has_restrictive_permissions() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let mut record = make_record(Some(tmp.path().to_path_buf()));
let driver = StubDriver {
capture_result: Ok("output".into()),
cwd: None,
};
capture_into(&mut record, &driver).await;
let path = record.scrollback_path.expect("scrollback_path must be set");
let meta = std::fs::metadata(&path).expect("file must exist");
let mode = meta.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "scrollback file must be 0600, got {mode:o}");
}
#[tokio::test]
async fn redaction_scrubs_secrets_before_write() {
let tmp = tempfile::TempDir::new().unwrap();
let mut record = make_record(Some(tmp.path().to_path_buf()));
let raw = "ANTHROPIC_API_KEY=sk-abc123\ncurl -H \"Bearer xyz\" https://api.example.com\n";
let driver = StubDriver {
capture_result: Ok(raw.into()),
cwd: None,
};
capture_into(&mut record, &driver).await;
let path = record.scrollback_path.expect("scrollback_path must be set");
let on_disk = std::fs::read_to_string(&path).expect("file must exist");
assert!(
!on_disk.contains("sk-abc123"),
"raw API key must not appear on disk: {on_disk:?}"
);
assert!(
!on_disk.contains("Bearer xyz"),
"raw Bearer token must not appear on disk: {on_disk:?}"
);
assert!(
on_disk.contains("***REDACTED***"),
"redaction marker must be present: {on_disk:?}"
);
assert!(
on_disk.contains("ANTHROPIC_API_KEY=***REDACTED***"),
"full key name must be preserved: {on_disk:?}"
);
}
}