Skip to main content

tsafe_core/
agent.rs

1//! Agent protocol — shared types used by both the daemon (tsafe-agent)
2//! and the client (tsafe-cli `open_vault_via_agent`).
3//!
4//! # Security model
5//!
6//! * The agent holds the vault **password** in memory, zeroized on drop.
7//! * Access requires the session token plus a `requesting_pid` claim that matches
8//!   the real peer PID on the IPC transport.
9//! * The daemon enforces the TTL chosen at `tsafe agent unlock`; expired
10//!   sessions reject new requests and clear their socket state on exit.
11//! * The named pipe is `\\.\pipe\tsafe-agent-{agent_pid}`.
12//! * The `TSAFE_AGENT_SOCK` env var carries `{pipe_name}::{session_token_hex}`.
13//! * A state file (`agent.sock` in the data dir) persists the sock address so
14//!   processes that do not inherit `TSAFE_AGENT_SOCK` (e.g. VS Code
15//!   background terminals) can still reach a running agent.
16//!
17//! # Wire protocol
18//!
19//! Request and response are newline-terminated JSON objects written over the
20//! named pipe.  One request → one response per connection.
21
22use std::path::PathBuf;
23use std::time::{Duration, Instant};
24
25use serde::{Deserialize, Serialize};
26
27// ── Agent socket env vars ─────────────────────────────────────────────────────
28
29/// Set by `tsafe agent unlock` after the user approves.
30/// Format: `{pipe_name}::{session_token_hex}`
31pub const ENV_AGENT_SOCK: &str = "TSAFE_AGENT_SOCK";
32
33/// Path for the CellOS broker Unix socket.  Overridden by `TSAFE_SOCKET`.
34pub const ENV_CELLOS_SOCK: &str = "TSAFE_SOCKET";
35
36/// Resolve the CellOS broker socket path: `$TSAFE_SOCKET` or `~/.tsafe/agent.sock`.
37pub 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// ── CellOS per-cell state ─────────────────────────────────────────────────────
48
49/// Immutable record established on the first `Resolve` for a cell.
50#[derive(Debug, Clone)]
51pub struct CellRecord {
52    /// PID that registered this cell (SO_PEERCRED on first Resolve).
53    pub pid: u32,
54    /// 32-byte hex random token set at supervisor spawn time.
55    pub token: String,
56}
57
58/// Lifecycle state of a tracked cell in the daemon's in-memory cache.
59#[derive(Debug, Clone)]
60pub enum CellState {
61    Active(CellRecord),
62    Revoked,
63}
64
65// ── CellOS wire messages ──────────────────────────────────────────────────────
66
67/// Requests sent by the CellOS broker over `TSAFE_SOCKET`.
68#[derive(Debug, Serialize, Deserialize)]
69#[serde(tag = "op")]
70pub enum CellosRequest {
71    /// Resolve a secret value for a cell.
72    ///
73    /// `cell_token` is a 32-byte hex random nonce generated at supervisor spawn.
74    /// The daemon validates token + PID against the first-registration record to
75    /// mitigate PID reuse between cell death and a new process reaching the socket.
76    Resolve {
77        key: String,
78        cell_id: String,
79        ttl_seconds: u64,
80        cell_token: String,
81    },
82    /// Purge cached material for a cell on TTL expiry or explicit destroy.
83    RevokeForCell { cell_id: String },
84}
85
86/// Responses from the daemon over `TSAFE_SOCKET`.
87#[derive(Debug, Serialize, Deserialize)]
88#[serde(tag = "status")]
89pub enum CellosResponse {
90    /// Successful resolution — plaintext secret value.
91    Value { value: String },
92    /// Successful acknowledgment (used by RevokeForCell).
93    Ok,
94    /// Typed error — `error` describes the rejection reason.
95    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// ── Wire messages ─────────────────────────────────────────────────────────────
107
108#[derive(Debug, Serialize, Deserialize)]
109#[serde(tag = "op")]
110pub enum AgentRequest {
111    /// Open the vault for `profile` and return its encrypted file bytes.
112    /// The agent re-reads the vault file from disk on every call.
113    /// `requesting_pid` must match the real PID of the connecting process.
114    OpenVault {
115        profile: String,
116        session_token: String, // hex-encoded 32 bytes
117        requesting_pid: u32,
118    },
119    /// Destroy the session token immediately.
120    Lock { session_token: String },
121    /// Heartbeat — returns `AgentResponse::Ok` with no data.
122    Ping,
123}
124
125#[derive(Debug, Serialize, Deserialize)]
126#[serde(tag = "status")]
127pub enum AgentResponse {
128    /// The vault password for the requested profile, as UTF-8 bytes.
129    /// The CLI uses this to call `Vault::open` locally.
130    Password {
131        password: String, // NOT b64 — just the plaintext password string
132    },
133    Ok,
134    Err {
135        reason: String,
136    },
137}
138
139// ── Session lifecycle ────────────────────────────────────────────────────────
140
141#[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    /// Sliding idle deadline — reset to `now + idle_secs` on each `OpenVault` request,
152    /// but never extended past `absolute_deadline`.
153    idle_deadline: Instant,
154    /// Hard cap — never refreshed regardless of activity.
155    absolute_deadline: Instant,
156    /// Idle window in seconds, used to recompute `idle_deadline` on activity.
157    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    /// Create a new session with separate idle and absolute TTLs.
170    ///
171    /// `idle_secs` — sliding window reset on every `OpenVault` request.
172    /// `absolute_deadline` — hard wall-clock cap, never extended.
173    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                        // Refresh idle deadline — capped at absolute deadline.
247                        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    /// Transitions to `Expired` if either deadline has passed.
264    /// Returns the expiry reason to use in the `Err` response, or `None` if still active.
265    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
291// ── Agent state file ─────────────────────────────────────────────────────────
292
293/// Path to the agent socket state file.
294/// Written by the daemon on startup; deleted on exit.
295/// Allows processes that don't inherit `TSAFE_AGENT_SOCK` to find a running agent.
296pub fn agent_sock_path() -> PathBuf {
297    crate::profile::app_state_dir().join("agent.sock")
298}
299
300/// Write the agent socket address to the state file so other processes can find it.
301/// File is restricted to owner-only on Unix (it contains the session token).
302pub 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    // Write atomically so a hard kill mid-write cannot leave a corrupt state file.
308    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
319/// Read the agent socket address from the state file. Returns `None` if missing.
320pub 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
327/// Delete the agent state file (called on daemon exit).
328pub fn clear_agent_sock() {
329    let _ = std::fs::remove_file(agent_sock_path());
330}
331// ── Pipe / socket naming ─────────────────────────────────────────────────────
332
333/// Build the IPC address for this agent process.
334///
335/// - **Windows:** named pipe `\\.\pipe\tsafe-agent-{pid}`
336/// - **macOS/Linux:** Unix domain socket in a user-private temp directory
337#[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    // SAFETY: getuid() is a leaf POSIX libc call that takes no arguments and
380    // cannot fail; it reads process credentials without dereferencing pointers.
381    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
394/// Parse `TSAFE_AGENT_SOCK` into `(pipe_name, session_token_hex)`.
395pub fn parse_agent_sock(sock: &str) -> Option<(String, String)> {
396    // Format: "{pipe_name}::{token_hex}"  (double colon to avoid clash with UNC paths)
397    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
406/// Build the value to put in `TSAFE_AGENT_SOCK`.
407pub fn format_agent_sock(pipe: &str, token_hex: &str) -> String {
408    format!("{pipe}::{token_hex}")
409}
410
411// ── Client-side helper ────────────────────────────────────────────────────────
412
413/// Try to retrieve the vault password from a running tsafe-agent.
414///
415/// Returns `Some(password)` if a valid agent is reachable and grants access.
416/// Returns `None` if neither `TSAFE_AGENT_SOCK` nor the state file point to a running agent
417/// (caller falls back to interactive prompt).
418/// Returns `Err` if the env var is set but the call fails (hard error — do not
419/// silently fall back to a password prompt when the user explicitly configured
420/// an agent session).
421#[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    // Prefer the env var; fall back to the persisted state file.
427    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            // Malformed state file — wipe it and fall through to prompt.
436            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            // Agent exited and left a stale state file — clean up and fall back to prompt.
459            clear_agent_sock();
460            return Ok(None);
461        }
462        Err(e) => return Err(e),
463    };
464    // NOTE: Windows named-pipe File handles do not support read timeouts via the
465    // standard Rust API. A full fix would require SetCommTimeouts FFI. For now the
466    // write is bounded by the OS pipe-full back-pressure (small JSON payload).
467    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// ── Unix domain socket client ────────────────────────────────────────────────
495
496#[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    // Prefer the env var; fall back to the persisted state file.
501    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            // Stale state file — agent exited. Clean up and fall back to prompt.
529            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// ── Windows named-pipe client ─────────────────────────────────────────────────
580
581#[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    // SAFETY: `wide` is a null-terminated UTF-16 buffer owned by this stack frame
604    // and lives for the duration of the CreateFileW call (the Vec is not moved or
605    // dropped before `wide.as_ptr()` is read). The remaining args are well-defined
606    // Windows constants (GENERIC_READ|GENERIC_WRITE access, no sharing, no security
607    // descriptor, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, no template handle).
608    // CreateFileW does not retain any of these pointers past the call.
609    let handle = unsafe {
610        // GENERIC_READ | GENERIC_WRITE = 0xC0000000, OPEN_EXISTING = 3, FILE_ATTRIBUTE_NORMAL = 128
611        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    // SAFETY: `handle` is a freshly-opened Windows HANDLE returned by CreateFileW
629    // above. The null/INVALID_HANDLE_VALUE check immediately preceding this block
630    // ensures it is a valid, owning handle that is not currently owned by any
631    // other File or RAII wrapper. Transferring ownership into std::fs::File is the
632    // sound usage pattern for FromRawHandle — File's Drop will call CloseHandle.
633    Ok(unsafe {
634        <std::fs::File as std::os::windows::io::FromRawHandle>::from_raw_handle(handle as _)
635    })
636}
637
638// ── Lock helper ───────────────────────────────────────────────────────────────
639
640/// Send a Lock request to the agent, causing it to exit immediately.
641/// No-op if no agent socket env is set.
642#[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// ── Ping helper ───────────────────────────────────────────────────────────────
699
700/// Send a Ping to the agent at `sock_val` and return `Ok(true)` if it responds `Ok`.
701/// Returns `Ok(false)` on an unexpected response, `Err` on I/O failure.
702#[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// ── Tests ────────────────────────────────────────────────────────────────────
742
743#[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    // ── H2: idle / absolute TTL tests ─────────────────────────────────────────
1020
1021    #[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        // Simulate time advancing; an OpenVault should push idle_deadline forward.
1029        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        // absolute = now + 5s; idle_secs = 100 (would push past absolute if uncapped)
1051        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        // absolute is in the future; force idle_deadline into the past
1075        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        // absolute already expired; idle would be in the future (but capped)
1094        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    /// Unix-only: start a mock agent on a socket, send Ping, verify Ok response.
1109    #[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        // Spawn a mock agent that responds Ok to any request.
1122        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            // Parse request and respond Ok.
1128            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        // Send Ping via the client helper.
1135        let resp = agent_rpc_unix(&sock_str, &AgentRequest::Ping).unwrap();
1136        assert!(matches!(resp, AgentResponse::Ok));
1137
1138        handle.join().unwrap();
1139    }
1140}