openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! The interactive-prompt seam.
//!
//! Every question `init` and `openlatch proxy` ask a human goes through [`Prompter`].
//! Production reads the terminal; tests hand over a script. That is the rustup / gh / cargo
//! model, and it is chosen over driving a real PTY for the reason those three chose it: a
//! prompt loop's interesting behaviour is its *decisions* — how many attempts, what an empty
//! answer means, whether the password is ever echoed — and none of those need a terminal to
//! be true. One Unix-only PTY smoke test covers the wiring the seam cannot: that the
//! production implementation really is attached to a tty and really does suppress echo.
//!
//! ## Never prompt when there is nobody to answer
//!
//! [`interactive`] is the single predicate. `IsTerminal` alone is provably insufficient —
//! Windows ConPTY reports a terminal for a process nothing is driving, and a CI job that
//! blocks on a prompt is indistinguishable from a hung build — so the gate is the same
//! four-condition test the telemetry-consent prompt already uses, plus `--yes`.

use std::io::{BufRead, IsTerminal, Write};

use secrecy::SecretString;

use crate::cli::output::{OutputConfig, OutputFormat};

/// What one prompt produced.
///
/// [`PromptResult::Aborted`] is first-class rather than an error string: pressing Enter on
/// an empty prompt is a deliberate answer ("stop asking"), the copy tells the operator it
/// is available, and the message the caller prints for it is not the message it prints for
/// three wrong URLs.
#[derive(Debug)]
pub enum PromptResult<T> {
    /// The human answered.
    Answered(T),
    /// The human pressed Enter on an empty line.
    Aborted,
    /// There is no terminal to ask. Never produced by the production implementation
    /// behind [`interactive`]; it exists so a caller that reaches a prompter anyway
    /// returns instead of blocking.
    NotATty,
}

/// Asks a human a question.
pub trait Prompter {
    /// Ask for a proxy URL. `attempt` is 1-based, for copy that counts down.
    fn ask_url(&mut self, attempt: u8) -> PromptResult<String>;
    /// Ask for the password belonging to `user` on `authority`. Never echoes.
    fn ask_password(&mut self, authority: &str, user: &str) -> PromptResult<SecretString>;
    /// Ask for the username to present to `authority`.
    fn ask_username(&mut self, authority: &str) -> PromptResult<String>;
}

/// Is there a human here, and are they allowed to be asked?
///
/// All five conditions, and each one is load-bearing:
///
/// - **stdin is a terminal** — nothing to read from otherwise.
/// - **stdout is a terminal** — a piped stdout means a script is consuming this, and a
///   prompt written into its input is corruption.
/// - **human output format** — `--json` promises one document on stdout; a prompt is not
///   part of it.
/// - **not `--quiet`** — quiet asked for no output, and a prompt is output.
/// - **not `--yes`** — the flag exists precisely to force headless semantics on a machine
///   that *does* have a terminal, which is the only way to script an install from an
///   interactive shell.
pub fn interactive(output: &OutputConfig, yes: bool) -> bool {
    std::io::stdin().is_terminal()
        && std::io::stdout().is_terminal()
        && output.format == OutputFormat::Human
        && !output.quiet
        && !yes
}

/// How many times the URL prompt asks before giving up.
///
/// Three, matching `sudo`. A cap exists at all so a mistyped-URL loop cannot become an
/// interactive hang inside a script that only *looks* headless.
pub const MAX_URL_ATTEMPTS: u8 = 3;

/// The production prompter: stderr for the questions, stdin for the answers, `rpassword`
/// for the password.
///
/// **Questions go to stderr, never stdout.** `--json` is off on this path by construction
/// (see [`interactive`]), but the rule holds anyway: stdout is the document channel, and
/// the pip `--report -` precedent is that human lines and machine output never share it.
pub struct TerminalPrompter {
    /// The platform origin named in the copy, so the operator knows what could not be
    /// reached.
    pub api_url: String,
}

impl TerminalPrompter {
    /// A prompter that names `api_url` as the unreachable destination.
    pub fn new(api_url: impl Into<String>) -> Self {
        Self {
            api_url: api_url.into(),
        }
    }

    fn read_line() -> Option<String> {
        let mut buf = String::new();
        let stdin = std::io::stdin();
        match stdin.lock().read_line(&mut buf) {
            // Zero bytes is EOF: the terminal went away mid-prompt. Treated as an abort,
            // because looping on a closed stdin is a spin, not a retry.
            Ok(0) | Err(_) => None,
            Ok(_) => Some(buf.trim().to_string()),
        }
    }
}

impl Prompter for TerminalPrompter {
    fn ask_url(&mut self, attempt: u8) -> PromptResult<String> {
        // The copy is normative — it is the PRD's Init & Discovery Flow line, verbatim on
        // the first attempt. Later attempts add the count, because "it did not work" and
        // "you have one try left" are different things to know.
        if attempt == 1 {
            eprintln!(
                "Cannot reach {}. Proxy URL (http://, https://, socks5://) or Enter to abort:",
                self.api_url
            );
        } else {
            eprintln!(
                "That proxy did not work. Proxy URL (http://, https://, socks5://) or Enter to \
                 abort ({attempt} of {MAX_URL_ATTEMPTS}):"
            );
        }
        let _ = std::io::stderr().flush();
        match Self::read_line() {
            None => PromptResult::Aborted,
            Some(s) if s.is_empty() => PromptResult::Aborted,
            Some(s) => PromptResult::Answered(s),
        }
    }

    fn ask_username(&mut self, authority: &str) -> PromptResult<String> {
        eprintln!("{authority} requires authentication. Username (leave empty to cancel):");
        let _ = std::io::stderr().flush();
        match Self::read_line() {
            None => PromptResult::Aborted,
            Some(s) if s.is_empty() => PromptResult::Aborted,
            Some(s) => PromptResult::Answered(s),
        }
    }

