diff --git a/src/openhuman/agent/agent.rs b/src/openhuman/agent/agent.rs index d9d29fa4c..ce28f6aa4 100644 --- a/src/openhuman/agent/agent.rs +++ b/src/openhuman/agent/agent.rs @@ -330,12 +330,20 @@ impl Agent { .unwrap_or(crate::openhuman::config::DEFAULT_MODEL) .to_string(); - let provider: Box = 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 = 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(); diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index 047e4037c..2200fe11d 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -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, diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index 3c2c574f9..847d05c31 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -27,6 +27,13 @@ struct ActiveWorkspaceState { } fn default_config_dir() -> Result { + 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 { 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 { + 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); + } +} diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index aeb06f7c7..c3eb173fb 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -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; diff --git a/src/openhuman/credentials/ops.rs b/src/openhuman/credentials/ops.rs index afce421ca..7fc0cac67 100644 --- a/src/openhuman/credentials/ops.rs +++ b/src/openhuman/credentials/ops.rs @@ -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, String> { @@ -100,6 +143,15 @@ pub async fn clear_session(config: &Config) -> Result, #[serde(default)] user: Option, diff --git a/src/openhuman/encryption/core.rs b/src/openhuman/encryption/core.rs index 462e7b2a9..20dfb02bb 100644 --- a/src/openhuman/encryption/core.rs +++ b/src/openhuman/encryption/core.rs @@ -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 { 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) } diff --git a/src/openhuman/local_ai/core.rs b/src/openhuman/local_ai/core.rs index 9687e1569..cec637583 100644 --- a/src/openhuman/local_ai/core.rs +++ b/src/openhuman/local_ai/core.rs @@ -15,11 +15,13 @@ pub fn global(config: &Config) -> Arc { } 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") diff --git a/src/openhuman/local_ai/paths.rs b/src/openhuman/local_ai/paths.rs index 357a3b0c3..9bed74a40 100644 --- a/src/openhuman/local_ai/paths.rs +++ b/src/openhuman/local_ai/paths.rs @@ -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 { } 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 { @@ -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!( diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 062f52fee..dd488cfac 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -54,10 +54,12 @@ impl Drop for EnvVarGuard { static JSON_RPC_E2E_ENV_LOCK: OnceLock> = 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);