Skip to main content

auths_core/agent/
handle.rs

1//! Agent handle for lifecycle management.
2//!
3//! This module provides `AgentHandle`, a wrapper around `AgentCore` that enables
4//! proper lifecycle management (start/stop/restart) for the SSH agent daemon.
5
6use crate::agent::AgentCore;
7use crate::error::AgentError;
8use std::path::PathBuf;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, Mutex, MutexGuard};
11use std::time::{Duration, Instant};
12use zeroize::Zeroizing;
13
14/// Default idle timeout (30 minutes)
15pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
16
17/// Default absolute cap on how long a single unlock keeps signing capability,
18/// measured from the last unlock and independent of activity (8 hours).
19pub const DEFAULT_MAX_UNLOCK_TTL: Duration = Duration::from_secs(8 * 60 * 60);
20
21/// A handle to an agent instance that manages its lifecycle.
22///
23/// `AgentHandle` wraps an `AgentCore` and provides:
24/// - Socket path and PID file tracking
25/// - Lifecycle management (shutdown, status checks)
26/// - Thread-safe access to the underlying `AgentCore`
27/// - Idle timeout and key locking
28///
29/// Unlike the previous global static pattern, multiple `AgentHandle` instances
30/// can coexist, enabling proper testing and multi-agent scenarios.
31pub struct AgentHandle {
32    /// The underlying agent core wrapped in a mutex for thread-safe access
33    core: Arc<Mutex<AgentCore>>,
34    /// Path to the Unix domain socket
35    socket_path: PathBuf,
36    /// Path to the PID file (optional)
37    pid_file: Option<PathBuf>,
38    /// Whether the agent is currently running
39    running: Arc<AtomicBool>,
40    /// Timestamp of last activity (for idle timeout, shared across clones)
41    last_activity: Arc<Mutex<Instant>>,
42    /// Idle timeout duration (0 = never timeout)
43    idle_timeout: Duration,
44    /// Timestamp of the last unlock (for the absolute unlock cap, shared across clones)
45    unlocked_at: Arc<Mutex<Instant>>,
46    /// Absolute cap on how long a single unlock keeps signing capability (0 = no cap)
47    max_unlock_ttl: Duration,
48    /// Whether the agent is currently locked (shared across clones)
49    locked: Arc<AtomicBool>,
50}
51
52impl std::fmt::Debug for AgentHandle {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("AgentHandle")
55            .field("socket_path", &self.socket_path)
56            .field("pid_file", &self.pid_file)
57            .field("running", &self.is_running())
58            .field("locked", &self.is_agent_locked())
59            .field("idle_timeout", &self.idle_timeout)
60            .field("max_unlock_ttl", &self.max_unlock_ttl)
61            .finish_non_exhaustive()
62    }
63}
64
65impl AgentHandle {
66    /// Creates a new agent handle with the specified socket path.
67    pub fn new(socket_path: PathBuf) -> Self {
68        Self::with_timeout(socket_path, DEFAULT_IDLE_TIMEOUT)
69    }
70
71    /// Creates a new agent handle with the specified socket path and idle timeout,
72    /// using the default absolute unlock cap.
73    pub fn with_timeout(socket_path: PathBuf, idle_timeout: Duration) -> Self {
74        Self::with_unlock_cap(socket_path, idle_timeout, DEFAULT_MAX_UNLOCK_TTL)
75    }
76
77    /// Creates a new agent handle with an explicit idle timeout and absolute unlock cap.
78    ///
79    /// The idle timeout locks the agent after a period of no signing (a "walked away"
80    /// control); the unlock cap locks it a fixed time after it was last unlocked,
81    /// regardless of activity, so continuous signing cannot keep it unlocked forever.
82    ///
83    /// Args:
84    /// * `socket_path`: The Unix-domain socket path.
85    /// * `idle_timeout`: Sliding inactivity timeout (0 disables).
86    /// * `max_unlock_ttl`: Absolute cap measured from the last unlock (0 disables).
87    ///
88    /// Usage:
89    /// ```ignore
90    /// let handle = AgentHandle::with_unlock_cap(path, idle, cap);
91    /// ```
92    pub fn with_unlock_cap(
93        socket_path: PathBuf,
94        idle_timeout: Duration,
95        max_unlock_ttl: Duration,
96    ) -> Self {
97        Self {
98            core: Arc::new(Mutex::new(AgentCore::default())),
99            socket_path,
100            pid_file: None,
101            running: Arc::new(AtomicBool::new(false)),
102            last_activity: Arc::new(Mutex::new(Instant::now())),
103            idle_timeout,
104            unlocked_at: Arc::new(Mutex::new(Instant::now())),
105            max_unlock_ttl,
106            locked: Arc::new(AtomicBool::new(false)),
107        }
108    }
109
110    /// Creates a new agent handle with socket and PID file paths.
111    pub fn with_pid_file(socket_path: PathBuf, pid_file: PathBuf) -> Self {
112        Self {
113            core: Arc::new(Mutex::new(AgentCore::default())),
114            socket_path,
115            pid_file: Some(pid_file),
116            running: Arc::new(AtomicBool::new(false)),
117            last_activity: Arc::new(Mutex::new(Instant::now())),
118            idle_timeout: DEFAULT_IDLE_TIMEOUT,
119            unlocked_at: Arc::new(Mutex::new(Instant::now())),
120            max_unlock_ttl: DEFAULT_MAX_UNLOCK_TTL,
121            locked: Arc::new(AtomicBool::new(false)),
122        }
123    }
124
125    /// Creates a new agent handle with socket, PID file, and custom timeout.
126    pub fn with_pid_file_and_timeout(
127        socket_path: PathBuf,
128        pid_file: PathBuf,
129        idle_timeout: Duration,
130    ) -> Self {
131        Self {
132            core: Arc::new(Mutex::new(AgentCore::default())),
133            socket_path,
134            pid_file: Some(pid_file),
135            running: Arc::new(AtomicBool::new(false)),
136            last_activity: Arc::new(Mutex::new(Instant::now())),
137            idle_timeout,
138            unlocked_at: Arc::new(Mutex::new(Instant::now())),
139            max_unlock_ttl: DEFAULT_MAX_UNLOCK_TTL,
140            locked: Arc::new(AtomicBool::new(false)),
141        }
142    }
143
144    /// Creates an agent handle from an existing `AgentCore`.
145    pub fn from_core(core: AgentCore, socket_path: PathBuf) -> Self {
146        Self {
147            core: Arc::new(Mutex::new(core)),
148            socket_path,
149            pid_file: None,
150            running: Arc::new(AtomicBool::new(false)),
151            last_activity: Arc::new(Mutex::new(Instant::now())),
152            idle_timeout: DEFAULT_IDLE_TIMEOUT,
153            unlocked_at: Arc::new(Mutex::new(Instant::now())),
154            max_unlock_ttl: DEFAULT_MAX_UNLOCK_TTL,
155            locked: Arc::new(AtomicBool::new(false)),
156        }
157    }
158
159    /// Returns the socket path for this agent.
160    pub fn socket_path(&self) -> &PathBuf {
161        &self.socket_path
162    }
163
164    /// Returns the PID file path, if configured.
165    pub fn pid_file(&self) -> Option<&PathBuf> {
166        self.pid_file.as_ref()
167    }
168
169    /// Sets the PID file path.
170    pub fn set_pid_file(&mut self, path: PathBuf) {
171        self.pid_file = Some(path);
172    }
173
174    /// Acquires a lock on the agent core.
175    ///
176    /// # Errors
177    /// Returns `AgentError::MutexError` if the mutex is poisoned.
178    pub fn lock(&self) -> Result<MutexGuard<'_, AgentCore>, AgentError> {
179        self.core
180            .lock()
181            .map_err(|_| AgentError::MutexError("Agent core mutex poisoned".to_string()))
182    }
183
184    /// Returns a clone of the inner `Arc<Mutex<AgentCore>>` for sharing.
185    pub fn core_arc(&self) -> Arc<Mutex<AgentCore>> {
186        Arc::clone(&self.core)
187    }
188
189    /// Returns whether the agent is currently running.
190    pub fn is_running(&self) -> bool {
191        self.running.load(Ordering::SeqCst)
192    }
193
194    /// Marks the agent as running.
195    pub fn set_running(&self, running: bool) {
196        self.running.store(running, Ordering::SeqCst);
197    }
198
199    // --- Idle Timeout and Locking ---
200
201    /// Returns the configured idle timeout duration.
202    pub fn idle_timeout(&self) -> Duration {
203        self.idle_timeout
204    }
205
206    /// Records activity, resetting the idle timer.
207    pub fn touch(&self) {
208        if let Ok(mut last) = self.last_activity.lock() {
209            *last = Instant::now();
210        }
211    }
212
213    /// Returns the duration since the last activity.
214    pub fn idle_duration(&self) -> Duration {
215        self.last_activity
216            .lock()
217            .map(|last| last.elapsed())
218            .unwrap_or(Duration::ZERO)
219    }
220
221    /// Returns whether the agent has exceeded the idle timeout.
222    pub fn is_idle_timed_out(&self) -> bool {
223        // A timeout of 0 means never timeout
224        if self.idle_timeout.is_zero() {
225            return false;
226        }
227        self.idle_duration() >= self.idle_timeout
228    }
229
230    /// Returns the absolute cap on how long a single unlock keeps signing capability.
231    pub fn max_unlock_ttl(&self) -> Duration {
232        self.max_unlock_ttl
233    }
234
235    /// Returns the time elapsed since the agent was last unlocked.
236    pub fn unlock_age(&self) -> Duration {
237        self.unlocked_at
238            .lock()
239            .map(|t| t.elapsed())
240            .unwrap_or(Duration::ZERO)
241    }
242
243    /// Returns whether the agent has been unlocked longer than its absolute cap.
244    ///
245    /// Unlike the idle timeout, this does not reset on activity — it bounds the total
246    /// lifetime of a single unlock so continuous signing cannot keep the agent unlocked
247    /// indefinitely. A cap of 0 disables it.
248    pub fn is_unlock_window_expired(&self) -> bool {
249        if self.max_unlock_ttl.is_zero() {
250            return false;
251        }
252        self.unlock_age() >= self.max_unlock_ttl
253    }
254
255    /// Returns whether the agent is currently locked.
256    pub fn is_agent_locked(&self) -> bool {
257        self.locked.load(Ordering::SeqCst)
258    }
259
260    /// Locks the agent, clearing all keys from memory.
261    ///
262    /// After locking, sign operations will fail with `AgentError::AgentLocked`.
263    pub fn lock_agent(&self) -> Result<(), AgentError> {
264        log::info!("Locking agent (clearing keys from memory)");
265
266        // Clear all keys (zeroizes sensitive data)
267        {
268            let mut core = self.lock()?;
269            core.clear_keys();
270        }
271
272        // Mark as locked
273        self.locked.store(true, Ordering::SeqCst);
274        log::debug!("Agent locked");
275        Ok(())
276    }
277
278    /// Unlocks the agent (marks as unlocked).
279    ///
280    /// Note: This only clears the locked flag. Keys must be re-loaded separately
281    /// using `register_key` or the CLI `auths agent unlock` command.
282    pub fn unlock_agent(&self) {
283        log::info!("Unlocking agent");
284        self.locked.store(false, Ordering::SeqCst);
285        self.touch(); // Reset idle timer
286        if let Ok(mut unlocked) = self.unlocked_at.lock() {
287            *unlocked = Instant::now(); // Start the absolute unlock window
288        }
289    }
290
291    /// Locks the agent if its unlock window has ended, clearing its keys.
292    ///
293    /// The agent locks when it has been idle past the idle timeout (a "walked away"
294    /// control) or when it has been unlocked longer than the absolute cap (a backstop
295    /// so continuous signing cannot keep it unlocked indefinitely). Call this
296    /// periodically from a background task. Neither control stops a process running as
297    /// the same user from using the agent within the window — they only bound the window.
298    pub fn check_idle_timeout(&self) -> Result<bool, AgentError> {
299        if (self.is_idle_timed_out() || self.is_unlock_window_expired()) && !self.is_agent_locked()
300        {
301            log::info!(
302                "Locking agent: idle for {:?}, unlocked for {:?}",
303                self.idle_duration(),
304                self.unlock_age()
305            );
306            self.lock_agent()?;
307            return Ok(true);
308        }
309        Ok(false)
310    }
311
312    /// Shuts down the agent, clearing all keys and resources.
313    ///
314    /// This method:
315    /// 1. Clears all keys from the agent core (zeroizing sensitive data)
316    /// 2. Marks the agent as not running
317    /// 3. Optionally removes the socket file and PID file
318    #[allow(clippy::disallowed_methods)] // INVARIANT: daemon lifecycle — socket/PID cleanup is inherently I/O
319    pub fn shutdown(&self) -> Result<(), AgentError> {
320        log::info!("Shutting down agent at {:?}", self.socket_path);
321
322        // Clear all keys (zeroizes sensitive data)
323        {
324            let mut core = self.lock()?;
325            core.clear_keys();
326            log::debug!("Cleared all keys from agent core");
327        }
328
329        // Mark as not running
330        self.set_running(false);
331
332        // Remove socket file if it exists
333        if self.socket_path.exists() {
334            if let Err(e) = std::fs::remove_file(&self.socket_path) {
335                log::warn!("Failed to remove socket file {:?}: {}", self.socket_path, e);
336            } else {
337                log::debug!("Removed socket file {:?}", self.socket_path);
338            }
339        }
340
341        // Remove PID file if it exists
342        if let Some(ref pid_file) = self.pid_file
343            && pid_file.exists()
344        {
345            if let Err(e) = std::fs::remove_file(pid_file) {
346                log::warn!("Failed to remove PID file {:?}: {}", pid_file, e);
347            } else {
348                log::debug!("Removed PID file {:?}", pid_file);
349            }
350        }
351
352        log::info!("Agent shutdown complete");
353        Ok(())
354    }
355
356    /// Returns the number of keys currently loaded in the agent.
357    pub fn key_count(&self) -> Result<usize, AgentError> {
358        let core = self.lock()?;
359        Ok(core.key_count())
360    }
361
362    /// Returns all public key bytes currently registered.
363    pub fn public_keys(&self) -> Result<Vec<Vec<u8>>, AgentError> {
364        let core = self.lock()?;
365        Ok(core.public_keys())
366    }
367
368    /// Registers a key in the agent core.
369    pub fn register_key(&self, pkcs8_bytes: Zeroizing<Vec<u8>>) -> Result<(), AgentError> {
370        {
371            let mut core = self.lock()?;
372            core.register_key(pkcs8_bytes)?;
373        }
374        if let Ok(mut unlocked) = self.unlocked_at.lock() {
375            *unlocked = Instant::now(); // Loading a key starts the absolute unlock window
376        }
377        Ok(())
378    }
379
380    /// Signs data using a key in the agent core.
381    ///
382    /// # Errors
383    /// Returns `AgentError::AgentLocked` if the agent is locked.
384    pub fn sign(&self, pubkey: &[u8], data: &[u8]) -> Result<Vec<u8>, AgentError> {
385        // Check if agent is locked
386        if self.is_agent_locked() {
387            return Err(AgentError::AgentLocked);
388        }
389
390        let core = self.lock()?;
391        let result = core.sign(pubkey, data);
392
393        // Touch on successful sign to reset idle timer
394        if result.is_ok() {
395            self.touch();
396        }
397
398        result
399    }
400}
401
402impl Clone for AgentHandle {
403    fn clone(&self) -> Self {
404        Self {
405            core: Arc::clone(&self.core),
406            socket_path: self.socket_path.clone(),
407            pid_file: self.pid_file.clone(),
408            running: Arc::clone(&self.running),
409            last_activity: Arc::clone(&self.last_activity),
410            idle_timeout: self.idle_timeout,
411            unlocked_at: Arc::clone(&self.unlocked_at),
412            max_unlock_ttl: self.max_unlock_ttl,
413            locked: Arc::clone(&self.locked),
414        }
415    }
416}
417
418#[cfg(test)]
419#[allow(clippy::disallowed_methods)]
420mod tests {
421    use super::*;
422    use ring::rand::SystemRandom;
423    use ring::signature::Ed25519KeyPair;
424    use tempfile::TempDir;
425
426    fn generate_test_pkcs8() -> Vec<u8> {
427        let rng = SystemRandom::new();
428        let pkcs8_doc = Ed25519KeyPair::generate_pkcs8(&rng).expect("Failed to generate PKCS#8");
429        pkcs8_doc.as_ref().to_vec()
430    }
431
432    #[test]
433    fn test_agent_handle_new() {
434        let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
435        assert_eq!(handle.socket_path(), &PathBuf::from("/tmp/test.sock"));
436        assert!(handle.pid_file().is_none());
437        assert!(!handle.is_running());
438    }
439
440    #[test]
441    fn test_agent_handle_with_pid_file() {
442        let handle = AgentHandle::with_pid_file(
443            PathBuf::from("/tmp/test.sock"),
444            PathBuf::from("/tmp/test.pid"),
445        );
446        assert_eq!(handle.socket_path(), &PathBuf::from("/tmp/test.sock"));
447        assert_eq!(handle.pid_file(), Some(&PathBuf::from("/tmp/test.pid")));
448    }
449
450    #[test]
451    fn test_agent_handle_running_state() {
452        let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
453        assert!(!handle.is_running());
454
455        handle.set_running(true);
456        assert!(handle.is_running());
457
458        handle.set_running(false);
459        assert!(!handle.is_running());
460    }
461
462    #[test]
463    fn test_agent_handle_key_operations() {
464        let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
465
466        assert_eq!(handle.key_count().unwrap(), 0);
467
468        let pkcs8_bytes = generate_test_pkcs8();
469        handle
470            .register_key(Zeroizing::new(pkcs8_bytes))
471            .expect("Failed to register key");
472
473        assert_eq!(handle.key_count().unwrap(), 1);
474
475        let pubkeys = handle.public_keys().unwrap();
476        assert_eq!(pubkeys.len(), 1);
477    }
478
479    #[test]
480    fn test_agent_handle_clone_shares_state() {
481        let handle1 = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
482        let handle2 = handle1.clone();
483
484        let pkcs8_bytes = generate_test_pkcs8();
485        handle1
486            .register_key(Zeroizing::new(pkcs8_bytes))
487            .expect("Failed to register key");
488
489        // Both handles should see the same key
490        assert_eq!(handle1.key_count().unwrap(), 1);
491        assert_eq!(handle2.key_count().unwrap(), 1);
492    }
493
494    #[test]
495    fn test_agent_handle_shutdown() {
496        let temp_dir = TempDir::new().unwrap();
497        let socket_path = temp_dir.path().join("test.sock");
498
499        // Create a dummy socket file
500        std::fs::write(&socket_path, "dummy").unwrap();
501
502        let handle = AgentHandle::new(socket_path.clone());
503        let pkcs8_bytes = generate_test_pkcs8();
504        handle
505            .register_key(Zeroizing::new(pkcs8_bytes))
506            .expect("Failed to register key");
507        handle.set_running(true);
508
509        assert_eq!(handle.key_count().unwrap(), 1);
510        assert!(handle.is_running());
511        assert!(socket_path.exists());
512
513        handle.shutdown().expect("Shutdown failed");
514
515        assert_eq!(handle.key_count().unwrap(), 0);
516        assert!(!handle.is_running());
517        assert!(!socket_path.exists());
518    }
519
520    #[test]
521    fn test_multiple_handles_independent() {
522        let handle1 = AgentHandle::new(PathBuf::from("/tmp/agent1.sock"));
523        let handle2 = AgentHandle::new(PathBuf::from("/tmp/agent2.sock"));
524
525        let pkcs8_bytes = generate_test_pkcs8();
526        handle1
527            .register_key(Zeroizing::new(pkcs8_bytes))
528            .expect("Failed to register key");
529
530        // Handles are independent - handle2 should have no keys
531        assert_eq!(handle1.key_count().unwrap(), 1);
532        assert_eq!(handle2.key_count().unwrap(), 0);
533    }
534
535    #[test]
536    fn test_agent_handle_lock_unlock() {
537        let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
538
539        // Initially not locked
540        assert!(!handle.is_agent_locked());
541
542        // Add a key
543        let pkcs8_bytes = generate_test_pkcs8();
544        handle
545            .register_key(Zeroizing::new(pkcs8_bytes))
546            .expect("Failed to register key");
547        assert_eq!(handle.key_count().unwrap(), 1);
548
549        // Lock the agent
550        handle.lock_agent().expect("Lock failed");
551        assert!(handle.is_agent_locked());
552        assert_eq!(handle.key_count().unwrap(), 0); // Keys cleared
553
554        // Unlock the agent
555        handle.unlock_agent();
556        assert!(!handle.is_agent_locked());
557    }
558
559    #[test]
560    fn test_agent_handle_sign_when_locked() {
561        let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
562
563        // Add a key
564        let pkcs8_bytes = generate_test_pkcs8();
565        handle
566            .register_key(Zeroizing::new(pkcs8_bytes))
567            .expect("Failed to register key");
568
569        // Get pubkey for signing
570        let pubkeys = handle.public_keys().unwrap();
571        let pubkey = &pubkeys[0];
572
573        // Sign should work when not locked
574        let result = handle.sign(pubkey, b"test data");
575        assert!(result.is_ok());
576
577        // Lock and try to sign
578        handle.lock_agent().expect("Lock failed");
579        let result = handle.sign(pubkey, b"test data");
580        assert!(matches!(result, Err(AgentError::AgentLocked)));
581    }
582
583    #[test]
584    fn test_agent_handle_idle_timeout() {
585        // Create handle with very short timeout for testing
586        let handle =
587            AgentHandle::with_timeout(PathBuf::from("/tmp/test.sock"), Duration::from_millis(10));
588
589        // Initially not timed out
590        assert!(!handle.is_idle_timed_out());
591        assert!(!handle.is_agent_locked());
592
593        // Wait for timeout
594        std::thread::sleep(Duration::from_millis(20));
595
596        // Should be timed out now
597        assert!(handle.is_idle_timed_out());
598
599        // Touch resets the timer
600        handle.touch();
601        assert!(!handle.is_idle_timed_out());
602    }
603
604    #[test]
605    fn test_agent_handle_zero_timeout_never_expires() {
606        // Create handle with zero timeout (never expires)
607        let handle = AgentHandle::with_timeout(PathBuf::from("/tmp/test.sock"), Duration::ZERO);
608
609        // Wait a bit
610        std::thread::sleep(Duration::from_millis(10));
611
612        // Should never be timed out
613        assert!(!handle.is_idle_timed_out());
614    }
615
616    #[test]
617    fn test_clone_shares_locked_state() {
618        let handle_a = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
619        let handle_b = handle_a.clone();
620
621        assert!(!handle_b.is_agent_locked());
622        handle_a.lock_agent().unwrap();
623        assert!(handle_b.is_agent_locked());
624
625        handle_a.unlock_agent();
626        assert!(!handle_b.is_agent_locked());
627    }
628
629    #[test]
630    fn test_clone_shares_last_activity() {
631        let handle_a =
632            AgentHandle::with_timeout(PathBuf::from("/tmp/test.sock"), Duration::from_millis(50));
633        let handle_b = handle_a.clone();
634
635        std::thread::sleep(Duration::from_millis(60));
636        assert!(handle_b.is_idle_timed_out());
637
638        // Touch on clone A resets timer visible from clone B
639        handle_a.touch();
640        assert!(!handle_b.is_idle_timed_out());
641    }
642
643    #[test]
644    fn test_clone_sign_returns_locked_after_other_clone_locks() {
645        let handle_a = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
646        let handle_b = handle_a.clone();
647
648        let pkcs8_bytes = generate_test_pkcs8();
649        handle_a.register_key(Zeroizing::new(pkcs8_bytes)).unwrap();
650
651        let pubkeys = handle_a.public_keys().unwrap();
652        let pubkey = &pubkeys[0];
653
654        assert!(handle_b.sign(pubkey, b"test data").is_ok());
655
656        handle_a.lock_agent().unwrap();
657        let result = handle_b.sign(pubkey, b"test data");
658        assert!(matches!(result, Err(AgentError::AgentLocked)));
659    }
660
661    #[test]
662    fn absolute_cap_locks_despite_continuous_activity() {
663        // Idle timeout effectively never; only the absolute unlock cap can fire.
664        let handle = AgentHandle::with_unlock_cap(
665            PathBuf::from("/tmp/test.sock"),
666            Duration::from_secs(3600),
667            Duration::from_millis(80),
668        );
669        handle
670            .register_key(Zeroizing::new(generate_test_pkcs8()))
671            .unwrap();
672
673        // Continuous activity keeps resetting the sliding idle timer.
674        for _ in 0..6 {
675            std::thread::sleep(Duration::from_millis(25));
676            handle.touch();
677        }
678
679        assert!(
680            handle.is_unlock_window_expired(),
681            "unlock window should be expired ~150ms past an 80ms cap"
682        );
683        let locked = handle.check_idle_timeout().unwrap();
684        assert!(
685            locked,
686            "the absolute unlock cap must lock the agent regardless of activity"
687        );
688        assert!(handle.is_agent_locked());
689        assert_eq!(handle.key_count().unwrap(), 0);
690    }
691
692    #[test]
693    fn public_keys_empty_after_lock() {
694        let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
695        handle
696            .register_key(Zeroizing::new(generate_test_pkcs8()))
697            .unwrap();
698        assert_eq!(handle.public_keys().unwrap().len(), 1);
699
700        handle.lock_agent().unwrap();
701
702        // Locking must clear keys before flipping the locked flag, so a listing request
703        // observed while locked cannot leak the key set.
704        assert!(handle.is_agent_locked());
705        assert!(
706            handle.public_keys().unwrap().is_empty(),
707            "locking must clear keys so the key list cannot leak while locked"
708        );
709    }
710}