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