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}