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=local-<branch>`; 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    /// `"local-main"`, `"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        // For Installed/Portable builds, version-scope the mutable
187        // runtime dirs so two concurrent release versions don't share
188        // SQLite DBs or Chromium caches. Dev builds are already
189        // branch-isolated via their path; no extra scoping needed.
190        //
191        // Layout after this change:
192        //   channels/<ch>/versions/<v>/data/      ← objects.db, sagas.db …
193        //   channels/<ch>/versions/<v>/logs/
194        //   channels/<ch>/versions/<v>/cef-cache/
195        //   channels/<ch>/versions/<v>/runtime/   ← ipc-port, lock
196        //   channels/<ch>/config/                 ← settings (channel-wide)
197        //   channels/<ch>/agents/                 ← agent defs (survive upgrades)
198        //
199        // See SPEC_VERSION_ISOLATION_2026_06_01.md §5 Phase 2.
200        let version_dir = match mode {
201            RuntimeMode::Installed | RuntimeMode::Portable => {
202                instance_dir.join("versions").join(version)
203            }
204            RuntimeMode::Dev { .. } => instance_dir.clone(),
205        };
206
207        let data_dir = version_dir.join("data");
208        let logs_dir = version_dir.join("logs");
209        let cef_cache_dir = version_dir.join("cef-cache");
210        let instance_runtime_dir = version_dir.join("runtime");
211        // config and agents stay channel-wide so settings and agent
212        // definitions persist across version upgrades.
213        let config_dir = instance_dir.join("config");
214        let agents_dir = instance_dir.join("agents");
215        let shared_dir = root.join("shared");
216
217        Ok(Self {
218            home_dir: root,
219            instance_dir,
220            channel,
221            data_dir,
222            config_dir,
223            logs_dir,
224            cef_cache_dir,
225            agents_dir,
226            instance_runtime_dir,
227            shared_dir,
228            mode: mode.clone(),
229        })
230    }
231
232    /// Create every directory that may be written to. Idempotent.
233    /// Safe to call on every launch.
234    pub fn ensure_dirs(&self) -> Result<(), String> {
235        for d in [
236            &self.instance_dir,
237            &self.data_dir,
238            &self.config_dir,
239            &self.logs_dir,
240            &self.cef_cache_dir,
241            &self.agents_dir,
242            &self.instance_runtime_dir,
243            &self.shared_dir,
244        ] {
245            std::fs::create_dir_all(d)
246                .map_err(|e| format!("failed to create {}: {}", d.display(), e))?;
247        }
248        // The data dir's `db/` subdir is the canonical srv DB home;
249        // mirrors legacy ensure_dirs() and lets srv unconditionally
250        // open `data_dir/db/objects.db`.
251        std::fs::create_dir_all(self.data_dir.join("db"))
252            .map_err(|e| format!("failed to create db dir: {}", e))?;
253        Ok(())
254    }
255
256    /// Env vars to pass to host + srv subprocesses. The launcher
257    /// computes `DataPaths` once and exports these; downstream
258    /// binaries read them via [`Self::from_env`] instead of
259    /// recomputing.
260    ///
261    /// Returns `OsString` (not `String`) so paths with non-UTF-8 bytes
262    /// — possible on Linux/macOS for users with exotic home dirs —
263    /// round-trip losslessly. `Command::env(k, v)` accepts any
264    /// `AsRef<OsStr>`, so the OsString flows through to children
265    /// unchanged. The mode value is the only `String`-typed entry
266    /// (it's a fixed ASCII vocabulary).
267    pub fn to_env_vars(&self) -> Vec<(&'static str, std::ffi::OsString)> {
268        use std::ffi::OsString;
269        let mut vars: Vec<(&'static str, OsString)> = vec![
270            ("AGENTMUX_INSTANCE_DIR", self.instance_dir.clone().into_os_string()),
271            ("AGENTMUX_DATA_DIR", self.data_dir.clone().into_os_string()),
272            ("AGENTMUX_CONFIG_DIR", self.config_dir.clone().into_os_string()),
273            ("AGENTMUX_LOG_DIR", self.logs_dir.clone().into_os_string()),
274            ("AGENTMUX_CEF_CACHE_DIR", self.cef_cache_dir.clone().into_os_string()),
275            ("AGENTMUX_AGENTS_DIR", self.agents_dir.clone().into_os_string()),
276            (
277                "AGENTMUX_INSTANCE_RUNTIME_DIR",
278                self.instance_runtime_dir.clone().into_os_string(),
279            ),
280            ("AGENTMUX_SHARED_DIR", self.shared_dir.clone().into_os_string()),
281            ("AGENTMUX_RUNTIME_MODE", OsString::from(self.mode.to_env_string())),
282            // Channel propagated so downstream binaries can log it +
283            // surface in diagnostics. NOT used to recompute paths
284            // (paths flow through the dir vars above).
285            ("AGENTMUX_CHANNEL", OsString::from(self.channel.clone())),
286        ];
287        // Dev mode also exports AGENTMUX_CLONE_ID so child processes
288        // (host, srv) can reconstruct the full `Dev { branch, clone_id }`
289        // variant via [`RuntimeMode::from_env_with_clone`]. The
290        // mode-string format (`dev:<branch>`) was kept backward-compatible
291        // and doesn't carry clone_id itself — see runtime_mode.rs.
292        if let RuntimeMode::Dev { clone_id: Some(id), .. } = &self.mode {
293            vars.push(("AGENTMUX_CLONE_ID", OsString::from(id.clone())));
294        }
295        vars
296    }
297
298    /// Reconstruct from env vars set by the launcher. Returns
299    /// `None` if any required var is missing — fail-fast vs.
300    /// silently falling back to legacy paths the way the old
301    /// sidecar.rs did.
302    ///
303    /// Uses `var_os` (not `var`) so non-UTF-8 path bytes survive.
304    pub fn from_env() -> Option<Self> {
305        let instance_dir = std::env::var_os("AGENTMUX_INSTANCE_DIR")?;
306        let data_dir = std::env::var_os("AGENTMUX_DATA_DIR")?;
307        let config_dir = std::env::var_os("AGENTMUX_CONFIG_DIR")?;
308        let logs_dir = std::env::var_os("AGENTMUX_LOG_DIR")?;
309        let cef_cache_dir = std::env::var_os("AGENTMUX_CEF_CACHE_DIR")?;
310        let agents_dir = std::env::var_os("AGENTMUX_AGENTS_DIR")?;
311        let instance_runtime_dir = std::env::var_os("AGENTMUX_INSTANCE_RUNTIME_DIR")?;
312        let shared_dir = std::env::var_os("AGENTMUX_SHARED_DIR")?;
313        // Pair AGENTMUX_RUNTIME_MODE with AGENTMUX_CLONE_ID so the Dev
314        // variant carries its clone discriminator. Legacy single-var
315        // form (no AGENTMUX_CLONE_ID set) leaves clone_id as None,
316        // which falls back to the pre-PR two-level dev path layout.
317        let mode = RuntimeMode::from_env_with_clone()?;
318        // Channel is required from the launcher (same fail-fast
319        // discipline as every other dir var). Missing AGENTMUX_CHANNEL
320        // means the launcher didn't export it — that's a launcher /
321        // srv version skew, surface it loudly rather than silently
322        // defaulting and risking a wrong-channel write.
323        let channel = std::env::var("AGENTMUX_CHANNEL").ok()?;
324
325        // Re-resolve home_dir (the agentmux root) on the consumer
326        // side rather than transmitting it via env — it's a function
327        // of the AGENTMUX_HOME_OVERRIDE env (test only) and the OS
328        // home dir, which are stable across the launcher → host hop.
329        let home_dir = resolve_root().ok()?;
330
331        Some(Self {
332            home_dir,
333            instance_dir: PathBuf::from(instance_dir),
334            channel,
335            data_dir: PathBuf::from(data_dir),
336            config_dir: PathBuf::from(config_dir),
337            logs_dir: PathBuf::from(logs_dir),
338            cef_cache_dir: PathBuf::from(cef_cache_dir),
339            agents_dir: PathBuf::from(agents_dir),
340            instance_runtime_dir: PathBuf::from(instance_runtime_dir),
341            shared_dir: PathBuf::from(shared_dir),
342            mode,
343        })
344    }
345
346    /// `~/.agentmux/shared/identities/` — root for per-bundle OAuth
347    /// credential directories. Lives under `shared_dir` so it's
348    /// account-wide and version-independent: upgrading agentmux does
349    /// not move a user's bundle credentials. Per
350    /// `SPEC_OAUTH_IDENTITY_BUNDLES_2026_05_22.md` §4.1.
351    pub fn identities_dir(&self) -> PathBuf {
352        self.shared_dir.join("identities")
353    }
354
355    /// `~/.agentmux/shared/identities/<bundle_id>/` — a specific
356    /// bundle's credential root, when `bundle_id` is a safe path
357    /// segment. Returns `None` for empty / `.` / `..` / any segment
358    /// containing `/`, `\`, drive-letter colons, or Windows-reserved
359    /// characters (same rules as the version/branch sanitizer in
360    /// `resolve`).
361    ///
362    /// Defensive return type: `bundle_id` flows from `auth.start`
363    /// request bodies (PR C) into `create_dir_all`, so an
364    /// unvalidated `PathBuf::join` would let a crafted id escape the
365    /// identities root and write outside the bundle area. codex P1
366    /// follow-up on #981.
367    ///
368    /// Per-provider subdirectories (e.g. `claude/`, `codex/`) hang
369    /// off this when the bundle gains an OAuth binding (PR C). The
370    /// directory is created lazily by the bundle / OAuth flow that
371    /// needs it — `ensure_dirs()` does not pre-create it.
372    pub fn identity_dir(&self, bundle_id: &str) -> Option<PathBuf> {
373        sanitize_path_segment(bundle_id).map(|safe| self.identities_dir().join(safe))
374    }
375
376    /// `~/.agentmux/shared/providers/<auth_dir_name>/` — the DEFAULT provider
377    /// config + auth dir. Lives under `shared_dir`, so it is account-wide,
378    /// version-independent, AND channel-independent: every instance / channel /
379    /// version logs in ONCE and shares it. This is the structural fix for the
380    /// per-channel "validate-spin" regression — there is no empty-per-instance
381    /// auth dir to spin on. The per-identity override (`identity_dir`) still
382    /// takes precedence for explicit multi-account bundles. `auth_dir_name`
383    /// comes from the static provider registry (e.g. "claude"), never user
384    /// input. Retro:
385    /// `docs/retro/retro-provider-auth-isolation-regression-2026-06-05.md`.
386    pub fn provider_auth_dir(&self, auth_dir_name: &str) -> PathBuf {
387        self.shared_dir.join("providers").join(auth_dir_name)
388    }
389}
390
391/// `~/.agentmux/` root, or the test override via
392/// `AGENTMUX_HOME_OVERRIDE`. Falls back to error if no home dir
393/// can be resolved (rare — should only happen in stripped CI envs).
394fn resolve_root() -> Result<PathBuf, String> {
395    if let Ok(s) = std::env::var("AGENTMUX_HOME_OVERRIDE") {
396        if !s.is_empty() {
397            return Ok(PathBuf::from(s));
398        }
399    }
400    let home = dirs::home_dir().ok_or_else(|| "dirs::home_dir() returned None".to_string())?;
401    Ok(home.join(".agentmux"))
402}
403
404/// Sanitize a string for use as a single filesystem path segment.
405/// Rejects empty, `.`, `..`, segments containing path separators, and
406/// any character that has filesystem-special meaning on Windows (which
407/// is the most restrictive of the platforms we target). Used as belt-
408/// and-braces protection in `DataPaths::resolve` to prevent traversal
409/// even when callers pass a directly-constructed `RuntimeMode::Dev` or
410/// odd version string.
411///
412/// Why `:` is rejected: on Windows `C:temp` is a drive-relative path,
413/// not a literal filename, so `PathBuf::join("versions").join("C:temp")`
414/// would resolve OUTSIDE the intended `~/.agentmux/versions/` subtree.
415fn sanitize_path_segment(s: &str) -> Option<String> {
416    // Reject whitespace padding rather than silently normalizing it
417    // away — otherwise distinct caller-supplied ids like "foo" and
418    // " foo " would alias to the same directory. bundle_id is a real
419    // caller-supplied identifier (passed through RPC payloads), so
420    // this matters for credential isolation. codex P2 follow-up on
421    // #981. Internally-generated version strings + branch names
422    // shouldn't carry padding anyway, so this is no-op for them.
423    if s != s.trim() {
424        return None;
425    }
426    if s.is_empty() || s == "." || s == ".." {
427        return None;
428    }
429    // Filesystem separators + Windows-reserved characters + NUL.
430    if s
431        .chars()
432        .any(|c| matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0'))
433    {
434        return None;
435    }
436    Some(s.to_string())
437}
438
439/// Sanitize a string for use as a channel name. Same path-segment
440/// safety rules as [`sanitize_path_segment`] plus:
441/// - Length capped at 64 chars (channel names show up in logs + the
442///   launcher splash + may eventually be displayed in a picker, so
443///   the cap is for UI sanity, not security).
444/// - Rejects names in [`RESERVED_CHANNEL_NAMES`] that would collide
445///   with sibling dirs at `~/.agentmux/` or reserved subdir names
446///   inside a channel.
447/// - The synonym `"default"` maps to `"stable"` (per
448///   `SPEC_DATA_CHANNELS_2026_05_24.md` §7.5).
449fn sanitize_channel_name(s: &str) -> Option<String> {
450    let base = sanitize_path_segment(s)?;
451    if base.len() > 64 {
452        return None;
453    }
454    if RESERVED_CHANNEL_NAMES.contains(&base.as_str()) {
455        return None;
456    }
457    if base == "default" {
458        return Some("stable".to_string());
459    }
460    Some(base)
461}
462
463/// Resolve the channel name and on-disk channel dir for a given mode.
464/// Pure function over (env, mode, root). When `honor_env_channel` is
465/// `true`, `AGENTMUX_CHANNEL` overrides the mode default; when `false`,
466/// the env is ignored and resolution depends only on `mode` +
467/// build-time defaults. The `false` path is used by dev-build self-
468/// detection (see [`DataPaths::resolve_path_only`]).
469///
470/// Resolution order (mirrors `SPEC_DATA_CHANNELS_2026_05_24.md` §2.2):
471///   1. (only if `honor_env_channel`) `AGENTMUX_CHANNEL` env override —
472///      any mode → path is `<root>/channels/<channel>/`. Lets the
473///      operator point any binary at any channel for parallel-channel
474///      testing.
475///   2. No override (or env-channel disallowed), mode = Dev { branch }
476///      → channel name is `dev-<branch>`, path stays at
477///      `<root>/dev/<branch>/` (unchanged from Phase 1).
478///   3. Same conditions, mode = Installed | Portable → channel name is
479///      [`BUILD_CHANNEL_DEFAULT`] (set at build time by the packaging
480///      script), path is `<root>/channels/<channel>/`.
481fn resolve_channel_and_dir(
482    mode: &RuntimeMode,
483    root: &Path,
484    honor_env_channel: bool,
485) -> Result<(String, PathBuf), String> {
486    // (1) Explicit env override — only when caller opted in.
487    if honor_env_channel {
488        if let Ok(raw) = std::env::var("AGENTMUX_CHANNEL") {
489            if !raw.is_empty() {
490                let channel = sanitize_channel_name(&raw).ok_or_else(|| {
491                    format!("invalid AGENTMUX_CHANNEL value: {:?}", raw)
492                })?;
493                let dir = root.join("channels").join(&channel);
494                return Ok((channel, dir));
495            }
496        }
497    }
498
499    // (2) Dev mode default: dev-<branch>[-<clone_id>], path under
500    // dev/<branch>/[<clone_id>/]. The clone_id nests one level deeper
501    // so two clones of the same branch don't collide on data dir,
502    // lockfile, or named-pipe IPC. When clone_id is None (legacy
503    // env-string round-trip or direct test construction) the layout
504    // falls back to the original two-level form for back-compat.
505    // See SPEC_DATA_CHANNELS_2026_05_24.md §2.4 and
506    // docs/analysis/ANALYSIS_MULTI_CLONE_TASK_DEV_ISOLATION_2026-05-26.md.
507    if let RuntimeMode::Dev { branch, clone_id } = mode {
508        let safe_branch = sanitize_path_segment(branch).ok_or_else(|| {
509            format!("invalid dev branch for path: {:?}", branch)
510        })?;
511        let safe_clone = clone_id
512            .as_deref()
513            .and_then(sanitize_path_segment)
514            .filter(|s| !s.is_empty());
515        let (channel, dir) = match safe_clone {
516            Some(c) => (
517                format!("dev-{}-{}", safe_branch, c),
518                root.join("dev").join(safe_branch).join(c),
519            ),
520            None => (
521                format!("dev-{}", safe_branch),
522                root.join("dev").join(safe_branch),
523            ),
524        };
525        return Ok((channel, dir));
526    }
527
528    // (3) Installed / Portable default: build-time channel.
529    let channel = sanitize_channel_name(BUILD_CHANNEL_DEFAULT).ok_or_else(|| {
530        format!(
531            "compile-time AGENTMUX_BUILD_CHANNEL_DEFAULT is invalid: {:?}",
532            BUILD_CHANNEL_DEFAULT
533        )
534    })?;
535    let dir = root.join("channels").join(&channel);
536    Ok((channel, dir))
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use crate::TEST_ENV_LOCK;
543    use tempfile::TempDir;
544
545    /// RAII guard that restores process state on drop, even if the
546    /// test panics. Without Drop-based cleanup, a panic inside `f`
547    /// would leave AGENTMUX_HOME_OVERRIDE set with a stale tempdir
548    /// path AND poison the mutex; subsequent tests recover from poison
549    /// but inherit the wrong env value.
550    struct HomeOverrideGuard {
551        _tmp: TempDir,
552        _lock: std::sync::MutexGuard<'static, ()>,
553    }
554
555    impl Drop for HomeOverrideGuard {
556        fn drop(&mut self) {
557            std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
558        }
559    }
560
561    fn with_home_override<F: FnOnce(&Path)>(f: F) {
562        let lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
563        let tmp = TempDir::new().expect("tempdir");
564        let path = tmp.path().to_path_buf();
565        std::env::set_var("AGENTMUX_HOME_OVERRIDE", &path);
566        let _guard = HomeOverrideGuard { _tmp: tmp, _lock: lock };
567        f(&path);
568        // _guard drops here, removing the env var even if f panicked
569        // (the panic still propagates after Drop runs).
570    }
571
572    /// Helper: clear AGENTMUX_CHANNEL inside an existing
573    /// with_home_override block to test pure mode-default resolution.
574    /// Channel resolution reads the live env var, so individual tests
575    /// must clear it to avoid leakage from sibling tests running
576    /// concurrently inside the same process (TEST_ENV_LOCK serializes
577    /// HOME_OVERRIDE but the channel var is a separate axis).
578    fn clear_channel_env() {
579        std::env::remove_var("AGENTMUX_CHANNEL");
580    }
581
582    #[test]
583    fn installed_paths_under_default_channel() {
584        with_home_override(|root| {
585            clear_channel_env();
586            let ver = "0.41.0";
587            let p = DataPaths::resolve(ver, &RuntimeMode::Installed).unwrap();
588            // Channel-level root (instance_dir).
589            assert_eq!(p.channel, "stable");
590            let ch = root.join("channels").join("stable");
591            assert_eq!(p.instance_dir, ch);
592            // Version-scoped dirs live under versions/<ver>/.
593            let vd = ch.join("versions").join(ver);
594            assert_eq!(p.data_dir,             vd.join("data"));
595            assert_eq!(p.logs_dir,             vd.join("logs"));
596            assert_eq!(p.cef_cache_dir,        vd.join("cef-cache"));
597            assert_eq!(p.instance_runtime_dir, vd.join("runtime"));
598            // Channel-wide dirs stay at instance_dir level.
599            assert_eq!(p.config_dir,  ch.join("config"));
600            assert_eq!(p.agents_dir,  ch.join("agents"));
601            assert_eq!(p.shared_dir,  root.join("shared"));
602        });
603    }
604
605    #[test]
606    fn two_installed_versions_have_distinct_data_dirs() {
607        with_home_override(|root| {
608            clear_channel_env();
609            let p1 = DataPaths::resolve("0.40.2", &RuntimeMode::Installed).unwrap();
610            let p2 = DataPaths::resolve("0.41.0", &RuntimeMode::Installed).unwrap();
611            // Same channel root — agents and config are shared.
612            assert_eq!(p1.instance_dir, p2.instance_dir);
613            assert_eq!(p1.agents_dir,   p2.agents_dir);
614            assert_eq!(p1.config_dir,   p2.config_dir);
615            // Different versioned dirs — concurrent writes are safe.
616            assert_ne!(p1.data_dir,             p2.data_dir);
617            assert_ne!(p1.cef_cache_dir,        p2.cef_cache_dir);
618            assert_ne!(p1.instance_runtime_dir, p2.instance_runtime_dir);
619            // Paths contain the version string.
620            assert!(p1.data_dir.to_string_lossy().contains("0.40.2"));
621            assert!(p2.data_dir.to_string_lossy().contains("0.41.0"));
622            let _ = root; // suppress unused warning
623        });
624    }
625
626    #[test]
627    fn home_dir_resolves_to_root() {
628        // The agentmux root (~/.agentmux/ or AGENTMUX_HOME_OVERRIDE)
629        // is exposed via DataPaths.home_dir for legacy account-wide
630        // state like the launcher's config.toml. Resolve in both
631        // installed and dev modes; both should point at the same root.
632        with_home_override(|root| {
633            clear_channel_env();
634            let inst = DataPaths::resolve("0.33.641", &RuntimeMode::Installed).unwrap();
635            assert_eq!(inst.home_dir, root);
636            let dev = DataPaths::resolve(
637                "0.33.641",
638                &RuntimeMode::Dev {
639                    branch: "main".into(),
640                    clone_id: None,
641                },
642            )
643            .unwrap();
644            assert_eq!(dev.home_dir, root);
645        });
646    }
647
648    #[test]
649    fn portable_paths_match_installed() {
650        with_home_override(|root| {
651            clear_channel_env();
652            let inst = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
653            let port = DataPaths::resolve("0.33.639", &RuntimeMode::Portable).unwrap();
654            // Portable + Installed share a default channel (no env
655            // override → both fall through to BUILD_CHANNEL_DEFAULT),
656            // so their data dirs are the same. Multi-instance
657            // isolation is now channel-keyed: if you want two
658            // independent portables, override AGENTMUX_CHANNEL on one.
659            assert_eq!(inst.channel, port.channel);
660            assert_eq!(inst.instance_dir, port.instance_dir);
661            assert_eq!(inst.data_dir, port.data_dir);
662            // shared/ is mode-independent.
663            assert_eq!(inst.shared_dir, root.join("shared"));
664            assert_eq!(port.shared_dir, root.join("shared"));
665        });
666    }
667
668    #[test]
669    fn dev_paths_under_branch_and_clone_id() {
670        // Two clones of the same branch must resolve to distinct
671        // instance dirs when clone_id is supplied. Same branch +
672        // different clone_id → different paths → distinct lockfile
673        // and pipe namespaces downstream.
674        with_home_override(|root| {
675            clear_channel_env();
676            let a = DataPaths::resolve(
677                "0.39.0",
678                &RuntimeMode::Dev {
679                    branch: "main".into(),
680                    clone_id: Some("aaaaaaaa00000000".into()),
681                },
682            )
683            .unwrap();
684            let b = DataPaths::resolve(
685                "0.39.0",
686                &RuntimeMode::Dev {
687                    branch: "main".into(),
688                    clone_id: Some("bbbbbbbb00000000".into()),
689                },
690            )
691            .unwrap();
692            assert_eq!(
693                a.instance_dir,
694                root.join("dev").join("main").join("aaaaaaaa00000000")
695            );
696            assert_eq!(
697                b.instance_dir,
698                root.join("dev").join("main").join("bbbbbbbb00000000")
699            );
700            assert_ne!(a.instance_dir, b.instance_dir);
701            assert_eq!(a.channel, "dev-main-aaaaaaaa00000000");
702            assert_eq!(b.channel, "dev-main-bbbbbbbb00000000");
703        });
704    }
705
706    #[test]
707    fn dev_paths_legacy_two_level_when_clone_id_none() {
708        // Backward compat: a Dev variant without clone_id (e.g.
709        // constructed by an older launcher binary, or by the
710        // env-string parser) MUST land at the pre-PR two-level dev
711        // path so existing in-flight dev sessions don't lose their
712        // state on first launch after the upgrade.
713        with_home_override(|root| {
714            clear_channel_env();
715            let p = DataPaths::resolve(
716                "0.39.0",
717                &RuntimeMode::Dev {
718                    branch: "main".into(),
719                    clone_id: None,
720                },
721            )
722            .unwrap();
723            assert_eq!(p.instance_dir, root.join("dev").join("main"));
724            assert_eq!(p.channel, "dev-main");
725        });
726    }
727
728    #[test]
729    fn dev_paths_under_dev_branch() {
730        with_home_override(|root| {
731            clear_channel_env();
732            let mode = RuntimeMode::Dev {
733                branch: "main".into(),
734                clone_id: None,
735            };
736            let p = DataPaths::resolve("0.33.639", &mode).unwrap();
737            // Dev mode default: on-disk path stays under dev/<branch>/
738            // (unchanged from Phase 1), channel name is "dev-<branch>"
739            // for diagnostics. The two diverge intentionally — see
740            // resolve_channel_and_dir doc.
741            assert_eq!(p.channel, "dev-main");
742            assert_eq!(p.instance_dir, root.join("dev").join("main"));
743            assert_eq!(p.data_dir, root.join("dev").join("main").join("data"));
744            assert_eq!(p.shared_dir, root.join("shared"));
745        });
746    }
747
748    #[test]
749    fn env_override_redirects_any_mode_under_channels() {
750        // AGENTMUX_CHANNEL is absolute precedence. Even Dev mode,
751        // which would otherwise land at dev/<branch>/, lands under
752        // channels/<override>/ when the env is set. This is the
753        // "test a hot-fix build against the live stable data" path
754        // from SPEC_DATA_CHANNELS_2026_05_24.md §2.2.
755        with_home_override(|root| {
756            std::env::set_var("AGENTMUX_CHANNEL", "experiment");
757            // Cleanup via Drop so a test panic doesn't leak it.
758            struct ChannelGuard;
759            impl Drop for ChannelGuard {
760                fn drop(&mut self) {
761                    std::env::remove_var("AGENTMUX_CHANNEL");
762                }
763            }
764            let _g = ChannelGuard;
765
766            let inst = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
767            assert_eq!(inst.channel, "experiment");
768            assert_eq!(inst.instance_dir, root.join("channels").join("experiment"));
769
770            let port = DataPaths::resolve("0.33.639", &RuntimeMode::Portable).unwrap();
771            assert_eq!(port.channel, "experiment");
772            assert_eq!(port.instance_dir, root.join("channels").join("experiment"));
773
774            let dev = DataPaths::resolve(
775                "0.33.639",
776                &RuntimeMode::Dev { branch: "main".into(), clone_id: None },
777            )
778            .unwrap();
779            // Override beats the dev/<branch>/ default — channel name
780            // matches the override, path lands under channels/, not dev/.
781            assert_eq!(dev.channel, "experiment");
782            assert_eq!(dev.instance_dir, root.join("channels").join("experiment"));
783        });
784    }
785
786    #[test]
787    fn env_override_rejects_unsafe_or_reserved_names() {
788        with_home_override(|_root| {
789            // Reserved (would collide with sibling dirs / inner dirs).
790            for bad in ["shared", "snapshots", "dev", "versions", "channels", "runtime"] {
791                std::env::set_var("AGENTMUX_CHANNEL", bad);
792                let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
793                std::env::remove_var("AGENTMUX_CHANNEL");
794                assert!(
795                    r.is_err(),
796                    "AGENTMUX_CHANNEL={:?} should be rejected as reserved",
797                    bad
798                );
799            }
800
801            // Path-unsafe (traversal, separators, Windows-reserved).
802            // NUL not tested here — Windows' WinAPI rejects NUL in
803            // env-var values at the syscall level, so `set_var` would
804            // panic before our sanitizer runs. NUL rejection is
805            // covered by the direct-call sanitize_path_segment path
806            // in identity_dir_rejects_unsafe_segments.
807            for bad in ["..", ".", "a/b", "a\\b", "C:foo", "a*b"] {
808                std::env::set_var("AGENTMUX_CHANNEL", bad);
809                let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
810                std::env::remove_var("AGENTMUX_CHANNEL");
811                assert!(
812                    r.is_err(),
813                    "AGENTMUX_CHANNEL={:?} should be rejected as path-unsafe",
814                    bad
815                );
816            }
817
818            // Empty string treated as "not set" — falls through to
819            // mode-based default. Documents the behavior so a
820            // `.env`-set empty value doesn't surprise.
821            std::env::set_var("AGENTMUX_CHANNEL", "");
822            let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
823            std::env::remove_var("AGENTMUX_CHANNEL");
824            assert!(r.is_ok(), "empty AGENTMUX_CHANNEL should fall through to default");
825            assert_eq!(r.unwrap().channel, "stable");
826        });
827    }
828
829    #[test]
830    fn env_override_default_is_synonym_for_stable() {
831        with_home_override(|root| {
832            std::env::set_var("AGENTMUX_CHANNEL", "default");
833            let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
834            std::env::remove_var("AGENTMUX_CHANNEL");
835            let r = r.unwrap();
836            // "default" maps to "stable" per spec §7.5; on-disk path
837            // is channels/stable/, not channels/default/.
838            assert_eq!(r.channel, "stable");
839            assert_eq!(r.instance_dir, root.join("channels").join("stable"));
840        });
841    }
842
843    #[test]
844    fn channel_name_length_capped_at_64() {
845        with_home_override(|_root| {
846            // 64 chars OK, 65 rejected. The cap is for UI sanity, not
847            // security — channel names show up in logs and the
848            // launcher splash.
849            let ok = "a".repeat(64);
850            std::env::set_var("AGENTMUX_CHANNEL", &ok);
851            let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
852            std::env::remove_var("AGENTMUX_CHANNEL");
853            assert!(r.is_ok(), "64-char channel should be accepted");
854
855            let too_long = "a".repeat(65);
856            std::env::set_var("AGENTMUX_CHANNEL", &too_long);
857            let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
858            std::env::remove_var("AGENTMUX_CHANNEL");
859            assert!(r.is_err(), "65-char channel should be rejected");
860        });
861    }
862
863    #[test]
864    fn dev_branch_traversal_via_runtime_mode_still_rejected() {
865        // Dev mode resolution sanitizes the branch via the same
866        // sanitize_path_segment as before — channel rename doesn't
867        // weaken the traversal-safety guarantees. Reproduces the
868        // pre-channel test for parity.
869        with_home_override(|_root| {
870            clear_channel_env();
871            let r = DataPaths::resolve(
872                "0.33.639",
873                &RuntimeMode::Dev { branch: "..".into(), clone_id: None },
874            );
875            assert!(r.is_err());
876            let r = DataPaths::resolve(
877                "0.33.639",
878                &RuntimeMode::Dev { branch: "foo/bar".into(), clone_id: None },
879            );
880            assert!(r.is_err());
881        });
882    }
883
884    #[test]
885    fn identity_dir_rejects_unsafe_segments() {
886        // bundle_id flows from auth.start request bodies into
887        // create_dir_all. Without sanitization a crafted id would
888        // escape the identities root. The function must return None
889        // for traversal attempts, separator-bearing segments, and
890        // Windows-reserved characters. codex P1 follow-up on #981.
891        with_home_override(|_root| {
892            let p = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
893
894            // Happy path — a normal UUID-shaped id resolves.
895            assert!(p.identity_dir("abc-123-uuid").is_some());
896
897            // Path traversal.
898            assert_eq!(p.identity_dir(".."), None);
899            assert_eq!(p.identity_dir("."), None);
900            assert_eq!(p.identity_dir("../../../etc"), None);
901            assert_eq!(p.identity_dir("a/b"), None);
902            assert_eq!(p.identity_dir("a\\b"), None);
903
904            // Empty / whitespace-only.
905            assert_eq!(p.identity_dir(""), None);
906            assert_eq!(p.identity_dir("   "), None);
907
908            // Windows-reserved characters.
909            assert_eq!(p.identity_dir("C:foo"), None);
910            assert_eq!(p.identity_dir("foo*bar"), None);
911            assert_eq!(p.identity_dir("foo?bar"), None);
912            assert_eq!(p.identity_dir("with\0nul"), None);
913        });
914    }
915
916    #[test]
917    fn provider_auth_dir_is_shared_and_channel_independent() {
918        // The DEFAULT provider auth lives under shared_dir, so it resolves to the
919        // SAME path regardless of channel / version / mode — the structural fix
920        // for the per-channel "validate-spin" regression. It must NOT live under
921        // the per-channel config dir (which is where the regression put it).
922        // Retro: docs/retro/retro-provider-auth-isolation-regression-2026-06-05.md
923        with_home_override(|_root| {
924            clear_channel_env();
925            let installed = DataPaths::resolve("0.42.0", &RuntimeMode::Installed).unwrap();
926            let dev = DataPaths::resolve(
927                "0.42.0",
928                &RuntimeMode::Dev { branch: "some-branch".into(), clone_id: None },
929            )
930            .unwrap();
931
932            let a = installed.provider_auth_dir("claude");
933            assert_eq!(
934                a,
935                dev.provider_auth_dir("claude"),
936                "provider auth dir must not vary by channel / mode (instance-independent)"
937            );
938            assert!(
939                a.ends_with("shared/providers/claude"),
940                "provider auth dir must live under shared/providers/: {a:?}"
941            );
942            assert!(
943                !a.starts_with(&installed.config_dir),
944                "provider auth dir must NOT be under the per-channel config dir"
945            );
946        });
947    }
948
949    #[test]
950    fn ensure_dirs_creates_everything() {
951        with_home_override(|_root| {
952            clear_channel_env();
953            let p = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
954            p.ensure_dirs().unwrap();
955            assert!(p.instance_dir.is_dir());
956            assert!(p.data_dir.is_dir());
957            assert!(p.data_dir.join("db").is_dir());
958            assert!(p.config_dir.is_dir());
959            assert!(p.logs_dir.is_dir());
960            assert!(p.cef_cache_dir.is_dir());
961            assert!(p.agents_dir.is_dir());
962            assert!(p.instance_runtime_dir.is_dir());
963            assert!(p.shared_dir.is_dir());
964        });
965    }
966
967    #[test]
968    fn env_vars_round_trip() {
969        with_home_override(|_root| {
970            clear_channel_env();
971            let p1 = DataPaths::resolve(
972                "0.33.639",
973                &RuntimeMode::Dev {
974                    branch: "main".into(),
975                    clone_id: None,
976                },
977            )
978            .unwrap();
979            // Apply each env var, then read back.
980            for (k, v) in p1.to_env_vars() {
981                std::env::set_var(k, v);
982            }
983            let p2 = DataPaths::from_env().expect("round-trip");
984            assert_eq!(p1.instance_dir, p2.instance_dir);
985            assert_eq!(p1.data_dir, p2.data_dir);
986            assert_eq!(p1.shared_dir, p2.shared_dir);
987            assert_eq!(p1.mode, p2.mode);
988            assert_eq!(p1.channel, p2.channel);
989            // Cleanup
990            for (k, _) in p1.to_env_vars() {
991                std::env::remove_var(k);
992            }
993        });
994    }
995
996    #[test]
997    fn resolve_rejects_dev_branch_traversal() {
998        // Even if a caller manages to construct a Dev variant with an
999        // unsafe branch (bypassing parse_mode_string sanitization),
1000        // resolve() must catch it.
1001        with_home_override(|_root| {
1002            clear_channel_env();
1003            let mode = RuntimeMode::Dev {
1004                branch: "..".into(),
1005                clone_id: None,
1006            };
1007            assert!(DataPaths::resolve("0.33.639", &mode).is_err());
1008            let mode = RuntimeMode::Dev {
1009                branch: "foo/bar".into(),
1010                clone_id: None,
1011            };
1012            assert!(DataPaths::resolve("0.33.639", &mode).is_err());
1013        });
1014    }
1015
1016    #[test]
1017    fn resolve_rejects_traversal_version() {
1018        with_home_override(|_root| {
1019            clear_channel_env();
1020            assert!(DataPaths::resolve("..", &RuntimeMode::Installed).is_err());
1021            assert!(DataPaths::resolve(
1022                "0.33.639/etc",
1023                &RuntimeMode::Installed
1024            )
1025            .is_err());
1026            // Drive-relative on Windows: `PathBuf::join("versions")
1027            // .join("C:temp")` would resolve outside the intended
1028            // ~/.agentmux/versions/ subtree because `C:temp` is a
1029            // drive-relative path, not a literal filename.
1030            assert!(DataPaths::resolve("C:temp", &RuntimeMode::Installed).is_err());
1031            // Other Windows-reserved chars also rejected.
1032            for v in ["a*b", "a?b", "a|b", "a<b", "a>b", "a\"b"] {
1033                assert!(
1034                    DataPaths::resolve(v, &RuntimeMode::Installed).is_err(),
1035                    "should reject version with reserved char: {:?}",
1036                    v
1037                );
1038            }
1039        });
1040    }
1041
1042    #[test]
1043    fn from_env_fails_fast_on_missing_vars() {
1044        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1045        // Clear all expected vars.
1046        for k in [
1047            "AGENTMUX_INSTANCE_DIR",
1048            "AGENTMUX_DATA_DIR",
1049            "AGENTMUX_CONFIG_DIR",
1050            "AGENTMUX_LOG_DIR",
1051            "AGENTMUX_CEF_CACHE_DIR",
1052            "AGENTMUX_AGENTS_DIR",
1053            "AGENTMUX_INSTANCE_RUNTIME_DIR",
1054            "AGENTMUX_SHARED_DIR",
1055            "AGENTMUX_RUNTIME_MODE",
1056            "AGENTMUX_CHANNEL",
1057        ] {
1058            std::env::remove_var(k);
1059        }
1060        assert!(DataPaths::from_env().is_none());
1061    }
1062
1063    #[test]
1064    fn from_env_fails_fast_when_channel_missing() {
1065        // Symmetric to from_env_fails_fast_on_missing_vars but
1066        // isolates the channel-specific case: a launcher built with
1067        // the new code will always export AGENTMUX_CHANNEL; a missing
1068        // value indicates a launcher/srv version skew that must fail
1069        // loudly rather than silently fall back to a wrong-channel
1070        // write. Pre-set all other vars to confirm channel is what's
1071        // gating.
1072        let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1073        for (k, v) in [
1074            ("AGENTMUX_INSTANCE_DIR", "/tmp/x"),
1075            ("AGENTMUX_DATA_DIR", "/tmp/x/data"),
1076            ("AGENTMUX_CONFIG_DIR", "/tmp/x/config"),
1077            ("AGENTMUX_LOG_DIR", "/tmp/x/logs"),
1078            ("AGENTMUX_CEF_CACHE_DIR", "/tmp/x/cef"),
1079            ("AGENTMUX_AGENTS_DIR", "/tmp/x/agents"),
1080            ("AGENTMUX_INSTANCE_RUNTIME_DIR", "/tmp/x/runtime"),
1081            ("AGENTMUX_SHARED_DIR", "/tmp/x/shared"),
1082            ("AGENTMUX_RUNTIME_MODE", "installed"),
1083        ] {
1084            std::env::set_var(k, v);
1085        }
1086        std::env::remove_var("AGENTMUX_CHANNEL");
1087        assert!(
1088            DataPaths::from_env().is_none(),
1089            "from_env() must refuse when AGENTMUX_CHANNEL is missing"
1090        );
1091        // Cleanup
1092        for k in [
1093            "AGENTMUX_INSTANCE_DIR",
1094            "AGENTMUX_DATA_DIR",
1095            "AGENTMUX_CONFIG_DIR",
1096            "AGENTMUX_LOG_DIR",
1097            "AGENTMUX_CEF_CACHE_DIR",
1098            "AGENTMUX_AGENTS_DIR",
1099            "AGENTMUX_INSTANCE_RUNTIME_DIR",
1100            "AGENTMUX_SHARED_DIR",
1101            "AGENTMUX_RUNTIME_MODE",
1102        ] {
1103            std::env::remove_var(k);
1104        }
1105    }
1106
1107    #[test]
1108    fn resolve_path_only_ignores_env_channel() {
1109        // resolve_path_only is the dev-build / nested-launch self-detection
1110        // variant — it deliberately ignores AGENTMUX_CHANNEL because a host (dev
1111        // OR a portable launched nested inside another AgentMux) inherits the
1112        // parent's channel env and would cross-contaminate.
1113        //
1114        // Codex P1 regression test on PR #1027 (dev), extended for the nested
1115        // portable case the launcher relies on (agentmux-launcher/src/data_dir.rs).
1116        with_home_override(|root| {
1117            // Use a NON-default channel value so "ignored" (→ baked default) is
1118            // distinguishable from "honored" (→ this value). BUILD_CHANNEL_DEFAULT
1119            // is "stable" in tests, so an override of "beta" that gets ignored
1120            // resolves to "stable" — a name the env never set. (A "stable" override
1121            // would be tautological against the default.)
1122            std::env::set_var("AGENTMUX_CHANNEL", "beta");
1123            struct ChannelGuard;
1124            impl Drop for ChannelGuard {
1125                fn drop(&mut self) {
1126                    std::env::remove_var("AGENTMUX_CHANNEL");
1127                }
1128            }
1129            let _g = ChannelGuard;
1130
1131            let dev = DataPaths::resolve_path_only(
1132                "0.33.639",
1133                &RuntimeMode::Dev { branch: "main".into(), clone_id: None },
1134            )
1135            .unwrap();
1136            // Dev path_only ignores "beta" → stays under dev/main/.
1137            assert_eq!(dev.channel, "dev-main");
1138            assert_eq!(dev.instance_dir, root.join("dev").join("main"));
1139
1140            let inst = DataPaths::resolve_path_only(
1141                "0.33.639",
1142                &RuntimeMode::Installed,
1143            )
1144            .unwrap();
1145            // Installed path_only ignores "beta" → baked default "stable".
1146            assert_eq!(inst.channel, "stable");
1147            assert_eq!(inst.instance_dir, root.join("channels").join("stable"));
1148
1149            // Portable path_only ALSO ignores the env channel — the behavior the
1150            // launcher relies on for a NESTED portable launch. A build launched
1151            // inside another AgentMux pane inherits AGENTMUX_CHANNEL=<parent> and
1152            // must resolve to its OWN baked channel, NOT the leaked one — else it
1153            // adopts the parent's data dir + cef-cache and CEF's user-data-dir
1154            // singleton forwards it into the parent. "beta" ignored → baked
1155            // "stable".
1156            let port = DataPaths::resolve_path_only(
1157                "0.33.639",
1158                &RuntimeMode::Portable,
1159            )
1160            .unwrap();
1161            assert_eq!(
1162                port.channel, "stable",
1163                "nested portable must IGNORE the leaked AGENTMUX_CHANNEL=beta"
1164            );
1165            assert_eq!(port.instance_dir, root.join("channels").join("stable"));
1166
1167            // Sanity / B6: regular `resolve` DOES honor the override in every mode
1168            // (Dev AND Portable) → "beta". This proves (a) the divergence is solely
1169            // on the path_only variant, (b) the assertions above are not tautological
1170            // against the default, and (c) an EXPLICIT standalone AGENTMUX_CHANNEL is
1171            // still honored for portables (parallel-channel testing, PR #1027).
1172            let dev_env = DataPaths::resolve(
1173                "0.33.639",
1174                &RuntimeMode::Dev { branch: "main".into(), clone_id: None },
1175            )
1176            .unwrap();
1177            assert_eq!(dev_env.channel, "beta");
1178            assert_eq!(dev_env.instance_dir, root.join("channels").join("beta"));
1179
1180            let port_env = DataPaths::resolve(
1181                "0.33.639",
1182                &RuntimeMode::Portable,
1183            )
1184            .unwrap();
1185            assert_eq!(
1186                port_env.channel, "beta",
1187                "standalone portable still HONORS an explicit AGENTMUX_CHANNEL"
1188            );
1189            assert_eq!(port_env.instance_dir, root.join("channels").join("beta"));
1190        });
1191    }
1192
1193    #[test]
1194    fn sanitize_channel_name_accepts_normal_names() {
1195        // The happy path — make sure stable / beta / local-main
1196        // and friends all sanitize cleanly. Catches regressions in
1197        // case the reserved list grows by mistake.
1198        assert_eq!(sanitize_channel_name("stable"), Some("stable".into()));
1199        assert_eq!(sanitize_channel_name("beta"), Some("beta".into()));
1200        assert_eq!(
1201            sanitize_channel_name("local-main"),
1202            Some("local-main".into())
1203        );
1204        assert_eq!(
1205            sanitize_channel_name("dev-main"),
1206            Some("dev-main".into())
1207        );
1208        assert_eq!(
1209            sanitize_channel_name("experiment_42"),
1210            Some("experiment_42".into())
1211        );
1212    }
1213}