diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4c321b3f62..3966c8486c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1187,14 +1187,41 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "codex-auth" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64", + "chrono", + "codex-app-server-protocol", + "codex-client", + "codex-keyring-store", + "codex-protocol", + "codex-user-agent", + "keyring", + "pretty_assertions", + "reqwest", + "schemars 0.8.22", + "serde", + "serde_json", + "serial_test", + "sha2", + "tempfile", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "codex-backend-client" version = "0.0.0" dependencies = [ "anyhow", + "codex-auth", "codex-backend-openapi-models", - "codex-core", "codex-protocol", + "codex-user-agent", "pretty_assertions", "reqwest", "serde", @@ -1369,16 +1396,16 @@ dependencies = [ "codex-apply-patch", "codex-arg0", "codex-async-utils", - "codex-client", + "codex-auth", "codex-core", "codex-execpolicy", "codex-file-search", "codex-git", - "codex-keyring-store", "codex-otel", "codex-protocol", "codex-rmcp-client", "codex-state", + "codex-user-agent", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-pty", @@ -1399,7 +1426,6 @@ dependencies = [ "include_dir", "indexmap 2.12.0", "indoc", - "keyring", "landlock", "libc", "maplit", @@ -1407,7 +1433,6 @@ dependencies = [ "multimap", "once_cell", "openssl-sys", - "os_info", "predicates", "pretty_assertions", "rand 0.9.2", @@ -1955,6 +1980,21 @@ dependencies = [ "winsplit", ] +[[package]] +name = "codex-user-agent" +version = "0.0.0" +dependencies = [ + "codex-client", + "core_test_support", + "os_info", + "pretty_assertions", + "regex-lite", + "reqwest", + "tokio", + "tracing", + "wiremock", +] + [[package]] name = "codex-utils-absolute-path" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5196213991..3335317884 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -2,6 +2,7 @@ members = [ "backend-client", "ansi-escape", + "auth", "async-utils", "app-server", "app-server-protocol", @@ -36,6 +37,7 @@ members = [ "stdio-to-uds", "otel", "tui", + "user-agent", "utils/absolute-path", "utils/cargo-bin", "utils/git", @@ -70,6 +72,7 @@ codex-app-server-protocol = { path = "app-server-protocol" } codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } codex-async-utils = { path = "async-utils" } +codex-auth = { path = "auth" } codex-backend-client = { path = "backend-client" } codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli"} @@ -104,6 +107,7 @@ codex-utils-pty = { path = "utils/pty" } codex-utils-readiness = { path = "utils/readiness" } codex-utils-string = { path = "utils/string" } codex-windows-sandbox = { path = "windows-sandbox-rs" } +codex-user-agent = { path = "user-agent" } core_test_support = { path = "core/tests/common" } exec_server_test_support = { path = "exec-server/tests/common" } mcp-types = { path = "mcp-types" } diff --git a/codex-rs/auth/BUILD.bazel b/codex-rs/auth/BUILD.bazel new file mode 100644 index 0000000000..6d3778b1b7 --- /dev/null +++ b/codex-rs/auth/BUILD.bazel @@ -0,0 +1,7 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "auth", + crate_name = "codex_auth", + crate_features = ["test-support"], +) diff --git a/codex-rs/auth/Cargo.toml b/codex-rs/auth/Cargo.toml new file mode 100644 index 0000000000..2ac024f9b6 --- /dev/null +++ b/codex-rs/auth/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "codex-auth" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[features] +test-support = [] + +[dependencies] +base64 = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +codex-app-server-protocol = { workspace = true } +codex-client = { workspace = true } +codex-keyring-store = { workspace = true } +codex-protocol = { workspace = true } +codex-user-agent = { workspace = true } +keyring = { workspace = true, features = ["crypto-rust"] } +reqwest = { workspace = true, features = ["json", "stream"] } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { workspace = true, features = ["linux-native-async-persistent"] } + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { workspace = true, features = ["apple-native"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { workspace = true, features = ["windows-native"] } + +[target.'cfg(any(target_os = "freebsd", target_os = "openbsd"))'.dependencies] +keyring = { workspace = true, features = ["sync-secret-service"] } + +[dev-dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +pretty_assertions = { workspace = true } +serial_test = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/codex-rs/auth/src/lib.rs b/codex-rs/auth/src/lib.rs index b746309270..6be324b03b 100644 --- a/codex-rs/auth/src/lib.rs +++ b/codex-rs/auth/src/lib.rs @@ -1,4 +1,5 @@ mod storage; +pub mod token_data; use chrono::Utc; use reqwest::StatusCode; @@ -12,26 +13,24 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; +use std::sync::RwLock; use codex_app_server_protocol::AuthMode; +use codex_client::CodexHttpClient; +use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::ForcedLoginMethod; +use serde_json::Value; +use thiserror::Error; +use tracing::debug; -pub use crate::auth::storage::AuthCredentialsStoreMode; -pub use crate::auth::storage::AuthDotJson; -use crate::auth::storage::AuthStorageBackend; -use crate::auth::storage::create_auth_storage; -use crate::config::Config; -use crate::error::RefreshTokenFailedError; -use crate::error::RefreshTokenFailedReason; +pub use crate::storage::AuthCredentialsStoreMode; +pub use crate::storage::AuthDotJson; +use crate::storage::AuthStorageBackend; +use crate::storage::create_auth_storage; use crate::token_data::KnownPlan as InternalKnownPlan; use crate::token_data::PlanType as InternalPlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; -use crate::util::try_parse_error_message; -use codex_client::CodexHttpClient; -use codex_protocol::account::PlanType as AccountPlanType; -use serde_json::Value; -use thiserror::Error; #[derive(Debug, Clone)] pub struct CodexAuth { @@ -68,6 +67,30 @@ pub enum RefreshTokenError { Transient(#[from] std::io::Error), } +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("{message}")] +pub struct RefreshTokenFailedError { + pub reason: RefreshTokenFailedReason, + pub message: String, +} + +impl RefreshTokenFailedError { + pub fn new(reason: RefreshTokenFailedReason, message: impl Into) -> Self { + Self { + reason, + message: message.into(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefreshTokenFailedReason { + Expired, + Exhausted, + Revoked, + Other, +} + impl RefreshTokenError { pub fn failed_reason(&self) -> Option { match self { @@ -176,7 +199,7 @@ impl CodexAuth { mode: AuthMode::ChatGPT, storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File), auth_dot_json, - client: crate::default_client::create_client(), + client: codex_user_agent::create_client(), } } @@ -191,7 +214,7 @@ impl CodexAuth { } pub fn from_api_key(api_key: &str) -> Self { - Self::from_api_key_with_client(api_key, crate::default_client::create_client()) + Self::from_api_key_with_client(api_key, codex_user_agent::create_client()) } } @@ -259,17 +282,25 @@ pub fn load_auth_dot_json( storage.load() } -pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { +#[derive(Debug, Clone)] +pub struct LoginRestrictions { + pub codex_home: PathBuf, + pub forced_login_method: Option, + pub forced_chatgpt_workspace_id: Option, + pub auth_credentials_store_mode: AuthCredentialsStoreMode, +} + +pub fn enforce_login_restrictions(restrictions: &LoginRestrictions) -> std::io::Result<()> { let Some(auth) = load_auth( - &config.codex_home, + &restrictions.codex_home, true, - config.cli_auth_credentials_store_mode, + restrictions.auth_credentials_store_mode, )? else { return Ok(()); }; - if let Some(required_method) = config.forced_login_method { + if let Some(required_method) = restrictions.forced_login_method { let method_violation = match (required_method, auth.mode) { (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, (ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT) => None, @@ -285,14 +316,14 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { if let Some(message) = method_violation { return logout_with_message( - &config.codex_home, + &restrictions.codex_home, message, - config.cli_auth_credentials_store_mode, + restrictions.auth_credentials_store_mode, ); } } - if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() { + if let Some(expected_account_id) = restrictions.forced_chatgpt_workspace_id.as_deref() { if auth.mode != AuthMode::ChatGPT { return Ok(()); } @@ -301,11 +332,11 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { Ok(data) => data, Err(err) => { return logout_with_message( - &config.codex_home, + &restrictions.codex_home, format!( "Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out." ), - config.cli_auth_credentials_store_mode, + restrictions.auth_credentials_store_mode, ); } }; @@ -322,9 +353,9 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { ), }; return logout_with_message( - &config.codex_home, + &restrictions.codex_home, message, - config.cli_auth_credentials_store_mode, + restrictions.auth_credentials_store_mode, ); } } @@ -351,7 +382,7 @@ fn load_auth( auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result> { if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { - let client = crate::default_client::create_client(); + let client = codex_user_agent::create_client(); return Ok(Some(CodexAuth::from_api_key_with_client( api_key.as_str(), client, @@ -360,7 +391,7 @@ fn load_auth( let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); - let client = crate::default_client::create_client(); + let client = codex_user_agent::create_client(); let auth_dot_json = match storage.load()? { Some(auth) => auth, None => return Ok(None), @@ -459,6 +490,21 @@ async fn try_refresh_token( } } +fn try_parse_error_message(text: &str) -> String { + debug!("Parsing server error response: {text}"); + let json = serde_json::from_str::(text).unwrap_or_default(); + if let Some(error) = json.get("error") + && let Some(message) = error.get("message") + && let Some(message_str) = message.as_str() + { + return message_str.to_string(); + } + if text.is_empty() { + return "Unknown error".to_string(); + } + text.to_string() +} + fn classify_refresh_token_failure(body: &str) -> RefreshTokenFailedError { let code = extract_refresh_token_error_code(body); @@ -537,8 +583,6 @@ fn refresh_token_endpoint() -> String { .unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string()) } -use std::sync::RwLock; - /// Internal cached auth state. #[derive(Clone, Debug)] struct CachedAuth { @@ -813,7 +857,7 @@ impl AuthManager { /// reloads the in‑memory auth cache so callers immediately observe the /// unauthenticated state. pub fn logout(&self) -> std::io::Result { - let removed = super::auth::logout(&self.codex_home, self.auth_credentials_store_mode)?; + let removed = logout(&self.codex_home, self.auth_credentials_store_mode)?; // Always reload to clear any cached auth (even if file absent). self.reload(); Ok(removed) @@ -871,10 +915,8 @@ impl AuthManager { #[cfg(test)] mod tests { use super::*; - use crate::auth::storage::FileAuthStorage; - use crate::auth::storage::get_auth_file; - use crate::config::Config; - use crate::config::ConfigBuilder; + use crate::storage::FileAuthStorage; + use crate::storage::get_auth_file; use crate::token_data::IdTokenInfo; use crate::token_data::KnownPlan as InternalKnownPlan; use crate::token_data::PlanType as InternalPlanType; @@ -957,6 +999,30 @@ mod tests { assert_eq!(auth, None); } + #[test] + fn test_try_parse_error_message() { + let text = r#"{ + "error": { + "message": "Your refresh token has already been used to generate a new access token. Please try signing in again.", + "type": "invalid_request_error", + "param": null, + "code": "refresh_token_reused" + } +}"#; + let message = try_parse_error_message(text); + assert_eq!( + message, + "Your refresh token has already been used to generate a new access token. Please try signing in again." + ); + } + + #[test] + fn test_try_parse_error_message_no_error() { + let text = r#"{"message": "test"}"#; + let message = try_parse_error_message(text); + assert_eq!(message, r#"{"message": "test"}"#); + } + #[tokio::test] #[serial(codex_api_key)] async fn pro_account_with_no_api_key_uses_chatgpt_auth() { @@ -1100,19 +1166,17 @@ mod tests { Ok(fake_jwt) } - async fn build_config( + fn build_restrictions( codex_home: &Path, forced_login_method: Option, forced_chatgpt_workspace_id: Option, - ) -> Config { - let mut config = ConfigBuilder::default() - .codex_home(codex_home.to_path_buf()) - .build() - .await - .expect("config should load"); - config.forced_login_method = forced_login_method; - config.forced_chatgpt_workspace_id = forced_chatgpt_workspace_id; - config + ) -> LoginRestrictions { + LoginRestrictions { + codex_home: codex_home.to_path_buf(), + forced_login_method, + forced_chatgpt_workspace_id, + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + } } /// Use sparingly. @@ -1146,15 +1210,16 @@ mod tests { } } - #[tokio::test] - async fn enforce_login_restrictions_logs_out_for_method_mismatch() { + #[test] + fn enforce_login_restrictions_logs_out_for_method_mismatch() { let codex_home = tempdir().unwrap(); login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) .expect("seed api key"); - let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None).await; + let restrictions = + build_restrictions(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None); - let err = super::enforce_login_restrictions(&config) + let err = super::enforce_login_restrictions(&restrictions) .expect_err("expected method mismatch to error"); assert!(err.to_string().contains("ChatGPT login is required")); assert!( @@ -1163,9 +1228,9 @@ mod tests { ); } - #[tokio::test] + #[test] #[serial(codex_api_key)] - async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { + fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { let codex_home = tempdir().unwrap(); let _jwt = write_auth_file( AuthFileParams { @@ -1177,9 +1242,10 @@ mod tests { ) .expect("failed to write auth file"); - let config = build_config(codex_home.path(), None, Some("org_mine".to_string())).await; + let restrictions = + build_restrictions(codex_home.path(), None, Some("org_mine".to_string())); - let err = super::enforce_login_restrictions(&config) + let err = super::enforce_login_restrictions(&restrictions) .expect_err("expected workspace mismatch to error"); assert!(err.to_string().contains("workspace org_mine")); assert!( @@ -1188,9 +1254,9 @@ mod tests { ); } - #[tokio::test] + #[test] #[serial(codex_api_key)] - async fn enforce_login_restrictions_allows_matching_workspace() { + fn enforce_login_restrictions_allows_matching_workspace() { let codex_home = tempdir().unwrap(); let _jwt = write_auth_file( AuthFileParams { @@ -1202,40 +1268,45 @@ mod tests { ) .expect("failed to write auth file"); - let config = build_config(codex_home.path(), None, Some("org_mine".to_string())).await; + let restrictions = + build_restrictions(codex_home.path(), None, Some("org_mine".to_string())); - super::enforce_login_restrictions(&config).expect("matching workspace should succeed"); + super::enforce_login_restrictions(&restrictions) + .expect("matching workspace should succeed"); assert!( codex_home.path().join("auth.json").exists(), "auth.json should remain when restrictions pass" ); } - #[tokio::test] - async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() + #[test] + fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() { let codex_home = tempdir().unwrap(); login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) .expect("seed api key"); - let config = build_config(codex_home.path(), None, Some("org_mine".to_string())).await; + let restrictions = + build_restrictions(codex_home.path(), None, Some("org_mine".to_string())); - super::enforce_login_restrictions(&config).expect("matching workspace should succeed"); + super::enforce_login_restrictions(&restrictions) + .expect("matching workspace should succeed"); assert!( codex_home.path().join("auth.json").exists(), "auth.json should remain when restrictions pass" ); } - #[tokio::test] + #[test] #[serial(codex_api_key)] - async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() { + fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() { let _guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); let codex_home = tempdir().unwrap(); - let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None).await; + let restrictions = + build_restrictions(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None); - let err = super::enforce_login_restrictions(&config) + let err = super::enforce_login_restrictions(&restrictions) .expect_err("environment API key should not satisfy forced ChatGPT login"); assert!( err.to_string() diff --git a/codex-rs/auth/src/token_data.rs b/codex-rs/auth/src/token_data.rs new file mode 100644 index 0000000000..ea2f9e74df --- /dev/null +++ b/codex-rs/auth/src/token_data.rs @@ -0,0 +1,228 @@ +use base64::Engine; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; + +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] +pub struct TokenData { + /// Flat info parsed from the JWT in auth.json. + #[serde( + deserialize_with = "deserialize_id_token", + serialize_with = "serialize_id_token" + )] + pub id_token: IdTokenInfo, + + /// This is a JWT. + pub access_token: String, + + pub refresh_token: String, + + pub account_id: Option, +} + +/// Flat subset of useful claims in id_token from auth.json. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct IdTokenInfo { + pub email: Option, + /// The ChatGPT subscription plan type + /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu"). + /// (Note: values may vary by backend.) + pub(crate) chatgpt_plan_type: Option, + /// ChatGPT user identifier associated with the token, if present. + pub chatgpt_user_id: Option, + /// Organization/workspace identifier associated with the token, if present. + pub chatgpt_account_id: Option, + pub raw_jwt: String, +} + +impl IdTokenInfo { + pub fn get_chatgpt_plan_type(&self) -> Option { + self.chatgpt_plan_type.as_ref().map(|t| match t { + PlanType::Known(plan) => format!("{plan:?}"), + PlanType::Unknown(s) => s.clone(), + }) + } + + pub fn is_workspace_account(&self) -> bool { + matches!( + self.chatgpt_plan_type, + Some(PlanType::Known( + KnownPlan::Team | KnownPlan::Business | KnownPlan::Enterprise | KnownPlan::Edu + )) + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PlanType { + Known(KnownPlan), + Unknown(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum KnownPlan { + Free, + Plus, + Pro, + Team, + Business, + Enterprise, + Edu, +} + +#[derive(Deserialize)] +struct IdClaims { + #[serde(default)] + email: Option, + #[serde(rename = "https://api.openai.com/auth", default)] + auth: Option, +} + +#[derive(Deserialize)] +struct AuthClaims { + #[serde(default)] + chatgpt_plan_type: Option, + #[serde(default)] + chatgpt_user_id: Option, + #[serde(default)] + user_id: Option, + #[serde(default)] + chatgpt_account_id: Option, +} + +#[derive(Debug, Error)] +pub enum IdTokenInfoError { + #[error("invalid ID token format")] + InvalidFormat, + #[error(transparent)] + Base64(#[from] base64::DecodeError), + #[error(transparent)] + Json(#[from] serde_json::Error), +} + +pub fn parse_id_token(id_token: &str) -> Result { + // JWT format: header.payload.signature + let mut parts = id_token.split('.'); + let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), + _ => return Err(IdTokenInfoError::InvalidFormat), + }; + + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?; + let claims: IdClaims = serde_json::from_slice(&payload_bytes)?; + + match claims.auth { + Some(auth) => Ok(IdTokenInfo { + email: claims.email, + raw_jwt: id_token.to_string(), + chatgpt_plan_type: auth.chatgpt_plan_type, + chatgpt_user_id: auth.chatgpt_user_id.or(auth.user_id), + chatgpt_account_id: auth.chatgpt_account_id, + }), + None => Ok(IdTokenInfo { + email: claims.email, + raw_jwt: id_token.to_string(), + chatgpt_plan_type: None, + chatgpt_user_id: None, + chatgpt_account_id: None, + }), + } +} + +fn deserialize_id_token<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + parse_id_token(&s).map_err(serde::de::Error::custom) +} + +fn serialize_id_token(id_token: &IdTokenInfo, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&id_token.raw_jwt) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde::Serialize; + + #[test] + fn id_token_info_parses_email_and_plan() { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_plan_type": "pro" + } + }); + + fn b64url_no_pad(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + } + + let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64url_no_pad(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_id_token(&fake_jwt).expect("should parse"); + assert_eq!(info.email.as_deref(), Some("user@example.com")); + assert_eq!(info.get_chatgpt_plan_type().as_deref(), Some("Pro")); + } + + #[test] + fn id_token_info_handles_missing_fields() { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ "sub": "123" }); + + fn b64url_no_pad(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + } + + let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64url_no_pad(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_id_token(&fake_jwt).expect("should parse"); + assert!(info.email.is_none()); + assert!(info.get_chatgpt_plan_type().is_none()); + } + + #[test] + fn workspace_account_detection_matches_workspace_plans() { + let workspace = IdTokenInfo { + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Business)), + ..IdTokenInfo::default() + }; + assert_eq!(workspace.is_workspace_account(), true); + + let personal = IdTokenInfo { + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + ..IdTokenInfo::default() + }; + assert_eq!(personal.is_workspace_account(), false); + } +} diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index ec5546a670..7d8e506761 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -15,7 +15,8 @@ serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } codex-protocol = { workspace = true } -codex-core = { workspace = true } +codex-auth = { workspace = true } +codex-user-agent = { workspace = true } [dev-dependencies] pretty_assertions = "1" diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index ec5fd3f61b..02cdfd34fa 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -6,12 +6,12 @@ use crate::types::RateLimitStatusPayload; use crate::types::RateLimitWindowSnapshot; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; -use codex_core::auth::CodexAuth; -use codex_core::default_client::get_codex_user_agent; +use codex_auth::CodexAuth; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; +use codex_user_agent::get_codex_user_agent; use reqwest::header::AUTHORIZATION; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index b336b296c6..970d2ad097 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -29,15 +29,15 @@ codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } -codex-client = { workspace = true } +codex-auth = { workspace = true } codex-execpolicy = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } -codex-keyring-store = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } codex-state = { workspace = true } +codex-user-agent = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } @@ -53,12 +53,10 @@ http = { workspace = true } include_dir = { workspace = true } indexmap = { workspace = true } indoc = { workspace = true } -keyring = { workspace = true, features = ["crypto-rust"] } libc = { workspace = true } mcp-types = { workspace = true } multimap = { workspace = true } once_cell = { workspace = true } -os_info = { workspace = true } rand = { workspace = true } regex = { workspace = true } regex-lite = { workspace = true } @@ -102,17 +100,15 @@ wildmatch = { workspace = true } [features] deterministic_process_ids = [] -test-support = [] +test-support = ["codex-auth/test-support"] [target.'cfg(target_os = "linux")'.dependencies] -keyring = { workspace = true, features = ["linux-native-async-persistent"] } landlock = { workspace = true } seccompiler = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" -keyring = { workspace = true, features = ["apple-native"] } # Build OpenSSL from source for musl builds. [target.x86_64-unknown-linux-musl.dependencies] @@ -122,11 +118,6 @@ openssl-sys = { workspace = true, features = ["vendored"] } [target.aarch64-unknown-linux-musl.dependencies] openssl-sys = { workspace = true, features = ["vendored"] } -[target.'cfg(target_os = "windows")'.dependencies] -keyring = { workspace = true, features = ["windows-native"] } - -[target.'cfg(any(target_os = "freebsd", target_os = "openbsd"))'.dependencies] -keyring = { workspace = true, features = ["sync-secret-service"] } [dev-dependencies] assert_cmd = { workspace = true } diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs new file mode 100644 index 0000000000..8fee1a4bd7 --- /dev/null +++ b/codex-rs/core/src/auth.rs @@ -0,0 +1,30 @@ +pub use codex_auth::AuthCredentialsStoreMode; +pub use codex_auth::AuthDotJson; +pub use codex_auth::AuthManager; +pub use codex_auth::CLIENT_ID; +pub use codex_auth::CODEX_API_KEY_ENV_VAR; +pub use codex_auth::CodexAuth; +pub use codex_auth::LoginRestrictions; +pub use codex_auth::OPENAI_API_KEY_ENV_VAR; +pub use codex_auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +pub use codex_auth::RefreshTokenError; +pub use codex_auth::RefreshTokenFailedError; +pub use codex_auth::RefreshTokenFailedReason; +pub use codex_auth::UnauthorizedRecovery; +pub use codex_auth::load_auth_dot_json; +pub use codex_auth::login_with_api_key; +pub use codex_auth::logout; +pub use codex_auth::read_codex_api_key_from_env; +pub use codex_auth::read_openai_api_key_from_env; +pub use codex_auth::save_auth; + +use crate::config::Config; + +pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { + codex_auth::enforce_login_restrictions(&LoginRestrictions { + codex_home: config.codex_home.clone(), + forced_login_method: config.forced_login_method, + forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), + auth_credentials_store_mode: config.cli_auth_credentials_store_mode, + }) +} diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 67a5daf1b1..66a4ca69b7 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -1,295 +1 @@ -use crate::spawn::CODEX_SANDBOX_ENV_VAR; -use codex_client::CodexHttpClient; -pub use codex_client::CodexRequestBuilder; -use reqwest::header::HeaderValue; -use std::sync::LazyLock; -use std::sync::Mutex; -use std::sync::RwLock; - -/// Set this to add a suffix to the User-Agent string. -/// -/// It is not ideal that we're using a global singleton for this. -/// This is primarily designed to differentiate MCP clients from each other. -/// Because there can only be one MCP server per process, it should be safe for this to be a global static. -/// However, future users of this should use this with caution as a result. -/// In addition, we want to be confident that this value is used for ALL clients and doing that requires a -/// lot of wiring and it's easy to miss code paths by doing so. -/// See https://github.com/openai/codex/pull/3388/files for an example of what that would look like. -/// Finally, we want to make sure this is set for ALL mcp clients without needing to know a special env var -/// or having to set data that they already specified in the mcp initialize request somewhere else. -/// -/// A space is automatically added between the suffix and the rest of the User-Agent string. -/// The full user agent string is returned from the mcp initialize response. -/// Parenthesis will be added by Codex. This should only specify what goes inside of the parenthesis. -pub static USER_AGENT_SUFFIX: LazyLock>> = LazyLock::new(|| Mutex::new(None)); -pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; -pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; - -#[derive(Debug, Clone)] -pub struct Originator { - pub value: String, - pub header_value: HeaderValue, -} -static ORIGINATOR: LazyLock>> = LazyLock::new(|| RwLock::new(None)); - -#[derive(Debug)] -pub enum SetOriginatorError { - InvalidHeaderValue, - AlreadyInitialized, -} - -fn get_originator_value(provided: Option) -> Originator { - let value = std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR) - .ok() - .or(provided) - .unwrap_or(DEFAULT_ORIGINATOR.to_string()); - - match HeaderValue::from_str(&value) { - Ok(header_value) => Originator { - value, - header_value, - }, - Err(e) => { - tracing::error!("Unable to turn originator override {value} into header value: {e}"); - Originator { - value: DEFAULT_ORIGINATOR.to_string(), - header_value: HeaderValue::from_static(DEFAULT_ORIGINATOR), - } - } - } -} - -pub fn set_default_originator(value: String) -> Result<(), SetOriginatorError> { - if HeaderValue::from_str(&value).is_err() { - return Err(SetOriginatorError::InvalidHeaderValue); - } - let originator = get_originator_value(Some(value)); - let Ok(mut guard) = ORIGINATOR.write() else { - return Err(SetOriginatorError::AlreadyInitialized); - }; - if guard.is_some() { - return Err(SetOriginatorError::AlreadyInitialized); - } - *guard = Some(originator); - Ok(()) -} - -pub fn originator() -> Originator { - if let Ok(guard) = ORIGINATOR.read() - && let Some(originator) = guard.as_ref() - { - return originator.clone(); - } - - if std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR).is_ok() { - let originator = get_originator_value(None); - if let Ok(mut guard) = ORIGINATOR.write() { - match guard.as_ref() { - Some(originator) => return originator.clone(), - None => *guard = Some(originator.clone()), - } - } - return originator; - } - - get_originator_value(None) -} - -pub fn is_first_party_originator(originator_value: &str) -> bool { - originator_value == DEFAULT_ORIGINATOR - || originator_value == "codex_vscode" - || originator_value.starts_with("Codex ") -} - -pub fn get_codex_user_agent() -> String { - let build_version = env!("CARGO_PKG_VERSION"); - let os_info = os_info::get(); - let originator = originator(); - let prefix = format!( - "{}/{build_version} ({} {}; {}) {}", - originator.value.as_str(), - os_info.os_type(), - os_info.version(), - os_info.architecture().unwrap_or("unknown"), - crate::terminal::user_agent() - ); - let suffix = USER_AGENT_SUFFIX - .lock() - .ok() - .and_then(|guard| guard.clone()); - let suffix = suffix - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map_or_else(String::new, |value| format!(" ({value})")); - - let candidate = format!("{prefix}{suffix}"); - sanitize_user_agent(candidate, &prefix) -} - -/// Sanitize the user agent string. -/// -/// Invalid characters are replaced with an underscore. -/// -/// If the user agent fails to parse, it falls back to fallback and then to ORIGINATOR. -fn sanitize_user_agent(candidate: String, fallback: &str) -> String { - if HeaderValue::from_str(candidate.as_str()).is_ok() { - return candidate; - } - - let sanitized: String = candidate - .chars() - .map(|ch| if matches!(ch, ' '..='~') { ch } else { '_' }) - .collect(); - if !sanitized.is_empty() && HeaderValue::from_str(sanitized.as_str()).is_ok() { - tracing::warn!( - "Sanitized Codex user agent because provided suffix contained invalid header characters" - ); - sanitized - } else if HeaderValue::from_str(fallback).is_ok() { - tracing::warn!( - "Falling back to base Codex user agent because provided suffix could not be sanitized" - ); - fallback.to_string() - } else { - tracing::warn!( - "Falling back to default Codex originator because base user agent string is invalid" - ); - originator().value - } -} - -/// Create an HTTP client with default `originator` and `User-Agent` headers set. -pub fn create_client() -> CodexHttpClient { - let inner = build_reqwest_client(); - CodexHttpClient::new(inner) -} - -pub fn build_reqwest_client() -> reqwest::Client { - use reqwest::header::HeaderMap; - - let mut headers = HeaderMap::new(); - headers.insert("originator", originator().header_value); - let ua = get_codex_user_agent(); - - let mut builder = reqwest::Client::builder() - // Set UA via dedicated helper to avoid header validation pitfalls - .user_agent(ua) - .default_headers(headers); - if is_sandboxed() { - builder = builder.no_proxy(); - } - - builder.build().unwrap_or_else(|_| reqwest::Client::new()) -} - -fn is_sandboxed() -> bool { - std::env::var(CODEX_SANDBOX_ENV_VAR).as_deref() == Ok("seatbelt") -} - -#[cfg(test)] -mod tests { - use super::*; - use core_test_support::skip_if_no_network; - use pretty_assertions::assert_eq; - - #[test] - fn test_get_codex_user_agent() { - let user_agent = get_codex_user_agent(); - let originator = originator().value; - let prefix = format!("{originator}/"); - assert!(user_agent.starts_with(&prefix)); - } - - #[test] - fn is_first_party_originator_matches_known_values() { - assert_eq!(is_first_party_originator(DEFAULT_ORIGINATOR), true); - assert_eq!(is_first_party_originator("codex_vscode"), true); - assert_eq!(is_first_party_originator("Codex Something Else"), true); - assert_eq!(is_first_party_originator("codex_cli"), false); - assert_eq!(is_first_party_originator("Other"), false); - } - - #[tokio::test] - async fn test_create_client_sets_default_headers() { - skip_if_no_network!(); - - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - - let client = create_client(); - - // Spin up a local mock server and capture a request. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/")) - .respond_with(ResponseTemplate::new(200)) - .mount(&server) - .await; - - let resp = client - .get(server.uri()) - .send() - .await - .expect("failed to send request"); - assert!(resp.status().is_success()); - - let requests = server - .received_requests() - .await - .expect("failed to fetch received requests"); - assert!(!requests.is_empty()); - let headers = &requests[0].headers; - - // originator header is set to the provided value - let originator_header = headers - .get("originator") - .expect("originator header missing"); - assert_eq!(originator_header.to_str().unwrap(), originator().value); - - // User-Agent matches the computed Codex UA for that originator - let expected_ua = get_codex_user_agent(); - let ua_header = headers - .get("user-agent") - .expect("user-agent header missing"); - assert_eq!(ua_header.to_str().unwrap(), expected_ua); - } - - #[test] - fn test_invalid_suffix_is_sanitized() { - let prefix = "codex_cli_rs/0.0.0"; - let suffix = "bad\rsuffix"; - - assert_eq!( - sanitize_user_agent(format!("{prefix} ({suffix})"), prefix), - "codex_cli_rs/0.0.0 (bad_suffix)" - ); - } - - #[test] - fn test_invalid_suffix_is_sanitized2() { - let prefix = "codex_cli_rs/0.0.0"; - let suffix = "bad\0suffix"; - - assert_eq!( - sanitize_user_agent(format!("{prefix} ({suffix})"), prefix), - "codex_cli_rs/0.0.0 (bad_suffix)" - ); - } - - #[test] - #[cfg(target_os = "macos")] - fn test_macos() { - use regex_lite::Regex; - let user_agent = get_codex_user_agent(); - let originator = regex_lite::escape(originator().value.as_str()); - let re = Regex::new(&format!( - r"^{originator}/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$" - )) - .unwrap(); - assert!(re.is_match(&user_agent)); - } -} +pub use codex_user_agent::*; diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index e9830f5189..7c1de2d2d4 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -8,6 +8,8 @@ use chrono::Datelike; use chrono::Local; use chrono::Utc; use codex_async_utils::CancelErr; +pub use codex_auth::RefreshTokenFailedError; +pub use codex_auth::RefreshTokenFailedReason; use codex_protocol::ThreadId; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; @@ -253,30 +255,6 @@ impl std::fmt::Display for ResponseStreamFailed { } } -#[derive(Debug, Clone, PartialEq, Eq, Error)] -#[error("{message}")] -pub struct RefreshTokenFailedError { - pub reason: RefreshTokenFailedReason, - pub message: String, -} - -impl RefreshTokenFailedError { - pub fn new(reason: RefreshTokenFailedReason, message: impl Into) -> Self { - Self { - reason, - message: message.into(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RefreshTokenFailedReason { - Expired, - Exhausted, - Revoked, - Other, -} - #[derive(Debug)] pub struct UnexpectedResponseError { pub status: StatusCode, diff --git a/codex-rs/core/src/terminal.rs b/codex-rs/core/src/terminal.rs index 8437962f6c..691e1a8222 100644 --- a/codex-rs/core/src/terminal.rs +++ b/codex-rs/core/src/terminal.rs @@ -1,1166 +1,5 @@ -//! Terminal detection utilities. -//! -//! This module feeds terminal metadata into OpenTelemetry user-agent logging and into -//! terminal-specific configuration choices in the TUI. - -use std::sync::OnceLock; - -/// Structured terminal identification data. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TerminalInfo { - /// The detected terminal name category. - pub name: TerminalName, - /// The `TERM_PROGRAM` value when provided by the terminal. - pub term_program: Option, - /// The terminal version string when available. - pub version: Option, - /// The `TERM` value when falling back to capability strings. - pub term: Option, - /// Multiplexer metadata when a terminal multiplexer is active. - pub multiplexer: Option, -} - -/// Known terminal name categories derived from environment variables. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum TerminalName { - /// Apple Terminal (Terminal.app). - AppleTerminal, - /// Ghostty terminal emulator. - Ghostty, - /// iTerm2 terminal emulator. - Iterm2, - /// Warp terminal emulator. - WarpTerminal, - /// Visual Studio Code integrated terminal. - VsCode, - /// WezTerm terminal emulator. - WezTerm, - /// kitty terminal emulator. - Kitty, - /// Alacritty terminal emulator. - Alacritty, - /// KDE Konsole terminal emulator. - Konsole, - /// GNOME Terminal emulator. - GnomeTerminal, - /// VTE backend terminal. - Vte, - /// Windows Terminal emulator. - WindowsTerminal, - /// Dumb terminal (TERM=dumb). - Dumb, - /// Unknown or missing terminal identification. - Unknown, -} - -/// Detected terminal multiplexer metadata. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Multiplexer { - /// tmux terminal multiplexer. - Tmux { - /// tmux version string when `TERM_PROGRAM=tmux` is available. - /// - /// This is derived from `TERM_PROGRAM_VERSION`. - version: Option, - }, - /// zellij terminal multiplexer. - Zellij {}, -} - -/// tmux client terminal identification captured via `tmux display-message`. -/// -/// `termtype` corresponds to `#{client_termtype}` and typically reflects the -/// underlying terminal program (for example, `ghostty` or `wezterm`) with an -/// optional version suffix. `termname` comes from `#{client_termname}` and -/// preserves the TERM capability string exposed by the client (for example, -/// `xterm-256color`). -/// -/// This information is only available when running under tmux and lets us -/// attribute the session to the underlying terminal rather than to tmux itself. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -struct TmuxClientInfo { - termtype: Option, - termname: Option, -} - -impl TerminalInfo { - /// Creates terminal metadata from detected fields. - fn new( - name: TerminalName, - term_program: Option, - version: Option, - term: Option, - multiplexer: Option, - ) -> Self { - Self { - name, - term_program, - version, - term, - multiplexer, - } - } - - /// Creates terminal metadata from a `TERM_PROGRAM` match. - fn from_term_program( - name: TerminalName, - term_program: String, - version: Option, - multiplexer: Option, - ) -> Self { - Self::new(name, Some(term_program), version, None, multiplexer) - } - - /// Creates terminal metadata from a `TERM_PROGRAM` match plus a `TERM` value. - fn from_term_program_and_term( - name: TerminalName, - term_program: String, - version: Option, - term: Option, - multiplexer: Option, - ) -> Self { - Self::new(name, Some(term_program), version, term, multiplexer) - } - - /// Creates terminal metadata from a known terminal name and optional version. - fn from_name( - name: TerminalName, - version: Option, - multiplexer: Option, - ) -> Self { - Self::new(name, None, version, None, multiplexer) - } - - /// Creates terminal metadata from a `TERM` capability value. - fn from_term(term: String, multiplexer: Option) -> Self { - let name = if term == "dumb" { - TerminalName::Dumb - } else { - TerminalName::Unknown - }; - Self::new(name, None, None, Some(term), multiplexer) - } - - /// Creates terminal metadata for unknown terminals. - fn unknown(multiplexer: Option) -> Self { - Self::new(TerminalName::Unknown, None, None, None, multiplexer) - } - - /// Formats the terminal info as a User-Agent token. - fn user_agent_token(&self) -> String { - let raw = if let Some(program) = self.term_program.as_ref() { - match self.version.as_ref().filter(|v| !v.is_empty()) { - Some(version) => format!("{program}/{version}"), - None => program.clone(), - } - } else if let Some(term) = self.term.as_ref().filter(|value| !value.is_empty()) { - term.clone() - } else { - match self.name { - TerminalName::AppleTerminal => { - format_terminal_version("Apple_Terminal", &self.version) - } - TerminalName::Ghostty => format_terminal_version("Ghostty", &self.version), - TerminalName::Iterm2 => format_terminal_version("iTerm.app", &self.version), - TerminalName::WarpTerminal => { - format_terminal_version("WarpTerminal", &self.version) - } - TerminalName::VsCode => format_terminal_version("vscode", &self.version), - TerminalName::WezTerm => format_terminal_version("WezTerm", &self.version), - TerminalName::Kitty => "kitty".to_string(), - TerminalName::Alacritty => "Alacritty".to_string(), - TerminalName::Konsole => format_terminal_version("Konsole", &self.version), - TerminalName::GnomeTerminal => "gnome-terminal".to_string(), - TerminalName::Vte => format_terminal_version("VTE", &self.version), - TerminalName::WindowsTerminal => "WindowsTerminal".to_string(), - TerminalName::Dumb => "dumb".to_string(), - TerminalName::Unknown => "unknown".to_string(), - } - }; - - sanitize_header_value(raw) - } -} - -static TERMINAL_INFO: OnceLock = OnceLock::new(); - -/// Environment variable access used by terminal detection. -/// -/// This trait exists to allow faking the environment in tests. -trait Environment { - /// Returns an environment variable when set. - fn var(&self, name: &str) -> Option; - - /// Returns whether an environment variable is set. - fn has(&self, name: &str) -> bool { - self.var(name).is_some() - } - - /// Returns a non-empty environment variable. - fn var_non_empty(&self, name: &str) -> Option { - self.var(name).and_then(none_if_whitespace) - } - - /// Returns whether an environment variable is set and non-empty. - fn has_non_empty(&self, name: &str) -> bool { - self.var_non_empty(name).is_some() - } - - /// Returns tmux client details when available. - fn tmux_client_info(&self) -> TmuxClientInfo; -} - -/// Reads environment variables from the running process. -struct ProcessEnvironment; - -impl Environment for ProcessEnvironment { - fn var(&self, name: &str) -> Option { - match std::env::var(name) { - Ok(value) => Some(value), - Err(std::env::VarError::NotPresent) => None, - Err(std::env::VarError::NotUnicode(_)) => { - tracing::warn!("failed to read env var {name}: value not valid UTF-8"); - None - } - } - } - - fn tmux_client_info(&self) -> TmuxClientInfo { - tmux_client_info() - } -} - -/// Returns a sanitized terminal identifier for User-Agent strings. -pub fn user_agent() -> String { - terminal_info().user_agent_token() -} - -/// Returns structured terminal metadata for the current process. -pub fn terminal_info() -> TerminalInfo { - TERMINAL_INFO - .get_or_init(|| detect_terminal_info_from_env(&ProcessEnvironment)) - .clone() -} - -/// Detects structured terminal metadata from an injectable environment. -/// -/// Detection order favors explicit identifiers before falling back to capability strings: -/// - If `TERM_PROGRAM=tmux`, the tmux client term type/name are used instead. The client term -/// type is split on whitespace to extract a program name plus optional version (for example, -/// `ghostty 1.2.3`), while the client term name becomes the `TERM` capability string. -/// - Otherwise, `TERM_PROGRAM` (plus `TERM_PROGRAM_VERSION`) drives the detected terminal name. -/// - Next, terminal-specific variables (WEZTERM, iTerm2, Apple Terminal, kitty, etc.) are checked. -/// - Finally, `TERM` is used as the capability fallback with `TerminalName::Unknown`. -/// -/// tmux client term info is only consulted when a tmux multiplexer is detected, and it is -/// derived from `tmux display-message` to surface the underlying terminal program instead of -/// reporting tmux itself. -fn detect_terminal_info_from_env(env: &dyn Environment) -> TerminalInfo { - let multiplexer = detect_multiplexer(env); - - if let Some(term_program) = env.var_non_empty("TERM_PROGRAM") { - if is_tmux_term_program(&term_program) - && matches!(multiplexer, Some(Multiplexer::Tmux { .. })) - && let Some(terminal) = - terminal_from_tmux_client_info(env.tmux_client_info(), multiplexer.clone()) - { - return terminal; - } - - let version = env.var_non_empty("TERM_PROGRAM_VERSION"); - let name = terminal_name_from_term_program(&term_program).unwrap_or(TerminalName::Unknown); - return TerminalInfo::from_term_program(name, term_program, version, multiplexer); - } - - if env.has("WEZTERM_VERSION") { - let version = env.var_non_empty("WEZTERM_VERSION"); - return TerminalInfo::from_name(TerminalName::WezTerm, version, multiplexer); - } - - if env.has("ITERM_SESSION_ID") || env.has("ITERM_PROFILE") || env.has("ITERM_PROFILE_NAME") { - return TerminalInfo::from_name(TerminalName::Iterm2, None, multiplexer); - } - - if env.has("TERM_SESSION_ID") { - return TerminalInfo::from_name(TerminalName::AppleTerminal, None, multiplexer); - } - - if env.has("KITTY_WINDOW_ID") - || env - .var("TERM") - .map(|term| term.contains("kitty")) - .unwrap_or(false) - { - return TerminalInfo::from_name(TerminalName::Kitty, None, multiplexer); - } - - if env.has("ALACRITTY_SOCKET") - || env - .var("TERM") - .map(|term| term == "alacritty") - .unwrap_or(false) - { - return TerminalInfo::from_name(TerminalName::Alacritty, None, multiplexer); - } - - if env.has("KONSOLE_VERSION") { - let version = env.var_non_empty("KONSOLE_VERSION"); - return TerminalInfo::from_name(TerminalName::Konsole, version, multiplexer); - } - - if env.has("GNOME_TERMINAL_SCREEN") { - return TerminalInfo::from_name(TerminalName::GnomeTerminal, None, multiplexer); - } - - if env.has("VTE_VERSION") { - let version = env.var_non_empty("VTE_VERSION"); - return TerminalInfo::from_name(TerminalName::Vte, version, multiplexer); - } - - if env.has("WT_SESSION") { - return TerminalInfo::from_name(TerminalName::WindowsTerminal, None, multiplexer); - } - - if let Some(term) = env.var_non_empty("TERM") { - return TerminalInfo::from_term(term, multiplexer); - } - - TerminalInfo::unknown(multiplexer) -} - -fn detect_multiplexer(env: &dyn Environment) -> Option { - if env.has_non_empty("TMUX") || env.has_non_empty("TMUX_PANE") { - return Some(Multiplexer::Tmux { - version: tmux_version_from_env(env), - }); - } - - if env.has_non_empty("ZELLIJ") - || env.has_non_empty("ZELLIJ_SESSION_NAME") - || env.has_non_empty("ZELLIJ_VERSION") - { - return Some(Multiplexer::Zellij {}); - } - - None -} - -fn is_tmux_term_program(value: &str) -> bool { - value.eq_ignore_ascii_case("tmux") -} - -fn terminal_from_tmux_client_info( - client_info: TmuxClientInfo, - multiplexer: Option, -) -> Option { - let termtype = client_info.termtype.and_then(none_if_whitespace); - let termname = client_info.termname.and_then(none_if_whitespace); - - if let Some(termtype) = termtype.as_ref() { - let (program, version) = split_term_program_and_version(termtype); - let name = terminal_name_from_term_program(&program).unwrap_or(TerminalName::Unknown); - return Some(TerminalInfo::from_term_program_and_term( - name, - program, - version, - termname, - multiplexer, - )); - } - - termname - .as_ref() - .map(|termname| TerminalInfo::from_term(termname.to_string(), multiplexer)) -} - -fn tmux_version_from_env(env: &dyn Environment) -> Option { - let term_program = env.var("TERM_PROGRAM")?; - if !is_tmux_term_program(&term_program) { - return None; - } - - env.var_non_empty("TERM_PROGRAM_VERSION") -} - -fn split_term_program_and_version(value: &str) -> (String, Option) { - let mut parts = value.split_whitespace(); - let program = parts.next().unwrap_or_default().to_string(); - let version = parts.next().map(ToString::to_string); - (program, version) -} - -fn tmux_client_info() -> TmuxClientInfo { - let termtype = tmux_display_message("#{client_termtype}"); - let termname = tmux_display_message("#{client_termname}"); - - TmuxClientInfo { termtype, termname } -} - -fn tmux_display_message(format: &str) -> Option { - let output = std::process::Command::new("tmux") - .args(["display-message", "-p", format]) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let value = String::from_utf8(output.stdout).ok()?; - none_if_whitespace(value.trim().to_string()) -} - -/// Sanitizes a terminal token for use in User-Agent headers. -/// -/// Invalid header characters are replaced with underscores. -fn sanitize_header_value(value: String) -> String { - value.replace(|c| !is_valid_header_value_char(c), "_") -} - -/// Returns whether a character is allowed in User-Agent header values. -fn is_valid_header_value_char(c: char) -> bool { - c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/' -} - -fn terminal_name_from_term_program(value: &str) -> Option { - let normalized: String = value - .trim() - .chars() - .filter(|c| !matches!(c, ' ' | '-' | '_' | '.')) - .map(|c| c.to_ascii_lowercase()) - .collect(); - - match normalized.as_str() { - "appleterminal" => Some(TerminalName::AppleTerminal), - "ghostty" => Some(TerminalName::Ghostty), - "iterm" | "iterm2" | "itermapp" => Some(TerminalName::Iterm2), - "warp" | "warpterminal" => Some(TerminalName::WarpTerminal), - "vscode" => Some(TerminalName::VsCode), - "wezterm" => Some(TerminalName::WezTerm), - "kitty" => Some(TerminalName::Kitty), - "alacritty" => Some(TerminalName::Alacritty), - "konsole" => Some(TerminalName::Konsole), - "gnometerminal" => Some(TerminalName::GnomeTerminal), - "vte" => Some(TerminalName::Vte), - "windowsterminal" => Some(TerminalName::WindowsTerminal), - "dumb" => Some(TerminalName::Dumb), - _ => None, - } -} - -fn format_terminal_version(name: &str, version: &Option) -> String { - match version.as_ref().filter(|value| !value.is_empty()) { - Some(version) => format!("{name}/{version}"), - None => name.to_string(), - } -} - -fn none_if_whitespace(value: String) -> Option { - (!value.trim().is_empty()).then_some(value) -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::collections::HashMap; - - struct FakeEnvironment { - vars: HashMap, - tmux_client_info: TmuxClientInfo, - } - - impl FakeEnvironment { - fn new() -> Self { - Self { - vars: HashMap::new(), - tmux_client_info: TmuxClientInfo::default(), - } - } - - fn with_var(mut self, key: &str, value: &str) -> Self { - self.vars.insert(key.to_string(), value.to_string()); - self - } - - fn with_tmux_client_info(mut self, termtype: Option<&str>, termname: Option<&str>) -> Self { - self.tmux_client_info = TmuxClientInfo { - termtype: termtype.map(ToString::to_string), - termname: termname.map(ToString::to_string), - }; - self - } - } - - impl Environment for FakeEnvironment { - fn var(&self, name: &str) -> Option { - self.vars.get(name).cloned() - } - - fn tmux_client_info(&self) -> TmuxClientInfo { - self.tmux_client_info.clone() - } - } - - fn terminal_info( - name: TerminalName, - term_program: Option<&str>, - version: Option<&str>, - term: Option<&str>, - multiplexer: Option, - ) -> TerminalInfo { - TerminalInfo { - name, - term_program: term_program.map(ToString::to_string), - version: version.map(ToString::to_string), - term: term.map(ToString::to_string), - multiplexer, - } - } - - #[test] - fn detects_term_program() { - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "iTerm.app") - .with_var("TERM_PROGRAM_VERSION", "3.5.0") - .with_var("WEZTERM_VERSION", "2024.2"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Iterm2, - Some("iTerm.app"), - Some("3.5.0"), - None, - None, - ), - "term_program_with_version_info" - ); - assert_eq!( - terminal.user_agent_token(), - "iTerm.app/3.5.0", - "term_program_with_version_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "iTerm.app") - .with_var("TERM_PROGRAM_VERSION", ""); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Iterm2, Some("iTerm.app"), None, None, None), - "term_program_without_version_info" - ); - assert_eq!( - terminal.user_agent_token(), - "iTerm.app", - "term_program_without_version_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "iTerm.app") - .with_var("WEZTERM_VERSION", "2024.2"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Iterm2, Some("iTerm.app"), None, None, None), - "term_program_overrides_wezterm_info" - ); - assert_eq!( - terminal.user_agent_token(), - "iTerm.app", - "term_program_overrides_wezterm_user_agent" - ); - } - - #[test] - fn detects_iterm2() { - let env = FakeEnvironment::new().with_var("ITERM_SESSION_ID", "w0t1p0"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Iterm2, None, None, None, None), - "iterm_session_id_info" - ); - assert_eq!( - terminal.user_agent_token(), - "iTerm.app", - "iterm_session_id_user_agent" - ); - } - - #[test] - fn detects_apple_terminal() { - let env = FakeEnvironment::new().with_var("TERM_PROGRAM", "Apple_Terminal"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::AppleTerminal, - Some("Apple_Terminal"), - None, - None, - None, - ), - "apple_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Apple_Terminal", - "apple_term_program_user_agent" - ); - - let env = FakeEnvironment::new().with_var("TERM_SESSION_ID", "A1B2C3"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::AppleTerminal, None, None, None, None), - "apple_term_session_id_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Apple_Terminal", - "apple_term_session_id_user_agent" - ); - } - - #[test] - fn detects_ghostty() { - let env = FakeEnvironment::new().with_var("TERM_PROGRAM", "Ghostty"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Ghostty, Some("Ghostty"), None, None, None), - "ghostty_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Ghostty", - "ghostty_term_program_user_agent" - ); - } - - #[test] - fn detects_vscode() { - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "vscode") - .with_var("TERM_PROGRAM_VERSION", "1.86.0"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::VsCode, - Some("vscode"), - Some("1.86.0"), - None, - None - ), - "vscode_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "vscode/1.86.0", - "vscode_term_program_user_agent" - ); - } - - #[test] - fn detects_warp_terminal() { - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "WarpTerminal") - .with_var("TERM_PROGRAM_VERSION", "v0.2025.12.10.08.12.stable_03"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::WarpTerminal, - Some("WarpTerminal"), - Some("v0.2025.12.10.08.12.stable_03"), - None, - None, - ), - "warp_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WarpTerminal/v0.2025.12.10.08.12.stable_03", - "warp_term_program_user_agent" - ); - } - - #[test] - fn detects_tmux_multiplexer() { - let env = FakeEnvironment::new() - .with_var("TMUX", "/tmp/tmux-1000/default,123,0") - .with_var("TERM_PROGRAM", "tmux") - .with_tmux_client_info(Some("xterm-256color"), Some("screen-256color")); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Unknown, - Some("xterm-256color"), - None, - Some("screen-256color"), - Some(Multiplexer::Tmux { version: None }), - ), - "tmux_multiplexer_info" - ); - assert_eq!( - terminal.user_agent_token(), - "xterm-256color", - "tmux_multiplexer_user_agent" - ); - } - - #[test] - fn detects_zellij_multiplexer() { - let env = FakeEnvironment::new().with_var("ZELLIJ", "1"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - TerminalInfo { - name: TerminalName::Unknown, - term_program: None, - version: None, - term: None, - multiplexer: Some(Multiplexer::Zellij {}), - }, - "zellij_multiplexer" - ); - } - - #[test] - fn detects_tmux_client_termtype() { - let env = FakeEnvironment::new() - .with_var("TMUX", "/tmp/tmux-1000/default,123,0") - .with_var("TERM_PROGRAM", "tmux") - .with_tmux_client_info(Some("WezTerm"), None); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::WezTerm, - Some("WezTerm"), - None, - None, - Some(Multiplexer::Tmux { version: None }), - ), - "tmux_client_termtype_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WezTerm", - "tmux_client_termtype_user_agent" - ); - } - - #[test] - fn detects_tmux_client_termname() { - let env = FakeEnvironment::new() - .with_var("TMUX", "/tmp/tmux-1000/default,123,0") - .with_var("TERM_PROGRAM", "tmux") - .with_tmux_client_info(None, Some("xterm-256color")); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Unknown, - None, - None, - Some("xterm-256color"), - Some(Multiplexer::Tmux { version: None }) - ), - "tmux_client_termname_info" - ); - assert_eq!( - terminal.user_agent_token(), - "xterm-256color", - "tmux_client_termname_user_agent" - ); - } - - #[test] - fn detects_tmux_term_program_uses_client_termtype() { - let env = FakeEnvironment::new() - .with_var("TMUX", "/tmp/tmux-1000/default,123,0") - .with_var("TERM_PROGRAM", "tmux") - .with_var("TERM_PROGRAM_VERSION", "3.6a") - .with_tmux_client_info(Some("ghostty 1.2.3"), Some("xterm-ghostty")); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Ghostty, - Some("ghostty"), - Some("1.2.3"), - Some("xterm-ghostty"), - Some(Multiplexer::Tmux { - version: Some("3.6a".to_string()), - }), - ), - "tmux_term_program_client_termtype_info" - ); - assert_eq!( - terminal.user_agent_token(), - "ghostty/1.2.3", - "tmux_term_program_client_termtype_user_agent" - ); - } - - #[test] - fn detects_wezterm() { - let env = FakeEnvironment::new().with_var("WEZTERM_VERSION", "2024.2"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::WezTerm, None, Some("2024.2"), None, None), - "wezterm_version_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WezTerm/2024.2", - "wezterm_version_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "WezTerm") - .with_var("TERM_PROGRAM_VERSION", "2024.2"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::WezTerm, - Some("WezTerm"), - Some("2024.2"), - None, - None - ), - "wezterm_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WezTerm/2024.2", - "wezterm_term_program_user_agent" - ); - - let env = FakeEnvironment::new().with_var("WEZTERM_VERSION", ""); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::WezTerm, None, None, None, None), - "wezterm_empty_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WezTerm", - "wezterm_empty_user_agent" - ); - } - - #[test] - fn detects_kitty() { - let env = FakeEnvironment::new().with_var("KITTY_WINDOW_ID", "1"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Kitty, None, None, None, None), - "kitty_window_id_info" - ); - assert_eq!( - terminal.user_agent_token(), - "kitty", - "kitty_window_id_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "kitty") - .with_var("TERM_PROGRAM_VERSION", "0.30.1"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Kitty, - Some("kitty"), - Some("0.30.1"), - None, - None - ), - "kitty_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "kitty/0.30.1", - "kitty_term_program_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM", "xterm-kitty") - .with_var("ALACRITTY_SOCKET", "/tmp/alacritty"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Kitty, None, None, None, None), - "kitty_term_over_alacritty_info" - ); - assert_eq!( - terminal.user_agent_token(), - "kitty", - "kitty_term_over_alacritty_user_agent" - ); - } - - #[test] - fn detects_alacritty() { - let env = FakeEnvironment::new().with_var("ALACRITTY_SOCKET", "/tmp/alacritty"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Alacritty, None, None, None, None), - "alacritty_socket_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Alacritty", - "alacritty_socket_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "Alacritty") - .with_var("TERM_PROGRAM_VERSION", "0.13.2"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Alacritty, - Some("Alacritty"), - Some("0.13.2"), - None, - None, - ), - "alacritty_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Alacritty/0.13.2", - "alacritty_term_program_user_agent" - ); - - let env = FakeEnvironment::new().with_var("TERM", "alacritty"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Alacritty, None, None, None, None), - "alacritty_term_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Alacritty", - "alacritty_term_user_agent" - ); - } - - #[test] - fn detects_konsole() { - let env = FakeEnvironment::new().with_var("KONSOLE_VERSION", "230800"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Konsole, None, Some("230800"), None, None), - "konsole_version_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Konsole/230800", - "konsole_version_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "Konsole") - .with_var("TERM_PROGRAM_VERSION", "230800"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Konsole, - Some("Konsole"), - Some("230800"), - None, - None - ), - "konsole_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Konsole/230800", - "konsole_term_program_user_agent" - ); - - let env = FakeEnvironment::new().with_var("KONSOLE_VERSION", ""); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Konsole, None, None, None, None), - "konsole_empty_info" - ); - assert_eq!( - terminal.user_agent_token(), - "Konsole", - "konsole_empty_user_agent" - ); - } - - #[test] - fn detects_gnome_terminal() { - let env = FakeEnvironment::new().with_var("GNOME_TERMINAL_SCREEN", "1"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::GnomeTerminal, None, None, None, None), - "gnome_terminal_screen_info" - ); - assert_eq!( - terminal.user_agent_token(), - "gnome-terminal", - "gnome_terminal_screen_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "gnome-terminal") - .with_var("TERM_PROGRAM_VERSION", "3.50"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::GnomeTerminal, - Some("gnome-terminal"), - Some("3.50"), - None, - None, - ), - "gnome_terminal_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "gnome-terminal/3.50", - "gnome_terminal_term_program_user_agent" - ); - } - - #[test] - fn detects_vte() { - let env = FakeEnvironment::new().with_var("VTE_VERSION", "7000"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Vte, None, Some("7000"), None, None), - "vte_version_info" - ); - assert_eq!( - terminal.user_agent_token(), - "VTE/7000", - "vte_version_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "VTE") - .with_var("TERM_PROGRAM_VERSION", "7000"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Vte, Some("VTE"), Some("7000"), None, None), - "vte_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "VTE/7000", - "vte_term_program_user_agent" - ); - - let env = FakeEnvironment::new().with_var("VTE_VERSION", ""); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Vte, None, None, None, None), - "vte_empty_info" - ); - assert_eq!(terminal.user_agent_token(), "VTE", "vte_empty_user_agent"); - } - - #[test] - fn detects_windows_terminal() { - let env = FakeEnvironment::new().with_var("WT_SESSION", "1"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::WindowsTerminal, None, None, None, None), - "wt_session_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WindowsTerminal", - "wt_session_user_agent" - ); - - let env = FakeEnvironment::new() - .with_var("TERM_PROGRAM", "WindowsTerminal") - .with_var("TERM_PROGRAM_VERSION", "1.21"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::WindowsTerminal, - Some("WindowsTerminal"), - Some("1.21"), - None, - None, - ), - "windows_terminal_term_program_info" - ); - assert_eq!( - terminal.user_agent_token(), - "WindowsTerminal/1.21", - "windows_terminal_term_program_user_agent" - ); - } - - #[test] - fn detects_term_fallbacks() { - let env = FakeEnvironment::new().with_var("TERM", "xterm-256color"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info( - TerminalName::Unknown, - None, - None, - Some("xterm-256color"), - None, - ), - "term_fallback_info" - ); - assert_eq!( - terminal.user_agent_token(), - "xterm-256color", - "term_fallback_user_agent" - ); - - let env = FakeEnvironment::new().with_var("TERM", "dumb"); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Dumb, None, None, Some("dumb"), None), - "dumb_term_info" - ); - assert_eq!(terminal.user_agent_token(), "dumb", "dumb_term_user_agent"); - - let env = FakeEnvironment::new(); - let terminal = detect_terminal_info_from_env(&env); - assert_eq!( - terminal, - terminal_info(TerminalName::Unknown, None, None, None, None), - "unknown_info" - ); - assert_eq!(terminal.user_agent_token(), "unknown", "unknown_user_agent"); - } -} +pub use codex_user_agent::Multiplexer; +pub use codex_user_agent::TerminalInfo; +pub use codex_user_agent::TerminalName; +pub use codex_user_agent::terminal_info; +pub use codex_user_agent::user_agent; diff --git a/codex-rs/core/src/token_data.rs b/codex-rs/core/src/token_data.rs index 744cc73d9b..105e171a04 100644 --- a/codex-rs/core/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -1,228 +1 @@ -use base64::Engine; -use serde::Deserialize; -use serde::Serialize; -use thiserror::Error; - -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] -pub struct TokenData { - /// Flat info parsed from the JWT in auth.json. - #[serde( - deserialize_with = "deserialize_id_token", - serialize_with = "serialize_id_token" - )] - pub id_token: IdTokenInfo, - - /// This is a JWT. - pub access_token: String, - - pub refresh_token: String, - - pub account_id: Option, -} - -/// Flat subset of useful claims in id_token from auth.json. -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct IdTokenInfo { - pub email: Option, - /// The ChatGPT subscription plan type - /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu"). - /// (Note: values may vary by backend.) - pub(crate) chatgpt_plan_type: Option, - /// ChatGPT user identifier associated with the token, if present. - pub chatgpt_user_id: Option, - /// Organization/workspace identifier associated with the token, if present. - pub chatgpt_account_id: Option, - pub raw_jwt: String, -} - -impl IdTokenInfo { - pub fn get_chatgpt_plan_type(&self) -> Option { - self.chatgpt_plan_type.as_ref().map(|t| match t { - PlanType::Known(plan) => format!("{plan:?}"), - PlanType::Unknown(s) => s.clone(), - }) - } - - pub fn is_workspace_account(&self) -> bool { - matches!( - self.chatgpt_plan_type, - Some(PlanType::Known( - KnownPlan::Team | KnownPlan::Business | KnownPlan::Enterprise | KnownPlan::Edu - )) - ) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged)] -pub(crate) enum PlanType { - Known(KnownPlan), - Unknown(String), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum KnownPlan { - Free, - Plus, - Pro, - Team, - Business, - Enterprise, - Edu, -} - -#[derive(Deserialize)] -struct IdClaims { - #[serde(default)] - email: Option, - #[serde(rename = "https://api.openai.com/auth", default)] - auth: Option, -} - -#[derive(Deserialize)] -struct AuthClaims { - #[serde(default)] - chatgpt_plan_type: Option, - #[serde(default)] - chatgpt_user_id: Option, - #[serde(default)] - user_id: Option, - #[serde(default)] - chatgpt_account_id: Option, -} - -#[derive(Debug, Error)] -pub enum IdTokenInfoError { - #[error("invalid ID token format")] - InvalidFormat, - #[error(transparent)] - Base64(#[from] base64::DecodeError), - #[error(transparent)] - Json(#[from] serde_json::Error), -} - -pub fn parse_id_token(id_token: &str) -> Result { - // JWT format: header.payload.signature - let mut parts = id_token.split('.'); - let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { - (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), - _ => return Err(IdTokenInfoError::InvalidFormat), - }; - - let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?; - let claims: IdClaims = serde_json::from_slice(&payload_bytes)?; - - match claims.auth { - Some(auth) => Ok(IdTokenInfo { - email: claims.email, - raw_jwt: id_token.to_string(), - chatgpt_plan_type: auth.chatgpt_plan_type, - chatgpt_user_id: auth.chatgpt_user_id.or(auth.user_id), - chatgpt_account_id: auth.chatgpt_account_id, - }), - None => Ok(IdTokenInfo { - email: claims.email, - raw_jwt: id_token.to_string(), - chatgpt_plan_type: None, - chatgpt_user_id: None, - chatgpt_account_id: None, - }), - } -} - -fn deserialize_id_token<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - let s = String::deserialize(deserializer)?; - parse_id_token(&s).map_err(serde::de::Error::custom) -} - -fn serialize_id_token(id_token: &IdTokenInfo, serializer: S) -> Result -where - S: serde::Serializer, -{ - serializer.serialize_str(&id_token.raw_jwt) -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use serde::Serialize; - - #[test] - fn id_token_info_parses_email_and_plan() { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = serde_json::json!({ - "email": "user@example.com", - "https://api.openai.com/auth": { - "chatgpt_plan_type": "pro" - } - }); - - fn b64url_no_pad(bytes: &[u8]) -> String { - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) - } - - let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); - let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); - let signature_b64 = b64url_no_pad(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let info = parse_id_token(&fake_jwt).expect("should parse"); - assert_eq!(info.email.as_deref(), Some("user@example.com")); - assert_eq!(info.get_chatgpt_plan_type().as_deref(), Some("Pro")); - } - - #[test] - fn id_token_info_handles_missing_fields() { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = serde_json::json!({ "sub": "123" }); - - fn b64url_no_pad(bytes: &[u8]) -> String { - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) - } - - let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); - let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); - let signature_b64 = b64url_no_pad(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let info = parse_id_token(&fake_jwt).expect("should parse"); - assert!(info.email.is_none()); - assert!(info.get_chatgpt_plan_type().is_none()); - } - - #[test] - fn workspace_account_detection_matches_workspace_plans() { - let workspace = IdTokenInfo { - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Business)), - ..IdTokenInfo::default() - }; - assert_eq!(workspace.is_workspace_account(), true); - - let personal = IdTokenInfo { - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), - ..IdTokenInfo::default() - }; - assert_eq!(personal.is_workspace_account(), false); - } -} +pub use codex_auth::token_data::*; diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index a100f28443..1df6739b26 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -3,7 +3,6 @@ use std::path::PathBuf; use std::time::Duration; use rand::Rng; -use tracing::debug; use tracing::error; const INITIAL_DELAY_MS: u64 = 200; @@ -49,21 +48,6 @@ pub(crate) fn error_or_panic(message: impl std::string::ToString) { } } -pub(crate) fn try_parse_error_message(text: &str) -> String { - debug!("Parsing server error response: {}", text); - let json = serde_json::from_str::(text).unwrap_or_default(); - if let Some(error) = json.get("error") - && let Some(message) = error.get("message") - && let Some(message_str) = message.as_str() - { - return message_str.to_string(); - } - if text.is_empty() { - return "Unknown error".to_string(); - } - text.to_string() -} - pub fn resolve_path(base: &Path, path: &PathBuf) -> PathBuf { if path.is_absolute() { path.clone() @@ -74,37 +58,11 @@ pub fn resolve_path(base: &Path, path: &PathBuf) -> PathBuf { #[cfg(test)] mod tests { - use super::*; - - #[test] - fn test_try_parse_error_message() { - let text = r#"{ - "error": { - "message": "Your refresh token has already been used to generate a new access token. Please try signing in again.", - "type": "invalid_request_error", - "param": null, - "code": "refresh_token_reused" - } -}"#; - let message = try_parse_error_message(text); - assert_eq!( - message, - "Your refresh token has already been used to generate a new access token. Please try signing in again." - ); - } - - #[test] - fn test_try_parse_error_message_no_error() { - let text = r#"{"message": "test"}"#; - let message = try_parse_error_message(text); - assert_eq!(message, r#"{"message": "test"}"#); - } - #[test] fn feedback_tags_macro_compiles() { #[derive(Debug)] struct OnlyDebug; - feedback_tags!(model = "gpt-5", cached = true, debug_only = OnlyDebug); + crate::feedback_tags!(model = "gpt-5", cached = true, debug_only = OnlyDebug); } } diff --git a/codex-rs/user-agent/BUILD.bazel b/codex-rs/user-agent/BUILD.bazel new file mode 100644 index 0000000000..3205a8e74f --- /dev/null +++ b/codex-rs/user-agent/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "user-agent", + crate_name = "codex_user_agent", +) diff --git a/codex-rs/user-agent/Cargo.toml b/codex-rs/user-agent/Cargo.toml new file mode 100644 index 0000000000..549cf88bf3 --- /dev/null +++ b/codex-rs/user-agent/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "codex-user-agent" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +codex-client = { workspace = true } +os_info = { workspace = true } +reqwest = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +core_test_support = { workspace = true } +pretty_assertions = { workspace = true } +regex-lite = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } +wiremock = { workspace = true } diff --git a/codex-rs/user-agent/src/lib.rs b/codex-rs/user-agent/src/lib.rs new file mode 100644 index 0000000000..fca26ab5df --- /dev/null +++ b/codex-rs/user-agent/src/lib.rs @@ -0,0 +1,303 @@ +mod terminal; + +use codex_client::CodexHttpClient; +pub use codex_client::CodexRequestBuilder; +use reqwest::header::HeaderValue; +use std::sync::LazyLock; +use std::sync::Mutex; +use std::sync::RwLock; + +pub use terminal::Multiplexer; +pub use terminal::TerminalInfo; +pub use terminal::TerminalName; +pub use terminal::terminal_info; +pub use terminal::user_agent; + +/// Set this to add a suffix to the User-Agent string. +/// +/// It is not ideal that we're using a global singleton for this. +/// This is primarily designed to differentiate MCP clients from each other. +/// Because there can only be one MCP server per process, it should be safe for this to be a global static. +/// However, future users of this should use this with caution as a result. +/// In addition, we want to be confident that this value is used for ALL clients and doing that requires a +/// lot of wiring and it's easy to miss code paths by doing so. +/// See https://github.com/openai/codex/pull/3388/files for an example of what that would look like. +/// Finally, we want to make sure this is set for ALL mcp clients without needing to know a special env var +/// or having to set data that they already specified in the mcp initialize request somewhere else. +/// +/// A space is automatically added between the suffix and the rest of the User-Agent string. +/// The full user agent string is returned from the mcp initialize response. +/// Parenthesis will be added by Codex. This should only specify what goes inside of the parenthesis. +pub static USER_AGENT_SUFFIX: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; +pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; +const CODEX_SANDBOX_ENV_VAR: &str = "CODEX_SANDBOX"; + +#[derive(Debug, Clone)] +pub struct Originator { + pub value: String, + pub header_value: HeaderValue, +} +static ORIGINATOR: LazyLock>> = LazyLock::new(|| RwLock::new(None)); + +#[derive(Debug)] +pub enum SetOriginatorError { + InvalidHeaderValue, + AlreadyInitialized, +} + +fn get_originator_value(provided: Option) -> Originator { + let value = std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR) + .ok() + .or(provided) + .unwrap_or(DEFAULT_ORIGINATOR.to_string()); + + match HeaderValue::from_str(&value) { + Ok(header_value) => Originator { + value, + header_value, + }, + Err(e) => { + tracing::error!("Unable to turn originator override {value} into header value: {e}"); + Originator { + value: DEFAULT_ORIGINATOR.to_string(), + header_value: HeaderValue::from_static(DEFAULT_ORIGINATOR), + } + } + } +} + +pub fn set_default_originator(value: String) -> Result<(), SetOriginatorError> { + if HeaderValue::from_str(&value).is_err() { + return Err(SetOriginatorError::InvalidHeaderValue); + } + let originator = get_originator_value(Some(value)); + let Ok(mut guard) = ORIGINATOR.write() else { + return Err(SetOriginatorError::AlreadyInitialized); + }; + if guard.is_some() { + return Err(SetOriginatorError::AlreadyInitialized); + } + *guard = Some(originator); + Ok(()) +} + +pub fn originator() -> Originator { + if let Ok(guard) = ORIGINATOR.read() + && let Some(originator) = guard.as_ref() + { + return originator.clone(); + } + + if std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR).is_ok() { + let originator = get_originator_value(None); + if let Ok(mut guard) = ORIGINATOR.write() { + match guard.as_ref() { + Some(originator) => return originator.clone(), + None => *guard = Some(originator.clone()), + } + } + return originator; + } + + get_originator_value(None) +} + +pub fn is_first_party_originator(originator_value: &str) -> bool { + originator_value == DEFAULT_ORIGINATOR + || originator_value == "codex_vscode" + || originator_value.starts_with("Codex ") +} + +pub fn get_codex_user_agent() -> String { + let build_version = env!("CARGO_PKG_VERSION"); + let os_info = os_info::get(); + let originator = originator(); + let prefix = format!( + "{}/{build_version} ({} {}; {}) {}", + originator.value.as_str(), + os_info.os_type(), + os_info.version(), + os_info.architecture().unwrap_or("unknown"), + user_agent() + ); + let suffix = USER_AGENT_SUFFIX + .lock() + .ok() + .and_then(|guard| guard.clone()); + let suffix = suffix + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map_or_else(String::new, |value| format!(" ({value})")); + + let candidate = format!("{prefix}{suffix}"); + sanitize_user_agent(candidate, &prefix) +} + +/// Sanitize the user agent string. +/// +/// Invalid characters are replaced with an underscore. +/// +/// If the user agent fails to parse, it falls back to fallback and then to ORIGINATOR. +fn sanitize_user_agent(candidate: String, fallback: &str) -> String { + if HeaderValue::from_str(candidate.as_str()).is_ok() { + return candidate; + } + + let sanitized: String = candidate + .chars() + .map(|ch| if matches!(ch, ' '..='~') { ch } else { '_' }) + .collect(); + if !sanitized.is_empty() && HeaderValue::from_str(sanitized.as_str()).is_ok() { + tracing::warn!( + "Sanitized Codex user agent because provided suffix contained invalid header characters" + ); + sanitized + } else if HeaderValue::from_str(fallback).is_ok() { + tracing::warn!( + "Falling back to base Codex user agent because provided suffix could not be sanitized" + ); + fallback.to_string() + } else { + tracing::warn!( + "Falling back to default Codex originator because base user agent string is invalid" + ); + originator().value + } +} + +/// Create an HTTP client with default `originator` and `User-Agent` headers set. +pub fn create_client() -> CodexHttpClient { + let inner = build_reqwest_client(); + CodexHttpClient::new(inner) +} + +pub fn build_reqwest_client() -> reqwest::Client { + use reqwest::header::HeaderMap; + + let mut headers = HeaderMap::new(); + headers.insert("originator", originator().header_value); + let ua = get_codex_user_agent(); + + let mut builder = reqwest::Client::builder() + // Set UA via dedicated helper to avoid header validation pitfalls + .user_agent(ua) + .default_headers(headers); + if is_sandboxed() { + builder = builder.no_proxy(); + } + + builder.build().unwrap_or_else(|_| reqwest::Client::new()) +} + +fn is_sandboxed() -> bool { + std::env::var(CODEX_SANDBOX_ENV_VAR).as_deref() == Ok("seatbelt") +} + +#[cfg(test)] +mod tests { + use super::*; + use core_test_support::skip_if_no_network; + use pretty_assertions::assert_eq; + + #[test] + fn test_get_codex_user_agent() { + let user_agent = get_codex_user_agent(); + let originator = originator().value; + let prefix = format!("{originator}/"); + assert!(user_agent.starts_with(&prefix)); + } + + #[test] + fn is_first_party_originator_matches_known_values() { + assert_eq!(is_first_party_originator(DEFAULT_ORIGINATOR), true); + assert_eq!(is_first_party_originator("codex_vscode"), true); + assert_eq!(is_first_party_originator("Codex Something Else"), true); + assert_eq!(is_first_party_originator("codex_cli"), false); + assert_eq!(is_first_party_originator("Other"), false); + } + + #[tokio::test] + async fn test_create_client_sets_default_headers() { + skip_if_no_network!(); + + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let client = create_client(); + + // Spin up a local mock server and capture a request. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let resp = client + .get(server.uri()) + .send() + .await + .expect("failed to send request"); + assert!(resp.status().is_success()); + + let requests = server + .received_requests() + .await + .expect("failed to fetch received requests"); + assert!(!requests.is_empty()); + let headers = &requests[0].headers; + + // originator header is set to the provided value + let originator_header = headers + .get("originator") + .expect("originator header missing"); + assert_eq!(originator_header.to_str().unwrap(), originator().value); + + // User-Agent matches the computed Codex UA for that originator + let expected_ua = get_codex_user_agent(); + let ua_header = headers + .get("user-agent") + .expect("user-agent header missing"); + assert_eq!(ua_header.to_str().unwrap(), expected_ua); + } + + #[test] + fn test_invalid_suffix_is_sanitized() { + let prefix = "codex_cli_rs/0.0.0"; + let suffix = "bad\rsuffix"; + + assert_eq!( + sanitize_user_agent(format!("{prefix} ({suffix})"), prefix), + "codex_cli_rs/0.0.0 (bad_suffix)" + ); + } + + #[test] + fn test_invalid_suffix_is_sanitized2() { + let prefix = "codex_cli_rs/0.0.0"; + let suffix = "bad\0suffix"; + + assert_eq!( + sanitize_user_agent(format!("{prefix} ({suffix})"), prefix), + "codex_cli_rs/0.0.0 (bad_suffix)" + ); + } + + #[test] + #[cfg(target_os = "macos")] + fn test_macos() { + use regex_lite::Regex; + let user_agent = get_codex_user_agent(); + let originator = regex_lite::escape(originator().value.as_str()); + let re = Regex::new(&format!( + r"^{originator}/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$" + )) + .unwrap(); + assert!(re.is_match(&user_agent)); + } +} diff --git a/codex-rs/user-agent/src/terminal.rs b/codex-rs/user-agent/src/terminal.rs new file mode 100644 index 0000000000..8437962f6c --- /dev/null +++ b/codex-rs/user-agent/src/terminal.rs @@ -0,0 +1,1166 @@ +//! Terminal detection utilities. +//! +//! This module feeds terminal metadata into OpenTelemetry user-agent logging and into +//! terminal-specific configuration choices in the TUI. + +use std::sync::OnceLock; + +/// Structured terminal identification data. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TerminalInfo { + /// The detected terminal name category. + pub name: TerminalName, + /// The `TERM_PROGRAM` value when provided by the terminal. + pub term_program: Option, + /// The terminal version string when available. + pub version: Option, + /// The `TERM` value when falling back to capability strings. + pub term: Option, + /// Multiplexer metadata when a terminal multiplexer is active. + pub multiplexer: Option, +} + +/// Known terminal name categories derived from environment variables. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TerminalName { + /// Apple Terminal (Terminal.app). + AppleTerminal, + /// Ghostty terminal emulator. + Ghostty, + /// iTerm2 terminal emulator. + Iterm2, + /// Warp terminal emulator. + WarpTerminal, + /// Visual Studio Code integrated terminal. + VsCode, + /// WezTerm terminal emulator. + WezTerm, + /// kitty terminal emulator. + Kitty, + /// Alacritty terminal emulator. + Alacritty, + /// KDE Konsole terminal emulator. + Konsole, + /// GNOME Terminal emulator. + GnomeTerminal, + /// VTE backend terminal. + Vte, + /// Windows Terminal emulator. + WindowsTerminal, + /// Dumb terminal (TERM=dumb). + Dumb, + /// Unknown or missing terminal identification. + Unknown, +} + +/// Detected terminal multiplexer metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Multiplexer { + /// tmux terminal multiplexer. + Tmux { + /// tmux version string when `TERM_PROGRAM=tmux` is available. + /// + /// This is derived from `TERM_PROGRAM_VERSION`. + version: Option, + }, + /// zellij terminal multiplexer. + Zellij {}, +} + +/// tmux client terminal identification captured via `tmux display-message`. +/// +/// `termtype` corresponds to `#{client_termtype}` and typically reflects the +/// underlying terminal program (for example, `ghostty` or `wezterm`) with an +/// optional version suffix. `termname` comes from `#{client_termname}` and +/// preserves the TERM capability string exposed by the client (for example, +/// `xterm-256color`). +/// +/// This information is only available when running under tmux and lets us +/// attribute the session to the underlying terminal rather than to tmux itself. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct TmuxClientInfo { + termtype: Option, + termname: Option, +} + +impl TerminalInfo { + /// Creates terminal metadata from detected fields. + fn new( + name: TerminalName, + term_program: Option, + version: Option, + term: Option, + multiplexer: Option, + ) -> Self { + Self { + name, + term_program, + version, + term, + multiplexer, + } + } + + /// Creates terminal metadata from a `TERM_PROGRAM` match. + fn from_term_program( + name: TerminalName, + term_program: String, + version: Option, + multiplexer: Option, + ) -> Self { + Self::new(name, Some(term_program), version, None, multiplexer) + } + + /// Creates terminal metadata from a `TERM_PROGRAM` match plus a `TERM` value. + fn from_term_program_and_term( + name: TerminalName, + term_program: String, + version: Option, + term: Option, + multiplexer: Option, + ) -> Self { + Self::new(name, Some(term_program), version, term, multiplexer) + } + + /// Creates terminal metadata from a known terminal name and optional version. + fn from_name( + name: TerminalName, + version: Option, + multiplexer: Option, + ) -> Self { + Self::new(name, None, version, None, multiplexer) + } + + /// Creates terminal metadata from a `TERM` capability value. + fn from_term(term: String, multiplexer: Option) -> Self { + let name = if term == "dumb" { + TerminalName::Dumb + } else { + TerminalName::Unknown + }; + Self::new(name, None, None, Some(term), multiplexer) + } + + /// Creates terminal metadata for unknown terminals. + fn unknown(multiplexer: Option) -> Self { + Self::new(TerminalName::Unknown, None, None, None, multiplexer) + } + + /// Formats the terminal info as a User-Agent token. + fn user_agent_token(&self) -> String { + let raw = if let Some(program) = self.term_program.as_ref() { + match self.version.as_ref().filter(|v| !v.is_empty()) { + Some(version) => format!("{program}/{version}"), + None => program.clone(), + } + } else if let Some(term) = self.term.as_ref().filter(|value| !value.is_empty()) { + term.clone() + } else { + match self.name { + TerminalName::AppleTerminal => { + format_terminal_version("Apple_Terminal", &self.version) + } + TerminalName::Ghostty => format_terminal_version("Ghostty", &self.version), + TerminalName::Iterm2 => format_terminal_version("iTerm.app", &self.version), + TerminalName::WarpTerminal => { + format_terminal_version("WarpTerminal", &self.version) + } + TerminalName::VsCode => format_terminal_version("vscode", &self.version), + TerminalName::WezTerm => format_terminal_version("WezTerm", &self.version), + TerminalName::Kitty => "kitty".to_string(), + TerminalName::Alacritty => "Alacritty".to_string(), + TerminalName::Konsole => format_terminal_version("Konsole", &self.version), + TerminalName::GnomeTerminal => "gnome-terminal".to_string(), + TerminalName::Vte => format_terminal_version("VTE", &self.version), + TerminalName::WindowsTerminal => "WindowsTerminal".to_string(), + TerminalName::Dumb => "dumb".to_string(), + TerminalName::Unknown => "unknown".to_string(), + } + }; + + sanitize_header_value(raw) + } +} + +static TERMINAL_INFO: OnceLock = OnceLock::new(); + +/// Environment variable access used by terminal detection. +/// +/// This trait exists to allow faking the environment in tests. +trait Environment { + /// Returns an environment variable when set. + fn var(&self, name: &str) -> Option; + + /// Returns whether an environment variable is set. + fn has(&self, name: &str) -> bool { + self.var(name).is_some() + } + + /// Returns a non-empty environment variable. + fn var_non_empty(&self, name: &str) -> Option { + self.var(name).and_then(none_if_whitespace) + } + + /// Returns whether an environment variable is set and non-empty. + fn has_non_empty(&self, name: &str) -> bool { + self.var_non_empty(name).is_some() + } + + /// Returns tmux client details when available. + fn tmux_client_info(&self) -> TmuxClientInfo; +} + +/// Reads environment variables from the running process. +struct ProcessEnvironment; + +impl Environment for ProcessEnvironment { + fn var(&self, name: &str) -> Option { + match std::env::var(name) { + Ok(value) => Some(value), + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { + tracing::warn!("failed to read env var {name}: value not valid UTF-8"); + None + } + } + } + + fn tmux_client_info(&self) -> TmuxClientInfo { + tmux_client_info() + } +} + +/// Returns a sanitized terminal identifier for User-Agent strings. +pub fn user_agent() -> String { + terminal_info().user_agent_token() +} + +/// Returns structured terminal metadata for the current process. +pub fn terminal_info() -> TerminalInfo { + TERMINAL_INFO + .get_or_init(|| detect_terminal_info_from_env(&ProcessEnvironment)) + .clone() +} + +/// Detects structured terminal metadata from an injectable environment. +/// +/// Detection order favors explicit identifiers before falling back to capability strings: +/// - If `TERM_PROGRAM=tmux`, the tmux client term type/name are used instead. The client term +/// type is split on whitespace to extract a program name plus optional version (for example, +/// `ghostty 1.2.3`), while the client term name becomes the `TERM` capability string. +/// - Otherwise, `TERM_PROGRAM` (plus `TERM_PROGRAM_VERSION`) drives the detected terminal name. +/// - Next, terminal-specific variables (WEZTERM, iTerm2, Apple Terminal, kitty, etc.) are checked. +/// - Finally, `TERM` is used as the capability fallback with `TerminalName::Unknown`. +/// +/// tmux client term info is only consulted when a tmux multiplexer is detected, and it is +/// derived from `tmux display-message` to surface the underlying terminal program instead of +/// reporting tmux itself. +fn detect_terminal_info_from_env(env: &dyn Environment) -> TerminalInfo { + let multiplexer = detect_multiplexer(env); + + if let Some(term_program) = env.var_non_empty("TERM_PROGRAM") { + if is_tmux_term_program(&term_program) + && matches!(multiplexer, Some(Multiplexer::Tmux { .. })) + && let Some(terminal) = + terminal_from_tmux_client_info(env.tmux_client_info(), multiplexer.clone()) + { + return terminal; + } + + let version = env.var_non_empty("TERM_PROGRAM_VERSION"); + let name = terminal_name_from_term_program(&term_program).unwrap_or(TerminalName::Unknown); + return TerminalInfo::from_term_program(name, term_program, version, multiplexer); + } + + if env.has("WEZTERM_VERSION") { + let version = env.var_non_empty("WEZTERM_VERSION"); + return TerminalInfo::from_name(TerminalName::WezTerm, version, multiplexer); + } + + if env.has("ITERM_SESSION_ID") || env.has("ITERM_PROFILE") || env.has("ITERM_PROFILE_NAME") { + return TerminalInfo::from_name(TerminalName::Iterm2, None, multiplexer); + } + + if env.has("TERM_SESSION_ID") { + return TerminalInfo::from_name(TerminalName::AppleTerminal, None, multiplexer); + } + + if env.has("KITTY_WINDOW_ID") + || env + .var("TERM") + .map(|term| term.contains("kitty")) + .unwrap_or(false) + { + return TerminalInfo::from_name(TerminalName::Kitty, None, multiplexer); + } + + if env.has("ALACRITTY_SOCKET") + || env + .var("TERM") + .map(|term| term == "alacritty") + .unwrap_or(false) + { + return TerminalInfo::from_name(TerminalName::Alacritty, None, multiplexer); + } + + if env.has("KONSOLE_VERSION") { + let version = env.var_non_empty("KONSOLE_VERSION"); + return TerminalInfo::from_name(TerminalName::Konsole, version, multiplexer); + } + + if env.has("GNOME_TERMINAL_SCREEN") { + return TerminalInfo::from_name(TerminalName::GnomeTerminal, None, multiplexer); + } + + if env.has("VTE_VERSION") { + let version = env.var_non_empty("VTE_VERSION"); + return TerminalInfo::from_name(TerminalName::Vte, version, multiplexer); + } + + if env.has("WT_SESSION") { + return TerminalInfo::from_name(TerminalName::WindowsTerminal, None, multiplexer); + } + + if let Some(term) = env.var_non_empty("TERM") { + return TerminalInfo::from_term(term, multiplexer); + } + + TerminalInfo::unknown(multiplexer) +} + +fn detect_multiplexer(env: &dyn Environment) -> Option { + if env.has_non_empty("TMUX") || env.has_non_empty("TMUX_PANE") { + return Some(Multiplexer::Tmux { + version: tmux_version_from_env(env), + }); + } + + if env.has_non_empty("ZELLIJ") + || env.has_non_empty("ZELLIJ_SESSION_NAME") + || env.has_non_empty("ZELLIJ_VERSION") + { + return Some(Multiplexer::Zellij {}); + } + + None +} + +fn is_tmux_term_program(value: &str) -> bool { + value.eq_ignore_ascii_case("tmux") +} + +fn terminal_from_tmux_client_info( + client_info: TmuxClientInfo, + multiplexer: Option, +) -> Option { + let termtype = client_info.termtype.and_then(none_if_whitespace); + let termname = client_info.termname.and_then(none_if_whitespace); + + if let Some(termtype) = termtype.as_ref() { + let (program, version) = split_term_program_and_version(termtype); + let name = terminal_name_from_term_program(&program).unwrap_or(TerminalName::Unknown); + return Some(TerminalInfo::from_term_program_and_term( + name, + program, + version, + termname, + multiplexer, + )); + } + + termname + .as_ref() + .map(|termname| TerminalInfo::from_term(termname.to_string(), multiplexer)) +} + +fn tmux_version_from_env(env: &dyn Environment) -> Option { + let term_program = env.var("TERM_PROGRAM")?; + if !is_tmux_term_program(&term_program) { + return None; + } + + env.var_non_empty("TERM_PROGRAM_VERSION") +} + +fn split_term_program_and_version(value: &str) -> (String, Option) { + let mut parts = value.split_whitespace(); + let program = parts.next().unwrap_or_default().to_string(); + let version = parts.next().map(ToString::to_string); + (program, version) +} + +fn tmux_client_info() -> TmuxClientInfo { + let termtype = tmux_display_message("#{client_termtype}"); + let termname = tmux_display_message("#{client_termname}"); + + TmuxClientInfo { termtype, termname } +} + +fn tmux_display_message(format: &str) -> Option { + let output = std::process::Command::new("tmux") + .args(["display-message", "-p", format]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let value = String::from_utf8(output.stdout).ok()?; + none_if_whitespace(value.trim().to_string()) +} + +/// Sanitizes a terminal token for use in User-Agent headers. +/// +/// Invalid header characters are replaced with underscores. +fn sanitize_header_value(value: String) -> String { + value.replace(|c| !is_valid_header_value_char(c), "_") +} + +/// Returns whether a character is allowed in User-Agent header values. +fn is_valid_header_value_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/' +} + +fn terminal_name_from_term_program(value: &str) -> Option { + let normalized: String = value + .trim() + .chars() + .filter(|c| !matches!(c, ' ' | '-' | '_' | '.')) + .map(|c| c.to_ascii_lowercase()) + .collect(); + + match normalized.as_str() { + "appleterminal" => Some(TerminalName::AppleTerminal), + "ghostty" => Some(TerminalName::Ghostty), + "iterm" | "iterm2" | "itermapp" => Some(TerminalName::Iterm2), + "warp" | "warpterminal" => Some(TerminalName::WarpTerminal), + "vscode" => Some(TerminalName::VsCode), + "wezterm" => Some(TerminalName::WezTerm), + "kitty" => Some(TerminalName::Kitty), + "alacritty" => Some(TerminalName::Alacritty), + "konsole" => Some(TerminalName::Konsole), + "gnometerminal" => Some(TerminalName::GnomeTerminal), + "vte" => Some(TerminalName::Vte), + "windowsterminal" => Some(TerminalName::WindowsTerminal), + "dumb" => Some(TerminalName::Dumb), + _ => None, + } +} + +fn format_terminal_version(name: &str, version: &Option) -> String { + match version.as_ref().filter(|value| !value.is_empty()) { + Some(version) => format!("{name}/{version}"), + None => name.to_string(), + } +} + +fn none_if_whitespace(value: String) -> Option { + (!value.trim().is_empty()).then_some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::collections::HashMap; + + struct FakeEnvironment { + vars: HashMap, + tmux_client_info: TmuxClientInfo, + } + + impl FakeEnvironment { + fn new() -> Self { + Self { + vars: HashMap::new(), + tmux_client_info: TmuxClientInfo::default(), + } + } + + fn with_var(mut self, key: &str, value: &str) -> Self { + self.vars.insert(key.to_string(), value.to_string()); + self + } + + fn with_tmux_client_info(mut self, termtype: Option<&str>, termname: Option<&str>) -> Self { + self.tmux_client_info = TmuxClientInfo { + termtype: termtype.map(ToString::to_string), + termname: termname.map(ToString::to_string), + }; + self + } + } + + impl Environment for FakeEnvironment { + fn var(&self, name: &str) -> Option { + self.vars.get(name).cloned() + } + + fn tmux_client_info(&self) -> TmuxClientInfo { + self.tmux_client_info.clone() + } + } + + fn terminal_info( + name: TerminalName, + term_program: Option<&str>, + version: Option<&str>, + term: Option<&str>, + multiplexer: Option, + ) -> TerminalInfo { + TerminalInfo { + name, + term_program: term_program.map(ToString::to_string), + version: version.map(ToString::to_string), + term: term.map(ToString::to_string), + multiplexer, + } + } + + #[test] + fn detects_term_program() { + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "iTerm.app") + .with_var("TERM_PROGRAM_VERSION", "3.5.0") + .with_var("WEZTERM_VERSION", "2024.2"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Iterm2, + Some("iTerm.app"), + Some("3.5.0"), + None, + None, + ), + "term_program_with_version_info" + ); + assert_eq!( + terminal.user_agent_token(), + "iTerm.app/3.5.0", + "term_program_with_version_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "iTerm.app") + .with_var("TERM_PROGRAM_VERSION", ""); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Iterm2, Some("iTerm.app"), None, None, None), + "term_program_without_version_info" + ); + assert_eq!( + terminal.user_agent_token(), + "iTerm.app", + "term_program_without_version_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "iTerm.app") + .with_var("WEZTERM_VERSION", "2024.2"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Iterm2, Some("iTerm.app"), None, None, None), + "term_program_overrides_wezterm_info" + ); + assert_eq!( + terminal.user_agent_token(), + "iTerm.app", + "term_program_overrides_wezterm_user_agent" + ); + } + + #[test] + fn detects_iterm2() { + let env = FakeEnvironment::new().with_var("ITERM_SESSION_ID", "w0t1p0"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Iterm2, None, None, None, None), + "iterm_session_id_info" + ); + assert_eq!( + terminal.user_agent_token(), + "iTerm.app", + "iterm_session_id_user_agent" + ); + } + + #[test] + fn detects_apple_terminal() { + let env = FakeEnvironment::new().with_var("TERM_PROGRAM", "Apple_Terminal"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::AppleTerminal, + Some("Apple_Terminal"), + None, + None, + None, + ), + "apple_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Apple_Terminal", + "apple_term_program_user_agent" + ); + + let env = FakeEnvironment::new().with_var("TERM_SESSION_ID", "A1B2C3"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::AppleTerminal, None, None, None, None), + "apple_term_session_id_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Apple_Terminal", + "apple_term_session_id_user_agent" + ); + } + + #[test] + fn detects_ghostty() { + let env = FakeEnvironment::new().with_var("TERM_PROGRAM", "Ghostty"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Ghostty, Some("Ghostty"), None, None, None), + "ghostty_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Ghostty", + "ghostty_term_program_user_agent" + ); + } + + #[test] + fn detects_vscode() { + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "vscode") + .with_var("TERM_PROGRAM_VERSION", "1.86.0"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::VsCode, + Some("vscode"), + Some("1.86.0"), + None, + None + ), + "vscode_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "vscode/1.86.0", + "vscode_term_program_user_agent" + ); + } + + #[test] + fn detects_warp_terminal() { + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "WarpTerminal") + .with_var("TERM_PROGRAM_VERSION", "v0.2025.12.10.08.12.stable_03"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::WarpTerminal, + Some("WarpTerminal"), + Some("v0.2025.12.10.08.12.stable_03"), + None, + None, + ), + "warp_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WarpTerminal/v0.2025.12.10.08.12.stable_03", + "warp_term_program_user_agent" + ); + } + + #[test] + fn detects_tmux_multiplexer() { + let env = FakeEnvironment::new() + .with_var("TMUX", "/tmp/tmux-1000/default,123,0") + .with_var("TERM_PROGRAM", "tmux") + .with_tmux_client_info(Some("xterm-256color"), Some("screen-256color")); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Unknown, + Some("xterm-256color"), + None, + Some("screen-256color"), + Some(Multiplexer::Tmux { version: None }), + ), + "tmux_multiplexer_info" + ); + assert_eq!( + terminal.user_agent_token(), + "xterm-256color", + "tmux_multiplexer_user_agent" + ); + } + + #[test] + fn detects_zellij_multiplexer() { + let env = FakeEnvironment::new().with_var("ZELLIJ", "1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + TerminalInfo { + name: TerminalName::Unknown, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Zellij {}), + }, + "zellij_multiplexer" + ); + } + + #[test] + fn detects_tmux_client_termtype() { + let env = FakeEnvironment::new() + .with_var("TMUX", "/tmp/tmux-1000/default,123,0") + .with_var("TERM_PROGRAM", "tmux") + .with_tmux_client_info(Some("WezTerm"), None); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::WezTerm, + Some("WezTerm"), + None, + None, + Some(Multiplexer::Tmux { version: None }), + ), + "tmux_client_termtype_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WezTerm", + "tmux_client_termtype_user_agent" + ); + } + + #[test] + fn detects_tmux_client_termname() { + let env = FakeEnvironment::new() + .with_var("TMUX", "/tmp/tmux-1000/default,123,0") + .with_var("TERM_PROGRAM", "tmux") + .with_tmux_client_info(None, Some("xterm-256color")); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Unknown, + None, + None, + Some("xterm-256color"), + Some(Multiplexer::Tmux { version: None }) + ), + "tmux_client_termname_info" + ); + assert_eq!( + terminal.user_agent_token(), + "xterm-256color", + "tmux_client_termname_user_agent" + ); + } + + #[test] + fn detects_tmux_term_program_uses_client_termtype() { + let env = FakeEnvironment::new() + .with_var("TMUX", "/tmp/tmux-1000/default,123,0") + .with_var("TERM_PROGRAM", "tmux") + .with_var("TERM_PROGRAM_VERSION", "3.6a") + .with_tmux_client_info(Some("ghostty 1.2.3"), Some("xterm-ghostty")); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Ghostty, + Some("ghostty"), + Some("1.2.3"), + Some("xterm-ghostty"), + Some(Multiplexer::Tmux { + version: Some("3.6a".to_string()), + }), + ), + "tmux_term_program_client_termtype_info" + ); + assert_eq!( + terminal.user_agent_token(), + "ghostty/1.2.3", + "tmux_term_program_client_termtype_user_agent" + ); + } + + #[test] + fn detects_wezterm() { + let env = FakeEnvironment::new().with_var("WEZTERM_VERSION", "2024.2"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::WezTerm, None, Some("2024.2"), None, None), + "wezterm_version_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WezTerm/2024.2", + "wezterm_version_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "WezTerm") + .with_var("TERM_PROGRAM_VERSION", "2024.2"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::WezTerm, + Some("WezTerm"), + Some("2024.2"), + None, + None + ), + "wezterm_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WezTerm/2024.2", + "wezterm_term_program_user_agent" + ); + + let env = FakeEnvironment::new().with_var("WEZTERM_VERSION", ""); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::WezTerm, None, None, None, None), + "wezterm_empty_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WezTerm", + "wezterm_empty_user_agent" + ); + } + + #[test] + fn detects_kitty() { + let env = FakeEnvironment::new().with_var("KITTY_WINDOW_ID", "1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Kitty, None, None, None, None), + "kitty_window_id_info" + ); + assert_eq!( + terminal.user_agent_token(), + "kitty", + "kitty_window_id_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "kitty") + .with_var("TERM_PROGRAM_VERSION", "0.30.1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Kitty, + Some("kitty"), + Some("0.30.1"), + None, + None + ), + "kitty_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "kitty/0.30.1", + "kitty_term_program_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM", "xterm-kitty") + .with_var("ALACRITTY_SOCKET", "/tmp/alacritty"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Kitty, None, None, None, None), + "kitty_term_over_alacritty_info" + ); + assert_eq!( + terminal.user_agent_token(), + "kitty", + "kitty_term_over_alacritty_user_agent" + ); + } + + #[test] + fn detects_alacritty() { + let env = FakeEnvironment::new().with_var("ALACRITTY_SOCKET", "/tmp/alacritty"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Alacritty, None, None, None, None), + "alacritty_socket_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Alacritty", + "alacritty_socket_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "Alacritty") + .with_var("TERM_PROGRAM_VERSION", "0.13.2"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Alacritty, + Some("Alacritty"), + Some("0.13.2"), + None, + None, + ), + "alacritty_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Alacritty/0.13.2", + "alacritty_term_program_user_agent" + ); + + let env = FakeEnvironment::new().with_var("TERM", "alacritty"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Alacritty, None, None, None, None), + "alacritty_term_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Alacritty", + "alacritty_term_user_agent" + ); + } + + #[test] + fn detects_konsole() { + let env = FakeEnvironment::new().with_var("KONSOLE_VERSION", "230800"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Konsole, None, Some("230800"), None, None), + "konsole_version_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Konsole/230800", + "konsole_version_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "Konsole") + .with_var("TERM_PROGRAM_VERSION", "230800"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Konsole, + Some("Konsole"), + Some("230800"), + None, + None + ), + "konsole_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Konsole/230800", + "konsole_term_program_user_agent" + ); + + let env = FakeEnvironment::new().with_var("KONSOLE_VERSION", ""); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Konsole, None, None, None, None), + "konsole_empty_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Konsole", + "konsole_empty_user_agent" + ); + } + + #[test] + fn detects_gnome_terminal() { + let env = FakeEnvironment::new().with_var("GNOME_TERMINAL_SCREEN", "1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::GnomeTerminal, None, None, None, None), + "gnome_terminal_screen_info" + ); + assert_eq!( + terminal.user_agent_token(), + "gnome-terminal", + "gnome_terminal_screen_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "gnome-terminal") + .with_var("TERM_PROGRAM_VERSION", "3.50"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::GnomeTerminal, + Some("gnome-terminal"), + Some("3.50"), + None, + None, + ), + "gnome_terminal_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "gnome-terminal/3.50", + "gnome_terminal_term_program_user_agent" + ); + } + + #[test] + fn detects_vte() { + let env = FakeEnvironment::new().with_var("VTE_VERSION", "7000"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Vte, None, Some("7000"), None, None), + "vte_version_info" + ); + assert_eq!( + terminal.user_agent_token(), + "VTE/7000", + "vte_version_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "VTE") + .with_var("TERM_PROGRAM_VERSION", "7000"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Vte, Some("VTE"), Some("7000"), None, None), + "vte_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "VTE/7000", + "vte_term_program_user_agent" + ); + + let env = FakeEnvironment::new().with_var("VTE_VERSION", ""); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Vte, None, None, None, None), + "vte_empty_info" + ); + assert_eq!(terminal.user_agent_token(), "VTE", "vte_empty_user_agent"); + } + + #[test] + fn detects_windows_terminal() { + let env = FakeEnvironment::new().with_var("WT_SESSION", "1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::WindowsTerminal, None, None, None, None), + "wt_session_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WindowsTerminal", + "wt_session_user_agent" + ); + + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "WindowsTerminal") + .with_var("TERM_PROGRAM_VERSION", "1.21"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::WindowsTerminal, + Some("WindowsTerminal"), + Some("1.21"), + None, + None, + ), + "windows_terminal_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "WindowsTerminal/1.21", + "windows_terminal_term_program_user_agent" + ); + } + + #[test] + fn detects_term_fallbacks() { + let env = FakeEnvironment::new().with_var("TERM", "xterm-256color"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Unknown, + None, + None, + Some("xterm-256color"), + None, + ), + "term_fallback_info" + ); + assert_eq!( + terminal.user_agent_token(), + "xterm-256color", + "term_fallback_user_agent" + ); + + let env = FakeEnvironment::new().with_var("TERM", "dumb"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Dumb, None, None, Some("dumb"), None), + "dumb_term_info" + ); + assert_eq!(terminal.user_agent_token(), "dumb", "dumb_term_user_agent"); + + let env = FakeEnvironment::new(); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Unknown, None, None, None, None), + "unknown_info" + ); + assert_eq!(terminal.user_agent_token(), "unknown", "unknown_user_agent"); + } +}