Skip to main content

lean_ctx/core/
agent_runtime_env.rs

1//! Bridge agent runtime/session environment variables across lean-ctx processes.
2//!
3//! The lean-ctx MCP server is a long-lived child of the agent host. Some agents
4//! (notably Codex) expose runtime/session variables such as `CODEX_THREAD_ID`
5//! only in the *native agent shell* environment, not in the MCP server process
6//! (#370). `ctx_shell` runs inside the MCP server, so it cannot forward those
7//! variables by reading its own `std::env`.
8//!
9//! Short-lived lean-ctx processes that *do* run inside the agent environment —
10//! the hook handlers (`lean-ctx hook …`) and the `lean-ctx -c` shell wrapper —
11//! [`capture`] the relevant variables into a small file in the data dir. The MCP
12//! server then [`load`]s them when constructing the child environment for
13//! `ctx_shell` (see `crate::server::execute`).
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18/// Env var name prefixes identifying agent runtime/session state worth forwarding
19/// to `ctx_shell` child processes.
20pub 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
31/// Captured variables older than this are ignored: a stale session/thread id is
32/// worse than forwarding none, and a fresh session re-captures on its first hook.
33const TTL_SECS: u64 = 7_200;
34
35/// Whether `key` is an agent runtime variable lean-ctx forwards to child shells.
36#[must_use]
37pub fn is_forwardable(key: &str) -> bool {
38    FORWARD_PREFIXES
39        .iter()
40        .any(|prefix| key.starts_with(prefix))
41}
42
43/// Canonical key-file location: the STATE dir (GH #408 / GL #605).
44///
45/// This file holds captured API keys (`GEMINI_API_KEY`, `OPENCODE_API_KEY`, …),
46/// so it must live in the RW state category — never in the RO/shareable config
47/// dir — and is always written `0o600`.
48fn store_path() -> Option<PathBuf> {
49    crate::core::paths::state_dir()
50        .ok()
51        .map(|dir| dir.join(FILE_NAME))
52}
53
54/// Legacy location: the file historically lived in the (config-shaped) data dir.
55/// Used only to migrate existing captures into [`store_path`].
56fn 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
62/// Relocate a pre-#408 key file from the data dir into the state dir, and never
63/// leave captured keys behind in the config-shaped legacy location.
64///
65/// Idempotent and a no-op in single-dir mode (where state == legacy). Safe to
66/// call on every access: the existence checks make the steady state one `stat`.
67fn 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        // A state-dir copy already exists; drop the stale legacy file so keys
76        // never linger in the config-shaped data dir.
77        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        // Cross-filesystem move: copy then remove the original.
85        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/// Forwardable variables present in the current process environment.
111#[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
133/// Capture forwardable variables from the current (agent) environment into the
134/// data dir so the MCP server can forward them to `ctx_shell` children.
135///
136/// No-op when the current environment carries no forwardable variables — this
137/// prevents a process with a stripped environment (e.g. the MCP server itself)
138/// from clobbering a good capture. The file is only rewritten when the variable
139/// set actually changes, keeping the cost of capturing on every shell command low.
140pub 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        && existing == vars
151    {
152        return;
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    // Atomic write + `0o600` (owner-only): captured keys must never be
159    // group/world-readable. `write_atomic` also rejects symlinks and creates
160    // the state dir if needed.
161    let _ = crate::config_io::write_atomic(&path, &json);
162}
163
164/// Load captured agent runtime variables, honoring the freshness TTL.
165///
166/// Returns an empty map when no capture exists or it has expired.
167#[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        crate::test_env::set_var("CODEX_THREAD_ID", "thread-roundtrip");
200
201        capture();
202        let loaded = load();
203
204        crate::test_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        // Ensure no forwardable vars leak in from the host test environment.
216        for (key, _) in collect_from_process() {
217            crate::test_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            crate::test_env::remove_var(key);
249        }
250        crate::test_env::set_var("GEMINI_API_KEY", "secret-token");
251
252        capture();
253        let path = store_path().unwrap();
254        crate::test_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        crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
266        crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", config.path());
267
268        let path = store_path().unwrap();
269
270        crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
271        crate::test_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        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
290        crate::test_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        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
305        crate::test_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        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
321        crate::test_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        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
332        crate::test_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}