Compare commits

...

1 Commits

Author SHA1 Message Date
Felipe Coury
364c3fbe21 fix(tui): harden windows vt output 2026-05-20 15:05:04 -03:00
9 changed files with 425 additions and 14 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -3712,6 +3712,7 @@ version = "0.0.0"
dependencies = [
"pretty_assertions",
"tracing",
"windows-sys 0.52.0",
]
[[package]]

View File

@@ -57,7 +57,9 @@ use codex_protocol::protocol::AskForApproval;
use codex_terminal_detection::Multiplexer;
use codex_terminal_detection::TerminalInfo;
use codex_terminal_detection::TerminalName;
use codex_terminal_detection::WindowsStdoutVtState;
use codex_terminal_detection::terminal_info;
use codex_terminal_detection::windows_stdout_vt_state;
use codex_tui::Cli as TuiCli;
use codex_utils_cli::CliConfigOverrides;
use http::HeaderMap;
@@ -1580,6 +1582,7 @@ struct TerminalCheckInputs {
stream_supports_color: bool,
terminal_size: Result<(u16, u16), String>,
tmux_details: Vec<String>,
windows_stdout_vt_state: Option<WindowsStdoutVtState>,
}
impl TerminalCheckInputs {
@@ -1604,6 +1607,7 @@ impl TerminalCheckInputs {
stream_supports_color: supports_color::on(Stream::Stdout).is_some(),
terminal_size,
tmux_details,
windows_stdout_vt_state: cfg!(windows).then(windows_stdout_vt_state),
}
}
@@ -1643,6 +1647,9 @@ fn terminal_check_from_inputs(inputs: TerminalCheckInputs) -> DoctorCheck {
Ok((columns, rows)) => details.push(format!("terminal size: {columns}x{rows}")),
Err(err) => details.push(format!("terminal size: unavailable ({err})")),
}
if let Some(windows_stdout_vt_state) = inputs.windows_stdout_vt_state {
push_windows_stdout_vt_details(&mut details, windows_stdout_vt_state);
}
push_terminal_env_values(&mut details, &inputs, TERMINAL_DIMENSION_ENV_VARS);
details.push(format!("color output: {}", color_output_summary(&inputs)));
push_terminal_env_values(&mut details, &inputs, COLOR_ENV_VARS);
@@ -1711,6 +1718,26 @@ fn terminal_check_from_inputs(inputs: TerminalCheckInputs) -> DoctorCheck {
check
}
fn push_windows_stdout_vt_details(
details: &mut Vec<String>,
windows_stdout_vt_state: WindowsStdoutVtState,
) {
match windows_stdout_vt_state {
WindowsStdoutVtState::Enabled { console_mode } => {
details.push(format!("Windows stdout console mode: {console_mode}"));
details.push("Windows stdout VT processing: enabled".to_string());
}
WindowsStdoutVtState::Disabled { console_mode } => {
details.push(format!("Windows stdout console mode: {console_mode}"));
details.push("Windows stdout VT processing: disabled".to_string());
}
WindowsStdoutVtState::Unavailable => {
details.push("Windows stdout console mode: unavailable".to_string());
details.push("Windows stdout VT processing: unavailable".to_string());
}
}
}
fn terminal_name(info: &TerminalInfo) -> &'static str {
match info.name {
TerminalName::AppleTerminal => "Apple Terminal",
@@ -3724,6 +3751,7 @@ mod tests {
stream_supports_color: true,
terminal_size: Ok((120, 40)),
tmux_details: Vec::new(),
windows_stdout_vt_state: None,
}
}
@@ -3868,6 +3896,63 @@ mod tests {
assert_eq!(check.summary, "terminal metadata was detected");
}
#[test]
fn terminal_check_reports_windows_stdout_vt_processing_when_enabled() {
let mut inputs = terminal_inputs();
inputs.windows_stdout_vt_state = Some(WindowsStdoutVtState::Enabled { console_mode: 7 });
let check = terminal_check_from_inputs(inputs);
assert!(
check
.details
.contains(&"Windows stdout console mode: 7".to_string())
);
assert!(
check
.details
.contains(&"Windows stdout VT processing: enabled".to_string())
);
}
#[test]
fn terminal_check_reports_windows_stdout_vt_processing_when_disabled() {
let mut inputs = terminal_inputs();
inputs.windows_stdout_vt_state = Some(WindowsStdoutVtState::Disabled { console_mode: 3 });
let check = terminal_check_from_inputs(inputs);
assert!(
check
.details
.contains(&"Windows stdout console mode: 3".to_string())
);
assert!(
check
.details
.contains(&"Windows stdout VT processing: disabled".to_string())
);
}
#[test]
fn terminal_check_reports_unavailable_windows_stdout_vt_processing() {
let mut inputs = terminal_inputs();
inputs.windows_stdout_vt_state = Some(WindowsStdoutVtState::Unavailable);
let check = terminal_check_from_inputs(inputs);
assert!(
check
.details
.contains(&"Windows stdout console mode: unavailable".to_string())
);
assert!(
check
.details
.contains(&"Windows stdout VT processing: unavailable".to_string())
);
}
#[test]
fn color_output_summary_reports_disabled_reasons() {
let mut inputs = terminal_inputs();

View File

@@ -15,5 +15,11 @@ workspace = true
[dependencies]
tracing = { workspace = true }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_System_Console",
] }
[dev-dependencies]
pretty_assertions = { workspace = true }

