supercode-cli 0.4.16

supercode — a lightweight, fully-customizable AI coding agent CLI in Rust. Any model via OpenRouter; natively continues Claude Code and Codex sessions.
//! Hidden (no-echo) line read for password/API-key prompts (UX-18).
//!
//! supercode's `login` prompt used to echo the pasted key straight back to
//! the terminal ("input hidden is not supported, paste carefully") — a real
//! shoulder-surfing / terminal-scrollback leak. This masks the input on a
//! real TTY by disabling `ECHO` via `termios` for the duration of the read
//! (keeping `ECHONL` so pressing Enter still moves the cursor down, exactly
//! like `sudo`/`ssh-keygen`/`git credential` prompts), and restores the
//! original terminal mode afterward via an RAII guard (see [`TermiosGuard`])
//! — so restoration happens on every exit path, not just the one explicit
//! call site a hand-written restore would cover.
//!
//! When stdin is not a TTY (piped input, e.g. `--api-key-stdin`), there is
//! no echo to suppress, so this falls back to a plain `read_line`.
//!
//! Known limitation (UX-18 ticket #2, documented not fixed): Ctrl-C during
//! the read. `ISIG` is deliberately left untouched (so `Ctrl-C` still works
//! at all), which means SIGINT invokes the default handler and kills the
//! process immediately — `Drop` does not run on death-by-signal, so
//! [`TermiosGuard`] cannot restore `ECHO` in that case, and the shell is
//! left with echo off until the user runs `stty sane`/`reset`. Fixing this
//! properly needs a `SIGINT` handler (the way `sudo`/`ssh` do it), which is
//! a materially bigger change that would also interact with UX-29's own
//! SIGINT handling — deliberately out of scope here. See `UX-18.md` →
//! "Known limitations".
//!
//! **Windows (UX-12).** Ported, not degraded: [`ConsoleGuard`] does the same
//! job as [`TermiosGuard`] via the Console API (`GetConsoleMode`/
//! `SetConsoleMode` clearing `ENABLE_ECHO_INPUT`, restored on drop). One
//! genuine behavior difference, called out here rather than left silent:
//! unlike Unix's `ECHONL` (which keeps the terminal driver echoing the
//! newline produced by Enter even with `ECHO` off), Windows has no
//! equivalent bit — suppressing `ENABLE_ECHO_INPUT` also suppresses the
//! Enter keypress's own line break, which would otherwise leave the cursor
//! sitting mid-line after the read. [`read_hidden_line_windows`] compensates
//! by writing the newline itself once the read completes, so the visible
//! result (masked input, cursor still lands on a fresh line after Enter) is
//! the same. `ISIG`'s Windows analog, `ENABLE_PROCESSED_INPUT`, is left
//! untouched for the same reason `ISIG` is on Unix: Ctrl-C must keep working.

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

#[cfg(unix)]
use std::os::unix::io::RawFd;

/// RAII guard that restores a terminal's original `termios` settings when
/// dropped. Constructed only after `ECHO` has actually been disabled, so its
/// existence is itself a witness that the terminal needs restoring.
///
/// Because restoration lives in `Drop`, it fires on *every* exit path out of
/// the scope that holds the guard — a normal return, an `Err`-propagating
/// `?`, an early `return`, or a panic unwind — without the caller having to
/// remember to call a matching "restore" function on each one.
#[cfg(unix)]
struct TermiosGuard {
    fd: RawFd,
    orig: libc::termios,
}

#[cfg(unix)]
impl Drop for TermiosGuard {
    fn drop(&mut self) {
        // SAFETY: `fd` is the stdin descriptor this guard was built for, and
        // `orig` is the termios `tcgetattr` produced for that same fd before
        // we mutated it — both remain valid for `tcsetattr` here. Restoring
        // on a best-effort basis (ignoring the return code) mirrors the
        // original behavior: there's nothing more useful to do with a
        // restore failure than to leave the terminal as-is.
        unsafe {
            libc::tcsetattr(self.fd, libc::TCSANOW, &self.orig);
        }
    }
}

