agentd/config/prompt.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2//! Interactive entry for missing secrets (`--prompt-missing`).
3//!
4//! The shape mirrors `agentd login`: a deployment that is missing credentials
5//! stops and asks the person in front of it, one value at a time, instead of
6//! failing once per missing value across repeated restarts.
7//!
8//! Two hard rules keep this from becoming a footgun:
9//!
10//! - **Opt-in, and only with a controlling terminal.** The gate is opening
11//! `/dev/tty`, not guessing from stdin: a daemon under systemd or in a pod
12//! has no controlling terminal, so the open fails and startup fails loudly
13//! with the missing-reference list — never a silent hang on a prompt nobody
14//! can see.
15//! - **Entered values live in process memory only** (`sec::secret`'s prompted
16//! store). They are never written to the config file (how secrets end up in
17//! git), never exported to the environment (children would inherit them),
18//! and a restart re-prompts — the honest cost of persisting nothing.
19
20use std::io::{Read, Write};
21use std::sync::atomic::{AtomicBool, Ordering};
22
23static REQUESTED: AtomicBool = AtomicBool::new(false);
24
25/// Record that `--prompt-missing` was on the command line (consumed by the CLI
26/// shell before the settings model sees argv, like `--fresh`).
27pub fn request_prompt_missing() {
28 REQUESTED.store(true, Ordering::Relaxed);
29}
30
31/// Whether the operator asked to be prompted for missing values.
32pub fn prompt_missing_requested() -> bool {
33 REQUESTED.load(Ordering::Relaxed)
34}
35
36/// Read one secret from the controlling terminal, echo off.
37///
38/// Errors when there is no controlling terminal — which is the correct answer
39/// for a daemonized process, not a condition to work around.
40pub fn read_secret_from_tty(label: &str) -> Result<String, String> {
41 let mut tty = std::fs::OpenOptions::new()
42 .read(true)
43 .write(true)
44 .open("/dev/tty")
45 .map_err(|e| format!("no controlling terminal ({e}) — --prompt-missing needs one"))?;
46 write!(tty, "{label}: ").map_err(|e| e.to_string())?;
47 tty.flush().ok();
48
49 // Echo off for the read; restored whatever happens after it.
50 let fd = std::os::fd::AsRawFd::as_raw_fd(&tty);
51 let mut term: libc::termios = unsafe { std::mem::zeroed() };
52 let had_termios = unsafe { libc::tcgetattr(fd, &mut term) } == 0;
53 let saved = term;
54 if had_termios {
55 term.c_lflag &= !libc::ECHO;
56 unsafe { libc::tcsetattr(fd, libc::TCSANOW, &term) };
57 }
58 let mut value = Vec::new();
59 let mut byte = [0u8; 1];
60 let read = loop {
61 match tty.read(&mut byte) {
62 Ok(0) => break Err("EOF before a value was entered".to_string()),
63 Ok(_) if byte[0] == b'\n' => break Ok(()),
64 Ok(_) => value.push(byte[0]),
65 Err(e) => break Err(e.to_string()),
66 }
67 };
68 if had_termios {
69 unsafe { libc::tcsetattr(fd, libc::TCSANOW, &saved) };
70 }
71 let _ = writeln!(tty);
72 read?;
73 let s = String::from_utf8_lossy(&value);
74 let s = s.trim_end_matches('\r').trim();
75 if s.is_empty() {
76 return Err("empty value".to_string());
77 }
78 Ok(s.to_string())
79}