feat(maestro): default daemon TMUX_TMPDIR to $XDG_RUNTIME_DIR (NAP-234)
ci / rust (push) Failing after 2m13s

Plain `tmux ls` in the user's shell now sees maestro-managed sessions
without the `TMUX_TMPDIR=/tmp` workaround, because most shells already
set `TMUX_TMPDIR=$XDG_RUNTIME_DIR` and the daemon now matches.

Precedence: env `MAESTRO_DAEMON_TMUX_TMPDIR` > `[daemon].tmux_tmpdir` >
`dirs::runtime_dir()` > None (tmux's own `/tmp` default). Set
`tmux_tmpdir = "/tmp"` in config.toml if the daemon must outlive the
user's login without linger enabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 07:37:33 +00:00
co-authored by Claude Opus 4.7
parent b1aa2e6eab
commit 1c5b6c615c
5 changed files with 112 additions and 1 deletions
+10
View File
@@ -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<usize>,
/// `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<PathBuf>,
/// `[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()],
},
+78
View File
@@ -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<PathBuf>,
}
impl DaemonConfig {
@@ -113,6 +120,7 @@ pub struct EnvOverrides {
pub token: Option<String>,
pub perimeter_id: Option<String>,
pub private_addr: Option<String>,
pub tmux_tmpdir: Option<String>,
}
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<DaemonConfig, Confi
let pty_replay_bytes = daemon
.pty_replay_bytes
.unwrap_or(crate::pty::DEFAULT_REPLAY_BYTES);
let tmux_tmpdir = env
.tmux_tmpdir
.map(PathBuf::from)
.or(daemon.tmux_tmpdir)
.or_else(dirs::runtime_dir);
Ok(DaemonConfig {
daemon_id,
@@ -219,6 +233,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
private_addr,
allow_inbound,
pty_replay_bytes,
tmux_tmpdir,
})
}
@@ -477,4 +492,67 @@ private_addr = "10.0.0.7:7070"
let h = system_hostname();
assert!(!h.is_empty(), "gethostname returned empty string");
}
// NAP-234: tmux_tmpdir precedence is env > 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());
}
}
+3
View File
@@ -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 ..."
+4 -1
View File
@@ -120,7 +120,10 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
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,
+17
View File
@@ -109,6 +109,23 @@ where you want browser access answer yes — both binaries end up in
| `--web-exec-start <path>` | 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