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
Generated
+83
View File
@@ -246,6 +246,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@@ -382,6 +388,12 @@ dependencies = [
"wasi", "wasi",
] ]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -403,6 +415,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]] [[package]]
name = "ioctl-rs" name = "ioctl-rs"
version = "0.1.6" version = "0.1.6"
@@ -736,6 +758,15 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serial" name = "serial"
version = "0.4.0" version = "0.4.0"
@@ -889,6 +920,7 @@ dependencies = [
"dirs", "dirs",
"fs2", "fs2",
"futures", "futures",
"libc",
"serde", "serde",
"serde_json", "serde_json",
"spacesh-core", "spacesh-core",
@@ -897,6 +929,7 @@ dependencies = [
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-util", "tokio-util",
"toml",
] ]
[[package]] [[package]]
@@ -986,6 +1019,47 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -1200,6 +1274,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winreg" name = "winreg"
version = "0.10.1" version = "0.10.1"
+2
View File
@@ -26,5 +26,7 @@ portable-pty = "0.8"
alacritty_terminal = "0.25" alacritty_terminal = "0.25"
fs2 = "0.4" fs2 = "0.4"
dirs = "5" dirs = "5"
toml = "0.8"
libc = "0.2"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap_complete = "4" clap_complete = "4"
+2
View File
@@ -22,3 +22,5 @@ thiserror.workspace = true
futures.workspace = true futures.workspace = true
fs2.workspace = true fs2.workspace = true
dirs.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_log;
mod event_store; mod event_store;
mod hooks; 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 _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
}; };
let sid = reg.new_surface_id(); 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 { let spec = SurfaceSpec {
command: shell, args: args.clone(), cwd: ws.path.clone(), command: shell, args: args.clone(), cwd: ws.path.clone(),
agent_label: command, cols, rows, autostart: false, agent_label: command, cols, rows, autostart: false,
@@ -333,7 +333,7 @@ async fn handle_request(
}; };
let ws = reg.workspace(&ws_id).cloned().unwrap(); let ws = reg.workspace(&ws_id).cloned().unwrap();
let new_sid = reg.new_surface_id(); 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 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); 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()) { 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 slot = slots.get(i);
let new_sid = reg.new_surface_id(); let new_sid = reg.new_surface_id();
let command = slot.and_then(|s| s.command.clone()); 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 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 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); let (env, hooks_active) = spawn_env(&new_sid, &spec);