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}