use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub(crate) const FORWARD_PREFIXES: &[&str] = &[
"CODEX_",
"CLAUDE_",
"CODEBUDDY_",
"OPENCODE_",
"HERMES_",
"GEMINI_",
];
const CREDENTIAL_MARKERS: &[&str] = &[
"_KEY", "APIKEY", "SECRET", "TOKEN", "PASSWORD", "PASSWD", "CREDENTIAL", "AUTH", ];
#[must_use]
pub(crate) fn is_credential_shaped(key: &str) -> bool {
let upper = key.to_ascii_uppercase();
CREDENTIAL_MARKERS
.iter()
.any(|marker| upper.contains(marker))
}
const FILE_NAME: &str = "agent_runtime_env.json";
const TTL_SECS: u64 = 7_200;
#[must_use]
pub(crate) fn is_forwardable(key: &str) -> bool {
FORWARD_PREFIXES
.iter()
.any(|prefix| key.starts_with(prefix))
&& !is_credential_shaped(key)
}
fn store_path() -> Option<PathBuf> {
crate::core::paths::state_dir()
.ok()
.map(|dir| dir.join(FILE_NAME))
}
fn legacy_store_path() -> Option<PathBuf> {
crate::core::data_dir::lean_ctx_data_dir()
.ok()
.map(|dir| dir.join(FILE_NAME))
}
fn migrate_legacy_key_file(state_path: &Path) {
let Some(legacy) = legacy_store_path() else {
return;
};
if legacy == *state_path || !legacy.exists() {
return;
}
if state_path.exists() {
let _ = std::fs::remove_file(&legacy);
return;
}
if let Some(parent) = state_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::rename(&legacy, state_path).is_err() {
if std::fs::copy(&legacy, state_path).is_ok() {
let _ = std::fs::remove_file(&legacy);
} else {
return;
}
}
restrict_key_file_permissions(state_path);
}
#[cfg(unix)]
fn restrict_key_file_permissions(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
fn restrict_key_file_permissions(_path: &Path) {}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[must_use]
pub(crate) fn collect_from_process() -> BTreeMap<String, String> {
std::env::vars()
.filter(|(key, _)| is_forwardable(key))
.collect()
}
fn read_store(path: &Path) -> Option<(BTreeMap<String, String>, u64)> {
let content = std::fs::read_to_string(path).ok()?;
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
let captured_at = value
.get("captured_at")
.and_then(serde_json::Value::as_u64)?;
let vars = value
.get("vars")
.and_then(serde_json::Value::as_object)?
.iter()
.filter_map(|(key, val)| val.as_str().map(|s| (key.clone(), s.to_string())))
.collect();
Some((vars, captured_at))
}
pub(crate) fn capture() {
let vars = collect_from_process();
if vars.is_empty() {
return;
}
let Some(path) = store_path() else {
return;
};
migrate_legacy_key_file(&path);
if let Some((existing, _)) = read_store(&path)
&& existing == vars
{
return;
}
let payload = serde_json::json!({ "vars": vars, "captured_at": now_secs() });
let Ok(json) = serde_json::to_string_pretty(&payload) else {
return;
};
let _ = crate::config_io::write_atomic(&path, &json);
}
#[must_use]
pub(crate) fn load() -> BTreeMap<String, String> {
let Some(path) = store_path() else {
return BTreeMap::new();
};
migrate_legacy_key_file(&path);
let Some((vars, captured_at)) = read_store(&path) else {
let probed = probe_parent_env();
if !probed.is_empty() {
persist_probed_vars(&path, &probed);
}
return probed;
};
if now_secs().saturating_sub(captured_at) > TTL_SECS {
return BTreeMap::new();
}
let cleaned: BTreeMap<String, String> = vars
.iter()
.filter(|(key, _)| is_forwardable(key))
.map(|(key, val)| (key.clone(), val.clone()))
.collect();
if cleaned.len() != vars.len() {
scrub_store(&path, &cleaned, captured_at);
}
cleaned
}
fn probe_parent_env() -> BTreeMap<String, String> {
let ppid = parent_pid();
if ppid == 0 {
return BTreeMap::new();
}
let raw_env = read_parent_environ(ppid);
raw_env
.into_iter()
.filter(|(key, _)| is_forwardable(key))
.collect()
}
fn parent_pid() -> u32 {
#[cfg(unix)]
{
unsafe { libc::getppid() as u32 }
}
#[cfg(not(unix))]
{
0
}
}
fn read_parent_environ(pid: u32) -> BTreeMap<String, String> {
#[cfg(target_os = "linux")]
if let Ok(data) = std::fs::read(format!("/proc/{pid}/environ")) {
return parse_null_separated_env(&data);
}
#[cfg(target_os = "macos")]
if let Ok(output) = std::process::Command::new("ps")
.args(["eww", "-o", "command", "-p", &pid.to_string()])
.output()
&& output.status.success()
{
return parse_ps_environ(&String::from_utf8_lossy(&output.stdout));
}
let _ = pid;
BTreeMap::new()
}
#[cfg(target_os = "linux")]
fn parse_null_separated_env(data: &[u8]) -> BTreeMap<String, String> {
data.split(|&b| b == 0)
.filter_map(|entry| {
let s = std::str::from_utf8(entry).ok()?;
let (key, val) = s.split_once('=')?;
Some((key.to_string(), val.to_string()))
})
.collect()
}
#[cfg(target_os = "macos")]
fn parse_ps_environ(output: &str) -> BTreeMap<String, String> {
let mut result = BTreeMap::new();
for line in output.lines().skip(1) {
for token in line.split_whitespace() {
if let Some((key, val)) = token.split_once('=')
&& FORWARD_PREFIXES
.iter()
.any(|prefix| key.starts_with(prefix))
{
result.insert(key.to_string(), val.to_string());
}
}
}
result
}
fn persist_probed_vars(path: &Path, vars: &BTreeMap<String, String>) {
let payload = serde_json::json!({ "vars": vars, "captured_at": now_secs() });
if let Ok(json) = serde_json::to_string_pretty(&payload) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = crate::config_io::write_atomic(path, &json);
}
}
fn scrub_store(path: &Path, vars: &BTreeMap<String, String>, captured_at: u64) {
if vars.is_empty() {
let _ = std::fs::remove_file(path);
return;
}
let payload = serde_json::json!({ "vars": vars, "captured_at": captured_at });
if let Ok(json) = serde_json::to_string_pretty(&payload) {
let _ = crate::config_io::write_atomic(path, &json);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_forwardable_matches_known_prefixes() {
assert!(is_forwardable("CODEX_THREAD_ID"));
assert!(is_forwardable("CLAUDE_SESSION"));
assert!(is_forwardable("OPENCODE_FOO"));
assert!(!is_forwardable("PATH"));
assert!(!is_forwardable("HOME"));
assert!(!is_forwardable("LEAN_CTX_DATA_DIR"));
}
#[test]
fn capture_then_load_roundtrips() {
let _iso = crate::core::data_dir::isolated_data_dir();
crate::test_env::set_var("CODEX_THREAD_ID", "thread-roundtrip");
capture();
let loaded = load();
crate::test_env::remove_var("CODEX_THREAD_ID");
assert_eq!(
loaded.get("CODEX_THREAD_ID").map(String::as_str),
Some("thread-roundtrip")
);
}
#[test]
fn capture_is_noop_without_forwardable_vars() {
let _iso = crate::core::data_dir::isolated_data_dir();
for (key, _) in collect_from_process() {
crate::test_env::remove_var(key);
}
capture();
let exists = store_path().is_some_and(|p| p.exists());
assert!(!exists, "capture must not write a store with no vars");
}
#[test]
fn load_ignores_expired_capture() {
let _iso = crate::core::data_dir::isolated_data_dir();
let path = store_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let stale = now_secs().saturating_sub(TTL_SECS + 60);
let payload =
serde_json::json!({ "vars": { "CODEX_THREAD_ID": "old" }, "captured_at": stale });
std::fs::write(&path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
let loaded = load();
assert!(loaded.is_empty(), "expired capture must not be loaded");
}
#[cfg(unix)]
#[test]
fn capture_sets_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let _iso = crate::core::data_dir::isolated_data_dir();
for (key, _) in collect_from_process() {
crate::test_env::remove_var(key);
}
crate::test_env::set_var("CODEX_THREAD_ID", "session-id");
capture();
let path = store_path().unwrap();
crate::test_env::remove_var("CODEX_THREAD_ID");
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"captured runtime-env file must be owner-only"
);
}
#[test]
fn is_forwardable_rejects_credential_shaped_vars() {
assert!(is_forwardable("CODEX_THREAD_ID"));
assert!(is_forwardable("CLAUDE_SESSION_ID"));
assert!(is_forwardable("OPENCODE_SESSION"));
assert!(!is_forwardable("GEMINI_API_KEY"));
assert!(!is_forwardable("OPENCODE_API_KEY"));
assert!(!is_forwardable("OPENCODE_SERVER_PASSWORD"));
assert!(!is_forwardable("CLAUDE_CODE_OAUTH_TOKEN"));
assert!(!is_forwardable("CODEX_ACCESS_TOKEN"));
assert!(!is_forwardable("CODEX_CLIENT_SECRET"));
assert!(!is_forwardable("CODEX_PRIVATE_KEY"));
assert!(!is_forwardable("GEMINI_CREDENTIALS"));
}
#[test]
fn capture_excludes_credentials() {
let _iso = crate::core::data_dir::isolated_data_dir();
for (key, _) in collect_from_process() {
crate::test_env::remove_var(key);
}
crate::test_env::set_var("CODEX_THREAD_ID", "thread-keep");
crate::test_env::set_var("GEMINI_API_KEY", "secret-drop");
capture();
let loaded = load();
crate::test_env::remove_var("CODEX_THREAD_ID");
crate::test_env::remove_var("GEMINI_API_KEY");
assert_eq!(
loaded.get("CODEX_THREAD_ID").map(String::as_str),
Some("thread-keep")
);
assert!(
!loaded.contains_key("GEMINI_API_KEY"),
"API key must never be captured or forwarded"
);
}
#[test]
fn load_scrubs_legacy_credentials_from_disk() {
let _iso = crate::core::data_dir::isolated_data_dir();
let path = store_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let payload = serde_json::json!({
"vars": { "CODEX_THREAD_ID": "t", "OPENCODE_SERVER_PASSWORD": "p4ssw0rd" },
"captured_at": now_secs()
});
std::fs::write(&path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
let loaded = load();
assert!(
!loaded.contains_key("OPENCODE_SERVER_PASSWORD"),
"legacy credential must not be loaded"
);
assert_eq!(loaded.get("CODEX_THREAD_ID").map(String::as_str), Some("t"));
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(
!on_disk.contains("OPENCODE_SERVER_PASSWORD") && !on_disk.contains("p4ssw0rd"),
"secret must be removed from the capture file at rest: {on_disk}"
);
}
#[test]
fn store_path_is_under_state_dir_not_config_dir_when_split() {
let _lock = crate::core::data_dir::test_env_lock();
let state = tempfile::tempdir().unwrap();
let config = tempfile::tempdir().unwrap();
crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", config.path());
let path = store_path().unwrap();
crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
crate::test_env::remove_var("LEAN_CTX_CONFIG_DIR");
assert!(
path.starts_with(state.path()),
"key file must live under the state dir: {}",
path.display()
);
assert!(
!path.starts_with(config.path()),
"key file must never resolve under the config dir"
);
}
#[test]
fn migrates_legacy_key_file_to_state_dir() {
let _lock = crate::core::data_dir::test_env_lock();
let data = tempfile::tempdir().unwrap();
let state = tempfile::tempdir().unwrap();
crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
let legacy = data.path().join(FILE_NAME);
let payload =
serde_json::json!({ "vars": { "GEMINI_API_KEY": "k" }, "captured_at": now_secs() });
std::fs::write(&legacy, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
let state_path = store_path().unwrap();
migrate_legacy_key_file(&state_path);
let legacy_exists = legacy.exists();
let migrated = state_path.exists();
let parent_ok = state_path.parent() == Some(state.path());
crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
assert!(migrated, "key file must be moved into the state dir");
assert!(!legacy_exists, "legacy key file must be removed after move");
assert!(
parent_ok,
"migrated file must sit directly in the state dir"
);
}
#[test]
fn removes_stale_legacy_when_state_copy_exists() {
let _lock = crate::core::data_dir::test_env_lock();
let data = tempfile::tempdir().unwrap();
let state = tempfile::tempdir().unwrap();
crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
let legacy = data.path().join(FILE_NAME);
std::fs::write(&legacy, "{}").unwrap();
let state_path = store_path().unwrap();
std::fs::write(&state_path, "{}").unwrap();
migrate_legacy_key_file(&state_path);
let legacy_exists = legacy.exists();
crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
assert!(
!legacy_exists,
"stale legacy key file must be removed when a state copy exists"
);
}
}