/// Whether a hidden-input attempt actually ended up hiding the input.
/// Reported once, before the blocking read starts, so a caller printing a
/// prompt banner can word it honestly instead of unconditionally claiming
/// "input is hidden".
pub enum HiddenReadStatus {
    /// stdin isn't a TTY (e.g. piped `--api-key-stdin`) — there is no
    /// terminal echo to suppress in the first place.
    NotATty,
    /// `ECHO` was disabled for this read; a guard restores it afterward.
    Hidden,
    /// stdin is a TTY but its termios could not be queried or set (e.g. an
    /// unusual sandbox) — falling back to a plain, visibly-echoed read.
    Unavailable,
}

/// Read one line from stdin with terminal echo disabled when possible,
/// reporting the outcome via `on_status` — called exactly once, before the
/// blocking read begins — so a caller can print an accurate prompt first.
/// Returns the line with leading/trailing whitespace trimmed.
pub fn read_hidden_line_reporting(on_status: impl FnOnce(HiddenReadStatus)) -> io::Result<String> {
    if !io::stdin().is_terminal() {
        on_status(HiddenReadStatus::NotATty);
        return read_plain_line();
    }

    #[cfg(unix)]
    {
        read_hidden_line_unix(on_status)
    }
    #[cfg(windows)]
    {
        read_hidden_line_windows(on_status)
    }
    #[cfg(not(any(unix, windows)))]
    {
        // Unsupported platform: no known raw-echo API. Degrade to a
        // visible prompt rather than failing the read outright.
        on_status(HiddenReadStatus::Unavailable);
        read_plain_line()
    }
}

#[cfg(unix)]
fn read_hidden_line_unix(on_status: impl FnOnce(HiddenReadStatus)) -> io::Result<String> {
    let fd = libc::STDIN_FILENO;
    let mut term = std::mem::MaybeUninit::<libc::termios>::uninit();
    // SAFETY: `term` is a valid, appropriately-sized out-pointer for
    // `tcgetattr`; `fd` is the well-known stdin descriptor.
    if unsafe { libc::tcgetattr(fd, term.as_mut_ptr()) } != 0 {
        // Couldn't query the terminal (unusual, e.g. under some sandboxes) —
        // degrade to a visible prompt rather than failing login outright.
        on_status(HiddenReadStatus::Unavailable);
        return read_plain_line();
    }
    // SAFETY: `tcgetattr` above returned success, so `term` is initialized.
    let orig = unsafe { term.assume_init() };
    let mut hidden = orig;
    hidden.c_lflag &= !libc::ECHO; // stop echoing typed/pasted characters
    hidden.c_lflag |= libc::ECHONL; // still echo the newline on Enter

    // SAFETY: `fd` is stdin, `hidden` is a validly-initialized termios
    // derived from a successful `tcgetattr` call.
    let applied = unsafe { libc::tcsetattr(fd, libc::TCSANOW, &hidden) } == 0;

    // Build the guard only when echo was actually disabled — its `Drop`
    // impl is what restores `orig` now, on *any* path out of this function
    // (including a future `?`/early-return/panic between here and the end
    // of the block), rather than a single hand-placed restore call that a
    // later edit could accidentally step around.
    let _guard = if applied {
        on_status(HiddenReadStatus::Hidden);
        Some(TermiosGuard { fd, orig })
    } else {
        on_status(HiddenReadStatus::Unavailable);
        None
    };

    let result = read_plain_line();
    io::stdout().flush().ok();
    result
    // `_guard` (if any) drops here — or earlier, on an early return/unwind
    // introduced above — restoring the original termios.
}

/// RAII guard that restores a console's original mode when dropped — the
/// Windows counterpart to [`TermiosGuard`]. See the module doc's "Windows"
/// section for why this ports cleanly (Console API mirrors `termios`
/// closely enough for this one bit) rather than needing to degrade.
#[cfg(windows)]
struct ConsoleGuard {
    handle: windows_sys::Win32::Foundation::HANDLE,
    orig_mode: u32,
}

#[cfg(windows)]
impl Drop for ConsoleGuard {
    fn drop(&mut self) {
        // SAFETY: `handle` is the stdin console handle this guard was built
        // for, and `orig_mode` is the mode `GetConsoleMode` produced for
        // that same handle before we mutated it — both remain valid for
        // `SetConsoleMode` here. Best-effort restore (ignoring the return
        // code), mirroring `TermiosGuard::drop`.
        unsafe {
            windows_sys::Win32::System::Console::SetConsoleMode(self.handle, self.orig_mode);
        }
    }
}

