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}