View File

@@ -5,6 +5,128 @@
use std::sync::OnceLock;
/// Visibility into the Windows stdout console VT-processing mode.
///
/// Windows console handles expose a mode bit that controls whether emitted VT
/// sequences are interpreted by the terminal. PTY-style stdout handles do not
/// necessarily support this console-mode inspection; those land in
/// [`WindowsStdoutVtState::Unavailable`] instead of being treated as broken.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WindowsStdoutVtState {
/// Stdout is an inspectable Windows console handle and VT processing is enabled.
Enabled {
/// Console mode bits reported for stdout.
console_mode: u32,
},
/// Stdout is an inspectable Windows console handle and VT processing is disabled.
Disabled {
/// Console mode bits reported for stdout.
console_mode: u32,
},
/// Stdout is not an inspectable Windows console handle.
Unavailable,
}
impl WindowsStdoutVtState {
/// Returns the inspected stdout console mode, if one is visible.
pub fn console_mode(self) -> Option<u32> {
match self {
Self::Enabled { console_mode } | Self::Disabled { console_mode } => Some(console_mode),
Self::Unavailable => None,
}
}
/// Returns whether stdout VT processing is enabled when the mode is visible.
pub fn vt_processing_enabled(self) -> Option<bool> {
match self {
Self::Enabled { .. } => Some(true),
Self::Disabled { .. } => Some(false),
Self::Unavailable => None,
}
}
}
/// Inspects the Windows stdout console VT-processing mode without mutating it.
///
/// On non-Windows targets, and for stdout handles that do not support Windows
/// console-mode inspection, this returns [`WindowsStdoutVtState::Unavailable`].
pub fn windows_stdout_vt_state() -> WindowsStdoutVtState {
#[cfg(windows)]
{
let Some((_handle, console_mode)) = windows_stdout_console_mode() else {
return WindowsStdoutVtState::Unavailable;
};
windows_stdout_vt_state_from_mode(console_mode)
}
#[cfg(not(windows))]
{
WindowsStdoutVtState::Unavailable
}
}
/// Attempts to enable Windows stdout VT processing and returns the verified mode state.
///
/// This preserves the inspection result for PTY-style or otherwise unavailable
/// stdout handles instead of assuming those handles cannot interpret ANSI.
pub fn enable_windows_stdout_vt_processing() -> WindowsStdoutVtState {
#[cfg(windows)]
{
use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_PROCESSING;
use windows_sys::Win32::System::Console::SetConsoleMode;
let Some((handle, console_mode)) = windows_stdout_console_mode() else {
return WindowsStdoutVtState::Unavailable;
};
let state = windows_stdout_vt_state_from_mode(console_mode);
if matches!(state, WindowsStdoutVtState::Disabled { .. }) {
// Safety: `handle` and `console_mode` came from `GetConsoleMode` for stdout.
let _ = unsafe {
SetConsoleMode(handle, console_mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
};
}
windows_stdout_vt_state()
}
#[cfg(not(windows))]
{
WindowsStdoutVtState::Unavailable
}
}
#[cfg(windows)]
fn windows_stdout_vt_state_from_mode(console_mode: u32) -> WindowsStdoutVtState {
use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_PROCESSING;
if console_mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 {
WindowsStdoutVtState::Enabled { console_mode }
} else {
WindowsStdoutVtState::Disabled { console_mode }
}
}
#[cfg(windows)]
fn windows_stdout_console_mode() -> Option<(windows_sys::Win32::Foundation::HANDLE, u32)> {
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::System::Console::GetConsoleMode;
use windows_sys::Win32::System::Console::GetStdHandle;
use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE;
// Safety: requesting the process stdout handle does not transfer ownership.
let handle = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) };
if handle == INVALID_HANDLE_VALUE || handle == 0 {
return None;
}
let mut console_mode = 0;
// Safety: `console_mode` is a valid output pointer for the stdout handle query.
if unsafe { GetConsoleMode(handle, &mut console_mode) } == 0 {
return None;
}
Some((handle, console_mode))
}
/// Structured terminal identification data.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerminalInfo {

