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
165pub fn handle_agent(cmd: AgentCommand) -> Result<()> {
166    match cmd.command {
167        AgentSubcommand::Start {
168            socket,
169            foreground,
170            timeout,
171        } => start_agent(socket, foreground, &timeout, false),
172        AgentSubcommand::Stop => stop_agent(),
173        AgentSubcommand::Status => show_status(),
174        AgentSubcommand::Env { shell } => output_env(shell),
175        AgentSubcommand::Lock => lock_agent(),
176        AgentSubcommand::Unlock { agent_key_alias } => unlock_agent(&agent_key_alias),
177        AgentSubcommand::InstallService {
178            dry_run,
179            force,
180            manager,
181        } => service::install_service(dry_run, force, manager),
182        AgentSubcommand::UninstallService => service::uninstall_service(),
183    }
184}
185
186fn parse_timeout(s: &str) -> Result<std::time::Duration> {
187    use std::time::Duration;
188
189    let s = s.trim();
190    if s == "0" {
191        return Ok(Duration::ZERO);
192    }
193
194    let (num_str, suffix) = if let Some(stripped) = s.strip_suffix('s') {
195        (stripped, "s")
196    } else if let Some(stripped) = s.strip_suffix('m') {
197        (stripped, "m")
198    } else if let Some(stripped) = s.strip_suffix('h') {
199        (stripped, "h")
200    } else {
201        (s, "m")
202    };
203
204    let num: u64 = num_str
205        .parse()
206        .with_context(|| format!("Invalid timeout number: {}", num_str))?;
207
208    let secs = match suffix {
209        "s" => num,
210        "m" => num * 60,
211        "h" => num * 3600,
212        _ => return Err(anyhow!("Invalid timeout suffix: {}", suffix)),
213    };
214
215    Ok(Duration::from_secs(secs))
216}
217
218fn get_auths_dir() -> Result<PathBuf> {
219    auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))
220}
221
222/// Get the default socket path.
223pub fn get_default_socket_path() -> Result<PathBuf> {
224    Ok(get_auths_dir()?.join(DEFAULT_SOCKET_NAME))
225}
226
227fn get_pid_file_path() -> Result<PathBuf> {
228    Ok(get_auths_dir()?.join(PID_FILE_NAME))
229}
230
231fn get_env_file_path() -> Result<PathBuf> {
232    Ok(get_auths_dir()?.join(ENV_FILE_NAME))
233}
234
235pub(crate) fn get_log_file_path() -> Result<PathBuf> {
236    Ok(get_auths_dir()?.join(LOG_FILE_NAME))
237}
238
239fn start_agent(
240    socket_path: Option<PathBuf>,
241    foreground: bool,
242    timeout_str: &str,
243    quiet: bool,
244) -> Result<()> {
245    let auths_dir = get_auths_dir()?;
246    create_restricted_dir(&auths_dir)
247        .with_context(|| format!("Failed to create auths directory: {:?}", auths_dir))?;
248
249    let socket = match socket_path {
250        Some(s) => s,
251        None => get_default_socket_path()?,
252    };
253    let pid_path = get_pid_file_path()?;
254    let env_path = get_env_file_path()?;
255    let timeout = parse_timeout(timeout_str)?;
256
257    if let Some(pid) = read_pid_file(&pid_path)? {
258        if is_process_running(pid) {
259            return Err(anyhow!(
260                "Agent already running (PID {}). Use 'auths agent stop' first.",
261                pid
262            ));
263        }
264        let _ = fs::remove_file(&pid_path);
265    }
266
267    match fs::remove_file(&socket) {
268        Ok(()) => {}
269        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
270        Err(e) => {
271            return Err(anyhow!("Failed to remove stale socket {:?}: {}", socket, e));
272        }
273    }
274
275    if foreground {
276        run_agent_foreground(&socket, &pid_path, &env_path, timeout)
277    } else {
278        daemonize_agent(&socket, &env_path, timeout_str, quiet)
279    }
280}
281
282#[cfg(unix)]
283fn run_agent_foreground(
284    socket: &std::path::Path,
285    pid_path: &std::path::Path,
286    env_path: &std::path::Path,
287    timeout: std::time::Duration,
288) -> Result<()> {
289    use auths_sdk::agent_core::AgentHandle;
290    use std::sync::Arc;
291
292    let pid = std::process::id();
293    write_pid_file(pid_path, pid)?;
294
295    let socket_str = socket
296        .to_str()
297        .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
298    let env_content = format!("export SSH_AUTH_SOCK=\"{}\"\n", socket_str);
299    write_sensitive_file(env_path, &env_content)
300        .with_context(|| format!("Failed to write env file: {:?}", env_path))?;
301
302    eprintln!("Starting SSH agent (foreground)...");
303    eprintln!("Socket: {}", socket_str);
304    eprintln!("PID: {}", pid);
305    if timeout.is_zero() {
306        eprintln!("Idle timeout: disabled");
307    } else {
308        eprintln!("Idle timeout: {:?}", timeout);
309    }
310    eprintln!();
311    eprintln!("To use this agent in your shell:");
312    eprintln!("  eval $(cat {})", env_path.display());
313    eprintln!("  # or");
314    eprintln!("  export SSH_AUTH_SOCK=\"{}\"", socket_str);
315    eprintln!();
316    eprintln!("Press Ctrl+C to stop.");
317
318    let handle = Arc::new(AgentHandle::with_pid_file_and_timeout(
319        socket.to_path_buf(),
320        pid_path.to_path_buf(),
321        timeout,
322    ));
323
324    let rt = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
325    let result = rt.block_on(async {
326        auths_sdk::agent_core::start_agent_listener_with_handle(handle.clone()).await
327    });
328
329    cleanup_stale_files(&[pid_path, env_path, socket]);
330
331    result.map_err(|e| anyhow!("Agent error: {}", e))
332}
333
334#[cfg(not(unix))]
335fn run_agent_foreground(
336    _socket: &std::path::Path,
337    _pid_path: &std::path::Path,
338    _env_path: &std::path::Path,
339    _timeout: std::time::Duration,
340) -> Result<()> {
341    Err(anyhow!(
342        "SSH agent is not supported on this platform (requires Unix domain sockets)"
343    ))
344}
345
346#[cfg(unix)]
347fn daemonize_agent(
348    socket: &std::path::Path,
349    env_path: &std::path::Path,
350    timeout_str: &str,
351    quiet: bool,
352) -> Result<()> {
353    let socket_str = socket
354        .to_str()
355        .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
356
357    let log_path = get_log_file_path()?;
358    let child_pid = process::spawn_detached(
359        &[
360            "agent",
361            "start",
362            "--foreground",
363            "--socket",
364            socket_str,
365            "--timeout",
366            timeout_str,
367        ],
368        &log_path,
369    )?;
370
371    if !quiet {
372        eprintln!("Agent daemon started (PID {})", child_pid);
373        eprintln!("Socket: {}", socket_str);
374        eprintln!("Log file: {}", log_path.display());
375        eprintln!();
376        eprintln!("To use this agent:");
377        eprintln!("  eval $(auths agent env)");
378        eprintln!("  # or");
379        eprintln!("  export SSH_AUTH_SOCK=\"{}\"", socket_str);
380    }
381
382    let env_content = format!("export SSH_AUTH_SOCK=\"{}\"\n", socket_str);
383    write_sensitive_file(env_path, &env_content)
384        .with_context(|| format!("Failed to write env file: {:?}", env_path))?;
385
386    Ok(())
387}
388
389#[cfg(not(unix))]
390fn daemonize_agent(
391    _socket: &std::path::Path,
392    _env_path: &std::path::Path,
393    _timeout_str: &str,
394    _quiet: bool,
395) -> Result<()> {
396    Err(anyhow!(
397        "Daemonization not supported on this platform. Use --foreground."
398    ))
399}
400
401fn stop_agent() -> Result<()> {
402    let pid_path = get_pid_file_path()?;
403    let socket_path = get_default_socket_path()?;
404    let env_path = get_env_file_path()?;
405
406    let pid = read_pid_file(&pid_path)?
407        .ok_or_else(|| anyhow!("Agent not running (no PID file found at {:?})", pid_path))?;
408
409    if !is_process_running(pid) {
410        eprintln!("Agent process {} not found. Cleaning up stale files.", pid);
411        cleanup_stale_files(&[&pid_path, &socket_path, &env_path]);
412        return Ok(());
413    }
414
415    eprintln!("Stopping agent (PID {})...", pid);
416    process::terminate_process(pid, std::time::Duration::from_secs(5))?;
417    cleanup_stale_files(&[&pid_path, &socket_path, &env_path]);
418    eprintln!("Agent stopped.");
419    Ok(())
420}
421
422fn show_status() -> Result<()> {
423    let pid_path = get_pid_file_path()?;
424    let socket_path = get_default_socket_path()?;
425
426    let pid = read_pid_file(&pid_path)?;
427    let running = pid.map(is_process_running).unwrap_or(false);
428    let socket_exists = socket_path.exists();
429
430    let status = AgentStatus {
431        running,
432        pid: if running { pid } else { None },
433        socket_path: if socket_exists {
434            Some(socket_path.to_string_lossy().to_string())
435        } else {
436            None
437        },
438        socket_exists,
439        uptime_secs: None,
440    };
441
442    if is_json_mode() {
443        JsonResponse::success("agent status", status).print()?;
444    } else if running {
445        eprintln!("Agent Status: RUNNING");
446        if let Some(p) = status.pid {
447            eprintln!("  PID: {}", p);
448        }
449        if let Some(ref sock) = status.socket_path {
450            eprintln!("  Socket: {}", sock);
451        }
452        eprintln!();
453        eprintln!("To use this agent:");
454        eprintln!("  eval $(auths agent env)");
455    } else {
456        eprintln!("Agent Status: STOPPED");
457        if pid.is_some() && !running {
458            eprintln!("  (Stale PID file found at {:?})", pid_path);
459        }
460        eprintln!();
461        eprintln!("To start the agent:");
462        eprintln!("  auths agent start");
463    }
464
465    Ok(())
466}
467
468fn output_env(shell: ShellFormat) -> Result<()> {
469    let socket_path = get_default_socket_path()?;
470    let pid_path = get_pid_file_path()?;
471
472    let pid = read_pid_file(&pid_path)?;
473    let running = pid.map(is_process_running).unwrap_or(false);
474
475    if !running {
476        eprintln!("Error: Agent is not running.");
477        eprintln!("Start the agent with: auths agent start");
478        std::process::exit(1);
479    }
480
481    if !socket_path.exists() {
482        eprintln!("Error: Socket file not found at {:?}", socket_path);
483        eprintln!("The agent may have crashed. Try: auths agent start");
484        std::process::exit(1);
485    }
486
487    let socket_str = socket_path
488        .to_str()
489        .ok_or_else(|| anyhow!("Socket path is not valid UTF-8"))?;
490
491    match shell {
492        ShellFormat::Bash | ShellFormat::Zsh => {
493            println!("export SSH_AUTH_SOCK=\"{}\"", socket_str);
494        }
495        ShellFormat::Fish => {
496            println!("set -x SSH_AUTH_SOCK \"{}\"", socket_str);
497        }
498    }
499
500    Ok(())
501}
502
503#[cfg(unix)]
504fn lock_agent() -> Result<()> {
505    let pid_path = get_pid_file_path()?;
506    let pid = read_pid_file(&pid_path)?;
507    let running = pid.map(is_process_running).unwrap_or(false);
508
509    if !running {
510        return Err(anyhow!("Agent is not running"));
511    }
512
513    let socket_path = get_default_socket_path()?;
514    auths_sdk::agent_core::remove_all_identities(&socket_path)
515        .map_err(|e| anyhow!("Failed to lock agent: {}", e))?;
516
517    eprintln!("Agent locked — all keys removed from memory.");
518    eprintln!("Use `auths agent unlock <key-alias>` to reload a key.");
519
520    Ok(())
521}
522
523#[cfg(not(unix))]
524fn lock_agent() -> Result<()> {
525    Err(anyhow!(
526        "Agent lock is not supported on this platform (requires Unix domain sockets)"
527    ))
528}
529
530#[cfg(unix)]
531fn unlock_agent(key_alias: &str) -> Result<()> {
532    let pid_path = get_pid_file_path()?;
533    let pid = read_pid_file(&pid_path)?;
534    let running = pid.map(is_process_running).unwrap_or(false);
535
536    if !running {
537        return Err(anyhow!("Agent is not running"));
538    }
539
540    let socket_path = get_default_socket_path()?;
541
542    let keychain = auths_sdk::keychain::get_platform_keychain()
543        .map_err(|e| anyhow!("Failed to get platform keychain: {}", e))?;
544
545    if keychain.is_hardware_backend() {
546        return Err(anyhow!(
547            "Agent-mode signing requires a software-backed key. Key '{}' is hardware-backed \
548             (Secure Enclave) and cannot export raw key material needed by the SSH agent. \
549             Use direct signing instead (which dispatches through the Secure Enclave), \
550             or initialize a separate software-backed identity for agent use.",
551            key_alias
552        ));
553    }
554
555    let (_identity_did, _role, encrypted_data) = keychain
556        .load_key(&auths_sdk::keychain::KeyAlias::new_unchecked(key_alias))
557        .map_err(|e| anyhow!("Failed to load key '{}': {}", key_alias, e))?;
558
559    let passphrase = rpassword::prompt_password(format!("Passphrase for '{}': ", key_alias))
560        .context("Failed to read passphrase")?;
561
562    let key_bytes = auths_sdk::crypto::decrypt_keypair(&encrypted_data, &passphrase)
563        .map_err(|e| anyhow!("Failed to decrypt key '{}': {}", key_alias, e))?;
564
565    auths_sdk::agent_core::add_identity(&socket_path, &key_bytes)
566        .map_err(|e| anyhow!("Failed to add key to agent: {}", e))?;
567
568    eprintln!("Agent unlocked — key '{}' loaded.", key_alias);
569
570    Ok(())
571}
572
573#[cfg(not(unix))]
574fn unlock_agent(_key_alias: &str) -> Result<()> {
575    Err(anyhow!(
576        "Agent unlock is not supported on this platform (requires Unix domain sockets)"
577    ))
578}
579
580impl crate::commands::executable::ExecutableCommand for AgentCommand {
581    fn execute(&self, _ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
582        handle_agent(self.clone())
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn test_get_auths_dir() {
592        let dir = get_auths_dir().unwrap();
593        assert!(dir.ends_with(".auths"));
594    }
595
596    #[test]
597    fn test_get_default_socket_path() {
598        let path = get_default_socket_path().unwrap();
599        assert!(path.ends_with("agent.sock"));
600    }
601
602    #[test]
603    fn test_shell_format_default() {
604        let format: ShellFormat = Default::default();
605        assert!(matches!(format, ShellFormat::Bash));
606    }
607
608    #[test]
609    fn test_parse_timeout() {
610        use std::time::Duration;
611
612        assert_eq!(parse_timeout("0").unwrap(), Duration::ZERO);
613        assert_eq!(parse_timeout("30s").unwrap(), Duration::from_secs(30));
614        assert_eq!(parse_timeout("5m").unwrap(), Duration::from_secs(300));
615        assert_eq!(parse_timeout("30m").unwrap(), Duration::from_secs(1800));
616        assert_eq!(parse_timeout("1h").unwrap(), Duration::from_secs(3600));
617        assert_eq!(parse_timeout("2h").unwrap(), Duration::from_secs(7200));
618        assert_eq!(parse_timeout("30").unwrap(), Duration::from_secs(1800));
619    }
620}