From ae4f7fa984e93d33447b55f8b6b0dc19ece7483f Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Sun, 26 Jul 2026 22:27:25 -0700 Subject: [PATCH] [silverwood] Resolve the base shell from the passwd DB, not $SHELL The base shell is documented as a clean login shell that does not inherit the parent process's environment, but it chose the shell binary from the inherited $SHELL. A frontend launched from a nix devshell has $SHELL overwritten with the devshell's bash, so panes spawned `bash -l` instead of the user's real login shell (e.g. fish); a packaged build inherited the real $SHELL and worked. Only the value of $SHELL differed between them. Resolve the login identity (shell/home/user) from the passwd database (getpwuid) in spawn_seed(), falling back to env vars only if the lookup fails, so the base shell is genuinely independent of the launching env. Also overlay SHELL (never exported by the login shell) and a default LANG onto the captured login env, which previously had neither on the capture-success path. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GBWyw3DyRvvNmgJpFZBsC5 --- experimental/silverwood/Cargo.lock | 1 + .../crates/silverwood-cli/Cargo.toml | 1 + .../crates/silverwood-cli/src/main.rs | 69 ++++++++++++-- .../crates/silverwood-core/src/spawn.rs | 92 ++++++++++++++++--- 4 files changed, 144 insertions(+), 19 deletions(-) diff --git a/experimental/silverwood/Cargo.lock b/experimental/silverwood/Cargo.lock index 8f571e6..2548829 100644 --- a/experimental/silverwood/Cargo.lock +++ b/experimental/silverwood/Cargo.lock @@ -1541,6 +1541,7 @@ name = "silverwood-cli" version = "0.1.0" dependencies = [ "clap", + "libc", "serde", "serde_json", "silverwood-core", diff --git a/experimental/silverwood/crates/silverwood-cli/Cargo.toml b/experimental/silverwood/crates/silverwood-cli/Cargo.toml index ad92620..87f3a9e 100644 --- a/experimental/silverwood/crates/silverwood-cli/Cargo.toml +++ b/experimental/silverwood/crates/silverwood-cli/Cargo.toml @@ -15,6 +15,7 @@ silverwood-core.workspace = true clap.workspace = true serde.workspace = true serde_json.workspace = true +libc.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/experimental/silverwood/crates/silverwood-cli/src/main.rs b/experimental/silverwood/crates/silverwood-cli/src/main.rs index 22148f9..d37e848 100644 --- a/experimental/silverwood/crates/silverwood-cli/src/main.rs +++ b/experimental/silverwood/crates/silverwood-cli/src/main.rs @@ -654,16 +654,69 @@ fn run_spawn( Err(format!("exec {}: {err}", program.display()).into()) } -/// Gather the spawn's dynamic inputs from the environment — frontend policy, like -/// [`resolve_forest_dir`] (`silverwood-core` never reads env). `HOME` is required -/// to reconstruct a login environment; `SHELL` defaults if unset. +/// The user's real login identity, read from the passwd database. +struct LoginIdentity { + name: String, + home: String, + shell: String, +} + +/// Read the user's real login identity from the passwd database (`getpwuid(getuid())`) +/// — authoritative and independent of the process env, which a nix devshell pollutes +/// (it overwrites `$SHELL`). On macOS this consults Directory Services; on Linux, +/// `/etc/passwd`/nsswitch. Returns `None` if the entry is missing or a field is +/// empty/non-UTF8, so the caller falls back to env vars. +fn login_identity() -> Option { + // SAFETY: getpwuid returns a pointer to a static buffer valid until the next getpw* + // call; we copy every field out immediately and never retain the pointer. spawn_seed + // is called once at startup on the main thread, so the shared buffer is not raced. + unsafe { + let pw = libc::getpwuid(libc::getuid()); + if pw.is_null() { + return None; + } + let pw = &*pw; + let field = |p: *const libc::c_char| -> Option { + if p.is_null() { + return None; + } + std::ffi::CStr::from_ptr(p) + .to_str() + .ok() + .filter(|s| !s.is_empty()) + .map(String::from) + }; + Some(LoginIdentity { + name: field(pw.pw_name)?, + home: field(pw.pw_dir)?, + shell: field(pw.pw_shell)?, + }) + } +} + +/// Gather the spawn's dynamic inputs — frontend policy, like [`resolve_forest_dir`] +/// (`silverwood-core` never touches env/the passwd DB). Identity (`home`/`user`/`shell`) +/// comes from the passwd database via [`login_identity`] so it is independent of the +/// process env (a nix devshell overwrites `$SHELL`); env vars are only a fallback. `HOME` +/// is required to reconstruct a login environment. `term`/`ssh_auth_sock` are session +/// context, so they stay env-sourced. fn spawn_seed() -> Result> { - let home = - std::env::var("HOME").map_err(|_| "no HOME: cannot reconstruct a login environment")?; - let user = std::env::var("USER") - .ok() + let login = login_identity(); + let home = login + .as_ref() + .map(|l| l.home.clone()) + .or_else(|| std::env::var("HOME").ok()) + .ok_or("no HOME: cannot reconstruct a login environment")?; + let user = login + .as_ref() + .map(|l| l.name.clone()) + .or_else(|| std::env::var("USER").ok()) .or_else(|| std::env::var("LOGNAME").ok()); - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + let shell = login + .as_ref() + .map(|l| l.shell.clone()) + .or_else(|| std::env::var("SHELL").ok()) + .unwrap_or_else(|| "/bin/zsh".to_string()); let term = std::env::var("TERM").ok(); let ssh_auth_sock = std::env::var("SSH_AUTH_SOCK").ok(); Ok(SpawnSeed { diff --git a/experimental/silverwood/crates/silverwood-core/src/spawn.rs b/experimental/silverwood/crates/silverwood-core/src/spawn.rs index bfc80ec..347d6e8 100644 --- a/experimental/silverwood/crates/silverwood-core/src/spawn.rs +++ b/experimental/silverwood/crates/silverwood-core/src/spawn.rs @@ -13,10 +13,12 @@ //! //! The env scrub mirrors what papyrus used to do inline: `process.env` in a //! frontend launched from a nix devshell is polluted (`IN_NIX_SHELL`, `DEVENV_*`, -//! an augmented `PATH`), so a spawned agent must not inherit it. Instead the -//! user's real login environment is reconstructed by running the login shell from -//! an empty base in a dir with no `.envrc`, and the two dynamic vars a terminal -//! needs (`TERM`, the ssh-agent socket) are overlaid on top. +//! an augmented `PATH`, an overwritten `$SHELL`), so a spawned agent must not inherit +//! it. Instead the user's real login environment is reconstructed by running the login +//! shell from an empty base in a dir with no `.envrc`, and the vars a login capture +//! can't produce are overlaid on top (`TERM`, the ssh-agent socket, `SHELL`, a default +//! `LANG`) — see `overlay_session_env`. The login shell itself comes from the passwd +//! database (via the frontend's `SpawnSeed`), not the polluted `$SHELL`. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -25,17 +27,18 @@ use std::process::Command; use crate::workstream::CheckoutMode; /// The dynamic, machine/session inputs a spawn needs, gathered by the frontend. -/// Reading these from the environment is frontend policy (like the forest dir), -/// so core takes them explicitly rather than touching `std::env`. `home`/`user`/ -/// `shell` seed the login-env capture; `term`/`ssh_auth_sock` are overlaid onto -/// the captured env (a login capture can't have them). +/// Resolving these (identity from the passwd database, session context from the +/// environment) is frontend policy, so core takes them explicitly rather than touching +/// `std::env`/the passwd DB itself. `home`/`user`/`shell` seed the login-env capture; +/// `term`/`ssh_auth_sock` are overlaid onto the captured env (a login capture can't +/// have them). #[derive(Debug, Clone)] pub struct SpawnSeed { /// The user's home directory (`$HOME`). pub home: String, /// The login user (`$USER`/`$LOGNAME`), if known. pub user: Option, - /// Absolute path to the user's login shell (`$SHELL`). + /// Absolute path to the user's login shell (from the passwd database). pub shell: String, /// The terminal type to advertise (`$TERM`), if any. pub term: Option, @@ -126,16 +129,30 @@ fn agent_argv(mode: &CheckoutMode, cwd: &str, session_id: &str, resume: bool) -> } /// The scrubbed environment for a spawned shell: the captured login env (or a -/// reconstructed fallback), with `TERM` and the ssh-agent socket overlaid. +/// reconstructed fallback), with the session/identity vars a login capture can't +/// produce overlaid on top (see [`overlay_session_env`]). fn clean_env(seed: &SpawnSeed) -> BTreeMap { let mut env = capture_login_env(seed).unwrap_or_else(|| fallback_env(seed)); + overlay_session_env(&mut env, seed); + env +} + +/// Overlay the vars a login-shell capture can't produce onto `env`: the dynamic terminal +/// context (`TERM`, the ssh-agent socket), `SHELL` (set by `login(1)`, never exported by +/// the shell itself), and a default `LANG` when the capture has none (macOS injects the +/// locale via Terminal.app, not the login shell). `SHELL` is authoritative (a hard +/// overlay); `LANG` is only a default, so a shell that sets its own locale wins. +fn overlay_session_env(env: &mut BTreeMap, seed: &SpawnSeed) { if let Some(term) = &seed.term { env.insert("TERM".to_string(), term.clone()); } if let Some(sock) = &seed.ssh_auth_sock { env.insert("SSH_AUTH_SOCK".to_string(), sock.clone()); } - env + env.insert("SHELL".to_string(), seed.shell.clone()); + if !env.contains_key("LANG") && !env.contains_key("LC_ALL") { + env.insert("LANG".to_string(), "en_US.UTF-8".to_string()); + } } /// Run the user's login shell from an empty base and read back its exported env: @@ -328,4 +345,57 @@ mod tests { }; assert_eq!(plan.resolve_program(), Some(PathBuf::from("/bin/zsh"))); } + + fn seed_with_shell(shell: &str) -> SpawnSeed { + SpawnSeed { + home: "/home/x".to_string(), + user: Some("x".to_string()), + shell: shell.to_string(), + term: Some("xterm-256color".to_string()), + ssh_auth_sock: Some("/tmp/agent.sock".to_string()), + } + } + + #[test] + fn overlay_sets_shell_authoritatively() { + // A stale SHELL in the captured env must lose to the resolved login shell. + let mut env = BTreeMap::new(); + env.insert("SHELL".to_string(), "/bin/bash".to_string()); + overlay_session_env(&mut env, &seed_with_shell("/usr/bin/fish")); + assert_eq!(env.get("SHELL"), Some(&"/usr/bin/fish".to_string())); + } + + #[test] + fn overlay_applies_term_and_ssh_auth_sock() { + let mut env = BTreeMap::new(); + overlay_session_env(&mut env, &seed_with_shell("/usr/bin/fish")); + assert_eq!(env.get("TERM"), Some(&"xterm-256color".to_string())); + assert_eq!( + env.get("SSH_AUTH_SOCK"), + Some(&"/tmp/agent.sock".to_string()) + ); + } + + #[test] + fn overlay_defaults_lang_when_absent() { + let mut env = BTreeMap::new(); + overlay_session_env(&mut env, &seed_with_shell("/usr/bin/fish")); + assert_eq!(env.get("LANG"), Some(&"en_US.UTF-8".to_string())); + } + + #[test] + fn overlay_keeps_an_existing_lang() { + let mut env = BTreeMap::new(); + env.insert("LANG".to_string(), "de_DE.UTF-8".to_string()); + overlay_session_env(&mut env, &seed_with_shell("/usr/bin/fish")); + assert_eq!(env.get("LANG"), Some(&"de_DE.UTF-8".to_string())); + } + + #[test] + fn overlay_skips_lang_default_when_lc_all_is_set() { + let mut env = BTreeMap::new(); + env.insert("LC_ALL".to_string(), "C".to_string()); + overlay_session_env(&mut env, &seed_with_shell("/usr/bin/fish")); + assert!(!env.contains_key("LANG")); + } } -- 2.51.2