1pub 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 {
38 #[arg(long, help = "Custom Unix socket path")]
40 socket: Option<PathBuf>,
41
42 #[arg(long, help = "Run in foreground instead of daemonizing")]
44 foreground: bool,
45
46 #[arg(long, default_value = "30m", help = "Idle timeout before auto-lock")]
48 timeout: String,
49 },
50
51 Stop,
53
54 Status,
56
57 Env {
59 #[arg(long, value_enum, default_value = "bash", help = "Shell format")]
61 shell: ShellFormat,
62 },
63
64 Lock,
66
67 Unlock {
69 #[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 InstallService {
81 #[arg(long, help = "Print service file without installing")]
83 dry_run: bool,
84
85 #[arg(long, help = "Overwrite existing service file")]
87 force: bool,
88
89 #[arg(long, value_enum, help = "Service manager (auto-detect by default)")]
91 manager: Option<ServiceManager>,
92 },
93
94 UninstallService,
96}
97
98#[derive(ValueEnum, Clone, Debug, Default)]
100pub enum ShellFormat {
101 #[default]
102 Bash,
103 Zsh,
104 Fish,
105}
106
107#[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#[allow(dead_code)] pub 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
165fn 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 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 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
253fn 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
281pub 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#[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#[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}