Skip to main content

lean_ctx/core/
host_env.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Environment markers as the host that started this process sees them.
4//!
5//! Some AI clients start MCP servers with an allowlisted environment: Codex
6//! passes only `HOME`, `PATH`, `USER`, `LANG` and a few more. A server started
7//! that way cannot see `CI`, `DO_NOT_TRACK` or `LEAN_CTX_TELEMETRY`, so a CI
8//! job reported as a local machine and an opt-out set in the shell did not
9//! reach the server. On Linux the nearest ancestor that sets one of the
10//! `HOST_KEYS` supplies it when this process has none. Only those keys are
11//! kept; the rest of an ancestor's environment is never stored.
12
13use std::collections::HashMap;
14use std::sync::OnceLock;
15
16/// Keys looked up in ancestors: consent, CI and runtime-environment markers.
17#[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    // Client markers for the aggregate's `client_family`.
45    "CLAUDECODE",
46    "CODEX_HOME",
47    "CURSOR_TRACE_ID",
48    "GEMINI_CLI",
49];
50
51/// How many ancestors are read: client, its shell, the job runner, its init.
52#[cfg(all(target_os = "linux", not(test)))]
53const MAX_ANCESTORS: usize = 4;
54
55/// `key` from this process, else from the nearest ancestor for `HOST_KEYS`.
56/// A key this process sets, even to an empty value, is never overridden.
57#[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/// Whether stdin is an interactive terminal, without ever blocking.
66///
67/// On Windows `std`'s `is_terminal` asks a non-console handle for its file
68/// name (MSYS pty detection). When stdin is a synchronous pipe that the
69/// parent is reading from at the same time — PowerShell's `Start-Job` hands
70/// its remoting pipe to every child — that query waits for the parent's
71/// read, so `lean-ctx --version` hung forever under `Start-Process -Wait`
72/// (#2176 VM). Here a console counts on Windows (a character device with a
73/// console mode). An MSYS/Cygwin pty is a pipe: only inside such a terminal
74/// (`MSYSTEM` or `TERM` set — PowerShell and cmd set neither) is `std`'s
75/// name-based pty check asked, so Git Bash prompts stay interactive while a
76/// plain pipe is never queried.
77#[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        // SAFETY: GetFileType and GetConsoleMode only inspect the handle
89        // (neither waits on a pipe's pending I/O, verified on Windows Server
90        // 2025); `mode` is a valid out-pointer.
91        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/// Running inside an MSYS2/Git Bash/Cygwin terminal, where stdin may be a
105/// pty pipe that only `std`'s name check recognises.
106#[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/// Unit tests read their own environment only, so the developer's or the CI
117/// runner's shell never changes a result.
118#[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/// Adds the [`HOST_KEYS`] from a NUL-separated `environ` that `found` does not
147/// hold yet, so the nearest ancestor wins.
148#[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/// The parent PID from `/proc/<pid>/stat`. The command name may contain spaces
164/// and parentheses, so the fields are read after its closing parenthesis.
165#[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    /// The aggregate's `client_family` reads these through `var`, so a client
209    /// that filters its MCP server's environment is still recognised.
210    #[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}