#[cfg(windows)]
fn read_hidden_line_windows(on_status: impl FnOnce(HiddenReadStatus)) -> io::Result<String> {
    use windows_sys::Win32::System::Console::{
        GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_ECHO_INPUT, STD_INPUT_HANDLE,
    };

    // SAFETY: `STD_INPUT_HANDLE` is the well-known standard-handle id;
    // `GetStdHandle` merely looks it up, no preconditions.
    let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
    let mut orig_mode: u32 = 0;
    // SAFETY: `orig_mode` is a valid out-pointer for `GetConsoleMode`;
    // `handle` was just obtained above (already validated as a real
    // console by the `is_terminal()` check in the caller).
    if unsafe { GetConsoleMode(handle, &mut orig_mode) } == 0 {
        // Couldn't query the console (unusual) — degrade to a visible
        // prompt rather than failing login outright, same as the Unix
        // `tcgetattr`-failure branch.
        on_status(HiddenReadStatus::Unavailable);
        return read_plain_line();
    }
    let hidden_mode = orig_mode & !ENABLE_ECHO_INPUT; // stop echoing typed/pasted characters
                                                      // (ENABLE_PROCESSED_INPUT deliberately left
                                                      // untouched — Ctrl-C must keep working, same
                                                      // as `ISIG` on Unix.)

    // SAFETY: `handle` is the stdin console handle, `hidden_mode` is a
    // validly-derived mode from a successful `GetConsoleMode` call.
    let applied = unsafe { SetConsoleMode(handle, hidden_mode) } != 0;

    let _guard = if applied {
        on_status(HiddenReadStatus::Hidden);
        Some(ConsoleGuard { handle, orig_mode })
    } else {
        on_status(HiddenReadStatus::Unavailable);
        None
    };

    let result = read_plain_line();
    if applied {
        // Windows has no `ECHONL` equivalent: suppressing
        // `ENABLE_ECHO_INPUT` also suppresses the newline Enter would
        // otherwise have produced. Write it ourselves so the cursor still
        // lands on a fresh line, matching the Unix behavior (see module
        // doc).
        let _ = io::stdout().write_all(b"\n");
    }
    io::stdout().flush().ok();
    result
    // `_guard` (if any) drops here — or earlier, on an early return/unwind
    // introduced above — restoring the original console mode.
}

/// Read one line from stdin with terminal echo disabled when possible.
/// Convenience wrapper over [`read_hidden_line_reporting`] for callers (like
/// `--api-key-stdin`) that don't print a status-dependent banner and so
/// don't need to know whether hiding actually happened.
pub fn read_hidden_line() -> io::Result<String> {
    read_hidden_line_reporting(|_| {})
}

fn read_plain_line() -> io::Result<String> {
    let mut line = String::new();
    io::stdin().lock().read_line(&mut line)?;
    Ok(line.trim().to_string())
}

// No unit tests here: the two that previously lived in this module didn't
// exercise production code (one re-implemented `str::trim` inline and
// claimed — incorrectly — to call `read_plain_line`; the other rebuilt the
// `ECHO`/`ECHONL` bit arithmetic inline instead of calling this module).
// The behavior they gestured at is covered for real by:
// - `crates/cli/tests/login_cli.rs`'s 4 integration tests, which spawn the
//   actual built binary and exercise the true non-TTY `read_plain_line`
//   path end-to-end (`--api-key-stdin`, reachability ping, save behavior).
// - A real-PTY manual check (see `UX-18.md`) that types a secret into an
//   actual `openpty()` master/slave pair and asserts it never appears in
//   the captured terminal bytes, then asserts the terminal's echo setting
//   is restored afterward — the one behavior (echo hidden, then restored)
//   that genuinely needs a real TTY and can't be faked with a pipe.
// A safe in-process unit test of the TTY branch isn't feasible: it would
// require repointing fd 0 at a pty for the duration of the test, which is
// process-global state shared with the rest of the (multi-threaded) test
// binary and every other test running concurrently in it.