agentmux_common/
runtime_mode.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Runtime mode detection — single source of truth.
5//!
6//! The launcher computes [`RuntimeMode::current`] once at startup and
7//! propagates the result to host + srv via the `AGENTMUX_RUNTIME_MODE`
8//! environment variable. No binary should call [`current`] more than
9//! once per process; downstream binaries read the env var via
10//! [`from_env`] instead.
11//!
12//! Replaces the legacy mix of `cfg!(debug_assertions)`, `env::var
13//! ("AGENTMUX_DEV").is_ok()`, and `== Ok("1")` checks across launcher,
14//! host, and sidecar — which were each correct in isolation but
15//! desynchronized in combination (see docs/specs/
16//! SPEC_DATA_DIR_UNIFICATION_2026-05-05.md §2.1).
17//!
18//! # Detection priority
19//!
20//! 1. `AGENTMUX_RUNTIME_MODE` env override (testing, CI).
21//! 2. Marker-based portable detection: `<exe-dir>/agentmux-portable.marker`
22//!    exists (also looks two levels up for macOS .app bundles). Written
23//!    by `scripts/package-portable.sh` at packaging time (see the
24//!    `printf '...' > "$PORTABLE/agentmux-portable.marker"` line).
25//! 3. Path-based dev detection: exe is under a known dev-build dir
26//!    (`dist/cef-dev/`, `target/debug/`, `target/release/`).
27//! 4. `AGENTMUX_DEV_BRANCH` env override (CI override for dev mode).
28//! 5. Default: `Installed`.
29
30use std::path::Path;
31use std::process::Command;
32
33/// Where this AgentMux binary is running from. Determines data path
34/// layout (see [`crate::DataPaths`]).
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum RuntimeMode {
37    /// Installed via the platform installer (msi/dmg/deb). State lives
38    /// in `~/.agentmux/versions/<v>/`.
39    Installed,
40    /// Running from an extracted portable ZIP. State STILL lives in
41    /// `~/.agentmux/versions/<v>/` (not under the portable folder) —
42    /// portable binaries are stateless on disk.
43    Portable,
44    /// Running from a source-tree build. State lives in
45    /// `~/.agentmux/dev/<branch>/` so different branches don't share
46    /// state.
47    Dev { branch: String },
48}
49
50impl RuntimeMode {
51    /// Detect runtime mode from the launcher's vantage point. Call
52    /// ONCE at process startup. Subsequent processes read the
53    /// `AGENTMUX_RUNTIME_MODE` env var via [`Self::from_env`].
54    ///
55    /// `exe_dir` should be `current_exe().parent()` of the binary
56    /// doing the detection (typically the launcher).
57    pub fn current(exe_dir: &Path) -> Self {
58        // 1. Explicit AGENTMUX_RUNTIME_MODE override (tests, CI, ops).
59        if let Ok(s) = std::env::var("AGENTMUX_RUNTIME_MODE") {
60            if let Some(mode) = parse_mode_string(&s) {
61                return mode;
62            }
63        }
64
65        // 2. Portable: marker file written next to the launcher by
66        //    `scripts/package-portable.sh`. The presence of a
67        //    `runtime/` subdir is NOT a discriminator — installed
68        //    builds ship it too — so we require the explicit marker.
69        if is_portable_marker_present(exe_dir) {
70            return Self::Portable;
71        }
72
73        // 3. Path-based dev detection: exe lives under a known
74        //    build-output dir.
75        if exe_dir_is_dev_build(exe_dir) {
76            let branch = detect_branch(exe_dir);
77            return Self::Dev { branch };
78        }
79
80        // 4. AGENTMUX_DEV_BRANCH override. The env-var IS the branch
81        //    source at this step; sanitize it directly, do NOT call
82        //    detect_branch (which has its own git fallback that would
83        //    take over when the env-var sanitizes to empty — and could
84        //    then return a real branch name from an unrelated .git
85        //    ancestor of `exe_dir`, silently routing installed runs
86        //    into Dev when the user only had a typo'd env var).
87        //
88        //    If the env value is unusable (empty after trim, or
89        //    sanitizes to empty via path-traversal stripping), fall
90        //    through to Installed instead of inventing a branch.
91        if let Ok(b) = std::env::var("AGENTMUX_DEV_BRANCH") {
92            let slug = sanitize_branch_slug(&b);
93            if !slug.is_empty() {
94                return Self::Dev { branch: slug };
95            }
96        }
97
98        // 5. Default.
99        Self::Installed
100    }
101
102    /// Read mode from the `AGENTMUX_RUNTIME_MODE` env var the launcher
103    /// set. Used by host + srv to consume the launcher's decision
104    /// without re-detecting (which would re-introduce the desync risk
105    /// the legacy code had).
106    pub fn from_env() -> Option<Self> {
107        std::env::var("AGENTMUX_RUNTIME_MODE")
108            .ok()
109            .and_then(|s| parse_mode_string(&s))
110    }
111
112    /// Path-only detection — skips the `AGENTMUX_RUNTIME_MODE` env step.
113    /// Use when the env can't be trusted (e.g., the binary was launched
114    /// as a child of a different AgentMux process that set its own
115    /// `AGENTMUX_*` vars). Mirrors the rest of [`Self::current`]'s
116    /// priority order (portable marker → dev exe path → installed).
117    pub fn current_path_only(exe_dir: &Path) -> Self {
118        if is_portable_marker_present(exe_dir) {
119            return Self::Portable;
120        }
121        if exe_dir_is_dev_build(exe_dir) {
122            let branch = detect_branch(exe_dir);
123            return Self::Dev { branch };
124        }
125        Self::Installed
126    }
127
128    /// Encode for the `AGENTMUX_RUNTIME_MODE` env var. Round-trips
129    /// with [`parse_mode_string`].
130    pub fn to_env_string(&self) -> String {
131        match self {
132            Self::Installed => "installed".to_string(),
133            Self::Portable => "portable".to_string(),
134            Self::Dev { branch } => format!("dev:{}", branch),
135        }
136    }
137
138    /// Slug used inside `~/.agentmux/` to separate state by mode.
139    /// Stable across releases of the same major mode + branch.
140    pub fn dir_slug(&self) -> String {
141        match self {
142            // Versioned modes don't include the version here — that's
143            // appended separately in DataPaths so callers can re-use
144            // RuntimeMode across version queries.
145            Self::Installed | Self::Portable => "versions".to_string(),
146            // Defense in depth: branch is sanitized at parse time, but
147            // a Dev variant constructed directly (e.g. via tests) might
148            // still hold an unsafe value. Slug-on-format ensures the
149            // returned string is always exactly two segments — `dev/`
150            // followed by a single-segment branch slug — so callers
151            // splitting on `/` see the expected shape and the resulting
152            // filesystem path is always a child of `dev/`.
153            Self::Dev { branch } => format!("dev/{}", sanitize_branch_slug(branch)),
154        }
155    }
156}
157
158/// True when this binary is running from one of our known dev-build
159/// output directories (`dist/cef-dev/`, `target/debug/`, `target/release/`).
160/// Walks ancestors to handle nested cases (CEF subprocesses run from a
161/// `runtime/` subdir even in dev). Path-only — does not read env.
162pub fn is_dev_build_exe(exe_dir: &Path) -> bool {
163    exe_dir_is_dev_build(exe_dir)
164}
165
166fn parse_mode_string(s: &str) -> Option<RuntimeMode> {
167    let trimmed = s.trim();
168    if trimmed.eq_ignore_ascii_case("installed") {
169        return Some(RuntimeMode::Installed);
170    }
171    if trimmed.eq_ignore_ascii_case("portable") {
172        return Some(RuntimeMode::Portable);
173    }
174    if let Some(raw_branch) = trimmed.strip_prefix("dev:") {
175        // Slugify at parse time — the branch then flows through
176        // DataPaths::resolve into a `~/.agentmux/dev/<branch>/` path,
177        // and we must not let `/`, `..`, or shell-meta chars in the
178        // env override (`AGENTMUX_RUNTIME_MODE=dev:../versions/x`)
179        // escape the dev/ subtree.
180        let slug = sanitize_branch_slug(raw_branch);
181        if slug.is_empty() {
182            return None;
183        }
184        return Some(RuntimeMode::Dev { branch: slug });
185    }
186    if trimmed.eq_ignore_ascii_case("dev") {
187        return Some(RuntimeMode::Dev {
188            branch: "default".to_string(),
189        });
190    }
191    None
192}
193
194/// True when `exe_dir` (or its parent on macOS app bundles) contains
195/// the `agentmux-portable.marker` marker file written by
196/// `scripts/package-portable.sh` at packaging time. Installed builds
197/// NEVER write this marker.
198///
199/// Falls back to `false` (i.e., not portable) if the dir isn't readable
200/// — installed-mode default is the safer guess when unsure.
201fn is_portable_marker_present(exe_dir: &Path) -> bool {
202    if exe_dir.join("agentmux-portable.marker").is_file() {
203        return true;
204    }
205    // On macOS the launcher exe is at <Bundle>.app/Contents/MacOS/<exe>;
206    // a portable .app would put the marker at the bundle root, two
207    // levels up.
208    if let Some(bundle_root) = exe_dir.parent().and_then(|p| p.parent()) {
209        if bundle_root.join("agentmux-portable.marker").is_file() {
210            return true;
211        }
212    }
213    false
214}
215
216/// True when `exe_dir` is one of our known dev-build output dirs.
217/// Walks parents to handle nested cases (CEF subprocesses run from
218/// `runtime/` even in dev).
219fn exe_dir_is_dev_build(exe_dir: &Path) -> bool {
220    // Match any ancestor of exe_dir that ends in a known build dir name.
221    // We accept: dist/cef-dev/<...>, target/debug/<...>, target/release/<...>
222    let mut cur = Some(exe_dir);
223    while let Some(p) = cur {
224        let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
225        let parent_name = p
226            .parent()
227            .and_then(|pp| pp.file_name())
228            .and_then(|n| n.to_str())
229            .unwrap_or("");
230
231        if (parent_name == "dist" && name == "cef-dev")
232            || (parent_name == "target" && (name == "debug" || name == "release"))
233        {
234            return true;
235        }
236        cur = p.parent();
237    }
238    false
239}
240
241/// Detect the current git branch slug for dev mode. Walks up from
242/// `exe_dir` to find a git repo, runs `git rev-parse --abbrev-ref
243/// HEAD`, and slugifies. Falls back to `"default"` if anything fails.
244/// `AGENTMUX_DEV_BRANCH` env override always wins.
245fn detect_branch(exe_dir: &Path) -> String {
246    if let Ok(b) = std::env::var("AGENTMUX_DEV_BRANCH") {
247        let slug = sanitize_branch_slug(&b);
248        if !slug.is_empty() {
249            return slug;
250        }
251    }
252    // Find a git repo by walking up from exe_dir.
253    let mut cur = Some(exe_dir);
254    while let Some(p) = cur {
255        if p.join(".git").exists() {
256            return run_git_branch(p).unwrap_or_else(|| "default".to_string());
257        }
258        cur = p.parent();
259    }
260    "default".to_string()
261}
262
263fn run_git_branch(repo_dir: &Path) -> Option<String> {
264    let output = Command::new("git")
265        .args(["rev-parse", "--abbrev-ref", "HEAD"])
266        .current_dir(repo_dir)
267        .output()
268        .ok()?;
269    if !output.status.success() {
270        return None;
271    }
272    let s = String::from_utf8(output.stdout).ok()?;
273    let trimmed = s.trim();
274    if trimmed.is_empty() || trimmed == "HEAD" {
275        // Detached state; not useful for branch keying.
276        return None;
277    }
278    Some(slugify_branch(trimmed))
279}
280
281/// Convert a git branch into a filesystem-safe slug.
282/// `agenta/feature-x` → `agenta-feature-x`.
283///
284/// Use [`sanitize_branch_slug`] for any value that originates from an
285/// env var or other untrusted source — it additionally strips `..` and
286/// leading dots that could escape the dev/ subtree.
287fn slugify_branch(b: &str) -> String {
288    b.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "-")
289}
290
291/// Stricter sanitization for branch values that came from outside the
292/// trusted slugify path (env overrides, CI inputs). Strips parent-dir
293/// segments, leading dots, and any whitespace that survived earlier
294/// trimming. Returns an empty string if nothing usable remains, which
295/// callers should treat as "reject this input."
296fn sanitize_branch_slug(b: &str) -> String {
297    // Step 1: replace shell + filesystem-meta characters (same as
298    // slugify_branch — git-valid chars get a "-").
299    let replaced = slugify_branch(b);
300    // Step 2: drop `..` segments (now dash-separated, so "-..-"-style
301    // sequences too) and any leading/trailing dots/dashes/whitespace
302    // that would resolve up out of the dev/ subdir.
303    let cleaned: String = replaced
304        .split('-')
305        .filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
306        .collect::<Vec<_>>()
307        .join("-");
308    cleaned
309        .trim_matches(|c: char| c == '.' || c == '-' || c.is_whitespace())
310        .to_string()
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::TEST_ENV_LOCK;
317    use std::path::PathBuf;
318    // tempfile is a dev-dep used here for the marker-detection test.
319
320    /// `with_env` does NOT take `TEST_ENV_LOCK` — callers must hold
321    /// it. This avoids the re-entrant-locking problem when a test
322    /// nests `with_env` calls. The lock is shared across the whole
323    /// crate (defined in lib.rs) so tests in this module also
324    /// serialize against `data_paths::tests` which touches the same
325    /// process-global env vars.
326    ///
327    /// Uses a Drop guard so the previous env value is restored even
328    /// if `f` panics — without it, a panicking test would leave the
329    /// env var modified and any subsequent test in the same process
330    /// would see the wrong value.
331    fn with_env<F: FnOnce()>(key: &str, val: Option<&str>, f: F) {
332        struct EnvGuard {
333            key: String,
334            prev: Option<String>,
335        }
336        impl Drop for EnvGuard {
337            fn drop(&mut self) {
338                match &self.prev {
339                    Some(v) => std::env::set_var(&self.key, v),
340                    None => std::env::remove_var(&self.key),
341                }
342            }
343        }
344
345        let prev = std::env::var(key).ok();
346        let _guard = EnvGuard {
347            key: key.to_string(),
348            prev,
349        };
350        match val {
351            Some(v) => std::env::set_var(key, v),
352            None => std::env::remove_var(key),
353        }
354        f();
355        // _guard drops here (also runs on panic).
356    }
357
358    #[test]
359    fn parses_env_strings_round_trip() {
360        for mode in [
361            RuntimeMode::Installed,
362            RuntimeMode::Portable,
363            RuntimeMode::Dev {
364                branch: "main".into(),
365            },
366            RuntimeMode::Dev {
367                branch: "agenta-feature-x".into(),
368            },
369        ] {
370            let s = mode.to_env_string();
371            let back = parse_mode_string(&s).expect("round-trip");
372            assert_eq!(back, mode);
373        }
374    }
375
376    #[test]
377    fn parse_accepts_bare_dev() {
378        assert_eq!(
379            parse_mode_string("dev"),
380            Some(RuntimeMode::Dev {
381                branch: "default".into()
382            })
383        );
384        assert_eq!(
385            parse_mode_string("DEV"),
386            Some(RuntimeMode::Dev {
387                branch: "default".into()
388            })
389        );
390    }
391
392    #[test]
393    fn env_override_wins_over_path_detection() {
394        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
395        // Even if exe_dir looks portable (has `runtime/`), env override wins.
396        // We simulate by passing a path that doesn't exist (so .is_dir()
397        // returns false), but we also force the env override.
398        with_env("AGENTMUX_RUNTIME_MODE", Some("dev:main"), || {
399            with_env("AGENTMUX_DEV_BRANCH", None, || {
400                let mode = RuntimeMode::current(&PathBuf::from("/nonexistent"));
401                assert_eq!(
402                    mode,
403                    RuntimeMode::Dev {
404                        branch: "main".into()
405                    }
406                );
407            });
408        });
409    }
410
411    #[test]
412    fn dev_build_path_pattern() {
413        // Match dist/cef-dev/...
414        assert!(exe_dir_is_dev_build(&PathBuf::from(
415            "/c/Systems/agentmux/dist/cef-dev"
416        )));
417        // Match target/debug/...
418        assert!(exe_dir_is_dev_build(&PathBuf::from(
419            "/c/Systems/agentmux/target/debug"
420        )));
421        // Match target/release/...
422        assert!(exe_dir_is_dev_build(&PathBuf::from(
423            "/c/Systems/agentmux/target/release"
424        )));
425        // Don't match an installed path.
426        assert!(!exe_dir_is_dev_build(&PathBuf::from(
427            "/c/Program Files/AgentMux"
428        )));
429        // Don't match a portable path.
430        assert!(!exe_dir_is_dev_build(&PathBuf::from(
431            "/Users/me/Desktop/agentmux-portable"
432        )));
433    }
434
435    #[test]
436    fn slugify_branch_replaces_unsafe_chars() {
437        assert_eq!(slugify_branch("agenta/feature-x"), "agenta-feature-x");
438        assert_eq!(slugify_branch("feat:foo*bar"), "feat-foo-bar");
439        assert_eq!(slugify_branch("plain"), "plain");
440    }
441
442    #[test]
443    fn dir_slug_per_mode() {
444        assert_eq!(RuntimeMode::Installed.dir_slug(), "versions");
445        assert_eq!(RuntimeMode::Portable.dir_slug(), "versions");
446        assert_eq!(
447            RuntimeMode::Dev {
448                branch: "main".into()
449            }
450            .dir_slug(),
451            "dev/main"
452        );
453        assert_eq!(
454            RuntimeMode::Dev {
455                branch: "agenta/x".into()
456            }
457            .dir_slug(),
458            "dev/agenta-x"
459        );
460    }
461
462    #[test]
463    fn invalid_env_string_falls_through() {
464        assert!(parse_mode_string("garbage").is_none());
465        assert!(parse_mode_string("").is_none());
466    }
467
468    #[test]
469    fn parse_dev_branch_rejects_traversal_attempts() {
470        // `..` resolves out of the dev/ subdir on disk — the slug must
471        // either reject it or strip it. We choose strip; if nothing
472        // usable remains, parse fails (returns None).
473        assert_eq!(parse_mode_string("dev:.."), None);
474        assert_eq!(parse_mode_string("dev:."), None);
475        assert_eq!(parse_mode_string("dev:"), None);
476        // `dev:../versions/x` slugifies to `versions-x` (the slashes
477        // are replaced and `..` segment is dropped).
478        let m = parse_mode_string("dev:../versions/x").expect("parses");
479        match m {
480            RuntimeMode::Dev { branch } => {
481                assert!(!branch.contains(".."));
482                assert!(!branch.contains('/'));
483                assert!(!branch.contains('\\'));
484            }
485            _ => panic!("expected Dev variant"),
486        }
487    }
488
489    #[test]
490    fn dev_branch_env_with_unusable_value_falls_through() {
491        // AGENTMUX_DEV_BRANCH=`..` sanitizes to empty.
492        // With the fix, an unusable value falls through to Installed
493        // when no other dev signal applies — even when exe_dir has
494        // a .git ancestor that detect_branch could have grabbed.
495        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
496        let tmp = tempfile::TempDir::new().expect("tempdir");
497        // Plant a .git directory so a buggy implementation that called
498        // detect_branch as a fallback would find a real branch name and
499        // wrongly classify as Dev. Step 4 now sanitizes the env var
500        // directly without invoking the git lookup — verifying that.
501        std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
502        let exe_dir = tmp.path().join("subdir");
503        std::fs::create_dir_all(&exe_dir).unwrap();
504
505        std::env::remove_var("AGENTMUX_RUNTIME_MODE");
506        std::env::set_var("AGENTMUX_DEV_BRANCH", "..");
507        let mode = RuntimeMode::current(&exe_dir);
508        std::env::remove_var("AGENTMUX_DEV_BRANCH");
509
510        // Even with the .git ancestor, an unusable env value must
511        // fall through to Installed (NOT to Dev with a git-detected
512        // branch). The env var is the source-of-truth at step 4.
513        assert_eq!(mode, RuntimeMode::Installed);
514    }
515
516    #[test]
517    fn portable_marker_detection() {
518        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
519        let tmp = tempfile::TempDir::new().expect("tempdir");
520        let exe_dir = tmp.path();
521
522        // No marker → not portable.
523        assert!(!is_portable_marker_present(exe_dir));
524
525        // With marker → portable.
526        std::fs::write(exe_dir.join("agentmux-portable.marker"), b"").unwrap();
527        assert!(is_portable_marker_present(exe_dir));
528
529        // Marker two levels up (macOS .app bundle case).
530        let nested = exe_dir.join("Contents/MacOS");
531        std::fs::create_dir_all(&nested).unwrap();
532        // Removing top-level marker would still allow detection via the
533        // bundle-root walk-up.
534        std::fs::remove_file(exe_dir.join("agentmux-portable.marker")).unwrap();
535        assert!(!is_portable_marker_present(&nested));
536        std::fs::write(exe_dir.join("agentmux-portable.marker"), b"").unwrap();
537        assert!(is_portable_marker_present(&nested));
538    }
539
540    #[test]
541    fn current_with_only_runtime_subdir_is_not_portable() {
542        // Regression: prior implementation returned Portable whenever
543        // <exe>/runtime/ existed, but installed builds also co-locate
544        // runtime/ (per the launcher unconditionally requiring it).
545        // Without a marker, we must NOT classify as Portable.
546        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
547        let tmp = tempfile::TempDir::new().expect("tempdir");
548        let exe_dir = tmp.path();
549        std::fs::create_dir_all(exe_dir.join("runtime")).unwrap();
550        // No agentmux-portable.marker marker — must be Installed (or whatever
551        // the fall-through is, but specifically NOT Portable).
552        std::env::remove_var("AGENTMUX_RUNTIME_MODE");
553        std::env::remove_var("AGENTMUX_DEV_BRANCH");
554        let mode = RuntimeMode::current(exe_dir);
555        assert_ne!(mode, RuntimeMode::Portable);
556    }
557
558    #[test]
559    fn sanitize_branch_slug_strips_traversal() {
560        assert_eq!(sanitize_branch_slug(".."), "");
561        assert_eq!(sanitize_branch_slug("../foo"), "foo");
562        assert_eq!(sanitize_branch_slug("foo/../bar"), "foo-bar");
563        assert_eq!(sanitize_branch_slug(".hidden"), "hidden");
564        assert_eq!(sanitize_branch_slug("ok-branch"), "ok-branch");
565    }
566}