feat: scope user data to per-user directories (#370)

* feat(config): add user ID retrieval and workspace scoping for authenticated users

- Implemented `read_authenticated_user_id` to extract the user's ID from `auth-profiles.json`, avoiding a dependency cycle with the credentials module.
- Introduced `maybe_scope_workspace_to_user` to create user-specific workspace directories based on the authenticated user ID, ensuring isolated workspace data.
- Updated the configuration loading process to call `maybe_scope_workspace_to_user`, enhancing user data management.
- Added unit tests for the new functionality, ensuring correct behavior in various scenarios.

This change improves user experience by providing personalized workspace management based on authentication status.

* feat(config): enhance user management with active user state handling

- Added functions to manage the active user state, including `read_active_user_id`, `write_active_user_id`, and `clear_active_user`, allowing for user-specific configuration and workspace isolation.
- Introduced `default_root_openhuman_dir` to standardize the retrieval of the root directory for user data.
- Updated configuration loading to support user-scoped directories, improving the overall user experience by ensuring personalized settings and workspace management.

This change enhances the OpenHuman platform by enabling better user data management and isolation.

* feat(credentials): enhance user directory management during session storage

- Added logic to create and activate user-scoped directories based on the resolved user ID when storing session data, ensuring credentials are saved in the correct location.
- Implemented error handling for directory creation and active user ID writing, with appropriate logging for failures.
- Updated the configuration loading process to reflect the newly activated user directory, improving user-specific settings management.
- Enhanced the `get_data_dir` function to return user-scoped directories if an active user is set, streamlining data access.

This change improves user experience by ensuring that session data is correctly organized and accessible based on user context.

* refactor(tests): update user ID handling and improve test coverage

- Renamed and refactored tests to better reflect functionality, focusing on active user ID management.
- Removed the `write_auth_profiles` helper function and replaced it with direct calls to `write_active_user_id` for clarity.
- Enhanced tests to cover scenarios for reading and clearing active user IDs, ensuring accurate behavior in user-specific configurations.
- Added a new test for building user directory paths, improving overall test coverage for user management features.

This change streamlines the testing process and enhances the clarity of user ID handling in the configuration schema.

* refactor(paths): streamline model and binary path resolution

- Introduced a new `shared_root_dir` function to centralize the logic for determining the shared root openhuman directory, improving code clarity and reducing duplication.
- Updated `workspace_ollama_dir` and `workspace_local_models_dir` functions to utilize the new shared root directory, ensuring consistent path resolution for user-specific and shared resources.
- Enhanced the `model_artifact_path` function to leverage the new directory structure, improving the organization of model artifacts.

This refactor enhances maintainability and clarity in the path management for local AI resources.

* style: apply cargo fmt formatting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(paths): streamline directory management for model artifacts

- Updated the `model_artifact_path` function to utilize a new `shared_root_dir` function, which centralizes the logic for determining the root openhuman directory.
- Enhanced the `config_root_dir` function to improve clarity and maintainability.
- Adjusted the `workspace_ollama_dir` and `workspace_local_models_dir` functions to leverage the new shared directory logic, ensuring consistent path resolution across the application.

These changes improve the organization of directory management and enhance the overall clarity of the codebase.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-04-06 13:51:17 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent df503a23fd
commit 200db04fc7
10 changed files with 285 additions and 29 deletions
+9 -1
View File
@@ -330,12 +330,20 @@ impl Agent {
.unwrap_or(crate::openhuman::config::DEFAULT_MODEL)
.to_string();
let provider: Box<dyn Provider> = providers::create_routed_provider(
let provider_runtime_options = providers::ProviderRuntimeOptions {
auth_profile_override: None,
openhuman_dir: config.config_path.parent().map(std::path::PathBuf::from),
secrets_encrypt: config.secrets.encrypt,
reasoning_enabled: config.runtime.reasoning_enabled,
};
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
config.api_key.as_deref(),
config.api_url.as_deref(),
&config.reliability,
&config.model_routes,
&model_name,
&provider_runtime_options,
)?;
let dispatcher_choice = config.agent.tool_dispatcher.as_str();
+4
View File
@@ -37,6 +37,10 @@ pub use schema::{
TelegramConfig, WebSearchConfig, WebhookConfig, DEFAULT_MODEL, MODEL_AGENTIC_V1,
MODEL_CODING_V1, MODEL_REASONING_V1,
};
pub use schema::{
clear_active_user, default_root_openhuman_dir, read_active_user_id, user_openhuman_dir,
write_active_user_id,
};
pub use schemas::{
all_controller_schemas as all_config_controller_schemas,
all_registered_controllers as all_config_registered_controllers,
+150
View File
@@ -27,6 +27,13 @@ struct ActiveWorkspaceState {
}
fn default_config_dir() -> Result<PathBuf> {
Ok(default_root_openhuman_dir()?)
}
/// Returns the root openhuman directory (`~/.openhuman`), independent of any
/// per-user scoping. Used to locate `active_user.toml` and the shared
/// `users/` tree.
pub fn default_root_openhuman_dir() -> Result<PathBuf> {
let home = UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.context("Could not find home directory")?;
@@ -174,6 +181,7 @@ fn resolve_config_dir_for_workspace(workspace_dir: &Path) -> (PathBuf, PathBuf)
enum ConfigResolutionSource {
EnvWorkspace,
ActiveWorkspaceMarker,
ActiveUser,
DefaultConfigDir,
}
@@ -182,6 +190,7 @@ impl ConfigResolutionSource {
match self {
Self::EnvWorkspace => "OPENHUMAN_WORKSPACE",
Self::ActiveWorkspaceMarker => "active_workspace.toml",
Self::ActiveUser => "active_user.toml",
Self::DefaultConfigDir => "default",
}
}
@@ -191,6 +200,7 @@ async fn resolve_runtime_config_dirs(
default_openhuman_dir: &Path,
default_workspace_dir: &Path,
) -> Result<(PathBuf, PathBuf, ConfigResolutionSource)> {
// 1. Explicit env override always wins.
if let Ok(custom_workspace) = std::env::var("OPENHUMAN_WORKSPACE") {
if !custom_workspace.is_empty() {
let (openhuman_dir, workspace_dir) =
@@ -203,6 +213,20 @@ async fn resolve_runtime_config_dirs(
}
}
// 2. Active user — scopes the entire openhuman dir to a per-user directory
// so that config, auth, encryption, and workspace are all user-isolated.
if let Some(user_id) = read_active_user_id(default_openhuman_dir) {
let user_dir = user_openhuman_dir(default_openhuman_dir, &user_id);
let user_workspace = user_dir.join("workspace");
tracing::debug!(
user_id = %user_id,
user_dir = %user_dir.display(),
"Config dirs resolved via active_user.toml"
);
return Ok((user_dir, user_workspace, ConfigResolutionSource::ActiveUser));
}
// 3. Active workspace marker (legacy / multi-workspace).
if let Some((openhuman_dir, workspace_dir)) =
load_persisted_workspace_dirs(default_openhuman_dir).await?
{
@@ -213,6 +237,7 @@ async fn resolve_runtime_config_dirs(
));
}
// 4. Default.
Ok((
default_openhuman_dir.to_path_buf(),
default_workspace_dir.to_path_buf(),
@@ -254,6 +279,58 @@ fn encrypt_optional_secret(
Ok(())
}
const ACTIVE_USER_STATE_FILE: &str = "active_user.toml";
#[derive(Debug, Serialize, Deserialize)]
struct ActiveUserState {
user_id: String,
}
/// Reads the active user id from `{default_openhuman_dir}/active_user.toml`.
/// Returns `None` when the file does not exist, is empty, or cannot be parsed.
pub 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 id = state.user_id.trim().to_string();
if id.is_empty() {
None
} else {
Some(id)
}
}
/// Writes the active user id to `{default_openhuman_dir}/active_user.toml`.
pub fn write_active_user_id(default_openhuman_dir: &Path, user_id: &str) -> Result<()> {
let path = default_openhuman_dir.join(ACTIVE_USER_STATE_FILE);
let state = ActiveUserState {
user_id: user_id.to_string(),
};
let toml_str = toml::to_string_pretty(&state).context("serialize active_user.toml")?;
std::fs::write(&path, toml_str)
.with_context(|| format!("Failed to write active user state: {}", path.display()))?;
tracing::debug!(user_id = %user_id, path = %path.display(), "active user written");
Ok(())
}
/// Removes the active user marker. After this, the next config load will
/// use the default (unauthenticated) openhuman directory.
pub fn clear_active_user(default_openhuman_dir: &Path) -> Result<()> {
let path = default_openhuman_dir.join(ACTIVE_USER_STATE_FILE);
if path.exists() {
std::fs::remove_file(&path)
.with_context(|| format!("Failed to remove active user state: {}", path.display()))?;
tracing::debug!(path = %path.display(), "active user cleared");
}
Ok(())
}
/// Returns the user-scoped openhuman directory for the given user id:
/// `{default_openhuman_dir}/users/{user_id}`.
pub fn user_openhuman_dir(default_openhuman_dir: &Path, user_id: &str) -> PathBuf {
default_openhuman_dir.join("users").join(user_id)
}
fn migrate_legacy_autocomplete_disabled_apps(config: &mut Config) {
// Legacy defaults blocked both terminal and code, which prevented Codex/CLI usage.
// Migrate only the exact legacy default so custom user preferences remain untouched.
@@ -364,6 +441,7 @@ impl Config {
}
migrate_legacy_autocomplete_disabled_apps(&mut config);
config.apply_env_overrides();
tracing::info!(
path = %config.config_path.display(),
workspace = %config.workspace_dir.display(),
@@ -385,6 +463,7 @@ impl Config {
}
config.apply_env_overrides();
tracing::info!(
path = %config.config_path.display(),
workspace = %config.workspace_dir.display(),
@@ -812,3 +891,74 @@ impl Config {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_active_user_returns_none_when_no_file() {
let tmp = tempfile::tempdir().unwrap();
assert!(read_active_user_id(tmp.path()).is_none());
}
#[test]
fn read_active_user_returns_none_when_empty() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(ACTIVE_USER_STATE_FILE), "").unwrap();
assert!(read_active_user_id(tmp.path()).is_none());
}
#[test]
fn read_active_user_returns_id_when_present() {
let tmp = tempfile::tempdir().unwrap();
write_active_user_id(tmp.path(), "user-789").unwrap();
assert_eq!(
read_active_user_id(tmp.path()),
Some("user-789".to_string())
);
}
#[test]
fn write_and_clear_active_user_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
write_active_user_id(tmp.path(), "u-abc").unwrap();
assert_eq!(read_active_user_id(tmp.path()), Some("u-abc".to_string()));
clear_active_user(tmp.path()).unwrap();
assert!(read_active_user_id(tmp.path()).is_none());
}
#[test]
fn user_openhuman_dir_builds_correct_path() {
let root = PathBuf::from("/home/test/.openhuman");
let dir = user_openhuman_dir(&root, "user-123");
assert_eq!(dir, PathBuf::from("/home/test/.openhuman/users/user-123"));
}
#[tokio::test]
async fn resolve_dirs_uses_active_user_when_present() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let default_workspace = root.join("workspace");
// No active user → uses default.
let (oh_dir, ws_dir, source) = resolve_runtime_config_dirs(root, &default_workspace)
.await
.unwrap();
assert_eq!(oh_dir, root);
assert_eq!(ws_dir, default_workspace);
assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
// With active user → scopes to user dir.
write_active_user_id(root, "u-test").unwrap();
let (oh_dir, ws_dir, source) = resolve_runtime_config_dirs(root, &default_workspace)
.await
.unwrap();
let expected_user_dir = root.join("users").join("u-test");
assert_eq!(oh_dir, expected_user_dir);
assert_eq!(ws_dir, expected_user_dir.join("workspace"));
assert_eq!(source, ConfigResolutionSource::ActiveUser);
}
}
+4
View File
@@ -14,6 +14,10 @@ mod heartbeat_cron;
mod identity_cost;
mod learning;
mod load;
pub use load::{
clear_active_user, default_root_openhuman_dir, read_active_user_id, user_openhuman_dir,
write_active_user_id,
};
mod local_ai;
mod observability;
mod orchestrator;
+63 -11
View File
@@ -13,6 +13,9 @@ use crate::openhuman::security::SecretStore;
use crate::rpc::RpcOutcome;
use super::{AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME};
use crate::openhuman::config::{
default_root_openhuman_dir, user_openhuman_dir, write_active_user_id,
};
fn secret_store_for_config(config: &Config) -> SecretStore {
let data_dir = config
@@ -72,7 +75,55 @@ pub async fn store_session(
let user_for_store = user.unwrap_or(settings);
metadata.insert("user_json".to_string(), user_for_store.to_string());
let auth = AuthService::from_config(config);
// Determine user_id so we can scope the openhuman directory to this user.
let resolved_user_id = metadata.get("user_id").cloned();
// If we know the user_id, activate the user-scoped directory BEFORE storing
// the auth profile so that credentials land in the correct place.
let mut logs = vec![format!(
"session JWT verified via GET /auth/me on {}",
api_url.trim_end_matches('/')
)];
if let Some(ref uid) = resolved_user_id {
if let Ok(root_dir) = default_root_openhuman_dir() {
let user_dir = user_openhuman_dir(&root_dir, uid);
if let Err(e) = std::fs::create_dir_all(&user_dir) {
tracing::warn!(
user_id = %uid,
error = %e,
"failed to create user directory"
);
} else if let Err(e) = write_active_user_id(&root_dir, uid) {
tracing::warn!(
user_id = %uid,
error = %e,
"failed to write active_user.toml"
);
} else {
logs.push(format!("user directory activated for {uid}"));
tracing::info!(
user_id = %uid,
user_dir = %user_dir.display(),
"User-scoped directory activated"
);
}
}
}
// Reload config so it picks up the newly activated user directory.
// This ensures auth-profiles.json, encryption key, etc. are written
// to the user-scoped location.
let effective_config = if resolved_user_id.is_some() {
match crate::openhuman::config::load_config_with_timeout().await {
Ok(c) => c,
Err(_) => config.clone(),
}
} else {
config.clone()
};
let auth = AuthService::from_config(&effective_config);
let profile = auth
.store_provider_token(
APP_SESSION_PROVIDER,
@@ -83,16 +134,8 @@ pub async fn store_session(
)
.map_err(|e| e.to_string())?;
Ok(RpcOutcome::new(
summarize_auth_profile(&profile),
vec![
format!(
"session JWT verified via GET /auth/me on {}",
api_url.trim_end_matches('/')
),
"session stored".to_string(),
],
))
logs.push("session stored".to_string());
Ok(RpcOutcome::new(summarize_auth_profile(&profile), logs))
}
pub async fn clear_session(config: &Config) -> Result<RpcOutcome<serde_json::Value>, String> {
@@ -100,6 +143,15 @@ pub async fn clear_session(config: &Config) -> Result<RpcOutcome<serde_json::Val
let removed = auth
.remove_profile(APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME)
.map_err(|e| e.to_string())?;
// Clear the active user marker so subsequent config loads fall back to the
// default (unauthenticated) openhuman directory.
if let Ok(root_dir) = default_root_openhuman_dir() {
if let Err(e) = crate::openhuman::config::clear_active_user(&root_dir) {
tracing::warn!(error = %e, "failed to clear active_user.toml on logout");
}
}
Ok(RpcOutcome::single_log(
json!({ "removed": removed }),
"session cleared",
+1 -2
View File
@@ -8,10 +8,9 @@ use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthStoreSessionParams {
token: String,
#[serde(default)]
#[serde(default, alias = "userId")]
user_id: Option<String>,
#[serde(default)]
user: Option<serde_json::Value>,
+15 -3
View File
@@ -100,12 +100,24 @@ impl EncryptionKey {
}
}
/// Get the path to the OpenHuman data directory (~/.openhuman/).
/// Get the path to the OpenHuman data directory.
/// If an active user is set, returns the user-scoped directory
/// (`~/.openhuman/users/{user_id}`); otherwise falls back to `~/.openhuman/`.
pub fn get_data_dir() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
let data_dir = home.join(".openhuman");
std::fs::create_dir_all(&data_dir)
let root_dir = home.join(".openhuman");
std::fs::create_dir_all(&root_dir)
.map_err(|e| format!("Failed to create data directory: {e}"))?;
let data_dir = if let Some(user_id) = crate::openhuman::config::read_active_user_id(&root_dir) {
let user_dir = crate::openhuman::config::user_openhuman_dir(&root_dir, &user_id);
std::fs::create_dir_all(&user_dir)
.map_err(|e| format!("Failed to create user data directory: {e}"))?;
user_dir
} else {
root_dir
};
Ok(data_dir)
}
+7 -5
View File
@@ -15,11 +15,13 @@ pub fn global(config: &Config) -> Arc<LocalAiService> {
}
pub fn model_artifact_path(config: &Config) -> PathBuf {
let root = config
.config_path
.parent()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| config.workspace_dir.clone());
let root = crate::openhuman::config::default_root_openhuman_dir().unwrap_or_else(|_| {
config
.config_path
.parent()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| config.workspace_dir.clone())
});
root.join("models")
.join("local-ai")
.join(effective_chat_model_id(config).replace(':', "-") + ".ollama")
+12 -3
View File
@@ -6,6 +6,7 @@ use crate::openhuman::config::Config;
use super::model_ids;
/// Returns the per-user config directory (parent of config.toml).
pub(crate) fn config_root_dir(config: &Config) -> PathBuf {
config
.config_path
@@ -14,8 +15,16 @@ pub(crate) fn config_root_dir(config: &Config) -> PathBuf {
.unwrap_or_else(|| config.workspace_dir.clone())
}
/// Returns the shared root openhuman directory (`~/.openhuman/`), which is
/// used for resources that should NOT be duplicated per user (model downloads,
/// binaries, etc.).
fn shared_root_dir(config: &Config) -> PathBuf {
crate::openhuman::config::default_root_openhuman_dir()
.unwrap_or_else(|_| config_root_dir(config))
}
pub(crate) fn workspace_ollama_dir(config: &Config) -> PathBuf {
config_root_dir(config).join("bin").join("ollama")
shared_root_dir(config).join("bin").join("ollama")
}
pub(crate) fn workspace_ollama_binary(config: &Config) -> PathBuf {
@@ -60,7 +69,7 @@ pub(crate) fn find_workspace_ollama_binary(config: &Config) -> Option<PathBuf> {
}
pub(crate) fn workspace_local_models_dir(config: &Config) -> PathBuf {
config_root_dir(config).join("models").join("local-ai")
shared_root_dir(config).join("models").join("local-ai")
}
pub(crate) fn resolve_whisper_binary() -> Option<PathBuf> {
@@ -233,7 +242,7 @@ mod tests {
#[test]
fn workspace_ollama_binary_matches_platform_layout() {
let (_tmp, config) = temp_config();
let root = config_root_dir(&config).join("bin").join("ollama");
let root = workspace_ollama_dir(&config);
if cfg!(target_os = "linux") {
assert_eq!(
+20 -4
View File
@@ -54,10 +54,12 @@ impl Drop for EnvVarGuard {
static JSON_RPC_E2E_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn json_rpc_e2e_env_lock() -> std::sync::MutexGuard<'static, ()> {
JSON_RPC_E2E_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("json_rpc_e2e env lock poisoned")
let mutex = JSON_RPC_E2E_ENV_LOCK.get_or_init(|| Mutex::new(()));
// Recover from poison so that a panic in one test does not cascade to all others.
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn mock_upstream_router() -> Router {
@@ -561,6 +563,12 @@ async fn json_rpc_protocol_auth_and_agent_hello() {
write_min_config(&openhuman_home, &mock_origin);
// Pre-create the user-scoped config directory so that when store_session
// activates user "e2e-user" and reloads config, it finds the correct
// api_url and secrets.encrypt=false (rather than defaults).
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_min_config(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
@@ -1853,6 +1861,10 @@ async fn billing_rpc_e2e() {
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
// Pre-create the user-scoped config so store_session finds correct settings.
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_min_config(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
@@ -1999,6 +2011,10 @@ async fn team_rpc_e2e() {
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
// Pre-create the user-scoped config so store_session finds correct settings.
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_min_config(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);