pub mod attach;
pub mod daemon;
pub mod launcher;
pub mod session;
#[must_use]
pub fn intercept_internal_reexec() -> Option<anyhow::Result<()>> {
if let Some(result) = attach::intercept_from_env() {
return Some(result);
}
daemon::intercept_from_env()
}
use std::path::PathBuf;
use std::process::id as process_id;
use std::sync::atomic::{AtomicU64, Ordering};
use ahash::AHashMap;
use anyhow::{Context, Result};
use rmux_sdk::{Rmux, RmuxBuilder, SessionName};
use tokio::sync::{Mutex, OnceCell};
use self::session::{ShellCommand, SpawnSpec};
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct SessionId(String);
impl SessionId {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SessionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShellSessionInfo {
pub session_id: SessionId,
pub name: SessionName,
pub alive: bool,
}
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
fn next_session_id() -> SessionId {
let n = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
SessionId(format!("bmsh-{}-{}", process_id(), n))
}
pub struct ShellRuntime {
socket_path: PathBuf,
rmux: OnceCell<Rmux>,
sessions: Mutex<AHashMap<SessionId, SessionName>>,
}
pub const SHELLS_SOCKET_ENV: &str = "BASEMIND_SHELLS_SOCKET";
impl ShellRuntime {
#[must_use]
pub fn new() -> Self {
let socket = std::env::var_os(SHELLS_SOCKET_ENV)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.filter(|path| match daemon::validate_socket_path(path) {
Ok(()) => true,
Err(error) => {
tracing::warn!(
error = %error,
path = %path.display(),
"BASEMIND_SHELLS_SOCKET rejected; falling back to the default socket path"
);
false
}
})
.unwrap_or_else(default_socket_path);
Self::with_socket_path(socket)
}
#[must_use]
pub fn with_socket_path(socket_path: PathBuf) -> Self {
Self {
socket_path,
rmux: OnceCell::new(),
sessions: Mutex::new(AHashMap::new()),
}
}
#[must_use]
pub fn socket_path(&self) -> &std::path::Path {
&self.socket_path
}
pub async fn rmux(&self) -> Result<&Rmux> {
self.rmux
.get_or_try_init(|| async {
self.rmux_builder()
.connect_or_start()
.await
.context("connect to (or start) embedded rmux daemon")
})
.await
}
fn rmux_builder(&self) -> RmuxBuilder {
#[cfg(windows)]
{
RmuxBuilder::new().windows_pipe(self.socket_path.to_string_lossy().into_owned())
}
#[cfg(not(windows))]
{
RmuxBuilder::new().unix_socket(self.socket_path.clone())
}
}
#[must_use]
pub fn mint_session_id(&self) -> SessionId {
next_session_id()
}
pub async fn spawn(
&self,
session_id: SessionId,
command: ShellCommand,
working_directory: Option<String>,
environment: Vec<String>,
cols: u16,
rows: u16,
) -> Result<(SessionId, SessionName)> {
let rmux = self.rmux().await?;
let name = SessionName::new(session_id.as_str())
.map_err(|e| anyhow::anyhow!("mint rmux session name: {e}"))?;
let spec = SpawnSpec {
name: name.clone(),
command,
working_directory,
environment,
cols,
rows,
};
let _session = session::spawn_session(rmux, spec).await?;
self.sessions
.lock()
.await
.insert(session_id.clone(), name.clone());
Ok((session_id, name))
}
pub async fn resolve(&self, id: &SessionId) -> Option<SessionName> {
self.sessions.lock().await.get(id).cloned()
}
pub async fn forget(&self, id: &SessionId) {
self.sessions.lock().await.remove(id);
}
pub async fn broadcast(&self, ids: &[SessionId], text: &str, enter: bool) -> Result<usize> {
let names = {
let map = self.sessions.lock().await;
let mut names = Vec::with_capacity(ids.len());
for id in ids {
let name = map
.get(id)
.cloned()
.with_context(|| format!("unknown session_id {id}"))?;
names.push(name);
}
names
};
let rmux = self.rmux().await?;
session::broadcast(rmux, &names, text, enter).await
}
pub async fn list(&self) -> Result<Vec<ShellSessionInfo>> {
let mapped: Vec<(SessionId, SessionName)> = {
let map = self.sessions.lock().await;
map.iter()
.map(|(id, name)| (id.clone(), name.clone()))
.collect()
};
let rmux = self.rmux().await?;
let live = session::list_sessions(rmux).await?;
let live: ahash::AHashSet<&SessionName> = live.iter().collect();
let infos: Vec<ShellSessionInfo> = mapped
.into_iter()
.map(|(session_id, name)| {
let alive = live.contains(&name);
ShellSessionInfo {
session_id,
name,
alive,
}
})
.collect();
{
let mut map = self.sessions.lock().await;
for info in &infos {
if !info.alive {
map.remove(&info.session_id);
}
}
}
Ok(infos)
}
}
impl Default for ShellRuntime {
fn default() -> Self {
Self::new()
}
}
#[cfg(not(windows))]
const SHELLS_SUBDIR: &str = "shells";
#[cfg(not(windows))]
const SHELLS_SOCKET_FILE: &str = "rmux.sock";
#[cfg(unix)]
const OWNER_ONLY_DIR: u32 = 0o700;
fn default_socket_path() -> PathBuf {
#[cfg(windows)]
{
PathBuf::from(format!(r"\\.\pipe\basemind-shells-{}", user_namespace()))
}
#[cfg(not(windows))]
{
if let Some(path) = project_dirs_socket_path() {
return path;
}
let mut dir = std::env::temp_dir();
dir.push(format!("basemind-shells-{}.sock", user_namespace()));
dir
}
}
#[cfg(not(windows))]
fn project_dirs_socket_path() -> Option<PathBuf> {
let dirs = directories::ProjectDirs::from("", "", "basemind")?;
let shells_dir = dirs.data_dir().join(SHELLS_SUBDIR);
if std::fs::create_dir_all(&shells_dir).is_err() {
return None;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
std::fs::set_permissions(&shells_dir, std::fs::Permissions::from_mode(OWNER_ONLY_DIR));
}
Some(shells_dir.join(SHELLS_SOCKET_FILE))
}
fn user_namespace() -> String {
let raw = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| process_id().to_string());
sanitize_namespace(&raw)
}
fn sanitize_namespace(raw: &str) -> String {
raw.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_ids_are_monotonic_and_pid_scoped() {
let a = next_session_id();
let b = next_session_id();
assert_ne!(a, b);
let pid_prefix = format!("bmsh-{}-", process_id());
assert!(a.as_str().starts_with(&pid_prefix));
assert!(b.as_str().starts_with(&pid_prefix));
}
#[test]
fn session_id_is_a_valid_rmux_name_unchanged() {
let id = next_session_id();
let name = SessionName::new(id.as_str()).expect("valid name");
assert_eq!(name.as_str(), id.as_str());
}
#[test]
fn sanitize_namespace_strips_shell_metacharacters() {
assert_eq!(sanitize_namespace("alice"), "alice");
assert_eq!(sanitize_namespace("a-b_c"), "a-b_c");
assert_eq!(sanitize_namespace("a;b\nc"), "a_b_c");
assert_eq!(sanitize_namespace("evil$(whoami)"), "evil__whoami_");
assert_eq!(sanitize_namespace("x\0y\"z"), "x_y_z");
}
#[test]
fn mint_session_id_yields_distinct_ids() {
let runtime = ShellRuntime::with_socket_path(PathBuf::from("/tmp/unused.sock"));
let a = runtime.mint_session_id();
let b = runtime.mint_session_id();
assert_ne!(a, b, "each mint yields a fresh id");
}
}