Files
codex/prs/bolinfest/PR-2437.md
2025-09-02 15:17:45 -07:00

9.7 KiB

PR #2437: detect terminal and include in request headers

Description

This adds the terminal version to the UA header.

Full Diff

diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
index 28d35f5376..ad3947aa94 100644
--- a/codex-rs/core/src/lib.rs
+++ b/codex-rs/core/src/lib.rs
@@ -49,6 +49,7 @@ pub(crate) mod safety;
 pub mod seatbelt;
 pub mod shell;
 pub mod spawn;
+pub mod terminal;
 pub mod turn_diff_tracker;
 pub mod user_agent;
 mod user_notification;
diff --git a/codex-rs/core/src/terminal.rs b/codex-rs/core/src/terminal.rs
new file mode 100644
index 0000000000..02104f8be5
--- /dev/null
+++ b/codex-rs/core/src/terminal.rs
@@ -0,0 +1,72 @@
+use std::sync::OnceLock;
+
+static TERMINAL: OnceLock<String> = OnceLock::new();
+
+pub fn user_agent() -> String {
+    TERMINAL.get_or_init(detect_terminal).to_string()
+}
+
+/// Sanitize a header value to be used in a User-Agent string.
+///
+/// This function replaces any characters that are not allowed in a User-Agent string with an underscore.
+///
+/// # Arguments
+///
+/// * `value` - The value to sanitize.
+fn is_valid_header_value_char(c: char) -> bool {
+    c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/'
+}
+
+fn sanitize_header_value(value: String) -> String {
+    value.replace(|c| !is_valid_header_value_char(c), "_")
+}
+
+fn detect_terminal() -> String {
+    sanitize_header_value(
+        if let Ok(tp) = std::env::var("TERM_PROGRAM")
+            && !tp.trim().is_empty()
+        {
+            let ver = std::env::var("TERM_PROGRAM_VERSION").ok();
+            match ver {
+                Some(v) if !v.trim().is_empty() => format!("{tp}/{v}"),
+                _ => tp,
+            }
+        } else if let Ok(v) = std::env::var("WEZTERM_VERSION") {
+            if !v.trim().is_empty() {
+                format!("WezTerm/{v}")
+            } else {
+                "WezTerm".to_string()
+            }
+        } else if std::env::var("KITTY_WINDOW_ID").is_ok()
+            || std::env::var("TERM")
+                .map(|t| t.contains("kitty"))
+                .unwrap_or(false)
+        {
+            "kitty".to_string()
+        } else if std::env::var("ALACRITTY_SOCKET").is_ok()
+            || std::env::var("TERM")
+                .map(|t| t == "alacritty")
+                .unwrap_or(false)
+        {
+            "Alacritty".to_string()
+        } else if let Ok(v) = std::env::var("KONSOLE_VERSION") {
+            if !v.trim().is_empty() {
+                format!("Konsole/{v}")
+            } else {
+                "Konsole".to_string()
+            }
+        } else if std::env::var("GNOME_TERMINAL_SCREEN").is_ok() {
+            return "gnome-terminal".to_string();
+        } else if let Ok(v) = std::env::var("VTE_VERSION") {
+            if !v.trim().is_empty() {
+                format!("VTE/{v}")
+            } else {
+                "VTE".to_string()
+            }
+        } else if std::env::var("WT_SESSION").is_ok() {
+            return "WindowsTerminal".to_string();
+        } else {
+            std::env::var("TERM").unwrap_or_else(|_| "unknown".to_string())
+        },
+    )
+}
diff --git a/codex-rs/core/src/user_agent.rs b/codex-rs/core/src/user_agent.rs
index ddcfd4b7f1..a63170cebd 100644
--- a/codex-rs/core/src/user_agent.rs
+++ b/codex-rs/core/src/user_agent.rs
@@ -4,11 +4,12 @@ pub fn get_codex_user_agent(originator: Option<&str>) -> String {
     let build_version = env!("CARGO_PKG_VERSION");
     let os_info = os_info::get();
     format!(
-        "{}/{build_version} ({} {}; {})",
+        "{}/{build_version} ({} {}; {}) {}",
         originator.unwrap_or(DEFAULT_ORIGINATOR),
         os_info.os_type(),
         os_info.version(),
         os_info.architecture().unwrap_or("unknown"),
+        crate::terminal::user_agent()
     )
 }
 
@@ -27,9 +28,10 @@ mod tests {
     fn test_macos() {
         use regex_lite::Regex;
         let user_agent = get_codex_user_agent(None);
-        let re =
-            Regex::new(r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\)$")
-                .unwrap();
+        let re = Regex::new(
+            r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$",
+        )
+        .unwrap();
         assert!(re.is_match(&user_agent));
     }
 }

Review Comments

codex-rs/core/src/terminal.rs

