diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index 3624ca3..2f5893c 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -146,6 +146,14 @@ pub struct DaemonSection { /// `None` ⇒ daemon default (~64 KB); `Some(0)` disables replay. #[serde(default, skip_serializing_if = "Option::is_none")] pub pty_replay_bytes: Option, + /// `TMUX_TMPDIR` the daemon exports for every `tmux` invocation (NAP-234). + /// When unset, the daemon defaults to `$XDG_RUNTIME_DIR` so that bare + /// `tmux ls` in a user shell (which usually has the same env var) sees + /// maestro sessions. Set to `/tmp` to opt back into tmux's own default — + /// useful if the daemon outlives the user's login session and you can't + /// enable `loginctl enable-linger`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tmux_tmpdir: Option, /// `[daemon.policy]` — per-daemon attach policy (NAP-247). #[serde(default, skip_serializing_if = "DaemonPolicySection::is_empty")] pub policy: DaemonPolicySection, @@ -162,6 +170,7 @@ impl DaemonSection { && self.perimeter_id.is_none() && self.private_addr.is_none() && self.pty_replay_bytes.is_none() + && self.tmux_tmpdir.is_none() && self.policy.is_empty() } } @@ -338,6 +347,7 @@ mod tests { perimeter_id: Some("wg-naps62".into()), private_addr: Some("10.0.0.7:7070".into()), pty_replay_bytes: Some(131_072), + tmux_tmpdir: Some(PathBuf::from("/run/user/1000")), policy: DaemonPolicySection { allow_inbound: vec!["konishi".into()], }, diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index 4e52aac..e752540 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -39,6 +39,8 @@ pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR"; pub const ENV_DAEMON_PERIMETER_ID: &str = "MAESTRO_DAEMON_PERIMETER_ID"; /// Env var that overrides `[daemon].private_addr`. pub const ENV_DAEMON_PRIVATE_ADDR: &str = "MAESTRO_DAEMON_PRIVATE_ADDR"; +/// Env var that overrides `[daemon].tmux_tmpdir`. +pub const ENV_DAEMON_TMUX_TMPDIR: &str = "MAESTRO_DAEMON_TMUX_TMPDIR"; fn default_bind_addr() -> SocketAddr { DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr") @@ -90,6 +92,11 @@ pub struct DaemonConfig { /// receives this much of the prior PTY output before the live stream. /// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables. pub pty_replay_bytes: usize, + /// `TMUX_TMPDIR` exported for every `tmux` invocation (NAP-234). + /// `None` lets tmux pick its own default (`/tmp/tmux-$UID`); `Some(p)` + /// forces sockets under `p` so bare `tmux ls` in a matching user shell + /// sees maestro sessions. + pub tmux_tmpdir: Option, } impl DaemonConfig { @@ -113,6 +120,7 @@ pub struct EnvOverrides { pub token: Option, pub perimeter_id: Option, pub private_addr: Option, + pub tmux_tmpdir: Option, } impl EnvOverrides { @@ -126,6 +134,7 @@ impl EnvOverrides { token: std::env::var(ENV_TOKEN).ok(), perimeter_id: std::env::var(ENV_DAEMON_PERIMETER_ID).ok(), private_addr: std::env::var(ENV_DAEMON_PRIVATE_ADDR).ok(), + tmux_tmpdir: std::env::var(ENV_DAEMON_TMUX_TMPDIR).ok(), } } } @@ -205,6 +214,11 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result Result file > dirs::runtime_dir(). + // Tested without mutating the process env (which races other tests) by + // feeding `EnvOverrides` directly. + + #[test] + fn tmux_tmpdir_env_beats_file_and_runtime_dir() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + r#" +[shared] +registry_url = "http://r" +token = "t" + +[daemon] +daemon_url = "ws://x" +tmux_tmpdir = "/from-file" +"#, + ) + .unwrap(); + let env = EnvOverrides { + tmux_tmpdir: Some("/from-env".into()), + ..EnvOverrides::default() + }; + let cfg = load_config(&path, env).unwrap(); + assert_eq!(cfg.tmux_tmpdir.as_deref(), Some(Path::new("/from-env"))); + } + + #[test] + fn tmux_tmpdir_file_beats_runtime_dir() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + r#" +[shared] +registry_url = "http://r" +token = "t" + +[daemon] +daemon_url = "ws://x" +tmux_tmpdir = "/from-file" +"#, + ) + .unwrap(); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + assert_eq!(cfg.tmux_tmpdir.as_deref(), Some(Path::new("/from-file"))); + } + + #[test] + fn tmux_tmpdir_defaults_to_xdg_runtime_dir_when_unset() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, minimal_file()).unwrap(); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + // dirs::runtime_dir() is `Some(_)` on any Linux host with + // $XDG_RUNTIME_DIR set (CI included). When it's `None`, the daemon + // falls through to None and tmux uses /tmp; either is acceptable — + // but on this test runner the env var is always present. + assert_eq!(cfg.tmux_tmpdir, dirs::runtime_dir()); + } } diff --git a/crates/maestro-daemon/src/configure.rs b/crates/maestro-daemon/src/configure.rs index adddee0..70b403d 100644 --- a/crates/maestro-daemon/src/configure.rs +++ b/crates/maestro-daemon/src/configure.rs @@ -131,6 +131,9 @@ pub fn run( // None ⇒ daemon falls back to pty::DEFAULT_REPLAY_BYTES. Override in // config.toml as `[daemon] pty_replay_bytes = ...` (NAP-229). pty_replay_bytes: None, + // None ⇒ daemon resolves to $XDG_RUNTIME_DIR at startup. Override in + // config.toml as `[daemon] tmux_tmpdir = "/tmp"` (NAP-234). + tmux_tmpdir: None, }; file.save(path).map_err(ConfigError::from)?; let _ = handle; // stdout handle only used during prompts; binary prints "wrote ..." diff --git a/crates/maestro-daemon/src/main.rs b/crates/maestro-daemon/src/main.rs index a37a934..a2fd194 100644 --- a/crates/maestro-daemon/src/main.rs +++ b/crates/maestro-daemon/src/main.rs @@ -120,7 +120,10 @@ async fn run(config_override: Option) -> Result<(), Box> { let sidecar = SidecarStore::new(config.sidecar_dir()); let worktree_mgr = WorktreeManager::new(config.worktree_root.clone()); - let tmux = Tmux::new(); + let tmux = match config.tmux_tmpdir.clone() { + Some(p) => Tmux::new().with_tmpdir(p), + None => Tmux::new(), + }; let manager = SessionManager::new_with_replay( config.daemon_id.clone(), tmux, diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 7d165ec..491aff5 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -109,6 +109,23 @@ where you want browser access answer yes — both binaries end up in | `--web-exec-start ` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-web`). | | `-y`, `--yes` | Non-interactive: install binaries + drop systemd unit + reload + enable, but **don't** auto-run `configure` or auto-start the unit (those need real config). Also skips the web step unless an existing `maestro-web.service` is being refreshed. | +## Where tmux sockets land + +`maestrod` defaults `TMUX_TMPDIR` to `$XDG_RUNTIME_DIR` (= `/run/user/$UID`) +so a plain `tmux ls` in your shell — which typically uses the same env var +— sees maestro-managed sessions. Override in +`~/.config/maestro/config.toml`: + +```toml +[daemon] +tmux_tmpdir = "/tmp" +``` + +Pick `/tmp` if the daemon must outlive your login session and you can't run +`loginctl enable-linger` (the runtime dir is wiped on logout, taking the +tmux sockets with it). The env var `MAESTRO_DAEMON_TMUX_TMPDIR` takes +precedence over the file. + ## What the systemd unit looks like The wizard writes a *user* unit (`systemctl --user`), since `maestrod` runs