Skip to main content

auths_cli/commands/agent/
mod.rs

1//! SSH agent daemon commands (start, stop, status).
2
3pub mod process;
4pub mod service;
5
6use anyhow::{Context, Result, anyhow};
7use clap::{Parser, Subcommand, ValueEnum};
8use serde::Serialize;
9use std::fs;
10use std::path::PathBuf;
11
12use crate::core::fs::{create_restricted_dir, write_sensitive_file};
13use crate::ux::format::{JsonResponse, is_json_mode};
14use process::{
15    cleanup_stale_files, is_process_running, read_pid_file, socket_is_connectable, write_pid_file,
16};
17use service::ServiceManager;
18
19const DEFAULT_SOCKET_NAME: &str = "agent.sock";
20const PID_FILE_NAME: &str = "agent.pid";
21const ENV_FILE_NAME: &str = "agent.env";
22const LOG_FILE_NAME: &str = "agent.log";
23
24#[derive(Parser, Debug, Clone)]
25#[command(
26    name = "agent",
27    about = "SSH agent daemon management (start, stop, status)."
28)]
29pub struct AgentCommand {
30    #[command(subcommand)]
31    pub command: AgentSubcommand,
32}
33
34#[derive(Subcommand, Debug, Clone)]
35pub enum AgentSubcommand {
36    /// Start the SSH agent daemon
37    Start {
38        /// Custom socket path (default: ~/.auths/agent.sock)
39        #[arg(long, help = "Custom Unix socket path")]
40        socket: Option<PathBuf>,
41
42        /// Run in foreground (don't daemonize)
43        #[arg(long, help = "Run in foreground instead of daemonizing")]
44        foreground: bool,
45
46        /// Idle timeout (e.g., "30m", "1h", "0" for never)
47        #[arg(long, default_value = "30m", help = "Idle timeout before auto-lock")]
48        timeout: String,
49    },
50
51    /// Stop the SSH agent daemon
52    Stop,
53
54    /// Show agent status
55    Status,
56
57    /// Output shell environment for SSH_AUTH_SOCK (use with eval)
58    Env {
59        /// Shell format for output
60        #[arg(long, value_enum, default_value = "bash", help = "Shell format")]
61        shell: ShellFormat,
62    },
63
64    /// Lock the agent (clear keys from memory)
65    Lock,
66
67    /// Unlock the agent (re-load keys)
68    Unlock {
69        /// Key alias to unlock
70        #[arg(
71            long = "agent-key-alias",
72            visible_alias = "key",
73            default_value = "default",
74            help = "Key alias to unlock"
75        )]
76        agent_key_alias: String,
77    },
78
79    /// Install as a system service (launchd on macOS, systemd on Linux)
80    InstallService {
81        /// Don't install, just print the service file
82        #[arg(long, help = "Print service file without installing")]
83        dry_run: bool,
84
85        /// Force overwrite if service already exists
86        #[arg(long, help = "Overwrite existing service file")]
87        force: bool,
88
89        /// Service manager to use (auto-detect if not specified)
90        #[arg(long, value_enum, help = "Service manager (auto-detect by default)")]
91        manager: Option<ServiceManager>,
92    },
93
94    /// Uninstall the system service
95    UninstallService,
96}
97
98/// Shell format for environment output.
99#[derive(ValueEnum, Clone, Debug, Default)]
100pub enum ShellFormat {
101    #[default]
102    Bash,
103    Zsh,
104    Fish,
105}
106
107/// Status information about the agent.
108#[derive(Serialize, Debug)]
109pub struct AgentStatus {
110    pub running: bool,
111    pub pid: Option<u32>,
112    pub socket_path: Option<String>,
113    pub socket_exists: bool,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub uptime_secs: Option<u64>,
116}
117
118/// Ensures the SSH agent is running, starting it if necessary.
119///
120/// Returns `Ok(true)` if already running, `Ok(false)` if it was started.
121///
122/// Args:
123/// * `quiet`: Suppress startup messages.
124///
125/// Usage:
126/// ```ignore
127/// let was_running = ensure_agent_running(true)?;
128/// ```
129#[allow(dead_code)] // Used by bin/sign.rs (cross-target usage not tracked by lint)
130pub fn ensure_agent_running(quiet: bool) -> Result<bool> {
131    let socket_path = get_default_socket_path()?;
132    let pid_path = get_pid_file_path()?;
133
134    if let Some(pid) = read_pid_file(&pid_path)?
135        && is_process_running(pid)
136        && socket_is_connectable(&socket_path)
137    {
138        return Ok(true);
139    }
140
141    if !quiet {
142        eprintln!("Agent not running, starting...");
143    }
144
145    start_agent(None, false, "30m", quiet)?;
146
147    let timeout = std::time::Duration::from_secs(2);
148    let start = std::time::Instant::now();
149    while start.elapsed() < timeout {
150        if let Some(pid) = read_pid_file(&pid_path)?
151            && is_process_running(pid)
152            && socket_is_connectable(&socket_path)
153        {
154            if !quiet {
155                eprintln!("Agent started (PID {})", pid);
156            }
157            return Ok(false);
158        }
159        std::thread::sleep(std::time::Duration::from_millis(100));
160    }
161
162    Err(anyhow!("Failed to start agent within 2 seconds"))
163}
164
165/// The name of a per-machine daemon operation, or `None` for a store operation.
166///
167/// Daemon operations (start/stop/status/install-service/uninstall-service) act
168/// on the per-machine agent and cannot be scoped by `--repo`. Store operations
169/// (env/lock/unlock) read repo-scoped paths.
170fn daemon_op_name(cmd: &AgentSubcommand) -> Option<&'static str> {
171    match cmd {
172        AgentSubcommand::Start { .. } => Some("start"),
173        AgentSubcommand::Stop => Some("stop"),
174        AgentSubcommand::Status => Some("status"),
175        AgentSubcommand::InstallService { .. } => Some("install-service"),
176        AgentSubcommand::UninstallService => Some("uninstall-service"),
177        AgentSubcommand::Env { .. } | AgentSubcommand::Lock | AgentSubcommand::Unlock { .. } => {
178            None
179        }
180    }
181}
182
183pub fn handle_agent(cmd: AgentCommand, repo: Option<PathBuf>) -> Result<()> {
184    // Daemon operations are per-machine; `--repo` cannot scope them, so reject
185    // it rather than silently operate on the global agent.
186    if let Some(op) = daemon_op_name(&cmd.command)
187        && repo.is_some()
188    {
189        anyhow::bail!(
190            "`--repo` is not supported for `auths agent {}`; the agent daemon is per-machine \
191             and is selected by AUTHS_HOME, not by repository.",
192            op
193        );
194    }
195
196    match cmd.command {
197        AgentSubcommand::Start {
198            socket,
199            foreground,
200            timeout,
201        } => start_agent(socket, foreground, &timeout, false),
202        AgentSubcommand::Stop => stop_agent(),
203        AgentSubcommand::Status => show_status(),
204        AgentSubcommand::InstallService {
205            dry_run,
206            force,
207            manager,
208        } => service::install_service(dry_run, force, manager),
209        AgentSubcommand::UninstallService => service::uninstall_service(),
210        // Store operations read repo-scoped paths; thread `--repo` through.
211        AgentSubcommand::Env { shell } => output_env(shell, repo),
212        AgentSubcommand::Lock => lock_agent(repo),
213        AgentSubcommand::Unlock { agent_key_alias } => unlock_agent(&agent_key_alias, repo),
214    }
215}
216
217fn parse_timeout(s: &str) -> Result<std::time::Duration> {
218    use std::time::Duration;
219
220    let s = s.trim();
221    if s == "0" {
222        return Ok(Duration::ZERO);
223    }
224
225    let (num_str, suffix) = if let Some(stripped) = s.strip_suffix('s') {
226        (stripped, "s")
227    } else if let Some(stripped) = s.strip_suffix('m') {
228        (stripped, "m")
229    } else if let Some(stripped) = s.strip_suffix('h') {
230        (stripped, "h")
231    } else {
232        (s, "m")
233    };
234
235    let num: u64 = num_str
236        .parse()
237        .with_context(|| format!("Invalid timeout number: {}", num_str))?;
238
239    let secs = match suffix {
240        "s" => num,
241        "m" => num * 60,
242        "h" => num * 3600,
243        _ => return Err(anyhow!("Invalid timeout suffix: {}", suffix)),
244    };
245
246    Ok(Duration::from_secs(secs))
247}
248
249fn get_auths_dir() -> Result<PathBuf> {
250    auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))
251}
252
253/// Resolve the agent's storage directory, honoring a `--repo` override.
254///
255/// `None` preserves the default (`AUTHS_HOME` / `~/.auths`); `Some(path)`
256/// scopes the agent's socket/pid/env files to that registry.
257///
258/// Args:
259/// * `repo`: The optional `--repo` override.
260///
261/// Usage:
262/// ```ignore
263/// let dir = get_auths_dir_for_repo(ctx.repo_path.clone())?;
264/// ```
265fn get_auths_dir_for_repo(repo: Option<PathBuf>) -> Result<PathBuf> {
266    match repo {
267        Some(_) => auths_sdk::storage_layout::resolve_repo_path(repo)
268            .context("Failed to resolve the repository path for the agent store"),
269        None => get_auths_dir(),
270    }
271}
272
273fn get_socket_path_for_repo(repo: Option<PathBuf>) -> Result<PathBuf> {
274    Ok(get_auths_dir_for_repo(repo)?.join(DEFAULT_SOCKET_NAME))
275}
276
277fn get_pid_file_path_for_repo(repo: Option<PathBuf>) -> Result<PathBuf> {
278    Ok(get_auths_dir_for_repo(repo)?.join(PID_FILE_NAME))
279}
280
281/// Get the default socket path.
282pub fn get_default_socket_path() -> Result<PathBuf> {
283    Ok(get_auths_dir()?.join(DEFAULT_SOCKET_NAME))
284}
285
286fn get_pid_file_path() -> Result<PathBuf> {
287    Ok(get_auths_dir()?.join(PID_FILE_NAME))
288}
289
290fn get_env_file_path() -> Result<PathBuf> {
291    Ok(get_auths_dir()?.join(ENV_FILE_NAME))
292}
293
294pub(crate) fn get_log_file_path() -> Result<PathBuf> {
295    Ok(get_auths_dir()?.join(LOG_FILE_NAME))
296}
297
298fn start_agent(
299    socket_path: Option<PathBuf>,
300    foreground: bool,
301    timeout_str: &str,
302    quiet: bool,
303) -> Result<()> {
304    let auths_dir = get_auths_dir()?;
305    create_restricted_dir(&auths_dir)
306        .with_context(|| format!("Failed to create auths directory: {:?}", auths_dir))?;
307
308    let socket = match socket_path {
309        Some(s) => s,
310        None => get_default_socket_path()?,
311    };
312    let pid_path = get_pid_file_path()?;
313    let env_path = get_env_file_path()?;
314    let timeout = parse_timeout(timeout_str)?;
315
316    if let Some(pid) = read_pid_file(&pid_path)? {
317        if is_process_running(pid) {
318            return Err(anyhow!(
319                "Agent already running (PID {}). Use 'auths agent stop' first.",
320                pid
321            ));
322        }
323        let _ = fs::remove_file(&pid_path);
324    }
325
326    match fs::remove_file(&socket) {
327        Ok(()) => {}
328        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
329        Err(e) => {
330            return Err(anyhow!("Failed to remove stale socket {:?}: {}", socket, e));
331        }
332    }
333
334    if foreground {
335        run_agent_foreground(&socket, &pid_path, &env_path, timeout)
336    } else {
337        daemonize_agent(&socket, &env_path, timeout_str, quiet)
338    }
339}
340
341#[cfg(unix)]
342fn run_agent_foreground(
343    socket: &std::path::Path,
344    pid_path: &std::path::Path,
345    env_path: &std::path::Path,
346    timeout: std::time::Duration,
347) -> Result<()> {
348    use auths_sdk::agent_core::AgentHandle;
349    use std::sync::Arc;
350
351    let pid = std::process::id();
352    write_pid_file(pid_path, pid)?;
353
354    let socket_str = socket
355        .to_str()
356        .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
357    let env_content = format!("export SSH_AUTH_SOCK=\"{}\"\n", socket_str);
358    write_sensitive_file(env_path, &env_content)
359        .with_context(|| format!("Failed to write env file: {:?}", env_path))?;
360
361    eprintln!("Starting SSH agent (foreground)...");
362    eprintln!("Socket: {}", socket_str);
363    eprintln!("PID: {}", pid);
364    if timeout.is_zero() {
365        eprintln!("Idle timeout: disabled");
366    } else {
367        eprintln!("Idle timeout: {:?}", timeout);
368    }
369    eprintln!();
370    eprintln!("To use this agent in your shell:");
371    eprintln!("  eval $(cat {})", env_path.display());
372    eprintln!("  # or");
373    eprintln!("  export SSH_AUTH_SOCK=\"{}\"", socket_str);
374    eprintln!();
375    eprintln!("Press Ctrl+C to stop.");
376
377    let handle = Arc::new(AgentHandle::with_pid_file_and_timeout(
378        socket.to_path_buf(),
379        pid_path.to_path_buf(),
380        timeout,
381    ));
382
383    let authorizer = build_sign_authorizer({
384        use std::io::IsTerminal;
385        std::io::stdin().is_terminal()
386    });
387
388    let rt = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
389    let result = rt.block_on(async {
390        auths_sdk::agent_core::start_agent_listener_with_handle(handle.clone(), authorizer).await
391    });
392
393    cleanup_stale_files(&[pid_path, env_path, socket]);
394
395    result.map_err(|e| anyhow!("Agent error: {}", e))
396}
397
398/// Builds the per-request signing authorizer for the agent. An interactive agent gates
399/// each new connecting process behind an approval prompt (so an unlocked agent does not
400/// grant silent signing to every same-user process); a daemonized / non-interactive
401/// agent is permissive, since there is no human present to approve.
402///
403/// Args:
404/// * `interactive`: whether the agent has a terminal to prompt on.
405///
406/// Usage:
407/// ```ignore
408/// let auth = build_sign_authorizer(std::io::stdin().is_terminal());
409/// ```
410#[cfg(unix)]
411fn build_sign_authorizer(
412    interactive: bool,
413) -> std::sync::Arc<dyn auths_sdk::agent_core::SignAuthorizer> {
414    if interactive {
415        std::sync::Arc::new(auths_sdk::agent_core::PerCallerAuthorizer::new(
416            approve_caller,
417        ))
418    } else {
419        std::sync::Arc::new(auths_sdk::agent_core::AllowAllSigning)
420    }
421}
422
423/// Prompts the operator to approve signing for a newly connected caller. Returns true to
424/// approve and pin that caller for the unlock window.
425///
426/// Args:
427/// * `peer`: the connecting process's identity.
428///
429/// Usage:
430/// ```ignore
431/// let approved = approve_caller(&peer);
432/// ```
433#[cfg(unix)]
434fn approve_caller(peer: &auths_sdk::agent_core::PeerIdentity) -> bool {
435    let who = match peer.pid {
436        Some(pid) => format!("process pid {pid} (uid {})", peer.uid),
437        None => format!("a process (uid {})", peer.uid),
438    };
439    eprintln!("\nauths agent: a new caller — {who} — is requesting a signature.");
440    dialoguer::Confirm::new()
441        .with_prompt("Approve signing for this caller?")
442        .default(false)
443        .interact()
444        .unwrap_or(false)
445}
446
447#[cfg(not(unix))]
448fn run_agent_foreground(
449    _socket: &std::path::Path,
450    _pid_path: &std::path::Path,
451    _env_path: &std::path::Path,
452    _timeout: std::time::Duration,
453) -> Result<()> {
454    Err(anyhow!(
455        "SSH agent is not supported on this platform (requires Unix domain sockets)"
456    ))
457}
458
459#[cfg(unix)]
460fn daemonize_agent(
461    socket: &std::path::Path,
462    env_path: &std::path::Path,
463    timeout_str: &str,
464    quiet: bool,
465) -> Result<()> {
466    let socket_str = socket
467        .to_str()
468        .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
469
470    let log_path = get_log_file_path()?;
471    let child_pid = process::spawn_detached(
472        &[
473            "agent",
474            "start",
475            "--foreground",
476            "--socket",
477            socket_str,
478            "--timeout",
479            timeout_str,
480        ],
481        &log_path,
482    )?;
483
484    if !quiet {
485        eprintln!("Agent daemon started (PID {})", child_pid);
486        eprintln!("Socket: {}", socket_str);
487        eprintln!("Log file: {}", log_path.display());
488        eprintln!();
489        eprintln!("To use this agent:");
490        eprintln!("  eval $(auths agent env)");
491        eprintln!("  # or");
492        eprintln!("  export SSH_AUTH_SOCK=\"{}\"", socket_str);
493    }
494
495    let env_content = format!("export SSH_AUTH_SOCK=\"{}\"\n", socket_str);
496    write_sensitive_file(env_path, &env_content)
497        .with_context(|| format!("Failed to write env file: {:?}", env_path))?;
498
499    Ok(())
500}
501
502#[cfg(not(unix))]
503fn daemonize_agent(
504    _socket: &std::path::Path,
505    _env_path: &std::path::Path,
506    _timeout_str: &str,
507    _quiet: bool,
508) -> Result<()> {
509    Err(anyhow!(
510        "Daemonization not supported on this platform. Use --foreground."
511    ))
512}
513
514fn stop_agent() -> Result<()> {
515    let pid_path = get_pid_file_path()?;
516    let socket_path = get_default_socket_path()?;
517    let env_path = get_env_file_path()?;
518
519    let pid = read_pid_file(&pid_path)?
520        .ok_or_else(|| anyhow!("Agent not running (no PID file found at {:?})", pid_path))?;
521
522    if !is_process_running(pid) {
523        eprintln!("Agent process {} not found. Cleaning up stale files.", pid);
524        cleanup_stale_files(&[&pid_path, &socket_path, &env_path]);
525        return Ok(());
526    }
527
528    eprintln!("Stopping agent (PID {})...", pid);
529    process::terminate_process(pid, std::time::Duration::from_secs(5))?;
530    cleanup_stale_files(&[&pid_path, &socket_path, &env_path]);
531    eprintln!("Agent stopped.");
532    Ok(())
533}
534
535fn show_status() -> Result<()> {
536    let pid_path = get_pid_file_path()?;
537    let socket_path = get_default_socket_path()?;
538
539    let pid = read_pid_file(&pid_path)?;
540    let running = pid.map(is_process_running).unwrap_or(false);
541    let socket_exists = socket_path.exists();
542
543    let status = AgentStatus {
544        running,
545        pid: if running { pid } else { None },
546        socket_path: if socket_exists {
547            Some(socket_path.to_string_lossy().to_string())
548        } else {
549            None
550        },
551        socket_exists,
552        uptime_secs: None,
553    };
554
555    if is_json_mode() {
556        JsonResponse::success("agent status", status).print()?;
557    } else if running {
558        eprintln!("Agent Status: RUNNING");
559        if let Some(p) = status.pid {
560            eprintln!("  PID: {}", p);
561        }
562        if let Some(ref sock) = status.socket_path {
563            eprintln!("  Socket: {}", sock);
564        }
565        eprintln!();
566        eprintln!("To use this agent:");
567        eprintln!("  eval $(auths agent env)");
568    } else {
569        eprintln!("Agent Status: STOPPED");
570        if pid.is_some() && !running {
571            eprintln!("  (Stale PID file found at {:?})", pid_path);
572        }
573        eprintln!();
574        eprintln!("To start the agent:");
575        eprintln!("  auths agent start");
576    }
577
578    Ok(())
579}
580
581fn output_env(shell: ShellFormat, repo: Option<PathBuf>) -> Result<()> {
582    let socket_path = get_socket_path_for_repo(repo.clone())?;
583    let pid_path = get_pid_file_path_for_repo(repo)?;
584
585    let pid = read_pid_file(&pid_path)?;
586    let running = pid.map(is_process_running).unwrap_or(false);
587
588    if !running {
589        eprintln!("Error: Agent is not running.");
590        eprintln!("Start the agent with: auths agent start");
591        std::process::exit(1);
592    }
593
594    if !socket_path.exists() {
595        eprintln!("Error: Socket file not found at {:?}", socket_path);
596        eprintln!("The agent may have crashed. Try: auths agent start");
597        std::process::exit(1);
598    }
599
600    let socket_str = socket_path
601        .to_str()
602        .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
603
604    match shell {
605        ShellFormat::Bash | ShellFormat::Zsh => {
606            println!("export SSH_AUTH_SOCK=\"{}\"", socket_str);
607        }
608        ShellFormat::Fish => {
609            println!("set -x SSH_AUTH_SOCK \"{}\"", socket_str);
610        }
611    }
612
613    Ok(())
614}
615
616#[cfg(unix)]
617fn lock_agent(repo: Option<PathBuf>) -> Result<()> {
618    let pid_path = get_pid_file_path_for_repo(repo.clone())?;
619    let pid = read_pid_file(&pid_path)?;
620    let running = pid.map(is_process_running).unwrap_or(false);
621
622    if !running {
623        return Err(anyhow!("Agent is not running"));
624    }
625
626    let socket_path = get_socket_path_for_repo(repo)?;
627    auths_sdk::agent_core::remove_all_identities(&socket_path)
628        .map_err(|e| anyhow!("Failed to lock agent: {}", e))?;
629
630    eprintln!("Agent locked — all keys removed from memory.");
631    eprintln!("Use `auths agent unlock <key-alias>` to reload a key.");
632
633    Ok(())
634}
635
636#[cfg(not(unix))]
637fn lock_agent(_repo: Option<PathBuf>) -> Result<()> {
638    Err(anyhow!(
639        "Agent lock is not supported on this platform (requires Unix domain sockets)"
640    ))
641}
642
643#[cfg(unix)]
644fn unlock_agent(key_alias: &str, repo: Option<PathBuf>) -> Result<()> {
645    let pid_path = get_pid_file_path_for_repo(repo.clone())?;
646    let pid = read_pid_file(&pid_path)?;
647    let running = pid.map(is_process_running).unwrap_or(false);
648
649    if !running {
650        return Err(anyhow!("Agent is not running"));
651    }
652
653    let socket_path = get_socket_path_for_repo(repo)?;
654
655    let keychain = auths_sdk::keychain::get_platform_keychain()
656        .map_err(|e| anyhow!("Failed to get platform keychain: {}", e))?;
657
658    if keychain.is_hardware_backend() {
659        return Err(anyhow!(
660            "Agent-mode signing requires a software-backed key. Key '{}' is hardware-backed \
661             (Secure Enclave) and cannot export raw key material needed by the SSH agent. \
662             Use direct signing instead (which dispatches through the Secure Enclave), \
663             or initialize a separate software-backed identity for agent use.",
664            key_alias
665        ));
666    }
667
668    let (_identity_did, _role, encrypted_data) = keychain
669        .load_key(&auths_sdk::keychain::KeyAlias::new_unchecked(key_alias))
670        .map_err(|e| anyhow!("Failed to load key '{}': {}", key_alias, e))?;
671
672    let passphrase = rpassword::prompt_password(format!("Passphrase for '{}': ", key_alias))
673        .context("Failed to read passphrase")?;
674
675    let key_bytes = auths_sdk::crypto::decrypt_keypair(&encrypted_data, &passphrase)
676        .map_err(|e| anyhow!("Failed to decrypt key '{}': {}", key_alias, e))?;
677
678    auths_sdk::agent_core::add_identity(&socket_path, &key_bytes)
679        .map_err(|e| anyhow!("Failed to add key to agent: {}", e))?;
680
681    eprintln!("Agent unlocked — key '{}' loaded.", key_alias);
682
683    Ok(())
684}
685
686#[cfg(not(unix))]
687fn unlock_agent(_key_alias: &str, _repo: Option<PathBuf>) -> Result<()> {
688    Err(anyhow!(
689        "Agent unlock is not supported on this platform (requires Unix domain sockets)"
690    ))
691}
692
693impl crate::commands::executable::ExecutableCommand for AgentCommand {
694    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
695        handle_agent(self.clone(), ctx.repo_path.clone())
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    #[test]
704    fn test_get_auths_dir() {
705        let dir = get_auths_dir().unwrap();
706        assert!(dir.ends_with(".auths"));
707    }
708
709    #[test]
710    fn test_get_default_socket_path() {
711        let path = get_default_socket_path().unwrap();
712        assert!(path.ends_with("agent.sock"));
713    }
714
715    #[test]
716    fn test_shell_format_default() {
717        let format: ShellFormat = Default::default();
718        assert!(matches!(format, ShellFormat::Bash));
719    }
720
721    #[test]
722    fn test_parse_timeout() {
723        use std::time::Duration;
724
725        assert_eq!(parse_timeout("0").unwrap(), Duration::ZERO);
726        assert_eq!(parse_timeout("30s").unwrap(), Duration::from_secs(30));
727        assert_eq!(parse_timeout("5m").unwrap(), Duration::from_secs(300));
728        assert_eq!(parse_timeout("30m").unwrap(), Duration::from_secs(1800));
729        assert_eq!(parse_timeout("1h").unwrap(), Duration::from_secs(3600));
730        assert_eq!(parse_timeout("2h").unwrap(), Duration::from_secs(7200));
731        assert_eq!(parse_timeout("30").unwrap(), Duration::from_secs(1800));
732    }
733}