1use std::path::PathBuf;
23use std::time::{Duration, Instant};
24
25use serde::{Deserialize, Serialize};
26
27pub const ENV_AGENT_SOCK: &str = "TSAFE_AGENT_SOCK";
32
33pub const ENV_CELLOS_SOCK: &str = "TSAFE_SOCKET";
35
36pub fn cellos_socket_path() -> std::path::PathBuf {
38 if let Ok(p) = std::env::var(ENV_CELLOS_SOCK) {
39 return std::path::PathBuf::from(p);
40 }
41 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
42 std::path::PathBuf::from(home)
43 .join(".tsafe")
44 .join("agent.sock")
45}
46
47#[derive(Debug, Clone)]
51pub struct CellRecord {
52 pub pid: u32,
54 pub token: String,
56}
57
58#[derive(Debug, Clone)]
60pub enum CellState {
61 Active(CellRecord),
62 Revoked,
63}
64
65#[derive(Debug, Serialize, Deserialize)]
69#[serde(tag = "op")]
70pub enum CellosRequest {
71 Resolve {
77 key: String,
78 cell_id: String,
79 ttl_seconds: u64,
80 cell_token: String,
81 },
82 RevokeForCell { cell_id: String },
84}
85
86#[derive(Debug, Serialize, Deserialize)]
88#[serde(tag = "status")]
89pub enum CellosResponse {
90 Value { value: String },
92 Ok,
94 Err { error: String },
96}
97
98pub fn read_agent_sock_env() -> Option<String> {
99 std::env::var(ENV_AGENT_SOCK).ok()
100}
101
102fn agent_sock_env_explicit() -> bool {
103 std::env::var(ENV_AGENT_SOCK).is_ok()
104}
105
106#[derive(Debug, Serialize, Deserialize)]
109#[serde(tag = "op")]
110pub enum AgentRequest {
111 OpenVault {
115 profile: String,
116 session_token: String, requesting_pid: u32,
118 },
119 Lock { session_token: String },
121 Ping,
123}
124
125#[derive(Debug, Serialize, Deserialize)]
126#[serde(tag = "status")]
127pub enum AgentResponse {
128 Password {
131 password: String, },
133 Ok,
134 Err {
135 reason: String,
136 },
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum AgentSessionState {
143 Active,
144 Locked,
145 Expired,
146}
147
148#[derive(Debug)]
149pub struct AgentSession {
150 session_token: String,
151 idle_deadline: Instant,
154 absolute_deadline: Instant,
156 idle_secs: u64,
158 state: AgentSessionState,
159}
160
161#[derive(Debug)]
162pub struct AgentSessionOutcome {
163 pub response: AgentResponse,
164 pub stop: bool,
165 pub state: AgentSessionState,
166}
167
168impl AgentSession {
169 pub fn new(
174 session_token: impl Into<String>,
175 idle_secs: u64,
176 absolute_deadline: Instant,
177 ) -> Self {
178 let idle_deadline =
179 (Instant::now() + Duration::from_secs(idle_secs)).min(absolute_deadline);
180 Self {
181 session_token: session_token.into(),
182 idle_deadline,
183 absolute_deadline,
184 idle_secs,
185 state: AgentSessionState::Active,
186 }
187 }
188
189 pub fn state(&self, now: Instant) -> AgentSessionState {
190 match self.state {
191 AgentSessionState::Active
192 if now >= self.idle_deadline || now >= self.absolute_deadline =>
193 {
194 AgentSessionState::Expired
195 }
196 state => state,
197 }
198 }
199
200 pub fn handle_request(
201 &mut self,
202 req: &AgentRequest,
203 peer_pid: Option<u32>,
204 password: &str,
205 now: Instant,
206 ) -> AgentSessionOutcome {
207 if let AgentRequest::Lock { session_token } = req {
208 if session_token == &self.session_token {
209 self.state = AgentSessionState::Locked;
210 return AgentSessionOutcome {
211 response: AgentResponse::Ok,
212 stop: true,
213 state: self.state,
214 };
215 }
216 return self.deny("invalid session token", false, now);
217 }
218
219 let expiry_reason = self.sync_expiry(now);
220 match self.state {
221 AgentSessionState::Expired => {
222 let reason = expiry_reason.unwrap_or("agent session expired");
223 self.deny(reason, true, now)
224 }
225 AgentSessionState::Locked => self.deny("agent session locked", true, now),
226 AgentSessionState::Active => match req {
227 AgentRequest::Ping => AgentSessionOutcome {
228 response: AgentResponse::Ok,
229 stop: false,
230 state: self.state,
231 },
232 AgentRequest::OpenVault {
233 profile: _,
234 session_token,
235 requesting_pid,
236 } => {
237 if session_token != &self.session_token {
238 self.deny("invalid session token", false, now)
239 } else if peer_pid != Some(*requesting_pid) {
240 self.deny(
241 "requesting PID does not match the connecting process",
242 false,
243 now,
244 )
245 } else {
246 self.idle_deadline =
248 (now + Duration::from_secs(self.idle_secs)).min(self.absolute_deadline);
249 AgentSessionOutcome {
250 response: AgentResponse::Password {
251 password: password.to_string(),
252 },
253 stop: false,
254 state: self.state,
255 }
256 }
257 }
258 AgentRequest::Lock { .. } => unreachable!("lock handled above"),
259 },
260 }
261 }
262
263 fn sync_expiry(&mut self, now: Instant) -> Option<&'static str> {
266 if matches!(self.state, AgentSessionState::Active) {
267 if now >= self.absolute_deadline {
268 self.state = AgentSessionState::Expired;
269 return Some("agent session expired (absolute timeout)");
270 }
271 if now >= self.idle_deadline {
272 self.state = AgentSessionState::Expired;
273 return Some("agent session expired (idle timeout)");
274 }
275 }
276 None
277 }
278
279 fn deny(&mut self, reason: &str, stop: bool, now: Instant) -> AgentSessionOutcome {
280 self.sync_expiry(now);
281 AgentSessionOutcome {
282 response: AgentResponse::Err {
283 reason: reason.to_string(),
284 },
285 stop,
286 state: self.state(now),
287 }
288 }
289}
290
291pub fn agent_sock_path() -> PathBuf {
297 crate::profile::app_state_dir().join("agent.sock")
298}
299
300pub fn write_agent_sock(sock_val: &str) {
303 let path = agent_sock_path();
304 if let Some(parent) = path.parent() {
305 let _ = std::fs::create_dir_all(parent);
306 }
307 let tmp = path.with_extension("sock.tmp");
309 if std::fs::write(&tmp, sock_val).is_ok() {
310 #[cfg(unix)]
311 {
312 use std::os::unix::fs::PermissionsExt;
313 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
314 }
315 let _ = std::fs::rename(&tmp, &path);
316 }
317}
318
319pub fn read_agent_sock() -> Option<String> {
321 std::fs::read_to_string(agent_sock_path())
322 .ok()
323 .map(|s| s.trim().to_string())
324 .filter(|s| !s.is_empty())
325}
326
327pub fn clear_agent_sock() {
329 let _ = std::fs::remove_file(agent_sock_path());
330}
331#[cfg(target_os = "windows")]
338pub fn pipe_name(agent_pid: u32) -> String {
339 format!(r"\\.\pipe\tsafe-agent-{agent_pid}")
340}
341
342#[cfg(not(target_os = "windows"))]
343pub fn pipe_name(agent_pid: u32) -> String {
344 let candidate_dirs = [
345 std::env::var("XDG_RUNTIME_DIR").ok(),
346 std::env::var("TMPDIR").ok(),
347 ];
348 pipe_name_for_dirs(agent_pid, candidate_dirs)
349}
350
351#[cfg(not(target_os = "windows"))]
352fn pipe_name_for_dirs(agent_pid: u32, candidate_dirs: [Option<String>; 2]) -> String {
353 use std::os::unix::ffi::OsStrExt;
354
355 const MAX_UNIX_SOCKET_PATH_BYTES: usize = 100;
356
357 let filename = format!("tsafe-agent-{agent_pid}.sock");
358
359 for dir in candidate_dirs
360 .into_iter()
361 .flatten()
362 .filter(|d| !d.is_empty())
363 {
364 let candidate = std::path::Path::new(&dir).join(&filename);
365 if candidate.as_os_str().as_bytes().len() <= MAX_UNIX_SOCKET_PATH_BYTES {
366 return candidate.to_string_lossy().into_owned();
367 }
368 }
369
370 let fallback_dir = short_agent_runtime_dir();
371 std::path::Path::new(&fallback_dir)
372 .join(filename)
373 .to_string_lossy()
374 .into_owned()
375}
376
377#[cfg(not(target_os = "windows"))]
378fn short_agent_runtime_dir() -> String {
379 let uid = unsafe { libc::getuid() };
382 let dir = std::path::PathBuf::from(format!("/tmp/tsafe-agent-{uid}"));
383 if std::fs::create_dir_all(&dir).is_ok() {
384 #[cfg(unix)]
385 {
386 use std::os::unix::fs::PermissionsExt;
387 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
388 }
389 return dir.to_string_lossy().into_owned();
390 }
391 "/tmp".to_string()
392}
393
394pub fn parse_agent_sock(sock: &str) -> Option<(String, String)> {
396 let idx = sock.rfind("::")?;
398 let pipe = sock[..idx].to_string();
399 let token = sock[idx + 2..].to_string();
400 if pipe.is_empty() || token.is_empty() {
401 return None;
402 }
403 Some((pipe, token))
404}
405
406pub fn format_agent_sock(pipe: &str, token_hex: &str) -> String {
408 format!("{pipe}::{token_hex}")
409}
410
411#[cfg(target_os = "windows")]
422pub fn request_password_from_agent(profile: &str) -> crate::errors::SafeResult<Option<String>> {
423 use crate::errors::SafeError;
424 use std::io::{BufRead, BufReader, Write};
425
426 let sock = match read_agent_sock_env().or_else(read_agent_sock) {
428 Some(s) => s,
429 None => return Ok(None),
430 };
431 let env_var_was_set = agent_sock_env_explicit();
432 let (pipe, token) = match parse_agent_sock(&sock) {
433 Some(v) => v,
434 None => {
435 if !env_var_was_set {
437 clear_agent_sock();
438 }
439 return Err(SafeError::InvalidVault {
440 reason: "malformed TSAFE_AGENT_SOCK: expected '{pipe}::{token_hex}'".into(),
441 });
442 }
443 };
444 let requesting_pid = std::process::id();
445
446 let req = AgentRequest::OpenVault {
447 profile: profile.to_string(),
448 session_token: token,
449 requesting_pid,
450 };
451 let req_json = serde_json::to_string(&req).map_err(|e| SafeError::Crypto {
452 context: e.to_string(),
453 })?;
454
455 let mut stream = match connect_pipe_client(&pipe) {
456 Ok(s) => s,
457 Err(_) if !env_var_was_set => {
458 clear_agent_sock();
460 return Ok(None);
461 }
462 Err(e) => return Err(e),
463 };
464 writeln!(stream, "{req_json}").map_err(|e| SafeError::InvalidVault {
468 reason: format!("agent write: {e}"),
469 })?;
470
471 let mut resp_line = String::new();
472 BufReader::new(&stream)
473 .read_line(&mut resp_line)
474 .map_err(|e| SafeError::InvalidVault {
475 reason: format!("agent read: {e}"),
476 })?;
477
478 let resp: AgentResponse =
479 serde_json::from_str(resp_line.trim()).map_err(|e| SafeError::InvalidVault {
480 reason: format!("agent bad response: {e}"),
481 })?;
482
483 match resp {
484 AgentResponse::Password { password } => Ok(Some(password)),
485 AgentResponse::Err { reason } => Err(SafeError::InvalidVault {
486 reason: format!("agent denied: {reason}"),
487 }),
488 _ => Err(SafeError::InvalidVault {
489 reason: "unexpected agent response".into(),
490 }),
491 }
492}
493
494#[cfg(not(target_os = "windows"))]
497pub fn request_password_from_agent(profile: &str) -> crate::errors::SafeResult<Option<String>> {
498 use crate::errors::SafeError;
499
500 let sock = match read_agent_sock_env().or_else(read_agent_sock) {
502 Some(s) => s,
503 None => return Ok(None),
504 };
505 let env_var_was_set = agent_sock_env_explicit();
506 let (pipe, token) = match parse_agent_sock(&sock) {
507 Some(v) => v,
508 None => {
509 if !env_var_was_set {
510 clear_agent_sock();
511 }
512 return Err(SafeError::InvalidVault {
513 reason: "malformed TSAFE_AGENT_SOCK: expected '{pipe}::{token_hex}'".into(),
514 });
515 }
516 };
517 let requesting_pid = std::process::id();
518
519 let req = AgentRequest::OpenVault {
520 profile: profile.to_string(),
521 session_token: token,
522 requesting_pid,
523 };
524
525 let resp = match agent_rpc_unix(&pipe, &req) {
526 Ok(r) => r,
527 Err(_) if !env_var_was_set => {
528 clear_agent_sock();
530 return Ok(None);
531 }
532 Err(e) => return Err(e),
533 };
534
535 match resp {
536 AgentResponse::Password { password } => Ok(Some(password)),
537 AgentResponse::Err { reason } => Err(SafeError::InvalidVault {
538 reason: format!("agent denied: {reason}"),
539 }),
540 _ => Err(SafeError::InvalidVault {
541 reason: "unexpected agent response".into(),
542 }),
543 }
544}
545
546#[cfg(not(target_os = "windows"))]
547fn agent_rpc_unix(pipe: &str, req: &AgentRequest) -> crate::errors::SafeResult<AgentResponse> {
548 use crate::errors::SafeError;
549 use std::io::{BufRead, BufReader, Write};
550 use std::os::unix::net::UnixStream;
551
552 let mut stream = UnixStream::connect(pipe).map_err(|e| SafeError::InvalidVault {
553 reason: format!("could not connect to agent socket '{pipe}': {e}"),
554 })?;
555 stream
556 .set_read_timeout(Some(Duration::from_secs(5)))
557 .map_err(|e| SafeError::InvalidVault {
558 reason: format!("agent set_read_timeout: {e}"),
559 })?;
560 let req_json = serde_json::to_string(req).map_err(|e| SafeError::Crypto {
561 context: e.to_string(),
562 })?;
563 writeln!(stream, "{req_json}").map_err(|e| SafeError::InvalidVault {
564 reason: format!("agent write: {e}"),
565 })?;
566
567 let mut resp_line = String::new();
568 BufReader::new(&stream)
569 .read_line(&mut resp_line)
570 .map_err(|e| SafeError::InvalidVault {
571 reason: format!("agent read: {e}"),
572 })?;
573
574 serde_json::from_str(resp_line.trim()).map_err(|e| SafeError::InvalidVault {
575 reason: format!("agent bad response: {e}"),
576 })
577}
578
579#[cfg(target_os = "windows")]
582fn connect_pipe_client(pipe: &str) -> crate::errors::SafeResult<std::fs::File> {
583 use crate::errors::SafeError;
584 use std::os::windows::ffi::OsStrExt;
585
586 let wide: Vec<u16> = std::ffi::OsStr::new(pipe)
587 .encode_wide()
588 .chain(std::iter::once(0))
589 .collect();
590
591 extern "system" {
592 fn CreateFileW(
593 name: *const u16,
594 access: u32,
595 share: u32,
596 security: *mut std::ffi::c_void,
597 creation: u32,
598 flags: u32,
599 template: *mut std::ffi::c_void,
600 ) -> *mut std::ffi::c_void;
601 }
602
603 let handle = unsafe {
610 CreateFileW(
612 wide.as_ptr(),
613 0xC000_0000,
614 0,
615 std::ptr::null_mut(),
616 3,
617 128,
618 std::ptr::null_mut(),
619 )
620 };
621
622 if handle.is_null() || handle as isize == -1 {
623 return Err(SafeError::InvalidVault {
624 reason: format!("could not connect to agent pipe '{pipe}' — is the agent running?"),
625 });
626 }
627
628 Ok(unsafe {
634 <std::fs::File as std::os::windows::io::FromRawHandle>::from_raw_handle(handle as _)
635 })
636}
637
638#[cfg(target_os = "windows")]
643pub fn send_lock() -> crate::errors::SafeResult<()> {
644 use crate::errors::SafeError;
645 use std::io::Write;
646
647 let sock = match read_agent_sock_env().or_else(read_agent_sock) {
648 Some(s) => s,
649 None => return Ok(()),
650 };
651 let env_var_was_set = agent_sock_env_explicit();
652 let (pipe, token) = parse_agent_sock(&sock).ok_or_else(|| SafeError::InvalidVault {
653 reason: "malformed TSAFE_AGENT_SOCK".into(),
654 })?;
655 let req = AgentRequest::Lock {
656 session_token: token,
657 };
658 let req_json = serde_json::to_string(&req).map_err(|e| SafeError::Crypto {
659 context: e.to_string(),
660 })?;
661 let mut stream = match connect_pipe_client(&pipe) {
662 Ok(s) => s,
663 Err(_) if !env_var_was_set => {
664 clear_agent_sock();
665 return Ok(());
666 }
667 Err(e) => return Err(e),
668 };
669 let _ = writeln!(stream, "{req_json}");
670 Ok(())
671}
672
673#[cfg(not(target_os = "windows"))]
674pub fn send_lock() -> crate::errors::SafeResult<()> {
675 use crate::errors::SafeError;
676
677 let sock = match read_agent_sock_env().or_else(read_agent_sock) {
678 Some(s) => s,
679 None => return Ok(()),
680 };
681 let env_var_was_set = agent_sock_env_explicit();
682 let (pipe, token) = parse_agent_sock(&sock).ok_or_else(|| SafeError::InvalidVault {
683 reason: "malformed TSAFE_AGENT_SOCK".into(),
684 })?;
685 let req = AgentRequest::Lock {
686 session_token: token,
687 };
688 match agent_rpc_unix(&pipe, &req) {
689 Ok(_) => {}
690 Err(_) if !env_var_was_set => {
691 clear_agent_sock();
692 }
693 Err(e) => return Err(e),
694 }
695 Ok(())
696}
697
698#[cfg(target_os = "windows")]
703pub fn ping_agent(sock_val: &str) -> crate::errors::SafeResult<bool> {
704 use crate::errors::SafeError;
705 use std::io::{BufRead, BufReader, Write};
706
707 let (pipe, _token) = parse_agent_sock(sock_val).ok_or_else(|| SafeError::InvalidVault {
708 reason: "malformed agent socket value".into(),
709 })?;
710 let req_json = serde_json::to_string(&AgentRequest::Ping).map_err(|e| SafeError::Crypto {
711 context: e.to_string(),
712 })?;
713 let mut stream = connect_pipe_client(&pipe)?;
714 writeln!(stream, "{req_json}").map_err(|e| SafeError::InvalidVault {
715 reason: format!("ping write: {e}"),
716 })?;
717 let mut line = String::new();
718 BufReader::new(&stream)
719 .read_line(&mut line)
720 .map_err(|e| SafeError::InvalidVault {
721 reason: format!("ping read: {e}"),
722 })?;
723 let resp: AgentResponse =
724 serde_json::from_str(line.trim()).map_err(|e| SafeError::InvalidVault {
725 reason: format!("ping bad response: {e}"),
726 })?;
727 Ok(matches!(resp, AgentResponse::Ok))
728}
729
730#[cfg(not(target_os = "windows"))]
731pub fn ping_agent(sock_val: &str) -> crate::errors::SafeResult<bool> {
732 use crate::errors::SafeError;
733
734 let (pipe, _token) = parse_agent_sock(sock_val).ok_or_else(|| SafeError::InvalidVault {
735 reason: "malformed agent socket value".into(),
736 })?;
737 let resp = agent_rpc_unix(&pipe, &AgentRequest::Ping)?;
738 Ok(matches!(resp, AgentResponse::Ok))
739}
740
741#[cfg(test)]
744mod tests {
745 use super::*;
746 use std::time::Duration;
747
748 #[test]
749 fn parse_agent_sock_valid() {
750 let (pipe, token) = parse_agent_sock(r"\\.\pipe\tsafe-agent-1234::abcdef").unwrap();
751 assert_eq!(pipe, r"\\.\pipe\tsafe-agent-1234");
752 assert_eq!(token, "abcdef");
753 }
754
755 #[test]
756 fn parse_agent_sock_unix_path() {
757 let (pipe, token) = parse_agent_sock("/tmp/tsafe-agent-5678.sock::deadbeef").unwrap();
758 assert_eq!(pipe, "/tmp/tsafe-agent-5678.sock");
759 assert_eq!(token, "deadbeef");
760 }
761
762 #[test]
763 fn parse_agent_sock_malformed() {
764 assert!(parse_agent_sock("no-separator").is_none());
765 assert!(parse_agent_sock("::token_only").is_none());
766 assert!(parse_agent_sock("pipe_only::").is_none());
767 }
768
769 #[test]
770 fn format_then_parse_roundtrips() {
771 let sock = format_agent_sock("/tmp/test.sock", "abc123");
772 let (pipe, token) = parse_agent_sock(&sock).unwrap();
773 assert_eq!(pipe, "/tmp/test.sock");
774 assert_eq!(token, "abc123");
775 }
776
777 #[test]
778 fn pipe_name_contains_pid() {
779 let name = pipe_name(9999);
780 assert!(name.contains("9999"), "pipe_name should contain the PID");
781 }
782
783 #[cfg(not(target_os = "windows"))]
784 #[test]
785 fn pipe_name_avoids_overlong_unix_socket_path() {
786 let overlong = format!("/tmp/{}", "x".repeat(120));
787 let name = pipe_name_for_dirs(12345, [Some(overlong.clone()), Some(overlong.clone())]);
788
789 assert!(name.ends_with("tsafe-agent-12345.sock"));
790 assert!(
791 name.len() <= 100,
792 "socket path should fit conservative Unix limit: {name}"
793 );
794 assert!(
795 !name.starts_with(&overlong),
796 "overlong runtime dirs must not be used"
797 );
798 }
799
800 #[test]
801 fn session_allows_matching_open_vault_request() {
802 let now = Instant::now();
803 let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
804 let outcome = session.handle_request(
805 &AgentRequest::OpenVault {
806 profile: "default".into(),
807 session_token: "token-123".into(),
808 requesting_pid: 4242,
809 },
810 Some(4242),
811 "secret",
812 now,
813 );
814
815 assert!(!outcome.stop);
816 assert_eq!(outcome.state, AgentSessionState::Active);
817 match outcome.response {
818 AgentResponse::Password { password } => assert_eq!(password, "secret"),
819 other => panic!("expected password response, got {other:?}"),
820 }
821 }
822
823 #[test]
824 fn session_rejects_pid_mismatch_without_stopping() {
825 let now = Instant::now();
826 let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
827 let outcome = session.handle_request(
828 &AgentRequest::OpenVault {
829 profile: "default".into(),
830 session_token: "token-123".into(),
831 requesting_pid: 4242,
832 },
833 Some(9001),
834 "secret",
835 now,
836 );
837
838 assert!(!outcome.stop);
839 assert_eq!(outcome.state, AgentSessionState::Active);
840 match outcome.response {
841 AgentResponse::Err { reason } => {
842 assert!(reason.contains("does not match the connecting process"));
843 }
844 other => panic!("expected authorization error, got {other:?}"),
845 }
846 }
847
848 #[test]
849 fn session_expires_and_stops_on_non_lock_requests() {
850 let now = Instant::now();
851 let mut session = AgentSession::new("token-123", 60, now - Duration::from_secs(1));
852 let outcome = session.handle_request(&AgentRequest::Ping, Some(4242), "secret", now);
853
854 assert!(outcome.stop);
855 assert_eq!(outcome.state, AgentSessionState::Expired);
856 match outcome.response {
857 AgentResponse::Err { reason } => {
858 assert_eq!(reason, "agent session expired (absolute timeout)")
859 }
860 other => panic!("expected expiry error, got {other:?}"),
861 }
862 }
863
864 #[test]
865 fn session_lock_transitions_to_locked_and_rejects_follow_up_requests() {
866 let now = Instant::now();
867 let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
868 let lock_outcome = session.handle_request(
869 &AgentRequest::Lock {
870 session_token: "token-123".into(),
871 },
872 Some(4242),
873 "secret",
874 now,
875 );
876
877 assert!(lock_outcome.stop);
878 assert_eq!(lock_outcome.state, AgentSessionState::Locked);
879 assert!(matches!(lock_outcome.response, AgentResponse::Ok));
880
881 let ping_outcome = session.handle_request(&AgentRequest::Ping, Some(4242), "secret", now);
882 assert!(ping_outcome.stop);
883 assert_eq!(ping_outcome.state, AgentSessionState::Locked);
884 match ping_outcome.response {
885 AgentResponse::Err { reason } => assert_eq!(reason, "agent session locked"),
886 other => panic!("expected locked-session error, got {other:?}"),
887 }
888 }
889
890 #[test]
891 fn session_rejects_invalid_lock_token_without_locking() {
892 let now = Instant::now();
893 let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
894
895 let bad_lock = session.handle_request(
896 &AgentRequest::Lock {
897 session_token: "wrong-token".into(),
898 },
899 Some(4242),
900 "secret",
901 now,
902 );
903
904 assert!(!bad_lock.stop);
905 assert_eq!(bad_lock.state, AgentSessionState::Active);
906 match bad_lock.response {
907 AgentResponse::Err { reason } => assert_eq!(reason, "invalid session token"),
908 other => panic!("expected invalid-token error, got {other:?}"),
909 }
910
911 let follow_up = session.handle_request(
912 &AgentRequest::OpenVault {
913 profile: "default".into(),
914 session_token: "token-123".into(),
915 requesting_pid: 4242,
916 },
917 Some(4242),
918 "secret",
919 now,
920 );
921
922 assert!(!follow_up.stop);
923 assert_eq!(follow_up.state, AgentSessionState::Active);
924 match follow_up.response {
925 AgentResponse::Password { password } => assert_eq!(password, "secret"),
926 other => panic!("expected password response after invalid lock, got {other:?}"),
927 }
928 }
929
930 #[test]
931 fn invalid_open_token_does_not_refresh_idle_deadline() {
932 let now = Instant::now();
933 let absolute = now + Duration::from_secs(3600);
934 let mut session = AgentSession::new("token-123", 10, absolute);
935 let before_idle = session.idle_deadline;
936 let later = now + Duration::from_secs(5);
937
938 let outcome = session.handle_request(
939 &AgentRequest::OpenVault {
940 profile: "default".into(),
941 session_token: "wrong-token".into(),
942 requesting_pid: 1,
943 },
944 Some(1),
945 "secret",
946 later,
947 );
948
949 assert!(!outcome.stop);
950 assert_eq!(outcome.state, AgentSessionState::Active);
951 match outcome.response {
952 AgentResponse::Err { reason } => assert_eq!(reason, "invalid session token"),
953 other => panic!("expected invalid-token error, got {other:?}"),
954 }
955 assert_eq!(
956 session.idle_deadline, before_idle,
957 "denied requests must not extend the idle window"
958 );
959 }
960
961 #[test]
962 fn locked_session_rejects_open_vault_even_with_valid_credentials() {
963 let now = Instant::now();
964 let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
965 let _ = session.handle_request(
966 &AgentRequest::Lock {
967 session_token: "token-123".into(),
968 },
969 Some(4242),
970 "secret",
971 now,
972 );
973
974 let outcome = session.handle_request(
975 &AgentRequest::OpenVault {
976 profile: "default".into(),
977 session_token: "token-123".into(),
978 requesting_pid: 4242,
979 },
980 Some(4242),
981 "secret",
982 now,
983 );
984
985 assert!(outcome.stop);
986 assert_eq!(outcome.state, AgentSessionState::Locked);
987 match outcome.response {
988 AgentResponse::Err { reason } => assert_eq!(reason, "agent session locked"),
989 other => panic!("expected locked-session error, got {other:?}"),
990 }
991 }
992
993 #[test]
994 fn expired_session_rejects_open_vault_without_returning_password() {
995 let now = Instant::now();
996 let mut session = AgentSession::new("token-123", 60, now - Duration::from_secs(1));
997
998 let outcome = session.handle_request(
999 &AgentRequest::OpenVault {
1000 profile: "default".into(),
1001 session_token: "token-123".into(),
1002 requesting_pid: 4242,
1003 },
1004 Some(4242),
1005 "secret",
1006 now,
1007 );
1008
1009 assert!(outcome.stop);
1010 assert_eq!(outcome.state, AgentSessionState::Expired);
1011 match outcome.response {
1012 AgentResponse::Err { reason } => {
1013 assert_eq!(reason, "agent session expired (absolute timeout)")
1014 }
1015 other => panic!("expected expiry error, got {other:?}"),
1016 }
1017 }
1018
1019 #[test]
1022 fn open_vault_refreshes_idle_deadline() {
1023 let now = Instant::now();
1024 let absolute = now + Duration::from_secs(3600);
1025 let mut session = AgentSession::new("token-123", 10, absolute);
1026 let before_idle = session.idle_deadline;
1027
1028 let later = now + Duration::from_secs(8);
1030 session.handle_request(
1031 &AgentRequest::OpenVault {
1032 profile: "default".into(),
1033 session_token: "token-123".into(),
1034 requesting_pid: 1,
1035 },
1036 Some(1),
1037 "pw",
1038 later,
1039 );
1040
1041 assert!(
1042 session.idle_deadline > before_idle,
1043 "idle_deadline should have advanced after OpenVault"
1044 );
1045 }
1046
1047 #[test]
1048 fn idle_refresh_is_capped_at_absolute_deadline() {
1049 let now = Instant::now();
1050 let absolute = now + Duration::from_secs(5);
1052 let mut session = AgentSession::new("token-123", 100, absolute);
1053
1054 session.handle_request(
1055 &AgentRequest::OpenVault {
1056 profile: "default".into(),
1057 session_token: "token-123".into(),
1058 requesting_pid: 1,
1059 },
1060 Some(1),
1061 "pw",
1062 now,
1063 );
1064
1065 assert_eq!(
1066 session.idle_deadline, session.absolute_deadline,
1067 "idle_deadline must not exceed absolute_deadline"
1068 );
1069 }
1070
1071 #[test]
1072 fn idle_timeout_produces_idle_timeout_reason() {
1073 let now = Instant::now();
1074 let absolute = now + Duration::from_secs(3600);
1076 let mut session = AgentSession::new("token-123", 3600, absolute);
1077 session.idle_deadline = now - Duration::from_secs(1);
1078
1079 let outcome = session.handle_request(&AgentRequest::Ping, Some(1), "pw", now);
1080 assert!(outcome.stop);
1081 match outcome.response {
1082 AgentResponse::Err { reason } => assert!(
1083 reason.contains("idle timeout"),
1084 "expected idle timeout in reason, got: {reason}"
1085 ),
1086 other => panic!("expected Err, got {other:?}"),
1087 }
1088 }
1089
1090 #[test]
1091 fn absolute_timeout_produces_absolute_timeout_reason() {
1092 let now = Instant::now();
1093 let absolute = now - Duration::from_secs(1);
1095 let mut session = AgentSession::new("token-123", 3600, absolute);
1096
1097 let outcome = session.handle_request(&AgentRequest::Ping, Some(1), "pw", now);
1098 assert!(outcome.stop);
1099 match outcome.response {
1100 AgentResponse::Err { reason } => assert!(
1101 reason.contains("absolute timeout"),
1102 "expected absolute timeout in reason, got: {reason}"
1103 ),
1104 other => panic!("expected Err, got {other:?}"),
1105 }
1106 }
1107
1108 #[cfg(unix)]
1110 #[test]
1111 fn unix_socket_ping_roundtrip() {
1112 use std::io::{BufRead, BufReader, Write};
1113 use std::os::unix::net::UnixListener;
1114
1115 let dir = tempfile::tempdir().unwrap();
1116 let sock_path = dir.path().join("test-agent.sock");
1117 let sock_str = sock_path.to_str().unwrap().to_string();
1118
1119 let listener = UnixListener::bind(&sock_path).unwrap();
1120
1121 let handle = std::thread::spawn(move || {
1123 let (stream, _) = listener.accept().unwrap();
1124 let mut reader = BufReader::new(&stream);
1125 let mut line = String::new();
1126 reader.read_line(&mut line).unwrap();
1127 let _req: AgentRequest = serde_json::from_str(line.trim()).unwrap();
1129 let resp = AgentResponse::Ok;
1130 let mut writer = &stream;
1131 writeln!(writer, "{}", serde_json::to_string(&resp).unwrap()).unwrap();
1132 });
1133
1134 let resp = agent_rpc_unix(&sock_str, &AgentRequest::Ping).unwrap();
1136 assert!(matches!(resp, AgentResponse::Ok));
1137
1138 handle.join().unwrap();
1139 }
1140}