lean_ctx/core/
host_env.rs1use std::collections::HashMap;
14use std::sync::OnceLock;
15
16#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
18const HOST_KEYS: &[&str] = &[
19 "DO_NOT_TRACK",
20 "LEAN_CTX_TELEMETRY",
21 "LEAN_CTX_TELEMETRY_IN_CI",
22 "CI",
23 "GITHUB_ACTIONS",
24 "GITLAB_CI",
25 "BUILDKITE",
26 "CIRCLECI",
27 "TRAVIS",
28 "JENKINS_URL",
29 "TF_BUILD",
30 "TEAMCITY_VERSION",
31 "BITBUCKET_BUILD_NUMBER",
32 "CODEBUILD_BUILD_ID",
33 "DRONE",
34 "APPVEYOR",
35 "SEMAPHORE",
36 "HEROKU_TEST_RUN_ID",
37 "CONTINUOUS_INTEGRATION",
38 "CODESPACES",
39 "GITPOD_WORKSPACE_ID",
40 "REPL_ID",
41 "CLAUDE_CODE_REMOTE",
42 "KUBERNETES_SERVICE_HOST",
43 "container",
44 "CLAUDECODE",
46 "CODEX_HOME",
47 "CURSOR_TRACE_ID",
48 "GEMINI_CLI",
49];
50
51#[cfg(all(target_os = "linux", not(test)))]
53const MAX_ANCESTORS: usize = 4;
54
55#[must_use]
58pub fn var(key: &str) -> Option<String> {
59 match std::env::var(key) {
60 Ok(value) => Some(value),
61 Err(_) => ancestors().get(key).cloned(),
62 }
63}
64
65#[must_use]
78pub fn stdin_is_terminal() -> bool {
79 #[cfg(windows)]
80 {
81 use std::os::windows::io::AsRawHandle;
82 use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_CHAR, GetFileType};
83 use windows_sys::Win32::System::Console::GetConsoleMode;
84 let handle = std::io::stdin().as_raw_handle();
85 if handle.is_null() || handle as isize == -1 {
86 return false;
87 }
88 let console = unsafe {
92 let mut mode = 0_u32;
93 GetFileType(handle) == FILE_TYPE_CHAR && GetConsoleMode(handle, &raw mut mode) != 0
94 };
95 console || (inside_posix_terminal() && std::io::IsTerminal::is_terminal(&std::io::stdin()))
96 }
97 #[cfg(not(windows))]
98 {
99 use std::io::IsTerminal as _;
100 std::io::stdin().is_terminal()
101 }
102}
103
104#[cfg(windows)]
107fn inside_posix_terminal() -> bool {
108 std::env::var_os("MSYSTEM").is_some() || std::env::var_os("TERM").is_some()
109}
110
111fn ancestors() -> &'static HashMap<&'static str, String> {
112 static ANCESTORS: OnceLock<HashMap<&'static str, String>> = OnceLock::new();
113 ANCESTORS.get_or_init(read_ancestors)
114}
115
116#[cfg(any(test, not(target_os = "linux")))]
119fn read_ancestors() -> HashMap<&'static str, String> {
120 HashMap::new()
121}
122
123#[cfg(all(target_os = "linux", not(test)))]
124fn read_ancestors() -> HashMap<&'static str, String> {
125 let mut found = HashMap::new();
126 let mut pid = std::os::unix::process::parent_id();
127 for _ in 0..MAX_ANCESTORS {
128 if pid <= 1 {
129 break;
130 }
131 if let Ok(environ) = std::fs::read(format!("/proc/{pid}/environ")) {
132 collect_host_keys(&environ, &mut found);
133 }
134 match std::fs::read_to_string(format!("/proc/{pid}/stat"))
135 .ok()
136 .as_deref()
137 .and_then(parent_pid)
138 {
139 Some(parent) => pid = parent,
140 None => break,
141 }
142 }
143 found
144}
145
146#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
149fn collect_host_keys(environ: &[u8], found: &mut HashMap<&'static str, String>) {
150 for entry in environ.split(|byte| *byte == 0) {
151 let Some(separator) = entry.iter().position(|byte| *byte == b'=') else {
152 continue;
153 };
154 let (name, value) = (&entry[..separator], &entry[separator + 1..]);
155 if let Some(key) = HOST_KEYS.iter().find(|key| key.as_bytes() == name) {
156 found
157 .entry(key)
158 .or_insert_with(|| String::from_utf8_lossy(value).into_owned());
159 }
160 }
161}
162
163#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
166fn parent_pid(stat: &str) -> Option<u32> {
167 stat.rsplit_once(')')?
168 .1
169 .split_whitespace()
170 .nth(1)?
171 .parse()
172 .ok()
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn only_host_keys_are_kept_and_the_nearest_ancestor_wins() {
181 let mut found = HashMap::new();
182 collect_host_keys(
183 b"GITHUB_TOKEN=secret\0CI=true\0DO_NOT_TRACK=1\0container=podman\0NOEQUALS\0",
184 &mut found,
185 );
186 collect_host_keys(b"CI=false\0GITLAB_CI=true\0", &mut found);
187 assert_eq!(found.get("CI").map(String::as_str), Some("true"));
188 assert_eq!(found.get("DO_NOT_TRACK").map(String::as_str), Some("1"));
189 assert_eq!(found.get("container").map(String::as_str), Some("podman"));
190 assert_eq!(found.get("GITLAB_CI").map(String::as_str), Some("true"));
191 assert_eq!(found.len(), 4, "{found:?}");
192 }
193
194 #[test]
195 fn parent_pid_survives_spaces_and_parentheses_in_the_command_name() {
196 assert_eq!(parent_pid("4242 (codex) S 17 4242 4242 0"), Some(17));
197 assert_eq!(parent_pid("4242 (my (odd) cmd) R 99 1 1"), Some(99));
198 assert_eq!(parent_pid("garbage"), None);
199 }
200
201 #[test]
202 fn every_ci_marker_is_a_host_key() {
203 for marker in crate::core::telemetry_consent::CI_MARKERS {
204 assert!(HOST_KEYS.contains(marker), "{marker}");
205 }
206 }
207
208 #[test]
211 fn every_client_marker_is_a_host_key() {
212 for marker in ["CLAUDECODE", "CODEX_HOME", "CURSOR_TRACE_ID", "GEMINI_CLI"] {
213 assert!(HOST_KEYS.contains(&marker), "{marker}");
214 }
215 }
216}