Skip to main content

lean_ctx/core/
agent_runtime_env.rs

1//! Bridge agent runtime/session environment variables across lean-ctx processes.
2//!
3//! The lean-ctx MCP server is a long-lived child of the agent host. Some agents
4//! (notably Codex) expose runtime/session variables such as `CODEX_THREAD_ID`
5//! only in the *native agent shell* environment, not in the MCP server process
6//! (#370). `ctx_shell` runs inside the MCP server, so it cannot forward those
7//! variables by reading its own `std::env`.
8//!
9//! Short-lived lean-ctx processes that *do* run inside the agent environment —
10//! the hook handlers (`lean-ctx hook …`) and the `lean-ctx -c` shell wrapper —
11//! [`capture`] the relevant variables into a small file in the data dir. The MCP
12//! server then [`load`]s them when constructing the child environment for
13//! `ctx_shell` (see `crate::server::execute`).
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18/// Env var name prefixes identifying agent runtime/session state worth forwarding
19/// to `ctx_shell` child processes.
20pub const FORWARD_PREFIXES: &[&str] = &["CODEX_", "CLAUDE_", "OPENCODE_", "HERMES_", "GEMINI_"];
21
22const FILE_NAME: &str = "agent_runtime_env.json";
23
24/// Captured variables older than this are ignored: a stale session/thread id is
25/// worse than forwarding none, and a fresh session re-captures on its first hook.
26const TTL_SECS: u64 = 7_200;
27
28/// Whether `key` is an agent runtime variable lean-ctx forwards to child shells.
29#[must_use]
30pub fn is_forwardable(key: &str) -> bool {
31    FORWARD_PREFIXES
32        .iter()
33        .any(|prefix| key.starts_with(prefix))
34}
35
36fn store_path() -> Option<PathBuf> {
37    crate::core::data_dir::lean_ctx_data_dir()
38        .ok()
39        .map(|dir| dir.join(FILE_NAME))
40}
41
42fn now_secs() -> u64 {
43    std::time::SystemTime::now()
44        .duration_since(std::time::UNIX_EPOCH)
45        .unwrap_or_default()
46        .as_secs()
47}
48
49/// Forwardable variables present in the current process environment.
50#[must_use]
51pub fn collect_from_process() -> BTreeMap<String, String> {
52    std::env::vars()
53        .filter(|(key, _)| is_forwardable(key))
54        .collect()
55}
56
57fn read_store(path: &Path) -> Option<(BTreeMap<String, String>, u64)> {
58    let content = std::fs::read_to_string(path).ok()?;
59    let value: serde_json::Value = serde_json::from_str(&content).ok()?;
60    let captured_at = value
61        .get("captured_at")
62        .and_then(serde_json::Value::as_u64)?;
63    let vars = value
64        .get("vars")
65        .and_then(serde_json::Value::as_object)?
66        .iter()
67        .filter_map(|(key, val)| val.as_str().map(|s| (key.clone(), s.to_string())))
68        .collect();
69    Some((vars, captured_at))
70}
71
72/// Capture forwardable variables from the current (agent) environment into the
73/// data dir so the MCP server can forward them to `ctx_shell` children.
74///
75/// No-op when the current environment carries no forwardable variables — this
76/// prevents a process with a stripped environment (e.g. the MCP server itself)
77/// from clobbering a good capture. The file is only rewritten when the variable
78/// set actually changes, keeping the cost of capturing on every shell command low.
79pub fn capture() {
80    let vars = collect_from_process();
81    if vars.is_empty() {
82        return;
83    }
84    let Some(path) = store_path() else {
85        return;
86    };
87    if let Some((existing, _)) = read_store(&path) {
88        if existing == vars {
89            return;
90        }
91    }
92    let payload = serde_json::json!({ "vars": vars, "captured_at": now_secs() });
93    let Ok(json) = serde_json::to_string_pretty(&payload) else {
94        return;
95    };
96    let tmp = path.with_extension("tmp");
97    if std::fs::write(&tmp, &json).is_ok() {
98        let _ = std::fs::rename(&tmp, &path);
99    }
100}
101
102/// Load captured agent runtime variables, honoring the freshness TTL.
103///
104/// Returns an empty map when no capture exists or it has expired.
105#[must_use]
106pub fn load() -> BTreeMap<String, String> {
107    let Some(path) = store_path() else {
108        return BTreeMap::new();
109    };
110    let Some((vars, captured_at)) = read_store(&path) else {
111        return BTreeMap::new();
112    };
113    if now_secs().saturating_sub(captured_at) > TTL_SECS {
114        return BTreeMap::new();
115    }
116    vars
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn is_forwardable_matches_known_prefixes() {
125        assert!(is_forwardable("CODEX_THREAD_ID"));
126        assert!(is_forwardable("CLAUDE_SESSION"));
127        assert!(is_forwardable("OPENCODE_FOO"));
128        assert!(!is_forwardable("PATH"));
129        assert!(!is_forwardable("HOME"));
130        assert!(!is_forwardable("LEAN_CTX_DATA_DIR"));
131    }
132
133    #[test]
134    fn capture_then_load_roundtrips() {
135        let _lock = crate::core::data_dir::test_env_lock();
136        let dir = std::env::temp_dir().join("lean_ctx_runtime_env_roundtrip");
137        let _ = std::fs::remove_dir_all(&dir);
138        std::fs::create_dir_all(&dir).unwrap();
139        std::env::set_var("LEAN_CTX_DATA_DIR", &dir);
140        std::env::set_var("CODEX_THREAD_ID", "thread-roundtrip");
141
142        capture();
143        let loaded = load();
144
145        std::env::remove_var("CODEX_THREAD_ID");
146        std::env::remove_var("LEAN_CTX_DATA_DIR");
147        let _ = std::fs::remove_dir_all(&dir);
148
149        assert_eq!(
150            loaded.get("CODEX_THREAD_ID").map(String::as_str),
151            Some("thread-roundtrip")
152        );
153    }
154
155    #[test]
156    fn capture_is_noop_without_forwardable_vars() {
157        let _lock = crate::core::data_dir::test_env_lock();
158        let dir = std::env::temp_dir().join("lean_ctx_runtime_env_noop");
159        let _ = std::fs::remove_dir_all(&dir);
160        std::fs::create_dir_all(&dir).unwrap();
161        std::env::set_var("LEAN_CTX_DATA_DIR", &dir);
162        // Ensure no forwardable vars leak in from the host test environment.
163        for (key, _) in collect_from_process() {
164            std::env::remove_var(key);
165        }
166
167        capture();
168        let exists = dir.join(FILE_NAME).exists();
169
170        std::env::remove_var("LEAN_CTX_DATA_DIR");
171        let _ = std::fs::remove_dir_all(&dir);
172
173        assert!(!exists, "capture must not write a store with no vars");
174    }
175
176    #[test]
177    fn load_ignores_expired_capture() {
178        let _lock = crate::core::data_dir::test_env_lock();
179        let dir = std::env::temp_dir().join("lean_ctx_runtime_env_expired");
180        let _ = std::fs::remove_dir_all(&dir);
181        std::fs::create_dir_all(&dir).unwrap();
182        std::env::set_var("LEAN_CTX_DATA_DIR", &dir);
183
184        let stale = now_secs().saturating_sub(TTL_SECS + 60);
185        let payload =
186            serde_json::json!({ "vars": { "CODEX_THREAD_ID": "old" }, "captured_at": stale });
187        std::fs::write(
188            dir.join(FILE_NAME),
189            serde_json::to_string_pretty(&payload).unwrap(),
190        )
191        .unwrap();
192
193        let loaded = load();
194
195        std::env::remove_var("LEAN_CTX_DATA_DIR");
196        let _ = std::fs::remove_dir_all(&dir);
197
198        assert!(loaded.is_empty(), "expired capture must not be loaded");
199    }
200}