lean_ctx/core/
agent_runtime_env.rs1use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18pub const FORWARD_PREFIXES: &[&str] = &["CODEX_", "CLAUDE_", "OPENCODE_", "HERMES_", "GEMINI_"];
21
22const FILE_NAME: &str = "agent_runtime_env.json";
23
24const TTL_SECS: u64 = 7_200;
27
28#[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#[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
72pub 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#[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 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}