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
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
222pub 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}