Skip to content

Commit 824e3da

Browse files
fix(plugin-engine): read whitelisted env vars from terminal zsh (#167)
* fix(plugin-engine): resolve whitelisted env vars from terminal zsh Read whitelisted plugin env vars through an interactive login zsh session so GUI-launched probes see the same values as Terminal, with in-memory caching to keep probe calls cheap. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(plugin-engine): improve environment variable reading logic Introduced a new helper function to streamline the retrieval of the last non-empty trimmed line from command output, enhancing the readability and maintainability of the code. Added unit tests to verify the behavior of the new function under various scenarios. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ffb8883 commit 824e3da

1 file changed

Lines changed: 71 additions & 8 deletions

File tree

src-tauri/src/plugin_engine/host_api.rs

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,52 @@
11
use rquickjs::{Ctx, Exception, Function, Object};
2+
use std::collections::HashMap;
23
use std::path::PathBuf;
4+
use std::process::Command;
5+
use std::sync::{Mutex, OnceLock};
36

47
const WHITELISTED_ENV_VARS: [&str; 3] = ["CODEX_HOME", "ZAI_API_KEY", "GLM_API_KEY"];
58

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+
650
/// Redact sensitive value to first4...last4 format (UTF-8 safe)
751
fn redact_value(value: &str) -> String {
852
let chars: Vec<char> = value.chars().collect();
@@ -131,7 +175,7 @@ pub fn inject_host_api<'js>(
131175
let host = Object::new(ctx.clone())?;
132176
inject_log(ctx, &host, plugin_id)?;
133177
inject_fs(ctx, &host)?;
134-
inject_env(ctx, &host)?;
178+
inject_env(ctx, &host, plugin_id)?;
135179
inject_http(ctx, &host, plugin_id)?;
136180
inject_keychain(ctx, &host)?;
137181
inject_sqlite(ctx, &host)?;
@@ -219,16 +263,16 @@ fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> {
219263
Ok(())
220264
}
221265

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<()> {
223267
let env_obj = Object::new(ctx.clone())?;
224268
env_obj.set(
225269
"get",
226270
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;
231273
}
274+
275+
resolve_env_from_terminal_zsh_cache(&name)
232276
})?,
233277
)?;
234278
host.set("env", env_obj)?;
@@ -1251,6 +1295,20 @@ mod tests {
12511295
use super::*;
12521296
use rquickjs::{Context, Function, Object, Runtime};
12531297

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+
12541312
#[test]
12551313
fn keychain_api_exposes_write() {
12561314
let rt = Runtime::new().expect("runtime");
@@ -1285,12 +1343,17 @@ mod tests {
12851343
let get: Function = env.get("get").expect("get");
12861344

12871345
for name in WHITELISTED_ENV_VARS {
1346+
let expected = read_env_from_interactive_zsh(name);
12881347
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");
12901349

12911350
let js_expr = format!(r#"__openusage_ctx.host.env.get("{}")"#, name);
12921351
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+
);
12941357
}
12951358

12961359
let blocked: Option<String> = get

0 commit comments

Comments
 (0)