Skip to main content

agentmux_srv/backend/
config_watcher_fs.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Filesystem watcher for settings.json — detects saves and pushes updated
5//! config to all connected WebSocket clients in real time.
6//!
7//! Migrated onto the shared `fs_watch::FsWatchPool`
8//! (SPEC_SHARED_FS_WATCHER_FRAMEWORK_2026_08_07.md) — this module keeps only
9//! its own domain-specific debounce/reload/broadcast logic; the actual
10//! `notify` construction, refcounting, and recovery-on-failure now live in
11//! the pool, shared with every other watcher that migrates onto it.
12
13use std::path::PathBuf;
14use std::sync::Arc;
15use std::time::Duration;
16
17use tokio::sync::broadcast;
18
19use super::eventbus::EventBus;
20use super::fs_watch::{FsWatchEvent, FsWatchEventKind, FsWatchPool};
21use super::wconfig::{self, ConfigState, SettingsType};
22
23/// Resolve the directory containing settings.json.
24///
25/// Priority:
26/// 1. `AGENTMUX_SETTINGS_DIR` env var (set by the host app to its config dir)
27/// 2. If [`agentmux_common::isolated_settings_enabled`] — the default for
28///    every channel except `stable`, see
29///    `docs/specs/SPEC_SETTINGS_ISOLATED_BY_CHANNEL_2026_08_19.md` —
30///    `AGENTMUX_CONFIG_HOME` used DIRECTLY, with no parent-walk. That var
31///    already carries the correctly channel-scoped `channels/<ch>/config/`
32///    directory (re-exported from `AGENTMUX_CONFIG_DIR`, see
33///    `data_paths.rs`'s own `channels/<ch>/config/ ← settings
34///    (channel-wide)` layout comment) — this is the fix, using that value
35///    as-is instead of walking past it.
36/// 3. Otherwise (global — `stable` channel, or an explicit
37///    `AGENTMUX_ISOLATED_SETTINGS=0` opt-out): `AGENTMUX_CONFIG_HOME`,
38///    walking up two parent directories — unchanged legacy behavior. That
39///    var's value is a channel-scoped `.../config` directory; walking up
40///    two levels lands one level ABOVE the channel root (on
41///    `~/.agentmux/channels/` itself, not inside any specific channel),
42///    which is what makes this the genuinely shared, cross-channel file.
43/// 4. `~/.agentmux` (legacy fallback — `AGENTMUX_CONFIG_HOME` unset)
44pub fn resolve_settings_dir() -> PathBuf {
45    if let Some(dir) = std::env::var_os("AGENTMUX_SETTINGS_DIR") {
46        if !dir.is_empty() {
47            return PathBuf::from(dir);
48        }
49    }
50    if let Some(dir) = std::env::var_os("AGENTMUX_CONFIG_HOME") {
51        if !dir.is_empty() {
52            let path = PathBuf::from(&dir);
53            if agentmux_common::isolated_settings_enabled() {
54                // Already channel-scoped — use directly, no parent-walk.
55                return path;
56            }
57            // AGENTMUX_CONFIG_HOME = channels/<ch>/config — go up two
58            // levels to the shared channels/ root.
59            if let Some(root) = path.parent().and_then(|p| p.parent()) {
60                return root.to_path_buf();
61            }
62        }
63    }
64    // One resolver for the AgentMux root (A10) -- this used to compute it
65    // locally, which meant it ignored AGENTMUX_DATA_HOME entirely.
66    crate::backend::base::get_mux_data_dir()
67}
68
69/// Load settings.json from disk into the ConfigState.
70/// Called once at startup so the backend has the user's saved settings.
71pub fn load_settings_from_disk(config_watcher: &ConfigState) {
72    let settings_dir = resolve_settings_dir();
73    let settings_path = settings_dir.join(wconfig::SETTINGS_FILE);
74
75    // Boot-time diagnostic distinguishing all four resolvable isolation
76    // states — see docs/specs/SPEC_SETTINGS_ISOLATED_BY_CHANNEL_2026_08_19.md
77    // Phase 1. Logged here (not inside resolve_settings_dir itself) since
78    // this function is the one true "called once at startup" call site;
79    // the other two call sites (spawn_settings_watcher, the save path)
80    // would spam this on every watcher re-arm / every save.
81    tracing::info!(
82        reason = agentmux_common::isolated_settings_reason().as_str(),
83        "settings.json isolation"
84    );
85
86    tracing::info!(
87        path = %settings_path.display(),
88        exists = settings_path.exists(),
89        "loading settings.json from disk"
90    );
91
92    let (settings, errors): (SettingsType, _) = wconfig::read_config_file(&settings_path);
93
94    if !errors.is_empty() {
95        for err in &errors {
96            tracing::warn!(file = %err.file, error = %err.err, "settings parse error at startup");
97        }
98        return;
99    }
100
101    config_watcher.update_settings(settings);
102    tracing::info!("settings.json loaded successfully");
103}
104
105/// Whether a raw fs_watch event refers to a `settings.json` save specifically
106/// — the pool's broadcast stream carries events for every currently-watched
107/// path in the process, not just this module's. Matches the pre-migration
108/// code's filter exactly: filename must be `settings.json` (was
109/// `event.paths.iter().any(|p| p.ends_with("settings.json"))`) AND the event
110/// must be a `Create`/`Modify` (was `EventKind::Modify(_) | EventKind::Create(_)`).
111/// A `Removed` event (external delete, or an editor's unlink+recreate save) is
112/// deliberately excluded — `read_config_file` treats a missing file as success
113/// (defaults, no errors), so reacting to `Remove` would reset the live config
114/// and broadcast it to every client.
115fn is_settings_file_event(event: &FsWatchEvent) -> bool {
116    let is_settings_file = event
117        .path
118        .file_name()
119        .map(|n| n == wconfig::SETTINGS_FILE)
120        .unwrap_or(false);
121    is_settings_file
122        && matches!(event.kind, FsWatchEventKind::Created | FsWatchEventKind::Modified)
123}
124
125/// Subscribe to `settings.json` via the shared `FsWatchPool` and broadcast
126/// config updates to all WebSocket clients on change.
127///
128/// Fire-and-forget — the returned subscription never needs to be
129/// unsubscribed (settings.json is watched for the app's entire lifetime),
130/// so unlike the pre-migration version there's no watcher handle for the
131/// caller to keep alive: the pool itself owns that now.
132pub fn spawn_settings_watcher(
133    pool: Arc<FsWatchPool>,
134    config_watcher: Arc<ConfigState>,
135    event_bus: Arc<EventBus>,
136) {
137    let settings_dir = resolve_settings_dir();
138    let settings_path = settings_dir.join(wconfig::SETTINGS_FILE);
139
140    if !settings_dir.exists() {
141        tracing::warn!(
142            dir = %settings_dir.display(),
143            "settings directory does not exist, file watcher not started"
144        );
145        return;
146    }
147
148    // events() MUST be called before subscribe_file() — a broadcast::Receiver
149    // only sees messages sent after it subscribes, so a change event delivered
150    // in the gap between starting the watch and creating this receiver would
151    // otherwise be silently missed (reagent P2 on PR #2456; see
152    // fs_watch::pool::tests::a_change_event_is_observable_on_the_broadcast_stream
153    // for the same ordering requirement enforced on the pool's own test).
154    let mut events = pool.events();
155
156    // Deliberately dropped immediately — `Subscription` has no `Drop`
157    // side effect (unsubscribe is always an explicit call), so this settles
158    // into a permanent watch for the process's lifetime with nothing to
159    // hold onto, matching the pre-migration version's own "this is a
160    // once-at-startup, forever" contract.
161    let _ = pool.subscribe_file(&settings_path);
162
163    tracing::info!(
164        path = %settings_path.display(),
165        dir = %settings_dir.display(),
166        "fs_watch subscription active for settings.json"
167    );
168
169    let watched_path = settings_path.clone();
170    tokio::spawn(async move {
171        loop {
172            match events.recv().await {
173                Ok(ev) if is_settings_file_event(&ev) => {}
174                Ok(_) => continue, // some other watched path — not ours
175                Err(broadcast::error::RecvError::Closed) => {
176                    tracing::info!("settings watcher event stream closed, stopping");
177                    break;
178                }
179                Err(broadcast::error::RecvError::Lagged(skipped)) => {
180                    // We fell behind the pool's broadcast buffer. A lagged
181                    // receiver has definitely missed *some* event, so treat
182                    // it the same as "something changed" rather than
183                    // silently resubscribing to a fresh position and
184                    // potentially missing a real settings.json save.
185                    tracing::warn!(skipped, "settings watcher lagged; reloading defensively");
186                }
187            }
188            // Debounce: drain whatever else is already queued within 300ms,
189            // same collapse-a-burst intent as before — this receiver is
190            // this task's own private clone of the broadcast stream, so
191            // draining it can't affect any other subscriber.
192            tokio::time::sleep(Duration::from_millis(300)).await;
193            while events.try_recv().is_ok() {}
194
195            reload_and_broadcast(&watched_path, &config_watcher, &event_bus);
196        }
197    });
198}
199
200/// Merge new keys into the current in-memory SettingsType and return the result.
201/// Used by the setconfig handler to update in-memory state before the fs watcher fires.
202pub fn merge_settings_into_current(
203    config_watcher: &wconfig::ConfigState,
204    new_keys: serde_json::Map<String, serde_json::Value>,
205) -> wconfig::SettingsType {
206    let mut current = config_watcher.get_settings();
207    // Merge via JSON round-trip so the extra HashMap catches all dynamic keys
208    if let Ok(mut current_val) = serde_json::to_value(&current) {
209        if let serde_json::Value::Object(ref mut map) = current_val {
210            // A null value deletes the key, as `merge_settings_to_disk` does on disk. Skipping
211            // it instead would keep the old value in memory and broadcast it, until the file
212            // watcher reloaded settings.json about 300 ms later (a "Reset" would flicker back).
213            for (key, value) in new_keys {
214                if value.is_null() {
215                    map.remove(&key);
216                } else {
217                    map.insert(key, value);
218                }
219            }
220        }
221        if let Ok(merged) = serde_json::from_value(current_val) {
222            current = merged;
223        }
224    }
225    current
226}
227
228/// Merge a flat map of settings keys into `settings.json` on disk.
229/// Existing keys not present in `new_keys` are preserved.
230/// The fs watcher will detect the write (~300ms) and broadcast the updated config.
231pub fn merge_settings_to_disk(new_keys: serde_json::Map<String, serde_json::Value>) -> Result<(), String> {
232    if new_keys.is_empty() {
233        return Ok(());
234    }
235    let settings_dir = resolve_settings_dir();
236    let settings_path = settings_dir.join(wconfig::SETTINGS_FILE);
237
238    let mut current = wconfig::read_settings_raw_jsonc(&settings_path);
239    current.extend(new_keys);
240
241    // Remove keys explicitly set to null (deletion semantics)
242    current.retain(|_, v| !v.is_null());
243
244    let merged = wconfig::merge_into_template(wconfig::SETTINGS_TEMPLATE, &current);
245    std::fs::write(&settings_path, &merged)
246        .map_err(|e| format!("write settings.json: {e}"))?;
247
248    tracing::info!(path = %settings_path.display(), "settings.json updated via setconfig");
249    Ok(())
250}
251
252fn reload_and_broadcast(
253    settings_path: &PathBuf,
254    config_watcher: &Arc<ConfigState>,
255    event_bus: &Arc<EventBus>,
256) {
257    tracing::info!(path = %settings_path.display(), "settings.json changed, reloading");
258
259    let (settings, errors): (SettingsType, _) = wconfig::read_config_file(settings_path);
260
261    if !errors.is_empty() {
262        for err in &errors {
263            tracing::warn!(file = %err.file, error = %err.err, "settings reload parse error (keeping previous config)");
264        }
265        return;
266    }
267
268    config_watcher.update_settings(settings);
269    tracing::info!("settings.json reloaded, broadcasting to clients");
270    broadcast_full_config(config_watcher, event_bus);
271}
272
273/// Change one connection's settings (`settings.json` → `connections.<name>`,
274/// SPEC_REMOTES_PANE_2026_10_05.md §4.3): a `null` value removes a key. Edits
275/// the raw `connections` object read from disk, so keys AgentMux doesn't know
276/// survive, then writes, updates the live config and broadcasts it, as
277/// `setconfig` does. An edit that leaves the entry unreadable is refused
278/// before anything is written. Serialized, so two quick edits can't drop
279/// each other's.
280pub fn set_connection_values(
281    config_watcher: &Arc<ConfigState>,
282    event_bus: &Arc<EventBus>,
283    connection: &str,
284    values: &serde_json::Map<String, serde_json::Value>,
285) -> Result<(), String> {
286    static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
287    let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
288    let settings_path = resolve_settings_dir().join(wconfig::SETTINGS_FILE);
289    let raw = wconfig::read_settings_raw_jsonc(&settings_path);
290    let mut connections = match raw.get("connections") {
291        Some(serde_json::Value::Object(map)) => map.clone(),
292        _ => serde_json::Map::new(),
293    };
294    crate::backend::remote::remotes::apply_connection_values(&mut connections, connection, values);
295    crate::backend::remote::remotes::check_connection_entry(&connections, connection)?;
296    let mut keys = serde_json::Map::new();
297    keys.insert("connections".to_string(), serde_json::Value::Object(connections));
298    merge_settings_to_disk(keys.clone())?;
299    let merged = merge_settings_into_current(config_watcher, keys);
300    config_watcher.update_settings(merged);
301    broadcast_full_config(config_watcher, event_bus);
302    Ok(())
303}
304
305/// Push the current `FullConfigType` snapshot to every connected client, in
306/// the same wire shape the initial `getfullconfig` RPC response uses.
307///
308/// Factored out of what used to be this file's own `reload_and_broadcast`
309/// tail — the `setconfig` handler (`server/websocket.rs`) duplicated the
310/// same broadcast-construction block verbatim as its own step 4, and
311/// `browser_start_page.rs`'s watcher needs the identical broadcast a third
312/// time. One shared function instead of a third copy.
313pub fn broadcast_full_config(config_watcher: &Arc<ConfigState>, event_bus: &Arc<EventBus>) {
314    // Every settings change passes here: the presence publisher reads
315    // "Publish this computer to my devices" again.
316    crate::muxbus::wan_presence::settings_changed();
317    let config = config_watcher.get_full_config();
318    let client_count = event_bus.connection_count();
319    if let Ok(mut config_val) = serde_json::to_value(config.as_ref()) {
320        crate::backend::wconfig::redact_full_config_for_renderer(&mut config_val);
321        event_bus.broadcast_typed::<crate::backend::mps_events::Config>(&crate::backend::mps_events::ConfigEvent { fullconfig: config_val });
322        tracing::info!(clients = client_count, "config event broadcast complete");
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    /// End-to-end regression for the migration onto `FsWatchPool`: a real
331    /// on-disk settings.json change is detected via the shared pool,
332    /// debounced, reloaded, and lands in `ConfigState`. This is new
333    /// coverage — `spawn_settings_watcher` had no test before this module
334    /// existed to migrate onto.
335    #[tokio::test]
336    async fn settings_change_via_pool_reloads_config_watcher() {
337        // codex P2 on PR #2664: this test mutates the process-global
338        // AGENTMUX_SETTINGS_DIR — must hold the crate-wide env lock, the
339        // same one resolve_settings_dir's isolation tests below use, or
340        // the two can interleave and redirect each other's settings dir
341        // mid-test.
342        let _lock = crate::test_support::ISOLATED_AUTH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
343        let tmp = tempfile::tempdir().unwrap();
344        let prev = std::env::var_os("AGENTMUX_SETTINGS_DIR");
345        std::env::set_var("AGENTMUX_SETTINGS_DIR", tmp.path());
346
347        let settings_path = tmp.path().join(wconfig::SETTINGS_FILE);
348        std::fs::write(&settings_path, wconfig::SETTINGS_TEMPLATE).unwrap();
349
350        let pool = FsWatchPool::new();
351        let config_watcher = Arc::new(ConfigState::new());
352        let event_bus = Arc::new(EventBus::new());
353        spawn_settings_watcher(pool.clone(), config_watcher.clone(), event_bus.clone());
354
355        // Give the watch a moment to actually register before writing —
356        // otherwise this is a "wrote before the watch was live" race, not a
357        // real assertion about the reload path.
358        tokio::time::sleep(Duration::from_millis(200)).await;
359
360        let mut new_keys = serde_json::Map::new();
361        new_keys.insert("app:defaultnewblock".to_string(), serde_json::json!("fs-watch-test-marker"));
362        let merged = wconfig::merge_into_template(wconfig::SETTINGS_TEMPLATE, &new_keys);
363        std::fs::write(&settings_path, &merged).unwrap();
364
365        let saw_it = tokio::time::timeout(Duration::from_secs(5), async {
366            loop {
367                if config_watcher.get_settings().app_default_new_block == "fs-watch-test-marker" {
368                    return true;
369                }
370                tokio::time::sleep(Duration::from_millis(50)).await;
371            }
372        })
373        .await
374        .unwrap_or(false);
375
376        match prev {
377            Some(v) => std::env::set_var("AGENTMUX_SETTINGS_DIR", v),
378            None => std::env::remove_var("AGENTMUX_SETTINGS_DIR"),
379        }
380
381        assert!(saw_it, "expected config_watcher to reload the updated setting after a settings.json change detected via FsWatchPool");
382    }
383
384    fn evt(path: &str, kind: FsWatchEventKind) -> FsWatchEvent {
385        FsWatchEvent { path: PathBuf::from(path), kind }
386    }
387
388    #[test]
389    fn is_settings_file_event_matches_only_the_settings_filename() {
390        assert!(is_settings_file_event(&evt("/some/dir/settings.json", FsWatchEventKind::Modified)));
391        assert!(is_settings_file_event(&evt("/some/dir/settings.json", FsWatchEventKind::Created)));
392        assert!(!is_settings_file_event(&evt("/some/dir/other.json", FsWatchEventKind::Modified)));
393        assert!(!is_settings_file_event(&evt("/some/dir", FsWatchEventKind::Modified)));
394    }
395
396    #[test]
397    fn is_settings_file_event_ignores_removed_events() {
398        // A Remove event for settings.json (external delete, or an editor's
399        // unlink+recreate save) must NOT trigger a reload — read_config_file
400        // treats a missing file as success-with-defaults, so reacting here
401        // would reset the live config and broadcast defaults to every client.
402        assert!(!is_settings_file_event(&evt("/some/dir/settings.json", FsWatchEventKind::Removed)));
403        assert!(!is_settings_file_event(&evt("/some/dir/settings.json", FsWatchEventKind::Other)));
404    }
405
406    /// `setconfig {"widget:pinned": null}` (Settings → Widgets → Reset) must drop the key from
407    /// memory at once, as it does on disk, or the broadcast that follows carries the old pins.
408    #[test]
409    fn merge_into_current_removes_a_key_set_to_null() {
410        let config = ConfigState::new();
411        let mut keys = serde_json::Map::new();
412        keys.insert("widget:pinned".into(), serde_json::json!(["agent", "terminal"]));
413        keys.insert("widget:hidden".into(), serde_json::json!(["defwidget@drone"]));
414        keys.insert("some:dynamic".into(), serde_json::json!(1));
415        config.update_settings(merge_settings_into_current(&config, keys));
416        assert_eq!(config.get_settings().widget_pinned, Some(vec!["agent".to_string(), "terminal".to_string()]));
417        assert!(config.get_settings().extra.contains_key("some:dynamic"));
418
419        let mut reset = serde_json::Map::new();
420        reset.insert("widget:pinned".into(), serde_json::Value::Null);
421        reset.insert("some:dynamic".into(), serde_json::Value::Null);
422        config.update_settings(merge_settings_into_current(&config, reset));
423        let settings = config.get_settings();
424        assert_eq!(settings.widget_pinned, None);
425        assert!(!settings.extra.contains_key("some:dynamic"));
426        // A key not named in the update is kept.
427        assert_eq!(settings.widget_hidden, Some(vec!["defwidget@drone".to_string()]));
428    }
429
430    /// A malformed widget list doesn't fail the whole settings file.
431    #[test]
432    fn a_malformed_widget_list_is_ignored_not_fatal() {
433        let settings: wconfig::SettingsType =
434            serde_json::from_str(r#"{ "widget:pinned": "agent", "widget:hidden": ["defwidget@drone", 3], "widget:icononly": true }"#)
435                .unwrap();
436        assert_eq!(settings.widget_pinned, None);
437        assert_eq!(settings.widget_hidden, Some(vec!["defwidget@drone".to_string()]));
438        assert_eq!(settings.widget_icon_only, Some(true));
439    }
440
441    // ── resolve_settings_dir isolation (SPEC_SETTINGS_ISOLATED_BY_CHANNEL_2026_08_19.md) ──
442
443    /// codex P2 on PR #2664: a module-local `Mutex<()>` here would only
444    /// serialize these tests against EACH OTHER — not against
445    /// `settings_change_via_pool_reloads_config_watcher` above (same file,
446    /// mutates `AGENTMUX_SETTINGS_DIR`) or `registry::paths`' tests
447    /// (mutate `AGENTMUX_CHANNEL` behind their own, different lock).
448    /// Cargo's default parallel test runner means those could still
449    /// interleave with these and redirect each other's resolved dir /
450    /// isolation state mid-test. Use the crate-wide lock instead — see
451    /// `test_support.rs`'s own doc comment, which documents exactly this
452    /// failure mode (reagent/codex on PR #2318) for the auth-isolation
453    /// tests this lock was introduced for; it's the crate's general
454    /// process-env-mutation lock, not auth-specific despite the name.
455    use crate::test_support::ISOLATED_AUTH_ENV_LOCK as SETTINGS_DIR_ENV_LOCK;
456
457    fn lock_settings_dir_env() -> std::sync::MutexGuard<'static, ()> {
458        SETTINGS_DIR_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
459    }
460
461    /// Clears every env var `resolve_settings_dir` / `isolated_settings_*`
462    /// read, so each test starts from a known state regardless of
463    /// leakage from a prior test or from process env inherited from the
464    /// test runner itself.
465    fn clear_settings_dir_env() {
466        std::env::remove_var("AGENTMUX_SETTINGS_DIR");
467        std::env::remove_var("AGENTMUX_CONFIG_HOME");
468        std::env::remove_var("AGENTMUX_CHANNEL");
469        std::env::remove_var("AGENTMUX_ISOLATED_SETTINGS");
470    }
471
472    #[test]
473    fn resolve_settings_dir_stays_global_on_stable_channel() {
474        let _lock = lock_settings_dir_env();
475        clear_settings_dir_env();
476        std::env::set_var("AGENTMUX_CONFIG_HOME", "/home/user/.agentmux/channels/stable/config");
477        std::env::set_var("AGENTMUX_CHANNEL", "stable");
478
479        let dir = resolve_settings_dir();
480
481        assert_eq!(dir, PathBuf::from("/home/user/.agentmux/channels"));
482        clear_settings_dir_env();
483    }
484
485    #[test]
486    fn resolve_settings_dir_is_isolated_by_default_on_non_stable_channel() {
487        // The behavior change this spec introduces: no AGENTMUX_ISOLATED_SETTINGS
488        // set at all — a task-dev branch or task-package build's channel is
489        // enough on its own to get an isolated settings dir.
490        let _lock = lock_settings_dir_env();
491        clear_settings_dir_env();
492        let config_home = "/home/user/.agentmux/channels/local-main-abc123-1/config";
493        std::env::set_var("AGENTMUX_CONFIG_HOME", config_home);
494        std::env::set_var("AGENTMUX_CHANNEL", "local-main-abc123-1");
495
496        let dir = resolve_settings_dir();
497
498        assert_eq!(
499            dir,
500            PathBuf::from(config_home),
501            "isolated-by-default settings dir must be the channel-scoped config dir itself, not its shared parent"
502        );
503        clear_settings_dir_env();
504    }
505
506    #[test]
507    fn resolve_settings_dir_explicit_opt_out_restores_global_on_non_stable_channel() {
508        let _lock = lock_settings_dir_env();
509        clear_settings_dir_env();
510        std::env::set_var("AGENTMUX_CONFIG_HOME", "/home/user/.agentmux/channels/dev-some-branch/config");
511        std::env::set_var("AGENTMUX_CHANNEL", "dev-some-branch");
512        std::env::set_var("AGENTMUX_ISOLATED_SETTINGS", "0");
513
514        let dir = resolve_settings_dir();
515
516        assert_eq!(dir, PathBuf::from("/home/user/.agentmux/channels"));
517        clear_settings_dir_env();
518    }
519
520    #[test]
521    fn resolve_settings_dir_explicit_opt_in_isolates_even_the_stable_channel() {
522        let _lock = lock_settings_dir_env();
523        clear_settings_dir_env();
524        std::env::set_var("AGENTMUX_CONFIG_HOME", "/home/user/.agentmux/channels/stable/config");
525        std::env::set_var("AGENTMUX_CHANNEL", "stable");
526        std::env::set_var("AGENTMUX_ISOLATED_SETTINGS", "1");
527
528        let dir = resolve_settings_dir();
529
530        assert_eq!(dir, PathBuf::from("/home/user/.agentmux/channels/stable/config"));
531        clear_settings_dir_env();
532    }
533
534    #[test]
535    fn resolve_settings_dir_stays_global_when_channel_unset() {
536        // Conservative fallback: no AGENTMUX_CHANNEL in the process env at
537        // all — stay global rather than guess, same as isolated_auth's
538        // equivalent case.
539        let _lock = lock_settings_dir_env();
540        clear_settings_dir_env();
541        std::env::set_var("AGENTMUX_CONFIG_HOME", "/home/user/.agentmux/channels/dev-some-branch/config");
542        // AGENTMUX_CHANNEL deliberately left unset.
543
544        let dir = resolve_settings_dir();
545
546        assert_eq!(dir, PathBuf::from("/home/user/.agentmux/channels"));
547        clear_settings_dir_env();
548    }
549
550    #[test]
551    fn resolve_settings_dir_settings_dir_override_wins_regardless_of_isolation() {
552        let _lock = lock_settings_dir_env();
553        clear_settings_dir_env();
554        std::env::set_var("AGENTMUX_SETTINGS_DIR", "/explicit/override");
555        std::env::set_var("AGENTMUX_CONFIG_HOME", "/home/user/.agentmux/channels/local-main-abc123-1/config");
556        std::env::set_var("AGENTMUX_CHANNEL", "local-main-abc123-1");
557
558        let dir = resolve_settings_dir();
559
560        assert_eq!(dir, PathBuf::from("/explicit/override"));
561        clear_settings_dir_env();
562    }
563}