agentmux_common/
data_paths.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Unified data-path resolution for AgentMux.
5//!
6//! Single source of truth for where state lives on disk. Replaces the
7//! launcher / host / sidecar trio of independent path computations
8//! (see docs/specs/SPEC_DATA_DIR_UNIFICATION_2026-05-05.md §3) and the
9//! per-version isolation pattern it set up (data was keyed on the
10//! build version so My Agents reset on every patch bump). The current
11//! model keys data on a *channel* — a stable identifier that spans
12//! versions within the same compat band, so agents survive rebuilds.
13//! See docs/specs/SPEC_DATA_CHANNELS_2026_05_24.md and discussion
14//! #1026 for the channel design and rationale.
15//!
16//! Layout:
17//!
18//! ```text
19//! ~/.agentmux/
20//! ├── shared/                       (cookies, credentials, account-wide)
21//! ├── channels/<channel>/           (installed + portable + custom)
22//! │   ├── data/, config/, logs/, cef-cache/, agents/
23//! │   └── runtime/                  (lock + IPC, single instance per channel)
24//! └── dev/<branch>/                 (per-branch dev isolation)
25//!     └── (same children as channels/<channel>/)
26//! ```
27//!
28//! Channel resolution (via [`DataPaths::resolve`]):
29//! - `AGENTMUX_CHANNEL=<name>` env override wins for `Installed` /
30//!   `Portable` modes — lets the operator point a released binary at
31//!   any channel for parallel-channel testing.
32//! - `RuntimeMode::Installed` / `Portable` w/o override → build-time
33//!   default from `AGENTMUX_BUILD_CHANNEL_DEFAULT` (set by the
34//!   packaging script; defaults to `"stable"` if unset, e.g. for
35//!   `cargo run`).
36//! - `RuntimeMode::Dev { branch }` → channel name is `dev-<branch>`
37//!   for diagnostics; on-disk path stays at `~/.agentmux/dev/<branch>/`
38//!   (NOT under `channels/`). Both the host (`agentmux-cef`) and
39//!   launcher (`agentmux-launcher`) use [`DataPaths::resolve_path_only`]
40//!   for dev builds to ignore `AGENTMUX_CHANNEL` — a dev session
41//!   launched from inside a parent agentmux pane mustn't inherit
42//!   the parent's channel and break per-branch isolation. Channel
43//!   override is intentionally NOT supported in dev mode; if you
44//!   want a different channel, use a portable build.
45
46use crate::RuntimeMode;
47use std::path::{Path, PathBuf};
48
49/// Build-time default channel for `Installed` / `Portable` modes.
50/// Set by the packaging script (`task package` exports
51/// `AGENTMUX_BUILD_CHANNEL_DEFAULT=dev-portable`; release CI exports
52/// `stable`). Falls back to `"stable"` when the binary is built
53/// without the env (e.g. plain `cargo build` / `cargo run` for tests).
54const BUILD_CHANNEL_DEFAULT: &str =
55    match option_env!("AGENTMUX_BUILD_CHANNEL_DEFAULT") {
56        Some(s) => s,
57        None => "stable",
58    };
59
60/// Channel names that would collide with sibling dirs at
61/// `~/.agentmux/` or with reserved subdir names inside a channel.
62/// Rejected by [`sanitize_channel_name`].
63const RESERVED_CHANNEL_NAMES: &[&str] = &[
64    "shared",
65    "snapshots",
66    "dev",
67    "versions",
68    "channels",
69    "runtime",
70];
71
72/// All paths a launcher / host / srv needs. Computed once by the
73/// launcher; downstream binaries read paths from env vars set by the
74/// launcher rather than recomputing (avoids the legacy desync risk
75/// where each binary made its own portable / dev-mode determination).
76#[derive(Debug, Clone)]
77pub struct DataPaths {
78    /// `~/.agentmux/` itself — the resolved root. Account-wide config
79    /// that predates the unified layout (e.g. the launcher's
80    /// `config.toml`) lives directly here. Honors
81    /// `AGENTMUX_HOME_OVERRIDE` for tests.
82    pub home_dir: PathBuf,
83
84    /// Top-level dir for this channel+mode. All per-channel paths
85    /// below are children. Either `~/.agentmux/channels/<channel>/`
86    /// (installed / portable / AGENTMUX_CHANNEL override) or
87    /// `~/.agentmux/dev/<branch>/` (dev mode without env override).
88    ///
89    /// Note: the field name is `instance_dir` for backward compat with
90    /// downstream call sites; semantically it's now the *channel* root,
91    /// not the *version* root.
92    pub instance_dir: PathBuf,
93
94    /// Channel identifier this resolution used (e.g. `"stable"`,
95    /// `"dev-portable"`, `"dev-main"`, or a user-specified custom
96    /// channel from `AGENTMUX_CHANNEL`). Surfaced for diagnostics,
97    /// logging, and the launcher splash; downstream binaries usually
98    /// don't need it (paths are passed via env vars).
99    pub channel: String,
100
101    /// `instance_dir/data/` — srv DB (objects.db, sagas.db, …).
102    pub data_dir: PathBuf,
103
104    /// `instance_dir/config/` — settings.json, repos.json, etc.
105    pub config_dir: PathBuf,
106
107    /// `instance_dir/logs/` — host + srv + launcher logs (rotated).
108    pub logs_dir: PathBuf,
109
110    /// `instance_dir/cef-cache/` — Chromium runtime cache (regenerable).
111    pub cef_cache_dir: PathBuf,
112
113    /// `instance_dir/agents/` — agent workspace state.
114    pub agents_dir: PathBuf,
115
116    /// `instance_dir/runtime/` — single-instance lock + IPC (pid,
117    /// lockfile, ipc-port, named-pipe). One set per version+mode.
118    pub instance_runtime_dir: PathBuf,
119
120    /// `~/.agentmux/shared/` — version-independent, account-wide
121    /// state (cookies, OAuth tokens, API keys, dictionary downloads).
122    pub shared_dir: PathBuf,
123
124    /// Snapshot of the [`RuntimeMode`] this resolution used. Helpful
125    /// for logging and feature gates.
126    pub mode: RuntimeMode,
127}
128
129impl DataPaths {
130    /// Resolve all paths for the given version + mode. Honors
131    /// `AGENTMUX_HOME_OVERRIDE` for tests (replaces `~/.agentmux` root).
132    ///
133    /// Returns `Err` if the input contains values that cannot be
134    /// represented as a safe single-segment subpath — e.g. `..` in the
135    /// version string, or a Dev branch that sanitizes to empty. This
136    /// is belt-and-braces safety: parse-time sanitization in
137    /// [`crate::RuntimeMode`] should already have caught these, but a
138    /// `RuntimeMode::Dev { branch }` constructed directly (e.g. by a
139    /// test or future caller) is also rejected here.
140    pub fn resolve(version: &str, mode: &RuntimeMode) -> Result<Self, String> {
141        Self::resolve_internal(version, mode, /* honor_env_channel = */ true)
142    }
143
144    /// Like [`Self::resolve`], but ignores the `AGENTMUX_CHANNEL` env
145    /// override and uses only the mode-based default channel. Mirror
146    /// of [`RuntimeMode::current_path_only`] for path resolution.
147    ///
148    /// Used by dev-build self-detection paths in `agentmux-cef`'s
149    /// `main.rs` and `sidecar.rs`. Those paths run when a dev host
150    /// has been launched from inside a parent AgentMux instance (e.g.
151    /// `task dev` invoked from inside an agent pane in a portable
152    /// build), where the child would otherwise inherit the parent's
153    /// `AGENTMUX_*` env — including `AGENTMUX_CHANNEL` — and write
154    /// into the parent's channel instead of `dev/<branch>/`. That
155    /// cross-contamination would also trip the channel's single-
156    /// instance lock and route every "open" back to the parent
157    /// window. Path-based mode detection is authoritative for dev
158    /// builds; channel resolution here mirrors that discipline.
159    /// Codex P1 follow-up on PR #1027.
160    pub fn resolve_path_only(version: &str, mode: &RuntimeMode) -> Result<Self, String> {
161        Self::resolve_internal(version, mode, /* honor_env_channel = */ false)
162    }
163
164    fn resolve_internal(
165        version: &str,
166        mode: &RuntimeMode,
167        honor_env_channel: bool,
168    ) -> Result<Self, String> {
169        let root = resolve_root()?;
170        // `version` is still validated for path safety even though it
171        // no longer appears in the on-disk path — it flows into
172        // logging, the migration framework (Increment B), and
173        // `meta.json` records, so a traversal-laced value mustn't
174        // round-trip into a future path build by accident.
175        sanitize_path_segment(version)
176            .ok_or_else(|| format!("invalid version string for path: {:?}", version))?;
177
178        // Channel resolution: env override > mode default. Dev mode's
179        // *channel name* and *path* diverge intentionally — name is
180        // `dev-<branch>` for diagnostics; path stays at
181        // `~/.agentmux/dev/<branch>/` so per-branch isolation works
182        // unchanged from Phase 1.
183        let (channel, instance_dir) =
184            resolve_channel_and_dir(mode, &root, honor_env_channel)?;
185
186        let data_dir = instance_dir.join("data");
187        let config_dir = instance_dir.join("config");
188        let logs_dir = instance_dir.join("logs");
189        let cef_cache_dir = instance_dir.join("cef-cache");
190        let agents_dir = instance_dir.join("agents");
191        let instance_runtime_dir = instance_dir.join("runtime");
192        let shared_dir = root.join("shared");
193
194        Ok(Self {
195            home_dir: root,
196            instance_dir,
197            channel,
198            data_dir,
199            config_dir,
200            logs_dir,
201            cef_cache_dir,
202            agents_dir,
203            instance_runtime_dir,
204            shared_dir,
205            mode: mode.clone(),
206        })
207    }
208
209    /// Create every directory that may be written to. Idempotent.
210    /// Safe to call on every launch.
211    pub fn ensure_dirs(&self) -> Result<(), String> {
212        for d in [
213            &self.instance_dir,
214            &self.data_dir,
215            &self.config_dir,
216            &self.logs_dir,
217            &self.cef_cache_dir,
218            &self.agents_dir,
219            &self.instance_runtime_dir,
220            &self.shared_dir,
221        ] {
222            std::fs::create_dir_all(d)
223                .map_err(|e| format!("failed to create {}: {}", d.display(), e))?;
224        }
225        // The data dir's `db/` subdir is the canonical srv DB home;
226        // mirrors legacy ensure_dirs() and lets srv unconditionally
227        // open `data_dir/db/objects.db`.
228        std::fs::create_dir_all(self.data_dir.join("db"))
229            .map_err(|e| format!("failed to create db dir: {}", e))?;
230        Ok(())
231    }
232
233    /// Env vars to pass to host + srv subprocesses. The launcher
234    /// computes `DataPaths` once and exports these; downstream
235    /// binaries read them via [`Self::from_env`] instead of
236    /// recomputing.
237    ///
238    /// Returns `OsString` (not `String`) so paths with non-UTF-8 bytes
239    /// — possible on Linux/macOS for users with exotic home dirs —
240    /// round-trip losslessly. `Command::env(k, v)` accepts any
241    /// `AsRef<OsStr>`, so the OsString flows through to children
242    /// unchanged. The mode value is the only `String`-typed entry
243    /// (it's a fixed ASCII vocabulary).
244    pub fn to_env_vars(&self) -> Vec<(&'static str, std::ffi::OsString)> {
245        use std::ffi::OsString;
246        vec![
247            ("AGENTMUX_INSTANCE_DIR", self.instance_dir.clone().into_os_string()),
248            ("AGENTMUX_DATA_DIR", self.data_dir.clone().into_os_string()),
249            ("AGENTMUX_CONFIG_DIR", self.config_dir.clone().into_os_string()),
250            ("AGENTMUX_LOG_DIR", self.logs_dir.clone().into_os_string()),
251            ("AGENTMUX_CEF_CACHE_DIR", self.cef_cache_dir.clone().into_os_string()),
252            ("AGENTMUX_AGENTS_DIR", self.agents_dir.clone().into_os_string()),
253            (
254                "AGENTMUX_INSTANCE_RUNTIME_DIR",
255                self.instance_runtime_dir.clone().into_os_string(),
256            ),
257            ("AGENTMUX_SHARED_DIR", self.shared_dir.clone().into_os_string()),
258            ("AGENTMUX_RUNTIME_MODE", OsString::from(self.mode.to_env_string())),
259            // Channel propagated so downstream binaries can log it +
260            // surface in diagnostics. NOT used to recompute paths
261            // (paths flow through the dir vars above).
262            ("AGENTMUX_CHANNEL", OsString::from(self.channel.clone())),
263        ]
264    }
265
266    /// Reconstruct from env vars set by the launcher. Returns
267    /// `None` if any required var is missing — fail-fast vs.
268    /// silently falling back to legacy paths the way the old
269    /// sidecar.rs did.
270    ///
271    /// Uses `var_os` (not `var`) so non-UTF-8 path bytes survive.
272    pub fn from_env() -> Option<Self> {
273        let instance_dir = std::env::var_os("AGENTMUX_INSTANCE_DIR")?;
274        let data_dir = std::env::var_os("AGENTMUX_DATA_DIR")?;
275        let config_dir = std::env::var_os("AGENTMUX_CONFIG_DIR")?;
276        let logs_dir = std::env::var_os("AGENTMUX_LOG_DIR")?;
277        let cef_cache_dir = std::env::var_os("AGENTMUX_CEF_CACHE_DIR")?;
278        let agents_dir = std::env::var_os("AGENTMUX_AGENTS_DIR")?;
279        let instance_runtime_dir = std::env::var_os("AGENTMUX_INSTANCE_RUNTIME_DIR")?;
280        let shared_dir = std::env::var_os("AGENTMUX_SHARED_DIR")?;
281        let mode = RuntimeMode::from_env()?;
282        // Channel is required from the launcher (same fail-fast
283        // discipline as every other dir var). Missing AGENTMUX_CHANNEL
284        // means the launcher didn't export it — that's a launcher /
285        // srv version skew, surface it loudly rather than silently
286        // defaulting and risking a wrong-channel write.
287        let channel = std::env::var("AGENTMUX_CHANNEL").ok()?;
288
289        // Re-resolve home_dir (the agentmux root) on the consumer
290        // side rather than transmitting it via env — it's a function
291        // of the AGENTMUX_HOME_OVERRIDE env (test only) and the OS
292        // home dir, which are stable across the launcher → host hop.
293        let home_dir = resolve_root().ok()?;
294
295        Some(Self {
296            home_dir,
297            instance_dir: PathBuf::from(instance_dir),
298            channel,
299            data_dir: PathBuf::from(data_dir),
300            config_dir: PathBuf::from(config_dir),
301            logs_dir: PathBuf::from(logs_dir),
302            cef_cache_dir: PathBuf::from(cef_cache_dir),
303            agents_dir: PathBuf::from(agents_dir),
304            instance_runtime_dir: PathBuf::from(instance_runtime_dir),
305            shared_dir: PathBuf::from(shared_dir),
306            mode,
307        })
308    }
309
310    /// `~/.agentmux/shared/identities/` — root for per-bundle OAuth
311    /// credential directories. Lives under `shared_dir` so it's
312    /// account-wide and version-independent: upgrading agentmux does
313    /// not move a user's bundle credentials. Per
314    /// `SPEC_OAUTH_IDENTITY_BUNDLES_2026_05_22.md` §4.1.
315    pub fn identities_dir(&self) -> PathBuf {
316        self.shared_dir.join("identities")
317    }
318
319    /// `~/.agentmux/shared/identities/<bundle_id>/` — a specific
320    /// bundle's credential root, when `bundle_id` is a safe path
321    /// segment. Returns `None` for empty / `.` / `..` / any segment
322    /// containing `/`, `\`, drive-letter colons, or Windows-reserved
323    /// characters (same rules as the version/branch sanitizer in
324    /// `resolve`).
325    ///
326    /// Defensive return type: `bundle_id` flows from `auth.start`
327    /// request bodies (PR C) into `create_dir_all`, so an
328    /// unvalidated `PathBuf::join` would let a crafted id escape the
329    /// identities root and write outside the bundle area. codex P1
330    /// follow-up on #981.
331    ///
332    /// Per-provider subdirectories (e.g. `claude/`, `codex/`) hang
333    /// off this when the bundle gains an OAuth binding (PR C). The
334    /// directory is created lazily by the bundle / OAuth flow that
335    /// needs it — `ensure_dirs()` does not pre-create it.
336    pub fn identity_dir(&self, bundle_id: &str) -> Option<PathBuf> {
337        sanitize_path_segment(bundle_id).map(|safe| self.identities_dir().join(safe))
338    }
339}
340
341/// `~/.agentmux/` root, or the test override via
342/// `AGENTMUX_HOME_OVERRIDE`. Falls back to error if no home dir
343/// can be resolved (rare — should only happen in stripped CI envs).
344fn resolve_root() -> Result<PathBuf, String> {
345    if let Ok(s) = std::env::var("AGENTMUX_HOME_OVERRIDE") {
346        if !s.is_empty() {
347            return Ok(PathBuf::from(s));
348        }
349    }
350    let home = dirs::home_dir().ok_or_else(|| "dirs::home_dir() returned None".to_string())?;
351    Ok(home.join(".agentmux"))
352}
353
354/// Sanitize a string for use as a single filesystem path segment.
355/// Rejects empty, `.`, `..`, segments containing path separators, and
356/// any character that has filesystem-special meaning on Windows (which
357/// is the most restrictive of the platforms we target). Used as belt-
358/// and-braces protection in `DataPaths::resolve` to prevent traversal
359/// even when callers pass a directly-constructed `RuntimeMode::Dev` or
360/// odd version string.
361///
362/// Why `:` is rejected: on Windows `C:temp` is a drive-relative path,
363/// not a literal filename, so `PathBuf::join("versions").join("C:temp")`
364/// would resolve OUTSIDE the intended `~/.agentmux/versions/` subtree.
365fn sanitize_path_segment(s: &str) -> Option<String> {
366    // Reject whitespace padding rather than silently normalizing it
367    // away — otherwise distinct caller-supplied ids like "foo" and
368    // " foo " would alias to the same directory. bundle_id is a real
369    // caller-supplied identifier (passed through RPC payloads), so
370    // this matters for credential isolation. codex P2 follow-up on
371    // #981. Internally-generated version strings + branch names
372    // shouldn't carry padding anyway, so this is no-op for them.
373    if s != s.trim() {
374        return None;
375    }
376    if s.is_empty() || s == "." || s == ".." {
377        return None;
378    }
379    // Filesystem separators + Windows-reserved characters + NUL.
380    if s
381        .chars()
382        .any(|c| matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0'))
383    {
384        return None;
385    }
386    Some(s.to_string())
387}
388
389/// Sanitize a string for use as a channel name. Same path-segment
390/// safety rules as [`sanitize_path_segment`] plus:
391/// - Length capped at 64 chars (channel names show up in logs + the
392///   launcher splash + may eventually be displayed in a picker, so
393///   the cap is for UI sanity, not security).
394/// - Rejects names in [`RESERVED_CHANNEL_NAMES`] that would collide
395///   with sibling dirs at `~/.agentmux/` or reserved subdir names
396///   inside a channel.
397/// - The synonym `"default"` maps to `"stable"` (per
398///   `SPEC_DATA_CHANNELS_2026_05_24.md` §7.5).
399fn sanitize_channel_name(s: &str) -> Option<String> {
400    let base = sanitize_path_segment(s)?;
401    if base.len() > 64 {
402        return None;
403    }
404    if RESERVED_CHANNEL_NAMES.contains(&base.as_str()) {
405        return None;
406    }
407    if base == "default" {
408        return Some("stable".to_string());
409    }
410    Some(base)
411}
412
413/// Resolve the channel name and on-disk channel dir for a given mode.
414/// Pure function over (env, mode, root). When `honor_env_channel` is
415/// `true`, `AGENTMUX_CHANNEL` overrides the mode default; when `false`,
416/// the env is ignored and resolution depends only on `mode` +
417/// build-time defaults. The `false` path is used by dev-build self-
418/// detection (see [`DataPaths::resolve_path_only`]).
419///
420/// Resolution order (mirrors `SPEC_DATA_CHANNELS_2026_05_24.md` §2.2):
421///   1. (only if `honor_env_channel`) `AGENTMUX_CHANNEL` env override —
422///      any mode → path is `<root>/channels/<channel>/`. Lets the
423///      operator point any binary at any channel for parallel-channel
424///      testing.
425///   2. No override (or env-channel disallowed), mode = Dev { branch }
426///      → channel name is `dev-<branch>`, path stays at
427///      `<root>/dev/<branch>/` (unchanged from Phase 1).
428///   3. Same conditions, mode = Installed | Portable → channel name is
429///      [`BUILD_CHANNEL_DEFAULT`] (set at build time by the packaging
430///      script), path is `<root>/channels/<channel>/`.
431fn resolve_channel_and_dir(
432    mode: &RuntimeMode,
433    root: &Path,
434    honor_env_channel: bool,
435) -> Result<(String, PathBuf), String> {
436    // (1) Explicit env override — only when caller opted in.
437    if honor_env_channel {
438        if let Ok(raw) = std::env::var("AGENTMUX_CHANNEL") {
439            if !raw.is_empty() {
440                let channel = sanitize_channel_name(&raw).ok_or_else(|| {
441                    format!("invalid AGENTMUX_CHANNEL value: {:?}", raw)
442                })?;
443                let dir = root.join("channels").join(&channel);
444                return Ok((channel, dir));
445            }
446        }
447    }
448
449    // (2) Dev mode default: dev-<branch>, path under dev/<branch>/.
450    if let RuntimeMode::Dev { branch } = mode {
451        let safe_branch = sanitize_path_segment(branch).ok_or_else(|| {
452            format!("invalid dev branch for path: {:?}", branch)
453        })?;
454        let channel = format!("dev-{}", safe_branch);
455        let dir = root.join("dev").join(safe_branch);
456        return Ok((channel, dir));
457    }
458
459    // (3) Installed / Portable default: build-time channel.
460    let channel = sanitize_channel_name(BUILD_CHANNEL_DEFAULT).ok_or_else(|| {
461        format!(
462            "compile-time AGENTMUX_BUILD_CHANNEL_DEFAULT is invalid: {:?}",
463            BUILD_CHANNEL_DEFAULT
464        )
465    })?;
466    let dir = root.join("channels").join(&channel);
467    Ok((channel, dir))
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::TEST_ENV_LOCK;
474    use tempfile::TempDir;
475
476    /// RAII guard that restores process state on drop, even if the
477    /// test panics. Without Drop-based cleanup, a panic inside `f`
478    /// would leave AGENTMUX_HOME_OVERRIDE set with a stale tempdir
479    /// path AND poison the mutex; subsequent tests recover from poison
480    /// but inherit the wrong env value.
481    struct HomeOverrideGuard {
482        _tmp: TempDir,
483        _lock: std::sync::MutexGuard<'static, ()>,
484    }
485
486    impl Drop for HomeOverrideGuard {
487        fn drop(&mut self) {
488            std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
489        }
490    }
491
492    fn with_home_override<F: FnOnce(&Path)>(f: F) {
493        let lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
494        let tmp = TempDir::new().expect("tempdir");
495        let path = tmp.path().to_path_buf();
496        std::env::set_var("AGENTMUX_HOME_OVERRIDE", &path);
497        let _guard = HomeOverrideGuard { _tmp: tmp, _lock: lock };
498        f(&path);
499        // _guard drops here, removing the env var even if f panicked
500        // (the panic still propagates after Drop runs).
501    }
502
503    /// Helper: clear AGENTMUX_CHANNEL inside an existing
504    /// with_home_override block to test pure mode-default resolution.
505    /// Channel resolution reads the live env var, so individual tests
506    /// must clear it to avoid leakage from sibling tests running
507    /// concurrently inside the same process (TEST_ENV_LOCK serializes
508    /// HOME_OVERRIDE but the channel var is a separate axis).
509    fn clear_channel_env() {
510        std::env::remove_var("AGENTMUX_CHANNEL");
511    }
512
513    #[test]
514    fn installed_paths_under_default_channel() {
515        with_home_override(|root| {
516            clear_channel_env();
517            let p = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
518            // Default channel for Installed without env override =
519            // BUILD_CHANNEL_DEFAULT (which is "stable" in dev / test
520            // builds — `option_env!` falls back when the build env
521            // wasn't set by the packaging script).
522            assert_eq!(p.channel, "stable");
523            assert_eq!(p.instance_dir, root.join("channels").join("stable"));
524            assert_eq!(p.data_dir, p.instance_dir.join("data"));
525            assert_eq!(p.config_dir, p.instance_dir.join("config"));
526            assert_eq!(p.logs_dir, p.instance_dir.join("logs"));
527            assert_eq!(p.cef_cache_dir, p.instance_dir.join("cef-cache"));
528            assert_eq!(p.agents_dir, p.instance_dir.join("agents"));
529            assert_eq!(p.instance_runtime_dir, p.instance_dir.join("runtime"));
530            assert_eq!(p.shared_dir, root.join("shared"));
531        });
532    }
533
534    #[test]
535    fn home_dir_resolves_to_root() {
536        // The agentmux root (~/.agentmux/ or AGENTMUX_HOME_OVERRIDE)
537        // is exposed via DataPaths.home_dir for legacy account-wide
538        // state like the launcher's config.toml. Resolve in both
539        // installed and dev modes; both should point at the same root.
540        with_home_override(|root| {
541            clear_channel_env();
542            let inst = DataPaths::resolve("0.33.641", &RuntimeMode::Installed).unwrap();
543            assert_eq!(inst.home_dir, root);
544            let dev = DataPaths::resolve(
545                "0.33.641",
546                &RuntimeMode::Dev {
547                    branch: "main".into(),
548                },
549            )
550            .unwrap();
551            assert_eq!(dev.home_dir, root);
552        });
553    }
554
555    #[test]
556    fn portable_paths_match_installed() {
557        with_home_override(|root| {
558            clear_channel_env();
559            let inst = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
560            let port = DataPaths::resolve("0.33.639", &RuntimeMode::Portable).unwrap();
561            // Portable + Installed share a default channel (no env
562            // override → both fall through to BUILD_CHANNEL_DEFAULT),
563            // so their data dirs are the same. Multi-instance
564            // isolation is now channel-keyed: if you want two
565            // independent portables, override AGENTMUX_CHANNEL on one.
566            assert_eq!(inst.channel, port.channel);
567            assert_eq!(inst.instance_dir, port.instance_dir);
568            assert_eq!(inst.data_dir, port.data_dir);
569            // shared/ is mode-independent.
570            assert_eq!(inst.shared_dir, root.join("shared"));
571            assert_eq!(port.shared_dir, root.join("shared"));
572        });
573    }
574
575    #[test]
576    fn dev_paths_under_dev_branch() {
577        with_home_override(|root| {
578            clear_channel_env();
579            let mode = RuntimeMode::Dev {
580                branch: "main".into(),
581            };
582            let p = DataPaths::resolve("0.33.639", &mode).unwrap();
583            // Dev mode default: on-disk path stays under dev/<branch>/
584            // (unchanged from Phase 1), channel name is "dev-<branch>"
585            // for diagnostics. The two diverge intentionally — see
586            // resolve_channel_and_dir doc.
587            assert_eq!(p.channel, "dev-main");
588            assert_eq!(p.instance_dir, root.join("dev").join("main"));
589            assert_eq!(p.data_dir, root.join("dev").join("main").join("data"));
590            assert_eq!(p.shared_dir, root.join("shared"));
591        });
592    }
593
594    #[test]
595    fn env_override_redirects_any_mode_under_channels() {
596        // AGENTMUX_CHANNEL is absolute precedence. Even Dev mode,
597        // which would otherwise land at dev/<branch>/, lands under
598        // channels/<override>/ when the env is set. This is the
599        // "test a hot-fix build against the live stable data" path
600        // from SPEC_DATA_CHANNELS_2026_05_24.md §2.2.
601        with_home_override(|root| {
602            std::env::set_var("AGENTMUX_CHANNEL", "experiment");
603            // Cleanup via Drop so a test panic doesn't leak it.
604            struct ChannelGuard;
605            impl Drop for ChannelGuard {
606                fn drop(&mut self) {
607                    std::env::remove_var("AGENTMUX_CHANNEL");
608                }
609            }
610            let _g = ChannelGuard;
611
612            let inst = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
613            assert_eq!(inst.channel, "experiment");
614            assert_eq!(inst.instance_dir, root.join("channels").join("experiment"));
615
616            let port = DataPaths::resolve("0.33.639", &RuntimeMode::Portable).unwrap();
617            assert_eq!(port.channel, "experiment");
618            assert_eq!(port.instance_dir, root.join("channels").join("experiment"));
619
620            let dev = DataPaths::resolve(
621                "0.33.639",
622                &RuntimeMode::Dev { branch: "main".into() },
623            )
624            .unwrap();
625            // Override beats the dev/<branch>/ default — channel name
626            // matches the override, path lands under channels/, not dev/.
627            assert_eq!(dev.channel, "experiment");
628            assert_eq!(dev.instance_dir, root.join("channels").join("experiment"));
629        });
630    }
631
632    #[test]
633    fn env_override_rejects_unsafe_or_reserved_names() {
634        with_home_override(|_root| {
635            // Reserved (would collide with sibling dirs / inner dirs).
636            for bad in ["shared", "snapshots", "dev", "versions", "channels", "runtime"] {
637                std::env::set_var("AGENTMUX_CHANNEL", bad);
638                let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
639                std::env::remove_var("AGENTMUX_CHANNEL");
640                assert!(
641                    r.is_err(),
642                    "AGENTMUX_CHANNEL={:?} should be rejected as reserved",
643                    bad
644                );
645            }
646
647            // Path-unsafe (traversal, separators, Windows-reserved).
648            // NUL not tested here — Windows' WinAPI rejects NUL in
649            // env-var values at the syscall level, so `set_var` would
650            // panic before our sanitizer runs. NUL rejection is
651            // covered by the direct-call sanitize_path_segment path
652            // in identity_dir_rejects_unsafe_segments.
653            for bad in ["..", ".", "a/b", "a\\b", "C:foo", "a*b"] {
654                std::env::set_var("AGENTMUX_CHANNEL", bad);
655                let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
656                std::env::remove_var("AGENTMUX_CHANNEL");
657                assert!(
658                    r.is_err(),
659                    "AGENTMUX_CHANNEL={:?} should be rejected as path-unsafe",
660                    bad
661                );
662            }
663
664            // Empty string treated as "not set" — falls through to
665            // mode-based default. Documents the behavior so a
666            // `.env`-set empty value doesn't surprise.
667            std::env::set_var("AGENTMUX_CHANNEL", "");
668            let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
669            std::env::remove_var("AGENTMUX_CHANNEL");
670            assert!(r.is_ok(), "empty AGENTMUX_CHANNEL should fall through to default");
671            assert_eq!(r.unwrap().channel, "stable");
672        });
673    }
674
675    #[test]
676    fn env_override_default_is_synonym_for_stable() {
677        with_home_override(|root| {
678            std::env::set_var("AGENTMUX_CHANNEL", "default");
679            let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
680            std::env::remove_var("AGENTMUX_CHANNEL");
681            let r = r.unwrap();
682            // "default" maps to "stable" per spec §7.5; on-disk path
683            // is channels/stable/, not channels/default/.
684            assert_eq!(r.channel, "stable");
685            assert_eq!(r.instance_dir, root.join("channels").join("stable"));
686        });
687    }
688
689    #[test]
690    fn channel_name_length_capped_at_64() {
691        with_home_override(|_root| {
692            // 64 chars OK, 65 rejected. The cap is for UI sanity, not
693            // security — channel names show up in logs and the
694            // launcher splash.
695            let ok = "a".repeat(64);
696            std::env::set_var("AGENTMUX_CHANNEL", &ok);
697            let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
698            std::env::remove_var("AGENTMUX_CHANNEL");
699            assert!(r.is_ok(), "64-char channel should be accepted");
700
701            let too_long = "a".repeat(65);
702            std::env::set_var("AGENTMUX_CHANNEL", &too_long);
703            let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
704            std::env::remove_var("AGENTMUX_CHANNEL");
705            assert!(r.is_err(), "65-char channel should be rejected");
706        });
707    }
708
709    #[test]
710    fn dev_branch_traversal_via_runtime_mode_still_rejected() {
711        // Dev mode resolution sanitizes the branch via the same
712        // sanitize_path_segment as before — channel rename doesn't
713        // weaken the traversal-safety guarantees. Reproduces the
714        // pre-channel test for parity.
715        with_home_override(|_root| {
716            clear_channel_env();
717            let r = DataPaths::resolve(
718                "0.33.639",
719                &RuntimeMode::Dev { branch: "..".into() },
720            );
721            assert!(r.is_err());
722            let r = DataPaths::resolve(
723                "0.33.639",
724                &RuntimeMode::Dev { branch: "foo/bar".into() },
725            );
726            assert!(r.is_err());
727        });
728    }
729
730    #[test]
731    fn identity_dir_rejects_unsafe_segments() {
732        // bundle_id flows from auth.start request bodies into
733        // create_dir_all. Without sanitization a crafted id would
734        // escape the identities root. The function must return None
735        // for traversal attempts, separator-bearing segments, and
736        // Windows-reserved characters. codex P1 follow-up on #981.
737        with_home_override(|_root| {
738            let p = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
739
740            // Happy path — a normal UUID-shaped id resolves.
741            assert!(p.identity_dir("abc-123-uuid").is_some());
742
743            // Path traversal.
744            assert_eq!(p.identity_dir(".."), None);
745            assert_eq!(p.identity_dir("."), None);
746            assert_eq!(p.identity_dir("../../../etc"), None);
747            assert_eq!(p.identity_dir("a/b"), None);
748            assert_eq!(p.identity_dir("a\\b"), None);
749
750            // Empty / whitespace-only.
751            assert_eq!(p.identity_dir(""), None);
752            assert_eq!(p.identity_dir("   "), None);
753
754            // Windows-reserved characters.
755            assert_eq!(p.identity_dir("C:foo"), None);
756            assert_eq!(p.identity_dir("foo*bar"), None);
757            assert_eq!(p.identity_dir("foo?bar"), None);
758            assert_eq!(p.identity_dir("with\0nul"), None);
759        });
760    }
761
762    #[test]
763    fn ensure_dirs_creates_everything() {
764        with_home_override(|_root| {
765            clear_channel_env();
766            let p = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
767            p.ensure_dirs().unwrap();
768            assert!(p.instance_dir.is_dir());
769            assert!(p.data_dir.is_dir());
770            assert!(p.data_dir.join("db").is_dir());
771            assert!(p.config_dir.is_dir());
772            assert!(p.logs_dir.is_dir());
773            assert!(p.cef_cache_dir.is_dir());
774            assert!(p.agents_dir.is_dir());
775            assert!(p.instance_runtime_dir.is_dir());
776            assert!(p.shared_dir.is_dir());
777        });
778    }
779
780    #[test]
781    fn env_vars_round_trip() {
782        with_home_override(|_root| {
783            clear_channel_env();
784            let p1 = DataPaths::resolve(
785                "0.33.639",
786                &RuntimeMode::Dev {
787                    branch: "main".into(),
788                },
789            )
790            .unwrap();
791            // Apply each env var, then read back.
792            for (k, v) in p1.to_env_vars() {
793                std::env::set_var(k, v);
794            }
795            let p2 = DataPaths::from_env().expect("round-trip");
796            assert_eq!(p1.instance_dir, p2.instance_dir);
797            assert_eq!(p1.data_dir, p2.data_dir);
798            assert_eq!(p1.shared_dir, p2.shared_dir);
799            assert_eq!(p1.mode, p2.mode);
800            assert_eq!(p1.channel, p2.channel);
801            // Cleanup
802            for (k, _) in p1.to_env_vars() {
803                std::env::remove_var(k);
804            }
805        });
806    }
807
808    #[test]
809    fn resolve_rejects_dev_branch_traversal() {
810        // Even if a caller manages to construct a Dev variant with an
811        // unsafe branch (bypassing parse_mode_string sanitization),
812        // resolve() must catch it.
813        with_home_override(|_root| {
814            clear_channel_env();
815            let mode = RuntimeMode::Dev {
816                branch: "..".into(),
817            };
818            assert!(DataPaths::resolve("0.33.639", &mode).is_err());
819            let mode = RuntimeMode::Dev {
820                branch: "foo/bar".into(),
821            };
822            assert!(DataPaths::resolve("0.33.639", &mode).is_err());
823        });
824    }
825
826    #[test]
827    fn resolve_rejects_traversal_version() {
828        with_home_override(|_root| {
829            clear_channel_env();
830            assert!(DataPaths::resolve("..", &RuntimeMode::Installed).is_err());
831            assert!(DataPaths::resolve(
832                "0.33.639/etc",
833                &RuntimeMode::Installed
834            )
835            .is_err());
836            // Drive-relative on Windows: `PathBuf::join("versions")
837            // .join("C:temp")` would resolve outside the intended
838            // ~/.agentmux/versions/ subtree because `C:temp` is a
839            // drive-relative path, not a literal filename.
840            assert!(DataPaths::resolve("C:temp", &RuntimeMode::Installed).is_err());
841            // Other Windows-reserved chars also rejected.
842            for v in ["a*b", "a?b", "a|b", "a<b", "a>b", "a\"b"] {
843                assert!(
844                    DataPaths::resolve(v, &RuntimeMode::Installed).is_err(),
845                    "should reject version with reserved char: {:?}",
846                    v
847                );
848            }
849        });
850    }
851
852    #[test]
853    fn from_env_fails_fast_on_missing_vars() {
854        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
855        // Clear all expected vars.
856        for k in [
857            "AGENTMUX_INSTANCE_DIR",
858            "AGENTMUX_DATA_DIR",
859            "AGENTMUX_CONFIG_DIR",
860            "AGENTMUX_LOG_DIR",
861            "AGENTMUX_CEF_CACHE_DIR",
862            "AGENTMUX_AGENTS_DIR",
863            "AGENTMUX_INSTANCE_RUNTIME_DIR",
864            "AGENTMUX_SHARED_DIR",
865            "AGENTMUX_RUNTIME_MODE",
866            "AGENTMUX_CHANNEL",
867        ] {
868            std::env::remove_var(k);
869        }
870        assert!(DataPaths::from_env().is_none());
871    }
872
873    #[test]
874    fn from_env_fails_fast_when_channel_missing() {
875        // Symmetric to from_env_fails_fast_on_missing_vars but
876        // isolates the channel-specific case: a launcher built with
877        // the new code will always export AGENTMUX_CHANNEL; a missing
878        // value indicates a launcher/srv version skew that must fail
879        // loudly rather than silently fall back to a wrong-channel
880        // write. Pre-set all other vars to confirm channel is what's
881        // gating.
882        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
883        for (k, v) in [
884            ("AGENTMUX_INSTANCE_DIR", "/tmp/x"),
885            ("AGENTMUX_DATA_DIR", "/tmp/x/data"),
886            ("AGENTMUX_CONFIG_DIR", "/tmp/x/config"),
887            ("AGENTMUX_LOG_DIR", "/tmp/x/logs"),
888            ("AGENTMUX_CEF_CACHE_DIR", "/tmp/x/cef"),
889            ("AGENTMUX_AGENTS_DIR", "/tmp/x/agents"),
890            ("AGENTMUX_INSTANCE_RUNTIME_DIR", "/tmp/x/runtime"),
891            ("AGENTMUX_SHARED_DIR", "/tmp/x/shared"),
892            ("AGENTMUX_RUNTIME_MODE", "installed"),
893        ] {
894            std::env::set_var(k, v);
895        }
896        std::env::remove_var("AGENTMUX_CHANNEL");
897        assert!(
898            DataPaths::from_env().is_none(),
899            "from_env() must refuse when AGENTMUX_CHANNEL is missing"
900        );
901        // Cleanup
902        for k in [
903            "AGENTMUX_INSTANCE_DIR",
904            "AGENTMUX_DATA_DIR",
905            "AGENTMUX_CONFIG_DIR",
906            "AGENTMUX_LOG_DIR",
907            "AGENTMUX_CEF_CACHE_DIR",
908            "AGENTMUX_AGENTS_DIR",
909            "AGENTMUX_INSTANCE_RUNTIME_DIR",
910            "AGENTMUX_SHARED_DIR",
911            "AGENTMUX_RUNTIME_MODE",
912        ] {
913            std::env::remove_var(k);
914        }
915    }
916
917    #[test]
918    fn resolve_path_only_ignores_env_channel() {
919        // resolve_path_only is the dev-build self-detection variant —
920        // it deliberately ignores AGENTMUX_CHANNEL because a dev host
921        // launched from inside a parent agentmux instance would inherit
922        // the parent's channel env and cross-contaminate.
923        //
924        // This is the codex P1 regression test on PR #1027 — without
925        // the gate, dev hosts launched via `task dev` from inside an
926        // agent pane would redirect to the parent's channel/<channel>/
927        // dir instead of dev/<branch>/, tripping the channel's
928        // single-instance lock.
929        with_home_override(|root| {
930            std::env::set_var("AGENTMUX_CHANNEL", "stable");
931            struct ChannelGuard;
932            impl Drop for ChannelGuard {
933                fn drop(&mut self) {
934                    std::env::remove_var("AGENTMUX_CHANNEL");
935                }
936            }
937            let _g = ChannelGuard;
938
939            let dev = DataPaths::resolve_path_only(
940                "0.33.639",
941                &RuntimeMode::Dev { branch: "main".into() },
942            )
943            .unwrap();
944            // Dev mode default holds — channel name is dev-main, path
945            // stays under dev/main/. AGENTMUX_CHANNEL=stable does NOT
946            // redirect to channels/stable/.
947            assert_eq!(dev.channel, "dev-main");
948            assert_eq!(dev.instance_dir, root.join("dev").join("main"));
949
950            let inst = DataPaths::resolve_path_only(
951                "0.33.639",
952                &RuntimeMode::Installed,
953            )
954            .unwrap();
955            // Installed default holds — build-time channel
956            // (BUILD_CHANNEL_DEFAULT = "stable" in tests). Path lands
957            // at channels/stable/ regardless of the env override.
958            assert_eq!(inst.channel, "stable");
959            assert_eq!(inst.instance_dir, root.join("channels").join("stable"));
960
961            // Sanity: regular `resolve` DOES honor the env override in
962            // both modes — confirms the divergence is solely on the
963            // path_only variant.
964            let dev_env = DataPaths::resolve(
965                "0.33.639",
966                &RuntimeMode::Dev { branch: "main".into() },
967            )
968            .unwrap();
969            assert_eq!(dev_env.channel, "stable");
970            assert_eq!(dev_env.instance_dir, root.join("channels").join("stable"));
971        });
972    }
973
974    #[test]
975    fn sanitize_channel_name_accepts_normal_names() {
976        // The happy path — make sure stable / beta / dev-portable
977        // and friends all sanitize cleanly. Catches regressions in
978        // case the reserved list grows by mistake.
979        assert_eq!(sanitize_channel_name("stable"), Some("stable".into()));
980        assert_eq!(sanitize_channel_name("beta"), Some("beta".into()));
981        assert_eq!(
982            sanitize_channel_name("dev-portable"),
983            Some("dev-portable".into())
984        );
985        assert_eq!(
986            sanitize_channel_name("dev-main"),
987            Some("dev-main".into())
988        );
989        assert_eq!(
990            sanitize_channel_name("experiment_42"),
991            Some("experiment_42".into())
992        );
993    }
994}