feat(spaceshd): configurable default shell

Add a config.rs module that loads optional ~/.spacesh/config.toml and a
default_shell() resolver: SPACESH_SHELL env -> config.toml default_shell ->
login shell from the passwd DB -> $SHELL -> /bin/sh. The passwd lookup
matters under launchd where $SHELL is absent. All plain-panel spawn sites in
server.rs now use the resolver. Adds toml + libc deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 08:01:04 +07:00
parent 0014d9358d
commit 6a3875670a
6 changed files with 192 additions and 3 deletions
+2
View File
@@ -22,3 +22,5 @@ thiserror.workspace = true
futures.workspace = true
fs2.workspace = true
dirs.workspace = true
toml.workspace = true
libc.workspace = true
+101
View File
@@ -0,0 +1,101 @@
//! Daemon configuration loaded from `~/.spacesh/config.toml`.
//!
//! The file is optional; every field has a sane fallback so a missing or
//! partial config never breaks startup.
use std::path::Path;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Config {
/// Shell launched for plain (no-command) panels. When unset, the daemon
/// auto-detects the user's login shell.
#[serde(default)]
pub default_shell: Option<String>,
}
impl Config {
/// Load `~/.spacesh/config.toml`. Any error (missing file, bad TOML) yields defaults.
pub fn load() -> Self {
let Ok(dir) = crate::lifecycle::spacesh_dir() else { return Self::default() };
Self::from_path(&dir.join("config.toml"))
}
pub fn from_path(path: &Path) -> Self {
match std::fs::read_to_string(path) {
Ok(s) => toml::from_str(&s).unwrap_or_default(),
Err(_) => Self::default(),
}
}
}
/// Resolve the shell to spawn for a plain panel.
///
/// Order: `SPACESH_SHELL` env → `config.toml` `default_shell` → login shell
/// from the passwd DB → `$SHELL` → `/bin/sh`. The passwd lookup matters under
/// launchd, where `$SHELL` is typically absent (so a bash fallback would win).
pub fn default_shell() -> String {
if let Ok(s) = std::env::var("SPACESH_SHELL") {
if !s.is_empty() { return s; }
}
if let Some(s) = Config::load().default_shell {
if !s.is_empty() { return s; }
}
if let Some(s) = login_shell() {
return s;
}
if let Ok(s) = std::env::var("SHELL") {
if !s.is_empty() { return s; }
}
"/bin/sh".into()
}
/// The current user's login shell from the passwd database (`getpwuid`).
#[cfg(unix)]
fn login_shell() -> Option<String> {
use std::ffi::CStr;
// SAFETY: getpwuid returns a pointer into a static buffer valid until the
// next libc passwd call; we copy the string out immediately on this thread.
unsafe {
let pw = libc::getpwuid(libc::getuid());
if pw.is_null() { return None; }
let shell = (*pw).pw_shell;
if shell.is_null() { return None; }
let s = CStr::from_ptr(shell).to_str().ok()?;
if s.is_empty() { None } else { Some(s.to_string()) }
}
}
#[cfg(not(unix))]
fn login_shell() -> Option<String> { None }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_is_default() {
let c = Config::from_path(Path::new("/no/such/spacesh/config.toml"));
assert!(c.default_shell.is_none());
}
#[test]
fn parses_default_shell() {
let dir = std::env::temp_dir().join("spacesh-cfg-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(&path, "default_shell = \"/bin/zsh\"\n").unwrap();
let c = Config::from_path(&path);
assert_eq!(c.default_shell.as_deref(), Some("/bin/zsh"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn env_override_wins() {
let _serial = crate::test_support::serial();
std::env::set_var("SPACESH_SHELL", "/tmp/fake-shell");
let s = default_shell();
std::env::remove_var("SPACESH_SHELL");
assert_eq!(s, "/tmp/fake-shell");
}
}
+1
View File
@@ -1,3 +1,4 @@
mod config;
mod event_log;
mod event_store;
mod hooks;
+3 -3
View File
@@ -298,7 +298,7 @@ async fn handle_request(
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
};
let sid = reg.new_surface_id();
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
let shell = command.clone().unwrap_or_else(crate::config::default_shell);
let spec = SurfaceSpec {
command: shell, args: args.clone(), cwd: ws.path.clone(),
agent_label: command, cols, rows, autostart: false,
@@ -333,7 +333,7 @@ async fn handle_request(
};
let ws = reg.workspace(&ws_id).cloned().unwrap();
let new_sid = reg.new_surface_id();
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
let shell = command.clone().unwrap_or_else(crate::config::default_shell);
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
let (env, hooks_active) = spawn_env(&new_sid, &spec);
match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, env, hooks_active, state_tx.clone(), exit_tx.clone()) {
@@ -406,7 +406,7 @@ async fn handle_request(
let slot = slots.get(i);
let new_sid = reg.new_surface_id();
let command = slot.and_then(|s| s.command.clone());
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
let shell = command.clone().unwrap_or_else(crate::config::default_shell);
let args = slot.map(|s| s.args.clone()).unwrap_or_default();
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
let (env, hooks_active) = spawn_env(&new_sid, &spec);