Skip to main content

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