Scope CEF data to OpenHuman user profiles (#917)

This commit is contained in:
Steven Enamakel
2026-04-25 18:18:01 -07:00
committed by GitHub
parent 579970c559
commit 87ca378248
10 changed files with 790 additions and 13 deletions
+103 -2
View File
@@ -11,6 +11,7 @@ dependencies = [
"base64 0.22.1",
"cef",
"chrono",
"directories",
"env_logger",
"flate2",
"futures-util",
@@ -36,8 +37,10 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-updater",
"tauri-runtime-cef",
"tempfile",
"tokio",
"tokio-tungstenite",
"toml 0.8.2",
"zip 2.4.2",
]
@@ -967,13 +970,34 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ea539174bb236e0e7dc9c12b19b88eae3cb574dedbd0252a2d43ea7e6de13e2"
[[package]]
name = "directories"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
]
[[package]]
@@ -984,7 +1008,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.5.2",
"windows-sys 0.61.2",
]
@@ -3687,6 +3711,17 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -6134,6 +6169,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -6185,6 +6229,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -6242,6 +6301,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -6260,6 +6325,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -6278,6 +6349,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -6308,6 +6385,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -6326,6 +6409,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -6344,6 +6433,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -6362,6 +6457,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
+3
View File
@@ -53,6 +53,8 @@ tauri-plugin-opener = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
directories = "5"
# Used by gmail/cdp_fetch for decoding binary IO.read chunks. Base64 is
# only emitted by CDP IO.read when the stream contains non-UTF-8 bytes,
# but we opt into the feature to stay robust against unexpected responses.
@@ -157,6 +159,7 @@ tauri-plugin-notification = { path = "vendor/tauri-plugin-notification" }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
tempfile = "3"
# Fast CI builds: trade runtime perf for compile speed
[profile.ci]
+9
View File
@@ -74,6 +74,15 @@ impl std::error::Error for CefLockError {}
/// Resolves the macOS default CEF cache directory and runs the preflight.
pub fn check_default_cache() -> Result<(), CefLockError> {
if let Some(configured) = std::env::var_os("OPENHUMAN_CEF_CACHE_PATH") {
let configured = PathBuf::from(configured);
log::debug!(
"[cef-preflight] using configured cache_path={}",
configured.display()
);
return check_cef_cache_lock(&configured);
}
let home = std::env::var_os("HOME").ok_or(CefLockError::NoHomeDir)?;
let cache_path = PathBuf::from(home)
.join("Library/Caches")
+490
View File
@@ -0,0 +1,490 @@
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const CEF_CACHE_PATH_ENV: &str = "OPENHUMAN_CEF_CACHE_PATH";
const ACTIVE_USER_STATE_FILE: &str = "active_user.toml";
/// Sibling of the OpenHuman data dir (not under it) so the marker survives
/// `reset_local_data` removing the whole `default_openhuman_dir` tree.
const PENDING_PURGE_STATE_FILE: &str = "openhuman_pending_cef_purge.toml";
/// Presibling-layout marker (lived under the data root; `reset_local_data` removed it).
const LEGACY_PENDING_PURGE_IN_TREE: &str = "pending_cef_purge.toml";
const PRE_LOGIN_USER_ID: &str = "local";
#[derive(Debug, Deserialize)]
struct ActiveUserState {
user_id: String,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct PendingCefPurgeState {
#[serde(default)]
paths: Vec<String>,
}
fn default_root_dir_name() -> &'static str {
let app_env = std::env::var("OPENHUMAN_APP_ENV")
.or_else(|_| std::env::var("VITE_OPENHUMAN_APP_ENV"))
.ok()
.map(|value| value.trim().to_ascii_lowercase());
if matches!(app_env.as_deref(), Some("staging")) {
".openhuman-staging"
} else {
".openhuman"
}
}
fn default_root_openhuman_dir() -> Result<PathBuf, String> {
if let Ok(workspace) = std::env::var("OPENHUMAN_WORKSPACE") {
let trimmed = workspace.trim();
if !trimmed.is_empty() {
return Ok(PathBuf::from(trimmed));
}
}
let home = directories::UserDirs::new()
.map(|dirs| dirs.home_dir().to_path_buf())
.ok_or_else(|| "Could not find home directory".to_string())?;
Ok(home.join(default_root_dir_name()))
}
fn read_active_user_id(default_openhuman_dir: &Path) -> Option<String> {
let path = default_openhuman_dir.join(ACTIVE_USER_STATE_FILE);
let contents = std::fs::read_to_string(path).ok()?;
let state: ActiveUserState = toml::from_str(&contents).ok()?;
let trimmed = state.user_id.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
/// Returns a single safe path segment for `users/<id>/…`. Rejects traversal, separators,
/// and other inputs that would escape the intended profile root.
fn validate_user_id_for_path(user_id: &str) -> Result<String, String> {
let trimmed = user_id.trim();
if trimmed.is_empty() {
return Err("user_id is empty after trim".to_string());
}
if matches!(trimmed, "." | "..") {
return Err("user_id must not be '.' or '..'".to_string());
}
if trimmed.contains("..")
|| trimmed
.chars()
.any(|c| matches!(c, '/' | '\\' | '\0' | char::REPLACEMENT_CHARACTER) || c.is_control())
{
return Err("user_id must not contain path components or control characters".to_string());
}
#[cfg(windows)]
if trimmed.contains(':') {
return Err("user_id must not contain ':' (Windows path roots)".to_string());
}
if !trimmed
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '@' || c == '.')
{
return Err("user_id must only use [A-Za-z0-9._@-] (after trim)".to_string());
}
Ok(trimmed.to_string())
}
fn user_openhuman_dir(default_openhuman_dir: &Path, user_id: &str) -> Result<PathBuf, String> {
let id = validate_user_id_for_path(user_id)?;
Ok(default_openhuman_dir.join("users").join(&id))
}
fn cache_dir_for_user(default_openhuman_dir: &Path, user_id: &str) -> Result<PathBuf, String> {
Ok(user_openhuman_dir(default_openhuman_dir, user_id)?.join("cef"))
}
/// `remove_dir_all` is only safe for CEF profile dirs we queued ourselves (under
/// `.../users/<id>/cef`). Rejects absolute paths outside that tree, corrupted
/// TOML, or anything that `canonicalize` would not place under
/// `default_openhuman_dir/users/…/cef`.
fn is_trusted_queued_purge_path(default_openhuman_dir: &Path, target: &Path) -> bool {
if !target.is_absolute() {
log::warn!(
"[cef-profile] refusing purge: path is not absolute (possible cwd-relative TOML injection) path={}",
target.display()
);
return false;
}
let Ok(data_root) = std::fs::canonicalize(default_openhuman_dir) else {
log::warn!(
"[cef-profile] refusing purge: could not canonicalize data root path={} (cannot validate purge target) target={}",
default_openhuman_dir.display(),
target.display()
);
return false;
};
let users_dir = data_root.join("users");
let Ok(users_canon) = std::fs::canonicalize(&users_dir) else {
log::warn!(
"[cef-profile] refusing purge: could not canonicalize `users` dir under {} (target={})",
data_root.display(),
target.display()
);
return false;
};
let Ok(canon) = std::fs::canonicalize(target) else {
log::warn!(
"[cef-profile] refusing purge: could not canonicalize target (symlink/permission?) path={}",
target.display()
);
return false;
};
if !canon.starts_with(&users_canon) {
log::warn!(
"[cef-profile] refusing purge: canonical path is not under users tree (possible malicious queue entry) data_root={} target_canon={}",
data_root.display(),
canon.display()
);
return false;
}
if canon.file_name() != Some(OsStr::new("cef")) {
log::warn!(
"[cef-profile] refusing purge: expected a .../users/<id>/cef directory, got file_name={:?} path={}",
canon.file_name(),
canon.display()
);
return false;
}
true
}
/// Marker file lives in the **parent** of the OpenHuman data root so a full
/// `remove_dir_all(default_openhuman_dir)` (e.g. from core `reset_local_data`) does
/// not delete the pending-purge list before it is processed.
fn pending_purge_marker_path(default_openhuman_dir: &Path) -> Result<PathBuf, String> {
let parent = default_openhuman_dir.parent().ok_or_else(|| {
"default OpenHuman data dir has no parent; cannot place CEF purge marker outside the data tree"
.to_string()
})?;
Ok(parent.join(PENDING_PURGE_STATE_FILE))
}
pub fn configured_cache_path_from_env() -> Option<PathBuf> {
std::env::var(CEF_CACHE_PATH_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn load_pending_purge_state(default_openhuman_dir: &Path) -> Result<PendingCefPurgeState, String> {
let path = pending_purge_marker_path(default_openhuman_dir)?;
if path.exists() {
let raw = std::fs::read_to_string(&path).map_err(|error| {
format!("read pending CEF purge marker {}: {error}", path.display())
})?;
return toml::from_str(&raw).map_err(|error| {
format!("parse pending CEF purge marker {}: {error}", path.display())
});
}
// One-time read from the legacy in-tree file (older app versions).
let legacy = default_openhuman_dir.join(LEGACY_PENDING_PURGE_IN_TREE);
if !legacy.exists() {
return Ok(PendingCefPurgeState::default());
}
let raw = std::fs::read_to_string(&legacy).map_err(|error| {
format!(
"read legacy pending CEF purge marker {}: {error}",
legacy.display()
)
})?;
let state: PendingCefPurgeState = toml::from_str(&raw).map_err(|error| {
format!(
"parse legacy pending CEF purge marker {}: {error}",
legacy.display()
)
})?;
match save_pending_purge_state(default_openhuman_dir, &state) {
Ok(()) => {
let _ = std::fs::remove_file(&legacy);
log::info!(
"[cef-profile] migrated pending CEF purge list from {} to {}",
legacy.display(),
path.display()
);
}
Err(err) => log::warn!(
"[cef-profile] could not write migrated pending CEF purge marker to {}: {err}",
path.display()
),
}
Ok(state)
}
fn save_pending_purge_state(
default_openhuman_dir: &Path,
state: &PendingCefPurgeState,
) -> Result<(), String> {
std::fs::create_dir_all(default_openhuman_dir).map_err(|error| {
format!(
"create OpenHuman root dir {}: {error}",
default_openhuman_dir.display()
)
})?;
let path = pending_purge_marker_path(default_openhuman_dir)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
format!(
"create parent of pending CEF purge marker {}: {error}",
path.display()
)
})?;
}
let raw = toml::to_string_pretty(state)
.map_err(|error| format!("serialize pending CEF purge marker: {error}"))?;
std::fs::write(&path, raw)
.map_err(|error| format!("write pending CEF purge marker {}: {error}", path.display()))
}
pub fn queue_profile_purge_for_user(user_id: Option<&str>) -> Result<PathBuf, String> {
let default_openhuman_dir = default_root_openhuman_dir()?;
let user_id = user_id
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(PRE_LOGIN_USER_ID);
let purge_path = cache_dir_for_user(&default_openhuman_dir, user_id)?;
let mut state = load_pending_purge_state(&default_openhuman_dir)?;
let mut unique = BTreeSet::new();
for path in state.paths {
unique.insert(path);
}
unique.insert(purge_path.display().to_string());
state = PendingCefPurgeState {
paths: unique.into_iter().collect(),
};
save_pending_purge_state(&default_openhuman_dir, &state)?;
log::info!(
"[cef-profile] queued purge for user={} path={}",
user_id,
purge_path.display()
);
Ok(purge_path)
}
pub fn prepare_process_cache_path() -> Result<PathBuf, String> {
let default_openhuman_dir = default_root_openhuman_dir()?;
drain_pending_purges(&default_openhuman_dir)?;
let user_id_raw = read_active_user_id(&default_openhuman_dir)
.unwrap_or_else(|| PRE_LOGIN_USER_ID.to_string());
let user_id = match validate_user_id_for_path(&user_id_raw) {
Ok(id) => id,
Err(why) => {
log::warn!(
"[cef-profile] invalid user_id in active user state: {why}; using {}",
PRE_LOGIN_USER_ID
);
PRE_LOGIN_USER_ID.to_string()
}
};
let cache_dir = cache_dir_for_user(&default_openhuman_dir, &user_id)?;
std::fs::create_dir_all(&cache_dir)
.map_err(|error| format!("create CEF cache dir {}: {error}", cache_dir.display()))?;
std::env::set_var(CEF_CACHE_PATH_ENV, &cache_dir);
log::info!(
"[cef-profile] configured CEF cache user={} path={}",
user_id,
cache_dir.display()
);
Ok(cache_dir)
}
fn drain_pending_purges(default_openhuman_dir: &Path) -> Result<(), String> {
let marker_path = pending_purge_marker_path(default_openhuman_dir)?;
let mut state = load_pending_purge_state(default_openhuman_dir)?;
if state.paths.is_empty() {
if marker_path.exists() {
let _ = std::fs::remove_file(&marker_path);
}
return Ok(());
}
let mut remaining: Vec<String> = Vec::new();
for raw_path in &state.paths {
let target = PathBuf::from(raw_path);
if !target.exists() {
log::debug!(
"[cef-profile] pending purge target already absent path={}",
target.display()
);
continue;
}
if !is_trusted_queued_purge_path(default_openhuman_dir, &target) {
log::warn!(
"[cef-profile] skipping unsafe purge and retaining queue entry (will not delete) path={} raw_toml={}",
target.display(),
raw_path
);
remaining.push(raw_path.clone());
continue;
}
match std::fs::remove_dir_all(&target) {
Ok(()) => {
log::info!(
"[cef-profile] purged queued CEF cache path={}",
target.display()
);
}
Err(error) => {
log::warn!(
"[cef-profile] failed to purge queued CEF cache path={} error={}",
target.display(),
error
);
remaining.push(raw_path.clone());
}
}
}
if !remaining.is_empty() {
state.paths = remaining;
save_pending_purge_state(default_openhuman_dir, &state)?;
log::warn!(
"[cef-profile] not removing pending CEF purge marker: {} path(s) still fail purge (will retry) marker={}",
state.paths.len(),
marker_path.display()
);
return Ok(());
}
if marker_path.exists() {
std::fs::remove_file(&marker_path).map_err(|error| {
format!(
"remove pending CEF purge marker {}: {error}",
marker_path.display()
)
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_active_user_id_ignores_empty_values() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(ACTIVE_USER_STATE_FILE), "user_id = \" \"").unwrap();
assert_eq!(read_active_user_id(tmp.path()), None);
}
#[test]
fn cache_dir_for_user_nests_under_users_tree() {
let root = PathBuf::from("/tmp/openhuman");
assert_eq!(
cache_dir_for_user(&root, "u-123").unwrap(),
PathBuf::from("/tmp/openhuman/users/u-123/cef")
);
}
#[test]
fn validate_user_id_rejects_path_traversal() {
assert!(validate_user_id_for_path("..").is_err());
assert!(validate_user_id_for_path("a/../b").is_err());
assert!(validate_user_id_for_path("x/y").is_err());
}
#[test]
fn validate_user_id_accepts_typical_ids() {
assert_eq!(validate_user_id_for_path("u-123").unwrap(), "u-123");
assert_eq!(
validate_user_id_for_path("user@ex.com").unwrap(),
"user@ex.com"
);
}
/// `default_openhuman_dir` must have a parent (sibling marker uses `parent()`).
fn test_data_hierarchy() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let data_root = tmp.path().join("oh_data");
std::fs::create_dir_all(&data_root).unwrap();
(tmp, data_root)
}
#[test]
fn legacy_purge_marker_migrates_to_sibling_file() {
let (_tmp, data_root) = test_data_hierarchy();
let legacy = data_root.join(LEGACY_PENDING_PURGE_IN_TREE);
let sibling = data_root.parent().unwrap().join(PENDING_PURGE_STATE_FILE);
let body = r#"paths = []"#;
std::fs::write(&legacy, body).unwrap();
assert!(!sibling.exists());
let _ = load_pending_purge_state(&data_root).unwrap();
assert!(!legacy.exists());
assert!(sibling.exists());
}
#[test]
fn drain_removes_only_trusted_paths_and_clears_marker() {
let (_tmp, data_root) = test_data_hierarchy();
let cef = data_root.join("users").join("u1").join("cef");
std::fs::create_dir_all(&cef).unwrap();
std::fs::write(cef.join("x.txt"), b"x").unwrap();
let cef_s = cef.to_string_lossy().to_string();
let state = PendingCefPurgeState { paths: vec![cef_s] };
save_pending_purge_state(&data_root, &state).unwrap();
drain_pending_purges(&data_root).unwrap();
assert!(!cef.exists());
let marker = pending_purge_marker_path(&data_root).unwrap();
assert!(!marker.exists());
}
#[test]
fn drain_retains_malicious_queue_path_without_deleting() {
let (tmp, data_root) = test_data_hierarchy();
let outside = tmp.path().join("outside_sandbox");
std::fs::create_dir_all(&outside).unwrap();
let outside_s = outside.to_string_lossy().to_string();
let state = PendingCefPurgeState {
paths: vec![outside_s.clone()],
};
save_pending_purge_state(&data_root, &state).unwrap();
drain_pending_purges(&data_root).unwrap();
assert!(outside.exists());
let rest = load_pending_purge_state(&data_root).unwrap();
assert_eq!(rest.paths, vec![outside_s]);
let marker = pending_purge_marker_path(&data_root).unwrap();
assert!(marker.exists());
}
/// Path is under `users/…` but last component is not `cef` (reject, retain in queue).
#[test]
fn drain_does_not_remove_path_without_cef_final_segment() {
let (_tmp, data_root) = test_data_hierarchy();
let d = data_root.join("users").join("u1").join("data");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("f"), b"1").unwrap();
save_pending_purge_state(
&data_root,
&PendingCefPurgeState {
paths: vec![d.to_string_lossy().to_string()],
},
)
.unwrap();
drain_pending_purges(&data_root).unwrap();
assert!(d.exists());
let after = load_pending_purge_state(&data_root).unwrap();
assert_eq!(after.paths.len(), 1);
}
}
+41 -3
View File
@@ -5,6 +5,8 @@ compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supp
mod cdp;
#[cfg(all(feature = "cef", target_os = "macos"))]
mod cef_preflight;
#[cfg(feature = "cef")]
mod cef_profile;
mod core_process;
mod core_update;
#[cfg(feature = "cef")]
@@ -330,6 +332,19 @@ async fn restart_core_process(
state.inner().restart().await
}
#[tauri::command]
async fn restart_app(app: tauri::AppHandle<AppRuntime>) -> Result<(), String> {
log::info!("[app] restart_app invoked from frontend");
app.restart();
}
#[cfg(feature = "cef")]
#[tauri::command]
async fn schedule_cef_profile_purge(user_id: Option<String>) -> Result<String, String> {
let queued = cef_profile::queue_profile_purge_for_user(user_id.as_deref())?;
Ok(queued.display().to_string())
}
/// Fire the proactive welcome agent on a detached core-side task.
///
/// Returns immediately; the agent runs in the background and publishes
@@ -762,6 +777,17 @@ pub fn run() {
// here and exit cleanly with a message that names the lock-holder PID
// and the workaround. Stale locks (PID dead) are removed and we
// continue, matching Chromium's own startup recovery.
#[cfg(feature = "cef")]
match cef_profile::prepare_process_cache_path() {
Ok(path) => log::debug!("[cef-profile] startup cache path={}", path.display()),
Err(error) => {
log::error!(
"[cef-profile] failed to configure per-user CEF cache; refusing to start with shared/global cache: {error}"
);
std::process::exit(1);
}
}
#[cfg(all(feature = "cef", target_os = "macos"))]
if let Err(e) = cef_preflight::check_default_cache() {
eprintln!("\n[openhuman] {e}\n");
@@ -920,8 +946,17 @@ pub fn run() {
// providers (gmail, whatsapp, slack, …) already have a live
// session cookie. Best-effort — if we can't resolve the path
// the core treats every provider as logged_out.
if let Ok(cache_dir) = app.path().app_cache_dir() {
let cookies_db = cache_dir.join("cef").join("Default").join("Cookies");
if let Some(cache_dir) = {
#[cfg(feature = "cef")]
{
cef_profile::configured_cache_path_from_env()
}
#[cfg(not(feature = "cef"))]
{
None
}
} {
let cookies_db = cache_dir.join("Default").join("Cookies");
log::debug!("[webview_accounts] exposing cookies DB path to core");
std::env::set_var("OPENHUMAN_CEF_COOKIES_DB", &cookies_db);
} else {
@@ -929,7 +964,7 @@ pub fn run() {
// stale path from a previous run or the parent shell.
std::env::remove_var("OPENHUMAN_CEF_COOKIES_DB");
log::warn!(
"[webview_accounts] could not resolve app_cache_dir — core \
"[webview_accounts] could not resolve configured CEF cache dir — core \
will report all webview providers as logged_out"
);
}
@@ -1266,6 +1301,9 @@ pub fn run() {
check_app_update,
apply_app_update,
restart_core_process,
restart_app,
#[cfg(feature = "cef")]
schedule_cef_profile_purge,
spawn_welcome_agent,
service_install_direct,
service_start_direct,
+21 -7
View File
@@ -2,14 +2,18 @@ import { useState } from 'react';
import { useCoreState } from '../../providers/CoreStateProvider';
import { persistor } from '../../store';
import { resetOpenHumanDataAndRestartCore } from '../../utils/tauriCommands';
import {
resetOpenHumanDataAndRestartCore,
restartApp,
scheduleCefProfilePurge,
} from '../../utils/tauriCommands';
import SettingsHeader from './components/SettingsHeader';
import SettingsMenuItem from './components/SettingsMenuItem';
import { useSettingsNavigation } from './hooks/useSettingsNavigation';
const SettingsHome = () => {
const { navigateToSettings } = useSettingsNavigation();
const { clearSession } = useCoreState();
const { clearSession, snapshot } = useCoreState();
const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -24,6 +28,18 @@ const SettingsHome = () => {
};
const clearAllAppData = async () => {
const currentUserId = snapshot.auth.userId ?? snapshot.currentUser?._id ?? null;
// Queue the current user-scoped CEF profile for deletion on next launch.
// The active CEF browser process may still hold SQLite/cache file handles,
// so we delete after the shell restarts rather than relying on in-process
// removal to succeed everywhere.
try {
await scheduleCefProfilePurge(currentUserId);
} catch (err) {
console.warn('[Settings] Failed to queue CEF profile purge:', err);
}
// 1. Logout — clear session in core (auth_clear_session). Best-effort:
// if the core process is wedged we still want to wipe local data.
try {
@@ -55,11 +71,9 @@ const SettingsHome = () => {
window.localStorage.clear();
window.sessionStorage.clear();
// 4. Hard-reload to reset in-memory Redux + React state. Without this
// the live store still holds user/socket/account data even though
// the persisted copy is gone — the UI would look logged-in until
// the user manually refreshes.
window.location.reload();
// 4. Full app restart so the CEF runtime reboots into the fresh
// pre-login profile instead of keeping the old browser process alive.
await restartApp();
};
const handleLogoutAndClearData = async () => {
+13
View File
@@ -27,6 +27,7 @@ import {
} from '../services/coreStateApi';
import {
openhumanUpdateAnalyticsSettings,
restartApp,
setOnboardingCompleted,
storeSession,
syncMemoryClientToken,
@@ -374,6 +375,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
const storeSessionToken = useCallback(
async (token: string, user?: object) => {
const previousIdentity = snapshotIdentity(getCoreStateSnapshot().snapshot);
logoutGuardUntilRef.current = 0;
await storeSession(token, user ?? {});
try {
@@ -386,6 +388,17 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
await refreshTeams().catch(err => {
log('refreshTeams failed after session store: %O', sanitizeError(err));
});
const nextIdentity = snapshotIdentity(getCoreStateSnapshot().snapshot);
if (nextIdentity && previousIdentity !== nextIdentity) {
const mask = (s: string | null) =>
s == null ? 'none' : s.length > 4 ? `****${s.slice(-4)}` : '****';
console.debug('[core-state] user changed; restarting app to switch CEF profile', {
previousIdentity: mask(previousIdentity),
nextIdentity: mask(nextIdentity),
});
await restartApp();
}
},
[refresh, refreshTeams]
);
+83
View File
@@ -0,0 +1,83 @@
import { invoke, isTauri } from '@tauri-apps/api/core';
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() }));
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
describe('tauriCommands/core', () => {
const mockIsTauri = isTauri as Mock;
const mockInvoke = invoke as Mock;
let restartApp: typeof import('./core').restartApp;
let scheduleCefProfilePurge: typeof import('./core').scheduleCefProfilePurge;
beforeEach(async () => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
const actual = await vi.importActual<typeof import('./core')>('./core');
restartApp = actual.restartApp;
scheduleCefProfilePurge = actual.scheduleCefProfilePurge;
});
describe('restartApp', () => {
test('no-ops when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
await restartApp();
expect(mockInvoke).not.toHaveBeenCalled();
expect(debug).toHaveBeenCalledWith(
expect.stringContaining('restartApp: skipped — not running in Tauri')
);
debug.mockRestore();
});
test('invokes restart_app when in Tauri', async () => {
await restartApp();
expect(mockInvoke).toHaveBeenCalledWith('restart_app');
});
});
describe('scheduleCefProfilePurge', () => {
test('returns null and does not invoke when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
const out = await scheduleCefProfilePurge('x');
expect(out).toBeNull();
expect(mockInvoke).not.toHaveBeenCalled();
expect(debug).toHaveBeenCalledWith(
expect.stringContaining('scheduleCefProfilePurge: skipped — not running in Tauri')
);
debug.mockRestore();
});
test('invoke with userId null when argument is undefined', async () => {
mockInvoke.mockResolvedValueOnce('/path');
const out = await scheduleCefProfilePurge();
expect(mockInvoke).toHaveBeenCalledWith('schedule_cef_profile_purge', { userId: null });
expect(out).toBe('/path');
});
test('invoke with userId null when argument is null', async () => {
mockInvoke.mockResolvedValueOnce('/path');
const out = await scheduleCefProfilePurge(null);
expect(mockInvoke).toHaveBeenCalledWith('schedule_cef_profile_purge', { userId: null });
expect(out).toBe('/path');
});
test('invoke with userId string when provided', async () => {
mockInvoke.mockResolvedValueOnce('/other');
const out = await scheduleCefProfilePurge('user-9');
expect(mockInvoke).toHaveBeenCalledWith('schedule_cef_profile_purge', { userId: 'user-9' });
expect(out).toBe('/other');
});
});
});
+26
View File
@@ -59,6 +59,32 @@ export async function restartCoreProcess(): Promise<void> {
console.debug('[core] restartCoreProcess: done');
}
/**
* Restart the desktop shell so CEF relaunches with updated profile paths.
*/
export async function restartApp(): Promise<void> {
if (!isTauri()) {
console.debug('[app] restartApp: skipped — not running in Tauri');
return;
}
console.debug('[app] restartApp: invoking restart_app');
await invoke<void>('restart_app');
}
/**
* Queue deletion of a user-scoped CEF profile on the next app launch.
*/
export async function scheduleCefProfilePurge(userId?: string | null): Promise<string | null> {
if (!isTauri()) {
console.debug('[cef-profile] scheduleCefProfilePurge: skipped — not running in Tauri');
return null;
}
console.debug('[cef-profile] scheduleCefProfilePurge: invoking schedule_cef_profile_purge', {
hasUserId: userId != null,
});
return invoke<string>('schedule_cef_profile_purge', { userId: userId ?? null });
}
/**
* Check if the running core sidecar is outdated compared to what the app expects.
*/