use std::{
collections::{BTreeMap, BTreeSet, HashMap},
path::Path,
process::{Command, Stdio},
sync::OnceLock,
time::Duration,
};
use anyhow::Result;
use regex::Regex;
use super::env_file;
static SHELL_SNAPSHOT: OnceLock<HashMap<String, String>> = OnceLock::new();
pub fn shell_snapshot() -> &'static HashMap<String, String> {
SHELL_SNAPSHOT.get_or_init(|| std::env::vars().collect())
}
pub fn reconcile(raw_config: &str, base_dir: &Path) -> Result<()> {
let shell = shell_snapshot();
let env_path = base_dir.join(".env");
let mut file = env_file::read(&env_path)?;
for (k, v) in &file {
unsafe { std::env::set_var(k, v) };
}
let needed = scan_var_refs(raw_config);
let mut added = Vec::new();
for (k, v) in vars_to_capture(shell, &file, &needed) {
unsafe { std::env::set_var(&k, &v) };
file.insert(k.clone(), v);
added.push(k);
}
let still_missing: Vec<String> = needed
.iter()
.filter(|v| std::env::var_os(v.as_str()).is_none())
.cloned()
.collect();
let mut recovered_from_rc: BTreeMap<String, String> = BTreeMap::new();
if !still_missing.is_empty() {
if let Some(found) = shell_rc_fallback(&still_missing) {
for (k, v) in &found {
unsafe { std::env::set_var(k, v) };
file.insert(k.clone(), v.clone());
recovered_from_rc.insert(k.clone(), v.clone());
}
}
}
let file_changed = !added.is_empty() || !recovered_from_rc.is_empty();
if file_changed {
env_file::write(&env_path, &file)?;
if !added.is_empty() {
tracing::info!(
vars = ?added,
path = %env_path.display(),
".env: added vars from shell"
);
}
if !recovered_from_rc.is_empty() {
tracing::info!(
vars = ?recovered_from_rc.keys().collect::<Vec<_>>(),
path = %env_path.display(),
".env: recovered vars by sourcing shell rc files"
);
}
}
Ok(())
}
fn vars_to_capture(
shell: &HashMap<String, String>,
file: &BTreeMap<String, String>,
needed: &BTreeSet<String>,
) -> Vec<(String, String)> {
needed
.iter()
.filter_map(|var| match (shell.get(var), file.get(var)) {
(Some(shell_val), None) => Some((var.clone(), shell_val.clone())),
_ => None,
})
.collect()
}
fn shell_rc_fallback(wanted: &[String]) -> Option<BTreeMap<String, String>> {
if cfg!(windows) {
return None;
}
if std::env::var_os("_RSCLAW_ENV_INHERITED").is_some() {
return None;
}
if std::env::var_os("RSCLAW_NO_SHELL_SOURCE").is_some() {
return None;
}
let shell = resolve_login_shell()?;
tracing::debug!(
shell = %shell,
vars = ?wanted,
"env reconcile: sourcing shell rc to recover missing vars"
);
let mut cmd = Command::new(&shell);
cmd.args(["-lic", "env"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let output = match run_with_timeout(cmd, Duration::from_secs(5)) {
Ok(o) => o,
Err(e) => {
tracing::warn!(
shell = %shell,
error = %e,
"env reconcile: shell rc source failed"
);
unsafe { std::env::set_var("_RSCLAW_ENV_INHERITED", "1") };
return None;
}
};
if !output.status.success() {
tracing::warn!(
shell = %shell,
exit = ?output.status.code(),
"env reconcile: shell rc source exited non-zero"
);
unsafe { std::env::set_var("_RSCLAW_ENV_INHERITED", "1") };
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let want: std::collections::HashSet<&str> = wanted.iter().map(String::as_str).collect();
let mut found = BTreeMap::new();
for line in stdout.lines() {
let Some((k, v)) = line.split_once('=') else {
continue;
};
if want.contains(k) {
found.insert(k.to_owned(), v.to_owned());
}
}
unsafe { std::env::set_var("_RSCLAW_ENV_INHERITED", "1") };
if found.is_empty() { None } else { Some(found) }
}
fn resolve_login_shell() -> Option<String> {
if let Ok(s) = std::env::var("SHELL") {
if !s.is_empty() && Path::new(&s).exists() {
return Some(s);
}
}
let user = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.ok()?;
#[cfg(target_os = "macos")]
{
let out = Command::new("dscl")
.args([".", "-read", &format!("/Users/{user}"), "UserShell"])
.output()
.ok()?;
if out.status.success() {
let s = String::from_utf8_lossy(&out.stdout);
if let Some((_, v)) = s.split_once(':') {
let shell = v.trim().to_owned();
if Path::new(&shell).exists() {
return Some(shell);
}
}
}
}
#[cfg(target_os = "linux")]
{
let out = Command::new("getent")
.args(["passwd", &user])
.output()
.ok()?;
if out.status.success() {
let s = String::from_utf8_lossy(&out.stdout);
let shell = s.trim().rsplit(':').next()?.to_owned();
if Path::new(&shell).exists() {
return Some(shell);
}
}
}
None
}
fn run_with_timeout(mut cmd: Command, dur: Duration) -> Result<std::process::Output> {
let child = cmd.spawn()?;
let pid = child.id();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(child.wait_with_output());
});
match rx.recv_timeout(dur) {
Ok(Ok(output)) => Ok(output),
Ok(Err(e)) => Err(e.into()),
Err(_) => {
#[cfg(unix)]
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
const SIGKILL_GRACE: Duration = Duration::from_millis(500);
if rx.recv_timeout(SIGKILL_GRACE).is_err() {
libc::kill(pid as i32, libc::SIGKILL);
let _ = rx.recv_timeout(Duration::from_millis(500));
}
}
anyhow::bail!("shell command timed out after {dur:?}")
}
}
}
static PLACEHOLDER_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("valid regex")
});
static SECRETREF_ENV_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r#""?source"?\s*:\s*"env"\s*,[^}]*?"?id"?\s*:\s*["']([A-Za-z_][A-Za-z0-9_]*)["']"#)
.expect("valid regex")
});
const PLACEHOLDER_SENTINEL_PREFIX: &str = "__RSCLAW_PH_";
const PLACEHOLDER_SENTINEL_SUFFIX: &str = "__";
fn strip_json5_comments(raw: &str) -> String {
let bytes = raw.as_bytes();
let mut out = String::with_capacity(raw.len());
let mut i = 0;
let mut in_string: Option<u8> = None;
while i < bytes.len() {
let c = bytes[i];
if let Some(q) = in_string {
out.push(c as char);
if c == b'\\' && i + 1 < bytes.len() {
out.push(bytes[i + 1] as char);
i += 2;
continue;
}
if c == q {
in_string = None;
}
i += 1;
continue;
}
if c == b'/' && i + 1 < bytes.len() {
match bytes[i + 1] {
b'/' => {
i += 2;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
b'*' => {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
i = (i + 2).min(bytes.len());
continue;
}
_ => {}
}
}
if c == b'"' || c == b'\'' {
in_string = Some(c);
}
out.push(c as char);
i += 1;
}
out
}
fn walk_value_for_refs(v: &serde_json::Value, out: &mut BTreeSet<String>) {
use serde_json::Value;
match v {
Value::Object(m) => {
let is_env_ref = matches!(m.get("source"), Some(Value::String(s)) if s == "env");
if is_env_ref {
if let Some(Value::String(raw_id)) = m.get("id") {
let id = raw_id.as_str();
let is_sentinel = id.starts_with(PLACEHOLDER_SENTINEL_PREFIX)
&& id.ends_with(PLACEHOLDER_SENTINEL_SUFFIX);
if !is_sentinel
&& !id.is_empty()
&& id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& id.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
{
out.insert(id.to_owned());
}
}
}
for v in m.values() {
walk_value_for_refs(v, out);
}
}
Value::Array(arr) => {
for v in arr {
walk_value_for_refs(v, out);
}
}
_ => {}
}
}
pub fn scan_var_refs(raw: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for caps in PLACEHOLDER_RE.captures_iter(raw) {
out.insert(caps[1].to_owned());
}
let sentinel = PLACEHOLDER_RE.replace_all(raw, |caps: ®ex::Captures<'_>| {
format!(
"\"{prefix}{name}{suffix}\"",
prefix = PLACEHOLDER_SENTINEL_PREFIX,
name = &caps[1],
suffix = PLACEHOLDER_SENTINEL_SUFFIX,
)
});
match json5::from_str::<serde_json::Value>(&sentinel) {
Ok(v) => walk_value_for_refs(&v, &mut out),
Err(_) => {
let stripped = strip_json5_comments(raw);
for caps in SECRETREF_ENV_RE.captures_iter(&stripped) {
out.insert(caps[1].to_owned());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn map(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn bmap(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn hand_edited_env_value_is_not_overwritten_by_stale_shell() {
let shell = map(&[("RSCLAW_API_KEY", "old-stale-from-shell")]);
let file = bmap(&[("RSCLAW_API_KEY", "new-hand-edited")]);
let needed: BTreeSet<String> = ["RSCLAW_API_KEY".to_string()].into_iter().collect();
let captured = vars_to_capture(&shell, &file, &needed);
assert!(
captured.is_empty(),
"must not overwrite an existing .env value from the shell, got {captured:?}"
);
}
#[test]
fn shell_only_var_is_captured_into_env() {
let shell = map(&[("RSCLAW_API_KEY", "from-shell"), ("UNRELATED", "x")]);
let file: BTreeMap<String, String> = BTreeMap::new();
let needed: BTreeSet<String> = ["RSCLAW_API_KEY".to_string()].into_iter().collect();
let captured = vars_to_capture(&shell, &file, &needed);
assert_eq!(
captured,
vec![("RSCLAW_API_KEY".to_string(), "from-shell".to_string())],
"only the referenced shell-only var should be captured"
);
}
#[test]
fn var_missing_everywhere_is_not_captured() {
let shell: HashMap<String, String> = HashMap::new();
let file: BTreeMap<String, String> = BTreeMap::new();
let needed: BTreeSet<String> = ["RSCLAW_API_KEY".to_string()].into_iter().collect();
assert!(vars_to_capture(&shell, &file, &needed).is_empty());
}
#[test]
fn scan_placeholders_collects_all_unique() {
let raw = r#"{
apiKey: "${RSCLAW_API_KEY}",
other: "${FOO_BAR}",
same: "${RSCLAW_API_KEY}",
}"#;
let got = scan_var_refs(raw);
assert_eq!(got.len(), 2, "got {got:?}");
assert!(got.contains("RSCLAW_API_KEY"));
assert!(got.contains("FOO_BAR"));
}
#[test]
fn scan_secretref_env_captures_id() {
let raw = r#"{
"apiKey": {"source": "env", "id": "MY_KEY"},
"other": {"source": "file", "path": "/x"}
}"#;
let got = scan_var_refs(raw);
assert!(got.contains("MY_KEY"), "got {got:?}");
assert_eq!(got.len(), 1, "got {got:?}");
}
#[test]
fn scan_handles_mixed_refs() {
let raw = r#"{
top: "${FROM_PLACEHOLDER}",
nested: { apiKey: {source: "env", id: "FROM_REF"} }
}"#;
let got = scan_var_refs(raw);
assert!(got.contains("FROM_PLACEHOLDER"));
assert!(got.contains("FROM_REF"));
}
#[test]
fn secretref_inside_string_literal_ignored() {
let raw = r#"{
tools: [{
name: "explain_secret_ref",
description: "Pass {source:'env', id:'PATH'} to fetch process PATH at runtime."
}],
apiKey: {source: "env", id: "REAL_KEY"}
}"#;
let got = scan_var_refs(raw);
assert!(got.contains("REAL_KEY"), "real ref missing: {got:?}");
assert!(!got.contains("PATH"), "false positive captured: {got:?}");
}
#[test]
fn secretref_inside_line_comment_ignored() {
let raw = r#"{
// example: apiKey: {source: "env", id: "EXAMPLE_KEY"}
apiKey: {source: "env", id: "REAL_KEY"}
}"#;
let got = scan_var_refs(raw);
assert!(got.contains("REAL_KEY"));
assert!(!got.contains("EXAMPLE_KEY"), "comment captured: {got:?}");
}
#[test]
fn secretref_inside_block_comment_ignored() {
let raw = r#"{
/* legacy: {source: "env", id: "OLD_KEY"} */
apiKey: {source: "env", id: "REAL_KEY"}
}"#;
let got = scan_var_refs(raw);
assert!(got.contains("REAL_KEY"));
assert!(!got.contains("OLD_KEY"), "block comment captured: {got:?}");
}
#[test]
fn placeholder_outside_string_still_captured() {
let raw = r#"{
port: ${PORT},
apiKey: "${MY_KEY}"
}"#;
let got = scan_var_refs(raw);
assert!(got.contains("PORT"), "got {got:?}");
assert!(got.contains("MY_KEY"), "got {got:?}");
}
}