From 76b1080a2687b0aaef72c5faff2d9a77eef85d25 Mon Sep 17 00:00:00 2001 From: louis Date: Fri, 5 Jun 2026 23:33:30 -0400 Subject: [PATCH] feat(scripting): run .tmux plugin files natively Sourcing a `.tmux` file now executes it as a shell script with a `tmux` shim placed on PATH that proxies commands back to the running rmux server over its socket. This lets tmux plugins (e.g. TPM-style `.tmux` entry scripts) drive rmux directly, instead of being parsed as config. The shim and script execution are Unix-only; the executable-bit setup is gated behind cfg(unix) so the crate still builds on Windows. --- Cargo.lock | 24 +++++- crates/rmux-server/Cargo.toml | 1 + .../src/handler_scripting/source_runtime.rs | 73 +++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd9ccd05..a01c1c4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -480,6 +480,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1293,6 +1299,7 @@ dependencies = [ "sha2", "signal-hook", "subtle", + "tempfile", "tokio", "toml", "tracing", @@ -1545,6 +1552,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1666,9 +1686,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" diff --git a/crates/rmux-server/Cargo.toml b/crates/rmux-server/Cargo.toml index 4d59b98b..f5edd763 100644 --- a/crates/rmux-server/Cargo.toml +++ b/crates/rmux-server/Cargo.toml @@ -30,6 +30,7 @@ serde_json = { version = "1", optional = true } sha1 = { version = "0.10", optional = true } sha2 = { version = "0.10", optional = true } subtle = { version = "2.6", optional = true } +tempfile = "3" tokio = { version = "1.48.0", features = ["io-util", "macros", "net", "process", "rt", "sync", "time"] } toml = { version = "0.8", optional = true } tracing = "0.1" diff --git a/crates/rmux-server/src/handler_scripting/source_runtime.rs b/crates/rmux-server/src/handler_scripting/source_runtime.rs index 4de1ca69..908bc17b 100644 --- a/crates/rmux-server/src/handler_scripting/source_runtime.rs +++ b/crates/rmux-server/src/handler_scripting/source_runtime.rs @@ -1,3 +1,5 @@ +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::sync::atomic::Ordering; @@ -101,6 +103,60 @@ impl RequestHandler { self.config_loading_depth.fetch_sub(1, Ordering::Relaxed); } + fn extract_plugin_paths(command: &ParsedSourceFileCommand) -> Vec<&str> { + command + .paths + .iter() + .filter(|p| p.ends_with(".tmux")) + .map(String::as_str) + .collect() + } + + async fn run_plugin_scripts(&self, plugin_paths: Vec<&str>) -> Result<(), RmuxError> { + let socket_path = self.server_socket_path.lock().unwrap().clone(); + let session_name = { + let state = self.state.lock().await; + let name = state.sessions.iter().next().map(|(name, _)| name.clone()); + name + }; + let Some(session_name) = session_name else { + return Err(RmuxError::Server("no active session".to_owned())); + }; + let rmux_binary = std::env::current_exe().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let shim_path = tmp.path().join("tmux"); + std::fs::write( + &shim_path, + format!( + "#!/bin/sh\nexec {} -S \"$RMUX_SOCKET\" \"$@\"", + rmux_binary.display() + ), + ) + .unwrap(); + #[cfg(unix)] + std::fs::set_permissions(&shim_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path_env = format!( + "{}:{}", + tmp.path().display(), + std::env::var("PATH").unwrap_or_default() + ); + for path in plugin_paths { + let status = tokio::process::Command::new("bash") + .arg(path) + .env("PATH", &path_env) + .env("RMUX_SOCKET", socket_path.to_string_lossy().as_ref()) + .env("RMUX_SESSION", session_name.as_str()) + .status() + .await; + if let Err(e) = status { + return Err(RmuxError::Server(format!( + "failed to run plugin {path}: {e}" + ))); + } + } + Ok(()) + } + pub(in crate::handler) async fn handle_source_file( &self, requester_pid: u32, @@ -110,6 +166,15 @@ impl RequestHandler { if command.target.is_none() { command.target = self.implicit_source_file_target(requester_pid).await; } + + let plugin_paths = Self::extract_plugin_paths(&command); + if !plugin_paths.is_empty() { + return match self.run_plugin_scripts(plugin_paths).await { + Ok(()) => Response::SourceFile(SourceFileResponse::no_output()), + Err(error) => Response::Error(ErrorResponse { error }), + }; + } + let mut loaded = match self.load_source_file_command(&command, 1).await { Ok(loaded) => loaded, Err(error) => return Response::Error(ErrorResponse { error }), @@ -167,6 +232,14 @@ impl RequestHandler { mut command: ParsedSourceFileCommand, context: &QueueExecutionContext, ) -> Result { + let plugin_paths = Self::extract_plugin_paths(&command); + if !plugin_paths.is_empty() { + self.run_plugin_scripts(plugin_paths).await?; + return Ok(QueueCommandAction::Normal { + output: None, + error: None, + }); + } let depth = context.source_file_depth.saturating_add(1); command.current_file = context.current_file.clone(); let mut loaded = self.load_source_file_command(&command, depth).await?;