@@ -0,0 +1,65 @@
+use std::sync::OnceLock;
+
+static TERMINAL: OnceLock<String> = OnceLock::new();
+
+pub fn user_agent() -> String {
+    TERMINAL.get_or_init(detect_terminal).to_string()
+}
+
+fn detect_terminal() -> String {
+    if let Ok(tp) = std::env::var("TERM_PROGRAM") {
+        if !tp.trim().is_empty() {
+            let ver = std::env::var("TERM_PROGRAM_VERSION").ok();
+            return match ver {
+                Some(v) if !v.trim().is_empty() => format!("{tp}/{v}"),
+                _ => tp,
+            };
+        }
+    }
+
+    if let Ok(v) = std::env::var("WEZTERM_VERSION") {
+        if !v.trim().is_empty() {
+            return format!("WezTerm/{v}");
+        }
+        return "WezTerm".to_string();

Consider:

        return if !v.trim().is_empty() {
            format!("WezTerm/{v}")
        } else {
            "WezTerm".to_string()
        };
@@ -0,0 +1,65 @@
+use std::sync::OnceLock;
+
+static TERMINAL: OnceLock<String> = OnceLock::new();
+
+pub fn user_agent() -> String {
+    TERMINAL.get_or_init(detect_terminal).to_string()
+}
+
+fn detect_terminal() -> String {
+    if let Ok(tp) = std::env::var("TERM_PROGRAM") {
+        if !tp.trim().is_empty() {
+            let ver = std::env::var("TERM_PROGRAM_VERSION").ok();
+            return match ver {
+                Some(v) if !v.trim().is_empty() => format!("{tp}/{v}"),
+                _ => tp,
+            };
+        }
+    }
+
+    if let Ok(v) = std::env::var("WEZTERM_VERSION") {
+        if !v.trim().is_empty() {
+            return format!("WezTerm/{v}");
+        }
+        return "WezTerm".to_string();
+    }
+
+    if std::env::var("KITTY_WINDOW_ID").is_ok()
+        || std::env::var("TERM")
+            .map(|t| t.contains("kitty"))
+            .unwrap_or(false)
+    {
+        return "kitty".to_string();
+    }
+
+    if std::env::var("ALACRITTY_SOCKET").is_ok()
+        || std::env::var("TERM")
+            .map(|t| t == "alacritty")
+            .unwrap_or(false)
+    {
+        return "Alacritty".to_string();
+    }
+
+    if let Ok(v) = std::env::var("KONSOLE_VERSION") {
+        if !v.trim().is_empty() {

same trick as above, but either way

@@ -0,0 +1,65 @@
+use std::sync::OnceLock;
+
+static TERMINAL: OnceLock<String> = OnceLock::new();
+
+pub fn user_agent() -> String {
+    TERMINAL.get_or_init(detect_terminal).to_string()
+}
+
+fn detect_terminal() -> String {
+    if let Ok(tp) = std::env::var("TERM_PROGRAM") {
+        if !tp.trim().is_empty() {
+            let ver = std::env::var("TERM_PROGRAM_VERSION").ok();
+            return match ver {
+                Some(v) if !v.trim().is_empty() => format!("{tp}/{v}"),
+                _ => tp,
+            };
+        }
+    }
+
+    if let Ok(v) = std::env::var("WEZTERM_VERSION") {
+        if !v.trim().is_empty() {
+            return format!("WezTerm/{v}");
+        }
+        return "WezTerm".to_string();
+    }
+
+    if std::env::var("KITTY_WINDOW_ID").is_ok()
+        || std::env::var("TERM")
+            .map(|t| t.contains("kitty"))
+            .unwrap_or(false)
+    {
+        return "kitty".to_string();
+    }
+
+    if std::env::var("ALACRITTY_SOCKET").is_ok()
+        || std::env::var("TERM")
+            .map(|t| t == "alacritty")
+            .unwrap_or(false)
+    {
+        return "Alacritty".to_string();
+    }
+
+    if let Ok(v) = std::env::var("KONSOLE_VERSION") {
+        if !v.trim().is_empty() {
+            return format!("Konsole/{v}");
+        }
+        return "Konsole".to_string();
+    }
+
+    if std::env::var("GNOME_TERMINAL_SCREEN").is_ok() {
+        return "gnome-terminal".to_string();
+    }
+    if let Ok(v) = std::env::var("VTE_VERSION") {
+        if !v.trim().is_empty() {

samezies

@@ -0,0 +1,65 @@
+use std::sync::OnceLock;
+
+static TERMINAL: OnceLock<String> = OnceLock::new();
+
+pub fn user_agent() -> String {
+    TERMINAL.get_or_init(detect_terminal).to_string()
+}
+
+fn detect_terminal() -> String {

I don't know if there is any pathological edge case here, but should we verify that the result of detect_terminal() is safe to include as an HTTP header? I don't know why TERM_PROGRAM_VERSION would contain newlines or other funky characters, but better safe than sorry?

codex-rs/core/src/user_agent.rs

@@ -4,11 +4,12 @@ pub fn get_codex_user_agent(originator: Option<&str>) -> String {
     let build_version = env!("CARGO_PKG_VERSION");
     let os_info = os_info::get();
     format!(
-        "{}/{build_version} ({} {}; {})",
+        "{}/{build_version} ({} {}; {}) {}",

Though perhaps the reqwest library or whatever consumes this checks to ensure header names and values are safe?