Skip to main content

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}