use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
pub const ENV_AGENT_SOCK: &str = "TSAFE_AGENT_SOCK";
pub const ENV_CELLOS_SOCK: &str = "TSAFE_SOCKET";
pub fn cellos_socket_path() -> std::path::PathBuf {
if let Ok(p) = std::env::var(ENV_CELLOS_SOCK) {
return std::path::PathBuf::from(p);
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
std::path::PathBuf::from(home)
.join(".tsafe")
.join("agent.sock")
}
#[derive(Debug, Clone)]
pub struct CellRecord {
pub pid: u32,
pub token: String,
}
#[derive(Debug, Clone)]
pub enum CellState {
Active(CellRecord),
Revoked,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "op")]
pub enum CellosRequest {
Resolve {
key: String,
cell_id: String,
ttl_seconds: u64,
cell_token: String,
},
RevokeForCell { cell_id: String },
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status")]
pub enum CellosResponse {
Value { value: String },
Ok,
Err { error: String },
}
pub fn read_agent_sock_env() -> Option<String> {
std::env::var(ENV_AGENT_SOCK).ok()
}
fn agent_sock_env_explicit() -> bool {
std::env::var(ENV_AGENT_SOCK).is_ok()
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "op")]
pub enum AgentRequest {
OpenVault {
profile: String,
session_token: String, requesting_pid: u32,
},
Lock { session_token: String },
Ping,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status")]
pub enum AgentResponse {
Password {
password: String, },
Ok,
Err {
reason: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentSessionState {
Active,
Locked,
Expired,
}
#[derive(Debug)]
pub struct AgentSession {
session_token: String,
idle_deadline: Instant,
absolute_deadline: Instant,
idle_secs: u64,
state: AgentSessionState,
}
#[derive(Debug)]
pub struct AgentSessionOutcome {
pub response: AgentResponse,
pub stop: bool,
pub state: AgentSessionState,
}
impl AgentSession {
pub fn new(
session_token: impl Into<String>,
idle_secs: u64,
absolute_deadline: Instant,
) -> Self {
let idle_deadline =
(Instant::now() + Duration::from_secs(idle_secs)).min(absolute_deadline);
Self {
session_token: session_token.into(),
idle_deadline,
absolute_deadline,
idle_secs,
state: AgentSessionState::Active,
}
}
pub fn state(&self, now: Instant) -> AgentSessionState {
match self.state {
AgentSessionState::Active
if now >= self.idle_deadline || now >= self.absolute_deadline =>
{
AgentSessionState::Expired
}
state => state,
}
}
pub fn handle_request(
&mut self,
req: &AgentRequest,
peer_pid: Option<u32>,
password: &str,
now: Instant,
) -> AgentSessionOutcome {
if let AgentRequest::Lock { session_token } = req {
if session_token == &self.session_token {
self.state = AgentSessionState::Locked;
return AgentSessionOutcome {
response: AgentResponse::Ok,
stop: true,
state: self.state,
};
}
return self.deny("invalid session token", false, now);
}
let expiry_reason = self.sync_expiry(now);
match self.state {
AgentSessionState::Expired => {
let reason = expiry_reason.unwrap_or("agent session expired");
self.deny(reason, true, now)
}
AgentSessionState::Locked => self.deny("agent session locked", true, now),
AgentSessionState::Active => match req {
AgentRequest::Ping => AgentSessionOutcome {
response: AgentResponse::Ok,
stop: false,
state: self.state,
},
AgentRequest::OpenVault {
profile: _,
session_token,
requesting_pid,
} => {
if session_token != &self.session_token {
self.deny("invalid session token", false, now)
} else if peer_pid != Some(*requesting_pid) {
self.deny(
"requesting PID does not match the connecting process",
false,
now,
)
} else {
self.idle_deadline =
(now + Duration::from_secs(self.idle_secs)).min(self.absolute_deadline);
AgentSessionOutcome {
response: AgentResponse::Password {
password: password.to_string(),
},
stop: false,
state: self.state,
}
}
}
AgentRequest::Lock { .. } => unreachable!("lock handled above"),
},
}
}
fn sync_expiry(&mut self, now: Instant) -> Option<&'static str> {
if matches!(self.state, AgentSessionState::Active) {
if now >= self.absolute_deadline {
self.state = AgentSessionState::Expired;
return Some("agent session expired (absolute timeout)");
}
if now >= self.idle_deadline {
self.state = AgentSessionState::Expired;
return Some("agent session expired (idle timeout)");
}
}
None
}
fn deny(&mut self, reason: &str, stop: bool, now: Instant) -> AgentSessionOutcome {
self.sync_expiry(now);
AgentSessionOutcome {
response: AgentResponse::Err {
reason: reason.to_string(),
},
stop,
state: self.state(now),
}
}
}
pub fn agent_sock_path() -> PathBuf {
crate::profile::app_state_dir().join("agent.sock")
}
pub fn write_agent_sock(sock_val: &str) {
let path = agent_sock_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = path.with_extension("sock.tmp");
if std::fs::write(&tmp, sock_val).is_ok() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
let _ = std::fs::rename(&tmp, &path);
}
}
pub fn read_agent_sock() -> Option<String> {
std::fs::read_to_string(agent_sock_path())
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn clear_agent_sock() {
let _ = std::fs::remove_file(agent_sock_path());
}
#[cfg(target_os = "windows")]
pub fn pipe_name(agent_pid: u32) -> String {
format!(r"\\.\pipe\tsafe-agent-{agent_pid}")
}
#[cfg(not(target_os = "windows"))]
pub fn pipe_name(agent_pid: u32) -> String {
let candidate_dirs = [
std::env::var("XDG_RUNTIME_DIR").ok(),
std::env::var("TMPDIR").ok(),
];
pipe_name_for_dirs(agent_pid, candidate_dirs)
}
#[cfg(not(target_os = "windows"))]
fn pipe_name_for_dirs(agent_pid: u32, candidate_dirs: [Option<String>; 2]) -> String {
use std::os::unix::ffi::OsStrExt;
const MAX_UNIX_SOCKET_PATH_BYTES: usize = 100;
let filename = format!("tsafe-agent-{agent_pid}.sock");
for dir in candidate_dirs
.into_iter()
.flatten()
.filter(|d| !d.is_empty())
{
let candidate = std::path::Path::new(&dir).join(&filename);
if candidate.as_os_str().as_bytes().len() <= MAX_UNIX_SOCKET_PATH_BYTES {
return candidate.to_string_lossy().into_owned();
}
}
let fallback_dir = short_agent_runtime_dir();
std::path::Path::new(&fallback_dir)
.join(filename)
.to_string_lossy()
.into_owned()
}
#[cfg(not(target_os = "windows"))]
fn short_agent_runtime_dir() -> String {
let uid = unsafe { libc::getuid() };
let dir = std::path::PathBuf::from(format!("/tmp/tsafe-agent-{uid}"));
if std::fs::create_dir_all(&dir).is_ok() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
return dir.to_string_lossy().into_owned();
}
"/tmp".to_string()
}
pub fn parse_agent_sock(sock: &str) -> Option<(String, String)> {
let idx = sock.rfind("::")?;
let pipe = sock[..idx].to_string();
let token = sock[idx + 2..].to_string();
if pipe.is_empty() || token.is_empty() {
return None;
}
Some((pipe, token))
}
pub fn format_agent_sock(pipe: &str, token_hex: &str) -> String {
format!("{pipe}::{token_hex}")
}
#[cfg(target_os = "windows")]
pub fn request_password_from_agent(profile: &str) -> crate::errors::SafeResult<Option<String>> {
use crate::errors::SafeError;
use std::io::{BufRead, BufReader, Write};
let sock = match read_agent_sock_env().or_else(read_agent_sock) {
Some(s) => s,
None => return Ok(None),
};
let env_var_was_set = agent_sock_env_explicit();
let (pipe, token) = match parse_agent_sock(&sock) {
Some(v) => v,
None => {
if !env_var_was_set {
clear_agent_sock();
}
return Err(SafeError::InvalidVault {
reason: "malformed TSAFE_AGENT_SOCK: expected '{pipe}::{token_hex}'".into(),
});
}
};
let requesting_pid = std::process::id();
let req = AgentRequest::OpenVault {
profile: profile.to_string(),
session_token: token,
requesting_pid,
};
let req_json = serde_json::to_string(&req).map_err(|e| SafeError::Crypto {
context: e.to_string(),
})?;
let mut stream = match connect_pipe_client(&pipe) {
Ok(s) => s,
Err(_) if !env_var_was_set => {
clear_agent_sock();
return Ok(None);
}
Err(e) => return Err(e),
};
writeln!(stream, "{req_json}").map_err(|e| SafeError::InvalidVault {
reason: format!("agent write: {e}"),
})?;
let mut resp_line = String::new();
BufReader::new(&stream)
.read_line(&mut resp_line)
.map_err(|e| SafeError::InvalidVault {
reason: format!("agent read: {e}"),
})?;
let resp: AgentResponse =
serde_json::from_str(resp_line.trim()).map_err(|e| SafeError::InvalidVault {
reason: format!("agent bad response: {e}"),
})?;
match resp {
AgentResponse::Password { password } => Ok(Some(password)),
AgentResponse::Err { reason } => Err(SafeError::InvalidVault {
reason: format!("agent denied: {reason}"),
}),
_ => Err(SafeError::InvalidVault {
reason: "unexpected agent response".into(),
}),
}
}
#[cfg(not(target_os = "windows"))]
pub fn request_password_from_agent(profile: &str) -> crate::errors::SafeResult<Option<String>> {
use crate::errors::SafeError;
let sock = match read_agent_sock_env().or_else(read_agent_sock) {
Some(s) => s,
None => return Ok(None),
};
let env_var_was_set = agent_sock_env_explicit();
let (pipe, token) = match parse_agent_sock(&sock) {
Some(v) => v,
None => {
if !env_var_was_set {
clear_agent_sock();
}
return Err(SafeError::InvalidVault {
reason: "malformed TSAFE_AGENT_SOCK: expected '{pipe}::{token_hex}'".into(),
});
}
};
let requesting_pid = std::process::id();
let req = AgentRequest::OpenVault {
profile: profile.to_string(),
session_token: token,
requesting_pid,
};
let resp = match agent_rpc_unix(&pipe, &req) {
Ok(r) => r,
Err(_) if !env_var_was_set => {
clear_agent_sock();
return Ok(None);
}
Err(e) => return Err(e),
};
match resp {
AgentResponse::Password { password } => Ok(Some(password)),
AgentResponse::Err { reason } => Err(SafeError::InvalidVault {
reason: format!("agent denied: {reason}"),
}),
_ => Err(SafeError::InvalidVault {
reason: "unexpected agent response".into(),
}),
}
}
#[cfg(not(target_os = "windows"))]
fn agent_rpc_unix(pipe: &str, req: &AgentRequest) -> crate::errors::SafeResult<AgentResponse> {
use crate::errors::SafeError;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
let mut stream = UnixStream::connect(pipe).map_err(|e| SafeError::InvalidVault {
reason: format!("could not connect to agent socket '{pipe}': {e}"),
})?;
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.map_err(|e| SafeError::InvalidVault {
reason: format!("agent set_read_timeout: {e}"),
})?;
let req_json = serde_json::to_string(req).map_err(|e| SafeError::Crypto {
context: e.to_string(),
})?;
writeln!(stream, "{req_json}").map_err(|e| SafeError::InvalidVault {
reason: format!("agent write: {e}"),
})?;
let mut resp_line = String::new();
BufReader::new(&stream)
.read_line(&mut resp_line)
.map_err(|e| SafeError::InvalidVault {
reason: format!("agent read: {e}"),
})?;
serde_json::from_str(resp_line.trim()).map_err(|e| SafeError::InvalidVault {
reason: format!("agent bad response: {e}"),
})
}
#[cfg(target_os = "windows")]
fn connect_pipe_client(pipe: &str) -> crate::errors::SafeResult<std::fs::File> {
use crate::errors::SafeError;
use std::os::windows::ffi::OsStrExt;
let wide: Vec<u16> = std::ffi::OsStr::new(pipe)
.encode_wide()
.chain(std::iter::once(0))
.collect();
extern "system" {
fn CreateFileW(
name: *const u16,
access: u32,
share: u32,
security: *mut std::ffi::c_void,
creation: u32,
flags: u32,
template: *mut std::ffi::c_void,
) -> *mut std::ffi::c_void;
}
let handle = unsafe {
CreateFileW(
wide.as_ptr(),
0xC000_0000,
0,
std::ptr::null_mut(),
3,
128,
std::ptr::null_mut(),
)
};
if handle.is_null() || handle as isize == -1 {
return Err(SafeError::InvalidVault {
reason: format!("could not connect to agent pipe '{pipe}' — is the agent running?"),
});
}
Ok(unsafe {
<std::fs::File as std::os::windows::io::FromRawHandle>::from_raw_handle(handle as _)
})
}
#[cfg(target_os = "windows")]
pub fn send_lock() -> crate::errors::SafeResult<()> {
use crate::errors::SafeError;
use std::io::Write;
let sock = match read_agent_sock_env().or_else(read_agent_sock) {
Some(s) => s,
None => return Ok(()),
};
let env_var_was_set = agent_sock_env_explicit();
let (pipe, token) = parse_agent_sock(&sock).ok_or_else(|| SafeError::InvalidVault {
reason: "malformed TSAFE_AGENT_SOCK".into(),
})?;
let req = AgentRequest::Lock {
session_token: token,
};
let req_json = serde_json::to_string(&req).map_err(|e| SafeError::Crypto {
context: e.to_string(),
})?;
let mut stream = match connect_pipe_client(&pipe) {
Ok(s) => s,
Err(_) if !env_var_was_set => {
clear_agent_sock();
return Ok(());
}
Err(e) => return Err(e),
};
let _ = writeln!(stream, "{req_json}");
Ok(())
}
#[cfg(not(target_os = "windows"))]
pub fn send_lock() -> crate::errors::SafeResult<()> {
use crate::errors::SafeError;
let sock = match read_agent_sock_env().or_else(read_agent_sock) {
Some(s) => s,
None => return Ok(()),
};
let env_var_was_set = agent_sock_env_explicit();
let (pipe, token) = parse_agent_sock(&sock).ok_or_else(|| SafeError::InvalidVault {
reason: "malformed TSAFE_AGENT_SOCK".into(),
})?;
let req = AgentRequest::Lock {
session_token: token,
};
match agent_rpc_unix(&pipe, &req) {
Ok(_) => {}
Err(_) if !env_var_was_set => {
clear_agent_sock();
}
Err(e) => return Err(e),
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn ping_agent(sock_val: &str) -> crate::errors::SafeResult<bool> {
use crate::errors::SafeError;
use std::io::{BufRead, BufReader, Write};
let (pipe, _token) = parse_agent_sock(sock_val).ok_or_else(|| SafeError::InvalidVault {
reason: "malformed agent socket value".into(),
})?;
let req_json = serde_json::to_string(&AgentRequest::Ping).map_err(|e| SafeError::Crypto {
context: e.to_string(),
})?;
let mut stream = connect_pipe_client(&pipe)?;
writeln!(stream, "{req_json}").map_err(|e| SafeError::InvalidVault {
reason: format!("ping write: {e}"),
})?;
let mut line = String::new();
BufReader::new(&stream)
.read_line(&mut line)
.map_err(|e| SafeError::InvalidVault {
reason: format!("ping read: {e}"),
})?;
let resp: AgentResponse =
serde_json::from_str(line.trim()).map_err(|e| SafeError::InvalidVault {
reason: format!("ping bad response: {e}"),
})?;
Ok(matches!(resp, AgentResponse::Ok))
}
#[cfg(not(target_os = "windows"))]
pub fn ping_agent(sock_val: &str) -> crate::errors::SafeResult<bool> {
use crate::errors::SafeError;
let (pipe, _token) = parse_agent_sock(sock_val).ok_or_else(|| SafeError::InvalidVault {
reason: "malformed agent socket value".into(),
})?;
let resp = agent_rpc_unix(&pipe, &AgentRequest::Ping)?;
Ok(matches!(resp, AgentResponse::Ok))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn parse_agent_sock_valid() {
let (pipe, token) = parse_agent_sock(r"\\.\pipe\tsafe-agent-1234::abcdef").unwrap();
assert_eq!(pipe, r"\\.\pipe\tsafe-agent-1234");
assert_eq!(token, "abcdef");
}
#[test]
fn parse_agent_sock_unix_path() {
let (pipe, token) = parse_agent_sock("/tmp/tsafe-agent-5678.sock::deadbeef").unwrap();
assert_eq!(pipe, "/tmp/tsafe-agent-5678.sock");
assert_eq!(token, "deadbeef");
}
#[test]
fn parse_agent_sock_malformed() {
assert!(parse_agent_sock("no-separator").is_none());
assert!(parse_agent_sock("::token_only").is_none());
assert!(parse_agent_sock("pipe_only::").is_none());
}
#[test]
fn format_then_parse_roundtrips() {
let sock = format_agent_sock("/tmp/test.sock", "abc123");
let (pipe, token) = parse_agent_sock(&sock).unwrap();
assert_eq!(pipe, "/tmp/test.sock");
assert_eq!(token, "abc123");
}
#[test]
fn pipe_name_contains_pid() {
let name = pipe_name(9999);
assert!(name.contains("9999"), "pipe_name should contain the PID");
}
#[cfg(not(target_os = "windows"))]
#[test]
fn pipe_name_avoids_overlong_unix_socket_path() {
let overlong = format!("/tmp/{}", "x".repeat(120));
let name = pipe_name_for_dirs(12345, [Some(overlong.clone()), Some(overlong.clone())]);
assert!(name.ends_with("tsafe-agent-12345.sock"));
assert!(
name.len() <= 100,
"socket path should fit conservative Unix limit: {name}"
);
assert!(
!name.starts_with(&overlong),
"overlong runtime dirs must not be used"
);
}
#[test]
fn session_allows_matching_open_vault_request() {
let now = Instant::now();
let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
let outcome = session.handle_request(
&AgentRequest::OpenVault {
profile: "default".into(),
session_token: "token-123".into(),
requesting_pid: 4242,
},
Some(4242),
"secret",
now,
);
assert!(!outcome.stop);
assert_eq!(outcome.state, AgentSessionState::Active);
match outcome.response {
AgentResponse::Password { password } => assert_eq!(password, "secret"),
other => panic!("expected password response, got {other:?}"),
}
}
#[test]
fn session_rejects_pid_mismatch_without_stopping() {
let now = Instant::now();
let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
let outcome = session.handle_request(
&AgentRequest::OpenVault {
profile: "default".into(),
session_token: "token-123".into(),
requesting_pid: 4242,
},
Some(9001),
"secret",
now,
);
assert!(!outcome.stop);
assert_eq!(outcome.state, AgentSessionState::Active);
match outcome.response {
AgentResponse::Err { reason } => {
assert!(reason.contains("does not match the connecting process"));
}
other => panic!("expected authorization error, got {other:?}"),
}
}
#[test]
fn session_expires_and_stops_on_non_lock_requests() {
let now = Instant::now();
let mut session = AgentSession::new("token-123", 60, now - Duration::from_secs(1));
let outcome = session.handle_request(&AgentRequest::Ping, Some(4242), "secret", now);
assert!(outcome.stop);
assert_eq!(outcome.state, AgentSessionState::Expired);
match outcome.response {
AgentResponse::Err { reason } => {
assert_eq!(reason, "agent session expired (absolute timeout)")
}
other => panic!("expected expiry error, got {other:?}"),
}
}
#[test]
fn session_lock_transitions_to_locked_and_rejects_follow_up_requests() {
let now = Instant::now();
let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
let lock_outcome = session.handle_request(
&AgentRequest::Lock {
session_token: "token-123".into(),
},
Some(4242),
"secret",
now,
);
assert!(lock_outcome.stop);
assert_eq!(lock_outcome.state, AgentSessionState::Locked);
assert!(matches!(lock_outcome.response, AgentResponse::Ok));
let ping_outcome = session.handle_request(&AgentRequest::Ping, Some(4242), "secret", now);
assert!(ping_outcome.stop);
assert_eq!(ping_outcome.state, AgentSessionState::Locked);
match ping_outcome.response {
AgentResponse::Err { reason } => assert_eq!(reason, "agent session locked"),
other => panic!("expected locked-session error, got {other:?}"),
}
}
#[test]
fn session_rejects_invalid_lock_token_without_locking() {
let now = Instant::now();
let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
let bad_lock = session.handle_request(
&AgentRequest::Lock {
session_token: "wrong-token".into(),
},
Some(4242),
"secret",
now,
);
assert!(!bad_lock.stop);
assert_eq!(bad_lock.state, AgentSessionState::Active);
match bad_lock.response {
AgentResponse::Err { reason } => assert_eq!(reason, "invalid session token"),
other => panic!("expected invalid-token error, got {other:?}"),
}
let follow_up = session.handle_request(
&AgentRequest::OpenVault {
profile: "default".into(),
session_token: "token-123".into(),
requesting_pid: 4242,
},
Some(4242),
"secret",
now,
);
assert!(!follow_up.stop);
assert_eq!(follow_up.state, AgentSessionState::Active);
match follow_up.response {
AgentResponse::Password { password } => assert_eq!(password, "secret"),
other => panic!("expected password response after invalid lock, got {other:?}"),
}
}
#[test]
fn invalid_open_token_does_not_refresh_idle_deadline() {
let now = Instant::now();
let absolute = now + Duration::from_secs(3600);
let mut session = AgentSession::new("token-123", 10, absolute);
let before_idle = session.idle_deadline;
let later = now + Duration::from_secs(5);
let outcome = session.handle_request(
&AgentRequest::OpenVault {
profile: "default".into(),
session_token: "wrong-token".into(),
requesting_pid: 1,
},
Some(1),
"secret",
later,
);
assert!(!outcome.stop);
assert_eq!(outcome.state, AgentSessionState::Active);
match outcome.response {
AgentResponse::Err { reason } => assert_eq!(reason, "invalid session token"),
other => panic!("expected invalid-token error, got {other:?}"),
}
assert_eq!(
session.idle_deadline, before_idle,
"denied requests must not extend the idle window"
);
}
#[test]
fn locked_session_rejects_open_vault_even_with_valid_credentials() {
let now = Instant::now();
let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
let _ = session.handle_request(
&AgentRequest::Lock {
session_token: "token-123".into(),
},
Some(4242),
"secret",
now,
);
let outcome = session.handle_request(
&AgentRequest::OpenVault {
profile: "default".into(),
session_token: "token-123".into(),
requesting_pid: 4242,
},
Some(4242),
"secret",
now,
);
assert!(outcome.stop);
assert_eq!(outcome.state, AgentSessionState::Locked);
match outcome.response {
AgentResponse::Err { reason } => assert_eq!(reason, "agent session locked"),
other => panic!("expected locked-session error, got {other:?}"),
}
}
#[test]
fn expired_session_rejects_open_vault_without_returning_password() {
let now = Instant::now();
let mut session = AgentSession::new("token-123", 60, now - Duration::from_secs(1));
let outcome = session.handle_request(
&AgentRequest::OpenVault {
profile: "default".into(),
session_token: "token-123".into(),
requesting_pid: 4242,
},
Some(4242),
"secret",
now,
);
assert!(outcome.stop);
assert_eq!(outcome.state, AgentSessionState::Expired);
match outcome.response {
AgentResponse::Err { reason } => {
assert_eq!(reason, "agent session expired (absolute timeout)")
}
other => panic!("expected expiry error, got {other:?}"),
}
}
#[test]
fn open_vault_refreshes_idle_deadline() {
let now = Instant::now();
let absolute = now + Duration::from_secs(3600);
let mut session = AgentSession::new("token-123", 10, absolute);
let before_idle = session.idle_deadline;
let later = now + Duration::from_secs(8);
session.handle_request(
&AgentRequest::OpenVault {
profile: "default".into(),
session_token: "token-123".into(),
requesting_pid: 1,
},
Some(1),
"pw",
later,
);
assert!(
session.idle_deadline > before_idle,
"idle_deadline should have advanced after OpenVault"
);
}
#[test]
fn idle_refresh_is_capped_at_absolute_deadline() {
let now = Instant::now();
let absolute = now + Duration::from_secs(5);
let mut session = AgentSession::new("token-123", 100, absolute);
session.handle_request(
&AgentRequest::OpenVault {
profile: "default".into(),
session_token: "token-123".into(),
requesting_pid: 1,
},
Some(1),
"pw",
now,
);
assert_eq!(
session.idle_deadline, session.absolute_deadline,
"idle_deadline must not exceed absolute_deadline"
);
}
#[test]
fn idle_timeout_produces_idle_timeout_reason() {
let now = Instant::now();
let absolute = now + Duration::from_secs(3600);
let mut session = AgentSession::new("token-123", 3600, absolute);
session.idle_deadline = now - Duration::from_secs(1);
let outcome = session.handle_request(&AgentRequest::Ping, Some(1), "pw", now);
assert!(outcome.stop);
match outcome.response {
AgentResponse::Err { reason } => assert!(
reason.contains("idle timeout"),
"expected idle timeout in reason, got: {reason}"
),
other => panic!("expected Err, got {other:?}"),
}
}
#[test]
fn absolute_timeout_produces_absolute_timeout_reason() {
let now = Instant::now();
let absolute = now - Duration::from_secs(1);
let mut session = AgentSession::new("token-123", 3600, absolute);
let outcome = session.handle_request(&AgentRequest::Ping, Some(1), "pw", now);
assert!(outcome.stop);
match outcome.response {
AgentResponse::Err { reason } => assert!(
reason.contains("absolute timeout"),
"expected absolute timeout in reason, got: {reason}"
),
other => panic!("expected Err, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn unix_socket_ping_roundtrip() {
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixListener;
let dir = tempfile::tempdir().unwrap();
let sock_path = dir.path().join("test-agent.sock");
let sock_str = sock_path.to_str().unwrap().to_string();
let listener = UnixListener::bind(&sock_path).unwrap();
let handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(&stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let _req: AgentRequest = serde_json::from_str(line.trim()).unwrap();
let resp = AgentResponse::Ok;
let mut writer = &stream;
writeln!(writer, "{}", serde_json::to_string(&resp).unwrap()).unwrap();
});
let resp = agent_rpc_unix(&sock_str, &AgentRequest::Ping).unwrap();
assert!(matches!(resp, AgentResponse::Ok));
handle.join().unwrap();
}
}