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] = &["CODEX_", "CLAUDE_", "OPENCODE_", "HERMES_", "GEMINI_"];
21
22const FILE_NAME: &str = "agent_runtime_env.json";
23
24/// Captured variables older than this are ignored: a stale session/thread id is
25/// worse than forwarding none, and a fresh session re-captures on its first hook.
26const TTL_SECS: u64 = 7_200;
27
28/// Whether `key` is an agent runtime variable lean-ctx forwards to child shells.
29#[must_use]
30pub fn is_forwardable(key: &str) -> bool {
31    FORWARD_PREFIXES
32        .iter()
33        .any(|prefix| key.starts_with(prefix))
34}
35
36/// Canonical key-file location: the STATE dir (GH #408 / GL #605).
37///
38/// This file holds captured API keys (`GEMINI_API_KEY`, `OPENCODE_API_KEY`, …),
39/// so it must live in the RW state category — never in the RO/shareable config
40/// dir — and is always written `0o600`.
41fn store_path() -> Option<PathBuf> {
42    crate::core::paths::state_dir()
43        .ok()
44        .map(|dir| dir.join(FILE_NAME))
45}
46
47/// Legacy location: the file historically lived in the (config-shaped) data dir.
48/// Used only to migrate existing captures into [`store_path`].
49fn 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
55/// Relocate a pre-#408 key file from the data dir into the state dir, and never
56/// leave captured keys behind in the config-shaped legacy location.
57///
58/// Idempotent and a no-op in single-dir mode (where state == legacy). Safe to
59/// call on every access: the existence checks make the steady state one `stat`.
60fn 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        // A state-dir copy already exists; drop the stale legacy file so keys
69        // never linger in the config-shaped data dir.
70        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        // Cross-filesystem move: copy then remove the original.
78        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/// Forwardable variables present in the current process environment.
104#[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
126/// Capture forwardable variables from the current (agent) environment into the
127/// data dir so the MCP server can forward them to `ctx_shell` children.
128///
129/// No-op when the current environment carries no forwardable variables — this
130/// prevents a process with a stripped environment (e.g. the MCP server itself)
131/// from clobbering a good capture. The file is only rewritten when the variable
132/// set actually changes, keeping the cost of capturing on every shell command low.
133pub 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    // Atomic write + `0o600` (owner-only): captured keys must never be
152    // group/world-readable. `write_atomic` also rejects symlinks and creates
153    // the state dir if needed.
154    let _ = crate::config_io::write_atomic(&path, &json);
155}
156
157/// Load captured agent runtime variables, honoring the freshness TTL.
158///
159/// Returns an empty map when no capture exists or it has expired.
160#[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        // Ensure no forwardable vars leak in from the host test environment.
209        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}