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