lean_ctx/core/
agent_runtime_env.rs1use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18pub const FORWARD_PREFIXES: &[&str] = &[
21 "CODEX_",
22 "CLAUDE_",
23 "CODEBUDDY_",
24 "OPENCODE_",
25 "HERMES_",
26 "GEMINI_",
27];
28
29const FILE_NAME: &str = "agent_runtime_env.json";
30
31const TTL_SECS: u64 = 7_200;
34
35#[must_use]
37pub fn is_forwardable(key: &str) -> bool {
38 FORWARD_PREFIXES
39 .iter()
40 .any(|prefix| key.starts_with(prefix))
41}
42
43fn store_path() -> Option<PathBuf> {
49 crate::core::paths::state_dir()
50 .ok()
51 .map(|dir| dir.join(FILE_NAME))
52}
53
54fn legacy_store_path() -> Option<PathBuf> {
57 crate::core::data_dir::lean_ctx_data_dir()
58 .ok()
59 .map(|dir| dir.join(FILE_NAME))
60}
61
62fn migrate_legacy_key_file(state_path: &Path) {
68 let Some(legacy) = legacy_store_path() else {
69 return;
70 };
71 if legacy == *state_path || !legacy.exists() {
72 return;
73 }
74 if state_path.exists() {
75 let _ = std::fs::remove_file(&legacy);
78 return;
79 }
80 if let Some(parent) = state_path.parent() {
81 let _ = std::fs::create_dir_all(parent);
82 }
83 if std::fs::rename(&legacy, state_path).is_err() {
84 if std::fs::copy(&legacy, state_path).is_ok() {
86 let _ = std::fs::remove_file(&legacy);
87 } else {
88 return;
89 }
90 }
91 restrict_key_file_permissions(state_path);
92}
93
94#[cfg(unix)]
95fn restrict_key_file_permissions(path: &Path) {
96 use std::os::unix::fs::PermissionsExt;
97 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
98}
99
100#[cfg(not(unix))]
101fn restrict_key_file_permissions(_path: &Path) {}
102
103fn now_secs() -> u64 {
104 std::time::SystemTime::now()
105 .duration_since(std::time::UNIX_EPOCH)
106 .unwrap_or_default()
107 .as_secs()
108}
109
110#[must_use]
112pub fn collect_from_process() -> BTreeMap<String, String> {
113 std::env::vars()
114 .filter(|(key, _)| is_forwardable(key))
115 .collect()
116}
117
118fn read_store(path: &Path) -> Option<(BTreeMap<String, String>, u64)> {
119 let content = std::fs::read_to_string(path).ok()?;
120 let value: serde_json::Value = serde_json::from_str(&content).ok()?;
121 let captured_at = value
122 .get("captured_at")
123 .and_then(serde_json::Value::as_u64)?;
124 let vars = value
125 .get("vars")
126 .and_then(serde_json::Value::as_object)?
127 .iter()
128 .filter_map(|(key, val)| val.as_str().map(|s| (key.clone(), s.to_string())))
129 .collect();
130 Some((vars, captured_at))
131}
132
133pub fn capture() {
141 let vars = collect_from_process();
142 if vars.is_empty() {
143 return;
144 }
145 let Some(path) = store_path() else {
146 return;
147 };
148 migrate_legacy_key_file(&path);
149 if let Some((existing, _)) = read_store(&path) {
150 if existing == vars {
151 return;
152 }
153 }
154 let payload = serde_json::json!({ "vars": vars, "captured_at": now_secs() });
155 let Ok(json) = serde_json::to_string_pretty(&payload) else {
156 return;
157 };
158 let _ = crate::config_io::write_atomic(&path, &json);
162}
163
164#[must_use]
168pub fn load() -> BTreeMap<String, String> {
169 let Some(path) = store_path() else {
170 return BTreeMap::new();
171 };
172 migrate_legacy_key_file(&path);
173 let Some((vars, captured_at)) = read_store(&path) else {
174 return BTreeMap::new();
175 };
176 if now_secs().saturating_sub(captured_at) > TTL_SECS {
177 return BTreeMap::new();
178 }
179 vars
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn is_forwardable_matches_known_prefixes() {
188 assert!(is_forwardable("CODEX_THREAD_ID"));
189 assert!(is_forwardable("CLAUDE_SESSION"));
190 assert!(is_forwardable("OPENCODE_FOO"));
191 assert!(!is_forwardable("PATH"));
192 assert!(!is_forwardable("HOME"));
193 assert!(!is_forwardable("LEAN_CTX_DATA_DIR"));
194 }
195
196 #[test]
197 fn capture_then_load_roundtrips() {
198 let _iso = crate::core::data_dir::isolated_data_dir();
199 std::env::set_var("CODEX_THREAD_ID", "thread-roundtrip");
200
201 capture();
202 let loaded = load();
203
204 std::env::remove_var("CODEX_THREAD_ID");
205
206 assert_eq!(
207 loaded.get("CODEX_THREAD_ID").map(String::as_str),
208 Some("thread-roundtrip")
209 );
210 }
211
212 #[test]
213 fn capture_is_noop_without_forwardable_vars() {
214 let _iso = crate::core::data_dir::isolated_data_dir();
215 for (key, _) in collect_from_process() {
217 std::env::remove_var(key);
218 }
219
220 capture();
221 let exists = store_path().is_some_and(|p| p.exists());
222
223 assert!(!exists, "capture must not write a store with no vars");
224 }
225
226 #[test]
227 fn load_ignores_expired_capture() {
228 let _iso = crate::core::data_dir::isolated_data_dir();
229 let path = store_path().unwrap();
230 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
231
232 let stale = now_secs().saturating_sub(TTL_SECS + 60);
233 let payload =
234 serde_json::json!({ "vars": { "CODEX_THREAD_ID": "old" }, "captured_at": stale });
235 std::fs::write(&path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
236
237 let loaded = load();
238
239 assert!(loaded.is_empty(), "expired capture must not be loaded");
240 }
241
242 #[cfg(unix)]
243 #[test]
244 fn capture_sets_owner_only_permissions() {
245 use std::os::unix::fs::PermissionsExt;
246 let _iso = crate::core::data_dir::isolated_data_dir();
247 for (key, _) in collect_from_process() {
248 std::env::remove_var(key);
249 }
250 std::env::set_var("GEMINI_API_KEY", "secret-token");
251
252 capture();
253 let path = store_path().unwrap();
254 std::env::remove_var("GEMINI_API_KEY");
255
256 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
257 assert_eq!(mode & 0o777, 0o600, "captured key file must be owner-only");
258 }
259
260 #[test]
261 fn store_path_is_under_state_dir_not_config_dir_when_split() {
262 let _lock = crate::core::data_dir::test_env_lock();
263 let state = tempfile::tempdir().unwrap();
264 let config = tempfile::tempdir().unwrap();
265 std::env::set_var("LEAN_CTX_STATE_DIR", state.path());
266 std::env::set_var("LEAN_CTX_CONFIG_DIR", config.path());
267
268 let path = store_path().unwrap();
269
270 std::env::remove_var("LEAN_CTX_STATE_DIR");
271 std::env::remove_var("LEAN_CTX_CONFIG_DIR");
272
273 assert!(
274 path.starts_with(state.path()),
275 "key file must live under the state dir: {}",
276 path.display()
277 );
278 assert!(
279 !path.starts_with(config.path()),
280 "key file must never resolve under the config dir"
281 );
282 }
283
284 #[test]
285 fn migrates_legacy_key_file_to_state_dir() {
286 let _lock = crate::core::data_dir::test_env_lock();
287 let data = tempfile::tempdir().unwrap();
288 let state = tempfile::tempdir().unwrap();
289 std::env::set_var("LEAN_CTX_DATA_DIR", data.path());
290 std::env::set_var("LEAN_CTX_STATE_DIR", state.path());
291
292 let legacy = data.path().join(FILE_NAME);
293 let payload =
294 serde_json::json!({ "vars": { "GEMINI_API_KEY": "k" }, "captured_at": now_secs() });
295 std::fs::write(&legacy, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
296
297 let state_path = store_path().unwrap();
298 migrate_legacy_key_file(&state_path);
299
300 let legacy_exists = legacy.exists();
301 let migrated = state_path.exists();
302 let parent_ok = state_path.parent() == Some(state.path());
303
304 std::env::remove_var("LEAN_CTX_DATA_DIR");
305 std::env::remove_var("LEAN_CTX_STATE_DIR");
306
307 assert!(migrated, "key file must be moved into the state dir");
308 assert!(!legacy_exists, "legacy key file must be removed after move");
309 assert!(
310 parent_ok,
311 "migrated file must sit directly in the state dir"
312 );
313 }
314
315 #[test]
316 fn removes_stale_legacy_when_state_copy_exists() {
317 let _lock = crate::core::data_dir::test_env_lock();
318 let data = tempfile::tempdir().unwrap();
319 let state = tempfile::tempdir().unwrap();
320 std::env::set_var("LEAN_CTX_DATA_DIR", data.path());
321 std::env::set_var("LEAN_CTX_STATE_DIR", state.path());
322
323 let legacy = data.path().join(FILE_NAME);
324 std::fs::write(&legacy, "{}").unwrap();
325 let state_path = store_path().unwrap();
326 std::fs::write(&state_path, "{}").unwrap();
327
328 migrate_legacy_key_file(&state_path);
329
330 let legacy_exists = legacy.exists();
331 std::env::remove_var("LEAN_CTX_DATA_DIR");
332 std::env::remove_var("LEAN_CTX_STATE_DIR");
333
334 assert!(
335 !legacy_exists,
336 "stale legacy key file must be removed when a state copy exists"
337 );
338 }
339}