    fn ask_password(&mut self, authority: &str, user: &str) -> PromptResult<SecretString> {
        eprintln!("Password for {user}@{authority} (not echoed; leave empty to cancel):");
        let _ = std::io::stderr().flush();
        // `rpassword` opens the controlling terminal directly and disables echo there, so
        // the password never reaches the scrollback — nor a recorded session, nor the
        // shell history of whoever pastes the transcript into a ticket.
        match rpassword::read_password() {
            Err(_) => PromptResult::Aborted,
            Ok(p) if p.trim().is_empty() => PromptResult::Aborted,
            Ok(p) => PromptResult::Answered(SecretString::from(p)),
        }
    }
}

/// A prompter that answers from a script. Test-only, and the reason the trait exists.
///
/// Lives beside the production implementation rather than in a test module because four
/// integration-test binaries drive it, and `#[cfg(test)]` items are invisible to those.
pub struct ScriptedPrompter {
    urls: std::collections::VecDeque<Option<String>>,
    usernames: std::collections::VecDeque<Option<String>>,
    passwords: std::collections::VecDeque<Option<String>>,
    /// Every question asked, in order — so a test can assert the *shape* of the
    /// conversation, not merely its outcome.
    pub asked: Vec<String>,
}

impl ScriptedPrompter {
    /// A prompter that answers URL prompts with `urls` in order. `None` is an abort;
    /// running out of answers is also an abort, so a loop that asks more times than the
    /// test scripted terminates instead of hanging.
    pub fn new(urls: Vec<Option<&str>>) -> Self {
        Self {
            urls: urls.into_iter().map(|u| u.map(str::to_string)).collect(),
            usernames: std::collections::VecDeque::new(),
            passwords: std::collections::VecDeque::new(),
            asked: Vec::new(),
        }
    }

    /// Queue the credential answers, in order.
    pub fn with_credentials(
        mut self,
        usernames: Vec<Option<&str>>,
        passwords: Vec<Option<&str>>,
    ) -> Self {
        self.usernames = usernames
            .into_iter()
            .map(|u| u.map(str::to_string))
            .collect();
        self.passwords = passwords
            .into_iter()
            .map(|p| p.map(str::to_string))
            .collect();
        self
    }
}

impl Prompter for ScriptedPrompter {
    fn ask_url(&mut self, attempt: u8) -> PromptResult<String> {
        self.asked.push(format!("url:{attempt}"));
        match self.urls.pop_front() {
            Some(Some(u)) => PromptResult::Answered(u),
            _ => PromptResult::Aborted,
        }
    }

    fn ask_username(&mut self, authority: &str) -> PromptResult<String> {
        self.asked.push(format!("username:{authority}"));
        match self.usernames.pop_front() {
            Some(Some(u)) => PromptResult::Answered(u),
            _ => PromptResult::Aborted,
        }
    }

    fn ask_password(&mut self, authority: &str, user: &str) -> PromptResult<SecretString> {
        // The recorded question carries the authority and the user, never the answer:
        // a test helper that logged the password would put it in CI output.
        self.asked.push(format!("password:{user}@{authority}"));
        match self.passwords.pop_front() {
            Some(Some(p)) => PromptResult::Answered(SecretString::from(p)),
            _ => PromptResult::Aborted,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use secrecy::ExposeSecret;

    #[test]
    fn a_script_answers_in_order_and_then_aborts() {
        let mut p = ScriptedPrompter::new(vec![Some("http://a:1"), Some("http://b:2")]);
        assert!(matches!(p.ask_url(1), PromptResult::Answered(u) if u == "http://a:1"));
        assert!(matches!(p.ask_url(2), PromptResult::Answered(u) if u == "http://b:2"));
        // Running out is an abort, not a hang: a loop asking more than the test scripted
        // must terminate.
        assert!(matches!(p.ask_url(3), PromptResult::Aborted));
        assert_eq!(p.asked, vec!["url:1", "url:2", "url:3"]);
    }

    #[test]
    fn an_explicit_none_is_the_abort_answer() {
        let mut p = ScriptedPrompter::new(vec![None]);
        assert!(matches!(p.ask_url(1), PromptResult::Aborted));
    }

    #[test]
    fn credentials_come_back_in_order_and_are_never_logged() {
        let mut p =
            ScriptedPrompter::new(vec![]).with_credentials(vec![Some("svc")], vec![Some("s3cr3t")]);
        assert!(
            matches!(p.ask_username("http://proxy:8080"), PromptResult::Answered(u) if u == "svc")
        );
        let PromptResult::Answered(pw) = p.ask_password("http://proxy:8080", "svc") else {
            panic!("scripted password must be answered");
        };
        assert_eq!(pw.expose_secret(), "s3cr3t");
        assert!(
            !p.asked.iter().any(|q| q.contains("s3cr3t")),
            "the transcript must never carry the password: {:?}",
            p.asked
        );
    }

    fn out(format: OutputFormat, quiet: bool) -> OutputConfig {
        OutputConfig {
            format,
            verbose: false,
            debug: false,
            quiet,
            color: false,
        }
    }

    /// `--yes` is the only way to script an install from an interactive shell, so it has
    /// to beat a real terminal rather than merely agree with a missing one.
    #[test]
    fn yes_forces_headless_semantics() {
        assert!(
            !interactive(&out(OutputFormat::Human, false), true),
            "--yes must suppress prompting whatever the terminal says"
        );
    }

    #[test]
    fn json_and_quiet_each_suppress_prompting_on_their_own() {
        assert!(!interactive(&out(OutputFormat::Json, false), false));
        assert!(!interactive(&out(OutputFormat::Human, true), false));
    }
}