|
1 | 1 | use rquickjs::{Ctx, Exception, Function, Object}; |
| 2 | +use std::collections::HashMap; |
2 | 3 | use std::path::PathBuf; |
| 4 | +use std::process::Command; |
| 5 | +use std::sync::{Mutex, OnceLock}; |
3 | 6 |
|
4 | 7 | const WHITELISTED_ENV_VARS: [&str; 3] = ["CODEX_HOME", "ZAI_API_KEY", "GLM_API_KEY"]; |
5 | 8 |
|
| 9 | +fn last_non_empty_trimmed_line(text: &str) -> Option<String> { |
| 10 | + text.lines() |
| 11 | + .map(|line| line.trim()) |
| 12 | + .rev() |
| 13 | + .find(|line| !line.is_empty()) |
| 14 | + .map(|line| line.to_string()) |
| 15 | +} |
| 16 | + |
| 17 | +fn read_env_value_via_command(program: &str, args: &[&str]) -> Option<String> { |
| 18 | + let output = Command::new(program).args(args).output().ok()?; |
| 19 | + if !output.status.success() { |
| 20 | + return None; |
| 21 | + } |
| 22 | + let stdout = String::from_utf8_lossy(&output.stdout); |
| 23 | + last_non_empty_trimmed_line(&stdout) |
| 24 | +} |
| 25 | + |
| 26 | +fn terminal_zsh_env_cache() -> &'static Mutex<HashMap<String, Option<String>>> { |
| 27 | + static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new(); |
| 28 | + CACHE.get_or_init(|| Mutex::new(HashMap::new())) |
| 29 | +} |
| 30 | + |
| 31 | +fn read_env_from_interactive_zsh(name: &str) -> Option<String> { |
| 32 | + let script = format!("printenv {}", name); |
| 33 | + read_env_value_via_command("/bin/zsh", &["-ilc", script.as_str()]) |
| 34 | +} |
| 35 | + |
| 36 | +fn resolve_env_from_terminal_zsh_cache(name: &str) -> Option<String> { |
| 37 | + if let Ok(cache) = terminal_zsh_env_cache().lock() { |
| 38 | + if let Some(cached) = cache.get(name) { |
| 39 | + return cached.clone(); |
| 40 | + } |
| 41 | + } |
| 42 | + |
| 43 | + let resolved = read_env_from_interactive_zsh(name); |
| 44 | + if let Ok(mut cache) = terminal_zsh_env_cache().lock() { |
| 45 | + cache.insert(name.to_string(), resolved.clone()); |
| 46 | + } |
| 47 | + resolved |
| 48 | +} |
| 49 | + |
6 | 50 | /// Redact sensitive value to first4...last4 format (UTF-8 safe) |
7 | 51 | fn redact_value(value: &str) -> String { |
8 | 52 | let chars: Vec<char> = value.chars().collect(); |
@@ -131,7 +175,7 @@ pub fn inject_host_api<'js>( |
131 | 175 | let host = Object::new(ctx.clone())?; |
132 | 176 | inject_log(ctx, &host, plugin_id)?; |
133 | 177 | inject_fs(ctx, &host)?; |
134 | | - inject_env(ctx, &host)?; |
| 178 | + inject_env(ctx, &host, plugin_id)?; |
135 | 179 | inject_http(ctx, &host, plugin_id)?; |
136 | 180 | inject_keychain(ctx, &host)?; |
137 | 181 | inject_sqlite(ctx, &host)?; |
@@ -219,16 +263,16 @@ fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { |
219 | 263 | Ok(()) |
220 | 264 | } |
221 | 265 |
|
222 | | -fn inject_env<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { |
| 266 | +fn inject_env<'js>(ctx: &Ctx<'js>, host: &Object<'js>, _plugin_id: &str) -> rquickjs::Result<()> { |
223 | 267 | let env_obj = Object::new(ctx.clone())?; |
224 | 268 | env_obj.set( |
225 | 269 | "get", |
226 | 270 | Function::new(ctx.clone(), move |name: String| -> Option<String> { |
227 | | - if WHITELISTED_ENV_VARS.contains(&name.as_str()) { |
228 | | - std::env::var(&name).ok() |
229 | | - } else { |
230 | | - None |
| 271 | + if !WHITELISTED_ENV_VARS.contains(&name.as_str()) { |
| 272 | + return None; |
231 | 273 | } |
| 274 | + |
| 275 | + resolve_env_from_terminal_zsh_cache(&name) |
232 | 276 | })?, |
233 | 277 | )?; |
234 | 278 | host.set("env", env_obj)?; |
@@ -1251,6 +1295,20 @@ mod tests { |
1251 | 1295 | use super::*; |
1252 | 1296 | use rquickjs::{Context, Function, Object, Runtime}; |
1253 | 1297 |
|
| 1298 | + #[test] |
| 1299 | + fn last_non_empty_trimmed_line_uses_final_value_when_stdout_is_noisy() { |
| 1300 | + let stdout = "banner line\nanother message\n sk-test-key-12345 \n"; |
| 1301 | + let value = last_non_empty_trimmed_line(stdout); |
| 1302 | + assert_eq!(value.as_deref(), Some("sk-test-key-12345")); |
| 1303 | + } |
| 1304 | + |
| 1305 | + #[test] |
| 1306 | + fn last_non_empty_trimmed_line_returns_none_for_empty_stdout() { |
| 1307 | + let stdout = " \n\n\t\n"; |
| 1308 | + let value = last_non_empty_trimmed_line(stdout); |
| 1309 | + assert!(value.is_none()); |
| 1310 | + } |
| 1311 | + |
1254 | 1312 | #[test] |
1255 | 1313 | fn keychain_api_exposes_write() { |
1256 | 1314 | let rt = Runtime::new().expect("runtime"); |
@@ -1285,12 +1343,17 @@ mod tests { |
1285 | 1343 | let get: Function = env.get("get").expect("get"); |
1286 | 1344 |
|
1287 | 1345 | for name in WHITELISTED_ENV_VARS { |
| 1346 | + let expected = read_env_from_interactive_zsh(name); |
1288 | 1347 | let value: Option<String> = get.call((name.to_string(),)).expect("get whitelisted var"); |
1289 | | - assert_eq!(value, std::env::var(name).ok(), "{name} should match process env"); |
| 1348 | + assert_eq!(value, expected, "{name} should match interactive zsh env"); |
1290 | 1349 |
|
1291 | 1350 | let js_expr = format!(r#"__openusage_ctx.host.env.get("{}")"#, name); |
1292 | 1351 | let js_value: Option<String> = ctx.eval(js_expr).expect("js get whitelisted var"); |
1293 | | - assert_eq!(js_value, std::env::var(name).ok(), "{name} should match process env from JS"); |
| 1352 | + assert_eq!( |
| 1353 | + js_value, |
| 1354 | + expected, |
| 1355 | + "{name} should match interactive zsh env from JS" |
| 1356 | + ); |
1294 | 1357 | } |
1295 | 1358 |
|
1296 | 1359 | let blocked: Option<String> = get |
|
0 commit comments