1#[cfg(not(target_os = "windows"))]
13use std::collections::HashMap;
14use std::io::{BufRead, BufReader, Write};
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17#[cfg(not(target_os = "windows"))]
18use std::sync::Mutex;
19use std::time::{Duration, Instant};
20
21use anyhow::{Context, Result};
22use zeroize::{Zeroize, ZeroizeOnDrop};
23
24#[cfg(not(target_os = "windows"))]
25use tsafe_core::agent::{cellos_socket_path, CellRecord, CellState, CellosRequest, CellosResponse};
26use tsafe_core::agent::{
27 clear_agent_sock, pipe_name, write_agent_sock, AgentRequest, AgentResponse, AgentSession,
28};
29#[cfg(not(target_os = "windows"))]
30use tsafe_core::audit::{AuditCellosContext, AuditContext, AuditEntry, AuditLog};
31use tsafe_core::profile;
32use tsafe_core::{keyring_store, vault::Vault};
33
34const HELP: &str = "\
35tsafe-agent — local session agent for tsafe
36
37Usage:
38 tsafe-agent <profile> <session_token_hex> <requesting_pid> <idle_ttl_secs> <absolute_ttl_secs>
39
40This companion runtime is normally launched by `tsafe agent unlock`.
41It holds one profile's unlock state in memory for the configured TTL and serves
42same-user callers over a local named pipe or Unix socket. It does not send telemetry
43or analytics. It also does not send crash reports, update pings, secret names,
44or secret values to a vendor service.
45
46Local surfaces:
47 - named pipe or Unix socket IPC
48 - in-memory unlock state until idle or absolute TTL expiry
49 - per-profile local audit receipts written by the calling tsafe workflow
50
51Revoke:
52 tsafe agent lock
53";
54
55#[derive(Zeroize, ZeroizeOnDrop)]
58struct Password(String);
59
60#[cfg(not(target_os = "windows"))]
63type CellCache = Arc<Mutex<HashMap<String, CellState>>>;
64
65#[cfg(not(target_os = "windows"))]
74fn lock_cell_cache(
75 cell_cache: &CellCache,
76) -> std::sync::MutexGuard<'_, HashMap<String, CellState>> {
77 match cell_cache.lock() {
78 Ok(g) => g,
79 Err(poisoned) => poisoned.into_inner(),
80 }
81}
82
83#[cfg(not(target_os = "windows"))]
96fn install_signal_handlers(stop: Arc<AtomicBool>) -> std::io::Result<()> {
97 use signal_hook::consts::{SIGINT, SIGTERM};
98 use signal_hook::flag;
99 flag::register(SIGTERM, Arc::clone(&stop))?;
100 flag::register(SIGINT, Arc::clone(&stop))?;
101 unsafe {
105 libc::signal(libc::SIGHUP, libc::SIG_IGN);
106 }
107 Ok(())
108}
109
110fn daemon_main() -> Result<()> {
113 let args: Vec<String> = std::env::args().collect();
114 if args
115 .iter()
116 .skip(1)
117 .any(|arg| arg == "--help" || arg == "-h")
118 {
119 print!("{HELP}");
120 return Ok(());
121 }
122 if args.len() != 6 {
123 eprintln!(
124 "usage: tsafe-agent <profile> <session_token_hex> <requesting_pid> <idle_ttl_secs> <absolute_ttl_secs>"
125 );
126 std::process::exit(1);
127 }
128
129 let profile_name = &args[1];
130 let session_token = &args[2];
131 let requesting_pid: u32 = args[3].parse().context("invalid requesting_pid")?;
132 let idle_ttl_secs: u64 = args[4].parse().context("invalid idle_ttl_secs")?;
133 let absolute_ttl_secs: u64 = args[5].parse().context("invalid absolute_ttl_secs")?;
134
135 #[cfg(not(target_os = "windows"))]
137 let raw_password = acquire_password(profile_name)?;
138 #[cfg(not(target_os = "windows"))]
139 let pw = Password(raw_password.clone());
140 #[cfg(target_os = "windows")]
141 let pw = Password(acquire_password(profile_name)?);
142
143 {
145 let path = profile::vault_path(profile_name);
146 Vault::open(&path, pw.0.as_bytes()).context("wrong password — agent will not start")?;
147 }
148
149 let agent_pid = std::process::id();
150 let pipe = pipe_name(agent_pid);
151
152 println!("TSAFE_AGENT_SOCK={pipe}::{session_token}");
160 let _ = std::io::stdout().flush();
161
162 write_agent_sock(&format!("{pipe}::{session_token}"));
165
166 let stop = Arc::new(AtomicBool::new(false));
167 #[cfg(not(target_os = "windows"))]
171 install_signal_handlers(Arc::clone(&stop))
172 .context("failed to install SIGTERM/SIGINT handlers")?;
173 let absolute_deadline = Instant::now() + Duration::from_secs(absolute_ttl_secs);
174 let mut session =
175 AgentSession::new(session_token.to_string(), idle_ttl_secs, absolute_deadline);
176 spawn_expiry_watchdog(pipe.clone(), absolute_deadline, Arc::clone(&stop));
177
178 #[cfg(not(target_os = "windows"))]
181 {
182 let cell_cache: CellCache = Arc::new(Mutex::new(HashMap::new()));
183 let profile = profile_name.clone();
184 let shared_pw = Arc::new(raw_password);
185 let cache = Arc::clone(&cell_cache);
186 let stop_clone = Arc::clone(&stop);
187 std::thread::spawn(move || {
188 if let Err(e) = serve_cellos(&profile, shared_pw, cache, stop_clone) {
189 eprintln!("tsafe-agent: CellOS socket error: {e:#}");
190 }
191 });
192 }
193
194 serve(
195 &pipe,
196 &pw,
197 &mut session,
198 requesting_pid,
199 absolute_deadline,
200 stop,
201 )?;
202
203 clear_agent_sock();
205
206 Ok(())
207}
208
209pub fn run() {
215 if let Err(e) = daemon_main() {
216 eprintln!("tsafe-agent: {e:#}");
217 std::process::exit(1);
218 }
219}
220
221fn acquire_password(profile: &str) -> Result<String> {
228 match keyring_store::retrieve_password(profile) {
237 Ok(Some(pw)) => return Ok(pw),
238 Ok(None) => {}
239 Err(e) => eprintln!("tsafe-agent: keychain lookup failed: {e}; falling back"),
240 }
241
242 if let Ok(env_pw) = std::env::var("TSAFE_VAULT_PASSWORD") {
244 eprintln!(
245 "tsafe-agent: WARNING — using TSAFE_VAULT_PASSWORD from environment. \
246 This value is visible in /proc/self/environ, `docker inspect`, and shell \
247 history. Use `tsafe biometric enable` to store the password in the OS \
248 keychain instead."
249 );
250 return Ok(env_pw);
251 }
252
253 rpassword_read()
255}
256
257#[cfg(not(target_os = "windows"))]
264fn serve_cellos(
265 profile: &str,
266 password: Arc<String>,
267 cell_cache: CellCache,
268 stop: Arc<AtomicBool>,
269) -> Result<()> {
270 use std::os::unix::net::UnixListener;
271
272 let sock_path = cellos_socket_path();
273 if let Some(parent) = sock_path.parent() {
274 std::fs::create_dir_all(parent)?;
275 }
276 let _ = std::fs::remove_file(&sock_path);
277 let listener = UnixListener::bind(&sock_path)
278 .with_context(|| format!("CellOS: failed to bind {}", sock_path.display()))?;
279 #[cfg(unix)]
280 {
281 use std::os::unix::fs::PermissionsExt;
282 let _ = std::fs::set_permissions(&sock_path, std::fs::Permissions::from_mode(0o600));
283 }
284 let _cleanup = SocketCleanup(sock_path.to_string_lossy().into_owned());
285
286 listener.set_nonblocking(true)?;
287 let daemon_uid = unsafe { libc::getuid() };
291 let vault_path = profile::vault_path(profile);
292 let audit = AuditLog::new(&profile::audit_log_path(profile));
293
294 loop {
295 if stop.load(Ordering::Relaxed) {
296 break;
297 }
298 match listener.accept() {
299 Ok((stream, _)) => {
300 stream.set_nonblocking(false)?;
301 let cred = match unix_peer_credential(&stream) {
302 Ok(c) => c,
303 Err(e) => {
304 eprintln!("tsafe-agent: CellOS: peer credential failed: {e}");
305 continue;
306 }
307 };
308 if cred.uid != daemon_uid {
309 let resp = CellosResponse::Err {
310 error: "uid mismatch".to_string(),
311 };
312 let mut w = &stream;
313 let _ = writeln!(w, "{}", serde_json::to_string(&resp).unwrap_or_default());
314 continue;
315 }
316 handle_cellos_connection(
317 &stream,
318 cred.pid,
319 &vault_path,
320 &password,
321 profile,
322 &cell_cache,
323 &audit,
324 );
325 }
326 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
327 std::thread::sleep(Duration::from_millis(200));
328 }
329 Err(e) => return Err(e.into()),
330 }
331 }
332 Ok(())
333}
334
335#[cfg(not(target_os = "windows"))]
336fn handle_cellos_connection(
337 stream: &std::os::unix::net::UnixStream,
338 peer_pid: u32,
339 vault_path: &std::path::Path,
340 password: &str,
341 profile: &str,
342 cell_cache: &CellCache,
343 audit: &AuditLog,
344) {
345 use std::io::BufRead;
346
347 let mut reader = BufReader::new(stream);
348 let mut line = String::new();
349 if reader.read_line(&mut line).unwrap_or(0) == 0 {
350 return;
351 }
352
353 let req: CellosRequest = match serde_json::from_str(line.trim()) {
354 Ok(r) => r,
355 Err(e) => {
356 let resp = CellosResponse::Err {
357 error: format!("bad request: {e}"),
358 };
359 let mut w = stream;
360 let _ = writeln!(w, "{}", serde_json::to_string(&resp).unwrap_or_default());
361 return;
362 }
363 };
364
365 let resp = dispatch_cellos(
366 req, peer_pid, vault_path, password, profile, cell_cache, audit,
367 );
368 let mut w = stream;
369 let _ = writeln!(w, "{}", serde_json::to_string(&resp).unwrap_or_default());
370}
371
372#[cfg(not(target_os = "windows"))]
373fn dispatch_cellos(
374 req: CellosRequest,
375 peer_pid: u32,
376 vault_path: &std::path::Path,
377 password: &str,
378 profile: &str,
379 cell_cache: &CellCache,
380 audit: &AuditLog,
381) -> CellosResponse {
382 match req {
383 CellosRequest::Resolve {
384 key,
385 cell_id,
386 ttl_seconds: _,
387 cell_token,
388 } => {
389 {
391 let mut cache = lock_cell_cache(cell_cache);
392 match cache.get(&cell_id) {
393 Some(CellState::Revoked) => {
394 return CellosResponse::Err {
395 error: "cell revoked".to_string(),
396 };
397 }
398 Some(CellState::Active(record)) => {
399 if record.token != cell_token {
400 return CellosResponse::Err {
401 error: "cell_token mismatch".to_string(),
402 };
403 }
404 }
405 None => {
406 cache.insert(
408 cell_id.clone(),
409 CellState::Active(CellRecord {
410 pid: peer_pid,
411 token: cell_token.clone(),
412 }),
413 );
414 }
415 }
416 }
417
418 let value = match Vault::open_read_only(vault_path, password.as_bytes()) {
420 Ok(v) => match v.get(&key) {
421 Ok(s) => s.to_string(),
422 Err(_) => {
423 return CellosResponse::Err {
424 error: format!("key not found: {key}"),
425 };
426 }
427 },
428 Err(e) => {
429 return CellosResponse::Err {
430 error: format!("vault error: {e}"),
431 };
432 }
433 };
434
435 audit
437 .append(
438 &AuditEntry::success(profile, "cellos-resolve", Some(&key)).with_context(
439 AuditContext::from_cellos(AuditCellosContext {
440 cellos_cell_id: cell_id,
441 cell_token: Some(cell_token),
442 }),
443 ),
444 )
445 .ok();
446
447 CellosResponse::Value { value }
448 }
449
450 CellosRequest::RevokeForCell { cell_id } => {
451 {
452 let mut cache = lock_cell_cache(cell_cache);
453 cache.insert(cell_id.clone(), CellState::Revoked);
454 }
455
456 audit
457 .append(
458 &AuditEntry::success(profile, "cellos-revoke", None).with_context(
459 AuditContext::from_cellos(AuditCellosContext {
460 cellos_cell_id: cell_id,
461 cell_token: None,
462 }),
463 ),
464 )
465 .ok();
466
467 CellosResponse::Ok
468 }
469 }
470}
471
472#[cfg(target_os = "windows")]
475fn serve(
476 pipe: &str,
477 pw: &Password,
478 session: &mut AgentSession,
479 _requesting_pid: u32,
480 deadline: Instant,
481 stop: Arc<AtomicBool>,
482) -> Result<()> {
483 use std::fs::File;
484 use std::os::windows::io::FromRawHandle;
485
486 let pipe_wide: Vec<u16> = pipe.encode_utf16().chain(std::iter::once(0)).collect();
488
489 loop {
490 if stop.load(Ordering::Relaxed) || Instant::now() >= deadline {
491 break;
492 }
493 let handle = unsafe { windows_create_named_pipe(&pipe_wide)? };
494
495 let connected = unsafe { windows_connect_with_timeout(handle, 5_000) };
497 if !connected {
498 unsafe { windows_close_handle(handle) };
499 continue;
500 }
501
502 let client_file = unsafe { File::from_raw_handle(handle as _) };
503 let mut reader = BufReader::new(&client_file);
504 let mut writer = &client_file;
505 handle_connection(
506 &mut reader,
507 &mut writer,
508 pw,
509 session,
510 Some(unsafe { windows_get_named_pipe_client_process_id(handle)? }),
511 &stop,
512 )?;
513 }
514
515 Ok(())
516}
517
518pub(crate) fn handle_connection(
519 reader: &mut impl BufRead,
520 writer: &mut impl Write,
521 pw: &Password,
522 session: &mut AgentSession,
523 peer_pid: Option<u32>,
524 stop: &Arc<AtomicBool>,
525) -> Result<()> {
526 let mut line = String::new();
527 if reader.read_line(&mut line).unwrap_or(0) == 0 {
528 return Ok(());
529 }
530
531 let req: AgentRequest = match serde_json::from_str(line.trim()) {
532 Ok(r) => r,
533 Err(e) => {
534 let resp = AgentResponse::Err {
535 reason: format!("bad request: {e}"),
536 };
537 let _ = writeln!(writer, "{}", serde_json::to_string(&resp)?);
538 return Ok(());
539 }
540 };
541
542 let outcome = session.handle_request(&req, peer_pid, &pw.0, Instant::now());
543 if outcome.stop {
544 stop.store(true, Ordering::Relaxed);
545 }
546 let resp = outcome.response;
547
548 let _ = writeln!(writer, "{}", serde_json::to_string(&resp)?);
549 Ok(())
550}
551
552pub(crate) fn spawn_expiry_watchdog(pipe: String, deadline: Instant, stop: Arc<AtomicBool>) {
553 std::thread::spawn(move || loop {
554 if stop.load(Ordering::Relaxed) {
555 return;
556 }
557
558 let now = Instant::now();
559 if now >= deadline {
560 stop.store(true, Ordering::Relaxed);
561 wake_listener(&pipe);
562 return;
563 }
564
565 std::thread::sleep((deadline - now).min(Duration::from_millis(200)));
566 });
567}
568
569#[cfg(target_os = "windows")]
570fn wake_listener(pipe: &str) {
571 let _ = windows_connect_pipe_client(pipe);
572}
573
574#[cfg(not(target_os = "windows"))]
575fn wake_listener(pipe: &str) {
576 let _ = std::os::unix::net::UnixStream::connect(pipe);
577}
578
579fn rpassword_read() -> Result<String> {
582 rpassword::prompt_password("Vault password: ").context("failed to read password")
584}
585
586#[cfg(target_os = "windows")]
589mod ffi {
590 use std::ffi::c_void;
591
592 extern "system" {
593 pub fn CreateNamedPipeW(
594 name: *const u16,
595 open_mode: u32,
596 pipe_mode: u32,
597 max_instances: u32,
598 out_buf: u32,
599 in_buf: u32,
600 default_timeout: u32,
601 security: *mut c_void,
602 ) -> *mut c_void;
603 pub fn CreateFileW(
604 name: *const u16,
605 access: u32,
606 share: u32,
607 security: *mut c_void,
608 creation: u32,
609 flags: u32,
610 template: *mut c_void,
611 ) -> *mut c_void;
612 pub fn ConnectNamedPipe(pipe: *mut c_void, overlapped: *mut c_void) -> i32;
613 pub fn CloseHandle(handle: *mut c_void) -> i32;
614 pub fn GetNamedPipeClientProcessId(pipe: *mut c_void, client_process_id: *mut u32) -> i32;
615 #[allow(dead_code)]
616 pub fn WaitForSingleObject(handle: *mut c_void, ms: u32) -> u32;
617 }
618}
619
620#[cfg(target_os = "windows")]
621unsafe fn windows_create_named_pipe(pipe_wide: &[u16]) -> Result<*mut std::ffi::c_void> {
622 let h = ffi::CreateNamedPipeW(
624 pipe_wide.as_ptr(),
625 3, 0x00, 1, 4096,
629 4096,
630 0,
631 std::ptr::null_mut(),
632 );
633 if h as isize == -1 || h.is_null() {
634 anyhow::bail!("CreateNamedPipeW failed");
635 }
636 Ok(h)
637}
638
639#[cfg(target_os = "windows")]
640unsafe fn windows_connect_with_timeout(handle: *mut std::ffi::c_void, _ms: u32) -> bool {
641 ffi::ConnectNamedPipe(handle, std::ptr::null_mut()) != 0
644}
645
646#[cfg(target_os = "windows")]
647unsafe fn windows_close_handle(handle: *mut std::ffi::c_void) {
648 ffi::CloseHandle(handle);
649}
650
651#[cfg(target_os = "windows")]
652unsafe fn windows_get_named_pipe_client_process_id(handle: *mut std::ffi::c_void) -> Result<u32> {
653 let mut pid = 0u32;
654 if ffi::GetNamedPipeClientProcessId(handle, &mut pid) == 0 {
655 anyhow::bail!("GetNamedPipeClientProcessId failed");
656 }
657 Ok(pid)
658}
659
660#[cfg(target_os = "windows")]
661fn windows_connect_pipe_client(pipe: &str) -> Result<std::fs::File> {
662 use std::os::windows::ffi::OsStrExt;
663 use std::os::windows::io::FromRawHandle;
664
665 let wide: Vec<u16> = std::ffi::OsStr::new(pipe)
666 .encode_wide()
667 .chain(std::iter::once(0))
668 .collect();
669
670 let handle = unsafe {
671 ffi::CreateFileW(
672 wide.as_ptr(),
673 0xC000_0000, 0,
675 std::ptr::null_mut(),
676 3, 128, std::ptr::null_mut(),
679 )
680 };
681
682 if handle.is_null() || handle as isize == -1 {
683 anyhow::bail!("CreateFileW failed");
684 }
685
686 Ok(unsafe { std::fs::File::from_raw_handle(handle as _) })
687}
688
689#[cfg(not(target_os = "windows"))]
691fn serve(
692 pipe: &str,
693 pw: &Password,
694 session: &mut AgentSession,
695 _requesting_pid: u32,
696 deadline: Instant,
697 stop: Arc<AtomicBool>,
698) -> Result<()> {
699 use std::os::unix::net::UnixListener;
700
701 let _ = std::fs::remove_file(pipe);
703
704 let listener =
705 UnixListener::bind(pipe).with_context(|| format!("failed to bind Unix socket: {pipe}"))?;
706
707 #[cfg(unix)]
709 {
710 use std::os::unix::fs::PermissionsExt;
711 let _ = std::fs::set_permissions(pipe, std::fs::Permissions::from_mode(0o600));
712 }
713
714 listener.set_nonblocking(true)?;
716
717 let _cleanup = SocketCleanup(pipe.to_string());
718
719 loop {
720 if stop.load(Ordering::Relaxed) || Instant::now() >= deadline {
721 break;
722 }
723 match listener.accept() {
724 Ok((stream, _)) => {
725 stream.set_nonblocking(false)?;
726 let mut reader = BufReader::new(&stream);
727 let mut writer = &stream;
728 handle_connection(
729 &mut reader,
730 &mut writer,
731 pw,
732 session,
733 Some(unix_peer_pid(&stream)?),
734 &stop,
735 )?;
736 }
737 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
738 std::thread::sleep(Duration::from_millis(200));
739 }
740 Err(e) => return Err(e.into()),
741 }
742 }
743
744 Ok(())
745}
746
747#[cfg(not(target_os = "windows"))]
749struct SocketCleanup(String);
750
751#[cfg(not(target_os = "windows"))]
752impl Drop for SocketCleanup {
753 fn drop(&mut self) {
754 let _ = std::fs::remove_file(&self.0);
755 }
756}
757
758#[cfg(unix)]
759struct PeerCredential {
760 pid: u32,
761 uid: u32,
762}
763
764#[cfg(target_os = "linux")]
766fn unix_peer_credential(
767 stream: &std::os::unix::net::UnixStream,
768) -> std::io::Result<PeerCredential> {
769 use std::mem::size_of;
770 use std::os::fd::AsRawFd;
771 let fd = stream.as_raw_fd();
772 let mut cred: libc::ucred = unsafe { std::mem::zeroed() };
776 let mut len = size_of::<libc::ucred>() as libc::socklen_t;
777 let rc = unsafe {
785 libc::getsockopt(
786 fd,
787 libc::SOL_SOCKET,
788 libc::SO_PEERCRED,
789 &mut cred as *mut _ as *mut libc::c_void,
790 &mut len,
791 )
792 };
793 if rc != 0 {
794 return Err(std::io::Error::last_os_error());
795 }
796 Ok(PeerCredential {
797 pid: cred.pid as u32,
798 uid: cred.uid,
799 })
800}
801
802#[cfg(target_os = "macos")]
803fn unix_peer_credential(
804 stream: &std::os::unix::net::UnixStream,
805) -> std::io::Result<PeerCredential> {
806 use std::mem::size_of;
807 use std::os::fd::AsRawFd;
808 let fd = stream.as_raw_fd();
809 let mut uid: libc::uid_t = 0;
810 let mut gid: libc::gid_t = 0;
811 let mut pid: libc::pid_t = 0;
812 let mut len = size_of::<libc::pid_t>() as libc::socklen_t;
813 let rc_uid = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) };
818 let rc_pid = unsafe {
823 libc::getsockopt(
824 fd,
825 libc::SOL_LOCAL,
826 libc::LOCAL_PEERPID,
827 &mut pid as *mut _ as *mut libc::c_void,
828 &mut len,
829 )
830 };
831 if rc_uid != 0 {
832 return Err(std::io::Error::last_os_error());
833 }
834 if rc_pid != 0 {
835 return Err(std::io::Error::last_os_error());
836 }
837 Ok(PeerCredential {
838 pid: pid as u32,
839 uid: uid as u32,
840 })
841}
842
843#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
844fn unix_peer_credential(
845 _stream: &std::os::unix::net::UnixStream,
846) -> std::io::Result<PeerCredential> {
847 Err(std::io::Error::new(
848 std::io::ErrorKind::Unsupported,
849 "peer credentials unsupported on this platform",
850 ))
851}
852
853#[cfg(unix)]
854fn unix_peer_pid(stream: &std::os::unix::net::UnixStream) -> Result<u32> {
855 unix_peer_credential(stream)
856 .map(|c| c.pid)
857 .map_err(Into::into)
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863
864 fn run_request(
865 req: AgentRequest,
866 peer_pid: Option<u32>,
867 absolute_deadline: Instant,
868 ) -> (AgentResponse, bool) {
869 let stop = Arc::new(AtomicBool::new(false));
870 let mut input = std::io::Cursor::new(format!("{}\n", serde_json::to_string(&req).unwrap()));
871 let mut output = Vec::new();
872 let password = Password("secret".to_string());
873 let idle_secs = if absolute_deadline > Instant::now() {
875 (absolute_deadline - Instant::now()).as_secs().max(1)
876 } else {
877 1
878 };
879 let mut session = AgentSession::new("token-123", idle_secs, absolute_deadline);
880
881 handle_connection(
882 &mut input,
883 &mut output,
884 &password,
885 &mut session,
886 peer_pid,
887 &stop,
888 )
889 .unwrap();
890
891 let response: AgentResponse = serde_json::from_slice(&output).unwrap();
892 (response, stop.load(Ordering::Relaxed))
893 }
894
895 #[test]
896 fn open_vault_allows_matching_peer_pid() {
897 let (response, stop) = run_request(
898 AgentRequest::OpenVault {
899 profile: "default".into(),
900 session_token: "token-123".into(),
901 requesting_pid: 4242,
902 },
903 Some(4242),
904 Instant::now() + Duration::from_secs(60),
905 );
906
907 assert!(!stop);
908 match response {
909 AgentResponse::Password { password } => assert_eq!(password, "secret"),
910 other => panic!("expected password response, got {other:?}"),
911 }
912 }
913
914 #[test]
915 fn open_vault_rejects_pid_mismatch() {
916 let (response, stop) = run_request(
917 AgentRequest::OpenVault {
918 profile: "default".into(),
919 session_token: "token-123".into(),
920 requesting_pid: 4242,
921 },
922 Some(9001),
923 Instant::now() + Duration::from_secs(60),
924 );
925
926 assert!(!stop);
927 match response {
928 AgentResponse::Err { reason } => {
929 assert!(reason.contains("does not match the connecting process"));
930 }
931 other => panic!("expected authorization error, got {other:?}"),
932 }
933 }
934
935 #[test]
936 fn expired_session_rejects_requests_and_stops() {
937 let (response, stop) = run_request(
938 AgentRequest::Ping,
939 Some(4242),
940 Instant::now() - Duration::from_secs(1),
941 );
942
943 assert!(stop);
944 match response {
945 AgentResponse::Err { reason } => assert!(
946 reason.contains("agent session expired"),
947 "unexpected: {reason}"
948 ),
949 other => panic!("expected expiry error, got {other:?}"),
950 }
951 }
952
953 #[test]
954 fn lock_request_transitions_session_and_stops() {
955 let (response, stop) = run_request(
956 AgentRequest::Lock {
957 session_token: "token-123".into(),
958 },
959 Some(4242),
960 Instant::now() + Duration::from_secs(60),
961 );
962
963 assert!(stop);
964 assert!(matches!(response, AgentResponse::Ok));
965 }
966}