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