Skip to main content

max / alloy

Report instead of panicking when the console has no terminal The installer's sshd drop-in runs `alloy install` as a ForceCommand, so `ssh installer@host` without -t reaches the shell's terminal setup with no pty at all. `ratatui::init` panics there, and the first thing a headless machine said to whoever connected to it was a Rust backtrace naming a crate version and a line number in a dependency. An error rather than a fallback: every verb here is a screen, there is no non-interactive mode to degrade to, and the remedy is one flag the message now names. The test spawns the binary, because having no terminal is a property of the process rather than of an argument.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 22:14 UTC
Signed with PGP, not checked
Commit: 0b92156b85617bcd963a7d700ae0321f5eafb562
Parent: 56fa627
2 files changed, +61 insertions, -0 deletions
@@ -228,6 +228,22 @@
228 228 /// The terminal is restored even when the loop fails, so a backend error does
229 229 /// not strand the user in raw mode with no echo.
230 230 pub(crate) fn run(theme: &Theme, view: &mut dyn View, log: &mut CommandLog) -> Result<()> {
231 + // Checked rather than left to `ratatui::init`, which panics. The panic is
232 + // reachable by anyone, not just by someone doing something odd: the
233 + // installer's sshd drop-in runs `alloy install` as a ForceCommand, so
234 + // `ssh installer@host` without `-t` reaches this line with no terminal at
235 + // all and answers a first contact with a Rust backtrace naming a crate
236 + // version and a line number in ratatui. Observed 2026-08-09 against a
237 + // booted ISO.
238 + //
239 + // An error and not a fallback: there is no non-interactive mode to fall
240 + // back to, and every verb here is a screen. Saying so is the whole fix.
241 + if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
242 + anyhow::bail!(
243 + "the Alloy console needs a terminal, and this session has none.\n\
244 + Over ssh, ask for one: `ssh -t <host>`."
245 + );
246 + }
231 247 let mut terminal = ratatui::init();
232 248 let result = event_loop(&mut terminal, theme, view, log);
233 249 ratatui::restore();
@@ -1,0 +1,45 @@
1 + //! A console verb run without a terminal says so instead of panicking.
2 + //!
3 + //! This is not a hypothetical. The installer's sshd drop-in
4 + //! (`etc/ssh/sshd_config.d/20-alloy-installer.conf`) runs `alloy install` as a
5 + //! `ForceCommand`, so the first thing a stranger's ssh client does to a headless
6 + //! machine reaches the shell's terminal setup. Without `-t` there is no pty, and
7 + //! `ratatui::init` panics: the session got a Rust backtrace naming a crate
8 + //! version and a line number in a dependency. Observed 2026-08-09 against a
9 + //! booted ISO, which is also the only way it could have been observed — every
10 + //! interactive use has a terminal by definition.
11 + //!
12 + //! Spawned rather than called, because having no terminal is a property of the
13 + //! process and not of an argument. `Command::output` pipes stdout, so the child
14 + //! is in exactly the state the ssh session was.
15 +
16 + use std::process::Command;
17 +
18 + #[test]
19 + fn a_verb_without_a_terminal_explains_itself_and_fails() {
20 + let output = Command::new(env!("CARGO_BIN_EXE_alloy"))
21 + .arg("net")
22 + .output()
23 + .expect("running the console");
24 +
25 + assert!(
26 + !output.status.success(),
27 + "a console with nowhere to draw exited 0",
28 + );
29 +
30 + let stderr = String::from_utf8_lossy(&output.stderr);
31 + assert!(
32 + stderr.contains("needs a terminal"),
33 + "the message does not say what is wrong:\n{stderr}",
34 + );
35 + // The remedy, not just the diagnosis: whoever hits this is at an ssh
36 + // prompt and the fix is one flag.
37 + assert!(
38 + stderr.contains("ssh -t"),
39 + "the message does not say what to do about it:\n{stderr}",
40 + );
41 + assert!(
42 + !stderr.contains("panicked"),
43 + "still panicking rather than reporting:\n{stderr}",
44 + );
45 + }