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: an `agentmux-portable.marker` file
22//!    written by `scripts/package-portable.sh`. Looked for next to the
23//!    detecting exe, one level down in `runtime/` (the packaging puts it
24//!    there to keep the extract root clean; the launcher is at the root and
25//!    the host/srv run from `runtime/`), and two levels up for macOS .app
26//!    bundles.
27//! 3. Path-based dev detection: exe is under a known dev-build dir
28//!    (`dist/cef-dev/`, `target/debug/`, `target/release/`).
29//! 4. `AGENTMUX_DEV_BRANCH` env override (CI override for dev mode).
30//! 5. Default: `Installed`.
31
32use std::path::Path;
33use std::process::Command;
34
35/// Where this AgentMux binary is running from. Determines data path
36/// layout (see [`crate::DataPaths`]).
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum RuntimeMode {
39    /// Installed via the platform installer (msi/dmg/deb). State lives
40    /// in `~/.agentmux/versions/<v>/`.
41    Installed,
42    /// Running from an extracted portable ZIP. State STILL lives in
43    /// `~/.agentmux/versions/<v>/` (not under the portable folder) —
44    /// portable binaries are stateless on disk.
45    Portable,
46    /// Running from a source-tree build. State lives in
47    /// `~/.agentmux/dev/<branch>/<clone_id>/` so different branches
48    /// AND different clones of the same branch don't share state.
49    ///
50    /// `clone_id` is a 16-char hex hash of the clone's workspace-root
51    /// path, derived by [`derive_clone_id`] when the launcher detects
52    /// Dev mode. `None` is permitted for backward compatibility with
53    /// callers that construct `RuntimeMode::Dev` directly (tests, the
54    /// `dev:branch` env-string parser that pre-dates this field): in
55    /// that case path resolution falls back to the old two-level
56    /// `dev/<branch>/` layout. See
57    /// `docs/analysis/ANALYSIS_MULTI_CLONE_TASK_DEV_ISOLATION_2026-05-26.md`.
58    Dev {
59        branch: String,
60        clone_id: Option<String>,
61    },
62}
63
64impl RuntimeMode {
65    /// Detect runtime mode from the launcher's vantage point. Call
66    /// ONCE at process startup. Subsequent processes read the
67    /// `AGENTMUX_RUNTIME_MODE` env var via [`Self::from_env`].
68    ///
69    /// `exe_dir` should be `current_exe().parent()` of the binary
70    /// doing the detection (typically the launcher).
71    pub fn current(exe_dir: &Path) -> Self {
72        // 1. Explicit AGENTMUX_RUNTIME_MODE override (tests, CI, ops).
73        if let Ok(s) = std::env::var("AGENTMUX_RUNTIME_MODE") {
74            if let Some(mode) = parse_mode_string(&s) {
75                return mode;
76            }
77        }
78
79        // 2. Portable: marker file written next to the launcher by
80        //    `scripts/package-portable.sh`. The presence of a
81        //    `runtime/` subdir is NOT a discriminator — installed
82        //    builds ship it too — so we require the explicit marker.
83        if is_portable_marker_present(exe_dir) {
84            return Self::Portable;
85        }
86
87        // 3. Path-based dev detection: exe lives under a known
88        //    build-output dir.
89        if exe_dir_is_dev_build(exe_dir) {
90            let branch = detect_branch(exe_dir);
91            let clone_id = derive_clone_id(exe_dir);
92            return Self::Dev { branch, clone_id };
93        }
94
95        // 4. AGENTMUX_DEV_BRANCH override. The env-var IS the branch
96        //    source at this step; sanitize it directly, do NOT call
97        //    detect_branch (which has its own git fallback that would
98        //    take over when the env-var sanitizes to empty — and could
99        //    then return a real branch name from an unrelated .git
100        //    ancestor of `exe_dir`, silently routing installed runs
101        //    into Dev when the user only had a typo'd env var).
102        //
103        //    If the env value is unusable (empty after trim, or
104        //    sanitizes to empty via path-traversal stripping), fall
105        //    through to Installed instead of inventing a branch.
106        if let Ok(b) = std::env::var("AGENTMUX_DEV_BRANCH") {
107            let slug = sanitize_branch_slug(&b);
108            if !slug.is_empty() {
109                let clone_id = derive_clone_id(exe_dir);
110                return Self::Dev {
111                    branch: slug,
112                    clone_id,
113                };
114            }
115        }
116
117        // 5. Default.
118        Self::Installed
119    }
120
121    /// Read mode from the `AGENTMUX_RUNTIME_MODE` env var the launcher
122    /// set. Used by host + srv to consume the launcher's decision
123    /// without re-detecting (which would re-introduce the desync risk
124    /// the legacy code had).
125    pub fn from_env() -> Option<Self> {
126        std::env::var("AGENTMUX_RUNTIME_MODE")
127            .ok()
128            .and_then(|s| parse_mode_string(&s))
129    }
130
131    /// Path-only detection — skips the `AGENTMUX_RUNTIME_MODE` env step.
132    /// Use when the env can't be trusted (e.g., the binary was launched
133    /// as a child of a different AgentMux process that set its own
134    /// `AGENTMUX_*` vars). Mirrors the rest of [`Self::current`]'s
135    /// priority order (portable marker → dev exe path → installed).
136    pub fn current_path_only(exe_dir: &Path) -> Self {
137        if is_portable_marker_present(exe_dir) {
138            return Self::Portable;
139        }
140        if exe_dir_is_dev_build(exe_dir) {
141            let branch = detect_branch(exe_dir);
142            let clone_id = derive_clone_id(exe_dir);
143            return Self::Dev { branch, clone_id };
144        }
145        Self::Installed
146    }
147
148    /// Encode for the `AGENTMUX_RUNTIME_MODE` env var. Round-trips
149    /// with [`parse_mode_string`].
150    pub fn to_env_string(&self) -> String {
151        match self {
152            Self::Installed => "installed".to_string(),
153            Self::Portable => "portable".to_string(),
154            // clone_id is intentionally NOT encoded here; it round-trips
155            // via a dedicated `AGENTMUX_CLONE_ID` env var so the existing
156            // `dev:<branch>` wire format stays backward-compatible with
157            // older launchers / parsers.
158            Self::Dev { branch, .. } => format!("dev:{}", branch),
159        }
160    }
161
162    /// Slug used inside `~/.agentmux/` to separate state by mode.
163    /// Stable across releases of the same major mode + branch.
164    pub fn dir_slug(&self) -> String {
165        match self {
166            // Versioned modes don't include the version here — that's
167            // appended separately in DataPaths so callers can re-use
168            // RuntimeMode across version queries.
169            Self::Installed | Self::Portable => "versions".to_string(),
170            // Defense in depth: branch is sanitized at parse time, but
171            // a Dev variant constructed directly (e.g. via tests) might
172            // still hold an unsafe value. Slug-on-format ensures the
173            // returned string is always exactly two segments — `dev/`
174            // followed by a single-segment branch slug — so callers
175            // splitting on `/` see the expected shape and the resulting
176            // filesystem path is always a child of `dev/`.
177            // Slug includes the clone_id when present so two clones of
178            // the same branch land in distinct subdirs. When clone_id
179            // is None (env-roundtrip or direct test construction), fall
180            // back to the legacy two-level layout for compatibility.
181            Self::Dev { branch, clone_id } => {
182                let b = sanitize_branch_slug(branch);
183                match clone_id.as_deref().map(sanitize_clone_id) {
184                    Some(c) if !c.is_empty() => format!("dev/{}/{}", b, c),
185                    _ => format!("dev/{}", b),
186                }
187            }
188        }
189    }
190
191    /// Read the runtime mode AND clone_id from the env vars exported
192    /// by the parent process. Pairs `AGENTMUX_RUNTIME_MODE` (variant +
193    /// branch) with `AGENTMUX_CLONE_ID` (Dev-only clone discriminator).
194    /// This is the version every child process (host, srv) should
195    /// call — [`Self::from_env`] is the legacy single-var form kept
196    /// for callers that don't care about clone isolation.
197    pub fn from_env_with_clone() -> Option<Self> {
198        let mode = Self::from_env()?;
199        if let Self::Dev { branch, .. } = mode {
200            let clone_id = std::env::var("AGENTMUX_CLONE_ID")
201                .ok()
202                .map(|s| sanitize_clone_id(&s))
203                .filter(|s| !s.is_empty());
204            return Some(Self::Dev { branch, clone_id });
205        }
206        Some(mode)
207    }
208}
209
210/// True when this binary is running from one of our known dev-build
211/// output directories (`dist/cef-dev/`, `target/debug/`, `target/release/`).
212/// Walks ancestors to handle nested cases (CEF subprocesses run from a
213/// `runtime/` subdir even in dev). Path-only — does not read env.
214pub fn is_dev_build_exe(exe_dir: &Path) -> bool {
215    exe_dir_is_dev_build(exe_dir)
216}
217
218/// True iff THIS binary is a source-tree / `task dev` build, determined from
219/// the binary's path + portable marker via [`RuntimeMode::current_path_only`]
220/// — NOT from `AGENTMUX_RUNTIME_MODE`. A running `task dev` AgentMux exports
221/// `AGENTMUX_RUNTIME_MODE=dev:<branch>` into its environment, which every
222/// descendant process inherits; a packaged build launched from a terminal /
223/// agent pane inside a dev instance would otherwise mis-identify as Dev (e.g.
224/// the "DEV" status-bar badge on a release build). Build identity is a
225/// property of the binary on disk, not of whoever launched it.
226///
227/// Use for **build-identity** self-checks (the DEV badge, the "AgentMux DEV"
228/// menu name, the dev-only frontend fallback). For intra-instance plumbing
229/// that must agree with the launcher's already-made decision, use
230/// [`RuntimeMode::from_env`] instead.
231pub fn is_dev_self() -> bool {
232    std::env::current_exe()
233        .ok()
234        .and_then(|p| p.parent().map(|d| d.to_path_buf()))
235        .map(|d| matches!(RuntimeMode::current_path_only(&d), RuntimeMode::Dev { .. }))
236        .unwrap_or(false)
237}
238
239fn parse_mode_string(s: &str) -> Option<RuntimeMode> {
240    let trimmed = s.trim();
241    if trimmed.eq_ignore_ascii_case("installed") {
242        return Some(RuntimeMode::Installed);
243    }
244    if trimmed.eq_ignore_ascii_case("portable") {
245        return Some(RuntimeMode::Portable);
246    }
247    if let Some(raw_branch) = trimmed.strip_prefix("dev:") {
248        // Slugify at parse time — the branch then flows through
249        // DataPaths::resolve into a `~/.agentmux/dev/<branch>/` path,
250        // and we must not let `/`, `..`, or shell-meta chars in the
251        // env override (`AGENTMUX_RUNTIME_MODE=dev:../versions/x`)
252        // escape the dev/ subtree.
253        let slug = sanitize_branch_slug(raw_branch);
254        if slug.is_empty() {
255            return None;
256        }
257        // clone_id is not encoded in this wire format — callers that
258        // care about clone isolation should use [`RuntimeMode::from_env_with_clone`]
259        // which pairs this with `AGENTMUX_CLONE_ID`.
260        return Some(RuntimeMode::Dev {
261            branch: slug,
262            clone_id: None,
263        });
264    }
265    if trimmed.eq_ignore_ascii_case("dev") {
266        return Some(RuntimeMode::Dev {
267            branch: "default".to_string(),
268            clone_id: None,
269        });
270    }
271    None
272}
273
274// ── Clone-id derivation ────────────────────────────────────────────────
275
276/// Walk up from `exe_dir` looking for a workspace-root marker, then
277/// hash that absolute (canonical) path with FNV-1a and return a 16-char
278/// hex string. Used as the per-clone discriminator in
279/// `~/.agentmux/dev/<branch>/<clone_id>/`. Returns `None` if no marker
280/// is found (extreme edge case — `task dev` always runs from inside a
281/// clone with `.git`/`Cargo.toml`/`Taskfile.yml`).
282///
283/// Recognized markers (any one suffices, in priority order):
284/// `.git` (dir or file — supports git worktrees), `Cargo.toml`,
285/// `Taskfile.yml`. We prefer `.git` because it identifies the literal
286/// clone, but Cargo.toml is a safe fallback (the workspace root has
287/// a `[workspace]` Cargo.toml).
288pub fn derive_clone_id(exe_dir: &Path) -> Option<String> {
289    let root = find_clone_root(exe_dir)?;
290    // Canonicalize to absorb mixed casing on Windows and resolve `..`
291    // segments. Falls back to the raw path if canonicalize fails
292    // (rare on real filesystems but possible on transient mounts).
293    let canonical = root.canonicalize().unwrap_or(root);
294    let s = canonical.to_string_lossy().to_lowercase();
295    Some(format!("{:016x}", fnv1a_64(s.as_bytes())))
296}
297
298fn find_clone_root(start: &Path) -> Option<std::path::PathBuf> {
299    let mut cur = Some(start);
300    while let Some(p) = cur {
301        if p.join(".git").exists()
302            || p.join("Cargo.toml").is_file()
303            || p.join("Taskfile.yml").is_file()
304        {
305            return Some(p.to_path_buf());
306        }
307        cur = p.parent();
308    }
309    None
310}
311
312/// Lenient sanitization for a clone_id received via env. The launcher
313/// produces a clean 16-hex string, but env vars survive process hops
314/// and could be tampered with — refuse anything that contains path
315/// separators or `..` segments before it lands in a filesystem path.
316fn sanitize_clone_id(s: &str) -> String {
317    let trimmed = s.trim();
318    if trimmed.is_empty()
319        || trimmed.contains('/')
320        || trimmed.contains('\\')
321        || trimmed.contains("..")
322        || trimmed.contains('\0')
323    {
324        return String::new();
325    }
326    trimmed.to_string()
327}
328
329// Tiny FNV-1a-64 kept inline so agentmux-common doesn't have to depend
330// on agentmux-launcher. Matches `agentmux-launcher/src/hash.rs`
331// byte-for-byte so hashes are interchangeable.
332const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
333const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
334
335fn fnv1a_64(bytes: &[u8]) -> u64 {
336    let mut hash = FNV_OFFSET_BASIS;
337    for b in bytes {
338        hash ^= *b as u64;
339        hash = hash.wrapping_mul(FNV_PRIME);
340    }
341    hash
342}
343
344/// True when `exe_dir` (or its parent on macOS app bundles) contains
345/// the `agentmux-portable.marker` marker file written by
346/// `scripts/package-portable.sh` at packaging time. Installed builds
347/// NEVER write this marker.
348///
349/// Falls back to `false` (i.e., not portable) if the dir isn't readable
350/// — installed-mode default is the safer guess when unsure.
351fn is_portable_marker_present(exe_dir: &Path) -> bool {
352    // Next to the detecting binary. Covers the host/srv (which live in
353    // `runtime/`, so `exe_dir` IS `runtime/`) and legacy portables that wrote
354    // the marker at the extract root next to the launcher.
355    if exe_dir.join("agentmux-portable.marker").is_file() {
356        return true;
357    }
358    // The launcher sits at the extract root with the marker one level down in
359    // `runtime/` (the packaging keeps the root clean: just agentmux.exe +
360    // README + runtime/). Check there so root-level detection still works.
361    if exe_dir.join("runtime").join("agentmux-portable.marker").is_file() {
362        return true;
363    }
364    // On macOS the launcher exe is at <Bundle>.app/Contents/MacOS/<exe>;
365    // a portable .app would put the marker at the bundle root, two
366    // levels up.
367    if let Some(bundle_root) = exe_dir.parent().and_then(|p| p.parent()) {
368        if bundle_root.join("agentmux-portable.marker").is_file() {
369            return true;
370        }
371    }
372    false
373}
374
375/// True when `exe_dir` is one of our known dev-build output dirs.
376/// Walks parents to handle nested cases (CEF subprocesses run from
377/// `runtime/` even in dev).
378fn exe_dir_is_dev_build(exe_dir: &Path) -> bool {
379    // Match any ancestor of exe_dir that ends in a known build dir name.
380    // We accept: dist/cef-dev/<...>, target/debug/<...>, target/release/<...>
381    let mut cur = Some(exe_dir);
382    while let Some(p) = cur {
383        let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
384        let parent_name = p
385            .parent()
386            .and_then(|pp| pp.file_name())
387            .and_then(|n| n.to_str())
388            .unwrap_or("");
389
390        if (parent_name == "dist" && name == "cef-dev")
391            || (parent_name == "target" && (name == "debug" || name == "release"))
392        {
393            return true;
394        }
395        cur = p.parent();
396    }
397    false
398}
399
400/// Detect the current git branch slug for dev mode. Walks up from
401/// `exe_dir` to find a git repo, runs `git rev-parse --abbrev-ref
402/// HEAD`, and slugifies. Falls back to `"default"` if anything fails.
403/// `AGENTMUX_DEV_BRANCH` env override always wins.
404fn detect_branch(exe_dir: &Path) -> String {
405    if let Ok(b) = std::env::var("AGENTMUX_DEV_BRANCH") {
406        let slug = sanitize_branch_slug(&b);
407        if !slug.is_empty() {
408            return slug;
409        }
410    }
411    // Find a git repo by walking up from exe_dir.
412    let mut cur = Some(exe_dir);
413    while let Some(p) = cur {
414        if p.join(".git").exists() {
415            return run_git_branch(p).unwrap_or_else(|| "default".to_string());
416        }
417        cur = p.parent();
418    }
419    "default".to_string()
420}
421
422fn run_git_branch(repo_dir: &Path) -> Option<String> {
423    let output = Command::new("git")
424        .args(["rev-parse", "--abbrev-ref", "HEAD"])
425        .current_dir(repo_dir)
426        .output()
427        .ok()?;
428    if !output.status.success() {
429        return None;
430    }
431    let s = String::from_utf8(output.stdout).ok()?;
432    let trimmed = s.trim();
433    if trimmed.is_empty() || trimmed == "HEAD" {
434        // Detached state; not useful for branch keying.
435        return None;
436    }
437    Some(slugify_branch(trimmed))
438}
439
440/// Convert a git branch into a filesystem-safe slug.
441/// `agenta/feature-x` → `agenta-feature-x`.
442///
443/// Use [`sanitize_branch_slug`] for any value that originates from an
444/// env var or other untrusted source — it additionally strips `..` and
445/// leading dots that could escape the dev/ subtree.
446fn slugify_branch(b: &str) -> String {
447    b.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "-")
448}
449
450/// Stricter sanitization for branch values that came from outside the
451/// trusted slugify path (env overrides, CI inputs). Strips parent-dir
452/// segments, leading dots, and any whitespace that survived earlier
453/// trimming. Returns an empty string if nothing usable remains, which
454/// callers should treat as "reject this input."
455fn sanitize_branch_slug(b: &str) -> String {
456    // Step 1: replace shell + filesystem-meta characters (same as
457    // slugify_branch — git-valid chars get a "-").
458    let replaced = slugify_branch(b);
459    // Step 2: drop `..` segments (now dash-separated, so "-..-"-style
460    // sequences too) and any leading/trailing dots/dashes/whitespace
461    // that would resolve up out of the dev/ subdir.
462    let cleaned: String = replaced
463        .split('-')
464        .filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
465        .collect::<Vec<_>>()
466        .join("-");
467    cleaned
468        .trim_matches(|c: char| c == '.' || c == '-' || c.is_whitespace())
469        .to_string()
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::TEST_ENV_LOCK;
476    use std::path::PathBuf;
477    // tempfile is a dev-dep used here for the marker-detection test.
478
479    /// `with_env` does NOT take `TEST_ENV_LOCK` — callers must hold
480    /// it. This avoids the re-entrant-locking problem when a test
481    /// nests `with_env` calls. The lock is shared across the whole
482    /// crate (defined in lib.rs) so tests in this module also
483    /// serialize against `data_paths::tests` which touches the same
484    /// process-global env vars.
485    ///
486    /// Uses a Drop guard so the previous env value is restored even
487    /// if `f` panics — without it, a panicking test would leave the
488    /// env var modified and any subsequent test in the same process
489    /// would see the wrong value.
490    fn with_env<F: FnOnce()>(key: &str, val: Option<&str>, f: F) {
491        struct EnvGuard {
492            key: String,
493            prev: Option<String>,
494        }
495        impl Drop for EnvGuard {
496            fn drop(&mut self) {
497                match &self.prev {
498                    Some(v) => std::env::set_var(&self.key, v),
499                    None => std::env::remove_var(&self.key),
500                }
501            }
502        }
503
504        let prev = std::env::var(key).ok();
505        let _guard = EnvGuard {
506            key: key.to_string(),
507            prev,
508        };
509        match val {
510            Some(v) => std::env::set_var(key, v),
511            None => std::env::remove_var(key),
512        }
513        f();
514        // _guard drops here (also runs on panic).
515    }
516
517    #[test]
518    fn parses_env_strings_round_trip() {
519        for mode in [
520            RuntimeMode::Installed,
521            RuntimeMode::Portable,
522            RuntimeMode::Dev {
523                branch: "main".into(),
524                clone_id: None,
525            },
526            RuntimeMode::Dev {
527                branch: "agenta-feature-x".into(),
528                clone_id: None,
529            },
530        ] {
531            let s = mode.to_env_string();
532            let back = parse_mode_string(&s).expect("round-trip");
533            assert_eq!(back, mode);
534        }
535    }
536
537    #[test]
538    fn parse_accepts_bare_dev() {
539        assert_eq!(
540            parse_mode_string("dev"),
541            Some(RuntimeMode::Dev {
542                branch: "default".into(),
543                clone_id: None,
544            })
545        );
546        assert_eq!(
547            parse_mode_string("DEV"),
548            Some(RuntimeMode::Dev {
549                branch: "default".into(),
550                clone_id: None,
551            })
552        );
553    }
554
555    #[test]
556    fn env_override_wins_over_path_detection() {
557        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
558        // Even if exe_dir looks portable (has `runtime/`), env override wins.
559        // We simulate by passing a path that doesn't exist (so .is_dir()
560        // returns false), but we also force the env override.
561        with_env("AGENTMUX_RUNTIME_MODE", Some("dev:main"), || {
562            with_env("AGENTMUX_DEV_BRANCH", None, || {
563                let mode = RuntimeMode::current(&PathBuf::from("/nonexistent"));
564                assert_eq!(
565                    mode,
566                    RuntimeMode::Dev {
567                        branch: "main".into(),
568                        clone_id: None,
569                    }
570                );
571            });
572        });
573    }
574
575    #[test]
576    fn dev_build_path_pattern() {
577        // Match dist/cef-dev/...
578        assert!(exe_dir_is_dev_build(&PathBuf::from(
579            "/c/Systems/agentmux/dist/cef-dev"
580        )));
581        // Match target/debug/...
582        assert!(exe_dir_is_dev_build(&PathBuf::from(
583            "/c/Systems/agentmux/target/debug"
584        )));
585        // Match target/release/...
586        assert!(exe_dir_is_dev_build(&PathBuf::from(
587            "/c/Systems/agentmux/target/release"
588        )));
589        // Don't match an installed path.
590        assert!(!exe_dir_is_dev_build(&PathBuf::from(
591            "/c/Program Files/AgentMux"
592        )));
593        // Don't match a portable path.
594        assert!(!exe_dir_is_dev_build(&PathBuf::from(
595            "/Users/me/Desktop/agentmux-portable"
596        )));
597    }
598
599    #[test]
600    fn slugify_branch_replaces_unsafe_chars() {
601        assert_eq!(slugify_branch("agenta/feature-x"), "agenta-feature-x");
602        assert_eq!(slugify_branch("feat:foo*bar"), "feat-foo-bar");
603        assert_eq!(slugify_branch("plain"), "plain");
604    }
605
606    #[test]
607    fn dir_slug_per_mode() {
608        assert_eq!(RuntimeMode::Installed.dir_slug(), "versions");
609        assert_eq!(RuntimeMode::Portable.dir_slug(), "versions");
610        assert_eq!(
611            RuntimeMode::Dev {
612                branch: "main".into(),
613                clone_id: None,
614            }
615            .dir_slug(),
616            "dev/main"
617        );
618        assert_eq!(
619            RuntimeMode::Dev {
620                branch: "agenta/x".into(),
621                clone_id: None,
622            }
623            .dir_slug(),
624            "dev/agenta-x"
625        );
626        // With a clone_id, the slug nests one level deeper so two
627        // clones on the same branch land in distinct subdirs.
628        assert_eq!(
629            RuntimeMode::Dev {
630                branch: "main".into(),
631                clone_id: Some("abcdef1234567890".into()),
632            }
633            .dir_slug(),
634            "dev/main/abcdef1234567890"
635        );
636    }
637
638    #[test]
639    fn invalid_env_string_falls_through() {
640        assert!(parse_mode_string("garbage").is_none());
641        assert!(parse_mode_string("").is_none());
642    }
643
644    #[test]
645    fn parse_dev_branch_rejects_traversal_attempts() {
646        // `..` resolves out of the dev/ subdir on disk — the slug must
647        // either reject it or strip it. We choose strip; if nothing
648        // usable remains, parse fails (returns None).
649        assert_eq!(parse_mode_string("dev:.."), None);
650        assert_eq!(parse_mode_string("dev:."), None);
651        assert_eq!(parse_mode_string("dev:"), None);
652        // `dev:../versions/x` slugifies to `versions-x` (the slashes
653        // are replaced and `..` segment is dropped).
654        let m = parse_mode_string("dev:../versions/x").expect("parses");
655        match m {
656            RuntimeMode::Dev { branch, .. } => {
657                assert!(!branch.contains(".."));
658                assert!(!branch.contains('/'));
659                assert!(!branch.contains('\\'));
660            }
661            _ => panic!("expected Dev variant"),
662        }
663    }
664
665    #[test]
666    fn dev_branch_env_with_unusable_value_falls_through() {
667        // AGENTMUX_DEV_BRANCH=`..` sanitizes to empty.
668        // With the fix, an unusable value falls through to Installed
669        // when no other dev signal applies — even when exe_dir has
670        // a .git ancestor that detect_branch could have grabbed.
671        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
672        let tmp = tempfile::TempDir::new().expect("tempdir");
673        // Plant a .git directory so a buggy implementation that called
674        // detect_branch as a fallback would find a real branch name and
675        // wrongly classify as Dev. Step 4 now sanitizes the env var
676        // directly without invoking the git lookup — verifying that.
677        std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
678        let exe_dir = tmp.path().join("subdir");
679        std::fs::create_dir_all(&exe_dir).unwrap();
680
681        std::env::remove_var("AGENTMUX_RUNTIME_MODE");
682        std::env::set_var("AGENTMUX_DEV_BRANCH", "..");
683        let mode = RuntimeMode::current(&exe_dir);
684        std::env::remove_var("AGENTMUX_DEV_BRANCH");
685
686        // Even with the .git ancestor, an unusable env value must
687        // fall through to Installed (NOT to Dev with a git-detected
688        // branch). The env var is the source-of-truth at step 4.
689        assert_eq!(mode, RuntimeMode::Installed);
690    }
691
692    #[test]
693    fn portable_marker_detection() {
694        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
695        let tmp = tempfile::TempDir::new().expect("tempdir");
696        let exe_dir = tmp.path();
697
698        // No marker → not portable.
699        assert!(!is_portable_marker_present(exe_dir));
700
701        // With marker next to the exe → portable (host/srv in runtime/, or a
702        // legacy portable with the marker at the root).
703        std::fs::write(exe_dir.join("agentmux-portable.marker"), b"").unwrap();
704        assert!(is_portable_marker_present(exe_dir));
705        std::fs::remove_file(exe_dir.join("agentmux-portable.marker")).unwrap();
706
707        // Marker inside runtime/ → portable (the launcher sits at the root and
708        // the marker lives one level down in runtime/). An empty runtime/ with
709        // no marker must NOT count (covered by
710        // current_with_only_runtime_subdir_is_not_portable).
711        std::fs::create_dir_all(exe_dir.join("runtime")).unwrap();
712        assert!(!is_portable_marker_present(exe_dir));
713        std::fs::write(exe_dir.join("runtime").join("agentmux-portable.marker"), b"").unwrap();
714        assert!(is_portable_marker_present(exe_dir));
715        std::fs::remove_file(exe_dir.join("runtime").join("agentmux-portable.marker")).unwrap();
716
717        // Marker two levels up (macOS .app bundle case). All markers are
718        // already removed above, so the bundle exe dir starts clean.
719        let nested = exe_dir.join("Contents/MacOS");
720        std::fs::create_dir_all(&nested).unwrap();
721        assert!(!is_portable_marker_present(&nested));
722        std::fs::write(exe_dir.join("agentmux-portable.marker"), b"").unwrap();
723        assert!(is_portable_marker_present(&nested));
724    }
725
726    #[test]
727    fn current_with_only_runtime_subdir_is_not_portable() {
728        // Regression: prior implementation returned Portable whenever
729        // <exe>/runtime/ existed, but installed builds also co-locate
730        // runtime/ (per the launcher unconditionally requiring it).
731        // Without a marker, we must NOT classify as Portable.
732        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
733        let tmp = tempfile::TempDir::new().expect("tempdir");
734        let exe_dir = tmp.path();
735        std::fs::create_dir_all(exe_dir.join("runtime")).unwrap();
736        // No agentmux-portable.marker marker — must be Installed (or whatever
737        // the fall-through is, but specifically NOT Portable).
738        std::env::remove_var("AGENTMUX_RUNTIME_MODE");
739        std::env::remove_var("AGENTMUX_DEV_BRANCH");
740        let mode = RuntimeMode::current(exe_dir);
741        assert_ne!(mode, RuntimeMode::Portable);
742    }
743
744    #[test]
745    fn sanitize_branch_slug_strips_traversal() {
746        assert_eq!(sanitize_branch_slug(".."), "");
747        assert_eq!(sanitize_branch_slug("../foo"), "foo");
748        assert_eq!(sanitize_branch_slug("foo/../bar"), "foo-bar");
749        assert_eq!(sanitize_branch_slug(".hidden"), "hidden");
750        assert_eq!(sanitize_branch_slug("ok-branch"), "ok-branch");
751    }
752
753    // ── derive_clone_id ─────────────────────────────────────────────
754
755    #[test]
756    fn derive_clone_id_returns_16_hex_chars_when_marker_present() {
757        let tmp = tempfile::TempDir::new().expect("tempdir");
758        // Plant a Cargo.toml at the root to act as the clone-root marker.
759        std::fs::write(tmp.path().join("Cargo.toml"), b"[workspace]").unwrap();
760        let nested = tmp.path().join("target/release");
761        std::fs::create_dir_all(&nested).unwrap();
762        let id = derive_clone_id(&nested).expect("found");
763        assert_eq!(id.len(), 16);
764        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
765    }
766
767    #[test]
768    fn derive_clone_id_is_stable_for_same_clone() {
769        let tmp = tempfile::TempDir::new().expect("tempdir");
770        std::fs::write(tmp.path().join("Taskfile.yml"), b"version: 3").unwrap();
771        let exe = tmp.path().join("dist/cef-dev");
772        std::fs::create_dir_all(&exe).unwrap();
773        let a = derive_clone_id(&exe).unwrap();
774        let b = derive_clone_id(&exe).unwrap();
775        assert_eq!(a, b);
776    }
777
778    #[test]
779    fn derive_clone_id_differs_between_clones() {
780        let tmp1 = tempfile::TempDir::new().expect("tempdir1");
781        let tmp2 = tempfile::TempDir::new().expect("tempdir2");
782        for t in [&tmp1, &tmp2] {
783            std::fs::write(t.path().join("Cargo.toml"), b"[workspace]").unwrap();
784        }
785        let id1 = derive_clone_id(tmp1.path()).unwrap();
786        let id2 = derive_clone_id(tmp2.path()).unwrap();
787        assert_ne!(
788            id1, id2,
789            "two clones at different paths must hash to different ids"
790        );
791    }
792
793    #[test]
794    fn derive_clone_id_returns_none_without_marker() {
795        let tmp = tempfile::TempDir::new().expect("tempdir");
796        // No marker at all — caller has no clone root to anchor to.
797        let id = derive_clone_id(tmp.path());
798        assert!(id.is_none());
799    }
800
801    #[test]
802    fn derive_clone_id_walks_up_to_find_marker() {
803        let tmp = tempfile::TempDir::new().expect("tempdir");
804        std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
805        let deep = tmp.path().join("a/b/c/d");
806        std::fs::create_dir_all(&deep).unwrap();
807        // From a deeply nested subdir, we should still find the .git marker.
808        assert!(derive_clone_id(&deep).is_some());
809    }
810
811    #[test]
812    fn sanitize_clone_id_rejects_traversal() {
813        assert_eq!(sanitize_clone_id("../foo"), "");
814        assert_eq!(sanitize_clone_id("a/b"), "");
815        assert_eq!(sanitize_clone_id("a\\b"), "");
816        assert_eq!(sanitize_clone_id("a..b"), "");
817        assert_eq!(sanitize_clone_id(""), "");
818        assert_eq!(sanitize_clone_id("abcdef1234567890"), "abcdef1234567890");
819    }
820
821    #[test]
822    fn from_env_with_clone_populates_clone_id_for_dev() {
823        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
824        with_env("AGENTMUX_RUNTIME_MODE", Some("dev:main"), || {
825            with_env("AGENTMUX_CLONE_ID", Some("deadbeefcafebabe"), || {
826                let m = RuntimeMode::from_env_with_clone().unwrap();
827                assert_eq!(
828                    m,
829                    RuntimeMode::Dev {
830                        branch: "main".into(),
831                        clone_id: Some("deadbeefcafebabe".into()),
832                    }
833                );
834            });
835        });
836    }
837
838    #[test]
839    fn from_env_with_clone_leaves_clone_id_none_when_unset() {
840        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
841        with_env("AGENTMUX_RUNTIME_MODE", Some("dev:main"), || {
842            with_env("AGENTMUX_CLONE_ID", None, || {
843                let m = RuntimeMode::from_env_with_clone().unwrap();
844                assert_eq!(
845                    m,
846                    RuntimeMode::Dev {
847                        branch: "main".into(),
848                        clone_id: None,
849                    }
850                );
851            });
852        });
853    }
854
855    /// Regression for "DEV badge on every build": a packaged build launched as
856    /// a descendant of a `task dev` AgentMux inherits
857    /// `AGENTMUX_RUNTIME_MODE=dev:main`. Build identity (badge, menu name,
858    /// dev-frontend fallback) goes through `current_path_only` / `is_dev_self`,
859    /// which must ignore that env and classify purely by the binary's path.
860    #[test]
861    fn current_path_only_ignores_leaked_dev_env() {
862        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
863        with_env("AGENTMUX_RUNTIME_MODE", Some("dev:main"), || {
864            // A non-dev exe dir: no portable marker, not a dev-build path.
865            let installed = std::path::Path::new("/Applications/AgentMux.app/Contents/MacOS");
866            assert!(
867                matches!(
868                    RuntimeMode::current_path_only(installed),
869                    RuntimeMode::Installed
870                ),
871                "current_path_only must ignore a leaked AGENTMUX_RUNTIME_MODE=dev:main"
872            );
873            // Contrast: current() DOES still honor an explicit env override.
874            assert!(
875                matches!(RuntimeMode::current(installed), RuntimeMode::Dev { .. }),
876                "current() still honors an explicit AGENTMUX_RUNTIME_MODE override"
877            );
878        });
879    }
880}