Skip to content

Commit 856732e

Browse files
authored
fix(config): migrate config.json to XDG config directory to prevent shell reload loop (#187)
- Relocate default config file from ~/.config/omarchy/plugins/icyleaf.bitwarden/config.json to ~/.config/omawarden/config.json - Add support for OMAWARDEN_CONFIG_DIR env var - Automatically migrate existing config.json from legacy plugin directory with 0600 permissions and clean up stale files - Document architectural decision in docs/adr/0020-xdg-config-location-and-migration.md and update CONTEXT.md - Add unit tests verifying default path, legacy migration, and cleanup
1 parent b149cb0 commit 856732e

4 files changed

Lines changed: 168 additions & 21 deletions

File tree

‎CONTEXT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
- **Atomic Credential Erasure**: Calling `auth logout` or updating to a different `server_url` triggers `clear_all()` across all `service=omarchy-bitwarden` entries in the OS keyring and purges local storage (see `docs/adr/0008-server-switching-credential-and-cache-invalidation.md`).
2828
- **Zero-Leakage Memory Policy**: Sensitive cryptographic keys (`SymmetricCryptoKey`) and intermediate hashes derive `Zeroize` and `#[zeroize(drop)]` to enforce volatile memory destruction on drop.
2929
- **Zero-Argv / Zero-Environ Security Seam**: To prevent credential leakage across Linux processes (`/proc/<pid>/cmdline` and `/proc/<pid>/environ`), secrets (Master Passwords, API Secrets, TOTP seeds, Clipboard text) are delivered exclusively through protected `stdin` streams or `0600` Unix Domain Sockets.
30-
- **Strict File & Socket Permissions & XDG Data Isolation**: Disk storage (`data.json`) resides outside the inotify-watched plugin directory at `$XDG_DATA_HOME/omawarden/data.json` (defaulting to `~/.local/share/omawarden/data.json` with automatic migration from legacy paths) to prevent hot-reload loops, and enforces `0600` owner-only permissions along with the daemon Unix socket (`omawarden.sock`) (see `docs/adr/0015-xdg-data-storage-location-and-migration.md`).
30+
- **Strict File & Socket Permissions & XDG Data Isolation**: Disk storage (`data.json`) resides outside the inotify-watched plugin directory at `$XDG_DATA_HOME/omawarden/data.json` (defaulting to `~/.local/share/omawarden/data.json`), and configuration (`config.json`) resides at `$XDG_CONFIG_HOME/omawarden/config.json` (defaulting to `~/.config/omawarden/config.json`), both featuring automatic transparent migration from legacy plugin directories to prevent hot-reload loops, and enforcing `0600` owner-only permissions along with the daemon Unix socket (`omawarden.sock`) (see `docs/adr/0015-xdg-data-storage-location-and-migration.md` and `docs/adr/0020-xdg-config-location-and-migration.md`).
3131
- **Daemon In-Memory Cache & Scrubbing**: Decrypted vault items and keys are held exclusively in daemon memory while unlocked. Manual `auth lock`, `auth logout`, idle timeouts, or screen-lock events immediately trigger `{"action": "lock"}` IPC to purge all decrypted items and keys from memory.
3232
- **Clipboard Guard**: Ephemeral clipboard management using Wayland native `wl-copy` with automatic 30-second TTL cleanup for copied passwords, PINs, and TOTPs.
3333
- **Website Icon Privacy & Endpoint Isolation**: Resolution policy governing website favicon retrieval. Official Bitwarden cloud instances query `https://icons.bitwarden.net/{domain}/icon.png`. Self-hosted instances (e.g. Vaultwarden or self-hosted Bitwarden) strictly query the user's configured server endpoint (`${server_root}/icons/{domain}/icon.png`), preventing leakage of intranet or private domain names to public third-party services. If self-hosted icon retrieval fails (such as on air-gapped networks or when server icon proxying is disabled), the UI fails closed to default category icons (`\uf084`) and never falls back to public servers (see `docs/adr/0014-self-hosted-icon-service-endpoint-and-privacy-isolation.md`).
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# ADR 0020: Relocate Configuration File to XDG Config Directory to Prevent Shell Reload Loops
2+
3+
## Status
4+
Accepted
5+
6+
## Context
7+
In ADR 0015, the encrypted vault storage `data.json` was relocated from `$XDG_CONFIG_HOME/omarchy/plugins/icyleaf.bitwarden/data.json` to `$XDG_DATA_HOME/omawarden/data.json` to eliminate hot-reload loops triggered by the host Omarchy desktop shell (`services/PluginRegistry.qml`), which actively monitors `$XDG_CONFIG_HOME/omarchy/plugins/` with `inotifywait -m -r -q -e close_write,create,delete,move`.
8+
9+
However, `config.json` remained located inside the watched plugin directory:
10+
`$XDG_CONFIG_HOME/omarchy/plugins/icyleaf.bitwarden/config.json`
11+
12+
Whenever a user modified preferences in the GUI Settings modal (such as changing the server URL, auto-lock timeout, clipboard TTL, or max attachment size) and clicked Save, `omawarden config set` performed an atomic file write (`fs_util::atomic_write_str`) over `config.json`. Because this atomic write creates a temporary file and renames it inside the plugin directory, `inotifywait` immediately detected the modification, causing `omarchy-shell` to misinterpret the event as a plugin code update and trigger `shell.reloadPlugins()`. This tore down panels, widgets, and services, causing visual UI restarts and desktop panel flickering.
13+
14+
In addition, storing application configuration inside a third-party desktop plugin repository violates the Freedesktop XDG Base Directory Specification, which specifies `$XDG_CONFIG_HOME` for application-specific configuration files.
15+
16+
## Decision
17+
1. **Relocate Default Configuration to `$XDG_CONFIG_HOME/omawarden/config.json`**:
18+
- `ConfigManager::default()` now resolves `config.json` to `$XDG_CONFIG_HOME/omawarden/config.json` (defaulting to `$HOME/.config/omawarden/config.json`).
19+
- This location resides completely outside the inotify-monitored Omarchy plugin hierarchy.
20+
21+
2. **Environment Variable Override (`OMAWARDEN_CONFIG_DIR`)**:
22+
- `ConfigManager` checks the `OMAWARDEN_CONFIG_DIR` environment variable before falling back to `XDG_CONFIG_HOME`, allowing integration tests, containerized environments, and custom setups to override the configuration path.
23+
24+
3. **Transparent Legacy Migration**:
25+
- Upon initialization, `ConfigManager` checks whether a legacy file exists at `$XDG_CONFIG_HOME/omarchy/plugins/icyleaf.bitwarden/config.json`.
26+
- If the legacy file exists and the new target does not, `omawarden` atomically moves the file, ensures the parent directory has `0700` permissions, enforces `0600` permissions on the target, and removes the legacy file.
27+
- If the target already exists, stale legacy files in the plugin directory are purged to avoid residual inotify events.
28+
29+
## Consequences
30+
- **Elimination of Reload Loops on Settings Save**: Saving settings in the UI never touches the watched `omarchy/plugins` directory and no longer triggers Omarchy Shell plugin reloads or panel flickering.
31+
- **XDG Specification Compliance**: Clean separation between plugin source code (`$XDG_CONFIG_HOME/omarchy/plugins/icyleaf.bitwarden/`) and application configuration (`$XDG_CONFIG_HOME/omawarden/config.json`).
32+
- **Zero Disruption for Existing Users**: Automatic migration preserves existing user configurations (custom server URLs, remember email settings, timeout intervals, etc.) seamlessly.

‎omawarden/src/attachment.rs‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1442,13 +1442,9 @@ mod tests {
14421442

14431443
// Set up temporary config directory with 1MB max_attachment_size_mb
14441444
let config_dir = tempfile::tempdir().unwrap();
1445-
let plugin_dir = config_dir
1446-
.path()
1447-
.join("omarchy")
1448-
.join("plugins")
1449-
.join("icyleaf.bitwarden");
1450-
std::fs::create_dir_all(&plugin_dir).unwrap();
1451-
let config_json = plugin_dir.join("config.json");
1445+
let cfg_dir = config_dir.path().join("omawarden");
1446+
std::fs::create_dir_all(&cfg_dir).unwrap();
1447+
let config_json = cfg_dir.join("config.json");
14521448
std::fs::write(&config_json, r#"{"max_attachment_size_mb": 1}"#).unwrap();
14531449

14541450
// Set up temporary data directory with data.json pointing to our mock server

‎omawarden/src/config.rs‎

Lines changed: 132 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
use serde::{Deserialize, Serialize};
22
use std::env;
33
use std::fs;
4+
use std::os::unix::fs::PermissionsExt;
45
use std::path::{Path, PathBuf};
56

7+
pub const DEFAULT_CONFIG_FILENAME: &str = "config.json";
68
pub const DEFAULT_SERVER_URL: &str = "https://vault.bitwarden.com";
79
pub const DEFAULT_DOWNLOAD_DIR: &str = "~/Downloads";
810
pub const DEFAULT_AUTO_LOCK_MINUTES: i64 = 15;
@@ -101,6 +103,59 @@ pub struct ConfigManager {
101103
pub config_path: PathBuf,
102104
}
103105

106+
pub fn resolve_legacy_config_path() -> PathBuf {
107+
let xdg_config = env::var("XDG_CONFIG_HOME")
108+
.map(PathBuf::from)
109+
.unwrap_or_else(|_| {
110+
let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
111+
PathBuf::from(home).join(".config")
112+
});
113+
xdg_config
114+
.join("omarchy")
115+
.join("plugins")
116+
.join("icyleaf.bitwarden")
117+
.join(DEFAULT_CONFIG_FILENAME)
118+
}
119+
120+
pub fn migrate_legacy_config_file(legacy_path: &Path, target_path: &Path) {
121+
if legacy_path != target_path && legacy_path.exists() {
122+
if !target_path.exists() {
123+
if let Some(parent) = target_path.parent() {
124+
let _ = crate::fs_util::create_secure_dir_all(parent, 0o700);
125+
}
126+
if fs::rename(legacy_path, target_path).is_err()
127+
&& fs::copy(legacy_path, target_path).is_ok()
128+
{
129+
let _ = fs::remove_file(legacy_path);
130+
}
131+
let _ = fs::set_permissions(target_path, fs::Permissions::from_mode(0o600));
132+
} else {
133+
let _ = fs::remove_file(legacy_path);
134+
}
135+
}
136+
}
137+
138+
pub fn resolve_default_config_path() -> PathBuf {
139+
if let Ok(custom_dir) = env::var("OMAWARDEN_CONFIG_DIR") {
140+
if !custom_dir.trim().is_empty() {
141+
return PathBuf::from(custom_dir.trim()).join(DEFAULT_CONFIG_FILENAME);
142+
}
143+
}
144+
145+
let xdg_config = env::var("XDG_CONFIG_HOME")
146+
.map(PathBuf::from)
147+
.unwrap_or_else(|_| {
148+
let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
149+
PathBuf::from(home).join(".config")
150+
});
151+
let target_path = xdg_config.join("omawarden").join(DEFAULT_CONFIG_FILENAME);
152+
let legacy_path = resolve_legacy_config_path();
153+
154+
migrate_legacy_config_file(&legacy_path, &target_path);
155+
156+
target_path
157+
}
158+
104159
impl Default for ConfigManager {
105160
fn default() -> Self {
106161
Self::new(None)
@@ -111,19 +166,7 @@ impl ConfigManager {
111166
pub fn new(custom_path: Option<&Path>) -> Self {
112167
let config_path = match custom_path {
113168
Some(p) => p.to_path_buf(),
114-
None => {
115-
let xdg_config = env::var("XDG_CONFIG_HOME")
116-
.map(PathBuf::from)
117-
.unwrap_or_else(|_| {
118-
let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
119-
PathBuf::from(home).join(".config")
120-
});
121-
xdg_config
122-
.join("omarchy")
123-
.join("plugins")
124-
.join("icyleaf.bitwarden")
125-
.join("config.json")
126-
}
169+
None => resolve_default_config_path(),
127170
};
128171
Self { config_path }
129172
}
@@ -821,4 +864,80 @@ esac
821864
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
822865
assert!(err.to_string().contains("Remote servers must use HTTPS"));
823866
}
867+
868+
#[test]
869+
fn test_default_config_path_not_in_watched_plugin_directory() {
870+
let config_mgr = ConfigManager::default();
871+
let path_str = config_mgr.config_path.to_string_lossy();
872+
assert!(
873+
!path_str.contains("omarchy/plugins"),
874+
"Config file path must not reside inside watched omarchy/plugins directory, was: {}",
875+
path_str
876+
);
877+
assert!(
878+
path_str.ends_with("omawarden/config.json"),
879+
"Config file path must end with omawarden/config.json, was: {}",
880+
path_str
881+
);
882+
}
883+
884+
#[test]
885+
fn test_migrate_legacy_config_file_moves_existing_data() {
886+
let dir = tempdir().unwrap();
887+
let legacy_dir = dir.path().join("legacy");
888+
fs::create_dir_all(&legacy_dir).unwrap();
889+
let legacy_file = legacy_dir.join("config.json");
890+
fs::write(
891+
&legacy_file,
892+
"{\"server_url\":\"https://vault.example.com\"}",
893+
)
894+
.unwrap();
895+
896+
let target_dir = dir.path().join("target");
897+
let target_file = target_dir.join("config.json");
898+
899+
assert!(legacy_file.exists());
900+
assert!(!target_file.exists());
901+
902+
migrate_legacy_config_file(&legacy_file, &target_file);
903+
904+
assert!(
905+
!legacy_file.exists(),
906+
"Legacy config file must be removed after migration"
907+
);
908+
assert!(
909+
target_file.exists(),
910+
"Target config file must exist after migration"
911+
);
912+
913+
let content = fs::read_to_string(&target_file).unwrap();
914+
assert_eq!(content, "{\"server_url\":\"https://vault.example.com\"}");
915+
916+
let metadata = fs::metadata(&target_file).unwrap();
917+
let mode = metadata.permissions().mode() & 0o777;
918+
assert_eq!(
919+
mode, 0o600,
920+
"Migrated config file must have 0600 permissions"
921+
);
922+
}
923+
924+
#[test]
925+
fn test_migrate_legacy_config_cleans_up_when_target_already_exists() {
926+
let dir = tempdir().unwrap();
927+
let legacy_file = dir.path().join("legacy.json");
928+
fs::write(&legacy_file, "{\"old\":true}").unwrap();
929+
930+
let target_file = dir.path().join("target.json");
931+
fs::write(&target_file, "{\"new\":true}").unwrap();
932+
933+
migrate_legacy_config_file(&legacy_file, &target_file);
934+
935+
assert!(
936+
!legacy_file.exists(),
937+
"Stale legacy config file must be deleted when target already exists"
938+
);
939+
assert!(target_file.exists());
940+
let content = fs::read_to_string(&target_file).unwrap();
941+
assert_eq!(content, "{\"new\":true}");
942+
}
824943
}

0 commit comments

Comments
 (0)