Skip to content

Commit 03003cd

Browse files
mrunalpelezar
andauthored
fix(cli): require ANSI-capable terminal before colorizing (#3121)
* fix(cli): require ANSI-capable terminal before colorizing Follow-up to #3026, raised in review. `auto` treated any terminal as styleable, so `TERM=dumb openshell ...` still emitted escapes into a terminal that renders them literally. An unset TERM had the same problem. This is partly a regression that #3026 introduced. `console`, which drives indicatif and dialoguer, already refused to colorize when TERM is `dumb` or unset, and miette applies the same check through supports-color. #3026 overrides both with its own switch, so it replaced two working checks rather than only failing to add one. tracing and the owo-colors wrapper never had detection, so those two are a gap rather than a regression. Add the capability check to the `auto` branch only, matching console's unix rule: `dumb` is not capable, and an unset TERM is not capable because nothing identifies a capable terminal. Empty is treated as unset, which diverges from console — it reads `TERM=""` as capable since the value is not `dumb` — because an empty value names no terminal type and every other variable here already treats empty as unset. Because the check sits after the explicit branches, `--color always` and FORCE_COLOR still force styling on a dumb terminal, and `--color never` and NO_COLOR still suppress it on a capable one. TERM is a unix signal; Windows consoles enable virtual terminal processing and do not set it, so the check does not apply there. The existing pty test now pins TERM. It previously inherited the ambient value, which would make its outcome depend on the environment now that capability is consulted — CI runners frequently leave TERM unset. Signed-off-by: Mrunal Patel <mrunalp@gmail.com> * refactor(cli): combine stream and terminal capability checks Signed-off-by: Evan Lezar <elezar@nvidia.com> * docs(cli): clarify table color behavior Signed-off-by: Evan Lezar <elezar@nvidia.com> * test(cli): cover redirected status table colors Signed-off-by: Evan Lezar <elezar@nvidia.com> --------- Signed-off-by: Mrunal Patel <mrunalp@gmail.com> Signed-off-by: Evan Lezar <elezar@nvidia.com> Co-authored-by: Evan Lezar <elezar@nvidia.com>
1 parent 857af42 commit 03003cd

4 files changed

Lines changed: 239 additions & 31 deletions

File tree

.agents/skills/openshell-cli/cli-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Quick-reference for the `openshell` command-line interface. For workflow guidanc
1212
| `-g`, `--gateway <NAME>` | Gateway to operate on. Also settable via `OPENSHELL_GATEWAY` env var. Falls back to active gateway in `~/.config/openshell/active_gateway`. |
1313
| `--gateway-endpoint <URL>` | Connect directly to a gateway endpoint without looking up stored metadata. Also settable via `OPENSHELL_GATEWAY_ENDPOINT`. |
1414
| `--gateway-insecure` | Skip TLS certificate verification. Also settable via `OPENSHELL_GATEWAY_INSECURE`; use only for trusted development endpoints. |
15-
| `--color <WHEN>` | `auto` (default), `always`, or `never`. `auto` decides per stream, so a redirected stream is plain text while a stream still on the terminal stays styled. Covers tables, `-v` log lines, progress spinners, prompts, and error messages. Also settable via `OPENSHELL_COLOR`. |
15+
| `--color <WHEN>` | `auto` (default), `always`, or `never`. `auto` decides per stream, so a redirected stream is plain text while a stream still on the terminal stays styled, and it skips terminals that do not render ANSI (`TERM=dumb` or unset). Covers tables, `-v` log lines, progress spinners, prompts, and error messages. Also settable via `OPENSHELL_COLOR`. |
1616

1717
## Environment Variables
1818

crates/openshell-cli/src/color.rs

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,15 @@
3434
//! 1. `--color always|never` on the command line.
3535
//! 2. `NO_COLOR`, set and non-empty, disables color (<https://no-color.org>).
3636
//! 3. `FORCE_COLOR`, set and non-empty, forces color on (<https://force-color.org>).
37-
//! 4. Otherwise the stream is styled only when that stream is a terminal.
37+
//! 4. Otherwise the stream is styled only when that stream is a terminal *and*
38+
//! that terminal renders ANSI.
39+
//!
40+
//! Attachment and capability are separate questions. `TERM=dumb` is a terminal
41+
//! that does not interpret escapes, so `auto` must not style it — and neither
42+
//! `console` nor `miette` can apply their own `TERM` checks any more, because
43+
//! [`init`] overrides both. Capability is consulted only under `auto`, so
44+
//! `--color always` and `FORCE_COLOR` still force styling on a `dumb` terminal
45+
//! for anyone who wants it.
3846
//!
3947
//! Step 4 is resolved per stream. Redirecting one must not decide for the other:
4048
//! `openshell ... 2> build.log` from a terminal should keep a styled stdout and
@@ -87,6 +95,7 @@ pub enum ColorChoice {
8795
pub fn init(choice: ColorChoice) {
8896
let no_color = std::env::var_os("NO_COLOR");
8997
let force_color = std::env::var_os("FORCE_COLOR");
98+
let term = std::env::var_os("TERM");
9099

91100
// Under `auto` each stream answers for itself. Redirecting one must not
92101
// decide for the other: `openshell ... 2> build.log` from a terminal has a
@@ -95,13 +104,13 @@ pub fn init(choice: ColorChoice) {
95104
choice,
96105
no_color.as_deref(),
97106
force_color.as_deref(),
98-
std::io::stdout().is_terminal(),
107+
terminal_supports_ansi(std::io::stdout().is_terminal(), term.as_deref()),
99108
);
100109
let stderr_enabled = resolve(
101110
choice,
102111
no_color.as_deref(),
103112
force_color.as_deref(),
104-
std::io::stderr().is_terminal(),
113+
terminal_supports_ansi(std::io::stderr().is_terminal(), term.as_deref()),
105114
);
106115
STDOUT_ENABLED.store(stdout_enabled, Ordering::Relaxed);
107116
STDERR_ENABLED.store(stderr_enabled, Ordering::Relaxed);
@@ -159,7 +168,7 @@ fn resolve(
159168
choice: ColorChoice,
160169
no_color: Option<&OsStr>,
161170
force_color: Option<&OsStr>,
162-
stream_is_terminal: bool,
171+
stream_supports_ansi: bool,
163172
) -> bool {
164173
match choice {
165174
ColorChoice::Always => return true,
@@ -177,7 +186,39 @@ fn resolve(
177186
return true;
178187
}
179188

189+
// Only `auto` consults the terminal. An explicit request above has already
190+
// returned, so `--color always` and `FORCE_COLOR` still win on a terminal
191+
// that reports no ANSI support.
192+
stream_supports_ansi
193+
}
194+
195+
/// Whether this output stream's terminal renders ANSI escapes.
196+
///
197+
/// Being attached to a terminal is not the same as that terminal rendering
198+
/// ANSI. `TERM` is the unix signal for it; Windows consoles enable virtual
199+
/// terminal processing instead and do not set `TERM`, so the check does not
200+
/// apply there.
201+
fn terminal_supports_ansi(stream_is_terminal: bool, term: Option<&OsStr>) -> bool {
180202
stream_is_terminal
203+
&& if cfg!(unix) {
204+
term_supports_ansi(term)
205+
} else {
206+
true
207+
}
208+
}
209+
210+
/// Whether the terminal named by `TERM` renders ANSI escapes on Unix.
211+
///
212+
/// Follows the rule `console` applies on unix, which this module overrides:
213+
/// `dumb` means no, and an unset `TERM` means no because nothing identifies a
214+
/// capable terminal.
215+
///
216+
/// Empty is treated as unset, which is a deliberate divergence: `console` reads
217+
/// `TERM=""` as `Ok("")`, and since that is not `"dumb"` it counts as capable.
218+
/// An empty value names no terminal type, and every other variable here already
219+
/// treats empty as unset, so it is handled the same way.
220+
fn term_supports_ansi(term: Option<&OsStr>) -> bool {
221+
is_set(term) && term != Some(OsStr::new("dumb"))
181222
}
182223

183224
/// Whether an environment variable counts as set: present and not empty.
@@ -329,7 +370,7 @@ mod tests {
329370
// and make every `.green()` below ambiguous.
330371
use super::{
331372
ColorChoice, Colorize, Ordering, STDERR_ENABLED, STDOUT_ENABLED, Style, painted_enabled,
332-
resolve,
373+
resolve, term_supports_ansi,
333374
};
334375
use std::ffi::OsStr;
335376

@@ -500,6 +541,44 @@ mod tests {
500541
assert!(!resolve(ColorChoice::Auto, None, Some(env("")), false));
501542
}
502543

544+
#[test]
545+
fn term_capability_follows_the_console_rule() {
546+
assert!(term_supports_ansi(Some(OsStr::new("xterm-256color"))));
547+
assert!(term_supports_ansi(Some(OsStr::new("screen"))));
548+
assert!(!term_supports_ansi(Some(OsStr::new("dumb"))));
549+
// Nothing to suggest a capable terminal, so assume none.
550+
assert!(!term_supports_ansi(None));
551+
// Empty names no terminal type; treated as unset, unlike `console`.
552+
assert!(!term_supports_ansi(Some(OsStr::new(""))));
553+
// Only an exact match counts; `dumb-something` is a different terminal.
554+
assert!(term_supports_ansi(Some(OsStr::new("dumb-but-color"))));
555+
}
556+
557+
#[test]
558+
fn auto_does_not_style_an_incapable_terminal() {
559+
// A `dumb` terminal is still a terminal, so `is_terminal()` alone would
560+
// wrongly enable color.
561+
assert!(!resolve(ColorChoice::Auto, None, None, false));
562+
assert!(resolve(ColorChoice::Auto, None, None, true));
563+
}
564+
565+
#[test]
566+
fn explicit_requests_outrank_terminal_capability() {
567+
// `--color always` and FORCE_COLOR are for callers who know better than
568+
// the detection, so an incapable terminal must not veto them.
569+
assert!(resolve(ColorChoice::Always, None, None, false));
570+
assert!(resolve(ColorChoice::Auto, None, Some(env("1")), false));
571+
// The negative direction still wins over capability too.
572+
assert!(!resolve(ColorChoice::Never, None, None, true));
573+
assert!(!resolve(ColorChoice::Auto, Some(env("1")), None, true));
574+
}
575+
576+
#[test]
577+
fn capability_does_not_rescue_a_redirected_stream() {
578+
// Capability is an additional requirement, not an alternative one.
579+
assert!(!resolve(ColorChoice::Auto, None, None, false));
580+
}
581+
503582
#[test]
504583
fn auto_resolves_each_stream_independently() {
505584
// `openshell ... 2> build.log` from a terminal: stdout is styled, the

crates/openshell-cli/tests/cli_color_integration.rs

Lines changed: 153 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -176,42 +176,25 @@ fn tracing_output_is_free_of_escape_sequences_when_piped() {
176176
);
177177
}
178178

179-
/// Run a failing command with stdout attached to a pseudo-terminal and stderr
180-
/// on a pipe, returning what each stream received.
181-
///
182-
/// `Command::output` gives both streams pipes, so it cannot distinguish a
183-
/// per-stream decision from a single one resolved off stdout. This asymmetric
184-
/// setup is the only way to catch a stream being handed the other stream's
185-
/// answer.
179+
/// Run a command with stdout attached to a pseudo-terminal and stderr on a
180+
/// pipe, returning what each stream received.
186181
#[cfg(target_os = "linux")]
187-
fn split_streams_stdout_tty(args: &[&str]) -> (String, String) {
182+
fn run_with_stdout_tty(mut command: Command) -> (String, String) {
188183
use std::io::Read;
189184
use std::os::fd::{AsRawFd, OwnedFd};
190185

191186
let pty = nix::pty::openpty(None, None).expect("openpty");
192187
let controller: OwnedFd = pty.master;
193188
let follower: OwnedFd = pty.slave;
194189

195-
let tmpdir = tempfile::tempdir().expect("create tmpdir");
196-
let mut child = Command::new(env!("CARGO_BIN_EXE_openshell"))
197-
.args([
198-
"sandbox",
199-
"list",
200-
"--gateway",
201-
"test-gateway",
202-
"--gateway-endpoint",
203-
"http://127.0.0.1:1",
204-
])
205-
.args(args)
206-
.env("XDG_CONFIG_HOME", tmpdir.path())
207-
.env("RUST_LOG", "debug")
208-
.env_remove("NO_COLOR")
209-
.env_remove("FORCE_COLOR")
210-
.env_remove("OPENSHELL_COLOR")
190+
let mut child = command
211191
.stdout(follower.try_clone().expect("dup pty follower"))
212192
.stderr(std::process::Stdio::piped())
213193
.spawn()
214194
.expect("spawn openshell");
195+
// `Command` retains its configured stdio handles after spawning. Drop it so
196+
// the controller sees EIO once the child exits.
197+
drop(command);
215198

216199
// Drop every follower handle in this process, or reading the controller
217200
// blocks forever instead of returning EIO once the child exits.
@@ -242,6 +225,135 @@ fn split_streams_stdout_tty(args: &[&str]) -> (String, String) {
242225
)
243226
}
244227

228+
/// Run a failing command with stdout attached to a pseudo-terminal and stderr
229+
/// on a pipe, returning what each stream received.
230+
///
231+
/// `Command::output` gives both streams pipes, so it cannot distinguish a
232+
/// per-stream decision from a single one resolved off stdout. This asymmetric
233+
/// setup is the only way to catch a stream being handed the other stream's
234+
/// answer.
235+
#[cfg(target_os = "linux")]
236+
fn split_streams_stdout_tty(args: &[&str]) -> (String, String) {
237+
let tmpdir = tempfile::tempdir().expect("create tmpdir");
238+
let mut command = Command::new(env!("CARGO_BIN_EXE_openshell"));
239+
command
240+
.args([
241+
"sandbox",
242+
"list",
243+
"--gateway",
244+
"test-gateway",
245+
"--gateway-endpoint",
246+
"http://127.0.0.1:1",
247+
])
248+
.args(args)
249+
.env("XDG_CONFIG_HOME", tmpdir.path())
250+
.env("RUST_LOG", "debug")
251+
// Pin TERM: `auto` now requires a capable terminal, and CI runners
252+
// often leave TERM unset, which would make this test's outcome depend
253+
// on the ambient environment.
254+
.env("TERM", "xterm-256color")
255+
.env_remove("NO_COLOR")
256+
.env_remove("FORCE_COLOR")
257+
.env_remove("OPENSHELL_COLOR");
258+
259+
run_with_stdout_tty(command)
260+
}
261+
262+
/// Run `forward list` with stdout on a pseudo-terminal, under the given `TERM`,
263+
/// and return everything stdout received.
264+
///
265+
/// When `stderr_on_tty` is true, both streams share the terminal so the
266+
/// `owo-colors` table is styled too. Otherwise, stderr is redirected to
267+
/// `/dev/null`, which verifies the conservative table behavior.
268+
#[cfg(target_os = "linux")]
269+
fn forward_list_on_pty(term: &str, args: &[&str], stderr_on_tty: bool) -> String {
270+
use std::os::fd::{AsRawFd, OwnedFd};
271+
272+
let pty = nix::pty::openpty(None, None).expect("openpty");
273+
let controller: OwnedFd = pty.master;
274+
let follower: OwnedFd = pty.slave;
275+
276+
let tmpdir = tempfile::tempdir().expect("create tmpdir");
277+
config_dir_with_forward(tmpdir.path());
278+
279+
let mut command = Command::new(env!("CARGO_BIN_EXE_openshell"));
280+
command
281+
.args(["forward", "list"])
282+
.args(args)
283+
.env("XDG_CONFIG_HOME", tmpdir.path())
284+
.env("TERM", term)
285+
.env_remove("NO_COLOR")
286+
.env_remove("FORCE_COLOR")
287+
.env_remove("OPENSHELL_COLOR")
288+
.stdout(follower.try_clone().expect("dup pty follower"));
289+
if stderr_on_tty {
290+
command.stderr(follower.try_clone().expect("dup pty follower"));
291+
} else {
292+
command.stderr(std::process::Stdio::null());
293+
}
294+
let mut child = command.spawn().expect("spawn openshell");
295+
// `Command` retains its configured stdio handles after spawning. Drop it so
296+
// the controller sees EIO once the child exits.
297+
drop(command);
298+
299+
// Drop every follower handle here, or the controller read never sees EIO.
300+
drop(follower);
301+
302+
let mut buf = Vec::new();
303+
let mut chunk = [0u8; 4096];
304+
loop {
305+
match nix::unistd::read(controller.as_raw_fd(), &mut chunk) {
306+
Ok(0) | Err(_) => break,
307+
Ok(n) => buf.extend_from_slice(&chunk[..n]),
308+
}
309+
}
310+
child.wait().expect("wait for openshell");
311+
312+
let out = String::from_utf8_lossy(&buf).into_owned();
313+
assert!(
314+
out.contains(SANDBOX),
315+
"expected the seeded forward in the table, got: {out:?}"
316+
);
317+
out
318+
}
319+
320+
/// A terminal that does not render ANSI must not be styled under `auto`.
321+
///
322+
/// `TERM=dumb` is still a terminal, so an `is_terminal()` check alone reports it
323+
/// as styleable. `console` and `miette` apply their own `TERM` checks, but the
324+
/// color switch overrides both, so the check has to live here.
325+
#[cfg(target_os = "linux")]
326+
#[test]
327+
fn dumb_terminal_is_not_styled_under_auto() {
328+
let dumb = forward_list_on_pty("dumb", &[], true);
329+
// Positive control: the same session on a capable terminal is styled, so a
330+
// plain result below means capability was consulted, not that the pty setup
331+
// silently produced nothing.
332+
let capable = forward_list_on_pty("xterm-256color", &[], true);
333+
334+
assert!(
335+
capable.contains(ESC),
336+
"expected styling on a capable terminal; got: {capable:?}"
337+
);
338+
assert!(
339+
!dumb.contains(ESC),
340+
"TERM=dumb must not be styled, got: {dumb:?}"
341+
);
342+
}
343+
344+
/// An explicit request outranks the capability check, for callers who know
345+
/// their terminal better than `TERM` does.
346+
#[cfg(target_os = "linux")]
347+
#[test]
348+
fn color_always_overrides_a_dumb_terminal() {
349+
let forced = forward_list_on_pty("dumb", &["--color", "always"], true);
350+
351+
assert!(
352+
forced.contains(ESC),
353+
"--color always must style even a dumb terminal, got: {forced:?}"
354+
);
355+
}
356+
245357
/// Regression test for a redirected stream inheriting the other stream's
246358
/// terminal check.
247359
///
@@ -264,6 +376,23 @@ fn redirected_stderr_stays_plain_while_stdout_is_a_terminal() {
264376
);
265377
}
266378

379+
/// `Painted` cannot identify its destination stream, so table styling is
380+
/// deliberately disabled when either stream is redirected.
381+
#[cfg(target_os = "linux")]
382+
#[test]
383+
fn status_table_is_plain_when_stderr_is_redirected() {
384+
let stdout = forward_list_on_pty("xterm-256color", &[], false);
385+
386+
assert!(
387+
stdout.contains(SANDBOX),
388+
"expected the seeded forward in the table, got: {stdout:?}"
389+
);
390+
assert!(
391+
!stdout.contains(ESC),
392+
"STATUS table must stay plain when stderr is redirected, got: {stdout:?}"
393+
);
394+
}
395+
267396
#[test]
268397
fn error_output_follows_the_color_setting() {
269398
// miette renders errors to stderr through its own handler. It already

docs/sandboxes/manage-sandboxes.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ Structured output includes `sandbox`, `bind_address`, `port`, `pid`, and
527527
expected OpenShell SSH forward; it does not probe the forwarded socket. When no
528528
forwards are tracked, structured output returns an empty collection.
529529
530-
The default table colorizes the `STATUS` column, but only when the stream it is written to is a terminal, so piping or redirecting gives plain text. Each stream is decided on its own, so redirecting one leaves the other styled. Prefer `--output json` for automation rather than matching on the table. Set `NO_COLOR` to any non-empty value or pass `--color never` to suppress color, and `--color always` to keep it when piping into a pager. `--color` applies to every `openshell` command and covers all styled output: tables, log lines from `-v`, progress spinners, prompts, and error messages.
530+
The default table colorizes the `STATUS` column only when both standard output and standard error are capable ANSI terminals; piping or redirecting either stream, or running under `TERM=dumb`, gives a plain-text table. Other styled output—including `-v` log lines, progress spinners, prompts, and error messages—is decided per stream, so redirecting one stream leaves the other styled. Prefer `--output json` for automation rather than matching on the table. Set `NO_COLOR` to any non-empty value or pass `--color never` to suppress ANSI formatting, and `--color always` to force it when piping into a pager. `--color` applies to every `openshell` command.
531531
532532
<Tip>
533533
You can also forward a port at creation time with `--forward`:

0 commit comments

Comments
 (0)