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 CREDENTIAL_MARKERS: &[&str] = &[
39 "_KEY", "APIKEY", "SECRET", "TOKEN", "PASSWORD", "PASSWD", "CREDENTIAL", "AUTH", ];
48
49#[must_use]
52pub fn is_credential_shaped(key: &str) -> bool {
53 let upper = key.to_ascii_uppercase();
54 CREDENTIAL_MARKERS
55 .iter()
56 .any(|marker| upper.contains(marker))
57}
58
59const FILE_NAME: &str = "agent_runtime_env.json";
60
61const TTL_SECS: u64 = 7_200;
64
65#[must_use]
71pub fn is_forwardable(key: &str) -> bool {
72 FORWARD_PREFIXES
73 .iter()
74 .any(|prefix| key.starts_with(prefix))
75 && !is_credential_shaped(key)
76}
77
78fn store_path() -> Option<PathBuf> {
86 crate::core::paths::state_dir()
87 .ok()
88 .map(|dir| dir.join(FILE_NAME))
89}
90
91fn legacy_store_path() -> Option<PathBuf> {
94 crate::core::data_dir::lean_ctx_data_dir()
95 .ok()
96 .map(|dir| dir.join(FILE_NAME))
97}
98
99fn migrate_legacy_key_file(state_path: &Path) {
105 let Some(legacy) = legacy_store_path() else {
106 return;
107 };
108 if legacy == *state_path || !legacy.exists() {
109 return;
110 }
111 if state_path.exists() {
112 let _ = std::fs::remove_file(&legacy);
115 return;
116 }
117 if let Some(parent) = state_path.parent() {
118 let _ = std::fs::create_dir_all(parent);
119 }
120 if std::fs::rename(&legacy, state_path).is_err() {
121 if std::fs::copy(&legacy, state_path).is_ok() {
123 let _ = std::fs::remove_file(&legacy);
124 } else {
125 return;
126 }
127 }
128 restrict_key_file_permissions(state_path);
129}
130
131#[cfg(unix)]
132fn restrict_key_file_permissions(path: &Path) {
133 use std::os::unix::fs::PermissionsExt;
134 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
135}
136
137#[cfg(not(unix))]
138fn restrict_key_file_permissions(_path: &Path) {}
139
140fn now_secs() -> u64 {
141 std::time::SystemTime::now()
142 .duration_since(std::time::UNIX_EPOCH)
143 .unwrap_or_default()
144 .as_secs()
145}
146
147#[must_use]
149pub fn collect_from_process() -> BTreeMap<String, String> {
150 std::env::vars()
151 .filter(|(key, _)| is_forwardable(key))
152 .collect()
153}
154
155fn read_store(path: &Path) -> Option<(BTreeMap<String, String>, u64)> {
156 let content = std::fs::read_to_string(path).ok()?;
157 let value: serde_json::Value = serde_json::from_str(&content).ok()?;
158 let captured_at = value
159 .get("captured_at")
160 .and_then(serde_json::Value::as_u64)?;
161 let vars = value
162 .get("vars")
163 .and_then(serde_json::Value::as_object)?
164 .iter()
165 .filter_map(|(key, val)| val.as_str().map(|s| (key.clone(), s.to_string())))
166 .collect();
167 Some((vars, captured_at))
168}
169
170pub fn capture() {
178 let vars = collect_from_process();
179 if vars.is_empty() {
180 return;
181 }
182 let Some(path) = store_path() else {
183 return;
184 };
185 migrate_legacy_key_file(&path);
186 if let Some((existing, _)) = read_store(&path)
187 && existing == vars
188 {
189 return;
190 }
191 let payload = serde_json::json!({ "vars": vars, "captured_at": now_secs() });
192 let Ok(json) = serde_json::to_string_pretty(&payload) else {
193 return;
194 };
195 let _ = crate::config_io::write_atomic(&path, &json);
199}
200
201#[must_use]
205pub fn load() -> BTreeMap<String, String> {
206 let Some(path) = store_path() else {
207 return BTreeMap::new();
208 };
209 migrate_legacy_key_file(&path);
210 let Some((vars, captured_at)) = read_store(&path) else {
211 return BTreeMap::new();
212 };
213 if now_secs().saturating_sub(captured_at) > TTL_SECS {
214 return BTreeMap::new();
215 }
216 let cleaned: BTreeMap<String, String> = vars
221 .iter()
222 .filter(|(key, _)| is_forwardable(key))
223 .map(|(key, val)| (key.clone(), val.clone()))
224 .collect();
225 if cleaned.len() != vars.len() {
226 scrub_store(&path, &cleaned, captured_at);
227 }
228 cleaned
229}
230
231fn scrub_store(path: &Path, vars: &BTreeMap<String, String>, captured_at: u64) {
235 if vars.is_empty() {
236 let _ = std::fs::remove_file(path);
237 return;
238 }
239 let payload = serde_json::json!({ "vars": vars, "captured_at": captured_at });
240 if let Ok(json) = serde_json::to_string_pretty(&payload) {
241 let _ = crate::config_io::write_atomic(path, &json);
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn is_forwardable_matches_known_prefixes() {
251 assert!(is_forwardable("CODEX_THREAD_ID"));
252 assert!(is_forwardable("CLAUDE_SESSION"));
253 assert!(is_forwardable("OPENCODE_FOO"));
254 assert!(!is_forwardable("PATH"));
255 assert!(!is_forwardable("HOME"));
256 assert!(!is_forwardable("LEAN_CTX_DATA_DIR"));
257 }
258
259 #[test]
260 fn capture_then_load_roundtrips() {
261 let _iso = crate::core::data_dir::isolated_data_dir();
262 crate::test_env::set_var("CODEX_THREAD_ID", "thread-roundtrip");
263
264 capture();
265 let loaded = load();
266
267 crate::test_env::remove_var("CODEX_THREAD_ID");
268
269 assert_eq!(
270 loaded.get("CODEX_THREAD_ID").map(String::as_str),
271 Some("thread-roundtrip")
272 );
273 }
274
275 #[test]
276 fn capture_is_noop_without_forwardable_vars() {
277 let _iso = crate::core::data_dir::isolated_data_dir();
278 for (key, _) in collect_from_process() {
280 crate::test_env::remove_var(key);
281 }
282
283 capture();
284 let exists = store_path().is_some_and(|p| p.exists());
285
286 assert!(!exists, "capture must not write a store with no vars");
287 }
288
289 #[test]
290 fn load_ignores_expired_capture() {
291 let _iso = crate::core::data_dir::isolated_data_dir();
292 let path = store_path().unwrap();
293 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
294
295 let stale = now_secs().saturating_sub(TTL_SECS + 60);
296 let payload =
297 serde_json::json!({ "vars": { "CODEX_THREAD_ID": "old" }, "captured_at": stale });
298 std::fs::write(&path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
299
300 let loaded = load();
301
302 assert!(loaded.is_empty(), "expired capture must not be loaded");
303 }
304
305 #[cfg(unix)]
306 #[test]
307 fn capture_sets_owner_only_permissions() {
308 use std::os::unix::fs::PermissionsExt;
309 let _iso = crate::core::data_dir::isolated_data_dir();
310 for (key, _) in collect_from_process() {
311 crate::test_env::remove_var(key);
312 }
313 crate::test_env::set_var("CODEX_THREAD_ID", "session-id");
316
317 capture();
318 let path = store_path().unwrap();
319 crate::test_env::remove_var("CODEX_THREAD_ID");
320
321 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
322 assert_eq!(
323 mode & 0o777,
324 0o600,
325 "captured runtime-env file must be owner-only"
326 );
327 }
328
329 #[test]
332 fn is_forwardable_rejects_credential_shaped_vars() {
333 assert!(is_forwardable("CODEX_THREAD_ID"));
335 assert!(is_forwardable("CLAUDE_SESSION_ID"));
336 assert!(is_forwardable("OPENCODE_SESSION"));
337 assert!(!is_forwardable("GEMINI_API_KEY"));
339 assert!(!is_forwardable("OPENCODE_API_KEY"));
340 assert!(!is_forwardable("OPENCODE_SERVER_PASSWORD"));
341 assert!(!is_forwardable("CLAUDE_CODE_OAUTH_TOKEN"));
342 assert!(!is_forwardable("CODEX_ACCESS_TOKEN"));
343 assert!(!is_forwardable("CODEX_CLIENT_SECRET"));
344 assert!(!is_forwardable("CODEX_PRIVATE_KEY"));
345 assert!(!is_forwardable("GEMINI_CREDENTIALS"));
346 }
347
348 #[test]
349 fn capture_excludes_credentials() {
350 let _iso = crate::core::data_dir::isolated_data_dir();
351 for (key, _) in collect_from_process() {
352 crate::test_env::remove_var(key);
353 }
354 crate::test_env::set_var("CODEX_THREAD_ID", "thread-keep");
355 crate::test_env::set_var("GEMINI_API_KEY", "secret-drop");
356
357 capture();
358 let loaded = load();
359
360 crate::test_env::remove_var("CODEX_THREAD_ID");
361 crate::test_env::remove_var("GEMINI_API_KEY");
362
363 assert_eq!(
364 loaded.get("CODEX_THREAD_ID").map(String::as_str),
365 Some("thread-keep")
366 );
367 assert!(
368 !loaded.contains_key("GEMINI_API_KEY"),
369 "API key must never be captured or forwarded"
370 );
371 }
372
373 #[test]
374 fn load_scrubs_legacy_credentials_from_disk() {
375 let _iso = crate::core::data_dir::isolated_data_dir();
376 let path = store_path().unwrap();
377 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
378
379 let payload = serde_json::json!({
381 "vars": { "CODEX_THREAD_ID": "t", "OPENCODE_SERVER_PASSWORD": "p4ssw0rd" },
382 "captured_at": now_secs()
383 });
384 std::fs::write(&path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
385
386 let loaded = load();
387
388 assert!(
389 !loaded.contains_key("OPENCODE_SERVER_PASSWORD"),
390 "legacy credential must not be loaded"
391 );
392 assert_eq!(loaded.get("CODEX_THREAD_ID").map(String::as_str), Some("t"));
393
394 let on_disk = std::fs::read_to_string(&path).unwrap();
396 assert!(
397 !on_disk.contains("OPENCODE_SERVER_PASSWORD") && !on_disk.contains("p4ssw0rd"),
398 "secret must be removed from the capture file at rest: {on_disk}"
399 );
400 }
401
402 #[test]
403 fn store_path_is_under_state_dir_not_config_dir_when_split() {
404 let _lock = crate::core::data_dir::test_env_lock();
405 let state = tempfile::tempdir().unwrap();
406 let config = tempfile::tempdir().unwrap();
407 crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
408 crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", config.path());
409
410 let path = store_path().unwrap();
411
412 crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
413 crate::test_env::remove_var("LEAN_CTX_CONFIG_DIR");
414
415 assert!(
416 path.starts_with(state.path()),
417 "key file must live under the state dir: {}",
418 path.display()
419 );
420 assert!(
421 !path.starts_with(config.path()),
422 "key file must never resolve under the config dir"
423 );
424 }
425
426 #[test]
427 fn migrates_legacy_key_file_to_state_dir() {
428 let _lock = crate::core::data_dir::test_env_lock();
429 let data = tempfile::tempdir().unwrap();
430 let state = tempfile::tempdir().unwrap();
431 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
432 crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
433
434 let legacy = data.path().join(FILE_NAME);
435 let payload =
436 serde_json::json!({ "vars": { "GEMINI_API_KEY": "k" }, "captured_at": now_secs() });
437 std::fs::write(&legacy, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
438
439 let state_path = store_path().unwrap();
440 migrate_legacy_key_file(&state_path);
441
442 let legacy_exists = legacy.exists();
443 let migrated = state_path.exists();
444 let parent_ok = state_path.parent() == Some(state.path());
445
446 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
447 crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
448
449 assert!(migrated, "key file must be moved into the state dir");
450 assert!(!legacy_exists, "legacy key file must be removed after move");
451 assert!(
452 parent_ok,
453 "migrated file must sit directly in the state dir"
454 );
455 }
456
457 #[test]
458 fn removes_stale_legacy_when_state_copy_exists() {
459 let _lock = crate::core::data_dir::test_env_lock();
460 let data = tempfile::tempdir().unwrap();
461 let state = tempfile::tempdir().unwrap();
462 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
463 crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
464
465 let legacy = data.path().join(FILE_NAME);
466 std::fs::write(&legacy, "{}").unwrap();
467 let state_path = store_path().unwrap();
468 std::fs::write(&state_path, "{}").unwrap();
469
470 migrate_legacy_key_file(&state_path);
471
472 let legacy_exists = legacy.exists();
473 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
474 crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
475
476 assert!(
477 !legacy_exists,
478 "stale legacy key file must be removed when a state copy exists"
479 );
480 }
481}