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/archive/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 ///
235 /// Makes the AgentMux root owner-only first (issue #3682): it holds
236 /// per-agent jekt HMAC keys and LAN private keys (in the DBs), provider
237 /// login state (identity dirs) and logs, and on Unix every directory
238 /// below it was otherwise created with the process umask — typically
239 /// `0755`, readable by other local users wherever the home dir is
240 /// traversable (macOS `/Users/<name>` is `0755` by default). Tightening
241 /// the root blocks traversal into everything beneath it, including dirs
242 /// srv creates later with plain `create_dir_all` (`shared/`, `archives/`).
243 /// Best-effort: a root this process cannot chmod (owned by another user,
244 /// read-only mount) is logged and the launch continues, as before.
245 pub fn ensure_dirs(&self) -> Result<(), String> {
246 match ensure_owner_only_dir(&self.home_dir) {
247 Ok(OwnerOnlyOutcome::Tightened { previous_mode }) => tracing::warn!(
248 root = %self.home_dir.display(),
249 previous_mode = format!("{previous_mode:o}"),
250 "AgentMux data root was readable by other local users; tightened it to owner-only (0700)"
251 ),
252 Ok(OwnerOnlyOutcome::Created) => tracing::info!(
253 root = %self.home_dir.display(),
254 "created AgentMux data root owner-only (0700)"
255 ),
256 Ok(OwnerOnlyOutcome::AlreadyPrivate | OwnerOnlyOutcome::NotApplicable) => {}
257 Err(e) => tracing::warn!(
258 root = %self.home_dir.display(),
259 error = %e,
260 "could not make the AgentMux data root owner-only; continuing"
261 ),
262 }
263 for d in [
264 &self.instance_dir,
265 &self.data_dir,
266 &self.config_dir,
267 &self.logs_dir,
268 &self.cef_cache_dir,
269 &self.agents_dir,
270 &self.instance_runtime_dir,
271 &self.shared_dir,
272 ] {
273 std::fs::create_dir_all(d)
274 .map_err(|e| format!("failed to create {}: {}", d.display(), e))?;
275 }
276 // The data dir's `db/` subdir is the canonical srv DB home;
277 // mirrors legacy ensure_dirs() and lets srv unconditionally
278 // open `data_dir/db/objects.db`.
279 std::fs::create_dir_all(self.data_dir.join("db"))
280 .map_err(|e| format!("failed to create db dir: {}", e))?;
281 Ok(())
282 }
283
284 /// Env vars to pass to host + srv subprocesses. The launcher
285 /// computes `DataPaths` once and exports these; downstream
286 /// binaries read them via [`Self::from_env`] instead of
287 /// recomputing.
288 ///
289 /// Returns `OsString` (not `String`) so paths with non-UTF-8 bytes
290 /// — possible on Linux/macOS for users with exotic home dirs —
291 /// round-trip losslessly. `Command::env(k, v)` accepts any
292 /// `AsRef<OsStr>`, so the OsString flows through to children
293 /// unchanged. The mode value is the only `String`-typed entry
294 /// (it's a fixed ASCII vocabulary).
295 pub fn to_env_vars(&self) -> Vec<(&'static str, std::ffi::OsString)> {
296 use std::ffi::OsString;
297 let mut vars: Vec<(&'static str, OsString)> = vec![
298 ("AGENTMUX_INSTANCE_DIR", self.instance_dir.clone().into_os_string()),
299 ("AGENTMUX_DATA_DIR", self.data_dir.clone().into_os_string()),
300 ("AGENTMUX_CONFIG_DIR", self.config_dir.clone().into_os_string()),
301 ("AGENTMUX_LOG_DIR", self.logs_dir.clone().into_os_string()),
302 ("AGENTMUX_CEF_CACHE_DIR", self.cef_cache_dir.clone().into_os_string()),
303 ("AGENTMUX_AGENTS_DIR", self.agents_dir.clone().into_os_string()),
304 (
305 "AGENTMUX_INSTANCE_RUNTIME_DIR",
306 self.instance_runtime_dir.clone().into_os_string(),
307 ),
308 ("AGENTMUX_SHARED_DIR", self.shared_dir.clone().into_os_string()),
309 ("AGENTMUX_RUNTIME_MODE", OsString::from(self.mode.to_env_string())),
310 // Channel propagated so downstream binaries can log it +
311 // surface in diagnostics. NOT used to recompute paths
312 // (paths flow through the dir vars above).
313 ("AGENTMUX_CHANNEL", OsString::from(self.channel.clone())),
314 ];
315 // Dev mode also exports AGENTMUX_CLONE_ID so child processes
316 // (host, srv) can reconstruct the full `Dev { branch, clone_id }`
317 // variant via [`RuntimeMode::from_env_with_clone`]. The
318 // mode-string format (`dev:<branch>`) was kept backward-compatible
319 // and doesn't carry clone_id itself — see runtime_mode.rs.
320 if let RuntimeMode::Dev { clone_id: Some(id), .. } = &self.mode {
321 vars.push(("AGENTMUX_CLONE_ID", OsString::from(id.clone())));
322 }
323 vars
324 }
325
326 /// Reconstruct from env vars set by the launcher. Returns
327 /// `None` if any required var is missing — fail-fast vs.
328 /// silently falling back to legacy paths the way the old
329 /// sidecar.rs did.
330 ///
331 /// Uses `var_os` (not `var`) so non-UTF-8 path bytes survive.
332 pub fn from_env() -> Option<Self> {
333 let instance_dir = std::env::var_os("AGENTMUX_INSTANCE_DIR")?;
334 let data_dir = std::env::var_os("AGENTMUX_DATA_DIR")?;
335 let config_dir = std::env::var_os("AGENTMUX_CONFIG_DIR")?;
336 let logs_dir = std::env::var_os("AGENTMUX_LOG_DIR")?;
337 let cef_cache_dir = std::env::var_os("AGENTMUX_CEF_CACHE_DIR")?;
338 let agents_dir = std::env::var_os("AGENTMUX_AGENTS_DIR")?;
339 let instance_runtime_dir = std::env::var_os("AGENTMUX_INSTANCE_RUNTIME_DIR")?;
340 let shared_dir = std::env::var_os("AGENTMUX_SHARED_DIR")?;
341 // Pair AGENTMUX_RUNTIME_MODE with AGENTMUX_CLONE_ID so the Dev
342 // variant carries its clone discriminator. Legacy single-var
343 // form (no AGENTMUX_CLONE_ID set) leaves clone_id as None,
344 // which falls back to the pre-PR two-level dev path layout.
345 let mode = RuntimeMode::from_env_with_clone()?;
346 // Channel is required from the launcher (same fail-fast
347 // discipline as every other dir var). Missing AGENTMUX_CHANNEL
348 // means the launcher didn't export it — that's a launcher /
349 // srv version skew, surface it loudly rather than silently
350 // defaulting and risking a wrong-channel write.
351 let channel = std::env::var("AGENTMUX_CHANNEL").ok()?;
352
353 // Re-resolve home_dir (the agentmux root) on the consumer
354 // side rather than transmitting it via env — it's a function
355 // of the AGENTMUX_HOME_OVERRIDE env (test only) and the OS
356 // home dir, which are stable across the launcher → host hop.
357 let home_dir = resolve_root().ok()?;
358
359 Some(Self {
360 home_dir,
361 instance_dir: PathBuf::from(instance_dir),
362 channel,
363 data_dir: PathBuf::from(data_dir),
364 config_dir: PathBuf::from(config_dir),
365 logs_dir: PathBuf::from(logs_dir),
366 cef_cache_dir: PathBuf::from(cef_cache_dir),
367 agents_dir: PathBuf::from(agents_dir),
368 instance_runtime_dir: PathBuf::from(instance_runtime_dir),
369 shared_dir: PathBuf::from(shared_dir),
370 mode,
371 })
372 }
373
374 /// `~/.agentmux/shared/identities/` — root for per-bundle OAuth
375 /// credential directories. Lives under `shared_dir` so it's
376 /// account-wide and version-independent: upgrading agentmux does
377 /// not move a user's bundle credentials. Per
378 /// `docs/specs/archive/SPEC_OAUTH_IDENTITY_BUNDLES_2026_05_22.md` §4.1.
379 ///
380 /// When [`isolated_auth_enabled`] is set, this resolves to
381 /// `instance_dir/identities/` instead — a channel-scoped credential
382 /// tree, used only with `AGENTMUX_ISOLATED_AUTH=1`: auth is shared on
383 /// every channel by default
384 /// (`docs/specs/SPEC_SHARED_AUTH_ACROSS_CHANNELS_2026_10_03.md`).
385 /// Originally scoped to destructive
386 /// account testing (delete-account flows) that must never touch the
387 /// real global identity store other channels/instances use.
388 ///
389 /// **Isolating this directory isolates a provider's conversation
390 /// transcripts too**, since e.g. Claude Code's `projects/` lives
391 /// inside the same per-bundle directory tree as its credentials —
392 /// see [`identity_history_dir`] for the always-global path those
393 /// transcripts are kept reachable at regardless of this flag, and
394 /// `docs/specs/SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md`
395 /// §4.1 for why that split exists.
396 /// See `docs/specs/SPEC_ISOLATED_AUTH_DEV_TESTING_2026_07_27.md`.
397 pub fn identities_dir(&self) -> PathBuf {
398 if isolated_auth_enabled() {
399 self.instance_dir.join("identities")
400 } else {
401 self.shared_dir.join("identities")
402 }
403 }
404
405 /// `~/.agentmux/shared/identities/<bundle_id>/<provider_dir>/<history_subdir>/`
406 /// — ALWAYS this location, regardless of [`isolated_auth_enabled`].
407 /// Conversation transcripts must survive a channel/build change even
408 /// when the surrounding credential directory ([`identities_dir`]) is
409 /// isolated per-channel for account testing — history and credentials
410 /// are different data-persistence categories (CONVERSATION HISTORY
411 /// vs CREDENTIAL in
412 /// `docs/specs/SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md`
413 /// P1) and must not share one isolation switch just because they
414 /// happen to live under the same directory as far as the provider
415 /// CLI is concerned. Callers that isolate credentials are expected
416 /// to redirect the isolated `<bundle_id>/<provider_dir>/<history_subdir>`
417 /// subpath to this location via [`ensure_history_link`] rather than
418 /// letting the provider CLI write real session data there directly.
419 ///
420 /// `history_subdir` is caller-supplied rather than hardcoded — it is
421 /// NOT the same directory name for every provider (reagentx P1 on PR
422 /// #2605: this used to hardcode `"projects"`, which is Claude-
423 /// specific; Codex uses `"sessions"`, Gemini uses `"history"`, per
424 /// `docs/specs/SPEC_UNIFIED_AGENT_HISTORY_STORE_2026-06-10.md` §2.1).
425 /// Callers should read this from
426 /// `ProviderConfig::history_native_subdir`, the single source of
427 /// truth for which providers have a linkable directory at all.
428 ///
429 /// `bundle_id`/`provider_dir` are not path-validated here — callers
430 /// must use [`sanitize_path_segment`]-checked values, same
431 /// requirement as [`identity_dir`].
432 pub fn identity_history_dir(&self, bundle_id: &str, provider_dir: &str, history_subdir: &str) -> PathBuf {
433 self.shared_dir
434 .join("identities")
435 .join(bundle_id)
436 .join(provider_dir)
437 .join(history_subdir)
438 }
439
440 /// `~/.agentmux/shared/identities/<bundle_id>/` — a specific
441 /// bundle's credential root, when `bundle_id` is a safe path
442 /// segment. Returns `None` for empty / `.` / `..` / any segment
443 /// containing `/`, `\`, drive-letter colons, or Windows-reserved
444 /// characters (same rules as the version/branch sanitizer in
445 /// `resolve`).
446 ///
447 /// Defensive return type: `bundle_id` flows from `auth.start`
448 /// request bodies (PR C) into `create_dir_all`, so an
449 /// unvalidated `PathBuf::join` would let a crafted id escape the
450 /// identities root and write outside the bundle area. codex P1
451 /// follow-up on #981.
452 ///
453 /// Per-provider subdirectories (e.g. `claude/`, `codex/`) hang
454 /// off this when the bundle gains an OAuth binding (PR C). The
455 /// directory is created lazily by the bundle / OAuth flow that
456 /// needs it — `ensure_dirs()` does not pre-create it.
457 pub fn identity_dir(&self, bundle_id: &str) -> Option<PathBuf> {
458 sanitize_path_segment(bundle_id).map(|safe| self.identities_dir().join(safe))
459 }
460
461 /// `~/.agentmux/shared/providers/<auth_dir_name>/` — the DEFAULT provider
462 /// config + auth dir. Lives under `shared_dir`, so it is account-wide,
463 /// version-independent, AND channel-independent: every instance / channel /
464 /// version logs in ONCE and shares it. This is the structural fix for the
465 /// per-channel "validate-spin" regression — there is no empty-per-instance
466 /// auth dir to spin on. The per-identity override (`identity_dir`) still
467 /// takes precedence for explicit multi-account bundles. `auth_dir_name`
468 /// comes from the static provider registry (e.g. "claude"), never user
469 /// input. Retro:
470 /// `docs/retro/retro-provider-auth-isolation-regression-2026-06-05.md`.
471 pub fn provider_auth_dir(&self, auth_dir_name: &str) -> PathBuf {
472 self.shared_dir.join("providers").join(auth_dir_name)
473 }
474
475 /// `instance_dir/wan-identity/` — the channel-wide WAN identity store
476 /// (`wan.db`: the instance key, agent WAN keys, and WAN verification
477 /// state). Beside `config/` and `agents/` rather than under the
478 /// per-version `data_dir`, so an upgrade keeps the same instance and the
479 /// same agent keys. Deliberately not `identity/`, which would sit next to
480 /// the unrelated `identities/`. For dev builds this is the branch (or
481 /// clone) dir, so each dev checkout is its own instance.
482 /// `SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md` §2.2.
483 pub fn wan_identity_dir(&self) -> PathBuf {
484 self.instance_dir.join("wan-identity")
485 }
486}
487
488/// Best-effort: ensure `link_path` (a `projects/` subdirectory inside an
489/// [`isolated_auth_enabled`]-isolated provider credential dir) is a
490/// directory junction (Windows) / symlink (Unix) pointing at
491/// `target_dir` (the always-global [`DataPaths::identity_history_dir`]),
492/// so a provider's session transcripts stay reachable across a
493/// channel/build change even though the credential directory around them
494/// is per-channel isolated. See
495/// `docs/specs/SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md`
496/// §4.1.
497///
498/// A Windows **junction** (not a symlink) is used deliberately —
499/// `std::os::windows::fs::symlink_dir` requires `SeCreateSymbolicLinkPrivilege`
500/// (admin, or Developer Mode enabled), which this app cannot assume every
501/// user has; junctions need no special privilege. Uses the `junction`
502/// crate (`FSCTL_SET_REPARSE_POINT` under the hood) rather than
503/// hand-rolled reparse-point FFI, for the same reason this codebase's own
504/// `bundle.rs` comment notes about Windows symlink privilege requirements
505/// — this is credential-adjacent storage, not a place to improvise
506/// low-level filesystem code.
507///
508/// Idempotent: a no-op if `link_path` is already a link pointing at
509/// `target_dir`. If a REAL directory already exists at `link_path` (data
510/// written before this function existed, or before `isolated_auth_enabled`
511/// applied to this bundle), its entries are moved into `target_dir` one
512/// by one via `rename` (atomic, same-volume — always true here, both
513/// live under the single `~/.agentmux` tree) before the now-empty
514/// directory is replaced with the link. **Never deletes or overwrites
515/// data**: an entry whose name already exists at the target is left
516/// exactly where it was, under the old per-channel path, rather than
517/// risking clobbering real history — it becomes reachable again once a
518/// human resolves the name collision, rather than silently lost. Any
519/// I/O error at any step is returned to the caller, who is expected to
520/// treat this as best-effort (log and continue spawning) per the same
521/// philosophy as the jekt-key injection in `agent_config.rs`.
522pub fn ensure_history_link(link_path: &std::path::Path, target_dir: &std::path::Path) -> std::io::Result<()> {
523 std::fs::create_dir_all(target_dir)?;
524 // The `junction` crate creates the link via a plain `fs::create_dir`
525 // (not `create_dir_all`) on `link_path` itself, so its parent must
526 // already exist. Unix's `symlink` has the same requirement. Real
527 // production callers already have this (the isolated identity dir is
528 // created before this function runs), but this function must not
529 // depend on caller ordering to be correct/testable on its own.
530 if let Some(parent) = link_path.parent() {
531 std::fs::create_dir_all(parent)?;
532 }
533
534 #[cfg(windows)]
535 let already_correct = junction::exists(link_path).unwrap_or(false)
536 && junction::get_target(link_path).ok().as_deref() == Some(target_dir);
537 #[cfg(unix)]
538 let already_correct = std::fs::symlink_metadata(link_path)
539 .map(|m| m.file_type().is_symlink())
540 .unwrap_or(false)
541 && std::fs::read_link(link_path).ok().as_deref() == Some(target_dir);
542 #[cfg(not(any(windows, unix)))]
543 let already_correct = false;
544
545 if already_correct {
546 return Ok(());
547 }
548
549 match std::fs::symlink_metadata(link_path) {
550 Ok(meta) if meta.is_dir() && !meta.file_type().is_symlink() => {
551 // Pre-existing REAL directory: migrate its contents (never
552 // clobbering an existing target entry), then remove the
553 // now-empty directory so a link can take its place.
554 for entry in std::fs::read_dir(link_path)? {
555 let entry = entry?;
556 let dest = target_dir.join(entry.file_name());
557 if !dest.exists() {
558 std::fs::rename(entry.path(), &dest)?;
559 }
560 }
561 // Only remove if migration left it empty (i.e. every entry
562 // moved, or there were none) — a leftover name collision
563 // means real, un-migrated data is still here, and this must
564 // not silently paper over that by leaving a directory where
565 // a link was expected. Surfacing the removal error is the
566 // signal to the caller that this bundle needs manual review.
567 std::fs::remove_dir(link_path)?;
568 }
569 Ok(meta) if meta.file_type().is_symlink() => {
570 // A link already exists but pointed at something else (stale/
571 // wrong target from a prior version of this function). Safe
572 // to drop: no data lives directly at a link path, only at
573 // whatever it points to, which this doesn't touch.
574 #[cfg(windows)]
575 {
576 let _ = junction::delete(link_path);
577 }
578 #[cfg(unix)]
579 {
580 let _ = std::fs::remove_file(link_path);
581 }
582 }
583 Ok(_) => {
584 // A plain file (or anything else that's neither a real
585 // directory nor a link) sits where a link needs to go.
586 // Nothing in this codebase is expected to ever put a file
587 // named "projects" here, but reagentx P2 on PR #2605 caught
588 // that the previous version of this match arm silently
589 // deleted it via remove_file/junction::delete with no
590 // migration — directly contradicting this function's own
591 // never-delete-data guarantee. Surface an error instead of
592 // guessing what to do with unexpected real data.
593 return Err(std::io::Error::new(
594 std::io::ErrorKind::AlreadyExists,
595 format!(
596 "ensure_history_link: {} exists and is neither a directory nor a link; \
597 refusing to delete it",
598 link_path.display()
599 ),
600 ));
601 }
602 Err(_) => {
603 // Nothing at link_path yet — normal first-time case.
604 }
605 }
606
607 #[cfg(windows)]
608 {
609 junction::create(target_dir, link_path)?;
610 }
611 #[cfg(unix)]
612 {
613 std::os::unix::fs::symlink(target_dir, link_path)?;
614 }
615 #[cfg(not(any(windows, unix)))]
616 {
617 return Err(std::io::Error::new(
618 std::io::ErrorKind::Unsupported,
619 "ensure_history_link: no directory-link mechanism on this platform",
620 ));
621 }
622
623 Ok(())
624}
625
626/// What [`ensure_owner_only_dir`] did.
627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
628pub enum OwnerOnlyOutcome {
629 /// The directory did not exist; it was created with mode `0700`.
630 Created,
631 /// It existed with group/other permission bits set; those were removed.
632 /// `previous_mode` is the old permission bits (e.g. `0o755`).
633 Tightened { previous_mode: u32 },
634 /// It already had no group/other access. Nothing changed.
635 AlreadyPrivate,
636 /// Nothing to do here: not a Unix platform (Windows scopes the user
637 /// profile, and so `~/.agentmux`, to its owner through ACLs), or `dir`
638 /// is the user's home directory itself — a misconfigured
639 /// `AGENTMUX_HOME_OVERRIDE`/`AGENTMUX_DATA_HOME` pointing at `$HOME`
640 /// must not chmod the whole home to `0700`.
641 NotApplicable,
642}
643
644/// Make `dir` owner-only on Unix: create it `0700` if missing (its parents
645/// with the normal umask — they are the user's, not AgentMux's), or strip
646/// group/other bits from an existing directory, keeping the owner bits as
647/// they were. See [`DataPaths::ensure_dirs`] for why (#3682).
648///
649/// Follows a symlinked root to its target, as every other access to the root
650/// already does. Errors if `dir` exists but is not a directory.
651pub fn ensure_owner_only_dir(dir: &Path) -> std::io::Result<OwnerOnlyOutcome> {
652 ensure_owner_only_dir_inner(dir, dirs::home_dir().as_deref())
653}
654
655fn ensure_owner_only_dir_inner(dir: &Path, user_home: Option<&Path>) -> std::io::Result<OwnerOnlyOutcome> {
656 #[cfg(unix)]
657 {
658 use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
659
660 if let Some(home) = user_home {
661 let same = match (std::fs::canonicalize(dir), std::fs::canonicalize(home)) {
662 (Ok(a), Ok(b)) => a == b,
663 _ => dir == home,
664 };
665 if same {
666 return Ok(OwnerOnlyOutcome::NotApplicable);
667 }
668 }
669
670 match std::fs::metadata(dir) {
671 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
672 if let Some(parent) = dir.parent() {
673 std::fs::create_dir_all(parent)?;
674 }
675 // Non-recursive on purpose: only the root itself is ours to
676 // make private. The requested mode is still masked by the
677 // umask, which can only remove bits — never add group/other.
678 match std::fs::DirBuilder::new().mode(0o700).create(dir) {
679 Ok(()) => return Ok(OwnerOnlyOutcome::Created),
680 // Lost a race with a concurrent launch: fall through and
681 // check what that one created.
682 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
683 Err(e) => return Err(e),
684 }
685 }
686 Err(e) => return Err(e),
687 Ok(_) => {}
688 }
689
690 let meta = std::fs::metadata(dir)?;
691 if !meta.is_dir() {
692 return Err(std::io::Error::new(
693 std::io::ErrorKind::AlreadyExists,
694 format!("{} exists and is not a directory", dir.display()),
695 ));
696 }
697 let mode = meta.permissions().mode() & 0o777;
698 if mode & 0o077 == 0 {
699 return Ok(OwnerOnlyOutcome::AlreadyPrivate);
700 }
701 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode & 0o700))?;
702 Ok(OwnerOnlyOutcome::Tightened { previous_mode: mode })
703 }
704 #[cfg(not(unix))]
705 {
706 let _ = (dir, user_home);
707 Ok(OwnerOnlyOutcome::NotApplicable)
708 }
709}
710
711/// Isolated per-channel auth (identity accounts + OAuth credential dirs).
712/// Read directly at every call site rather than cached on `DataPaths` so
713/// `identities_dir()` behaves consistently regardless of whether the
714/// caller built its `DataPaths` via `resolve()` (launcher) or
715/// `from_env()` (downstream host/srv).
716///
717/// Resolution order:
718/// 1. `AGENTMUX_ISOLATED_AUTH=1` — isolate this channel's auth. Meant for
719/// testing the login and relogin paths from an empty store.
720/// 2. Any other value of `AGENTMUX_ISOLATED_AUTH` — shared (explicit opt-out).
721/// 3. Unset — **shared, on every channel**. A new build, a rebuilt branch,
722/// `task dev` and a release all read the same accounts, provider login
723/// folders, bundles, drones, native memory, Global Memory and MuxBus
724/// sign-in, so a new build needs no login
725/// (`SPEC_SHARED_AUTH_ACROSS_CHANNELS_2026_10_03.md`). Until 2026-10-03 every
726/// channel except `stable` defaulted to isolated
727/// (`SPEC_ISOLATED_AUTH_DEFAULT_BY_CHANNEL_2026_08_06.md`).
728///
729/// See `docs/specs/SPEC_ISOLATED_AUTH_DEV_TESTING_2026_07_27.md` (the
730/// underlying mechanism — channel-scoped store + credential dirs — this
731/// flag drives, still authoritative).
732pub fn isolated_auth_enabled() -> bool {
733 isolated_auth_reason().is_isolated()
734}
735
736/// Which rule decided [`isolated_auth_enabled`]'s result — for boot-time
737/// diagnostics (see `bootstrap/stores.rs`'s "shared store: attached" log line)
738/// so a developer staring at an empty Connectors → Accounts can tell at a glance whether
739/// that is an explicit choice, rather than re-deriving it from the env by
740/// hand. Callers that only need the boolean should use
741/// [`isolated_auth_enabled`] directly — this exists purely so the two never
742/// drift (one resolution, two views).
743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
744pub enum IsolatedAuthReason {
745 /// `AGENTMUX_ISOLATED_AUTH=1`.
746 ExplicitOptIn,
747 /// `AGENTMUX_ISOLATED_AUTH` is set to anything other than exactly `"1"`
748 /// (`"0"`, `""`, a typo like `"false"`, anything): shared. Fail-safe by
749 /// construction: a malformed opt-in can never silently isolate a channel
750 /// (#2431), and since 2026-10-03 the unset default is shared as well.
751 ExplicitOptOut,
752 /// No override: shared, on every channel.
753 DefaultShared,
754}
755
756impl IsolatedAuthReason {
757 pub fn is_isolated(self) -> bool {
758 matches!(self, Self::ExplicitOptIn)
759 }
760
761 /// Short, log-friendly label.
762 pub fn as_str(self) -> &'static str {
763 match self {
764 Self::ExplicitOptIn => "explicit opt-in",
765 Self::ExplicitOptOut => "explicit opt-out",
766 Self::DefaultShared => "default — shared",
767 }
768 }
769}
770
771pub fn isolated_auth_reason() -> IsolatedAuthReason {
772 match std::env::var("AGENTMUX_ISOLATED_AUTH") {
773 // Exactly "1" isolates; any other value the var is explicitly set to
774 // ("0", "", a typo) is an explicit opt-out.
775 Ok(v) if v == "1" => IsolatedAuthReason::ExplicitOptIn,
776 Ok(_) => IsolatedAuthReason::ExplicitOptOut,
777 Err(_) => IsolatedAuthReason::DefaultShared,
778 }
779}
780
781/// Isolated per-channel `settings.json`. Same shape and same reasoning as
782/// [`isolated_auth_enabled`] — see
783/// `docs/specs/SPEC_SETTINGS_ISOLATED_BY_CHANNEL_2026_08_19.md`.
784///
785/// Resolution order:
786/// 1. `AGENTMUX_ISOLATED_SETTINGS=1` / `=0` — explicit override, always wins.
787/// 2. Otherwise, defaults to isolated for every channel except `"stable"`.
788/// `stable` is the real release channel — the daily-driver install(s)
789/// this machine's actual work depends on — and keeps the old
790/// always-global behavior, so nobody's real window theme/pinned
791/// widgets/voice API key gets silently blanked by a channel-name
792/// coincidence. Every `task dev` branch and every `task package` local
793/// build now starts with a genuinely default `settings.json` — the
794/// motivating case was `network:lan_discovery` silently carrying
795/// `true` into a brand-new build from a decision made in an unrelated
796/// channel weeks earlier.
797/// 3. If `AGENTMUX_CHANNEL` isn't set yet, stays global — conservative
798/// default when channel context is unknown, not a guess.
799///
800/// Deliberately a SEPARATE flag from `AGENTMUX_ISOLATED_AUTH`, not a
801/// shared `AGENTMUX_ISOLATED` umbrella — see the spec's Open Questions
802/// §1 for why.
803pub fn isolated_settings_enabled() -> bool {
804 isolated_settings_reason().is_isolated()
805}
806
807/// Which rule decided [`isolated_settings_enabled`]'s result — for
808/// boot-time diagnostics, mirroring [`IsolatedAuthReason`] exactly (see
809/// that type's doc comment for the rationale: one resolution, two views,
810/// so the boolean and the log line can never drift apart).
811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
812pub enum IsolatedSettingsReason {
813 /// `AGENTMUX_ISOLATED_SETTINGS=1`.
814 ExplicitOptIn,
815 /// `AGENTMUX_ISOLATED_SETTINGS` is set to anything other than exactly
816 /// `"1"` — fail-safe by construction, same rule as
817 /// `IsolatedAuthReason::ExplicitOptOut` and for the same reason: a
818 /// typo'd opt-out attempt must not silently isolate a non-stable
819 /// channel instead of falling back to the safe (global) state.
820 ExplicitOptOut,
821 /// No override; `AGENTMUX_CHANNEL` is set and isn't `"stable"`.
822 ChannelDefaultIsolated,
823 /// No override; `AGENTMUX_CHANNEL` is `"stable"` or unset entirely.
824 ChannelDefaultGlobal,
825}
826
827impl IsolatedSettingsReason {
828 pub fn is_isolated(self) -> bool {
829 matches!(self, Self::ExplicitOptIn | Self::ChannelDefaultIsolated)
830 }
831
832 /// Short, log-friendly label.
833 pub fn as_str(self) -> &'static str {
834 match self {
835 Self::ExplicitOptIn => "explicit opt-in",
836 Self::ExplicitOptOut => "explicit opt-out",
837 Self::ChannelDefaultIsolated => "channel default — isolated",
838 Self::ChannelDefaultGlobal => "channel default — global",
839 }
840 }
841}
842
843pub fn isolated_settings_reason() -> IsolatedSettingsReason {
844 match std::env::var("AGENTMUX_ISOLATED_SETTINGS") {
845 Ok(v) if v == "1" => IsolatedSettingsReason::ExplicitOptIn,
846 Ok(_) => IsolatedSettingsReason::ExplicitOptOut,
847 Err(_) => match std::env::var("AGENTMUX_CHANNEL") {
848 Ok(ch) if ch != "stable" => IsolatedSettingsReason::ChannelDefaultIsolated,
849 _ => IsolatedSettingsReason::ChannelDefaultGlobal,
850 },
851 }
852}
853
854/// Skip the automatic, EAGER MuxBus cloud-session reconnect
855/// (`CloudSubscriber::init_global`, called at boot) on a randomized
856/// per-build local-package channel. See
857/// `docs/retro/retro-macos-0560-stale-cef-cache-launch-crash-2026-09-16.md`
858/// for the incident this closes.
859///
860/// Without this flag, `CloudSubscriber::init_global`
861/// (`crates/srv/src/bootstrap/background.rs`) runs on every launch (unless
862/// `AGENTMUX_DISABLE_CLOUD_SUBSCRIBER` is set) and performs a real, synchronous
863/// OS-keychain read of the single global `muxbus:global` credential
864/// almost immediately. On macOS that read requires interactive OS consent
865/// the first time a given code signature touches it — and every local
866/// `task package:macos`/`task package`/`task package:linux` build bakes a
867/// brand-new, randomized per-build channel (and, on macOS, a bundle
868/// identifier derived from it) into the binary. So every fresh local
869/// build looks like a never-before-seen app to the Keychain, and the same
870/// already-`Always Allow`'d credential prompts again on every single
871/// local rebuild — indefinitely, since a new random channel is minted
872/// every time.
873///
874/// **This gates ONLY the one-shot eager call at boot** — not every future
875/// MuxBus interaction on that channel. `muxbus.status` (reagentx P0/P1 on
876/// PR #3248, round 2) instead checks whether
877/// `muxbus::cloud_subscriber::get_global_subscriber()` has been
878/// initialized — `None` until either this flag let boot initialize it
879/// (stable/dev channels), or a user explicitly completes `muxbus.login`
880/// on an isolated channel, which lazily initializes it right there. That
881/// split is what makes "only prompt once the user actually enables
882/// MuxBus" true: skipping the eager boot call must not permanently wall
883/// off every other MuxBus code path on that process for its whole
884/// lifetime, or an explicit login would "succeed" while silently never
885/// opening a WebSocket (reagentx P1, round 2) and the status bar's
886/// 60-second poll would keep hitting Keychain regardless of this flag
887/// (reagentx P0, round 2).
888///
889/// Resolution order:
890/// 1. `AGENTMUX_ISOLATED_MUXBUS=1` / `=0` — explicit override, always wins.
891/// 2. Otherwise, defaults to isolated (skip the eager reconnect) for a
892/// **local package channel** — `AGENTMUX_CHANNEL` is set, isn't
893/// `"stable"`, and doesn't start with `"dev-"`. `stable` is the real
894/// release channel; `dev-<branch>[-<clone-id>]` (`RuntimeMode::Dev`'s
895/// channel format, below) is `task dev`'s **stable, per-branch**
896/// channel name — reused across every `task dev`
897/// invocation on that branch, unlike a package build's channel, which
898/// mints a brand-new random hash on literally every single build. A
899/// dev session therefore doesn't have the "never-before-seen app to
900/// Keychain every time" problem this flag exists to solve, and
901/// isolating it anyway would be a pure regression (reagentx P1 on PR
902/// #3248, round 1 + round 2: dev sessions losing automatic MuxBus
903/// reconnect, and thus WAN notifications, for the entire session).
904/// This is a **deliberate narrowing** relative to
905/// [`isolated_auth_enabled`]/[`isolated_settings_enabled`], which
906/// isolate `dev-*` too — those exist to make local testing exercise
907/// the real accounts/settings flow, a goal `dev-*`'s stability doesn't
908/// undermine the same way it undermines this flag's actual, narrower
909/// motivation (avoiding a repeat OS prompt from a randomized
910/// per-build identity).
911/// 3. If `AGENTMUX_CHANNEL` isn't set yet, stays global — conservative
912/// default when channel context is unknown, not a guess.
913///
914/// Deliberately a separate flag from `AGENTMUX_ISOLATED_AUTH`/
915/// `AGENTMUX_ISOLATED_SETTINGS`, not folded into either — MuxBus's cloud
916/// session is a different kind of credential (one real cloud account, not
917/// per-provider OAuth or local UI prefs) and callers should be able to
918/// isolate one without the others.
919pub fn isolated_muxbus_reconnect_enabled() -> bool {
920 isolated_muxbus_reconnect_reason().is_isolated()
921}
922
923/// Which rule decided [`isolated_muxbus_reconnect_enabled`]'s result —
924/// mirrors [`IsolatedAuthReason`]/[`IsolatedSettingsReason`] exactly, same
925/// rationale (one resolution, two views).
926#[derive(Debug, Clone, Copy, PartialEq, Eq)]
927pub enum IsolatedMuxbusReconnectReason {
928 /// `AGENTMUX_ISOLATED_MUXBUS=1`.
929 ExplicitOptIn,
930 /// `AGENTMUX_ISOLATED_MUXBUS` is set to anything other than exactly
931 /// `"1"` — fail-safe by construction, same rule as
932 /// `IsolatedAuthReason::ExplicitOptOut` and for the same reason: a
933 /// typo'd opt-out attempt must not silently isolate a non-stable
934 /// channel instead of falling back to the safe (global/reconnect)
935 /// state.
936 ExplicitOptOut,
937 /// No override; `AGENTMUX_CHANNEL` is set, isn't `"stable"`, and
938 /// doesn't start with `"dev-"` — a randomized local-package channel.
939 ChannelDefaultIsolated,
940 /// No override; `AGENTMUX_CHANNEL` is `"stable"`, starts with
941 /// `"dev-"`, or is unset entirely.
942 ChannelDefaultGlobal,
943}
944
945impl IsolatedMuxbusReconnectReason {
946 pub fn is_isolated(self) -> bool {
947 matches!(self, Self::ExplicitOptIn | Self::ChannelDefaultIsolated)
948 }
949
950 /// Short, log-friendly label.
951 pub fn as_str(self) -> &'static str {
952 match self {
953 Self::ExplicitOptIn => "explicit opt-in",
954 Self::ExplicitOptOut => "explicit opt-out",
955 Self::ChannelDefaultIsolated => "channel default — isolated",
956 Self::ChannelDefaultGlobal => "channel default — global",
957 }
958 }
959}
960
961pub fn isolated_muxbus_reconnect_reason() -> IsolatedMuxbusReconnectReason {
962 match std::env::var("AGENTMUX_ISOLATED_MUXBUS") {
963 Ok(v) if v == "1" => IsolatedMuxbusReconnectReason::ExplicitOptIn,
964 Ok(_) => IsolatedMuxbusReconnectReason::ExplicitOptOut,
965 Err(_) => match std::env::var("AGENTMUX_CHANNEL") {
966 Ok(ch) if ch != "stable" && !ch.starts_with("dev-") => {
967 IsolatedMuxbusReconnectReason::ChannelDefaultIsolated
968 }
969 _ => IsolatedMuxbusReconnectReason::ChannelDefaultGlobal,
970 },
971 }
972}
973
974/// The AgentMux root (`~/.agentmux`) — the single resolver for it.
975///
976/// Public because srv had its own copy (`backend/base.rs::get_mux_data_dir`)
977/// reading a *different* env var and failing differently: where this returns
978/// `Err`, that one fell back to `PathBuf::from("/")`, so a host with no
979/// resolvable home directory wrote to `/.agentmux` instead of refusing.
980/// A10 of docs/analysis/TRACKING_ARCHITECTURE_REFACTOR_A1_A15_2026_06_18.md.
981///
982/// Two env vars are honoured, in this order, because both already exist in
983/// the wild and silently dropping either would move where a live install
984/// finds its data:
985///
986/// - `AGENTMUX_HOME_OVERRIDE` — this module's own, used by tests.
987/// - `AGENTMUX_DATA_HOME` — srv's own, read at startup and relied on by the
988/// MSIX packaging path (`blockcontroller/shell/lifecycle.rs`). It is
989/// deliberately **stripped** from pane environments, not exported into them:
990/// it is one of the identity vars invariant I7 removes so a pane cannot
991/// inherit and resolve another instance's data dir
992/// (`backend/pane_env.rs`, test `the_identity_vars_that_caused_the_breach_are_stripped`).
993///
994/// NOT to be confused with `AGENTMUX_DATA_DIR`, which the launcher exports
995/// and which names the per-channel *data* directory
996/// (`<root>/channels/<channel>/data`), not the root. The similar names are
997/// exactly why srv ended up with a second resolver.
998pub fn agentmux_root() -> Result<PathBuf, String> {
999 // `var_os`: a root that isn't valid UTF-8 must not read as unset.
1000 for var in ["AGENTMUX_HOME_OVERRIDE", "AGENTMUX_DATA_HOME"] {
1001 if let Some(s) = std::env::var_os(var) {
1002 if !s.is_empty() {
1003 return Ok(PathBuf::from(s));
1004 }
1005 }
1006 }
1007 let home = dirs::home_dir().ok_or_else(|| "dirs::home_dir() returned None".to_string())?;
1008 Ok(home.join(".agentmux"))
1009}
1010
1011fn resolve_root() -> Result<PathBuf, String> {
1012 agentmux_root()
1013}
1014
1015/// Sanitize a string for use as a single filesystem path segment.
1016/// Rejects empty, `.`, `..`, segments containing path separators, and
1017/// any character that has filesystem-special meaning on Windows (which
1018/// is the most restrictive of the platforms we target). Used as belt-
1019/// and-braces protection in `DataPaths::resolve` to prevent traversal
1020/// even when callers pass a directly-constructed `RuntimeMode::Dev` or
1021/// odd version string.
1022///
1023/// Why `:` is rejected: on Windows `C:temp` is a drive-relative path,
1024/// not a literal filename, so `PathBuf::join("versions").join("C:temp")`
1025/// would resolve OUTSIDE the intended `~/.agentmux/versions/` subtree.
1026fn sanitize_path_segment(s: &str) -> Option<String> {
1027 // Reject whitespace padding rather than silently normalizing it
1028 // away — otherwise distinct caller-supplied ids like "foo" and
1029 // " foo " would alias to the same directory. bundle_id is a real
1030 // caller-supplied identifier (passed through RPC payloads), so
1031 // this matters for credential isolation. codex P2 follow-up on
1032 // #981. Internally-generated version strings + branch names
1033 // shouldn't carry padding anyway, so this is no-op for them.
1034 if s != s.trim() {
1035 return None;
1036 }
1037 if s.is_empty() || s == "." || s == ".." {
1038 return None;
1039 }
1040 // Filesystem separators + Windows-reserved characters + NUL.
1041 if s
1042 .chars()
1043 .any(|c| matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0'))
1044 {
1045 return None;
1046 }
1047 Some(s.to_string())
1048}
1049
1050/// Sanitize a string for use as a channel name. Same path-segment
1051/// safety rules as [`sanitize_path_segment`] plus:
1052/// - Length capped at 64 chars (channel names show up in logs + the
1053/// launcher splash + may eventually be displayed in a picker, so
1054/// the cap is for UI sanity, not security).
1055/// - Rejects names in [`RESERVED_CHANNEL_NAMES`] that would collide
1056/// with sibling dirs at `~/.agentmux/` or reserved subdir names
1057/// inside a channel.
1058/// - The synonym `"default"` maps to `"stable"` (per
1059/// `SPEC_DATA_CHANNELS_2026_05_24.md` §7.5).
1060fn sanitize_channel_name(s: &str) -> Option<String> {
1061 let base = sanitize_path_segment(s)?;
1062 if base.len() > 64 {
1063 return None;
1064 }
1065 if RESERVED_CHANNEL_NAMES.contains(&base.as_str()) {
1066 return None;
1067 }
1068 if base == "default" {
1069 return Some("stable".to_string());
1070 }
1071 Some(base)
1072}
1073
1074/// Resolve the channel name and on-disk channel dir for a given mode.
1075/// Pure function over (env, mode, root). When `honor_env_channel` is
1076/// `true`, `AGENTMUX_CHANNEL` overrides the mode default; when `false`,
1077/// the env is ignored and resolution depends only on `mode` +
1078/// build-time defaults. The `false` path is used by dev-build self-
1079/// detection (see [`DataPaths::resolve_path_only`]).
1080///
1081/// Resolution order (mirrors `SPEC_DATA_CHANNELS_2026_05_24.md` §2.2):
1082/// 1. (only if `honor_env_channel`) `AGENTMUX_CHANNEL` env override —
1083/// any mode → path is `<root>/channels/<channel>/`. Lets the
1084/// operator point any binary at any channel for parallel-channel
1085/// testing.
1086/// 2. No override (or env-channel disallowed), mode = Dev { branch }
1087/// → channel name is `dev-<branch>`, path stays at
1088/// `<root>/dev/<branch>/` (unchanged from Phase 1).
1089/// 3. Same conditions, mode = Installed | Portable → channel name is
1090/// [`BUILD_CHANNEL_DEFAULT`] (set at build time by the packaging
1091/// script), path is `<root>/channels/<channel>/`.
1092fn resolve_channel_and_dir(
1093 mode: &RuntimeMode,
1094 root: &Path,
1095 honor_env_channel: bool,
1096) -> Result<(String, PathBuf), String> {
1097 // (1) Explicit env override — only when caller opted in.
1098 if honor_env_channel {
1099 if let Ok(raw) = std::env::var("AGENTMUX_CHANNEL") {
1100 if !raw.is_empty() {
1101 let channel = sanitize_channel_name(&raw).ok_or_else(|| {
1102 format!("invalid AGENTMUX_CHANNEL value: {:?}", raw)
1103 })?;
1104 let dir = root.join("channels").join(&channel);
1105 return Ok((channel, dir));
1106 }
1107 }
1108 }
1109
1110 // (2) Dev mode default: dev-<branch>[-<clone_id>], path under
1111 // dev/<branch>/[<clone_id>/]. The clone_id nests one level deeper
1112 // so two clones of the same branch don't collide on data dir,
1113 // lockfile, or named-pipe IPC. When clone_id is None (legacy
1114 // env-string round-trip or direct test construction) the layout
1115 // falls back to the original two-level form for back-compat.
1116 // See SPEC_DATA_CHANNELS_2026_05_24.md §2.4 and
1117 // docs/analysis/ANALYSIS_MULTI_CLONE_TASK_DEV_ISOLATION_2026-05-26.md.
1118 if let RuntimeMode::Dev { branch, clone_id } = mode {
1119 let safe_branch = sanitize_path_segment(branch).ok_or_else(|| {
1120 format!("invalid dev branch for path: {:?}", branch)
1121 })?;
1122 let safe_clone = clone_id
1123 .as_deref()
1124 .and_then(sanitize_path_segment)
1125 .filter(|s| !s.is_empty());
1126 let (channel, dir) = match safe_clone {
1127 Some(c) => (
1128 format!("dev-{}-{}", safe_branch, c),
1129 root.join("dev").join(safe_branch).join(c),
1130 ),
1131 None => (
1132 format!("dev-{}", safe_branch),
1133 root.join("dev").join(safe_branch),
1134 ),
1135 };
1136 return Ok((channel, dir));
1137 }
1138
1139 // (3) Installed / Portable default: build-time channel.
1140 let channel = sanitize_channel_name(BUILD_CHANNEL_DEFAULT).ok_or_else(|| {
1141 format!(
1142 "compile-time AGENTMUX_BUILD_CHANNEL_DEFAULT is invalid: {:?}",
1143 BUILD_CHANNEL_DEFAULT
1144 )
1145 })?;
1146 let dir = root.join("channels").join(&channel);
1147 Ok((channel, dir))
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152 use super::*;
1153 use crate::TEST_ENV_LOCK;
1154 use tempfile::TempDir;
1155
1156 /// RAII guard that restores process state on drop, even if the
1157 /// test panics. Without Drop-based cleanup, a panic inside `f`
1158 /// would leave AGENTMUX_HOME_OVERRIDE set with a stale tempdir
1159 /// path AND poison the mutex; subsequent tests recover from poison
1160 /// but inherit the wrong env value.
1161 struct HomeOverrideGuard {
1162 _tmp: TempDir,
1163 _lock: std::sync::MutexGuard<'static, ()>,
1164 }
1165
1166 impl Drop for HomeOverrideGuard {
1167 fn drop(&mut self) {
1168 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
1169 }
1170 }
1171
1172 fn with_home_override<F: FnOnce(&Path)>(f: F) {
1173 let lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1174 let tmp = TempDir::new().expect("tempdir");
1175 let path = tmp.path().to_path_buf();
1176 std::env::set_var("AGENTMUX_HOME_OVERRIDE", &path);
1177 let _guard = HomeOverrideGuard { _tmp: tmp, _lock: lock };
1178 f(&path);
1179 // _guard drops here, removing the env var even if f panicked
1180 // (the panic still propagates after Drop runs).
1181 }
1182
1183 /// Helper: clear AGENTMUX_CHANNEL inside an existing
1184 /// with_home_override block to test pure mode-default resolution.
1185 /// Channel resolution reads the live env var, so individual tests
1186 /// must clear it to avoid leakage from sibling tests running
1187 /// concurrently inside the same process (TEST_ENV_LOCK serializes
1188 /// HOME_OVERRIDE but the channel var is a separate axis).
1189 fn clear_channel_env() {
1190 std::env::remove_var("AGENTMUX_CHANNEL");
1191 }
1192
1193 /// A10 compatibility guarantee. srv's old `get_mux_data_dir` read
1194 /// AGENTMUX_DATA_HOME; this module's resolver read AGENTMUX_HOME_OVERRIDE.
1195 /// Consolidating onto one resolver must not move where a live install
1196 /// finds its data, so BOTH are honoured — and an install that only ever
1197 /// set AGENTMUX_DATA_HOME must still resolve to exactly that path.
1198 #[test]
1199 fn agentmux_root_honours_srvs_env_var_for_existing_installs() {
1200 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1201 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
1202 let tmp = TempDir::new().expect("tempdir");
1203 std::env::set_var("AGENTMUX_DATA_HOME", tmp.path());
1204 let got = agentmux_root().expect("root resolves");
1205 std::env::remove_var("AGENTMUX_DATA_HOME");
1206 assert_eq!(got, tmp.path(), "AGENTMUX_DATA_HOME must still win");
1207 }
1208
1209 /// With neither var set the answer is `~/.agentmux` — unchanged from both
1210 /// pre-consolidation resolvers, which is the case every default install is
1211 /// in. Asserted against dirs::home_dir() rather than a literal so this
1212 /// fails if the default layout ever moves.
1213 #[test]
1214 fn agentmux_root_defaults_to_home_dot_agentmux() {
1215 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1216 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
1217 std::env::remove_var("AGENTMUX_DATA_HOME");
1218 let got = agentmux_root().expect("root resolves");
1219 let want = dirs::home_dir().expect("home").join(".agentmux");
1220 assert_eq!(got, want);
1221 }
1222
1223 /// Precedence is documented, not accidental: HOME_OVERRIDE is this
1224 /// module's test hook and wins, so a test that sets it is not silently
1225 /// overridden by an AGENTMUX_DATA_HOME leaked from the ambient
1226 /// environment.
1227 #[test]
1228 fn agentmux_root_prefers_home_override_over_data_home() {
1229 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1230 let a = TempDir::new().expect("tempdir");
1231 let b = TempDir::new().expect("tempdir");
1232 std::env::set_var("AGENTMUX_HOME_OVERRIDE", a.path());
1233 std::env::set_var("AGENTMUX_DATA_HOME", b.path());
1234 let got = agentmux_root().expect("root resolves");
1235 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
1236 std::env::remove_var("AGENTMUX_DATA_HOME");
1237 assert_eq!(got, a.path());
1238 }
1239
1240 /// An empty value is not a path. Both resolvers skipped empty strings
1241 /// before; the consolidated one must too, or `AGENTMUX_DATA_HOME=```
1242 /// in a shell profile would redirect every install to "".
1243 #[test]
1244 fn agentmux_root_ignores_empty_env_values() {
1245 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1246 std::env::set_var("AGENTMUX_HOME_OVERRIDE", "");
1247 std::env::set_var("AGENTMUX_DATA_HOME", "");
1248 let got = agentmux_root().expect("root resolves");
1249 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
1250 std::env::remove_var("AGENTMUX_DATA_HOME");
1251 assert_eq!(got, dirs::home_dir().expect("home").join(".agentmux"));
1252 }
1253
1254 #[test]
1255 fn installed_paths_under_default_channel() {
1256 with_home_override(|root| {
1257 clear_channel_env();
1258 let ver = "0.41.0";
1259 let p = DataPaths::resolve(ver, &RuntimeMode::Installed).unwrap();
1260 // Channel-level root (instance_dir).
1261 assert_eq!(p.channel, "stable");
1262 let ch = root.join("channels").join("stable");
1263 assert_eq!(p.instance_dir, ch);
1264 // Version-scoped dirs live under versions/<ver>/.
1265 let vd = ch.join("versions").join(ver);
1266 assert_eq!(p.data_dir, vd.join("data"));
1267 assert_eq!(p.logs_dir, vd.join("logs"));
1268 assert_eq!(p.cef_cache_dir, vd.join("cef-cache"));
1269 assert_eq!(p.instance_runtime_dir, vd.join("runtime"));
1270 // Channel-wide dirs stay at instance_dir level.
1271 assert_eq!(p.config_dir, ch.join("config"));
1272 assert_eq!(p.agents_dir, ch.join("agents"));
1273 assert_eq!(p.shared_dir, root.join("shared"));
1274 });
1275 }
1276
1277 #[test]
1278 fn two_installed_versions_have_distinct_data_dirs() {
1279 with_home_override(|root| {
1280 clear_channel_env();
1281 let p1 = DataPaths::resolve("0.40.2", &RuntimeMode::Installed).unwrap();
1282 let p2 = DataPaths::resolve("0.41.0", &RuntimeMode::Installed).unwrap();
1283 // Same channel root — agents and config are shared.
1284 assert_eq!(p1.instance_dir, p2.instance_dir);
1285 assert_eq!(p1.agents_dir, p2.agents_dir);
1286 assert_eq!(p1.config_dir, p2.config_dir);
1287 // Different versioned dirs — concurrent writes are safe.
1288 assert_ne!(p1.data_dir, p2.data_dir);
1289 assert_ne!(p1.cef_cache_dir, p2.cef_cache_dir);
1290 assert_ne!(p1.instance_runtime_dir, p2.instance_runtime_dir);
1291 // Paths contain the version string.
1292 assert!(p1.data_dir.to_string_lossy().contains("0.40.2"));
1293 assert!(p2.data_dir.to_string_lossy().contains("0.41.0"));
1294 let _ = root; // suppress unused warning
1295 });
1296 }
1297
1298 #[test]
1299 fn the_wan_identity_dir_is_channel_wide_not_per_version() {
1300 // An upgrade must keep the same WAN instance and agent keys
1301 // (SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md §2.2).
1302 with_home_override(|root| {
1303 clear_channel_env();
1304 let p1 = DataPaths::resolve("0.40.2", &RuntimeMode::Installed).unwrap();
1305 let p2 = DataPaths::resolve("0.41.0", &RuntimeMode::Installed).unwrap();
1306 assert_eq!(p1.wan_identity_dir(), root.join("channels").join("stable").join("wan-identity"));
1307 assert_eq!(p1.wan_identity_dir(), p2.wan_identity_dir());
1308 assert!(!p1.wan_identity_dir().starts_with(&p1.data_dir));
1309 });
1310 }
1311
1312 #[test]
1313 fn home_dir_resolves_to_root() {
1314 // The agentmux root (~/.agentmux/ or AGENTMUX_HOME_OVERRIDE)
1315 // is exposed via DataPaths.home_dir for legacy account-wide
1316 // state like the launcher's config.toml. Resolve in both
1317 // installed and dev modes; both should point at the same root.
1318 with_home_override(|root| {
1319 clear_channel_env();
1320 let inst = DataPaths::resolve("0.33.641", &RuntimeMode::Installed).unwrap();
1321 assert_eq!(inst.home_dir, root);
1322 let dev = DataPaths::resolve(
1323 "0.33.641",
1324 &RuntimeMode::Dev {
1325 branch: "main".into(),
1326 clone_id: None,
1327 },
1328 )
1329 .unwrap();
1330 assert_eq!(dev.home_dir, root);
1331 });
1332 }
1333
1334 #[test]
1335 fn portable_paths_match_installed() {
1336 with_home_override(|root| {
1337 clear_channel_env();
1338 let inst = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
1339 let port = DataPaths::resolve("0.33.639", &RuntimeMode::Portable).unwrap();
1340 // Portable + Installed share a default channel (no env
1341 // override → both fall through to BUILD_CHANNEL_DEFAULT),
1342 // so their data dirs are the same. Multi-instance
1343 // isolation is now channel-keyed: if you want two
1344 // independent portables, override AGENTMUX_CHANNEL on one.
1345 assert_eq!(inst.channel, port.channel);
1346 assert_eq!(inst.instance_dir, port.instance_dir);
1347 assert_eq!(inst.data_dir, port.data_dir);
1348 // shared/ is mode-independent.
1349 assert_eq!(inst.shared_dir, root.join("shared"));
1350 assert_eq!(port.shared_dir, root.join("shared"));
1351 });
1352 }
1353
1354 #[test]
1355 fn dev_paths_under_branch_and_clone_id() {
1356 // Two clones of the same branch must resolve to distinct
1357 // instance dirs when clone_id is supplied. Same branch +
1358 // different clone_id → different paths → distinct lockfile
1359 // and pipe namespaces downstream.
1360 with_home_override(|root| {
1361 clear_channel_env();
1362 let a = DataPaths::resolve(
1363 "0.39.0",
1364 &RuntimeMode::Dev {
1365 branch: "main".into(),
1366 clone_id: Some("aaaaaaaa00000000".into()),
1367 },
1368 )
1369 .unwrap();
1370 let b = DataPaths::resolve(
1371 "0.39.0",
1372 &RuntimeMode::Dev {
1373 branch: "main".into(),
1374 clone_id: Some("bbbbbbbb00000000".into()),
1375 },
1376 )
1377 .unwrap();
1378 assert_eq!(
1379 a.instance_dir,
1380 root.join("dev").join("main").join("aaaaaaaa00000000")
1381 );
1382 assert_eq!(
1383 b.instance_dir,
1384 root.join("dev").join("main").join("bbbbbbbb00000000")
1385 );
1386 assert_ne!(a.instance_dir, b.instance_dir);
1387 assert_eq!(a.channel, "dev-main-aaaaaaaa00000000");
1388 assert_eq!(b.channel, "dev-main-bbbbbbbb00000000");
1389 });
1390 }
1391
1392 #[test]
1393 fn dev_paths_legacy_two_level_when_clone_id_none() {
1394 // Backward compat: a Dev variant without clone_id (e.g.
1395 // constructed by an older launcher binary, or by the
1396 // env-string parser) MUST land at the pre-PR two-level dev
1397 // path so existing in-flight dev sessions don't lose their
1398 // state on first launch after the upgrade.
1399 with_home_override(|root| {
1400 clear_channel_env();
1401 let p = DataPaths::resolve(
1402 "0.39.0",
1403 &RuntimeMode::Dev {
1404 branch: "main".into(),
1405 clone_id: None,
1406 },
1407 )
1408 .unwrap();
1409 assert_eq!(p.instance_dir, root.join("dev").join("main"));
1410 assert_eq!(p.channel, "dev-main");
1411 });
1412 }
1413
1414 #[test]
1415 fn dev_paths_under_dev_branch() {
1416 with_home_override(|root| {
1417 clear_channel_env();
1418 let mode = RuntimeMode::Dev {
1419 branch: "main".into(),
1420 clone_id: None,
1421 };
1422 let p = DataPaths::resolve("0.33.639", &mode).unwrap();
1423 // Dev mode default: on-disk path stays under dev/<branch>/
1424 // (unchanged from Phase 1), channel name is "dev-<branch>"
1425 // for diagnostics. The two diverge intentionally — see
1426 // resolve_channel_and_dir doc.
1427 assert_eq!(p.channel, "dev-main");
1428 assert_eq!(p.instance_dir, root.join("dev").join("main"));
1429 assert_eq!(p.data_dir, root.join("dev").join("main").join("data"));
1430 assert_eq!(p.shared_dir, root.join("shared"));
1431 });
1432 }
1433
1434 #[test]
1435 fn env_override_redirects_any_mode_under_channels() {
1436 // AGENTMUX_CHANNEL is absolute precedence. Even Dev mode,
1437 // which would otherwise land at dev/<branch>/, lands under
1438 // channels/<override>/ when the env is set. This is the
1439 // "test a hot-fix build against the live stable data" path
1440 // from SPEC_DATA_CHANNELS_2026_05_24.md §2.2.
1441 with_home_override(|root| {
1442 std::env::set_var("AGENTMUX_CHANNEL", "experiment");
1443 // Cleanup via Drop so a test panic doesn't leak it.
1444 struct ChannelGuard;
1445 impl Drop for ChannelGuard {
1446 fn drop(&mut self) {
1447 std::env::remove_var("AGENTMUX_CHANNEL");
1448 }
1449 }
1450 let _g = ChannelGuard;
1451
1452 let inst = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
1453 assert_eq!(inst.channel, "experiment");
1454 assert_eq!(inst.instance_dir, root.join("channels").join("experiment"));
1455
1456 let port = DataPaths::resolve("0.33.639", &RuntimeMode::Portable).unwrap();
1457 assert_eq!(port.channel, "experiment");
1458 assert_eq!(port.instance_dir, root.join("channels").join("experiment"));
1459
1460 let dev = DataPaths::resolve(
1461 "0.33.639",
1462 &RuntimeMode::Dev { branch: "main".into(), clone_id: None },
1463 )
1464 .unwrap();
1465 // Override beats the dev/<branch>/ default — channel name
1466 // matches the override, path lands under channels/, not dev/.
1467 assert_eq!(dev.channel, "experiment");
1468 assert_eq!(dev.instance_dir, root.join("channels").join("experiment"));
1469 });
1470 }
1471
1472 #[test]
1473 fn env_override_rejects_unsafe_or_reserved_names() {
1474 with_home_override(|_root| {
1475 // Reserved (would collide with sibling dirs / inner dirs).
1476 for bad in ["shared", "snapshots", "dev", "versions", "channels", "runtime"] {
1477 std::env::set_var("AGENTMUX_CHANNEL", bad);
1478 let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
1479 std::env::remove_var("AGENTMUX_CHANNEL");
1480 assert!(
1481 r.is_err(),
1482 "AGENTMUX_CHANNEL={:?} should be rejected as reserved",
1483 bad
1484 );
1485 }
1486
1487 // Path-unsafe (traversal, separators, Windows-reserved).
1488 // NUL not tested here — Windows' WinAPI rejects NUL in
1489 // env-var values at the syscall level, so `set_var` would
1490 // panic before our sanitizer runs. NUL rejection is
1491 // covered by the direct-call sanitize_path_segment path
1492 // in identity_dir_rejects_unsafe_segments.
1493 for bad in ["..", ".", "a/b", "a\\b", "C:foo", "a*b"] {
1494 std::env::set_var("AGENTMUX_CHANNEL", bad);
1495 let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
1496 std::env::remove_var("AGENTMUX_CHANNEL");
1497 assert!(
1498 r.is_err(),
1499 "AGENTMUX_CHANNEL={:?} should be rejected as path-unsafe",
1500 bad
1501 );
1502 }
1503
1504 // Empty string treated as "not set" — falls through to
1505 // mode-based default. Documents the behavior so a
1506 // `.env`-set empty value doesn't surprise.
1507 std::env::set_var("AGENTMUX_CHANNEL", "");
1508 let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
1509 std::env::remove_var("AGENTMUX_CHANNEL");
1510 assert!(r.is_ok(), "empty AGENTMUX_CHANNEL should fall through to default");
1511 assert_eq!(r.unwrap().channel, "stable");
1512 });
1513 }
1514
1515 #[test]
1516 fn env_override_default_is_synonym_for_stable() {
1517 with_home_override(|root| {
1518 std::env::set_var("AGENTMUX_CHANNEL", "default");
1519 let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
1520 std::env::remove_var("AGENTMUX_CHANNEL");
1521 let r = r.unwrap();
1522 // "default" maps to "stable" per spec §7.5; on-disk path
1523 // is channels/stable/, not channels/default/.
1524 assert_eq!(r.channel, "stable");
1525 assert_eq!(r.instance_dir, root.join("channels").join("stable"));
1526 });
1527 }
1528
1529 #[test]
1530 fn channel_name_length_capped_at_64() {
1531 with_home_override(|_root| {
1532 // 64 chars OK, 65 rejected. The cap is for UI sanity, not
1533 // security — channel names show up in logs and the
1534 // launcher splash.
1535 let ok = "a".repeat(64);
1536 std::env::set_var("AGENTMUX_CHANNEL", &ok);
1537 let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
1538 std::env::remove_var("AGENTMUX_CHANNEL");
1539 assert!(r.is_ok(), "64-char channel should be accepted");
1540
1541 let too_long = "a".repeat(65);
1542 std::env::set_var("AGENTMUX_CHANNEL", &too_long);
1543 let r = DataPaths::resolve("0.33.639", &RuntimeMode::Installed);
1544 std::env::remove_var("AGENTMUX_CHANNEL");
1545 assert!(r.is_err(), "65-char channel should be rejected");
1546 });
1547 }
1548
1549 #[test]
1550 fn dev_branch_traversal_via_runtime_mode_still_rejected() {
1551 // Dev mode resolution sanitizes the branch via the same
1552 // sanitize_path_segment as before — channel rename doesn't
1553 // weaken the traversal-safety guarantees. Reproduces the
1554 // pre-channel test for parity.
1555 with_home_override(|_root| {
1556 clear_channel_env();
1557 let r = DataPaths::resolve(
1558 "0.33.639",
1559 &RuntimeMode::Dev { branch: "..".into(), clone_id: None },
1560 );
1561 assert!(r.is_err());
1562 let r = DataPaths::resolve(
1563 "0.33.639",
1564 &RuntimeMode::Dev { branch: "foo/bar".into(), clone_id: None },
1565 );
1566 assert!(r.is_err());
1567 });
1568 }
1569
1570 #[test]
1571 fn identity_dir_rejects_unsafe_segments() {
1572 // bundle_id flows from auth.start request bodies into
1573 // create_dir_all. Without sanitization a crafted id would
1574 // escape the identities root. The function must return None
1575 // for traversal attempts, separator-bearing segments, and
1576 // Windows-reserved characters. codex P1 follow-up on #981.
1577 with_home_override(|_root| {
1578 let p = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
1579
1580 // Happy path — a normal UUID-shaped id resolves.
1581 assert!(p.identity_dir("abc-123-uuid").is_some());
1582
1583 // Path traversal.
1584 assert_eq!(p.identity_dir(".."), None);
1585 assert_eq!(p.identity_dir("."), None);
1586 assert_eq!(p.identity_dir("../../../etc"), None);
1587 assert_eq!(p.identity_dir("a/b"), None);
1588 assert_eq!(p.identity_dir("a\\b"), None);
1589
1590 // Empty / whitespace-only.
1591 assert_eq!(p.identity_dir(""), None);
1592 assert_eq!(p.identity_dir(" "), None);
1593
1594 // Windows-reserved characters.
1595 assert_eq!(p.identity_dir("C:foo"), None);
1596 assert_eq!(p.identity_dir("foo*bar"), None);
1597 assert_eq!(p.identity_dir("foo?bar"), None);
1598 assert_eq!(p.identity_dir("with\0nul"), None);
1599 });
1600 }
1601
1602 #[test]
1603 fn provider_auth_dir_is_shared_and_channel_independent() {
1604 // The DEFAULT provider auth lives under shared_dir, so it resolves to the
1605 // SAME path regardless of channel / version / mode — the structural fix
1606 // for the per-channel "validate-spin" regression. It must NOT live under
1607 // the per-channel config dir (which is where the regression put it).
1608 // Retro: docs/retro/retro-provider-auth-isolation-regression-2026-06-05.md
1609 with_home_override(|_root| {
1610 clear_channel_env();
1611 let installed = DataPaths::resolve("0.42.0", &RuntimeMode::Installed).unwrap();
1612 let dev = DataPaths::resolve(
1613 "0.42.0",
1614 &RuntimeMode::Dev { branch: "some-branch".into(), clone_id: None },
1615 )
1616 .unwrap();
1617
1618 let a = installed.provider_auth_dir("claude");
1619 assert_eq!(
1620 a,
1621 dev.provider_auth_dir("claude"),
1622 "provider auth dir must not vary by channel / mode (instance-independent)"
1623 );
1624 assert!(
1625 a.ends_with("shared/providers/claude"),
1626 "provider auth dir must live under shared/providers/: {a:?}"
1627 );
1628 assert!(
1629 !a.starts_with(&installed.config_dir),
1630 "provider auth dir must NOT be under the per-channel config dir"
1631 );
1632 });
1633 }
1634
1635 /// RAII guard clearing `AGENTMUX_ISOLATED_AUTH` on drop, even on panic —
1636 /// mirrors `HomeOverrideGuard` above.
1637 struct IsolatedAuthGuard;
1638 impl Drop for IsolatedAuthGuard {
1639 fn drop(&mut self) {
1640 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
1641 }
1642 }
1643
1644 #[test]
1645 fn identities_dir_is_shared_on_stable_channel() {
1646 // Auth is shared on every channel by default
1647 // (SPEC_SHARED_AUTH_ACROSS_CHANNELS_2026_10_03.md); this pins the real
1648 // release channel. AGENTMUX_CHANNEL is set to "stable" explicitly
1649 // (mirroring what a real host/srv process always has via from_env(),
1650 // per to_env_vars()) rather than left unset, so the test names the
1651 // "stable" channel, not the "channel unknown"
1652 // fallback covered by identities_dir_is_shared_when_channel_unset.
1653 with_home_override(|_root| {
1654 clear_channel_env();
1655 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
1656 let installed = DataPaths::resolve("0.42.0", &RuntimeMode::Installed).unwrap();
1657 assert_eq!(installed.channel, "stable");
1658 std::env::set_var("AGENTMUX_CHANNEL", "stable");
1659
1660 assert!(installed.identities_dir().ends_with("shared/identities"));
1661 std::env::remove_var("AGENTMUX_CHANNEL");
1662 });
1663 }
1664
1665 #[test]
1666 fn identities_dir_is_shared_by_default_on_a_non_stable_channel() {
1667 // SPEC_SHARED_AUTH_ACROSS_CHANNELS_2026_10_03.md: a task-dev branch, a
1668 // task-package local build and a custom AGENTMUX_CHANNEL all share the
1669 // global identities with NO explicit AGENTMUX_ISOLATED_AUTH set. This
1670 // was the opposite until 2026-10-03.
1671 with_home_override(|_root| {
1672 clear_channel_env();
1673 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
1674 let dev = DataPaths::resolve(
1675 "0.42.0",
1676 &RuntimeMode::Dev { branch: "some-branch".into(), clone_id: None },
1677 )
1678 .unwrap();
1679 std::env::set_var("AGENTMUX_CHANNEL", &dev.channel);
1680
1681 assert!(dev.identities_dir().ends_with("shared/identities"));
1682 assert!(
1683 dev.identities_dir().starts_with(&dev.shared_dir),
1684 "a shared-by-default identities_dir lives under the global shared_dir"
1685 );
1686 std::env::remove_var("AGENTMUX_CHANNEL");
1687 });
1688 }
1689
1690 #[test]
1691 fn identities_dir_is_shared_when_channel_unset() {
1692 // Conservative fallback: no AGENTMUX_CHANNEL in the process env
1693 // at all (e.g. a bare `cargo test`/`cargo run` outside the
1694 // launcher's from_env() chain) — stay global rather than guess.
1695 with_home_override(|_root| {
1696 clear_channel_env();
1697 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
1698 let dev = DataPaths::resolve(
1699 "0.42.0",
1700 &RuntimeMode::Dev { branch: "some-branch".into(), clone_id: None },
1701 )
1702 .unwrap();
1703 // AGENTMUX_CHANNEL deliberately left unset here, unlike the
1704 // non-stable-channel test above.
1705
1706 assert!(dev.identities_dir().ends_with("shared/identities"));
1707 });
1708 }
1709
1710 #[test]
1711 fn identities_dir_is_per_channel_when_isolated_auth_explicitly_set() {
1712 with_home_override(|_root| {
1713 clear_channel_env();
1714 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
1715 let _guard = IsolatedAuthGuard;
1716
1717 let dev_a = DataPaths::resolve(
1718 "0.42.0",
1719 &RuntimeMode::Dev { branch: "branch-a".into(), clone_id: None },
1720 )
1721 .unwrap();
1722 let dev_b = DataPaths::resolve(
1723 "0.42.0",
1724 &RuntimeMode::Dev { branch: "branch-b".into(), clone_id: None },
1725 )
1726 .unwrap();
1727
1728 assert_ne!(
1729 dev_a.identities_dir(),
1730 dev_b.identities_dir(),
1731 "isolated identities_dir must differ per channel"
1732 );
1733 assert_eq!(dev_a.identities_dir(), dev_a.instance_dir.join("identities"));
1734 assert!(
1735 !dev_a.identities_dir().starts_with(&dev_a.shared_dir),
1736 "isolated identities_dir must NOT live under the global shared_dir"
1737 );
1738 });
1739 }
1740
1741 #[test]
1742 fn identities_dir_is_shared_when_isolated_auth_explicitly_disabled_on_non_stable_channel() {
1743 // The escape hatch: AGENTMUX_ISOLATED_AUTH=0 overrides the new
1744 // channel-based default back to global sharing, even on a
1745 // non-stable channel.
1746 with_home_override(|_root| {
1747 clear_channel_env();
1748 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "0");
1749 let _guard = IsolatedAuthGuard;
1750
1751 let dev = DataPaths::resolve(
1752 "0.42.0",
1753 &RuntimeMode::Dev { branch: "some-branch".into(), clone_id: None },
1754 )
1755 .unwrap();
1756 std::env::set_var("AGENTMUX_CHANNEL", &dev.channel);
1757
1758 assert!(dev.identities_dir().ends_with("shared/identities"));
1759 std::env::remove_var("AGENTMUX_CHANNEL");
1760 });
1761 }
1762
1763 #[test]
1764 fn isolated_auth_reason_classifies_all_three_states() {
1765 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1766 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
1767 clear_channel_env();
1768
1769 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
1770 assert_eq!(isolated_auth_reason(), IsolatedAuthReason::ExplicitOptIn);
1771 assert!(isolated_auth_reason().is_isolated());
1772
1773 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "0");
1774 assert_eq!(isolated_auth_reason(), IsolatedAuthReason::ExplicitOptOut);
1775 assert!(!isolated_auth_reason().is_isolated());
1776
1777 // No override: shared on EVERY channel, not only `stable`.
1778 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
1779 for channel in ["dev-some-branch", "local-main-b28b7a-051fbf53", "stable", "custom-channel"] {
1780 std::env::set_var("AGENTMUX_CHANNEL", channel);
1781 assert_eq!(isolated_auth_reason(), IsolatedAuthReason::DefaultShared, "{channel}");
1782 assert!(!isolated_auth_reason().is_isolated(), "{channel}");
1783 }
1784
1785 clear_channel_env();
1786 assert_eq!(isolated_auth_reason(), IsolatedAuthReason::DefaultShared);
1787 assert!(!isolated_auth_reason().is_isolated());
1788 }
1789
1790 #[test]
1791 fn isolated_auth_reason_fails_safe_on_a_malformed_value_on_a_non_stable_channel() {
1792 // Anything other than exactly "1" is an explicit opt-out (global),
1793 // matching the original `.map(|v| v == "1")` rule for every non-"1"
1794 // value (#2431): a misspelled value must never isolate a channel.
1795 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1796 clear_channel_env();
1797 std::env::set_var("AGENTMUX_CHANNEL", "dev-some-branch");
1798
1799 for malformed in ["false", "no", "TRUE", "2", ""] {
1800 std::env::set_var("AGENTMUX_ISOLATED_AUTH", malformed);
1801 assert_eq!(
1802 isolated_auth_reason(),
1803 IsolatedAuthReason::ExplicitOptOut,
1804 "AGENTMUX_ISOLATED_AUTH={malformed:?} on a non-stable channel must fail safe to global, not isolate"
1805 );
1806 assert!(!isolated_auth_reason().is_isolated());
1807 }
1808
1809 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
1810 clear_channel_env();
1811 }
1812
1813 /// RAII guard clearing `AGENTMUX_ISOLATED_SETTINGS` on drop, even on
1814 /// panic — mirrors `IsolatedAuthGuard` above.
1815 struct IsolatedSettingsGuard;
1816 impl Drop for IsolatedSettingsGuard {
1817 fn drop(&mut self) {
1818 std::env::remove_var("AGENTMUX_ISOLATED_SETTINGS");
1819 }
1820 }
1821
1822 #[test]
1823 fn isolated_settings_reason_classifies_all_four_states() {
1824 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1825 std::env::remove_var("AGENTMUX_ISOLATED_SETTINGS");
1826 clear_channel_env();
1827 let _guard = IsolatedSettingsGuard;
1828
1829 std::env::set_var("AGENTMUX_ISOLATED_SETTINGS", "1");
1830 assert_eq!(isolated_settings_reason(), IsolatedSettingsReason::ExplicitOptIn);
1831 assert!(isolated_settings_reason().is_isolated());
1832
1833 std::env::set_var("AGENTMUX_ISOLATED_SETTINGS", "0");
1834 assert_eq!(isolated_settings_reason(), IsolatedSettingsReason::ExplicitOptOut);
1835 assert!(!isolated_settings_reason().is_isolated());
1836
1837 std::env::remove_var("AGENTMUX_ISOLATED_SETTINGS");
1838 std::env::set_var("AGENTMUX_CHANNEL", "local-some-branch-abc123-1");
1839 assert_eq!(isolated_settings_reason(), IsolatedSettingsReason::ChannelDefaultIsolated);
1840 assert!(isolated_settings_reason().is_isolated());
1841
1842 std::env::set_var("AGENTMUX_CHANNEL", "stable");
1843 assert_eq!(isolated_settings_reason(), IsolatedSettingsReason::ChannelDefaultGlobal);
1844 assert!(!isolated_settings_reason().is_isolated());
1845
1846 clear_channel_env();
1847 assert_eq!(isolated_settings_reason(), IsolatedSettingsReason::ChannelDefaultGlobal);
1848 assert!(!isolated_settings_reason().is_isolated());
1849 }
1850
1851 #[test]
1852 fn isolated_settings_reason_fails_safe_on_a_malformed_value_on_a_non_stable_channel() {
1853 // Same fail-safe rule as isolated_auth_reason's equivalent test
1854 // (reagentx P2 on PR #2431) — a typo'd opt-out must land on
1855 // ExplicitOptOut (global), not silently isolate.
1856 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1857 clear_channel_env();
1858 std::env::set_var("AGENTMUX_CHANNEL", "dev-some-branch");
1859 let _guard = IsolatedSettingsGuard;
1860
1861 for malformed in ["false", "no", "TRUE", "2", ""] {
1862 std::env::set_var("AGENTMUX_ISOLATED_SETTINGS", malformed);
1863 assert_eq!(
1864 isolated_settings_reason(),
1865 IsolatedSettingsReason::ExplicitOptOut,
1866 "AGENTMUX_ISOLATED_SETTINGS={malformed:?} on a non-stable channel must fail safe to global, not isolate"
1867 );
1868 assert!(!isolated_settings_reason().is_isolated());
1869 }
1870
1871 std::env::remove_var("AGENTMUX_ISOLATED_SETTINGS");
1872 clear_channel_env();
1873 }
1874
1875 #[test]
1876 fn isolated_settings_and_isolated_auth_are_independent_flags() {
1877 // Open question §1 in the spec: these must not accidentally share
1878 // state — setting one must not affect the other's resolution.
1879 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1880 clear_channel_env();
1881 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
1882 std::env::remove_var("AGENTMUX_ISOLATED_SETTINGS");
1883 std::env::set_var("AGENTMUX_CHANNEL", "dev-some-branch");
1884 let _guard_a = IsolatedAuthGuard;
1885 let _guard_s = IsolatedSettingsGuard;
1886
1887 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "0");
1888 assert!(!isolated_auth_enabled());
1889 assert!(
1890 isolated_settings_enabled(),
1891 "AGENTMUX_ISOLATED_AUTH=0 must not disable settings isolation"
1892 );
1893
1894 // Auth is shared by default now, so isolate it explicitly to see that
1895 // turning settings isolation off leaves it alone.
1896 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
1897 std::env::set_var("AGENTMUX_ISOLATED_SETTINGS", "0");
1898 assert!(!isolated_settings_enabled());
1899 assert!(
1900 isolated_auth_enabled(),
1901 "AGENTMUX_ISOLATED_SETTINGS=0 must not disable auth isolation"
1902 );
1903
1904 std::env::remove_var("AGENTMUX_ISOLATED_SETTINGS");
1905 clear_channel_env();
1906 }
1907
1908 /// RAII guard clearing `AGENTMUX_ISOLATED_MUXBUS` on drop, even on
1909 /// panic — mirrors `IsolatedSettingsGuard` above.
1910 struct IsolatedMuxbusReconnectGuard;
1911 impl Drop for IsolatedMuxbusReconnectGuard {
1912 fn drop(&mut self) {
1913 std::env::remove_var("AGENTMUX_ISOLATED_MUXBUS");
1914 }
1915 }
1916
1917 #[test]
1918 fn isolated_muxbus_reconnect_reason_classifies_all_four_states() {
1919 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1920 std::env::remove_var("AGENTMUX_ISOLATED_MUXBUS");
1921 clear_channel_env();
1922 let _guard = IsolatedMuxbusReconnectGuard;
1923
1924 std::env::set_var("AGENTMUX_ISOLATED_MUXBUS", "1");
1925 assert_eq!(
1926 isolated_muxbus_reconnect_reason(),
1927 IsolatedMuxbusReconnectReason::ExplicitOptIn
1928 );
1929 assert!(isolated_muxbus_reconnect_reason().is_isolated());
1930
1931 std::env::set_var("AGENTMUX_ISOLATED_MUXBUS", "0");
1932 assert_eq!(
1933 isolated_muxbus_reconnect_reason(),
1934 IsolatedMuxbusReconnectReason::ExplicitOptOut
1935 );
1936 assert!(!isolated_muxbus_reconnect_reason().is_isolated());
1937
1938 std::env::remove_var("AGENTMUX_ISOLATED_MUXBUS");
1939 std::env::set_var("AGENTMUX_CHANNEL", "local-some-branch-abc123-1");
1940 assert_eq!(
1941 isolated_muxbus_reconnect_reason(),
1942 IsolatedMuxbusReconnectReason::ChannelDefaultIsolated
1943 );
1944 assert!(isolated_muxbus_reconnect_reason().is_isolated());
1945
1946 std::env::set_var("AGENTMUX_CHANNEL", "stable");
1947 assert_eq!(
1948 isolated_muxbus_reconnect_reason(),
1949 IsolatedMuxbusReconnectReason::ChannelDefaultGlobal
1950 );
1951 assert!(!isolated_muxbus_reconnect_reason().is_isolated());
1952
1953 clear_channel_env();
1954 assert_eq!(
1955 isolated_muxbus_reconnect_reason(),
1956 IsolatedMuxbusReconnectReason::ChannelDefaultGlobal
1957 );
1958 assert!(!isolated_muxbus_reconnect_reason().is_isolated());
1959 }
1960
1961 #[test]
1962 fn isolated_muxbus_reconnect_reason_exempts_task_dev_channels() {
1963 // reagentx P1 on PR #3248 (both rounds): a `dev-<branch>` channel
1964 // (RuntimeMode::Dev's channel format) must NOT be isolated by
1965 // default — it's stable/per-branch, not a randomized per-build
1966 // local-package channel, so it never has the "never-before-seen
1967 // app to Keychain every time" problem this flag targets, and
1968 // isolating it anyway silently drops automatic MuxBus reconnect
1969 // (and WAN notifications) for the entire `task dev` session.
1970 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1971 std::env::remove_var("AGENTMUX_ISOLATED_MUXBUS");
1972 clear_channel_env();
1973 let _guard = IsolatedMuxbusReconnectGuard;
1974
1975 for dev_channel in ["dev-main", "dev-some-branch", "dev-some-branch-a1b2c3d4"] {
1976 std::env::set_var("AGENTMUX_CHANNEL", dev_channel);
1977 assert_eq!(
1978 isolated_muxbus_reconnect_reason(),
1979 IsolatedMuxbusReconnectReason::ChannelDefaultGlobal,
1980 "dev channel {dev_channel:?} must NOT be isolated by default"
1981 );
1982 assert!(!isolated_muxbus_reconnect_reason().is_isolated());
1983 }
1984
1985 // A local-package channel (no "dev-" prefix) is still isolated —
1986 // the exemption is specific to "dev-", not "anything but stable".
1987 std::env::set_var("AGENTMUX_CHANNEL", "local-main-abc123-9");
1988 assert_eq!(
1989 isolated_muxbus_reconnect_reason(),
1990 IsolatedMuxbusReconnectReason::ChannelDefaultIsolated
1991 );
1992 assert!(isolated_muxbus_reconnect_reason().is_isolated());
1993
1994 clear_channel_env();
1995 }
1996
1997 #[test]
1998 fn isolated_muxbus_reconnect_reason_fails_safe_on_a_malformed_value_on_a_non_stable_channel() {
1999 // Same fail-safe rule as isolated_auth_reason's/isolated_settings_reason's
2000 // equivalent tests (reagentx P2 on PR #2431) — a typo'd opt-out must
2001 // land on ExplicitOptOut (reconnect stays on), not silently isolate.
2002 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2003 clear_channel_env();
2004 std::env::set_var("AGENTMUX_CHANNEL", "dev-some-branch");
2005 let _guard = IsolatedMuxbusReconnectGuard;
2006
2007 for malformed in ["false", "no", "TRUE", "2", ""] {
2008 std::env::set_var("AGENTMUX_ISOLATED_MUXBUS", malformed);
2009 assert_eq!(
2010 isolated_muxbus_reconnect_reason(),
2011 IsolatedMuxbusReconnectReason::ExplicitOptOut,
2012 "AGENTMUX_ISOLATED_MUXBUS={malformed:?} on a non-stable channel must fail safe to global, not isolate"
2013 );
2014 assert!(!isolated_muxbus_reconnect_reason().is_isolated());
2015 }
2016
2017 std::env::remove_var("AGENTMUX_ISOLATED_MUXBUS");
2018 clear_channel_env();
2019 }
2020
2021 #[test]
2022 fn isolated_muxbus_reconnect_is_independent_of_auth_and_settings_isolation() {
2023 // Same independence guarantee as
2024 // isolated_settings_and_isolated_auth_are_independent_flags — a
2025 // separate flag on purpose (see the production doc comment).
2026 let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2027 clear_channel_env();
2028 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
2029 std::env::remove_var("AGENTMUX_ISOLATED_SETTINGS");
2030 std::env::remove_var("AGENTMUX_ISOLATED_MUXBUS");
2031 // NOT "dev-some-branch": muxbus reconnect deliberately exempts
2032 // dev-* channels (see isolated_muxbus_reconnect_reason's doc
2033 // comment) while auth/settings do not — a dev channel would make
2034 // this independence test's own channel-default assertions below
2035 // wrong for muxbus specifically, unrelated to what this test
2036 // actually checks (that the three env-var overrides don't leak
2037 // into each other). A local-package-shaped channel isolates settings
2038 // and the muxbus reconnect by their channel default; auth is opted in
2039 // explicitly below, since it is shared by default.
2040 std::env::set_var("AGENTMUX_CHANNEL", "local-some-branch-abc123-1");
2041 let _guard_a = IsolatedAuthGuard;
2042 let _guard_s = IsolatedSettingsGuard;
2043 let _guard_m = IsolatedMuxbusReconnectGuard;
2044
2045 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
2046 std::env::set_var("AGENTMUX_ISOLATED_MUXBUS", "0");
2047 assert!(!isolated_muxbus_reconnect_enabled());
2048 assert!(
2049 isolated_auth_enabled(),
2050 "AGENTMUX_ISOLATED_MUXBUS=0 must not disable auth isolation"
2051 );
2052 assert!(
2053 isolated_settings_enabled(),
2054 "AGENTMUX_ISOLATED_MUXBUS=0 must not disable settings isolation"
2055 );
2056
2057 std::env::remove_var("AGENTMUX_ISOLATED_MUXBUS");
2058 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "0");
2059 assert!(!isolated_auth_enabled());
2060 assert!(
2061 isolated_muxbus_reconnect_enabled(),
2062 "AGENTMUX_ISOLATED_AUTH=0 must not disable muxbus-reconnect isolation"
2063 );
2064
2065 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
2066 clear_channel_env();
2067 }
2068
2069 #[test]
2070 fn ensure_dirs_creates_everything() {
2071 with_home_override(|_root| {
2072 clear_channel_env();
2073 let p = DataPaths::resolve("0.33.639", &RuntimeMode::Installed).unwrap();
2074 p.ensure_dirs().unwrap();
2075 assert!(p.instance_dir.is_dir());
2076 assert!(p.data_dir.is_dir());
2077 assert!(p.data_dir.join("db").is_dir());
2078 assert!(p.config_dir.is_dir());
2079 assert!(p.logs_dir.is_dir());
2080 assert!(p.cef_cache_dir.is_dir());
2081 assert!(p.agents_dir.is_dir());
2082 assert!(p.instance_runtime_dir.is_dir());
2083 assert!(p.shared_dir.is_dir());
2084 });
2085 }
2086
2087 #[test]
2088 fn env_vars_round_trip() {
2089 with_home_override(|_root| {
2090 clear_channel_env();
2091 let p1 = DataPaths::resolve(
2092 "0.33.639",
2093 &RuntimeMode::Dev {
2094 branch: "main".into(),
2095 clone_id: None,
2096 },
2097 )
2098 .unwrap();
2099 // Apply each env var, then read back.
2100 for (k, v) in p1.to_env_vars() {
2101 std::env::set_var(k, v);
2102 }
2103 let p2 = DataPaths::from_env().expect("round-trip");
2104 assert_eq!(p1.instance_dir, p2.instance_dir);
2105 assert_eq!(p1.data_dir, p2.data_dir);
2106 assert_eq!(p1.shared_dir, p2.shared_dir);
2107 assert_eq!(p1.mode, p2.mode);
2108 assert_eq!(p1.channel, p2.channel);
2109 // Cleanup
2110 for (k, _) in p1.to_env_vars() {
2111 std::env::remove_var(k);
2112 }
2113 });
2114 }
2115
2116 #[test]
2117 fn resolve_rejects_dev_branch_traversal() {
2118 // Even if a caller manages to construct a Dev variant with an
2119 // unsafe branch (bypassing parse_mode_string sanitization),
2120 // resolve() must catch it.
2121 with_home_override(|_root| {
2122 clear_channel_env();
2123 let mode = RuntimeMode::Dev {
2124 branch: "..".into(),
2125 clone_id: None,
2126 };
2127 assert!(DataPaths::resolve("0.33.639", &mode).is_err());
2128 let mode = RuntimeMode::Dev {
2129 branch: "foo/bar".into(),
2130 clone_id: None,
2131 };
2132 assert!(DataPaths::resolve("0.33.639", &mode).is_err());
2133 });
2134 }
2135
2136 #[test]
2137 fn resolve_rejects_traversal_version() {
2138 with_home_override(|_root| {
2139 clear_channel_env();
2140 assert!(DataPaths::resolve("..", &RuntimeMode::Installed).is_err());
2141 assert!(DataPaths::resolve(
2142 "0.33.639/etc",
2143 &RuntimeMode::Installed
2144 )
2145 .is_err());
2146 // Drive-relative on Windows: `PathBuf::join("versions")
2147 // .join("C:temp")` would resolve outside the intended
2148 // ~/.agentmux/versions/ subtree because `C:temp` is a
2149 // drive-relative path, not a literal filename.
2150 assert!(DataPaths::resolve("C:temp", &RuntimeMode::Installed).is_err());
2151 // Other Windows-reserved chars also rejected.
2152 for v in ["a*b", "a?b", "a|b", "a<b", "a>b", "a\"b"] {
2153 assert!(
2154 DataPaths::resolve(v, &RuntimeMode::Installed).is_err(),
2155 "should reject version with reserved char: {:?}",
2156 v
2157 );
2158 }
2159 });
2160 }
2161
2162 #[test]
2163 fn from_env_fails_fast_on_missing_vars() {
2164 let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2165 // Clear all expected vars.
2166 for k in [
2167 "AGENTMUX_INSTANCE_DIR",
2168 "AGENTMUX_DATA_DIR",
2169 "AGENTMUX_CONFIG_DIR",
2170 "AGENTMUX_LOG_DIR",
2171 "AGENTMUX_CEF_CACHE_DIR",
2172 "AGENTMUX_AGENTS_DIR",
2173 "AGENTMUX_INSTANCE_RUNTIME_DIR",
2174 "AGENTMUX_SHARED_DIR",
2175 "AGENTMUX_RUNTIME_MODE",
2176 "AGENTMUX_CHANNEL",
2177 ] {
2178 std::env::remove_var(k);
2179 }
2180 assert!(DataPaths::from_env().is_none());
2181 }
2182
2183 #[test]
2184 fn from_env_fails_fast_when_channel_missing() {
2185 // Symmetric to from_env_fails_fast_on_missing_vars but
2186 // isolates the channel-specific case: a launcher built with
2187 // the new code will always export AGENTMUX_CHANNEL; a missing
2188 // value indicates a launcher/srv version skew that must fail
2189 // loudly rather than silently fall back to a wrong-channel
2190 // write. Pre-set all other vars to confirm channel is what's
2191 // gating.
2192 let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2193 for (k, v) in [
2194 ("AGENTMUX_INSTANCE_DIR", "/tmp/x"),
2195 ("AGENTMUX_DATA_DIR", "/tmp/x/data"),
2196 ("AGENTMUX_CONFIG_DIR", "/tmp/x/config"),
2197 ("AGENTMUX_LOG_DIR", "/tmp/x/logs"),
2198 ("AGENTMUX_CEF_CACHE_DIR", "/tmp/x/cef"),
2199 ("AGENTMUX_AGENTS_DIR", "/tmp/x/agents"),
2200 ("AGENTMUX_INSTANCE_RUNTIME_DIR", "/tmp/x/runtime"),
2201 ("AGENTMUX_SHARED_DIR", "/tmp/x/shared"),
2202 ("AGENTMUX_RUNTIME_MODE", "installed"),
2203 ] {
2204 std::env::set_var(k, v);
2205 }
2206 std::env::remove_var("AGENTMUX_CHANNEL");
2207 assert!(
2208 DataPaths::from_env().is_none(),
2209 "from_env() must refuse when AGENTMUX_CHANNEL is missing"
2210 );
2211 // Cleanup
2212 for k in [
2213 "AGENTMUX_INSTANCE_DIR",
2214 "AGENTMUX_DATA_DIR",
2215 "AGENTMUX_CONFIG_DIR",
2216 "AGENTMUX_LOG_DIR",
2217 "AGENTMUX_CEF_CACHE_DIR",
2218 "AGENTMUX_AGENTS_DIR",
2219 "AGENTMUX_INSTANCE_RUNTIME_DIR",
2220 "AGENTMUX_SHARED_DIR",
2221 "AGENTMUX_RUNTIME_MODE",
2222 ] {
2223 std::env::remove_var(k);
2224 }
2225 }
2226
2227 #[test]
2228 fn resolve_path_only_ignores_env_channel() {
2229 // resolve_path_only is the dev-build / nested-launch self-detection
2230 // variant — it deliberately ignores AGENTMUX_CHANNEL because a host (dev
2231 // OR a portable launched nested inside another AgentMux) inherits the
2232 // parent's channel env and would cross-contaminate.
2233 //
2234 // Codex P1 regression test on PR #1027 (dev), extended for the nested
2235 // portable case the launcher relies on (crates/launcher/src/data_dir.rs).
2236 with_home_override(|root| {
2237 // Use a NON-default channel value so "ignored" (→ baked default) is
2238 // distinguishable from "honored" (→ this value). BUILD_CHANNEL_DEFAULT
2239 // is "stable" in tests, so an override of "beta" that gets ignored
2240 // resolves to "stable" — a name the env never set. (A "stable" override
2241 // would be tautological against the default.)
2242 std::env::set_var("AGENTMUX_CHANNEL", "beta");
2243 struct ChannelGuard;
2244 impl Drop for ChannelGuard {
2245 fn drop(&mut self) {
2246 std::env::remove_var("AGENTMUX_CHANNEL");
2247 }
2248 }
2249 let _g = ChannelGuard;
2250
2251 let dev = DataPaths::resolve_path_only(
2252 "0.33.639",
2253 &RuntimeMode::Dev { branch: "main".into(), clone_id: None },
2254 )
2255 .unwrap();
2256 // Dev path_only ignores "beta" → stays under dev/main/.
2257 assert_eq!(dev.channel, "dev-main");
2258 assert_eq!(dev.instance_dir, root.join("dev").join("main"));
2259
2260 let inst = DataPaths::resolve_path_only(
2261 "0.33.639",
2262 &RuntimeMode::Installed,
2263 )
2264 .unwrap();
2265 // Installed path_only ignores "beta" → baked default "stable".
2266 assert_eq!(inst.channel, "stable");
2267 assert_eq!(inst.instance_dir, root.join("channels").join("stable"));
2268
2269 // Portable path_only ALSO ignores the env channel — the behavior the
2270 // launcher relies on for a NESTED portable launch. A build launched
2271 // inside another AgentMux pane inherits AGENTMUX_CHANNEL=<parent> and
2272 // must resolve to its OWN baked channel, NOT the leaked one — else it
2273 // adopts the parent's data dir + cef-cache and CEF's user-data-dir
2274 // singleton forwards it into the parent. "beta" ignored → baked
2275 // "stable".
2276 let port = DataPaths::resolve_path_only(
2277 "0.33.639",
2278 &RuntimeMode::Portable,
2279 )
2280 .unwrap();
2281 assert_eq!(
2282 port.channel, "stable",
2283 "nested portable must IGNORE the leaked AGENTMUX_CHANNEL=beta"
2284 );
2285 assert_eq!(port.instance_dir, root.join("channels").join("stable"));
2286
2287 // Sanity / B6: regular `resolve` DOES honor the override in every mode
2288 // (Dev AND Portable) → "beta". This proves (a) the divergence is solely
2289 // on the path_only variant, (b) the assertions above are not tautological
2290 // against the default, and (c) an EXPLICIT standalone AGENTMUX_CHANNEL is
2291 // still honored for portables (parallel-channel testing, PR #1027).
2292 let dev_env = DataPaths::resolve(
2293 "0.33.639",
2294 &RuntimeMode::Dev { branch: "main".into(), clone_id: None },
2295 )
2296 .unwrap();
2297 assert_eq!(dev_env.channel, "beta");
2298 assert_eq!(dev_env.instance_dir, root.join("channels").join("beta"));
2299
2300 let port_env = DataPaths::resolve(
2301 "0.33.639",
2302 &RuntimeMode::Portable,
2303 )
2304 .unwrap();
2305 assert_eq!(
2306 port_env.channel, "beta",
2307 "standalone portable still HONORS an explicit AGENTMUX_CHANNEL"
2308 );
2309 assert_eq!(port_env.instance_dir, root.join("channels").join("beta"));
2310 });
2311 }
2312
2313 #[test]
2314 fn sanitize_channel_name_accepts_normal_names() {
2315 // The happy path — make sure stable / beta / local-main
2316 // and friends all sanitize cleanly. Catches regressions in
2317 // case the reserved list grows by mistake.
2318 assert_eq!(sanitize_channel_name("stable"), Some("stable".into()));
2319 assert_eq!(sanitize_channel_name("beta"), Some("beta".into()));
2320 assert_eq!(
2321 sanitize_channel_name("local-main"),
2322 Some("local-main".into())
2323 );
2324 assert_eq!(
2325 sanitize_channel_name("dev-main"),
2326 Some("dev-main".into())
2327 );
2328 assert_eq!(
2329 sanitize_channel_name("experiment_42"),
2330 Some("experiment_42".into())
2331 );
2332 }
2333
2334 // ensure_history_link — real filesystem operations (junction on
2335 // Windows, symlink on Unix), not mocked, per
2336 // docs/specs/SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md
2337 // §4.1. This is credential-adjacent storage code; these tests
2338 // actually create the link and read a file back THROUGH it, rather
2339 // than only asserting the `Result` came back `Ok`.
2340
2341 #[test]
2342 fn ensure_history_link_makes_files_written_at_target_visible_through_link() {
2343 let tmp = TempDir::new().expect("tempdir");
2344 let link_path = tmp.path().join("isolated").join("projects");
2345 let target_dir = tmp.path().join("global").join("projects");
2346
2347 ensure_history_link(&link_path, &target_dir).expect("link creation must succeed");
2348
2349 std::fs::write(target_dir.join("session-1.jsonl"), b"hello").unwrap();
2350 let via_link = std::fs::read(link_path.join("session-1.jsonl"))
2351 .expect("a file written at the global target must be readable through the isolated link path");
2352 assert_eq!(via_link, b"hello");
2353 }
2354
2355 #[test]
2356 fn ensure_history_link_is_idempotent() {
2357 let tmp = TempDir::new().expect("tempdir");
2358 let link_path = tmp.path().join("isolated").join("projects");
2359 let target_dir = tmp.path().join("global").join("projects");
2360
2361 ensure_history_link(&link_path, &target_dir).unwrap();
2362 std::fs::write(target_dir.join("session-1.jsonl"), b"hello").unwrap();
2363
2364 // A second call against the already-correct link must not touch
2365 // (let alone lose) the file that's already there.
2366 ensure_history_link(&link_path, &target_dir).expect("re-calling on an already-correct link must succeed");
2367 assert!(link_path.join("session-1.jsonl").exists());
2368 }
2369
2370 #[test]
2371 fn ensure_history_link_migrates_pre_existing_real_directory_contents() {
2372 let tmp = TempDir::new().expect("tempdir");
2373 let link_path = tmp.path().join("isolated").join("projects");
2374 let target_dir = tmp.path().join("global").join("projects");
2375
2376 // Simulate pre-fix state: link_path is a REAL directory with real
2377 // session data already in it (written before this function
2378 // existed / before isolation applied to this bundle).
2379 std::fs::create_dir_all(&link_path).unwrap();
2380 std::fs::write(link_path.join("old-session.jsonl"), b"pre-existing history").unwrap();
2381
2382 ensure_history_link(&link_path, &target_dir).expect("migration + link creation must succeed");
2383
2384 // The old file must now live at the global target, not be lost,
2385 // and must still be reachable via the (now-linked) original path.
2386 assert_eq!(
2387 std::fs::read(target_dir.join("old-session.jsonl")).unwrap(),
2388 b"pre-existing history",
2389 "pre-existing history must be migrated to the global target, not lost"
2390 );
2391 assert_eq!(
2392 std::fs::read(link_path.join("old-session.jsonl")).unwrap(),
2393 b"pre-existing history",
2394 "migrated history must still be reachable at the original (now-linked) path"
2395 );
2396 }
2397
2398 #[test]
2399 fn ensure_history_link_never_overwrites_a_name_collision_at_the_target() {
2400 let tmp = TempDir::new().expect("tempdir");
2401 let link_path = tmp.path().join("isolated").join("projects");
2402 let target_dir = tmp.path().join("global").join("projects");
2403
2404 std::fs::create_dir_all(&target_dir).unwrap();
2405 std::fs::write(target_dir.join("session-1.jsonl"), b"global version").unwrap();
2406 std::fs::create_dir_all(&link_path).unwrap();
2407 std::fs::write(link_path.join("session-1.jsonl"), b"isolated version -- must not be lost").unwrap();
2408
2409 // The link can't be created while link_path is a non-empty real
2410 // directory (the colliding entry blocks the migration's cleanup
2411 // remove_dir) -- this must surface as an error, not silently
2412 // clobber either copy.
2413 let result = ensure_history_link(&link_path, &target_dir);
2414 assert!(result.is_err(), "a name collision must surface as an error, not silently pick a winner");
2415
2416 // Neither copy was touched.
2417 assert_eq!(std::fs::read(target_dir.join("session-1.jsonl")).unwrap(), b"global version");
2418 assert_eq!(
2419 std::fs::read(link_path.join("session-1.jsonl")).unwrap(),
2420 b"isolated version -- must not be lost"
2421 );
2422 }
2423
2424 // reagentx P2 on PR #2605: the previous version of this function's
2425 // fallback match arm treated "a plain file at link_path" the same as
2426 // "a stale link at link_path" and deleted it outright with no
2427 // migration, contradicting the function's own doc comment.
2428 #[test]
2429 fn ensure_history_link_refuses_to_delete_a_plain_file_at_the_link_path() {
2430 let tmp = TempDir::new().expect("tempdir");
2431 let link_path = tmp.path().join("isolated").join("projects");
2432 let target_dir = tmp.path().join("global").join("projects");
2433
2434 std::fs::create_dir_all(link_path.parent().unwrap()).unwrap();
2435 std::fs::write(&link_path, b"unexpected real file, not a directory or a link").unwrap();
2436
2437 let result = ensure_history_link(&link_path, &target_dir);
2438 assert!(result.is_err(), "a plain file at the link path must surface as an error, never be silently deleted");
2439 assert_eq!(
2440 std::fs::read(&link_path).unwrap(),
2441 b"unexpected real file, not a directory or a link",
2442 "the file must still exist, untouched, after the refused operation"
2443 );
2444 }
2445
2446 // chatgpt-codex-connector + reagentx both claimed on PR #2605 that
2447 // `std::fs::symlink_metadata` reports a Windows junction as
2448 // `is_dir()=true, is_symlink()=false` — which, if true, would mean
2449 // `ensure_history_link`'s `meta.is_dir() && !meta.file_type().is_symlink()`
2450 // migration-branch check incorrectly treats a stale junction as a
2451 // real directory. Verified empirically (twice, independently) that
2452 // this claim is FALSE for the actual Rust std + toolchain this crate
2453 // builds with: a real junction reports `is_dir()=false,
2454 // is_symlink()=true` via `symlink_metadata` — `is_dir()=true` only
2455 // shows up via `fs::metadata` (which follows the link, as
2456 // documented). Kept as a permanent test, not deleted after
2457 // resolving the review thread, so a future "fix" for this
2458 // non-existent bug doesn't silently reintroduce one by "fixing"
2459 // something that was already correct.
2460 #[cfg(windows)]
2461 #[test]
2462 fn windows_junction_is_reported_as_symlink_not_as_a_real_directory() {
2463 let tmp = TempDir::new().expect("tempdir");
2464 let link = tmp.path().join("isolated").join("linked");
2465 let target = tmp.path().join("global");
2466 ensure_history_link(&link, &target).unwrap();
2467
2468 assert!(junction::exists(&link).unwrap(), "sanity: this must actually be a junction");
2469
2470 let meta = std::fs::symlink_metadata(&link).unwrap();
2471 assert!(
2472 meta.file_type().is_symlink(),
2473 "a Windows junction must report is_symlink()=true via symlink_metadata"
2474 );
2475 assert!(
2476 !meta.is_dir(),
2477 "a Windows junction must NOT report is_dir()=true via symlink_metadata (only via the \
2478 link-following fs::metadata) — if this ever changes, ensure_history_link's migration \
2479 branch needs the junction::exists() check the (currently incorrect) review claimed was missing"
2480 );
2481 }
2482 // ── #3682: the AgentMux root is owner-only on Unix ──────────────────────
2483
2484 #[cfg(unix)]
2485 fn mode_of(p: &Path) -> u32 {
2486 use std::os::unix::fs::PermissionsExt;
2487 std::fs::metadata(p).unwrap().permissions().mode() & 0o777
2488 }
2489
2490 #[cfg(unix)]
2491 fn set_mode(p: &Path, mode: u32) {
2492 use std::os::unix::fs::PermissionsExt;
2493 std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap();
2494 }
2495
2496 #[cfg(unix)]
2497 #[test]
2498 fn owner_only_creates_a_missing_root_0700() {
2499 let tmp = TempDir::new().unwrap();
2500 let root = tmp.path().join("parent").join(".agentmux");
2501 assert_eq!(ensure_owner_only_dir_inner(&root, None).unwrap(), OwnerOnlyOutcome::Created);
2502 assert_eq!(mode_of(&root), 0o700);
2503 }
2504
2505 #[cfg(unix)]
2506 #[test]
2507 fn owner_only_leaves_parents_it_had_to_create_at_the_normal_umask() {
2508 // Only the root is AgentMux's to make private; a parent dir it had to
2509 // create (a custom AGENTMUX_DATA_HOME) belongs to the user.
2510 let tmp = TempDir::new().unwrap();
2511 let parent = tmp.path().join("parent");
2512 let root = parent.join(".agentmux");
2513 ensure_owner_only_dir_inner(&root, None).unwrap();
2514 assert_ne!(mode_of(&parent), 0o700, "parent must not be forced owner-only");
2515 }
2516
2517 #[cfg(unix)]
2518 #[test]
2519 fn owner_only_tightens_an_existing_world_readable_root() {
2520 let tmp = TempDir::new().unwrap();
2521 let root = tmp.path().join(".agentmux");
2522 std::fs::create_dir(&root).unwrap();
2523 set_mode(&root, 0o755);
2524 assert_eq!(
2525 ensure_owner_only_dir_inner(&root, None).unwrap(),
2526 OwnerOnlyOutcome::Tightened { previous_mode: 0o755 }
2527 );
2528 assert_eq!(mode_of(&root), 0o700);
2529 }
2530
2531 #[cfg(unix)]
2532 #[test]
2533 fn owner_only_keeps_the_owner_bits_it_found() {
2534 let tmp = TempDir::new().unwrap();
2535 let root = tmp.path().join(".agentmux");
2536 std::fs::create_dir(&root).unwrap();
2537 set_mode(&root, 0o570);
2538 assert_eq!(
2539 ensure_owner_only_dir_inner(&root, None).unwrap(),
2540 OwnerOnlyOutcome::Tightened { previous_mode: 0o570 }
2541 );
2542 assert_eq!(mode_of(&root), 0o500);
2543 set_mode(&root, 0o700); // so TempDir can clean up
2544 }
2545
2546 #[cfg(unix)]
2547 #[test]
2548 fn owner_only_is_a_no_op_on_an_already_private_root() {
2549 let tmp = TempDir::new().unwrap();
2550 let root = tmp.path().join(".agentmux");
2551 std::fs::create_dir(&root).unwrap();
2552 set_mode(&root, 0o700);
2553 assert_eq!(ensure_owner_only_dir_inner(&root, None).unwrap(), OwnerOnlyOutcome::AlreadyPrivate);
2554 assert_eq!(mode_of(&root), 0o700);
2555 }
2556
2557 #[cfg(unix)]
2558 #[test]
2559 fn owner_only_never_touches_the_users_home_itself() {
2560 // A misconfigured AGENTMUX_DATA_HOME=$HOME must not chmod ~ to 0700.
2561 let tmp = TempDir::new().unwrap();
2562 let home = tmp.path().join("home");
2563 std::fs::create_dir(&home).unwrap();
2564 set_mode(&home, 0o755);
2565 assert_eq!(
2566 ensure_owner_only_dir_inner(&home, Some(&home)).unwrap(),
2567 OwnerOnlyOutcome::NotApplicable
2568 );
2569 assert_eq!(mode_of(&home), 0o755);
2570 }
2571
2572 #[cfg(unix)]
2573 #[test]
2574 fn owner_only_refuses_a_file_where_the_root_should_be() {
2575 let tmp = TempDir::new().unwrap();
2576 let root = tmp.path().join(".agentmux");
2577 std::fs::write(&root, b"not a dir").unwrap();
2578 assert!(ensure_owner_only_dir_inner(&root, None).is_err());
2579 }
2580
2581 #[cfg(unix)]
2582 #[test]
2583 fn ensure_dirs_leaves_the_root_owner_only_and_still_creates_the_tree() {
2584 with_home_override(|root| {
2585 clear_channel_env();
2586 set_mode(root, 0o755); // an install from before #3682
2587 let paths = DataPaths::resolve("0.0.0", &RuntimeMode::Installed).unwrap();
2588 paths.ensure_dirs().unwrap();
2589 assert_eq!(mode_of(root), 0o700);
2590 for d in [&paths.data_dir, &paths.config_dir, &paths.agents_dir, &paths.shared_dir] {
2591 assert!(d.is_dir(), "{} not created", d.display());
2592 }
2593 assert!(paths.data_dir.join("db").is_dir());
2594 });
2595 }
2596
2597 #[cfg(not(unix))]
2598 #[test]
2599 fn owner_only_is_not_applicable_off_unix() {
2600 let tmp = TempDir::new().unwrap();
2601 let root = tmp.path().join(".agentmux");
2602 assert_eq!(ensure_owner_only_dir_inner(&root, None).unwrap(), OwnerOnlyOutcome::NotApplicable);
2603 // Nothing created either — ensure_dirs' own create_dir_all does that.
2604 assert!(!root.exists());
2605 }
2606}