View File

@@ -237,6 +237,7 @@ impl ChatWidget {
tracing::debug!(error = %err, "failed to clear terminal title");
}
}
Ok(SetTerminalTitleResult::SkippedUnsupported) => {}
Err(err) => {
tracing::debug!(error = %err, "failed to set terminal title");
}

View File

@@ -160,6 +160,7 @@ mod oss_selection;
mod pager_overlay;
mod permission_compat;
pub(crate) mod public_widgets;
mod raw_ansi;
mod render;
mod resize_reflow_cap;
mod resume_picker;

View File

@@ -0,0 +1,97 @@
//! Gate forced-ANSI TUI output that can leak raw bytes on Windows.
use std::io;
use std::io::stdout;
use codex_terminal_detection::WindowsStdoutVtState;
use crossterm::SynchronizedUpdate;
/// Whether raw ANSI writes should be emitted through the current stdout path.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RawAnsiCapability {
/// Raw ANSI should follow the existing TUI write path.
Available,
/// Stdout is an inspectable Windows console handle whose VT mode is still disabled.
Unavailable,
}
impl RawAnsiCapability {
pub(crate) fn is_available(self) -> bool {
matches!(self, Self::Available)
}
}
/// Enables and verifies Windows stdout VT processing when that mode is inspectable.
///
/// Handles that do not expose Windows console mode inspection preserve the
/// existing PTY behavior and continue to allow raw ANSI output.
pub(crate) fn stdout_capability() -> RawAnsiCapability {
#[cfg(windows)]
{
let state = codex_terminal_detection::enable_windows_stdout_vt_processing();
let capability = capability_for_windows_stdout_vt_state(state);
if !capability.is_available() {
warn_windows_stdout_vt_disabled(state);
}
capability
}
#[cfg(not(windows))]
{
capability_for_windows_stdout_vt_state(WindowsStdoutVtState::Unavailable)
}
}
/// Runs a draw update with synchronized-update wrappers only when raw ANSI is safe.
pub(crate) fn synchronized_update<T>(update: impl FnOnce() -> T) -> io::Result<T> {
match stdout_capability() {
RawAnsiCapability::Available => stdout().sync_update(|_| update()),
RawAnsiCapability::Unavailable => Ok(update()),
}
}
fn capability_for_windows_stdout_vt_state(state: WindowsStdoutVtState) -> RawAnsiCapability {
match state {
WindowsStdoutVtState::Disabled { .. } => RawAnsiCapability::Unavailable,
WindowsStdoutVtState::Enabled { .. } | WindowsStdoutVtState::Unavailable => {
RawAnsiCapability::Available
}
}
}
#[cfg(windows)]
fn warn_windows_stdout_vt_disabled(state: WindowsStdoutVtState) {
use std::sync::Once;
static WARNED: Once = Once::new();
WARNED.call_once(|| {
tracing::warn!(
?state,
"Windows stdout VT processing is disabled; skipping raw ANSI TUI output"
);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unavailable_windows_stdout_state_keeps_raw_ansi_available() {
assert_eq!(
capability_for_windows_stdout_vt_state(WindowsStdoutVtState::Unavailable),
RawAnsiCapability::Available
);
}
#[test]
fn disabled_windows_stdout_state_blocks_raw_ansi() {
assert_eq!(
capability_for_windows_stdout_vt_state(WindowsStdoutVtState::Disabled {
console_mode: 3,
}),
RawAnsiCapability::Unavailable
);
}
}

View File

@@ -23,6 +23,8 @@ use std::io::stdout;
use crossterm::Command;
use ratatui::crossterm::execute;
use crate::raw_ansi;
/// Practical upper bound on title length, measured in Rust `char`s.
///
/// Most terminals silently truncate titles beyond a few hundred characters.
@@ -41,6 +43,8 @@ pub(crate) enum SetTerminalTitleResult {
/// empty post-sanitization value should result in no-op behavior, clearing
/// the title Codex manages, or some other fallback.
NoVisibleContent,
/// The title would require a raw ANSI write while stdout cannot safely emit one.
SkippedUnsupported,
}
/// Writes a sanitized OSC window-title sequence to stdout.
@@ -57,6 +61,9 @@ pub(crate) fn set_terminal_title(title: &str) -> io::Result<SetTerminalTitleResu
if !stdout().is_terminal() {
return Ok(SetTerminalTitleResult::Applied);
}
if !title_output_supported(raw_ansi::stdout_capability()) {
return Ok(SetTerminalTitleResult::SkippedUnsupported);
}
let title = sanitize_terminal_title(title);
if title.is_empty() {
@@ -75,10 +82,17 @@ pub(crate) fn clear_terminal_title() -> io::Result<()> {
if !stdout().is_terminal() {
return Ok(());
}
if !title_output_supported(raw_ansi::stdout_capability()) {
return Ok(());
}
execute!(stdout(), SetWindowTitle(String::new()))
}
fn title_output_supported(raw_ansi_capability: raw_ansi::RawAnsiCapability) -> bool {
raw_ansi_capability.is_available()
}
#[derive(Debug, Clone)]
struct SetWindowTitle(String);
@@ -181,6 +195,8 @@ mod tests {
use super::MAX_TERMINAL_TITLE_CHARS;
use super::SetWindowTitle;
use super::sanitize_terminal_title;
use super::title_output_supported;
use crate::raw_ansi::RawAnsiCapability;
use crossterm::Command;
use pretty_assertions::assert_eq;
@@ -222,4 +238,9 @@ mod tests {
.expect("encode terminal title");
assert_eq!(out, "\x1b]0;hello\x07");
}
#[test]
fn raw_ansi_disabled_skips_terminal_title_output() {
assert!(!title_output_supported(RawAnsiCapability::Unavailable));
}
}

View File

@@ -14,7 +14,6 @@ use std::sync::atomic::Ordering;
use std::time::Duration;
use crossterm::Command;
use crossterm::SynchronizedUpdate;
use crossterm::cursor::SetCursorStyle;
use crossterm::event::DisableBracketedPaste;
use crossterm::event::DisableFocusChange;
@@ -43,6 +42,7 @@ use crate::custom_terminal::Terminal as CustomTerminal;
use crate::insert_history::HistoryLineWrapPolicy;
use crate::notifications::DesktopNotificationBackend;
use crate::notifications::detect_backend;
use crate::raw_ansi;
use crate::tui::event_stream::EventBroker;
use crate::tui::event_stream::TuiEventStream;
#[cfg(unix)]
@@ -91,9 +91,13 @@ impl Drop for Tui {
mod tests {
use std::io::Write as _;
use super::PendingHistoryLines;
use super::Tui;
use super::clear_for_viewport_change;
use super::should_emit_notification;
use crate::custom_terminal::Terminal as CustomTerminal;
use crate::insert_history::HistoryLineWrapPolicy;
use crate::raw_ansi::RawAnsiCapability;
use crate::test_backend::VT100Backend;
use codex_config::types::NotificationCondition;
use ratatui::layout::Position;
@@ -163,10 +167,45 @@ mod tests {
"expected stale cells inside the new viewport to be cleared, rows: {rows:?}"
);
}
#[test]
fn raw_ansi_disabled_skips_pending_history_writes() {
let width = 12;
let backend = VT100Backend::new(width, /*height*/ 4);
let mut terminal =
CustomTerminal::with_options_and_cursor_position(backend, Position { x: 0, y: 1 })
.expect("terminal");
let mut pending_history_lines = vec![PendingHistoryLines {
lines: vec![ratatui::text::Line::from("history")],
wrap_policy: HistoryLineWrapPolicy::PreWrap,
}];
Tui::flush_pending_history_lines_with_raw_ansi_capability(
&mut terminal,
&mut pending_history_lines,
RawAnsiCapability::Unavailable,
)
.expect("skip history writes");
assert!(pending_history_lines.is_empty());
let rows: Vec<String> = terminal
.backend()
.vt100()
.screen()
.rows(/*start*/ 0, width)
.collect();
assert!(
!rows.iter().any(|row| row.contains("history")),
"expected no history writes while raw ANSI is unavailable, rows: {rows:?}"
);
}
}
pub fn set_modes() -> Result<()> {
execute!(stdout(), EnableBracketedPaste)?;
let raw_ansi_capability = raw_ansi::stdout_capability();
if raw_ansi_capability.is_available() {
execute!(stdout(), EnableBracketedPaste)?;
}
enable_raw_mode()?;
// Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated.
@@ -177,7 +216,9 @@ pub fn set_modes() -> Result<()> {
// gracefully if unsupported.
keyboard_modes::enable_keyboard_enhancement();
let _ = execute!(stdout(), EnableFocusChange);
if raw_ansi_capability.is_available() {
let _ = execute!(stdout(), EnableFocusChange);
}
Ok(())
}
@@ -244,8 +285,15 @@ fn restore_common(
KeyboardRestore::ResetAfterExit => keyboard_modes::reset_keyboard_reporting_after_exit(),
}
let mut first_error = execute!(stdout(), DisableBracketedPaste).err();
let _ = execute!(stdout(), DisableFocusChange);
let raw_ansi_capability = raw_ansi::stdout_capability();
let mut first_error = if raw_ansi_capability.is_available() {
execute!(stdout(), DisableBracketedPaste).err()
} else {
None
};
if raw_ansi_capability.is_available() {
let _ = execute!(stdout(), DisableFocusChange);
}
if matches!(raw_mode_restore, RawModeRestore::Disable)
&& let Err(err) = disable_raw_mode()
{
@@ -659,6 +707,10 @@ impl Tui {
if !self.alt_screen_enabled {
return Ok(());
}
let raw_ansi_capability = raw_ansi::stdout_capability();
if !raw_ansi_capability.is_available() {
return Ok(());
}
let _ = execute!(self.terminal.backend_mut(), EnterAlternateScreen);
// Enable "alternate scroll" so terminals may translate wheel to arrows
let _ = execute!(self.terminal.backend_mut(), EnableAlternateScroll);
@@ -681,9 +733,12 @@ impl Tui {
if !self.alt_screen_enabled {
return Ok(());
}
let raw_ansi_capability = raw_ansi::stdout_capability();
// Disable alternate scroll when leaving alt-screen
let _ = execute!(self.terminal.backend_mut(), DisableAlternateScroll);
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
if raw_ansi_capability.is_available() {
let _ = execute!(self.terminal.backend_mut(), DisableAlternateScroll);
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
}
if let Some(saved) = self.alt_saved_viewport.take() {
self.terminal.set_viewport_area(saved);
}
@@ -762,13 +817,35 @@ impl Tui {
}
/// Write any buffered history lines above the viewport and clear the buffer.
fn flush_pending_history_lines(
terminal: &mut Terminal,
fn flush_pending_history_lines<B>(
terminal: &mut CustomTerminal<B>,
pending_history_lines: &mut Vec<PendingHistoryLines>,
) -> Result<()> {
) -> Result<()>
where
B: Backend + Write,
{
Self::flush_pending_history_lines_with_raw_ansi_capability(
terminal,
pending_history_lines,
raw_ansi::stdout_capability(),
)
}
fn flush_pending_history_lines_with_raw_ansi_capability<B>(
terminal: &mut CustomTerminal<B>,
pending_history_lines: &mut Vec<PendingHistoryLines>,
raw_ansi_capability: raw_ansi::RawAnsiCapability,
) -> Result<()>
where
B: Backend + Write,
{
if pending_history_lines.is_empty() {
return Ok(());
}
if !raw_ansi_capability.is_available() {
pending_history_lines.clear();
return Ok(());
}
for batch in pending_history_lines.iter() {
crate::insert_history::insert_history_lines_with_wrap_policy(
@@ -797,7 +874,7 @@ impl Tui {
// the synchronized update, to avoid racing with the event reader.
let mut pending_viewport_area = self.pending_viewport_area()?;
stdout().sync_update(|_| {
raw_ansi::synchronized_update(|| {
#[cfg(unix)]
if let Some(prepared) = prepared_resume.take() {
prepared.apply(&mut self.terminal)?;
@@ -856,7 +933,7 @@ impl Tui {
) -> std::result::Result<(), crate::pets::PetImageRenderError> {
let terminal = &mut self.terminal;
let state = &mut self.ambient_pet_image_state;
stdout().sync_update(|_| {
raw_ansi::synchronized_update(|| {
match crate::pets::render_ambient_pet_image(terminal.backend_mut(), state, request) {
Ok(()) => Ok(Ok(())),
Err(crate::pets::PetImageRenderError::Terminal(err)) => Err(err),
@@ -871,7 +948,7 @@ impl Tui {
) -> std::result::Result<(), crate::pets::PetImageRenderError> {
let terminal = &mut self.terminal;
let state = &mut self.pet_picker_preview_image_state;
stdout().sync_update(|_| {
raw_ansi::synchronized_update(|| {
match crate::pets::render_pet_picker_preview_image(
terminal.backend_mut(),
state,
@@ -911,7 +988,7 @@ impl Tui {
.suspend_context
.prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport);
stdout().sync_update(|_| {
raw_ansi::synchronized_update(|| {
#[cfg(unix)]
if let Some(prepared) = prepared_resume.take() {
prepared.apply(&mut self.terminal)?;